21720:
[arvados.git] / services / workbench2 / cypress / e2e / project.cy.js
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 describe("Project tests", function () {
6     let activeUser;
7     let adminUser;
8
9     before(function () {
10         // Only set up common users once. These aren't set up as aliases because
11         // aliases are cleaned up after every test. Also it doesn't make sense
12         // to set the same users on beforeEach() over and over again, so we
13         // separate a little from Cypress' 'Best Practices' here.
14         cy.getUser("admin", "Admin", "User", true, true)
15             .as("adminUser")
16             .then(function () {
17                 adminUser = this.adminUser;
18             });
19         cy.getUser("user", "Active", "User", false, true)
20             .as("activeUser")
21             .then(function () {
22                 activeUser = this.activeUser;
23             });
24     });
25
26     it("creates a new project with multiple properties", function () {
27         const projName = `Test project (${Math.floor(999999 * Math.random())})`;
28         cy.loginAs(activeUser);
29         cy.get("[data-cy=side-panel-button]").click();
30         cy.get("[data-cy=side-panel-new-project]").click();
31         cy.get("[data-cy=form-dialog]")
32             .should("contain", "New Project")
33             .within(() => {
34                 cy.get("[data-cy=name-field]").within(() => {
35                     cy.get("input").type(projName);
36                 });
37             });
38         // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
39         cy.get("[data-cy=form-dialog]").should("not.contain", "Color: Magenta");
40         cy.get("[data-cy=resource-properties-form]").within(() => {
41             cy.get("[data-cy=property-field-key]").within(() => {
42                 cy.get("input").type("Color").blur();
43             });
44             cy.get("[data-cy=property-field-value]").within(() => {
45                 cy.get("input").type("Magenta").blur();
46             });
47             cy.get("[data-cy=property-add-btn]").click();
48
49             cy.get("[data-cy=property-field-value]").within(() => {
50                 cy.get("input").type("Pink").blur();
51             });
52             cy.get("[data-cy=property-add-btn]").click();
53
54             cy.get("[data-cy=property-field-value]").within(() => {
55                 cy.get("input").type("Yellow").blur();
56             });
57             cy.get("[data-cy=property-add-btn]").click();
58         });
59         // Confirm proper vocabulary labels are displayed on the UI.
60         cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
61         cy.get("[data-cy=form-dialog]").should("contain", "Color: Pink");
62         cy.get("[data-cy=form-dialog]").should("contain", "Color: Yellow");
63
64         cy.get("[data-cy=resource-properties-form]").within(() => {
65             cy.get("[data-cy=property-field-key]").within(() => {
66                 cy.get("input").focus();
67             });
68             cy.get("[data-cy=property-field-key]").should("not.contain", "Color");
69         });
70
71         // Create project and confirm the properties' real values.
72         cy.get("[data-cy=form-submit-btn]").click();
73         cy.get("[data-cy=form-dialog]").should("not.exist");        
74         cy.get("[data-cy=breadcrumb-last]").should("exist");
75         cy.get("[data-cy=breadcrumb-last]").should("contain", projName);
76         cy.doRequest("GET", "/arvados/v1/groups", null, {
77             filters: `[["name", "=", "${projName}"], ["group_class", "=", "project"]]`,
78         })
79             .its("body.items")
80             .as("projects")
81             .then(function () {
82                 expect(this.projects).to.have.lengthOf(1);
83                 expect(this.projects[0].properties).to.deep.equal(
84                     // Pink is not in the test vocab
85                     { IDTAGCOLORS: ["IDVALCOLORS3", "Pink", "IDVALCOLORS1"] }
86                 );
87             });
88
89         // Open project edit via breadcrumbs
90         cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick();
91         cy.get("[data-cy=context-menu]").contains("Edit").click();
92         cy.get("[data-cy=form-dialog]").within(() => {
93             cy.get("[data-cy=resource-properties-list]").within(() => {
94                 cy.get("div[role=button]").contains("Color: Magenta");
95                 cy.get("div[role=button]").contains("Color: Pink");
96                 cy.get("div[role=button]").contains("Color: Yellow");
97             });
98         });
99         // Add another property
100         cy.get("[data-cy=resource-properties-form]").within(() => {
101             cy.get("[data-cy=property-field-key]").within(() => {
102                 cy.get("input").type("Medium").blur();
103             });
104             cy.get("[data-cy=property-field-value]").within(() => {
105                 cy.get("input").type("Egg").blur();
106             });
107             cy.get("[data-cy=property-add-btn]").click();
108         });
109         cy.get("[data-cy=form-submit-btn]").click();
110         // Reopen edit via breadcrumbs and verify properties
111         cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick();
112         cy.get("[data-cy=context-menu]").contains("Edit").click();
113         cy.get("[data-cy=form-dialog]").within(() => {
114             cy.get("[data-cy=resource-properties-list]").within(() => {
115                 cy.get("div[role=button]").contains("Color: Magenta");
116                 cy.get("div[role=button]").contains("Color: Pink");
117                 cy.get("div[role=button]").contains("Color: Yellow");
118                 cy.get("div[role=button]").contains("Medium: Egg");
119             });
120         });
121     });
122
123     it("creates a project without and with description", function () {
124         const projName = `Test project (${Math.floor(999999 * Math.random())})`;
125         cy.loginAs(activeUser);
126
127         // Create project
128         cy.get("[data-cy=side-panel-button]").click();
129         cy.get("[data-cy=side-panel-new-project]").click();
130         cy.get("[data-cy=form-dialog]")
131             .should("contain", "New Project")
132             .within(() => {
133                 cy.get("[data-cy=name-field]").within(() => {
134                     cy.get("input").type(projName);
135                 });
136             });
137         cy.get("[data-cy=form-submit-btn]").click();
138         cy.get("[data-cy=form-dialog]").should("not.exist");
139
140         const editProjectDescription = (name, type) => {
141             cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
142             cy.get("[data-cy=project-panel] tbody tr").contains(name).rightclick({ force: true });
143             cy.get("[data-cy=context-menu]").contains("Edit").click();
144             cy.get("[data-cy=form-dialog]").within(() => {
145                 cy.get("div[contenteditable=true]").click().type(type);
146                 cy.get("[data-cy=form-submit-btn]").click();
147             });
148         };
149
150         const verifyProjectDescription = (name, description) => {
151             cy.doRequest("GET", "/arvados/v1/groups", null, {
152                 filters: `[["name", "=", "${name}"], ["group_class", "=", "project"]]`,
153             })
154                 .its("body.items")
155                 .as("projects")
156                 .then(function () {
157                     expect(this.projects).to.have.lengthOf(1);
158                     expect(this.projects[0].description).to.equal(description);
159                 });
160         };
161
162         // Edit description
163         editProjectDescription(projName, "Test description");
164
165         // Check description is set
166         verifyProjectDescription(projName, "<p>Test description</p>");
167
168         // Clear description
169         editProjectDescription(projName, "{selectall}{backspace}");
170
171         // Check description is null
172         verifyProjectDescription(projName, null);
173
174         // Set description to contain whitespace
175         editProjectDescription(projName, "{selectall}{backspace}    x");
176         editProjectDescription(projName, "{backspace}");
177
178         // Check description is null
179         verifyProjectDescription(projName, null);
180     });
181
182     it("creates a project from the context menu in the correct subfolder", function () {
183         const parentProjName = `Test project (${Math.floor(999999 * Math.random())})`;
184         const childProjName = `Test project (${Math.floor(999999 * Math.random())})`;
185         cy.loginAs(activeUser);
186
187         // Create project
188         cy.get("[data-cy=side-panel-button]").click();
189         cy.get("[data-cy=side-panel-new-project]").click();
190         cy.get("[data-cy=form-dialog]")
191             .should("contain", "New Project")
192             .within(() => {
193                 cy.get("[data-cy=name-field]").within(() => {
194                     cy.get("input").type(parentProjName);
195                 });
196             });
197         cy.get("[data-cy=form-submit-btn]").click();
198         cy.get("[data-cy=form-dialog]").should("not.exist");
199         cy.go('back')
200
201         // Create subproject from context menu
202         cy.get("[data-cy=project-panel] tbody tr").contains(parentProjName).rightclick({ force: true });
203         cy.get("[data-cy=context-menu]").contains("New project").click();
204         cy.get("[data-cy=form-dialog]")
205             .should("contain", "New Project")
206             .within(() => {
207                 cy.get("[data-cy=name-field]").within(() => {
208                     cy.get("input").type(childProjName);
209                 });
210             });
211         cy.get("[data-cy=form-submit-btn]").click();
212         cy.get("[data-cy=form-dialog]").should("not.exist");
213
214         // open details panel and check 'owner' field
215         cy.get("[data-cy=additional-info-icon]").click();
216         cy.waitForDom();
217         cy.get("[data-cy=details-panel-owner]").contains(parentProjName).should("be.visible")
218         cy.get("[data-cy=additional-info-icon]").click();
219     });
220
221     it('shows the appropriate buttons in the multiselect toolbar', () => {
222
223         const msButtonTooltips = [
224             'View details',
225             'Open in new tab',
226             'Copy link to clipboard',
227             'Open with 3rd party client',
228             'API Details',
229             'Share',
230             'New project',
231             'Edit project',
232             'Move to',
233             'Move to trash',
234             'Freeze project',
235             'Add to favorites',
236         ];
237
238         cy.loginAs(activeUser);
239         const projName = `Test project (${Math.floor(999999 * Math.random())})`;
240         cy.get('[data-cy=side-panel-button]').click();
241         cy.get('[data-cy=side-panel-new-project]').click();
242         cy.get('[data-cy=form-dialog]')
243             .should('contain', 'New Project')
244             .within(() => {
245                 cy.get('[data-cy=name-field]').within(() => {
246                     cy.get('input').type(projName);
247                 });
248             })
249         cy.get("[data-cy=form-submit-btn]").click();
250         cy.waitForDom()
251         cy.go('back')
252
253         cy.get('[data-cy=data-table-row]').contains(projName).should('exist').parent().parent().parent().click()
254         cy.waitForDom()
255         cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length)
256         for (let i = 0; i < msButtonTooltips.length; i++) {
257             cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover');
258             cy.get('body').contains(msButtonTooltips[i]).should('exist')
259             cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout');
260         }
261     })
262
263     it("creates new project on home project and then a subproject inside it", function () {
264         const createProject = function (name, parentName) {
265             cy.get("[data-cy=side-panel-button]").click();
266             cy.get("[data-cy=side-panel-new-project]").click();
267             cy.get("[data-cy=form-dialog]")
268                 .should("contain", "New Project")
269                 .within(() => {
270                     cy.get("[data-cy=parent-field]").within(() => {
271                         cy.get("input")
272                             .invoke("val")
273                             .then(val => {
274                                 expect(val).to.include(parentName);
275                             });
276                     });
277                     cy.get("[data-cy=name-field]").within(() => {
278                         cy.get("input").type(name);
279                     });
280                 });
281             cy.get("[data-cy=form-submit-btn]").click();
282         };
283
284         cy.loginAs(activeUser);
285         cy.goToPath(`/projects/${activeUser.user.uuid}`);
286         cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
287         cy.get("[data-cy=breadcrumb-last]").should("not.exist");
288         // Create new project
289         const projName = `Test project (${Math.floor(999999 * Math.random())})`;
290         createProject(projName, "Home project");
291         // Confirm that the user was taken to the newly created thing
292         cy.get("[data-cy=form-dialog]").should("not.exist");
293         cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
294         cy.get("[data-cy=breadcrumb-last]").should("contain", projName);
295         // Create a subproject
296         const subProjName = `Test project (${Math.floor(999999 * Math.random())})`;
297         createProject(subProjName, projName);
298         cy.get("[data-cy=form-dialog]").should("not.exist");
299         cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
300         cy.get("[data-cy=breadcrumb-last]").should("contain", subProjName);
301     });
302
303     it("attempts to use a preexisting name creating a project", function () {
304         const name = `Test project ${Math.floor(Math.random() * 999999)}`;
305         cy.createGroup(activeUser.token, {
306             name: name,
307             group_class: "project",
308         });
309         cy.loginAs(activeUser);
310         cy.goToPath(`/projects/${activeUser.user.uuid}`);
311
312         // Attempt to create new collection with a duplicate name
313         cy.get("[data-cy=side-panel-button]").click();
314         cy.get("[data-cy=side-panel-new-project]").click();
315         cy.get("[data-cy=form-dialog]")
316             .should("contain", "New Project")
317             .within(() => {
318                 cy.get("[data-cy=name-field]").within(() => {
319                     cy.get("input").type(name);
320                 });
321                 cy.get("[data-cy=form-submit-btn]").click();
322             });
323         // Error message should display, allowing editing the name
324         cy.get("[data-cy=form-dialog]")
325             .should("exist")
326             .and("contain", "Project with the same name already exists")
327             .within(() => {
328                 cy.get("[data-cy=name-field]").within(() => {
329                     cy.get("input").type(" renamed");
330                 });
331                 cy.get("[data-cy=form-submit-btn]").click();
332             });
333         cy.get("[data-cy=form-dialog]").should("not.exist");
334     });
335
336     it("navigates to the parent project after trashing the one being displayed", function () {
337         cy.createGroup(activeUser.token, {
338             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
339             group_class: "project",
340         })
341             .as("testRootProject")
342             .then(function () {
343                 cy.createGroup(activeUser.token, {
344                     name: `Test subproject ${Math.floor(Math.random() * 999999)}`,
345                     group_class: "project",
346                     owner_uuid: this.testRootProject.uuid,
347                 }).as("testSubProject");
348             });
349         cy.getAll("@testRootProject", "@testSubProject").then(function ([testRootProject, testSubProject]) {
350             cy.loginAs(activeUser);
351
352             // Go to subproject and trash it.
353             cy.goToPath(`/projects/${testSubProject.uuid}`);
354             cy.get("[data-cy=side-panel-tree]").should("contain", testSubProject.name);
355             cy.get("[data-cy=breadcrumb-last]").should("contain", testSubProject.name).rightclick();
356             cy.get("[data-cy=context-menu]").contains("Move to trash").click();
357
358             // Confirm that the parent project should be displayed.
359             cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name);
360             cy.url().should("contain", `/projects/${testRootProject.uuid}`);
361             cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name);
362
363             // Checks for bugfix #17637.
364             cy.get("[data-cy=not-found-content]").should("not.exist");
365             cy.get("[data-cy=not-found-page]").should("not.exist");
366         });
367     });
368
369     it("resets the search box only when navigating out of the current project", function () {
370         const fooProjectNameA = `Test foo project ${Math.floor(Math.random() * 999999)}`;
371         const fooProjectNameB = `Test foo project ${Math.floor(Math.random() * 999999)}`;
372         const barProjectNameA = `Test bar project ${Math.floor(Math.random() * 999999)}`;
373
374         [fooProjectNameA, fooProjectNameB, barProjectNameA].forEach(projName => {
375             cy.createGroup(activeUser.token, {
376                 name: projName,
377                 group_class: "project",
378             });
379         });
380
381         cy.loginAs(activeUser);
382         cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("contain", barProjectNameA);
383
384         cy.get("[data-cy=search-input]").type("foo");
385         cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("not.contain", barProjectNameA);
386
387         // Click on the table row to select it, search should remain the same.
388         cy.get(`p:contains(${fooProjectNameA})`).parent().parent().parent().parent().click();
389         cy.get("[data-cy=search-input] input").should("have.value", "foo");
390
391         // Click to navigate to the project, search should be reset
392         cy.get(`p:contains(${fooProjectNameA})`).click();
393         cy.get("[data-cy=search-input] input").should("not.have.value", "foo");
394     });
395
396     it("navigates to the root project after trashing the parent of the one being displayed", function () {
397         cy.createGroup(activeUser.token, {
398             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
399             group_class: "project",
400         })
401             .as("testRootProject")
402             .then(function () {
403                 cy.createGroup(activeUser.token, {
404                     name: `Test subproject ${Math.floor(Math.random() * 999999)}`,
405                     group_class: "project",
406                     owner_uuid: this.testRootProject.uuid,
407                 })
408                     .as("testSubProject")
409                     .then(function () {
410                         cy.createGroup(activeUser.token, {
411                             name: `Test sub subproject ${Math.floor(Math.random() * 999999)}`,
412                             group_class: "project",
413                             owner_uuid: this.testSubProject.uuid,
414                         }).as("testSubSubProject");
415                     });
416             });
417         cy.getAll("@testRootProject", "@testSubProject", "@testSubSubProject").then(function ([testRootProject, testSubProject, testSubSubProject]) {
418             cy.loginAs(activeUser);
419
420             // Go to innermost project and trash its parent.
421             cy.goToPath(`/projects/${testSubSubProject.uuid}`);
422             cy.get("[data-cy=side-panel-tree]").should("contain", testSubSubProject.name);
423             cy.get("[data-cy=breadcrumb-last]").should("contain", testSubSubProject.name);
424             cy.get("[data-cy=side-panel-tree]").contains(testSubProject.name).rightclick();
425             cy.get("[data-cy=context-menu]").contains("Move to trash").click();
426
427             // Confirm that the trashed project's parent should be displayed.
428             cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name);
429             cy.url().should("contain", `/projects/${testRootProject.uuid}`);
430             cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name);
431             cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubSubProject.name);
432
433             // Checks for bugfix #17637.
434             cy.get("[data-cy=not-found-content]").should("not.exist");
435             cy.get("[data-cy=not-found-page]").should("not.exist");
436         });
437     });
438
439     it("shows details panel when clicking on the info icon", () => {
440         cy.createGroup(activeUser.token, {
441             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
442             group_class: "project",
443         })
444             .as("testRootProject")
445             .then(function (testRootProject) {
446                 cy.loginAs(activeUser);
447
448                 cy.get("[data-cy=side-panel-tree]").contains(testRootProject.name).click();
449
450                 cy.get("[data-cy=additional-info-icon]").click();
451
452                 cy.contains(testRootProject.uuid).should("exist");
453             });
454     });
455
456     it("clears search input when changing project", () => {
457         cy.createGroup(activeUser.token, {
458             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
459             group_class: "project",
460         })
461             .as("testProject1")
462             .then(testProject1 => {
463                 cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, "can_write");
464             });
465
466         cy.getAll("@testProject1").then(function ([testProject1]) {
467             cy.loginAs(activeUser);
468
469             cy.get("[data-cy=side-panel-tree]").contains(testProject1.name).click();
470
471             cy.get("[data-cy=search-input] input").type("test123");
472
473             cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
474
475             cy.get("[data-cy=search-input] input").should("not.have.value", "test123");
476         });
477     });
478
479     it("opens advanced popup for project with username", () => {
480         const projectName = `Test project ${Math.floor(Math.random() * 999999)}`;
481
482         cy.createGroup(adminUser.token, {
483             name: projectName,
484             group_class: "project",
485         }).as("mainProject");
486
487         cy.getAll("@mainProject").then(function ([mainProject]) {
488             cy.loginAs(adminUser);
489
490             cy.get("[data-cy=side-panel-tree]").contains("Groups").click();
491
492             cy.get("[data-cy=uuid]")
493                 .eq(0)
494                 .invoke("text")
495                 .then(uuid => {
496                     cy.createLink(adminUser.token, {
497                         name: "can_write",
498                         link_class: "permission",
499                         head_uuid: mainProject.uuid,
500                         tail_uuid: uuid,
501                     });
502
503                     cy.createLink(adminUser.token, {
504                         name: "can_write",
505                         link_class: "permission",
506                         head_uuid: mainProject.uuid,
507                         tail_uuid: activeUser.user.uuid,
508                     });
509
510                     cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
511
512                     cy.get("main").contains(projectName).rightclick();
513
514                     cy.get("[data-cy=context-menu]").contains("API Details").click();
515
516                     cy.get("[role=tablist]").contains("METADATA").click();
517
518                     cy.get("td").contains(uuid).should("exist");
519
520                     cy.get("td").contains(activeUser.user.uuid).should("exist");
521                 });
522         });
523     });
524
525     describe("Frozen projects", () => {
526         beforeEach(() => {
527             cy.createGroup(activeUser.token, {
528                 name: `Main project ${Math.floor(Math.random() * 999999)}`,
529                 group_class: "project",
530             }).as("mainProject");
531
532             cy.createGroup(adminUser.token, {
533                 name: `Admin project ${Math.floor(Math.random() * 999999)}`,
534                 group_class: "project",
535             })
536                 .as("adminProject")
537                 .then(mainProject => {
538                     cy.shareWith(adminUser.token, activeUser.user.uuid, mainProject.uuid, "can_write");
539                 });
540
541             cy.get("@mainProject").then(mainProject => {
542                 cy.createGroup(adminUser.token, {
543                     name: `Sub project ${Math.floor(Math.random() * 999999)}`,
544                     group_class: "project",
545                     owner_uuid: mainProject.uuid,
546                 }).as("subProject");
547
548                 cy.createCollection(adminUser.token, {
549                     name: `Main collection ${Math.floor(Math.random() * 999999)}`,
550                     owner_uuid: mainProject.uuid,
551                     manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
552                 }).as("mainCollection");
553             });
554         });
555
556         it("should be able to freeze own project", () => {
557             cy.getAll("@mainProject").then(([mainProject]) => {
558                 cy.loginAs(activeUser);
559
560                 cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
561
562                 cy.get("[data-cy=context-menu]").contains("Freeze").click();
563
564                 cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
565
566                 cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist");
567             });
568         });
569
570         it("should not be able to modify items within the frozen project", () => {
571             cy.getAll("@mainProject", "@mainCollection").then(([mainProject, mainCollection]) => {
572                 cy.loginAs(activeUser);
573
574                 cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
575
576                 cy.get("[data-cy=context-menu]").contains("Freeze").click();
577
578                 cy.get("[data-cy=project-panel]").contains(mainProject.name).click();
579
580                 cy.get("[data-cy=project-panel]").contains(mainCollection.name).rightclick();
581
582                 cy.get("[data-cy=context-menu]").contains("Move to trash").should("not.exist");
583             });
584         });
585
586         it("should be able to freeze not owned project", () => {
587             cy.getAll("@adminProject").then(([adminProject]) => {
588                 cy.loginAs(activeUser);
589
590                 cy.get("[data-cy=side-panel-tree]").contains("Shared with me").click();
591
592                 cy.get("main").contains(adminProject.name).rightclick();
593
594                 cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist");
595             });
596         });
597
598         it("should be able to unfreeze project if user is an admin", () => {
599             cy.getAll("@adminProject").then(([adminProject]) => {
600                 cy.loginAs(adminUser);
601
602                 cy.get("main").contains(adminProject.name).rightclick();
603
604                 cy.get("[data-cy=context-menu]").contains("Freeze").click();
605
606                 cy.wait(1000);
607
608                 cy.get("main").contains(adminProject.name).rightclick();
609
610                 cy.get("[data-cy=context-menu]").contains("Unfreeze").click();
611
612                 cy.get("main").contains(adminProject.name).rightclick();
613
614                 cy.get("[data-cy=context-menu]").contains("Freeze").should("exist");
615             });
616         });
617     });
618
619     // The following test is enabled on Electron only, as Chromium and Firefox
620     // require permissions to access the clipboard.
621     it("copies project URL to clipboard", { browser: 'electron' }, () => {
622         const projectName = `Test project (${Math.floor(999999 * Math.random())})`;
623
624         cy.loginAs(activeUser);
625         cy.get("[data-cy=side-panel-button]").click();
626         cy.get("[data-cy=side-panel-new-project]").click();
627         cy.get("[data-cy=form-dialog]")
628             .should("contain", "New Project")
629             .within(() => {
630                 cy.get("[data-cy=name-field]").within(() => {
631                     cy.get("input").type(projectName);
632                 });
633                 cy.get("[data-cy=form-submit-btn]").click();
634             });
635         cy.get("[data-cy=form-dialog]").should("not.exist");
636         cy.get("[data-cy=snackbar]").contains("created");
637         cy.get("[data-cy=snackbar]").should("not.exist");
638         cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
639         cy.waitForDom();
640         cy.get("[data-cy=project-panel]").contains(projectName).should("be.visible").rightclick();
641         cy.get("[data-cy=context-menu]").contains("Copy link to clipboard").click();
642         cy.window().then(win =>
643             win.navigator.clipboard.readText().then(text => {
644                 expect(text).to.match(/https\:\/\/127\.0\.0\.1\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/);
645             })
646         );
647     });
648
649     it("sorts displayed items correctly", () => {
650         cy.loginAs(activeUser);
651
652         cy.get('[data-cy=project-panel] button[aria-label="Select columns"]').click();
653         cy.get("div[role=presentation] ul > div[role=button]").contains("Date Created").click();
654         cy.get("div[role=presentation] ul > div[role=button]").contains("Trash at").click();
655         cy.get("div[role=presentation] ul > div[role=button]").contains("Delete at").click();
656         cy.get("div[role=presentation] > div[aria-hidden=true]").click();
657
658         cy.intercept({ method: "GET", url: "**/arvados/v1/groups/*/contents*" }).as("filteredQuery");
659         [
660             {
661                 name: "Name",
662                 asc: "collections.name asc,container_requests.name asc,groups.name asc,container_requests.created_at desc",
663                 desc: "collections.name desc,container_requests.name desc,groups.name desc,container_requests.created_at desc",
664             },
665             {
666                 name: "Last Modified",
667                 asc: "collections.modified_at asc,container_requests.modified_at asc,groups.modified_at asc,container_requests.created_at desc",
668                 desc: "collections.modified_at desc,container_requests.modified_at desc,groups.modified_at desc,container_requests.created_at desc",
669             },
670             {
671                 name: "Date Created",
672                 asc: "collections.created_at asc,container_requests.created_at asc,groups.created_at asc,container_requests.created_at desc",
673                 desc: "collections.created_at desc,container_requests.created_at desc,groups.created_at desc,container_requests.created_at desc",
674             },
675             {
676                 name: "Trash at",
677                 asc: "collections.trash_at asc,container_requests.trash_at asc,groups.trash_at asc,container_requests.created_at desc",
678                 desc: "collections.trash_at desc,container_requests.trash_at desc,groups.trash_at desc,container_requests.created_at desc",
679             },
680             {
681                 name: "Delete at",
682                 asc: "collections.delete_at asc,container_requests.delete_at asc,groups.delete_at asc,container_requests.created_at desc",
683                 desc: "collections.delete_at desc,container_requests.delete_at desc,groups.delete_at desc,container_requests.created_at desc",
684             },
685         ].forEach(test => {
686             cy.get("[data-cy=project-panel] table thead th").contains(test.name).click();
687             cy.wait("@filteredQuery").then(interception => {
688                 const searchParams = new URLSearchParams(new URL(interception.request.url).search);
689                 expect(searchParams.get("order")).to.eq(test.asc);
690             });
691             cy.get("[data-cy=project-panel] table thead th").contains(test.name).click();
692             cy.wait("@filteredQuery").then(interception => {
693                 const searchParams = new URLSearchParams(new URL(interception.request.url).search);
694                 expect(searchParams.get("order")).to.eq(test.desc);
695             });
696         });
697     });
698 });