X-Git-Url: https://git.arvados.org/arvados-workbench2.git/blobdiff_plain/08ac60f877d6495a748747a3a0d30ca9f0e289d5..26e2f985f4c2b511c59977e4c63158649f296624:/cypress/support/commands.js diff --git a/cypress/support/commands.js b/cypress/support/commands.js index a28308e3..1682a8a8 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -28,17 +28,23 @@ // -- This will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) -const controllerURL = Cypress.env('controller_url'); -const systemToken = Cypress.env('system_token'); +import { extractFilesData } from "services/collection-service/collection-service-files-response"; + +const controllerURL = Cypress.env("controller_url"); +const systemToken = Cypress.env("system_token"); let createdResources = []; -// Clean up on a 'before' hook to allow post-mortem analysis on individual tests. -beforeEach(function () { +const containerLogFolderPrefix = "log for container "; + +// Clean up anything that was created. You can temporarily add +// 'return' to the top if you need the resources to hang around to +// debug a specific test. +afterEach(function () { if (createdResources.length === 0) { return; } - cy.log(`Cleaning ${createdResources.length} previously created resource(s)`); - createdResources.forEach(function({suffix, uuid}) { + cy.log(`Cleaning ${createdResources.length} previously created resource(s).`); + createdResources.forEach(function ({ suffix, uuid }) { // Don't fail when a resource isn't already there, some objects may have // been removed, directly or indirectly, from the test that created them. cy.deleteResource(systemToken, suffix, uuid, false); @@ -47,343 +53,416 @@ beforeEach(function () { }); Cypress.Commands.add( - "doRequest", (method = 'GET', path = '', data = null, qs = null, - token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => { - return cy.request({ - method: method, - url: `${controllerURL.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`, - body: data, - qs: auth ? qs : Object.assign({ api_token: token }, qs), - auth: auth ? { bearer: `${token}` } : undefined, - followRedirect: followRedirect, - failOnStatusCode: failOnStatusCode - }); -}); + "doRequest", + (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => { + return cy.request({ + method: method, + url: `${controllerURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`, + body: data, + qs: auth ? qs : Object.assign({ api_token: token }, qs), + auth: auth ? { bearer: `${token}` } : undefined, + followRedirect: followRedirect, + failOnStatusCode: failOnStatusCode, + }); + } +); Cypress.Commands.add( - "getUser", (username, first_name = '', last_name = '', is_admin = false, is_active = true) => { - // Create user if not already created - return cy.doRequest('POST', '/auth/controller/callback', { - auth_info: JSON.stringify({ - email: `${username}@example.local`, - username: username, - first_name: first_name, - last_name: last_name, - alternate_emails: [] - }), - return_to: ',https://example.local' - }, null, systemToken, true, false) // Don't follow redirects so we can catch the token - .its('headers.location').as('location') + "doWebDAVRequest", + (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => { + return cy.doRequest("GET", "/arvados/v1/config", null, null).then(({ body: config }) => { + return cy.request({ + method: method, + url: `${config.Services.WebDAVDownload.ExternalURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`, + body: data, + qs: auth ? qs : Object.assign({ api_token: token }, qs), + auth: auth ? { bearer: `${token}` } : undefined, + followRedirect: followRedirect, + failOnStatusCode: failOnStatusCode, + }); + }); + } +); + +Cypress.Commands.add("getUser", (username, first_name = "", last_name = "", is_admin = false, is_active = true) => { + // Create user if not already created + return ( + cy + .doRequest( + "POST", + "/auth/controller/callback", + { + auth_info: JSON.stringify({ + email: `${username}@example.local`, + username: username, + first_name: first_name, + last_name: last_name, + alternate_emails: [], + }), + return_to: ",https://controller.api.client.invalid", + }, + null, + systemToken, + true, + false + ) // Don't follow redirects so we can catch the token + .its("headers.location") + .as("location") // Get its token and set the account up as admin and/or active .then(function () { - this.userToken = this.location.split("=")[1] - assert.isString(this.userToken) - return cy.doRequest('GET', '/arvados/v1/users', null, { - filters: `[["username", "=", "${username}"]]` - }) - .its('body.items.0') - .as('aUser') + this.userToken = this.location.split("=")[1]; + assert.isString(this.userToken); + return cy + .doRequest("GET", "/arvados/v1/users", null, { + filters: `[["username", "=", "${username}"]]`, + }) + .its("body.items.0") + .as("aUser") .then(function () { - cy.doRequest('PUT', `/arvados/v1/users/${this.aUser.uuid}`, { + cy.doRequest("PUT", `/arvados/v1/users/${this.aUser.uuid}`, { user: { is_admin: is_admin, - is_active: is_active - } + is_active: is_active, + }, }) - .its('body') - .as('theUser') + .its("body") + .as("theUser") .then(function () { - return { user: this.theUser, token: this.userToken }; - }) - }) + cy.doRequest("GET", "/arvados/v1/api_clients", null, { + filters: `[["is_trusted", "=", false]]`, + order: `["created_at desc"]`, + }) + .its("body.items") + .as("apiClients") + .then(function () { + if (this.apiClients.length > 0) { + cy.doRequest("PUT", `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, { + api_client: { + is_trusted: true, + }, + }) + .its("body") + .as("updatedApiClient") + .then(function () { + assert(this.updatedApiClient.is_trusted); + }); + } + }) + .then(function () { + return { user: this.theUser, token: this.userToken }; + }); + }); + }); }) - } -) + ); +}); -Cypress.Commands.add( - "createLink", (token, data) => { - return cy.createResource(token, 'links', { - link: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("createLink", (token, data) => { + return cy.createResource(token, "links", { + link: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "createGroup", (token, data) => { - return cy.createResource(token, 'groups', { - group: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("createGroup", (token, data) => { + return cy.createResource(token, "groups", { + group: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "trashGroup", (token, uuid) => { - return cy.deleteResource(token, 'groups', uuid); - } -) +Cypress.Commands.add("trashGroup", (token, uuid) => { + return cy.deleteResource(token, "groups", uuid); +}); +Cypress.Commands.add("createWorkflow", (token, data) => { + return cy.createResource(token, "workflows", { + workflow: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "createWorkflow", (token, data) => { - return cy.createResource(token, 'workflows', { - workflow: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("createCollection", (token, data) => { + return cy.createResource(token, "collections", { + collection: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "getCollection", (token, uuid) => { - return cy.getResource(token, 'collections', uuid) - } -) +Cypress.Commands.add("getCollection", (token, uuid) => { + return cy.getResource(token, "collections", uuid); +}); -Cypress.Commands.add( - "createCollection", (token, data) => { - return cy.createResource(token, 'collections', { - collection: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("updateCollection", (token, uuid, data) => { + return cy.updateResource(token, "collections", uuid, { + collection: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "updateCollection", (token, uuid, data) => { - return cy.updateResource(token, 'collections', uuid, { - collection: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("collectionReplaceFiles", (token, uuid, data) => { + return cy.updateResource(token, "collections", uuid, { + collection: { + preserve_version: true, + }, + replace_files: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "getContainer", (token, uuid) => { - return cy.getResource(token, 'containers', uuid) - } -) +Cypress.Commands.add("getContainer", (token, uuid) => { + return cy.getResource(token, "containers", uuid); +}); -Cypress.Commands.add( - "updateContainer", (token, uuid, data) => { - return cy.updateResource(token, 'containers', uuid, { - container: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("updateContainer", (token, uuid, data) => { + return cy.updateResource(token, "containers", uuid, { + container: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - 'createContainerRequest', (token, data) => { - return cy.createResource(token, 'container_requests', { - container_request: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("getContainerRequest", (token, uuid) => { + return cy.getResource(token, "container_requests", uuid); +}); -Cypress.Commands.add( - "updateContainerRequest", (token, uuid, data) => { - return cy.updateResource(token, 'container_requests', uuid, { - container_request: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("createContainerRequest", (token, data) => { + return cy.createResource(token, "container_requests", { + container_request: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "createLog", (token, data) => { - return cy.createResource(token, 'logs', { - log: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("updateContainerRequest", (token, uuid, data) => { + return cy.updateResource(token, "container_requests", uuid, { + container_request: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "logsForContainer", (token, uuid, logType, logTextArray = []) => { - let logs = []; - for (const logText of logTextArray) { - logs.push(cy.createLog(token, { - object_uuid: uuid, - event_type: logType, - properties: { - text: logText +/** + * Requires an admin token for log_uuid modification to succeed + */ +Cypress.Commands.add("appendLog", (token, crUuid, fileName, lines = []) => + cy.getContainerRequest(token, crUuid).then(containerRequest => { + if (containerRequest.log_uuid) { + cy.listContainerRequestLogs(token, crUuid).then(logFiles => { + const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`; + if (logFiles.find(file => file.name === fileName)) { + // File exists, fetch and append + return cy + .doWebDAVRequest("GET", `c=${filePath}`, null, null, token) + .then(({ body: contents }) => + cy.doWebDAVRequest("PUT", `c=${filePath}`, contents.split("\n").concat(lines).join("\n"), null, token) + ); + } else { + // File not exists, put new file + cy.doWebDAVRequest("PUT", `c=${filePath}`, lines.join("\n"), null, token); } - }).as('lastLogRecord')) + }); + } else { + // Create log collection + return cy + .createCollection(token, { + name: `Test log collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: containerRequest.owner_uuid, + manifest_text: "", + }) + .then(collection => { + // Update CR log_uuid to fake log collection + cy.updateContainerRequest(token, containerRequest.uuid, { + log_uuid: collection.uuid, + }).then(() => + // Create empty directory for container uuid + cy + .collectionReplaceFiles(token, collection.uuid, { + [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0", + }) + .then(() => + // Put new log file with contents into fake log collection + cy.doWebDAVRequest( + "PUT", + `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`, + lines.join("\n"), + null, + token + ) + ) + ); + }); } - cy.getAll('@lastLogRecord').then(function () { - return logs; - }) - } -) - -Cypress.Commands.add( - "createVirtualMachine", (token, data) => { - return cy.createResource(token, 'virtual_machines', { - virtual_machine: JSON.stringify(data), - ensure_unique_name: true - }) - } -) - -Cypress.Commands.add( - "getResource", (token, suffix, uuid) => { - return cy.doRequest('GET', `/arvados/v1/${suffix}/${uuid}`, null, {}, token) - .its('body') - .then(function (resource) { - return resource; + }) +); + +Cypress.Commands.add("listContainerRequestLogs", (token, crUuid) => + cy.getContainerRequest(token, crUuid).then(containerRequest => + cy + .doWebDAVRequest( + "PROPFIND", + `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`, + null, + null, + token + ) + .then(({ body: data }) => { + return extractFilesData(new DOMParser().parseFromString(data, "text/xml")); }) - } -) + ) +); -Cypress.Commands.add( - "createResource", (token, suffix, data) => { - return cy.doRequest('POST', '/arvados/v1/' + suffix, data, null, token, true) - .its('body') - .then(function (resource) { - createdResources.push({suffix, uuid: resource.uuid}); - return resource; - }) - } -) +Cypress.Commands.add("createVirtualMachine", (token, data) => { + return cy.createResource(token, "virtual_machines", { + virtual_machine: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "deleteResource", (token, suffix, uuid, failOnStatusCode = true) => { - return cy.doRequest('DELETE', '/arvados/v1/' + suffix + '/' + uuid, null, null, token, false, true, failOnStatusCode) - .its('body') - .then(function (resource) { - return resource; - }) - } -) +Cypress.Commands.add("getResource", (token, suffix, uuid) => { + return cy + .doRequest("GET", `/arvados/v1/${suffix}/${uuid}`, null, {}, token) + .its("body") + .then(function (resource) { + return resource; + }); +}); -Cypress.Commands.add( - "updateResource", (token, suffix, uuid, data) => { - return cy.doRequest('PATCH', '/arvados/v1/' + suffix + '/' + uuid, data, null, token, true) - .its('body') - .then(function (resource) { - return resource; - }) - } -) +Cypress.Commands.add("createResource", (token, suffix, data) => { + return cy + .doRequest("POST", "/arvados/v1/" + suffix, data, null, token, true) + .its("body") + .then(function (resource) { + createdResources.push({ suffix, uuid: resource.uuid }); + return resource; + }); +}); -Cypress.Commands.add( - "loginAs", (user) => { - cy.clearCookies() - cy.clearLocalStorage() - cy.visit(`/token/?api_token=${user.token}`); - cy.url({timeout: 10000}).should('contain', '/projects/'); - cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)'); - cy.get('div#root').should('not.contain', 'Your account is inactive'); - } -) +Cypress.Commands.add("deleteResource", (token, suffix, uuid, failOnStatusCode = true) => { + return cy + .doRequest("DELETE", "/arvados/v1/" + suffix + "/" + uuid, null, null, token, false, true, failOnStatusCode) + .its("body") + .then(function (resource) { + return resource; + }); +}); -Cypress.Commands.add( - "testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => { - cy.get(container).contains(oldName).rightclick(); - cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('input[name=name]').clear().type(newName); - cy.get(isProject ? 'div[contenteditable=true]' : 'input[name=description]').clear().type(newDescription); - cy.get('[data-cy=form-submit-btn]').click(); +Cypress.Commands.add("updateResource", (token, suffix, uuid, data) => { + return cy + .doRequest("PATCH", "/arvados/v1/" + suffix + "/" + uuid, data, null, token, true) + .its("body") + .then(function (resource) { + return resource; }); +}); - cy.get(container).contains(newName).rightclick(); - cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('input[name=name]').should('have.value', newName); +Cypress.Commands.add("loginAs", user => { + cy.clearCookies(); + cy.clearLocalStorage(); + cy.visit(`/token/?api_token=${user.token}`); + cy.url({ timeout: 10000 }).should("contain", "/projects/"); + cy.get("div#root").should("contain", "Arvados Workbench (zzzzz)"); + cy.get("div#root").should("not.contain", "Your account is inactive"); +}); - if (isProject) { - cy.get('span[data-text=true]').contains(newDescription); - } else { - cy.get('input[name=description]').should('have.value', newDescription); - } +Cypress.Commands.add("testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => { + cy.get(container).contains(oldName).rightclick(); + cy.get("[data-cy=context-menu]") + .contains(isProject ? "Edit project" : "Edit collection") + .click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("input[name=name]").clear().type(newName); + cy.get(isProject ? "div[contenteditable=true]" : "input[name=description]") + .clear() + .type(newDescription); + cy.get("[data-cy=form-submit-btn]").click(); + }); - cy.get('[data-cy=form-cancel-btn]').click(); - }); - } -) + cy.get(container).contains(newName).rightclick(); + cy.get("[data-cy=context-menu]") + .contains(isProject ? "Edit project" : "Edit collection") + .click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("input[name=name]").should("have.value", newName); + + if (isProject) { + cy.get("span[data-text=true]").contains(newDescription); + } else { + cy.get("input[name=description]").should("have.value", newDescription); + } -Cypress.Commands.add( - "doSearch", (searchTerm) => { - cy.get('[data-cy=searchbar-input-field]').type(`{selectall}${searchTerm}{enter}`); - } -) + cy.get("[data-cy=form-cancel-btn]").click(); + }); +}); -Cypress.Commands.add( - "goToPath", (path) => { - return cy.window().its('appHistory').invoke('push', path); - } -) +Cypress.Commands.add("doSearch", searchTerm => { + cy.get("[data-cy=searchbar-input-field]").type(`{selectall}${searchTerm}{enter}`); +}); + +Cypress.Commands.add("goToPath", path => { + return cy.window().its("appHistory").invoke("push", path); +}); -Cypress.Commands.add('getAll', (...elements) => { - const promise = cy.wrap([], { log: false }) +Cypress.Commands.add("getAll", (...elements) => { + const promise = cy.wrap([], { log: false }); for (let element of elements) { - promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got]))) + promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got]))); } - return promise -}) + return promise; +}); -Cypress.Commands.add('shareWith', (srcUserToken, targetUserUUID, itemUUID, permission = 'can_write') => { +Cypress.Commands.add("shareWith", (srcUserToken, targetUserUUID, itemUUID, permission = "can_write") => { cy.createLink(srcUserToken, { name: permission, - link_class: 'permission', + link_class: "permission", head_uuid: itemUUID, - tail_uuid: targetUserUUID + tail_uuid: targetUserUUID, }); -}) +}); -Cypress.Commands.add('addToFavorites', (userToken, userUUID, itemUUID) => { +Cypress.Commands.add("addToFavorites", (userToken, userUUID, itemUUID) => { cy.createLink(userToken, { head_uuid: itemUUID, - link_class: 'star', - name: '', + link_class: "star", + name: "", owner_uuid: userUUID, tail_uuid: userUUID, }); -}) +}); -Cypress.Commands.add('createProject', ({ - owningUser, - targetUser, - projectName, - canWrite, - addToFavorites -}) => { - const writePermission = canWrite ? 'can_write' : 'can_read'; +Cypress.Commands.add("createProject", ({ owningUser, targetUser, projectName, canWrite, addToFavorites }) => { + const writePermission = canWrite ? "can_write" : "can_read"; cy.createGroup(owningUser.token, { name: `${projectName} ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as(`${projectName}`).then((project) => { - if (targetUser && targetUser !== owningUser) { - cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission); - } - if (addToFavorites) { - const user = targetUser ? targetUser : owningUser; - cy.addToFavorites(user.token, user.user.uuid, project.uuid); - } - }); + group_class: "project", + }) + .as(`${projectName}`) + .then(project => { + if (targetUser && targetUser !== owningUser) { + cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission); + } + if (addToFavorites) { + const user = targetUser ? targetUser : owningUser; + cy.addToFavorites(user.token, user.user.uuid, project.uuid); + } + }); }); Cypress.Commands.add( - 'upload', + "upload", { - prevSubject: 'element', + prevSubject: "element", }, - (subject, file, fileName) => { + (subject, file, fileName, binaryMode = true) => { cy.window().then(window => { - const blob = b64toBlob(file, '', 512); + const blob = binaryMode ? b64toBlob(file, "", 512) : new Blob([file], { type: "text/plain" }); const testFile = new window.File([blob], fileName); - cy.wrap(subject).trigger('drop', { + cy.wrap(subject).trigger("drop", { dataTransfer: { files: [testFile] }, }); - }) + }); } -) +); -function b64toBlob(b64Data, contentType = '', sliceSize = 512) { - const byteCharacters = atob(b64Data) - const byteArrays = [] +function b64toBlob(b64Data, contentType = "", sliceSize = 512) { + const byteCharacters = atob(b64Data); + const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { const slice = byteCharacters.slice(offset, offset + sliceSize); @@ -399,5 +478,85 @@ function b64toBlob(b64Data, contentType = '', sliceSize = 512) { } const blob = new Blob(byteArrays, { type: contentType }); - return blob + return blob; } + +// From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070= +// This command requires the async package (https://www.npmjs.com/package/async) +Cypress.Commands.add("waitForDom", () => { + cy.window().then( + { + // Don't timeout before waitForDom finishes + timeout: 10000, + }, + win => { + let timeElapsed = 0; + + cy.log("Waiting for DOM mutations to complete"); + + return new Cypress.Promise(resolve => { + // set the required variables + let async = require("async"); + let observerConfig = { attributes: true, childList: true, subtree: true }; + let items = Array.apply(null, { length: 50 }).map(Number.call, Number); + win.mutationCount = 0; + win.previousMutationCount = null; + + // create an observer instance + let observer = new win.MutationObserver(mutations => { + mutations.forEach(mutation => { + // Only record "attributes" type mutations that are not a "class" mutation. + // If the mutation is not an "attributes" type, then we always record it. + if (mutation.type === "attributes" && mutation.attributeName !== "class") { + win.mutationCount += 1; + } else if (mutation.type !== "attributes") { + win.mutationCount += 1; + } + }); + + // initialize the previousMutationCount + if (win.previousMutationCount == null) win.previousMutationCount = 0; + }); + + // watch the document body for the specified mutations + observer.observe(win.document.body, observerConfig); + + // check the DOM for mutations up to 50 times for a maximum time of 5 seconds + async.eachSeries( + items, + function iteratee(item, callback) { + // keep track of the elapsed time so we can log it at the end of the command + timeElapsed = timeElapsed + 100; + + // make each iteration of the loop 100ms apart + setTimeout(() => { + if (win.mutationCount === win.previousMutationCount) { + // pass an argument to the async callback to exit the loop + return callback("Resolved - DOM changes complete."); + } else if (win.previousMutationCount != null) { + // only set the previous count if the observer has checked the DOM at least once + win.previousMutationCount = win.mutationCount; + return callback(); + } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) { + // this is an early exit in case nothing is changing in the DOM. That way we only + // wait 500ms instead of the full 5 seconds when no DOM changes are occurring. + return callback("Resolved - Exiting early since no DOM changes were detected."); + } else { + // proceed to the next iteration + return callback(); + } + }, 100); + }, + function done() { + // Log the total wait time so users can see it + cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`); + + // disconnect the observer and resolve the promise + observer.disconnect(); + resolve(); + } + ); + }); + } + ); +});