Merge branch '21621-io-panel-json-tab-copy' into main. Closes #21621
[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     createdResources.forEach(function ({ suffix, uuid }) {
49         // Don't fail when a resource isn't already there, some objects may have
50         // been removed, directly or indirectly, from the test that created them.
51         cy.deleteResource(systemToken, suffix, uuid, false);
52     });
53     createdResources = [];
54 });
55
56 Cypress.Commands.add(
57     "doRequest",
58     (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
59         return cy.request({
60             method: method,
61             url: `${controllerURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
62             body: data,
63             qs: auth ? qs : Object.assign({ api_token: token }, qs),
64             auth: auth ? { bearer: `${token}` } : undefined,
65             followRedirect: followRedirect,
66             failOnStatusCode: failOnStatusCode,
67         });
68     }
69 );
70
71 Cypress.Commands.add(
72     "doWebDAVRequest",
73     (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
74         return cy.doRequest("GET", "/arvados/v1/config", null, null).then(({ body: config }) => {
75             return cy.request({
76                 method: method,
77                 url: `${config.Services.WebDAVDownload.ExternalURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
78                 body: data,
79                 qs: auth ? qs : Object.assign({ api_token: token }, qs),
80                 auth: auth ? { bearer: `${token}` } : undefined,
81                 followRedirect: followRedirect,
82                 failOnStatusCode: failOnStatusCode,
83             });
84         });
85     }
86 );
87
88 Cypress.Commands.add("getUser", (username, first_name = "", last_name = "", is_admin = false, is_active = true) => {
89     // Create user if not already created
90     return (
91         cy
92             .doRequest(
93                 "POST",
94                 "/auth/controller/callback",
95                 {
96                     auth_info: JSON.stringify({
97                         email: `${username}@example.local`,
98                         username: username,
99                         first_name: first_name,
100                         last_name: last_name,
101                         alternate_emails: [],
102                     }),
103                     return_to: ",https://controller.api.client.invalid",
104                 },
105                 null,
106                 systemToken,
107                 true,
108                 false
109             ) // Don't follow redirects so we can catch the token
110             .its("headers.location")
111             .as("location")
112             // Get its token and set the account up as admin and/or active
113             .then(function () {
114                 this.userToken = this.location.split("=")[1];
115                 assert.isString(this.userToken);
116                 return cy
117                     .doRequest("GET", "/arvados/v1/users", null, {
118                         filters: `[["username", "=", "${username}"]]`,
119                     })
120                     .its("body.items.0")
121                     .as("aUser")
122                     .then(function () {
123                         cy.doRequest("PUT", `/arvados/v1/users/${this.aUser.uuid}`, {
124                             user: {
125                                 is_admin: is_admin,
126                                 is_active: is_active,
127                             },
128                         })
129                             .its("body")
130                             .as("theUser")
131                             .then(function () {
132                                 cy.doRequest("GET", "/arvados/v1/api_clients", null, {
133                                     filters: `[["is_trusted", "=", false]]`,
134                                     order: `["created_at desc"]`,
135                                 })
136                                     .its("body.items")
137                                     .as("apiClients")
138                                     .then(function () {
139                                         if (this.apiClients.length > 0) {
140                                             cy.doRequest("PUT", `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, {
141                                                 api_client: {
142                                                     is_trusted: true,
143                                                 },
144                                             })
145                                                 .its("body")
146                                                 .as("updatedApiClient")
147                                                 .then(function () {
148                                                     assert(this.updatedApiClient.is_trusted);
149                                                 });
150                                         }
151                                     })
152                                     .then(function () {
153                                         return { user: this.theUser, token: this.userToken };
154                                     });
155                             });
156                     });
157             })
158     );
159 });
160
161 Cypress.Commands.add("createLink", (token, data) => {
162     return cy.createResource(token, "links", {
163         link: JSON.stringify(data),
164     });
165 });
166
167 Cypress.Commands.add("createGroup", (token, data) => {
168     return cy.createResource(token, "groups", {
169         group: JSON.stringify(data),
170         ensure_unique_name: true,
171     });
172 });
173
174 Cypress.Commands.add("trashGroup", (token, uuid) => {
175     return cy.deleteResource(token, "groups", uuid);
176 });
177
178 Cypress.Commands.add("createWorkflow", (token, data) => {
179     return cy.createResource(token, "workflows", {
180         workflow: JSON.stringify(data),
181         ensure_unique_name: true,
182     });
183 });
184
185 Cypress.Commands.add("createCollection", (token, data, keep = false) => {
186     return cy.createResource(token, "collections", {
187         collection: JSON.stringify(data),
188         ensure_unique_name: true,
189     }, keep);
190 });
191
192 Cypress.Commands.add("getCollection", (token, uuid) => {
193     return cy.getResource(token, "collections", uuid);
194 });
195
196 Cypress.Commands.add("updateCollection", (token, uuid, data) => {
197     return cy.updateResource(token, "collections", uuid, {
198         collection: JSON.stringify(data),
199     });
200 });
201
202 Cypress.Commands.add("collectionReplaceFiles", (token, uuid, data) => {
203     return cy.updateResource(token, "collections", uuid, {
204         collection: {
205             preserve_version: true,
206         },
207         replace_files: JSON.stringify(data),
208     });
209 });
210
211 Cypress.Commands.add("getContainer", (token, uuid) => {
212     return cy.getResource(token, "containers", uuid);
213 });
214
215 Cypress.Commands.add("updateContainer", (token, uuid, data) => {
216     return cy.updateResource(token, "containers", uuid, {
217         container: JSON.stringify(data),
218     });
219 });
220
221 Cypress.Commands.add("getContainerRequest", (token, uuid) => {
222     return cy.getResource(token, "container_requests", uuid);
223 });
224
225 Cypress.Commands.add("createContainerRequest", (token, data) => {
226     return cy.createResource(token, "container_requests", {
227         container_request: JSON.stringify(data),
228         ensure_unique_name: true,
229     });
230 });
231
232 Cypress.Commands.add("updateContainerRequest", (token, uuid, data) => {
233     return cy.updateResource(token, "container_requests", uuid, {
234         container_request: JSON.stringify(data),
235     });
236 });
237
238 /**
239  * Requires an admin token for log_uuid modification to succeed
240  */
241 Cypress.Commands.add("appendLog", (token, crUuid, fileName, lines = []) =>
242     cy.getContainerRequest(token, crUuid).then(containerRequest => {
243         if (containerRequest.log_uuid) {
244             cy.listContainerRequestLogs(token, crUuid).then(logFiles => {
245                 const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`;
246                 if (logFiles.find(file => file.name === fileName)) {
247                     // File exists, fetch and append
248                     return cy
249                         .doWebDAVRequest("GET", `c=${filePath}`, null, null, token)
250                         .then(({ body: contents }) =>
251                             cy.doWebDAVRequest("PUT", `c=${filePath}`, contents.split("\n").concat(lines).join("\n"), null, token)
252                         );
253                 } else {
254                     // File not exists, put new file
255                     cy.doWebDAVRequest("PUT", `c=${filePath}`, lines.join("\n"), null, token);
256                 }
257             });
258         } else {
259             // Create log collection
260             return cy
261                 .createCollection(token, {
262                     name: `Test log collection ${Math.floor(Math.random() * 999999)}`,
263                     owner_uuid: containerRequest.owner_uuid,
264                     manifest_text: "",
265                 })
266                 .then(collection => {
267                     // Update CR log_uuid to fake log collection
268                     cy.updateContainerRequest(token, containerRequest.uuid, {
269                         log_uuid: collection.uuid,
270                     }).then(() =>
271                         // Create empty directory for container uuid
272                         cy
273                             .collectionReplaceFiles(token, collection.uuid, {
274                                 [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0",
275                             })
276                             .then(() =>
277                                 // Put new log file with contents into fake log collection
278                                 cy.doWebDAVRequest(
279                                     "PUT",
280                                     `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`,
281                                     lines.join("\n"),
282                                     null,
283                                     token
284                                 )
285                             )
286                     );
287                 });
288         }
289     })
290 );
291
292 Cypress.Commands.add("listContainerRequestLogs", (token, crUuid) =>
293     cy.getContainerRequest(token, crUuid).then(containerRequest =>
294         cy
295             .doWebDAVRequest(
296                 "PROPFIND",
297                 `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`,
298                 null,
299                 null,
300                 token
301             )
302             .then(({ body: data }) => {
303                 return extractFilesData(new DOMParser().parseFromString(data, "text/xml"));
304             })
305     )
306 );
307
308 Cypress.Commands.add("createVirtualMachine", (token, data) => {
309     return cy.createResource(token, "virtual_machines", {
310         virtual_machine: JSON.stringify(data),
311         ensure_unique_name: true,
312     });
313 });
314
315 Cypress.Commands.add("getResource", (token, suffix, uuid) => {
316     return cy
317         .doRequest("GET", `/arvados/v1/${suffix}/${uuid}`, null, {}, token)
318         .its("body")
319         .then(function (resource) {
320             return resource;
321         });
322 });
323
324 Cypress.Commands.add("createResource", (token, suffix, data, keep = false) => {
325     return cy
326         .doRequest("POST", "/arvados/v1/" + suffix, data, null, token, true)
327         .its("body")
328         .then(function (resource) {
329             if (! keep) {
330                 createdResources.push({ suffix, uuid: resource.uuid });
331             };
332             return resource;
333         });
334 });
335
336
337 Cypress.Commands.add("deleteResource", (token, suffix, uuid, failOnStatusCode = true) => {
338     return cy
339         .doRequest("DELETE", "/arvados/v1/" + suffix + "/" + uuid, null, null, token, false, true, failOnStatusCode)
340         .its("body")
341         .then(function (resource) {
342             return resource;
343         });
344 });
345
346 Cypress.Commands.add("updateResource", (token, suffix, uuid, data) => {
347     return cy
348         .doRequest("PATCH", "/arvados/v1/" + suffix + "/" + uuid, data, null, token, true)
349         .its("body")
350         .then(function (resource) {
351             return resource;
352         });
353 });
354
355 Cypress.Commands.add("loginAs", user => {
356     // This shouldn't be necessary unless we need to call loginAs multiple times
357     // in the same test.
358     cy.clearCookies();
359     cy.clearAllLocalStorage();
360     cy.clearAllSessionStorage();
361     cy.visit(`/token/?api_token=${user.token}`);
362     // Use waitUntil to avoid permafail race conditions with window.location being undefined
363     cy.waitUntil(() => cy.window().then(win =>
364         win?.location?.href &&
365         win.location.href.includes("/projects/")
366     ), { timeout: 15000 });
367     // Wait for page to settle before getting elements
368     cy.waitForDom();
369     cy.get("div#root").should("contain", "Arvados Workbench (zzzzz)");
370     cy.get("div#root").should("not.contain", "Your account is inactive");
371 });
372
373 Cypress.Commands.add("testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => {
374     cy.get(container).contains(oldName).rightclick();
375     cy.get("[data-cy=context-menu]")
376         .contains(isProject ? "Edit project" : "Edit collection")
377         .click();
378     cy.get("[data-cy=form-dialog]").within(() => {
379         cy.get("input[name=name]").clear().type(newName);
380         cy.get(isProject ? "div[contenteditable=true]" : "input[name=description]")
381             .clear()
382             .type(newDescription);
383         cy.get("[data-cy=form-submit-btn]").click();
384     });
385
386     cy.get(container).contains(newName).rightclick();
387     cy.get("[data-cy=context-menu]")
388         .contains(isProject ? "Edit project" : "Edit collection")
389         .click();
390     cy.get("[data-cy=form-dialog]").within(() => {
391         cy.get("input[name=name]").should("have.value", newName);
392
393         if (isProject) {
394             cy.get("span[data-text=true]").contains(newDescription);
395         } else {
396             cy.get("input[name=description]").should("have.value", newDescription);
397         }
398
399         cy.get("[data-cy=form-cancel-btn]").click();
400     });
401 });
402
403 Cypress.Commands.add("doSearch", searchTerm => {
404     cy.get("[data-cy=searchbar-input-field]").type(`{selectall}${searchTerm}{enter}`);
405 });
406
407 Cypress.Commands.add("goToPath", path => {
408     return cy.window().its("appHistory").invoke("push", path);
409 });
410
411 Cypress.Commands.add("getAll", (...elements) => {
412     const promise = cy.wrap([], { log: false });
413
414     for (let element of elements) {
415         promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got])));
416     }
417
418     return promise;
419 });
420
421 Cypress.Commands.add("shareWith", (srcUserToken, targetUserUUID, itemUUID, permission = "can_write") => {
422     cy.createLink(srcUserToken, {
423         name: permission,
424         link_class: "permission",
425         head_uuid: itemUUID,
426         tail_uuid: targetUserUUID,
427     });
428 });
429
430 Cypress.Commands.add("addToFavorites", (userToken, userUUID, itemUUID) => {
431     cy.createLink(userToken, {
432         head_uuid: itemUUID,
433         link_class: "star",
434         name: "",
435         owner_uuid: userUUID,
436         tail_uuid: userUUID,
437     });
438 });
439
440 Cypress.Commands.add("createProject", ({ owningUser, targetUser, projectName, canWrite, addToFavorites }) => {
441     const writePermission = canWrite ? "can_write" : "can_read";
442
443     cy.createGroup(owningUser.token, {
444         name: `${projectName} ${Math.floor(Math.random() * 999999)}`,
445         group_class: "project",
446     })
447         .as(`${projectName}`)
448         .then(project => {
449             if (targetUser && targetUser !== owningUser) {
450                 cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
451             }
452             if (addToFavorites) {
453                 const user = targetUser ? targetUser : owningUser;
454                 cy.addToFavorites(user.token, user.user.uuid, project.uuid);
455             }
456         });
457 });
458
459 Cypress.Commands.add(
460     "upload",
461     {
462         prevSubject: "element",
463     },
464     (subject, file, fileName, binaryMode = true) => {
465         cy.window().then(window => {
466             const blob = binaryMode ? b64toBlob(file, "", 512) : new Blob([file], { type: "text/plain" });
467             const testFile = new window.File([blob], fileName);
468
469             cy.wrap(subject).trigger("drop", {
470                 dataTransfer: { files: [testFile] },
471             });
472         });
473     }
474 );
475
476 function b64toBlob(b64Data, contentType = "", sliceSize = 512) {
477     const byteCharacters = atob(b64Data);
478     const byteArrays = [];
479
480     for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
481         const slice = byteCharacters.slice(offset, offset + sliceSize);
482
483         const byteNumbers = new Array(slice.length);
484         for (let i = 0; i < slice.length; i++) {
485             byteNumbers[i] = slice.charCodeAt(i);
486         }
487
488         const byteArray = new Uint8Array(byteNumbers);
489
490         byteArrays.push(byteArray);
491     }
492
493     const blob = new Blob(byteArrays, { type: contentType });
494     return blob;
495 }
496
497 // From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070=
498 // This command requires the async package (https://www.npmjs.com/package/async)
499 Cypress.Commands.add("waitForDom", () => {
500     cy.window({ timeout: 10000 }).then(
501         {
502             // Don't timeout before waitForDom finishes
503             timeout: 10000,
504         },
505         win => {
506             let timeElapsed = 0;
507
508             cy.log("Waiting for DOM mutations to complete");
509
510             return new Cypress.Promise(resolve => {
511                 // set the required variables
512                 let async = require("async");
513                 let observerConfig = { attributes: true, childList: true, subtree: true };
514                 let items = Array.apply(null, { length: 50 }).map(Number.call, Number);
515                 win.mutationCount = 0;
516                 win.previousMutationCount = null;
517
518                 // create an observer instance
519                 let observer = new win.MutationObserver(mutations => {
520                     mutations.forEach(mutation => {
521                         // Only record "attributes" type mutations that are not a "class" mutation.
522                         // If the mutation is not an "attributes" type, then we always record it.
523                         if (mutation.type === "attributes" && mutation.attributeName !== "class") {
524                             win.mutationCount += 1;
525                         } else if (mutation.type !== "attributes") {
526                             win.mutationCount += 1;
527                         }
528                     });
529
530                     // initialize the previousMutationCount
531                     if (win.previousMutationCount == null) win.previousMutationCount = 0;
532                 });
533
534                 // watch the document body for the specified mutations
535                 observer.observe(win.document.body, observerConfig);
536
537                 // check the DOM for mutations up to 50 times for a maximum time of 5 seconds
538                 async.eachSeries(
539                     items,
540                     function iteratee(item, callback) {
541                         // keep track of the elapsed time so we can log it at the end of the command
542                         timeElapsed = timeElapsed + 100;
543
544                         // make each iteration of the loop 100ms apart
545                         setTimeout(() => {
546                             if (win.mutationCount === win.previousMutationCount) {
547                                 // pass an argument to the async callback to exit the loop
548                                 return callback("Resolved - DOM changes complete.");
549                             } else if (win.previousMutationCount != null) {
550                                 // only set the previous count if the observer has checked the DOM at least once
551                                 win.previousMutationCount = win.mutationCount;
552                                 return callback();
553                             } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) {
554                                 // this is an early exit in case nothing is changing in the DOM. That way we only
555                                 // wait 500ms instead of the full 5 seconds when no DOM changes are occurring.
556                                 return callback("Resolved - Exiting early since no DOM changes were detected.");
557                             } else {
558                                 // proceed to the next iteration
559                                 return callback();
560                             }
561                         }, 100);
562                     },
563                     function done() {
564                         // Log the total wait time so users can see it
565                         cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`);
566
567                         // disconnect the observer and resolve the promise
568                         observer.disconnect();
569                         resolve();
570                     }
571                 );
572             });
573         }
574     );
575 });