1 require 'integration_helper'
2 require 'selenium-webdriver'
5 class ProjectsTest < ActionDispatch::IntegrationTest
7 headless = Headless.new
9 Capybara.current_driver = :selenium
11 # project tests need bigger page size to be able to see all the buttons
12 Capybara.current_session.driver.browser.manage.window.resize_to(1152, 768)
15 test 'Check collection count for A Project in the tab pane titles' do
16 project_uuid = api_fixture('groups')['aproject']['uuid']
17 visit page_with_token 'active', '/projects/' + project_uuid
19 collection_count = page.all("[data-pk*='collection']").count
20 assert_selector '#Data_collections-tab span', text: "(#{collection_count})"
23 test 'Find a project and edit its description' do
24 visit page_with_token 'active', '/'
25 find("#projects-menu").click
26 find(".dropdown-menu a", text: "A Project").click
27 within('.container-fluid', text: api_fixture('groups')['aproject']['name']) do
28 find('span', text: api_fixture('groups')['aproject']['name']).click
29 within('.arv-description-as-subtitle') do
30 find('.fa-pencil').click
31 find('.editable-input textarea').set('I just edited this.')
32 find('.editable-submit').click
37 assert(find?('.container-fluid', text: 'I just edited this.'),
38 "Description update did not survive page refresh")
41 test 'Find a project and edit description to textile description' do
42 visit page_with_token 'active', '/'
43 find("#projects-menu").click
44 find(".dropdown-menu a", text: "A Project").click
45 within('.container-fluid', text: api_fixture('groups')['aproject']['name']) do
46 find('span', text: api_fixture('groups')['aproject']['name']).click
47 within('.arv-description-as-subtitle') do
48 find('.fa-pencil').click
49 find('.editable-input textarea').set('<p>*Textile description for A project* - "take me home":/ </p><p>And a new paragraph in description.</p>')
50 find('.editable-submit').click
57 assert_no_text '*Textile description for A project*'
58 assert(find?('.container-fluid', text: 'Textile description for A project'),
59 "Description update did not survive page refresh")
60 assert(find?('.container-fluid', text: 'And a new paragraph in description'),
61 "Description did not contain the expected new paragraph")
62 assert(page.has_link?("take me home"), "link not found in description")
64 click_link 'take me home'
67 assert(page.has_text?('Active pipelines'), 'Active pipelines - not found on dashboard')
70 test 'Find a project and edit description to html description' do
71 visit page_with_token 'active', '/'
72 find("#projects-menu").click
73 find(".dropdown-menu a", text: "A Project").click
74 within('.container-fluid', text: api_fixture('groups')['aproject']['name']) do
75 find('span', text: api_fixture('groups')['aproject']['name']).click
76 within('.arv-description-as-subtitle') do
77 find('.fa-pencil').click
78 find('.editable-input textarea').set('<br>Textile description for A project</br> - <a href="/">take me home</a>')
79 find('.editable-submit').click
84 assert(find?('.container-fluid', text: 'Textile description for A project'),
85 "Description update did not survive page refresh")
86 assert(!find?('.container-fluid', text: '<br>Textile description for A project</br>'),
87 "Textile description is displayed with uninterpreted formatting characters")
88 assert(page.has_link?("take me home"),"link not found in description")
89 click_link 'take me home'
90 assert page.has_text?('Active pipelines')
93 test 'Find a project and edit description to textile description with link to object' do
94 visit page_with_token 'active', '/'
95 find("#projects-menu").click
96 find(".dropdown-menu a", text: "A Project").click
97 within('.container-fluid', text: api_fixture('groups')['aproject']['name']) do
98 find('span', text: api_fixture('groups')['aproject']['name']).click
99 within('.arv-description-as-subtitle') do
100 find('.fa-pencil').click
101 find('.editable-input textarea').set('*Textile description for A project* - "go to sub-project":' + api_fixture('groups')['asubproject']['uuid'] + "'")
102 find('.editable-submit').click
107 assert(find?('.container-fluid', text: 'Textile description for A project'),
108 "Description update did not survive page refresh")
109 assert(!find?('.container-fluid', text: '*Textile description for A project*'),
110 "Textile description is displayed with uninterpreted formatting characters")
111 assert(page.has_link?("go to sub-project"), "link not found in description")
112 click_link 'go to sub-project'
113 assert(page.has_text?(api_fixture('groups')['asubproject']['name']), 'sub-project name not found after clicking link')
116 test 'Add a new name, then edit it, without creating a duplicate' do
117 project_uuid = api_fixture('groups')['aproject']['uuid']
118 specimen_uuid = api_fixture('traits')['owned_by_aproject_with_no_name']['uuid']
119 visit page_with_token 'active', '/projects/' + project_uuid
120 click_link 'Other objects'
121 within '.selection-action-container' do
122 # Wait for the tab to load:
123 assert_selector 'tr[data-kind="arvados#trait"]'
124 within first('tr', text: 'Trait') do
125 find(".fa-pencil").click
126 find('.editable-input input').set('Now I have a name.')
127 find('.glyphicon-ok').click
128 assert_selector '.editable', text: 'Now I have a name.'
129 find(".fa-pencil").click
130 find('.editable-input input').set('Now I have a new name.')
131 find('.glyphicon-ok').click
134 assert_selector '.editable', text: 'Now I have a new name.'
137 click_link 'Other objects'
138 within '.selection-action-container' do
139 find '.editable', text: 'Now I have a new name.'
140 assert_no_selector '.editable', text: 'Now I have a name.'
144 test 'Create a project and move it into a different project' do
145 visit page_with_token 'active', '/projects'
146 find("#projects-menu").click
147 find(".dropdown-menu a", text: "Home").click
148 find('.btn', text: "Add a subproject").click
150 # within('.editable', text: 'New project') do
152 find('.fa-pencil').click
153 find('.editable-input input').set('Project 1234')
154 find('.glyphicon-ok').click
159 find("#projects-menu").click
160 find(".dropdown-menu a", text: "Home").click
161 find('.btn', text: "Add a subproject").click
163 find('.fa-pencil').click
164 find('.editable-input input').set('Project 5678')
165 find('.glyphicon-ok').click
169 click_link 'Move project...'
170 find('.selectable', text: 'Project 1234').click
171 find('.modal-footer a,button', text: 'Move').click
174 # Wait for the page to refresh and show the new parent in Sharing panel
176 assert(page.has_link?("Project 1234"),
177 "Project 5678 should now be inside project 1234")
180 def show_project_using(auth_key, proj_key='aproject')
181 project_uuid = api_fixture('groups')[proj_key]['uuid']
182 visit(page_with_token(auth_key, "/projects/#{project_uuid}"))
183 assert(page.has_text?("A Project"), "not on expected project page")
187 find('#project_sharing').all('tr')
190 def add_share_and_check(share_type, name, obj=nil)
191 assert(page.has_no_text?(name), "project is already shared with #{name}")
192 start_share_count = share_rows.size
193 click_on("Share with #{share_type}")
194 within(".modal-container") do
195 # Order is important here: we should find something that appears in the
196 # modal before we make any assertions about what's not in the modal.
197 # Otherwise, the not-included assertions might falsely pass because
198 # the modal hasn't loaded yet.
199 find(".selectable", text: name).click
200 assert(has_no_selector?(".modal-dialog-preview-pane"),
201 "preview pane available in sharing dialog")
202 if share_type == 'users' and obj and obj['email']
203 assert(page.has_text?(obj['email']), "Did not find user's email")
205 assert_raises(Capybara::ElementNotFound,
206 "Projects pulldown available from sharing dialog") do
207 click_on "All projects"
211 using_wait_time(Capybara.default_wait_time * 3) do
212 assert(page.has_link?(name),
213 "new share was not added to sharing table")
214 assert_equal(start_share_count + 1, share_rows.size,
215 "new share did not add row to sharing table")
219 def modify_share_and_check(name)
220 start_rows = share_rows
221 link_row = start_rows.select { |row| row.has_text?(name) }
222 assert_equal(1, link_row.size, "row with new permission not found")
223 within(link_row.first) do
225 select("Write", from: "share_change_level")
226 click_on("editable-submit")
227 assert(has_link?("Write"),
228 "failed to change access level on new share")
230 page.driver.browser.switch_to.alert.accept
233 using_wait_time(Capybara.default_wait_time * 3) do
234 assert(page.has_no_text?(name),
235 "new share row still exists after being revoked")
236 assert_equal(start_rows.size - 1, share_rows.size,
237 "revoking share did not remove row from sharing table")
241 test "project viewer can't see project sharing tab" do
242 show_project_using("project_viewer")
243 assert(page.has_no_link?("Sharing"),
244 "read-only project user sees sharing tab")
247 test "project owner can manage sharing for another user" do
248 add_user = api_fixture('users')['future_project_user']
249 new_name = ["first_name", "last_name"].map { |k| add_user[k] }.join(" ")
251 show_project_using("active")
253 add_share_and_check("users", new_name, add_user)
254 modify_share_and_check(new_name)
257 test "project owner can manage sharing for another group" do
258 new_name = api_fixture('groups')['future_project_viewing_group']['name']
260 show_project_using("active")
262 add_share_and_check("groups", new_name)
263 modify_share_and_check(new_name)
266 test "'share with group' listing does not offer projects" do
267 show_project_using("active")
269 click_on "Share with groups"
270 good_uuid = api_fixture("groups")["private"]["uuid"]
271 assert(page.has_selector?(".selectable[data-object-uuid=\"#{good_uuid}\"]"),
272 "'share with groups' listing missing owned user group")
273 bad_uuid = api_fixture("groups")["asubproject"]["uuid"]
274 assert(page.has_no_selector?(".selectable[data-object-uuid=\"#{bad_uuid}\"]"),
275 "'share with groups' listing includes project")
279 ['Move',api_fixture('collections')['collection_to_move_around_in_aproject'],
280 api_fixture('groups')['aproject'],api_fixture('groups')['asubproject']],
281 ['Remove',api_fixture('collections')['collection_to_move_around_in_aproject'],
282 api_fixture('groups')['aproject']],
283 ['Copy',api_fixture('collections')['collection_to_move_around_in_aproject'],
284 api_fixture('groups')['aproject'],api_fixture('groups')['asubproject']],
285 ['Remove',api_fixture('collections')['collection_in_aproject_with_same_name_as_in_home_project'],
286 api_fixture('groups')['aproject'],nil,true],
287 ].each do |action, my_collection, src, dest=nil, expect_name_change=nil|
288 test "selection #{action} -> #{expect_name_change.inspect} for project" do
289 perform_selection_action src, dest, my_collection, action
293 assert page.has_text?(my_collection['name']), 'Collection not found in src project after copy'
294 visit page_with_token 'active', '/'
295 find("#projects-menu").click
296 find(".dropdown-menu a", text: dest['name']).click
297 assert page.has_text?(my_collection['name']), 'Collection not found in dest project after copy'
300 assert page.has_no_text?(my_collection['name']), 'Collection still found in src project after move'
301 visit page_with_token 'active', '/'
302 find("#projects-menu").click
303 find(".dropdown-menu a", text: dest['name']).click
304 assert page.has_text?(my_collection['name']), 'Collection not found in dest project after move'
307 assert page.has_no_text?(my_collection['name']), 'Collection still found in src project after remove'
308 visit page_with_token 'active', '/'
309 find("#projects-menu").click
310 find(".dropdown-menu a", text: "Home").click
311 assert page.has_text?(my_collection['name']), 'Collection not found in home project after remove'
312 if expect_name_change
313 assert page.has_text?(my_collection['name']+' removed from ' + src['name']),
314 'Collection with update name is not found in home project after remove'
320 def perform_selection_action src, dest, item, action
321 visit page_with_token 'active', '/'
322 find("#projects-menu").click
323 find(".dropdown-menu a", text: src['name']).click
324 assert page.has_text?(item['name']), 'Collection not found in src project'
326 within('tr', text: item['name']) do
327 find('input[type=checkbox]').click
330 click_button 'Selection'
332 within('.selection-action-container') do
333 assert page.has_text?("Compare selected"), "Compare selected link text not found"
334 assert page.has_link?("Copy selected"), "Copy selected link not found"
335 assert page.has_link?("Move selected"), "Move selected link not found"
336 assert page.has_link?("Remove selected"), "Remove selected link not found"
338 click_link "#{action} selected"
341 # select the destination project if a Copy or Move action is being performed
342 if action == 'Copy' || action == 'Move'
343 within(".modal-container") do
344 find('.selectable', text: dest['name']).click
345 find('.modal-footer a,button', text: action).click
351 # Test copy action state. It should not be available when a subproject is selected.
352 test "copy action is disabled when a subproject is selected" do
353 my_project = api_fixture('groups')['aproject']
354 my_collection = api_fixture('collections')['collection_to_move_around_in_aproject']
355 my_subproject = api_fixture('groups')['asubproject']
357 # verify that selection options are disabled on the project until an item is selected
358 visit page_with_token 'active', '/'
359 find("#projects-menu").click
360 find(".dropdown-menu a", text: my_project['name']).click
362 click_button 'Selection'
363 within('.selection-action-container') do
364 assert_selector 'li.disabled', text: 'Create new collection with selected collections'
365 assert_selector 'li.disabled', text: 'Compare selected'
366 assert_selector 'li.disabled', text: 'Copy selected'
367 assert_selector 'li.disabled', text: 'Move selected'
368 assert_selector 'li.disabled', text: 'Remove selected'
371 # select collection and verify links are enabled
372 visit page_with_token 'active', '/'
373 find("#projects-menu").click
374 find(".dropdown-menu a", text: my_project['name']).click
375 assert page.has_text?(my_collection['name']), 'Collection not found in project'
377 within('tr', text: my_collection['name']) do
378 find('input[type=checkbox]').click
381 click_button 'Selection'
382 within('.selection-action-container') do
383 assert_no_selector 'li.disabled', text: 'Create new collection with selected collections'
384 assert_selector 'li', text: 'Create new collection with selected collections'
385 assert_selector 'li.disabled', text: 'Compare selected'
386 assert_no_selector 'li.disabled', text: 'Copy selected'
387 assert_selector 'li', text: 'Copy selected'
388 assert_no_selector 'li.disabled', text: 'Move selected'
389 assert_selector 'li', text: 'Move selected'
390 assert_no_selector 'li.disabled', text: 'Remove selected'
391 assert_selector 'li', text: 'Remove selected'
394 # select subproject and verify that copy action is disabled
395 visit page_with_token 'active', '/'
396 find("#projects-menu").click
397 find(".dropdown-menu a", text: my_project['name']).click
399 click_link 'Subprojects'
400 assert page.has_text?(my_subproject['name']), 'Subproject not found in project'
402 within('tr', text: my_subproject['name']) do
403 find('input[type=checkbox]').click
406 click_button 'Selection'
407 within('.selection-action-container') do
408 assert_selector 'li.disabled', text: 'Create new collection with selected collections'
409 assert_selector 'li.disabled', text: 'Compare selected'
410 assert_selector 'li.disabled', text: 'Copy selected'
411 assert_no_selector 'li.disabled', text: 'Move selected'
412 assert_selector 'li', text: 'Move selected'
413 assert_no_selector 'li.disabled', text: 'Remove selected'
414 assert_selector 'li', text: 'Remove selected'
417 # select subproject and a collection and verify that copy action is still disabled
418 visit page_with_token 'active', '/'
419 find("#projects-menu").click
420 find(".dropdown-menu a", text: my_project['name']).click
422 click_link 'Subprojects'
423 assert page.has_text?(my_subproject['name']), 'Subproject not found in project'
425 within('tr', text: my_subproject['name']) do
426 find('input[type=checkbox]').click
429 click_link 'Data collections'
430 assert page.has_text?(my_collection['name']), 'Collection not found in project'
432 within('tr', text: my_collection['name']) do
433 find('input[type=checkbox]').click
436 click_link 'Subprojects'
437 click_button 'Selection'
438 within('.selection-action-container') do
439 assert_selector 'li.disabled', text: 'Create new collection with selected collections'
440 assert_selector 'li.disabled', text: 'Compare selected'
441 assert_selector 'li.disabled', text: 'Copy selected'
442 assert_no_selector 'li.disabled', text: 'Move selected'
443 assert_selector 'li', text: 'Move selected'
444 assert_no_selector 'li.disabled', text: 'Remove selected'
445 assert_selector 'li', text: 'Remove selected'
449 # When project tabs are switched, only options applicable to the current tab's selections are enabled.
450 test "verify selection options when tabs are switched" do
451 my_project = api_fixture('groups')['aproject']
452 my_collection = api_fixture('collections')['collection_to_move_around_in_aproject']
453 my_subproject = api_fixture('groups')['asubproject']
455 # select subproject and a collection and verify that copy action is still disabled
456 visit page_with_token 'active', '/'
457 find("#projects-menu").click
458 find(".dropdown-menu a", text: my_project['name']).click
460 # Select a sub-project
461 click_link 'Subprojects'
462 assert page.has_text?(my_subproject['name']), 'Subproject not found in project'
464 within('tr', text: my_subproject['name']) do
465 find('input[type=checkbox]').click
468 # Select a collection
469 click_link 'Data collections'
470 assert page.has_text?(my_collection['name']), 'Collection not found in project'
472 within('tr', text: my_collection['name']) do
473 find('input[type=checkbox]').click
476 # Go back to Subprojects tab
477 click_link 'Subprojects'
478 click_button 'Selection'
479 within('.selection-action-container') do
480 assert_selector 'li.disabled', text: 'Create new collection with selected collections'
481 assert_selector 'li.disabled', text: 'Compare selected'
482 assert_selector 'li.disabled', text: 'Copy selected'
483 assert_no_selector 'li.disabled', text: 'Move selected'
484 assert_selector 'li', text: 'Move selected'
485 assert_no_selector 'li.disabled', text: 'Remove selected'
486 assert_selector 'li', text: 'Remove selected'
489 # Go back to Data collections tab
490 click_link 'Data collections'
491 click_button 'Selection'
492 within('.selection-action-container') do
493 assert_no_selector 'li.disabled', text: 'Create new collection with selected collections'
494 assert_selector 'li', text: 'Create new collection with selected collections'
495 assert_selector 'li.disabled', text: 'Compare selected'
496 assert_no_selector 'li.disabled', text: 'Copy selected'
497 assert_selector 'li', text: 'Copy selected'
498 assert_no_selector 'li.disabled', text: 'Move selected'
499 assert_selector 'li', text: 'Move selected'
500 assert_no_selector 'li.disabled', text: 'Remove selected'
501 assert_selector 'li', text: 'Remove selected'
505 # "Remove selected" selection option should not be available when current user cannot write to the project
506 test "remove selected action is not available when current user cannot write to project" do
507 my_project = api_fixture('groups')['anonymously_accessible_project']
508 visit page_with_token 'active', "/projects/#{my_project['uuid']}"
510 click_button 'Selection'
511 within('.selection-action-container') do
512 assert_selector 'li', text: 'Create new collection with selected collections'
513 assert_selector 'li', text: 'Compare selected'
514 assert_selector 'li', text: 'Copy selected'
515 assert_selector 'li', text: 'Move selected'
516 assert_no_selector 'li', text: 'Remove selected'
522 ['project_viewer', false],
523 ].each do |user, expect_collection_in_aproject|
524 test "combine selected collections into new collection #{user} #{expect_collection_in_aproject}" do
525 my_project = api_fixture('groups')['aproject']
526 my_collection = api_fixture('collections')['collection_to_move_around_in_aproject']
528 visit page_with_token user, '/'
529 find("#projects-menu").click
530 find(".dropdown-menu a", text: my_project['name']).click
531 assert page.has_text?(my_collection['name']), 'Collection not found in project'
533 within('tr', text: my_collection['name']) do
534 find('input[type=checkbox]').click
537 click_button 'Selection'
538 within('.selection-action-container') do
539 click_link 'Create new collection with selected collections'
542 # now in the new collection page
543 if expect_collection_in_aproject
544 assert page.has_text?("Created new collection in the project #{my_project['name']}"),
545 'Not found flash message that new collection is created in aproject'
547 assert page.has_text?("Created new collection in your Home project"),
548 'Not found flash message that new collection is created in Home project'
550 assert page.has_text?('Content hash'), 'Not found content hash in collection page'
556 ["pipelines", "/pipeline_instances"],
557 ["collections", "/collections"]
558 ].each do |target,path|
559 test "Test dashboard button all #{target}" do
560 visit page_with_token 'active', '/'
561 click_link "All #{target}"
562 assert_equal path, current_path
566 def scroll_setup(project_name,
570 sort_parameters = nil)
571 project_uuid = api_fixture('groups')[project_name]['uuid']
572 visit page_with_token 'user1_with_load', '/projects/' + project_uuid
574 assert(page.has_text?("#{item_list_parameter.humanize} (#{total_nbr_items})"), "Number of #{item_list_parameter.humanize} did not match the input amount")
576 click_link item_list_parameter.humanize
580 find("th[data-sort-order='#{sort_parameters.gsub(/\s/,'')}']").click
585 def scroll_items_check(nbr_items,
591 for i in 1..nbr_items
592 items << "#{fixture_prefix}#{i}"
595 verify_items = items.dup
596 unexpected_items = []
598 within(".arv-project-#{item_list_parameter}") do
599 page.execute_script "window.scrollBy(0,999000)"
605 # Visit all rows. If not all expected items are found, retry
606 found_items = page.all(item_selector)
607 item_count = found_items.count
610 (0..item_count-1).each do |i|
611 # Found row text using the fixture string e.g. "Show Collection_#{n} "
612 item_name = found_items[i].text.split[1]
613 if !items.include? item_name
614 unexpected_items << item_name
616 verify_items.delete item_name
620 assert_operator( previous.downcase, :<=, item_name.downcase) if previous
625 assert_equal true, unexpected_items.empty?, "Found unexpected #{item_list_parameter.humanize} #{unexpected_items.inspect}"
626 assert_equal nbr_items, item_count, "Found different number of #{item_list_parameter.humanize}"
627 assert_equal true, verify_items.empty?, "Did not find all the #{item_list_parameter.humanize}"
632 ['project_with_10_collections', 10],
633 ['project_with_201_collections', 201], # two pages of data
634 ].each do |project_name, nbr_items|
635 test "scroll collections tab for #{project_name} with #{nbr_items} objects" do
636 item_list_parameter = "Data_collections"
637 scroll_setup project_name,
640 scroll_items_check nbr_items,
643 'tr[data-kind="arvados#collection"]'
648 ['project_with_10_collections', 10],
649 ['project_with_201_collections', 201], # two pages of data
650 ].each do |project_name, nbr_items|
651 test "scroll collections tab for #{project_name} with #{nbr_items} objects with ascending sort (case insensitive)" do
652 item_list_parameter = "Data_collections"
653 scroll_setup project_name,
658 scroll_items_check nbr_items,
661 'tr[data-kind="arvados#collection"]',
667 ['project_with_10_pipelines', 10, 0],
668 ['project_with_2_pipelines_and_60_jobs', 2, 60],
669 ['project_with_25_pipelines', 25, 0],
670 ].each do |project_name, num_pipelines, num_jobs|
671 test "scroll pipeline instances tab for #{project_name} with #{num_pipelines} pipelines and #{num_jobs} jobs" do
672 item_list_parameter = "Jobs_and_pipelines"
673 scroll_setup project_name,
674 num_pipelines + num_jobs,
676 # check the general scrolling and the pipelines
677 scroll_items_check num_pipelines,
680 'tr[data-kind="arvados#pipelineInstance"]'
681 # Check job count separately
682 jobs_found = page.all('tr[data-kind="arvados#job"]')
683 found_job_count = jobs_found.count
684 assert_equal num_jobs, found_job_count, 'Did not find expected number of jobs'
688 # Move button accessibility
691 ['active', true], # project owner
692 ['project_viewer', false],
693 ].each do |user, can_move|
694 test "#{user} can move subproject under another user's Home #{can_move}" do
695 project = api_fixture('groups')['aproject']
696 collection = api_fixture('collections')['collection_to_move_around_in_aproject']
698 # verify the project move button
699 visit page_with_token user, "/projects/#{project['uuid']}"
701 assert page.has_link? 'Move project...'
703 assert page.has_no_link? 'Move project...'
708 test "error while loading tab" do
709 original_arvados_v1_base = Rails.configuration.arvados_v1_base
711 visit page_with_token 'active', '/projects/' + api_fixture('groups')['aproject']['uuid']
713 # Point to a bad api server url to generate error
714 Rails.configuration.arvados_v1_base = "https://[100::f]:1/"
715 click_link 'Other objects'
716 within '#Other_objects' do
718 assert_selector('a', text: 'Reload tab')
720 # Now point back to the orig api server and reload tab
721 Rails.configuration.arvados_v1_base = original_arvados_v1_base
722 click_link 'Reload tab'
723 assert_no_selector('a', text: 'Reload tab')
724 assert_selector('button', text: 'Selection')
725 within '.selection-action-container' do
726 assert_selector 'tr[data-kind="arvados#trait"]'