diff options
Diffstat (limited to 'cypress/support')
-rw-r--r-- | cypress/support/commands.ts | 211 | ||||
-rw-r--r-- | cypress/support/commonUtils.ts | 80 | ||||
-rw-r--r-- | cypress/support/component-index.html | 4 | ||||
-rw-r--r-- | cypress/support/component.ts | 94 | ||||
-rw-r--r-- | cypress/support/cypress-component.d.ts | 17 | ||||
-rw-r--r-- | cypress/support/cypress-e2e.d.ts | 64 | ||||
-rw-r--r-- | cypress/support/e2e.ts | 33 | ||||
-rw-r--r-- | cypress/support/utils/assertions.ts | 40 |
8 files changed, 394 insertions, 149 deletions
diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index d214bb2b45e..ad486a8a8f7 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,77 +1,51 @@ /** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -/* eslint-disable n/no-unpublished-import */ -import axios from '@nextcloud/axios' +// eslint-disable-next-line n/no-extraneous-import +import axios from 'axios' import { addCommands, User } from '@nextcloud/cypress' import { basename } from 'path' // Add custom commands +import '@testing-library/cypress/add-commands' +import 'cypress-if' import 'cypress-wait-until' addCommands() -// Register this file's custom commands types -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable<Subject = any> { - /** - * Upload a file from the fixtures folder to a given user storage. - * **Warning**: Using this function will reset the previous session - */ - uploadFile(user: User, fixture?: string, mimeType?: string, target?: string): Cypress.Chainable<void>, - - /** - * Upload a raw content to a given user storage. - * **Warning**: Using this function will reset the previous session - */ - uploadContent(user: User, content: Blob, mimeType: string, target: string): Cypress.Chainable<void>, - - /** - * Reset the admin theming entirely. - * **Warning**: Using this function will reset the previous session - */ - resetAdminTheming(): Cypress.Chainable<void>, - - /** - * Reset the user theming settings. - * If provided, will clear session and login as the given user. - * **Warning**: Providing a user will reset the previous session. - */ - resetUserTheming(user?: User): Cypress.Chainable<void>, - - /** - * Run an occ command in the docker container. - */ - runOccCommand(command: string): Cypress.Chainable<void>, - } - } -} - const url = (Cypress.config('baseUrl') || '').replace(/\/index.php\/?$/g, '') Cypress.env('baseUrl', url) /** + * Enable or disable a user + * TODO: standardize in @nextcloud/cypress + * + * @param {User} user the user to dis- / enable + * @param {boolean} enable True if the user should be enable, false to disable + */ +Cypress.Commands.add('enableUser', (user: User, enable = true) => { + const url = `${Cypress.config('baseUrl')}/ocs/v2.php/cloud/users/${user.userId}/${enable ? 'enable' : 'disable'}`.replace('index.php/', '') + return cy.request({ + method: 'PUT', + url, + form: true, + auth: { + user: 'admin', + password: 'admin', + }, + headers: { + 'OCS-ApiRequest': 'true', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }).then((response) => { + cy.log(`Enabled user ${user}`, response.status) + return cy.wrap(response) + }) +}) + +/** * cy.uploadedFile - uploads a file from the fixtures folder - * TODO: standardise in @nextcloud/cypress + * TODO: standardize in @nextcloud/cypress * * @param {User} user the owner of the file, e.g. admin * @param {string} fixture the fixture file name, e.g. image1.jpg @@ -80,50 +54,132 @@ Cypress.env('baseUrl', url) */ Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'image/jpeg', target = `/${fixture}`) => { // get fixture - return cy.fixture(fixture, 'base64').then(async file => { - // convert the base64 string to a blob - const blob = Cypress.Blob.base64StringToBlob(file, mimeType) + return cy.fixture(fixture, 'base64') + .then((file) => ( + // convert the base64 string to a blob + Cypress.Blob.base64StringToBlob(file, mimeType) + )) + .then((blob) => cy.uploadContent(user, blob, mimeType, target)) +}) - cy.uploadContent(user, blob, mimeType, target) - }) +Cypress.Commands.add('setFileAsFavorite', (user: User, target: string, favorite = true) => { + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.clearAllCookies() + .then(async () => { + try { + const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}` + const filePath = target.split('/').map(encodeURIComponent).join('/') + const response = await axios({ + url: `${rootPath}${filePath}`, + method: 'PROPPATCH', + auth: { + username: user.userId, + password: user.password, + }, + headers: { + 'Content-Type': 'application/xml', + }, + data: `<?xml version="1.0"?> + <d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> + <d:set> + <d:prop> + <oc:favorite>${favorite ? 1 : 0}</oc:favorite> + </d:prop> + </d:set> + </d:propertyupdate>`, + }) + cy.log(`Created directory ${target}`, response) + } catch (error) { + cy.log('error', error) + throw new Error('Unable to process fixture') + } + }) +}) + +Cypress.Commands.add('mkdir', (user: User, target: string) => { + // eslint-disable-next-line cypress/unsafe-to-chain-command + return cy.clearCookies() + .then(async () => { + try { + const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}` + const filePath = target.split('/').map(encodeURIComponent).join('/') + const response = await axios({ + url: `${rootPath}${filePath}`, + method: 'MKCOL', + auth: { + username: user.userId, + password: user.password, + }, + }) + cy.log(`Created directory ${target}`, response) + return response + } catch (error) { + cy.log('error', error) + throw new Error('Unable to create directory') + } + }) +}) + +Cypress.Commands.add('rm', (user: User, target: string) => { + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.clearCookies() + .then(async () => { + try { + const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}` + const filePath = target.split('/').map(encodeURIComponent).join('/') + const response = await axios({ + url: `${rootPath}${filePath}`, + method: 'DELETE', + auth: { + username: user.userId, + password: user.password, + }, + }) + cy.log(`delete file or directory ${target}`, response) + } catch (error) { + cy.log('error', error) + throw new Error('Unable to delete file or directory') + } + }) }) /** * cy.uploadedContent - uploads a raw content - * TODO: standardise in @nextcloud/cypress + * TODO: standardize in @nextcloud/cypress * * @param {User} user the owner of the file, e.g. admin * @param {Blob} blob the content to upload * @param {string} mimeType e.g. image/png * @param {string} target the target of the file relative to the user root */ -Cypress.Commands.add('uploadContent', (user, blob, mimeType, target) => { +Cypress.Commands.add('uploadContent', (user: User, blob: Blob, mimeType: string, target: string, mtime?: number) => { cy.clearCookies() - .then(async () => { + return cy.then(async () => { const fileName = basename(target) // Process paths const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}` const filePath = target.split('/').map(encodeURIComponent).join('/') try { - const file = new File([blob], fileName, { type: mimeType }) - await axios({ + const file = new File([blob], fileName, { type: mimeType }) + const response = await axios({ url: `${rootPath}${filePath}`, method: 'PUT', data: file, headers: { 'Content-Type': mimeType, + 'X-OC-MTime': mtime ? `${mtime}` : undefined, }, auth: { username: user.userId, password: user.password, }, - }).then(response => { - cy.log(`Uploaded content as ${fileName}`, response) }) + cy.log(`Uploaded content as ${fileName}`, response) + return response } catch (error) { cy.log('error', error) - throw new Error(`Unable to process fixture`) + throw new Error('Unable to process fixture') } }) }) @@ -184,6 +240,9 @@ Cypress.Commands.add('resetUserTheming', (user?: User) => { } }) -Cypress.Commands.add('runOccCommand', (command: string) => { - cy.exec(`docker exec --user www-data nextcloud-cypress-tests-server php ./occ ${command}`) -})
\ No newline at end of file +Cypress.Commands.add('userFileExists', (user: string, path: string) => { + user.replaceAll('"', '\\"') + path.replaceAll('"', '\\"').replaceAll(/^\/+/gm, '') + return cy.runCommand(`stat --printf="%s" "data/${user}/files/${path}"`, { failOnNonZeroExit: true }) + .then((exec) => Number.parseInt(exec.stdout || '0')) +}) diff --git a/cypress/support/commonUtils.ts b/cypress/support/commonUtils.ts new file mode 100644 index 00000000000..8d02ace151b --- /dev/null +++ b/cypress/support/commonUtils.ts @@ -0,0 +1,80 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { basename } from 'path' + +/** + * Get the header navigation bar + */ +export function getNextcloudHeader() { + return cy.get('#header') +} + +/** + * Get user menu in the header navigation bar + */ +export function getNextcloudUserMenu() { + return getNextcloudHeader().find('#user-menu') +} + +/** + * Get the user menu toggle in the header navigation bar + */ +export function getNextcloudUserMenuToggle() { + return getNextcloudUserMenu().find('.header-menu__trigger').should('have.length', 1) +} + +/** + * Helper function ensure users and groups in this tests have a clean state + * Deletes all users (except admin) and groups + */ +export function clearState() { + // cleanup ignoring any failures + cy.runOccCommand('group:list --output=json').then(($result) => { + const groups = Object.keys(JSON.parse($result.stdout)).filter((name) => name !== 'admin') + groups.forEach((groupID) => cy.runOccCommand(`group:delete '${groupID}'`)) + }) + + cy.runOccCommand('user:list --output=json').then(($result) => { + const users = Object.keys(JSON.parse($result.stdout)).filter((name) => name !== 'admin') + users.forEach((userID) => cy.runOccCommand(`user:delete '${userID}'`)) + }) +} + +/** + * Install the test app + */ +export function installTestApp() { + const testAppPath = 'cypress/fixtures/testapp' + cy.runOccCommand('-V').then((output) => { + const version = output.stdout.match(/(\d\d+)\.\d+\.\d+/)?.[1] + cy.wrap(version).should('not.be.undefined') + getContainerName() + .then(containerName => { + cy.exec(`docker cp '${testAppPath}' ${containerName}:/var/www/html/apps`, { log: true }) + cy.exec(`docker exec --workdir /var/www/html ${containerName} chown -R www-data:www-data /var/www/html/apps/testapp`) + }) + cy.runCommand(`sed -i -e 's|-version=\\"[0-9]\\+|-version=\\"${version}|g' apps/testapp/appinfo/info.xml`) + cy.runOccCommand('app:enable --force testapp') + }) +} + +/** + * Remove the test app + */ +export function uninstallTestApp() { + cy.runOccCommand('app:remove testapp', { failOnNonZeroExit: false }) + cy.runCommand('rm -fr apps/testapp/appinfo/info.xml') +} + +/** + * + */ +export function getContainerName(): Cypress.Chainable<string> { + return cy.exec('pwd') + .then(({ stdout }) => { + return cy.wrap(`nextcloud-cypress-tests_${basename(stdout).replace(' ', '')}`) + }) +} diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html index ac6e79fd83d..e525b445373 100644 --- a/cypress/support/component-index.html +++ b/cypress/support/component-index.html @@ -1,4 +1,8 @@ <!DOCTYPE html> +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <html> <head> <meta charset="utf-8"> diff --git a/cypress/support/component.ts b/cypress/support/component.ts index be4b8c94b1b..853609bb4dd 100644 --- a/cypress/support/component.ts +++ b/cypress/support/component.ts @@ -1,57 +1,45 @@ /** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { mount } from 'cypress/vue2' - -// Augment the Cypress namespace to include type definitions for -// your custom command. -// Alternatively, can be defined in cypress/support/component.d.ts -// with a <reference path="./component" /> at the top of your spec. -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - mount: typeof mount - } - } -} - -// Example use: -// cy.mount(MyComponent) -Cypress.Commands.add('mount', (component, optionsOrProps) => { - let instance = null - const oldMounted = component?.mounted || false - - // Override the mounted method to expose - // the component instance to cypress - component.mounted = function() { - // eslint-disable-next-line - instance = this - if (oldMounted) { - oldMounted() - } - } - - // Expose the component with cy.get('@component') - return mount(component, optionsOrProps).then(() => { - return cy.wrap(instance).as('component') + +import '@testing-library/cypress/add-commands' +import 'cypress-axe' + +// styles +import '../../apps/theming/css/default.css' +import '../../core/css/server.css' + +/* eslint-disable */ +import { mount } from '@cypress/vue2' + +Cypress.Commands.add('mount', (component, options = {}) => { + // Setup options object + options.extensions = options.extensions || {} + options.extensions.plugins = options.extensions.plugins || [] + options.extensions.components = options.extensions.components || {} + + return mount(component, options) +}) + +Cypress.Commands.add('mockInitialState', (app: string, key: string, value: unknown) => { + cy.document().then(($document) => { + const input = $document.createElement('input') + input.setAttribute('type', 'hidden') + input.setAttribute('id', `initial-state-${app}-${key}`) + input.setAttribute('value', btoa(JSON.stringify(value))) + $document.body.appendChild(input) + }) +}) + +Cypress.Commands.add('unmockInitialState', (app?: string, key?: string) => { + cy.window().then(($window) => { + // @ts-expect-error internal value + delete $window._nc_initial_state + }) + + cy.document().then(($document) => { + $document.querySelectorAll('body > input[type="hidden"]' + (app ? `[id="initial-state-${app}-${key}"]` : '')) + .forEach((node) => $document.body.removeChild(node)) }) }) diff --git a/cypress/support/cypress-component.d.ts b/cypress/support/cypress-component.d.ts new file mode 100644 index 00000000000..735db871e35 --- /dev/null +++ b/cypress/support/cypress-component.d.ts @@ -0,0 +1,17 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { mount } from '@cypress/vue2' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + mount: typeof mount + mockInitialState: (app: string, key: string, value: unknown) => Cypress.Chainable<void> + unmockInitialState: (app?: string, key?: string) => Cypress.Chainable<void> + } + } +} diff --git a/cypress/support/cypress-e2e.d.ts b/cypress/support/cypress-e2e.d.ts new file mode 100644 index 00000000000..97385ac070b --- /dev/null +++ b/cypress/support/cypress-e2e.d.ts @@ -0,0 +1,64 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// eslint-disable-next-line n/no-extraneous-import +import type { AxiosResponse } from 'axios' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars + interface Chainable<Subject = any> { + /** + * Enable or disable a given user + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + enableUser(user: User, enable?: boolean): Cypress.Chainable<Cypress.Response<any>>, + + /** + * Upload a file from the fixtures folder to a given user storage. + * **Warning**: Using this function will reset the previous session + */ + uploadFile(user: User, fixture?: string, mimeType?: string, target?: string): Cypress.Chainable<AxiosResponse>, + + /** + * Upload a raw content to a given user storage. + * **Warning**: Using this function will reset the previous session + */ + uploadContent(user: User, content: Blob, mimeType: string, target: string, mtime?: number): Cypress.Chainable<AxiosResponse>, + + /** + * Delete a file or directory + */ + rm(user: User, target: string): Cypress.Chainable<AxiosResponse>, + + /** + * Create a new directory + * **Warning**: Using this function will reset the previous session + */ + mkdir(user: User, target: string): Cypress.Chainable<AxiosResponse>, + + /** + * Set a file as favorite (or remove from favorite) + */ + setFileAsFavorite(user: User, target: string, favorite?: boolean): Cypress.Chainable<void>, + + /** + * Reset the admin theming entirely. + * **Warning**: Using this function will reset the previous session + */ + resetAdminTheming(): Cypress.Chainable<void>, + + /** + * Reset the user theming settings. + * If provided, will clear session and login as the given user. + * **Warning**: Providing a user will reset the previous session. + */ + resetUserTheming(user?: User): Cypress.Chainable<void>, + + userFileExists(user: string, path: string): Cypress.Chainable<number> + } + } +} diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 4c1ddcc344a..65fb4b2a110 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,22 +1,15 @@ /** - * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import './commands' +import 'cypress-axe' +import './commands.ts' + +// Remove with Node 22 +// Ensure that we can use `Promise.withResolvers` - works in browser but on Node we need Node 22+ +import 'core-js/actual/promise/with-resolvers.js' + +// Fix ResizeObserver loop limit exceeded happening in Cypress only +// @see https://github.com/cypress-io/cypress/issues/20341 +Cypress.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop limit exceeded')) +Cypress.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop completed with undelivered notifications')) diff --git a/cypress/support/utils/assertions.ts b/cypress/support/utils/assertions.ts new file mode 100644 index 00000000000..08b93b32e86 --- /dev/null +++ b/cypress/support/utils/assertions.ts @@ -0,0 +1,40 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { ZipReader } from '@zip.js/zip.js' +/** + * Assert that a file contains a list of expected files + * @param expectedFiles List of expected filenames + * @example + * ```js + * cy.readFile('file', null, { ... }) + * .should(zipFileContains(['file.txt'])) + * ``` + */ +export function zipFileContains(expectedFiles: string[]) { + return async (buffer: Buffer) => { + const blob = new Blob([buffer]) + const zip = new ZipReader(blob.stream()) + // check the real file names + const entries = (await zip.getEntries()).map((e) => e.filename).sort() + console.info('Zip contains entries:', entries) + expect(entries).to.deep.equal(expectedFiles.sort()) + } +} + +/** + * Check validity of an input element + * @param validity The expected validity message (empty string means it is valid) + * @example + * ```js + * cy.findByRole('textbox') + * .should(haveValidity(/must not be empty/i)) + * ``` + */ +export const haveValidity = (validity: string | RegExp) => { + if (typeof validity === 'string') { + return (el: JQuery<HTMLElement>) => expect((el.get(0) as HTMLInputElement).validationMessage).to.equal(validity) + } + return (el: JQuery<HTMLElement>) => expect((el.get(0) as HTMLInputElement).validationMessage).to.match(validity) +} |