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