22141: Improve selectors used in tests
[arvados.git] / services / workbench2 / cypress / support / commands.js
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 // ***********************************************
6 // This example commands.js shows you how to
7 // create various custom commands and overwrite
8 // existing commands.
9 //
10 // For more comprehensive examples of custom
11 // commands please read more here:
12 // https://on.cypress.io/custom-commands
13 // ***********************************************
14 //
15 //
16 // -- This is a parent command --
17 // Cypress.Commands.add("login", (email, password) => { ... })
18 //
19 //
20 // -- This is a child command --
21 // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
22 //
23 //
24 // -- This is a dual command --
25 // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
26 //
27 //
28 // -- This will overwrite an existing command --
29 // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
30
31 import 'cypress-wait-until';
32 import { extractFilesData } from "services/collection-service/collection-service-files-response";
33
34 const controllerURL = Cypress.env("controller_url");
35 const systemToken = Cypress.env("system_token");
36 let createdResources = [];
37
38 const containerLogFolderPrefix = "log for container ";
39
40 // Clean up anything that was created.  You can temporarily add
41 // 'return' to the top if you need the resources to hang around to
42 // debug a specific test.
43 afterEach(function () {
44     if (createdResources.length === 0) {
45         return;
46     }
47     cy.log(`Cleaning ${createdResources.length} previously created resource(s).`);
48     // delete them in FIFO order because later created resources may
49     // be linked to the earlier ones.
50     createdResources.reverse().forEach(function ({ suffix, uuid }) {
51         // Don't fail when a resource isn't already there, some objects may have
52         // been removed, directly or indirectly, from the test that created them.
53         cy.deleteResource(systemToken, suffix, uuid, false);
54     });
55     createdResources = [];
56 });
57
58 Cypress.Commands.add(
59     "doRequest",
60     (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
61         return cy.request({
62             method: method,
63             url: `${controllerURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
64             body: data,
65             qs: auth ? qs : Object.assign({ api_token: token }, qs),
66             auth: auth ? { bearer: `${token}` } : undefined,
67             followRedirect: followRedirect,
68             failOnStatusCode: failOnStatusCode,
69         });
70     }
71 );
72
73 Cypress.Commands.add(
74     "doWebDAVRequest",
75     (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
76         return cy.doRequest("GET", "/arvados/v1/config", null, null).then(({ body: config }) => {
77             return cy.request({
78                 method: method,
79                 url: `${config.Services.WebDAVDownload.ExternalURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
80                 body: data,
81                 qs: auth ? qs : Object.assign({ api_token: token }, qs),
82                 auth: auth ? { bearer: `${token}` } : undefined,
83                 followRedirect: followRedirect,
84                 failOnStatusCode: failOnStatusCode,
85             });
86         });
87     }
88 );
89
90 Cypress.Commands.add("getUser", (username, first_name = "", last_name = "", is_admin = false, is_active = true) => {
91     // Create user if not already created
92     return (
93         cy
94             .doRequest(
95                 "POST",
96                 "/auth/controller/callback",
97                 {
98                     auth_info: JSON.stringify({
99                         email: `${username}@example.local`,
100                         username: username,
101                         first_name: first_name,
102                         last_name: last_name,
103                         alternate_emails: [],
104                     }),
105                     return_to: ",https://controller.api.client.invalid",
106                 },
107                 null,
108                 systemToken,
109                 true,
110                 false
111             ) // Don't follow redirects so we can catch the token
112             .its("headers.location")
113             .as("location")
114             // Get its token and set the account up as admin and/or active
115             .then(function () {
116                 this.userToken = this.location.split("=")[1];
117                 assert.isString(this.userToken);
118                 return cy
119                     .doRequest("GET", "/arvados/v1/users", null, {
120                         filters: `[["username", "=", "${username}"]]`,
121                     })
122                     .its("body.items.0")
123                     .as("aUser")
124                     .then(function () {
125                         cy.doRequest("PUT", `/arvados/v1/users/${this.aUser.uuid}`, {
126                             user: {
127                                 is_admin: is_admin,
128                                 is_active: is_active,
129                             },
130                         })
131                             .its("body")
132                             .as("theUser")
133                             .then(function () {
134                                 return { user: this.theUser, token: this.userToken };
135                             });
136                     });
137             })
138     );
139 });
140
141 Cypress.Commands.add("createLink", (token, data) => {
142     return cy.createResource(token, "links", {
143         link: JSON.stringify(data),
144     });
145 });
146
147 Cypress.Commands.add("createGroup", (token, data) => {
148     return cy.createResource(token, "groups", {
149         group: JSON.stringify(data),
150         ensure_unique_name: true,
151     });
152 });
153
154 Cypress.Commands.add("trashGroup", (token, uuid) => {
155     return cy.deleteResource(token, "groups", uuid);
156 });
157
158 Cypress.Commands.add("createWorkflow", (token, data) => {
159     return cy.createResource(token, "workflows", {
160         workflow: JSON.stringify(data),
161         ensure_unique_name: true,
162     });
163 });
164
165 Cypress.Commands.add("createCollection", (token, data, keep = false) => {
166     return cy.createResource(token, "collections", {
167         collection: JSON.stringify(data),
168         ensure_unique_name: true,
169     }, keep);
170 });
171
172 Cypress.Commands.add("getCollection", (token, uuid) => {
173     return cy.getResource(token, "collections", uuid);
174 });
175
176 Cypress.Commands.add("updateCollection", (token, uuid, data) => {
177     return cy.updateResource(token, "collections", uuid, {
178         collection: JSON.stringify(data),
179     });
180 });
181
182 Cypress.Commands.add("collectionReplaceFiles", (token, uuid, data) => {
183     return cy.updateResource(token, "collections", uuid, {
184         collection: {
185             preserve_version: true,
186         },
187         replace_files: JSON.stringify(data),
188     });
189 });
190
191 Cypress.Commands.add("getContainer", (token, uuid) => {
192     return cy.getResource(token, "containers", uuid);
193 });
194
195 Cypress.Commands.add("updateContainer", (token, uuid, data) => {
196     return cy.updateResource(token, "containers", uuid, {
197         container: JSON.stringify(data),
198     });
199 });
200
201 Cypress.Commands.add("getContainerRequest", (token, uuid) => {
202     return cy.getResource(token, "container_requests", uuid);
203 });
204
205 Cypress.Commands.add("createContainerRequest", (token, data) => {
206     return cy.createResource(token, "container_requests", {
207         container_request: JSON.stringify(data),
208         ensure_unique_name: true,
209     });
210 });
211
212 Cypress.Commands.add("updateContainerRequest", (token, uuid, data) => {
213     return cy.updateResource(token, "container_requests", uuid, {
214         container_request: JSON.stringify(data),
215     });
216 });
217
218 /**
219  * Requires an admin token for log_uuid modification to succeed
220  */
221 Cypress.Commands.add("appendLog", (token, crUuid, fileName, lines = []) =>
222     cy.getContainerRequest(token, crUuid).then(containerRequest => {
223         if (containerRequest.log_uuid) {
224             cy.listContainerRequestLogs(token, crUuid).then(logFiles => {
225                 const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`;
226                 if (logFiles.find(file => file.name === fileName)) {
227                     // File exists, fetch and append
228                     return cy
229                         .doWebDAVRequest("GET", `c=${filePath}`, null, null, token)
230                         .then(({ body: contents }) =>
231                             cy.doWebDAVRequest("PUT", `c=${filePath}`, contents.split("\n").concat(lines).join("\n"), null, token)
232                         );
233                 } else {
234                     // File not exists, put new file
235                     cy.doWebDAVRequest("PUT", `c=${filePath}`, lines.join("\n"), null, token);
236                 }
237             });
238         } else {
239             // Create log collection
240             return cy
241                 .createCollection(token, {
242                     name: `Test log collection ${Math.floor(Math.random() * 999999)}`,
243                     owner_uuid: containerRequest.owner_uuid,
244                     manifest_text: "",
245                 })
246                 .then(collection => {
247                     // Update CR log_uuid to fake log collection
248                     cy.updateContainerRequest(token, containerRequest.uuid, {
249                         log_uuid: collection.uuid,
250                     }).then(() =>
251                         // Create empty directory for container uuid
252                         cy
253                             .collectionReplaceFiles(token, collection.uuid, {
254                                 [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0",
255                             })
256                             .then(() =>
257                                 // Put new log file with contents into fake log collection
258                                 cy.doWebDAVRequest(
259                                     "PUT",
260                                     `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`,
261                                     lines.join("\n"),
262                                     null,
263                                     token
264                                 )
265                             )
266                     );
267                 });
268         }
269     })
270 );
271
272 Cypress.Commands.add("listContainerRequestLogs", (token, crUuid) =>
273     cy.getContainerRequest(token, crUuid).then(containerRequest =>
274         cy
275             .doWebDAVRequest(
276                 "PROPFIND",
277                 `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`,
278                 null,
279                 null,
280                 token
281             )
282             .then(({ body: data }) => {
283                 return extractFilesData(new DOMParser().parseFromString(data, "text/xml"));
284             })
285     )
286 );
287
288 Cypress.Commands.add("createVirtualMachine", (token, data) => {
289     return cy.createResource(token, "virtual_machines", {
290         virtual_machine: JSON.stringify(data),
291         ensure_unique_name: true,
292     });
293 });
294
295 Cypress.Commands.add("getResource", (token, suffix, uuid) => {
296     return cy
297         .doRequest("GET", `/arvados/v1/${suffix}/${uuid}`, null, {}, token)
298         .its("body")
299         .then(function (resource) {
300             return resource;
301         });
302 });
303
304 Cypress.Commands.add("createResource", (token, suffix, data, keep = false) => {
305     return cy
306         .doRequest("POST", "/arvados/v1/" + suffix, data, null, token, true)
307         .its("body")
308         .then(function (resource) {
309             if (! keep) {
310                 createdResources.push({ suffix, uuid: resource.uuid });
311             };
312             return resource;
313         });
314 });
315
316
317 Cypress.Commands.add("deleteResource", (token, suffix, uuid, failOnStatusCode = true) => {
318     return cy
319         .doRequest("DELETE", "/arvados/v1/" + suffix + "/" + uuid, null, null, token, false, true, failOnStatusCode)
320         .its("body")
321         .then(function (resource) {
322             return resource;
323         });
324 });
325
326 Cypress.Commands.add("updateResource", (token, suffix, uuid, data) => {
327     return cy
328         .doRequest("PATCH", "/arvados/v1/" + suffix + "/" + uuid, data, null, token, true)
329         .its("body")
330         .then(function (resource) {
331             return resource;
332         });
333 });
334
335 Cypress.Commands.add("loginAs", (user, preserveLocalStorage = false) => {
336     // This shouldn't be necessary unless we need to call loginAs multiple times
337     // in the same test.
338     cy.clearCookies();
339     if(preserveLocalStorage === false) {
340         cy.clearAllLocalStorage();
341         cy.clearAllSessionStorage();
342     }
343     cy.visit(`/token/?api_token=${user.token}`);
344     // Use waitUntil to avoid permafail race conditions with window.location being undefined
345     cy.waitUntil(() => cy.window().then(win =>
346         win?.location?.href &&
347         win.location.href.includes("/projects/")
348     ), { timeout: 15000 });
349     // Wait for page to settle before getting elements
350     cy.waitForDom();
351     cy.get("div#root").should("contain", "Arvados Workbench (zzzzz)");
352     cy.get("div#root").should("not.contain", "Your account is inactive");
353 });
354
355 Cypress.Commands.add("testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => {
356     cy.get(container).contains(oldName).rightclick();
357     cy.get("[data-cy=context-menu]")
358         .contains(isProject ? "Edit project" : "Edit collection")
359         .click();
360     cy.get("[data-cy=form-dialog]").within(() => {
361         cy.get("input[name=name]").clear().type(newName);
362         cy.get(isProject ? "div[contenteditable=true]" : "input[name=description]")
363             .clear()
364             .type(newDescription);
365         cy.get("[data-cy=form-submit-btn]").click();
366     });
367
368     cy.get(container).contains(newName).rightclick();
369     cy.get("[data-cy=context-menu]")
370         .contains(isProject ? "Edit project" : "Edit collection")
371         .click();
372     cy.get("[data-cy=form-dialog]").within(() => {
373         cy.get("input[name=name]").should("have.value", newName);
374
375         if (isProject) {
376             cy.get("span[data-text=true]").contains(newDescription);
377         } else {
378             cy.get("input[name=description]").should("have.value", newDescription);
379         }
380
381         cy.get("[data-cy=form-cancel-btn]").click();
382     });
383 });
384
385 Cypress.Commands.add("doSearch", searchTerm => {
386     cy.get("[data-cy=searchbar-input-field]").type(`{selectall}${searchTerm}{enter}`);
387 });
388
389 Cypress.Commands.add("goToPath", path => {
390     return cy.visit(path);
391 });
392
393 Cypress.Commands.add("getAll", (...elements) => {
394     const promise = cy.wrap([], { log: false });
395
396     for (let element of elements) {
397         promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got])));
398     }
399
400     return promise;
401 });
402
403 Cypress.Commands.add("shareWith", (srcUserToken, targetUserUUID, itemUUID, permission = "can_write") => {
404     cy.createLink(srcUserToken, {
405         name: permission,
406         link_class: "permission",
407         head_uuid: itemUUID,
408         tail_uuid: targetUserUUID,
409     });
410 });
411
412 Cypress.Commands.add("addToFavorites", (userToken, userUUID, itemUUID) => {
413     cy.createLink(userToken, {
414         head_uuid: itemUUID,
415         link_class: "star",
416         name: "",
417         owner_uuid: userUUID,
418         tail_uuid: userUUID,
419     });
420 });
421
422 Cypress.Commands.add("createProject", ({ owningUser, targetUser, projectName, canWrite, addToFavorites }) => {
423     const writePermission = canWrite ? "can_write" : "can_read";
424
425     cy.createGroup(owningUser.token, {
426         name: `${projectName} ${Math.floor(Math.random() * 999999)}`,
427         group_class: "project",
428     })
429         .as(`${projectName}`)
430         .then(project => {
431             if (targetUser && targetUser !== owningUser) {
432                 cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
433             }
434             if (addToFavorites) {
435                 const user = targetUser ? targetUser : owningUser;
436                 cy.addToFavorites(user.token, user.user.uuid, project.uuid);
437             }
438         });
439 });
440
441 Cypress.Commands.add(
442     "upload",
443     {
444         prevSubject: "element",
445     },
446     (subject, file, fileName, binaryMode = true) => {
447         cy.window().then(window => {
448             const blob = binaryMode ? b64toBlob(file, "", 512) : new Blob([file], { type: "text/plain" });
449             const testFile = new window.File([blob], fileName);
450
451             cy.wrap(subject).trigger("drop", {
452                 dataTransfer: { files: [testFile] },
453             });
454         });
455     }
456 );
457
458 function b64toBlob(b64Data, contentType = "", sliceSize = 512) {
459     const byteCharacters = atob(b64Data);
460     const byteArrays = [];
461
462     for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
463         const slice = byteCharacters.slice(offset, offset + sliceSize);
464
465         const byteNumbers = new Array(slice.length);
466         for (let i = 0; i < slice.length; i++) {
467             byteNumbers[i] = slice.charCodeAt(i);
468         }
469
470         const byteArray = new Uint8Array(byteNumbers);
471
472         byteArrays.push(byteArray);
473     }
474
475     const blob = new Blob(byteArrays, { type: contentType });
476     return blob;
477 }
478
479 // From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070=
480 // This command requires the async package (https://www.npmjs.com/package/async)
481 Cypress.Commands.add("waitForDom", () => {
482     cy.window({ timeout: 10000 }).then(
483         {
484             // Don't timeout before waitForDom finishes
485             timeout: 10000,
486         },
487         win => {
488             let timeElapsed = 0;
489
490             cy.log("Waiting for DOM mutations to complete");
491
492             return new Cypress.Promise(resolve => {
493                 // set the required variables
494                 let async = require("async");
495                 let observerConfig = { attributes: true, childList: true, subtree: true };
496                 let items = Array.apply(null, { length: 50 }).map(Number.call, Number);
497                 win.mutationCount = 0;
498                 win.previousMutationCount = null;
499
500                 // create an observer instance
501                 let observer = new win.MutationObserver(mutations => {
502                     mutations.forEach(mutation => {
503                         // Only record "attributes" type mutations that are not a "class" mutation.
504                         // If the mutation is not an "attributes" type, then we always record it.
505                         if (mutation.type === "attributes" && mutation.attributeName !== "class") {
506                             win.mutationCount += 1;
507                         } else if (mutation.type !== "attributes") {
508                             win.mutationCount += 1;
509                         }
510                     });
511
512                     // initialize the previousMutationCount
513                     if (win.previousMutationCount == null) win.previousMutationCount = 0;
514                 });
515
516                 // watch the document body for the specified mutations
517                 observer.observe(win.document.body, observerConfig);
518
519                 // check the DOM for mutations up to 50 times for a maximum time of 5 seconds
520                 async.eachSeries(
521                     items,
522                     function iteratee(item, callback) {
523                         // keep track of the elapsed time so we can log it at the end of the command
524                         timeElapsed = timeElapsed + 100;
525
526                         // make each iteration of the loop 100ms apart
527                         setTimeout(() => {
528                             if (win.mutationCount === win.previousMutationCount) {
529                                 // pass an argument to the async callback to exit the loop
530                                 return callback("Resolved - DOM changes complete.");
531                             } else if (win.previousMutationCount != null) {
532                                 // only set the previous count if the observer has checked the DOM at least once
533                                 win.previousMutationCount = win.mutationCount;
534                                 return callback();
535                             } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) {
536                                 // this is an early exit in case nothing is changing in the DOM. That way we only
537                                 // wait 500ms instead of the full 5 seconds when no DOM changes are occurring.
538                                 return callback("Resolved - Exiting early since no DOM changes were detected.");
539                             } else {
540                                 // proceed to the next iteration
541                                 return callback();
542                             }
543                         }, 100);
544                     },
545                     function done() {
546                         // Log the total wait time so users can see it
547                         cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`);
548
549                         // disconnect the observer and resolve the promise
550                         observer.disconnect();
551                         resolve();
552                     }
553                 );
554             });
555         }
556     );
557 });
558
559 Cypress.Commands.add('waitForLocalStorage', (key, options = {}) => {
560     const timeout = options.timeout || 10000;
561     const interval = options.interval || 100;
562   
563     cy.log(`Waiting for localStorage key: ${key}`)
564   
565     const checkLocalStorage = () => {
566       return new Cypress.Promise((resolve, reject) => {
567         const startTime = Date.now();
568   
569         const check = () => {
570           const value = localStorage.getItem(key);
571   
572           if (value !== null) {
573             resolve(value);
574           } else if (Date.now() - startTime > timeout) {
575             reject(new Error(`Timed out waiting for localStorage key: ${key}`));
576           } else {
577             setTimeout(check, interval);
578           }
579         };
580   
581         check();
582       });
583     };
584   
585     return cy.wrap(checkLocalStorage());
586   });
587   
588   //pauses test execution until the localStorage key changes
589   Cypress.Commands.add('waitForLocalStorageUpdate', (key, timeout = 10000) => {
590     const checkInterval = 200; // Interval to check the localStorage value
591     let previousValue = localStorage.getItem(key);
592   
593     return new Cypress.Promise((resolve, reject) => {
594       const checkValue = () => {
595         const currentValue = localStorage.getItem(key);
596         if (currentValue !== previousValue) {
597           resolve(currentValue);
598         } else if (Date.now() - startTime >= timeout) {
599           reject(new Error(`Timed out waiting for localStorage key "${key}" to change`));
600         } else {
601           setTimeout(checkValue, checkInterval);
602         }
603       };
604   
605       const startTime = Date.now();
606       checkValue();
607     });
608   });
609   
610 Cypress.Commands.add("setupDockerImage", (image_name) => {
611     // Create a collection that will be used as a docker image for the tests.
612     let activeUser;
613     let adminUser;
614
615         cy.getUser("admin", "Admin", "User", true, true)
616             .as("adminUser")
617             .then(function () {
618                 adminUser = this.adminUser;
619             });
620
621         cy.getUser('activeuser', 'Active', 'User', false, true)
622             .as('activeUser').then(function () {
623                 activeUser = this.activeUser;
624             });
625
626     cy.getAll('@activeUser', '@adminUser').then(([activeUser, adminUser]) => {
627         cy.createCollection(adminUser.token, {
628             name: "docker_image",
629             manifest_text:
630                 ". d21353cfe035e3e384563ee55eadbb2f+67108864 5c77a43e329b9838cbec18ff42790e57+55605760 0:122714624:sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678.tar\n",
631         })
632             .as("dockerImage")
633             .then(function (dockerImage) {
634                 // Give read permissions to the active user on the docker image.
635                 cy.createLink(adminUser.token, {
636                     link_class: "permission",
637                     name: "can_read",
638                     tail_uuid: activeUser.user.uuid,
639                     head_uuid: dockerImage.uuid,
640                 })
641                     .as("dockerImagePermission")
642                     .then(function () {
643                         // Set-up docker image collection tags
644                         cy.createLink(activeUser.token, {
645                             link_class: "docker_image_repo+tag",
646                             name: image_name,
647                             head_uuid: dockerImage.uuid,
648                         }).as("dockerImageRepoTag");
649                         cy.createLink(activeUser.token, {
650                             link_class: "docker_image_hash",
651                             name: "sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678",
652                             head_uuid: dockerImage.uuid,
653                         }).as("dockerImageHash");
654                     });
655             });
656     });
657     return cy.getAll("@dockerImage", "@dockerImageRepoTag", "@dockerImageHash", "@dockerImagePermission").then(function ([dockerImage]) {
658         return dockerImage;
659     });
660 });