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