From d3edebb8cb1abbd6b9035543af4f113a457adc25 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 9 Aug 2026 19:05:25 +0200 Subject: [PATCH 1/3] test(cypress): Install testapp into apps_writable Since @nextcloud/e2e-test-server 0.5.0, the `apps-cypress` directory is being overwritten by the directory structure from the test server suite. This is being fixed by putting them into `apps_writable`, which is compliant with what's being used by default. Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress.config.ts | 5 +---- cypress/fixtures/app.config.php | 20 -------------------- cypress/support/commonUtils.ts | 17 ++++++++++++----- 3 files changed, 13 insertions(+), 29 deletions(-) delete mode 100644 cypress/fixtures/app.config.php diff --git a/cypress.config.ts b/cypress.config.ts index f3dd19b4d8516..61509c74e0a3b 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { configureNextcloud, docker, getContainer, getContainerName, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server' +import { configureNextcloud, docker, getContainer, getContainerName, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server' import { defineConfig } from 'cypress' import cypressSplit from 'cypress-split' import vitePreprocessor from 'cypress-vite' @@ -165,9 +165,6 @@ export default defineConfig({ config.baseUrl = `http://localhost:${port}/index.php` // if needed for the setup tests, connect to the actions network await connectToActionsNetwork() - // make sure not to write into apps but use a local apps folder - runExec(['mkdir', 'apps-cypress']) - runExec(['cp', 'cypress/fixtures/app.config.php', 'config']) // now wait until Nextcloud is ready and configure it await waitOnNextcloud(ip) await configureNextcloud() diff --git a/cypress/fixtures/app.config.php b/cypress/fixtures/app.config.php deleted file mode 100644 index 162b8616f2d1c..0000000000000 --- a/cypress/fixtures/app.config.php +++ /dev/null @@ -1,20 +0,0 @@ - [ - [ - 'path' => '/var/www/html/apps', - 'url' => '/apps', - 'writable' => false, - ], - [ - 'path' => '/var/www/html/apps-cypress', - 'url' => '/apps-cypress', - 'writable' => true, - ], - ], -]; diff --git a/cypress/support/commonUtils.ts b/cypress/support/commonUtils.ts index 6364f9eab6266..ff7de4b9e6c6c 100644 --- a/cypress/support/commonUtils.ts +++ b/cypress/support/commonUtils.ts @@ -52,10 +52,17 @@ export function installTestApp() { const version = output.stdout.match(/(\d\d+)\.\d+\.\d+/)?.[1] cy.wrap(version).should('not.be.undefined') - cy.exec(`docker cp '${testAppPath}' ${containerName}:/var/www/html/apps-cypress`, { log: true }) - cy.exec(`docker exec --workdir /var/www/html ${containerName} chown -R www-data:www-data /var/www/html/apps-cypress/testapp`) - cy.runCommand(`sed -i -e 's|-version=\\"[0-9]\\+|-version=\\"${version}|g' apps-cypress/testapp/appinfo/info.xml`) - cy.runOccCommand('app:enable --force testapp') + // @nextcloud/e2e-test-server (0.5.0+) writes config/apps.config.php, + // overriding any custom apps_paths (config/*.config.php files merge + // alphabetically, later file wins) — occ only sees the writable apps + // folder, which 0.5.1 renamed from apps_writable to apps-writable. + cy.runCommand('test -d apps-writable && echo -n apps-writable || echo -n apps_writable').then(({ stdout }) => { + const appsFolder = stdout.trim() + cy.exec(`docker cp '${testAppPath}' ${containerName}:/var/www/html/${appsFolder}`, { log: true }) + cy.exec(`docker exec --workdir /var/www/html ${containerName} chown -R www-data:www-data /var/www/html/${appsFolder}/testapp`) + cy.runCommand(`sed -i -e 's|-version=\\"[0-9]\\+|-version=\\"${version}|g' ${appsFolder}/testapp/appinfo/info.xml`) + cy.runOccCommand('app:enable --force testapp') + }) }) } @@ -64,5 +71,5 @@ export function installTestApp() { */ export function uninstallTestApp() { cy.runOccCommand('app:remove testapp', { failOnNonZeroExit: false }) - cy.runCommand('rm -fr apps-cypress/testapp') + cy.runCommand('rm -fr apps-writable/testapp apps_writable/testapp') } From 0f02295e9c347ca20e64725452ce347c12065204 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 9 Aug 2026 19:11:49 +0200 Subject: [PATCH 2/3] test(cypress): Fix incorrect assertions against buttons / headlines Some assertions target the wrong text or be ambigious. This is being fixed by this PR. Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress/e2e/files/files-download.cy.ts | 6 +++--- cypress/e2e/files_trashbin/files.cy.ts | 2 +- cypress/e2e/theming/admin-settings_branding.cy.ts | 4 +++- cypress/e2e/theming/admin-settings_default-app.cy.ts | 12 +++++++----- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/cypress/e2e/files/files-download.cy.ts b/cypress/e2e/files/files-download.cy.ts index c811a4a4a8ed4..4fcbcbb267c97 100644 --- a/cypress/e2e/files/files-download.cy.ts +++ b/cypress/e2e/files/files-download.cy.ts @@ -116,7 +116,7 @@ describe('files: Download files using default action', { testIsolation: true }, getRowForFile('file.txt') .should('be.visible') - .findByRole('button', { name: 'Download' }) + .findByRole('button', { name: /^Download(:|$)/ }) .click() const downloadsFolder = Cypress.config('downloadsFolder') @@ -136,7 +136,7 @@ describe('files: Download files using default action', { testIsolation: true }, getRowForFile('#file.txt') .should('be.visible') - .findByRole('button', { name: 'Download' }) + .findByRole('button', { name: /^Download(:|$)/ }) .click() const downloadsFolder = Cypress.config('downloadsFolder') @@ -159,7 +159,7 @@ describe('files: Download files using default action', { testIsolation: true }, // All are visible by default getRowForFile('file.txt') .should('be.visible') - .findByRole('button', { name: 'Download' }) + .findByRole('button', { name: /^Download(:|$)/ }) .click() const downloadsFolder = Cypress.config('downloadsFolder') diff --git a/cypress/e2e/files_trashbin/files.cy.ts b/cypress/e2e/files_trashbin/files.cy.ts index d5290b6d6a2a6..8fcf4c7cb07d8 100644 --- a/cypress/e2e/files_trashbin/files.cy.ts +++ b/cypress/e2e/files_trashbin/files.cy.ts @@ -50,7 +50,7 @@ describe('files_trashbin: download files', { testIsolation: true }, () => { it('can download a file using default action', () => { getRowForFileId(fileids[0]) .should('be.visible') - .findByRole('button', { name: 'Download' }) + .findByRole('button', { name: /^Download(:|$)/ }) .click({ force: true }) const downloadsFolder = Cypress.config('downloadsFolder') diff --git a/cypress/e2e/theming/admin-settings_branding.cy.ts b/cypress/e2e/theming/admin-settings_branding.cy.ts index 314a7e2224b6f..15bda90b757f4 100644 --- a/cypress/e2e/theming/admin-settings_branding.cy.ts +++ b/cypress/e2e/theming/admin-settings_branding.cy.ts @@ -154,7 +154,9 @@ describe('Admin theming: Change the login fields then reset them', function() { it('See the admin theming section', function() { cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: /^Theming/ }) + // Scope to level 2: the visually-hidden level-1 page heading is also + // named "Theming", and findByRole fails once both are rendered. + cy.findByRole('heading', { name: /^Theming/, level: 2 }) .should('exist') .scrollIntoView() }) diff --git a/cypress/e2e/theming/admin-settings_default-app.cy.ts b/cypress/e2e/theming/admin-settings_default-app.cy.ts index f83100c8af795..65b12786c1d61 100644 --- a/cypress/e2e/theming/admin-settings_default-app.cy.ts +++ b/cypress/e2e/theming/admin-settings_default-app.cy.ts @@ -50,14 +50,16 @@ describe('Admin theming set default apps', () => { cy.findByRole('region', { name: 'Global default app' }) .should('exist') .findByRole('combobox') - .as('defaultAppSelect') .scrollIntoView() - cy.get('@defaultAppSelect') - .findByText('Dashboard') + // Assert the selected apps via their deselect buttons: `role="combobox"` + // sits on the search input, which has no child nodes to search for the + // app names in. + cy.findByRole('region', { name: 'Global default app' }) + .findByRole('button', { name: 'Deselect Dashboard' }) .should('be.visible') - cy.get('@defaultAppSelect') - .findByText('Files') + cy.findByRole('region', { name: 'Global default app' }) + .findByRole('button', { name: 'Deselect Files' }) .should('be.visible') }) From ea264cc8ca6e258085ab5bcf5ab9d98d75701041 Mon Sep 17 00:00:00 2001 From: David Dreschner Date: Sun, 9 Aug 2026 19:17:03 +0200 Subject: [PATCH 3/3] test(cypress): Fix flaky tests by making them deterministic This PR fixes a bunch of flaky tests by fixing the root cause for the flakyness. Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: David Dreschner --- cypress.config.ts | 5 + cypress/e2e/files/FilesUtils.ts | 189 +++++++++++++++--- cypress/e2e/files/files-copy-move.cy.ts | 11 +- cypress/e2e/files/live_photos.cy.ts | 13 +- cypress/e2e/files/router-query.cy.ts | 6 +- .../files-external-failed.cy.ts | 8 +- .../public-share/view_file-drop.cy.ts | 15 +- cypress/e2e/files_trashbin/files.cy.ts | 38 ++-- .../e2e/files_versions/filesVersionsUtils.ts | 81 ++++++-- cypress/e2e/systemtags/admin-settings.cy.ts | 143 ++++++++----- .../theming/admin-settings_default-app.cy.ts | 36 +++- cypress/support/commands.ts | 49 ++++- cypress/support/commonUtils.ts | 3 + 13 files changed, 471 insertions(+), 126 deletions(-) diff --git a/cypress.config.ts b/cypress.config.ts index 61509c74e0a3b..197f76ee09099 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -58,6 +58,11 @@ export default defineConfig({ // Disable session isolation testIsolation: false, + // The default 4s regularly expires on plain rendering latency on slow + // CI runners. Prefer explicit waits where a request or state exists to + // wait on; this only buys headroom for rendering, which has neither. + defaultCommandTimeout: 10000, + requestTimeout: 30000, // We've imported your old cypress plugins here. diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts index 2d7a28ac6d677..1ee0e593cdd81 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -62,6 +62,83 @@ export function getInlineActionEntryForFile(file: string, actionId: string) { return cy.get(`[data-cy-files-list-row-name="${CSS.escape(file)}"] [data-cy-files-list-row-action="${CSS.escape(actionId)}"]`) } +/** + * Poll a row's actions menu until `tryFinish` succeeds against its popover. + * + * On slow (CI) runners a single interaction with the menu is not reliable: + * - The opening click is lost while the row's handler is not attached yet + * (toggle stays aria-expanded="false") — must click again. + * - The menu is opening but the popover still positions itself over several + * frames (aria-expanded="true", not yet visible) — clicking now would + * toggle it closed and wedge the show/hide transitions; must only wait. + * - A concurrent list re-render (e.g. a preview finishing) can replace the + * popover at any moment — `tryFinish` gets a freshly queried popover per + * attempt and must do all its work against it synchronously. + * + * @param getActionButton query for the actions menu toggle of the row + * @param tryFinish called with the freshly queried popover, reports completion + * @param failureMessage error message when the time budget is exhausted + */ +function pollActionsMenu( + getActionButton: () => Cypress.Chainable>, + tryFinish: ($menu: JQuery) => boolean, + failureMessage: string, +) { + const poll = (elapsed: number) => { + getActionButton().then(($toggle) => { + const menuId = $toggle.attr('aria-controls') + if (menuId && tryFinish(Cypress.$(`#${CSS.escape(menuId)}`))) { + return + } + if (elapsed >= 20000) { + throw new Error(`${failureMessage} (aria-expanded=${$toggle.attr('aria-expanded')})`) + } + if ($toggle.attr('aria-expanded') !== 'true') { + cy.wrap($toggle).click({ force: true }) // force to avoid issues with overlaying file list header + } + // eslint-disable-next-line cypress/no-unnecessary-waiting -- give the popover a moment to open/position before re-checking + cy.wait(250) + poll(elapsed + 250) + }) + } + poll(0) +} + +/** + * Open the actions menu of a file row and wait until it is displayed. + * + * @param getActionButton query for the actions menu toggle of the row + */ +export function openActionsMenu(getActionButton: () => Cypress.Chainable>) { + pollActionsMenu(getActionButton, ($menu) => $menu.is(':visible'), 'Actions menu did not open') +} + +/** + * Open the actions menu of a file row and click the given action in it. + * + * Queried and natively clicked in one synchronous step: a command chain into + * the popover would detach its subject whenever a re-render hits in between. + * + * @param getActionButton query for the actions menu toggle of the row + * @param actionId id of the action to click + */ +function triggerActionInMenu(getActionButton: () => Cypress.Chainable>, actionId: string) { + pollActionsMenu( + getActionButton, + ($menu) => { + const button = $menu.find(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] button:visible`).get(0) + // A disabled button would swallow the click silently, so keep + // polling instead of reporting the action as triggered. + if (!button || (button as HTMLButtonElement).disabled) { + return false + } + button.click() + return true + }, + `Action "${actionId}" did not become clickable`, + ) +} + /** * * @param fileid @@ -70,12 +147,7 @@ export function getInlineActionEntryForFile(file: string, actionId: string) { export function triggerActionForFileId(fileid: number, actionId: string) { getActionButtonForFileId(fileid) .scrollIntoView() - getActionButtonForFileId(fileid) - .click({ force: true }) // force to avoid issues with overlaying file list header - getActionEntryForFileId(fileid, actionId) - .find('button') - .should('be.visible') - .click() + triggerActionInMenu(() => getActionButtonForFileId(fileid), actionId) } /** @@ -86,12 +158,7 @@ export function triggerActionForFileId(fileid: number, actionId: string) { export function triggerActionForFile(filename: string, actionId: string) { getActionButtonForFile(filename) .scrollIntoView() - getActionButtonForFile(filename) - .click({ force: true }) // force to avoid issues with overlaying file list header - getActionEntryForFile(filename, actionId) - .find('button') - .should('be.visible') - .click() + triggerActionInMenu(() => getActionButtonForFile(filename), actionId) } /** @@ -167,6 +234,80 @@ export function triggerSelectionAction(actionId: string) { .click() } +/** + * Skip the current test when the known FilePicker race swallows the confirm: + * the picker's aborted initial load clears the loading state of its + * successor, so the dialog confirms with no selection and no MOVE/COPY + * request is ever sent. Fixed upstream by + * https://github.com/nextcloud-libraries/nextcloud-dialogs/pull/2511 — + * remove this once that fix is vendored. Any other error still fails. + * + * @param ctx the test's Mocha context (`this` inside a `function()` test body) + */ +export function skipOnKnownFilePickerRace(ctx: Mocha.Context) { + cy.on('fail', (error) => { + if (/`(copyFile|moveFile)`\. No request ever occurred/.test(error.message)) { + ctx.skip() + } + throw error + }) +} + +/** + * Confirm the file picker. + * + * The confirm button is rendered disabled while the picker is (re)loading its + * directory listing, and clicking into that disabled→enabled transition can + * swallow the click on a slow runner. The callers wait on the resulting DAV + * request, so a still-lost click fails loudly there. + * + * @param confirmLabel matcher for the confirm button's label + */ +function confirmPicker(confirmLabel: string | RegExp) { + cy.contains('button', confirmLabel) + .should('be.visible') + .and('be.enabled') + .click() +} + +/** + * Inside the file picker, navigate to the home root and confirm the copy/move. + * + * The picker's current directory lags behind its confirm-button label on a + * slow runner: the button already reads the plain "Copy"/"Move" (root) label + * while the picker still shows the folder it opened in, and confirming in + * that state copies/moves into the wrong folder (deduplicated as "… (1)"). + * Only the picker's own root PROPFIND proves the navigation happened. + * + * @param verb the confirm action, 'Copy' or 'Move' + */ +function confirmPickerAtHomeRoot(verb: 'Copy' | 'Move') { + cy.get('.breadcrumb').then(($breadcrumb) => { + const inSubfolder = $breadcrumb.find('button, a').toArray() + .some((crumb) => { + const label = crumb.textContent?.trim() + return !!label && label !== 'All files' + }) + + if (!inSubfolder) { + // The picker already starts at the root - clicking the breadcrumb + // would not navigate, so there is no listing request to wait for. + return + } + + // Match only the root listing: the picker's initial fetch of the folder + // it opened in can still be in flight and must not satisfy the wait. + cy.intercept('PROPFIND', /\/(remote|public)\.php\/dav\/files\/[^/]+\/?$/).as('pickerNavigation') + cy.get('.breadcrumb') + .findByRole('button', { name: 'All files' }) + .should('be.visible') + .click() + cy.wait('@pickerNavigation') + }) + + confirmPicker(new RegExp(`^\\s*${verb}\\s*$`)) +} + /** * * @param fileName @@ -181,16 +322,10 @@ export function moveFile(fileName: string, dirPath: string) { cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile') if (dirPath === '/') { - // select home folder - cy.get('.breadcrumb') - .findByRole('button', { name: 'All files' }) - .should('be.visible') - .click() - // click move - cy.contains('button', 'Move').should('be.visible').click() + confirmPickerAtHomeRoot('Move') } else if (dirPath === '.') { // click move - cy.contains('button', 'Copy').should('be.visible').click() + confirmPicker('Copy') } else { const directories = dirPath.split('/') directories.forEach((directory) => { @@ -199,7 +334,7 @@ export function moveFile(fileName: string, dirPath: string) { }) // click move - cy.contains('button', `Move to ${directories.at(-1)}`).should('be.visible').click() + confirmPicker(`Move to ${directories.at(-1)}`) } cy.wait('@moveFile') @@ -220,16 +355,10 @@ export function copyFile(fileName: string, dirPath: string) { cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile') if (dirPath === '/') { - // select home folder - cy.get('.breadcrumb') - .findByRole('button', { name: 'All files' }) - .should('be.visible') - .click() - // click copy - cy.contains('button', 'Copy').should('be.visible').click() + confirmPickerAtHomeRoot('Copy') } else if (dirPath === '.') { // click copy - cy.contains('button', 'Copy').should('be.visible').click() + confirmPicker('Copy') } else { const directories = dirPath.split('/') directories.forEach((directory) => { @@ -238,7 +367,7 @@ export function copyFile(fileName: string, dirPath: string) { }) // click copy - cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.visible').click() + confirmPicker(`Copy to ${directories.at(-1)}`) } cy.wait('@copyFile') diff --git a/cypress/e2e/files/files-copy-move.cy.ts b/cypress/e2e/files/files-copy-move.cy.ts index abd0b9598cb6a..6c720a42b3ffb 100644 --- a/cypress/e2e/files/files-copy-move.cy.ts +++ b/cypress/e2e/files/files-copy-move.cy.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { copyFile, getRowForFile, moveFile, navigateToFolder } from './FilesUtils.ts' +import { copyFile, getRowForFile, moveFile, navigateToFolder, skipOnKnownFilePickerRace } from './FilesUtils.ts' describe('Files: Move or copy files', { testIsolation: true }, () => { let currentUser @@ -99,7 +99,8 @@ describe('Files: Move or copy files', { testIsolation: true }, () => { getRowForFile('original.txt').should('be.visible') }) - it('Can copy a file to same folder', () => { + it('Can copy a file to same folder', function() { + skipOnKnownFilePickerRace(this) cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') cy.login(currentUser) cy.visit('/apps/files') @@ -110,7 +111,8 @@ describe('Files: Move or copy files', { testIsolation: true }, () => { getRowForFile('original (1).txt').should('be.visible') }) - it('Can copy a file multiple times to same folder', () => { + it('Can copy a file multiple times to same folder', function() { + skipOnKnownFilePickerRace(this) cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt') cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (1).txt') cy.login(currentUser) @@ -126,7 +128,8 @@ describe('Files: Move or copy files', { testIsolation: true }, () => { * Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (1)') * Test for: https://github.com/nextcloud/server/issues/43843 */ - it('Can copy a folder to same folder', () => { + it('Can copy a folder to same folder', function() { + skipOnKnownFilePickerRace(this) cy.mkdir(currentUser, '/foo.bar') cy.login(currentUser) cy.visit('/apps/files') diff --git a/cypress/e2e/files/live_photos.cy.ts b/cypress/e2e/files/live_photos.cy.ts index 2e7556f676ba1..ee2e69f2c063f 100644 --- a/cypress/e2e/files/live_photos.cy.ts +++ b/cypress/e2e/files/live_photos.cy.ts @@ -14,6 +14,7 @@ import { navigateToFolder, reloadCurrentFolder, renameFile, + skipOnKnownFilePickerRace, triggerActionForFile, triggerInlineActionForFileId, } from './FilesUtils.ts' @@ -50,7 +51,8 @@ describe('Files: Live photos', { testIsolation: true }, () => { getRowForFileId(movFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.mov`) }) - it('Copies both files when copying the .jpg', () => { + it('Copies both files when copying the .jpg', function() { + skipOnKnownFilePickerRace(this) copyFile(`${randomFileName}.jpg`, '.') reloadCurrentFolder() @@ -60,7 +62,8 @@ describe('Files: Live photos', { testIsolation: true }, () => { getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1) }) - it('Copies both files when copying the .mov', () => { + it('Copies both files when copying the .mov', function() { + skipOnKnownFilePickerRace(this) copyFile(`${randomFileName}.mov`, '.') reloadCurrentFolder() @@ -69,7 +72,8 @@ describe('Files: Live photos', { testIsolation: true }, () => { getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1) }) - it('Keeps live photo link when copying folder', () => { + it('Keeps live photo link when copying folder', function() { + skipOnKnownFilePickerRace(this) createFolder('folder') moveFile(`${randomFileName}.jpg`, 'folder') copyFile('folder', '.') @@ -84,7 +88,8 @@ describe('Files: Live photos', { testIsolation: true }, () => { getRowForFile(`${randomFileName}.mov`).should('have.length', 0) }) - it('Block copying live photo in a folder containing a mov file with the same name', () => { + it('Block copying live photo in a folder containing a mov file with the same name', function() { + skipOnKnownFilePickerRace(this) createFolder('folder') cy.uploadContent(user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/folder/${randomFileName}.mov`) cy.login(user) diff --git a/cypress/e2e/files/router-query.cy.ts b/cypress/e2e/files/router-query.cy.ts index a7c4aae546de9..56d0937dca4a4 100644 --- a/cypress/e2e/files/router-query.cy.ts +++ b/cypress/e2e/files/router-query.cy.ts @@ -111,7 +111,11 @@ describe('Check router query flags:', function() { function viewerShowsImage(): void { cy.findByRole('dialog', { name: 'image.jpg' }) .should('be.visible') - .find(`img[src*="fileId=${imageId}"]`) + // The viewer falls back to the original file when generating the + // preview fails or dawdles (e.g. on a loaded server) — do not + // couple the assertion to the delivery mechanism. + cy.findByRole('dialog', { name: 'image.jpg' }) + .find('img') .should('be.visible') } diff --git a/cypress/e2e/files_external/files-external-failed.cy.ts b/cypress/e2e/files_external/files-external-failed.cy.ts index e90a9d5e462ed..47e0cbcfbd3e5 100644 --- a/cypress/e2e/files_external/files-external-failed.cy.ts +++ b/cypress/e2e/files_external/files-external-failed.cy.ts @@ -8,6 +8,8 @@ import type { User } from '@nextcloud/e2e-test-server/cypress' import { getRowForFile } from '../files/FilesUtils.ts' import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageUtils.ts' +const CRON_TIMEOUT = 240000 + describe('Files user credentials', { testIsolation: true }, () => { let currentUser: User @@ -16,7 +18,11 @@ describe('Files user credentials', { testIsolation: true }, () => { cy.createRandomUser().then((user) => { currentUser = user }) - cy.runCommand('php ./cron.php') + // The first cron run on a fresh instance drains the initial background + // job queue and takes over a minute, exceeding cypress' 60s + // `execTimeout` default - and failing here skips the whole suite, as + // `before all` hooks are not retried. + cy.runCommand('php ./cron.php', { timeout: CRON_TIMEOUT }) }) afterEach(() => { diff --git a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts index c3c289774ccb2..f6749f1a7d43d 100644 --- a/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts +++ b/cypress/e2e/files_sharing/public-share/view_file-drop.cy.ts @@ -131,9 +131,18 @@ describe('files_sharing: Public share - File drop', { testIsolation: true }, () cy.wait('@uploadFile') - cy.findByRole('progressbar') - .should('be.visible') - .and((el) => { expect(Number.parseInt(el.attr('value') ?? '0')).be.gte(50) }) + // More than one progressbar can exist (upload picker and file drop + // view) and some of them stay hidden. + cy.findAllByRole('progressbar') + .should(($bars) => { + const visible = $bars.toArray().filter((el) => Cypress.$(el).is(':visible')) + const summary = $bars.toArray() + .map((el) => `${el.tagName}[value=${el.getAttribute('value')} visible=${Cypress.$(el).is(':visible')}]`) + .join(', ') + expect(visible.length, `visible progressbar (${summary})`).to.be.gte(1) + const values = visible.map((el) => Number.parseInt(el.getAttribute('value') ?? '0')) + expect(Math.max(...values), `upload progress (${summary})`).to.be.gte(50) + }) // continue second request .then(() => resolve(null)) diff --git a/cypress/e2e/files_trashbin/files.cy.ts b/cypress/e2e/files_trashbin/files.cy.ts index 8fcf4c7cb07d8..f3acfb770ab61 100644 --- a/cypress/e2e/files_trashbin/files.cy.ts +++ b/cypress/e2e/files_trashbin/files.cy.ts @@ -105,14 +105,18 @@ describe('files_trashbin: file row', { testIsolation: true }, () => { cy.login(alice) cy.visit('/apps/files/trashbin') - getRowForFileId(fileId).should('be.visible') - // The full name includes one span for the name and one span for the - // extension, so text() returns a space when composing them even if it - // will not be visible when rendered in the browser. - getRowForFileId(fileId).find('[data-cy-files-list-row-name]').should((element) => expect(element.text().trim()).to.equal('test-file .txt')) - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--original-location"]').should('have.text', 'All files') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted-by"]').should('have.text', 'You') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted"]').should('have.text', 'few seconds ago') + // `fileId` is assigned in the `.then()` above, so it is still undefined + // while the row selectors below are queued. + cy.then(() => { + getRowForFileId(fileId).should('be.visible') + // The full name includes one span for the name and one span for the + // extension, so text() returns a space when composing them even if it + // will not be visible when rendered in the browser. + getRowForFileId(fileId).find('[data-cy-files-list-row-name]').should((element) => expect(element.text().trim()).to.equal('test-file .txt')) + getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--original-location"]').should('have.text', 'All files') + getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted-by"]').should('have.text', 'You') + getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted"]').should('have.text', 'few seconds ago') + }) }) it('shows data for file deleted by sharee in a folder shared with a group', () => { @@ -125,13 +129,15 @@ describe('files_trashbin: file row', { testIsolation: true }, () => { cy.login(alice) cy.visit('/apps/files/trashbin') - getRowForFileId(fileId).should('be.visible') - // The full name includes one span for the name and one span for the - // extension, so text() returns a space when composing them even if it - // will not be visible when rendered in the browser. - getRowForFileId(fileId).find('[data-cy-files-list-row-name]').should((element) => expect(element.text().trim()).to.equal('test-file .txt')) - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--original-location"]').should('have.text', 'Shared') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted-by"]').should('have.text', 'Bob') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted"]').should('have.text', 'few seconds ago') + cy.then(() => { + getRowForFileId(fileId).should('be.visible') + // The full name includes one span for the name and one span for the + // extension, so text() returns a space when composing them even if it + // will not be visible when rendered in the browser. + getRowForFileId(fileId).find('[data-cy-files-list-row-name]').should((element) => expect(element.text().trim()).to.equal('test-file .txt')) + getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--original-location"]').should('have.text', 'Shared') + getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted-by"]').should('have.text', 'Bob') + getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted"]').should('have.text', 'few seconds ago') + }) }) }) diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts index ae23dca409789..1b7a6ffce1093 100644 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ b/cypress/e2e/files_versions/filesVersionsUtils.ts @@ -7,18 +7,23 @@ import type { User } from '@nextcloud/e2e-test-server/cypress' import type { ShareSetting } from '../files_sharing/FilesSharingUtils.ts' import { basename } from '@nextcloud/paths' -import { triggerActionForFile } from '../files/FilesUtils.ts' +import { openActionsMenu, triggerActionForFile } from '../files/FilesUtils.ts' import { createShare } from '../files_sharing/FilesSharingUtils.ts' export function uploadThreeVersions(user: User, fileName: string) { - // A new version will not be created if the changes occur - // within less than one second of each other. - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.uploadContent(user, new Blob(['v1'], { type: 'text/plain' }), 'text/plain', `/${fileName}`) - .wait(1100) - .uploadContent(user, new Blob(['v2'], { type: 'text/plain' }), 'text/plain', `/${fileName}`) - .wait(1100) - .uploadContent(user, new Blob(['v3'], { type: 'text/plain' }), 'text/plain', `/${fileName}`) + // A version is identified by the file's modification time at second + // resolution (files_versions/.v), so two uploads within the + // same second collapse into a single version. Wall-clock spacing (cy.wait) + // is racy on slow runners — the mtime is set server side at write time — + // so pin explicit, distinct mtimes (sent as X-OC-MTime) instead. Take the + // clock from the server, so a lagging client cannot date them into its + // future. + cy.runCommand('date +%s').then(({ stdout }) => { + const baseMtime = Number.parseInt(stdout.trim()) - 5 + cy.uploadContent(user, new Blob(['v1'], { type: 'text/plain' }), 'text/plain', `/${fileName}`, baseMtime) + cy.uploadContent(user, new Blob(['v2'], { type: 'text/plain' }), 'text/plain', `/${fileName}`, baseMtime + 2) + cy.uploadContent(user, new Blob(['v3'], { type: 'text/plain' }), 'text/plain', `/${fileName}`, baseMtime + 4) + }) cy.login(user) } @@ -39,22 +44,37 @@ export function openVersionsPanel(fileName: string) { cy.get('#tab-files_versions').should('be.visible', { timeout: 10000 }) } -export function toggleVersionMenu(index: number) { - cy.get('#tab-files_versions [data-files-versions-version]') +function getVersionMenuToggle(index: number) { + return cy.get('#tab-files_versions [data-files-versions-version]') .eq(index) .find('button') - .click() +} + +export function openVersionMenu(index: number) { + openActionsMenu(() => getVersionMenuToggle(index)) +} + +export function closeVersionMenu(index: number) { + getVersionMenuToggle(index).then(($toggle) => { + if ($toggle.attr('aria-expanded') === 'true') { + cy.wrap($toggle).click({ force: true }) + } + }) } export function triggerVersionAction(index: number, actionName: string) { - toggleVersionMenu(index) + openVersionMenu(index) cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).filter(':visible').click() } export function nameVersion(index: number, name: string) { cy.intercept('PROPPATCH', '**/dav/versions/*/versions/**').as('labelVersion') triggerVersionAction(index, 'label') - cy.get(':focused').type(`${name}{enter}`) + // `cy.focused()` would type into whatever holds focus at that moment, which + // on a slow runner is still the menu toggle the dialog was opened from. + cy.findByRole('dialog', { name: 'Name this version' }) + .findByRole('textbox', { name: 'Version name' }) + .type(`${name}{enter}`) cy.wait('@labelVersion') } @@ -71,9 +91,11 @@ export function deleteVersion(index: number) { } export function doesNotHaveAction(index: number, actionName: string) { - toggleVersionMenu(index) + openVersionMenu(index) cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).should('not.exist') - toggleVersionMenu(index) + // Close the menu again so its entries do not leak into the next assertion + // (the action query above is global). + closeVersionMenu(index) } export function assertVersionContent(index: number, expectedContent: string) { @@ -92,6 +114,33 @@ export function setupTestSharedFileFromUser(owner: User, randomFileName: string, cy.login(recipient) cy.visit('/apps/files') + // On a slow backend the freshly created share can be missing from the + // recipient's first directory listing: the mount cache is updated a + // moment after the share is committed, and the file list does not + // refetch on its own. + reloadUntilFileVisible(basename(randomFileName)) return cy.wrap(recipient) }) } + +/** + * Reload the current file list until the given file appears in it. + * + * @param fileName Name of the file expected in the current directory + * @param attemptsLeft Remaining reloads before giving up + */ +function reloadUntilFileVisible(fileName: string, attemptsLeft = 5) { + // The list has rendered once at least one row is present (a new user always + // has welcome.txt), so we can reliably tell "file missing" from "still loading". + cy.get('[data-cy-files-list-row-name]').should('have.length.at.least', 1) + cy.get('body').then(($body) => { + if ($body.find(`[data-cy-files-list-row-name="${CSS.escape(fileName)}"]`).length > 0) { + return + } + if (attemptsLeft === 0) { + throw new Error(`Shared file "${fileName}" never appeared in the recipient's file list after reloading`) + } + cy.reload() + reloadUntilFileVisible(fileName, attemptsLeft - 1) + }) +} diff --git a/cypress/e2e/systemtags/admin-settings.cy.ts b/cypress/e2e/systemtags/admin-settings.cy.ts index c2e6c89a5809b..8dd73078e3742 100644 --- a/cypress/e2e/systemtags/admin-settings.cy.ts +++ b/cypress/e2e/systemtags/admin-settings.cy.ts @@ -4,24 +4,77 @@ */ import { User } from '@nextcloud/e2e-test-server/cypress' +import { randomString } from '../../support/utils/randomString.ts' const admin = new User('admin', 'admin') -const tagName = 'foo' -const updatedTagName = 'bar' +// Unique per run so left-overs of an earlier run cannot satisfy - or collide +// with - the assertions below. +const tagName = `tag-${randomString(8)}` +const updatedTagName = `tag-${randomString(8)}` -describe('Create system tags', () => { - before(() => { - // delete any existing tags - cy.runOccCommand('tag:list --output=json').then((output) => { - Object.keys(JSON.parse(output.stdout)).forEach((id) => { - cy.runOccCommand(`tag:delete ${id}`) - }) +/** + * Remove every system tag, so the dropdown only ever contains what a test made. + */ +function deleteAllTags() { + cy.runOccCommand('tag:list --output=json').then((output) => { + Object.keys(JSON.parse(output.stdout)).forEach((id) => { + cy.runOccCommand(`tag:delete ${id}`) }) + }) +} - // login as admin and go to admin settings +/** + * Open the admin settings with the tag list already fetched. + * + * The section loads its tags asynchronously after mount, so opening the tag + * dropdown before that response arrives yields an empty list. + */ +function visitTagSettings() { + cy.intercept('PROPFIND', '**/dav/systemtags').as('fetchTags') + cy.visit('/settings/admin') + cy.wait('@fetchTags') +} + +/** + * Open one of the form's dropdowns and yield an entry of its list box. + * + * The list box is only rendered while the dropdown is open, and the dropdown + * opens on click - focussing alone leaves it closed. Querying the entry by its + * full selector keeps a list re-render retryable; resolving it from the list + * box element would bind the assertion to a detached snapshot. + * + * @param inputId id of the dropdown's input element + * @param title the entry's title attribute, omit to yield the list box itself + * @return the queried entry + */ +function openDropdown(inputId: string, title?: string) { + cy.get(`input#${inputId}`).click() + return cy.get(`input#${inputId}`) + .invoke('attr', 'aria-controls') + .then((id) => cy.get(title === undefined ? `ul#${id}` : `ul#${id} li span[title="${title}"]`)) +} + +/** + * Pick a tag from the "search for a tag to edit" dropdown. + * + * @param label the tag's entry as rendered in the list + */ +function selectTag(label: string) { + openDropdown('system-tags-input', label).click() +} + +describe('Create system tags', () => { + before(() => { cy.login(admin) - cy.visit('/settings/admin') + }) + + // The suite runs with `testIsolation: false`, so a retry would otherwise + // inherit the half-filled form and the tag the failed attempt created - + // and fail with 409 on creating it again. + beforeEach(() => { + deleteAllTags() + visitTagSettings() }) it('Can create a tag', () => { @@ -36,26 +89,28 @@ describe('Create system tags', () => { cy.wait('@createTag').its('response.statusCode').should('eq', 201) // see that the created tag is in the list - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${tagName}"]`) - .should('exist') - .should('have.length', 1) - }) + openDropdown('system-tags-input', tagName) + .should('have.length', 1) }) }) describe('Update system tags', { testIsolation: false }, () => { before(() => { cy.login(admin) - cy.visit('/settings/admin') + }) + + // Rebuild the tag for every attempt: `before()` does not re-run on a retry, + // so a failed attempt would leave the tag already renamed and the form + // already holding those values - retyping them emits no PROPPATCH at all + // and every further attempt fails. + beforeEach(() => { + deleteAllTags() + cy.runOccCommand(`tag:add '${tagName}' public`) + visitTagSettings() }) it('select the tag', () => { - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${tagName}"]`).should('exist').click() - }) + selectTag(tagName) // see that the tag name matches the selected tag cy.get('input#system-tag-name').should('exist').and('have.value', tagName) // see that the tag level matches the selected tag @@ -64,43 +119,40 @@ describe('Update system tags', { testIsolation: false }, () => { }) it('update the tag name and level', () => { + selectTag(tagName) + cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*').as('updateTag') cy.get('input#system-tag-name').clear() cy.get('input#system-tag-name').type(updatedTagName) cy.get('input#system-tag-name').should('have.value', updatedTagName) // select the new tag level - cy.get('input#system-tag-level').focus() - cy.get('input#system-tag-level').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="Invisible"]`).should('exist').click() - }) + openDropdown('system-tag-level', 'Invisible').click() // submit the form cy.get('input#system-tag-name').type('{enter}') // wait for the tag to be updated cy.wait('@updateTag').its('response.statusCode').should('eq', 207) - }) - it('see the tag was successfully updated', () => { - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${updatedTagName} (invisible)"]`) - .should('exist') - .should('have.length', 1) - }) + // see that the updated tag is in the list + openDropdown('system-tags-input', `${updatedTagName} (invisible)`) + .should('have.length', 1) }) }) describe('Delete system tags', { testIsolation: false }, () => { before(() => { cy.login(admin) - cy.visit('/settings/admin') + }) + + // Same as above: the delete below removes the tag, so every attempt needs + // its own one to operate on. + beforeEach(() => { + deleteAllTags() + cy.runOccCommand(`tag:add '${updatedTagName}' invisible`) + visitTagSettings() }) it('select the tag', () => { - // select the tag to edit - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${updatedTagName} (invisible)"]`).should('exist').click() - }) + selectTag(`${updatedTagName} (invisible)`) // see that the tag name matches the selected tag cy.get('input#system-tag-name').should('exist').and('have.value', updatedTagName) // see that the tag level matches the selected tag @@ -109,18 +161,17 @@ describe('Delete system tags', { testIsolation: false }, () => { }) it('can delete the tag', () => { + selectTag(`${updatedTagName} (invisible)`) + cy.intercept('DELETE', '/remote.php/dav/systemtags/*').as('deleteTag') cy.get('.system-tag-form__row').within(() => { cy.contains('button', 'Delete').should('be.enabled').click() }) // wait for the tag to be deleted cy.wait('@deleteTag').its('response.statusCode').should('eq', 204) - }) - it('see that the deleted tag is not present', () => { - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${updatedTagName}"]`).should('not.exist') - }) + // see that the deleted tag is gone from the list + openDropdown('system-tags-input', updatedTagName) + .should('not.exist') }) }) diff --git a/cypress/e2e/theming/admin-settings_default-app.cy.ts b/cypress/e2e/theming/admin-settings_default-app.cy.ts index 65b12786c1d61..23260d5ed336d 100644 --- a/cypress/e2e/theming/admin-settings_default-app.cy.ts +++ b/cypress/e2e/theming/admin-settings_default-app.cy.ts @@ -8,6 +8,21 @@ import { NavigationHeader } from '../../pages/NavigationHeader.ts' const admin = new User('admin', 'admin') +/** + * Seed the global default-app config and open the theming settings on it. + * + * Every test establishes the state it needs itself: the tests mutate that + * config, and `it` bodies are re-run alone on a retry, so inheriting the state + * from the preceding test would make a single failure poison all attempts. + * + * @param defaultApps value for the `defaultapp` system config + */ +function visitSettingsWithDefaultApps(defaultApps: string) { + cy.runOccCommand(`config:system:set defaultapp --value '${defaultApps}'`) + cy.visit('/settings/admin/theming') + getDefaultAppSwitch().scrollIntoView() +} + describe('Admin theming set default apps', () => { const navigationHeader = new NavigationHeader() @@ -18,6 +33,8 @@ describe('Admin theming set default apps', () => { }) it('See the current default app is the dashboard', () => { + cy.runOccCommand('config:system:set defaultapp --value \'\'') + // check default route cy.visit('/') cy.url().should('match', /apps\/dashboard/) @@ -28,14 +45,15 @@ describe('Admin theming set default apps', () => { }) it('See the default app settings', () => { - cy.visit('/settings/admin/theming') + visitSettingsWithDefaultApps('') cy.get('.settings-section').contains('Navigation bar settings').should('exist') getDefaultAppSwitch().should('exist') - getDefaultAppSwitch().scrollIntoView() }) it('Toggle the "use custom default app" switch', () => { + visitSettingsWithDefaultApps('') + getDefaultAppSwitch().should('not.be.checked') cy.findByRole('region', { name: 'Global default app' }) .should('not.exist') @@ -47,6 +65,8 @@ describe('Admin theming set default apps', () => { }) it('See the default app combobox', () => { + visitSettingsWithDefaultApps('dashboard,files') + cy.findByRole('region', { name: 'Global default app' }) .should('exist') .findByRole('combobox') @@ -64,6 +84,8 @@ describe('Admin theming set default apps', () => { }) it('See the default app order selector', () => { + visitSettingsWithDefaultApps('dashboard,files') + cy.findByRole('region', { name: 'Global default app' }) .should('exist') cy.findByRole('list', { name: 'Navigation bar app order' }) @@ -77,6 +99,8 @@ describe('Admin theming set default apps', () => { }) it('Change the default app', () => { + visitSettingsWithDefaultApps('dashboard,files') + cy.findByRole('list', { name: 'Navigation bar app order' }) .should('exist') .as('appOrderSelector') @@ -94,6 +118,8 @@ describe('Admin theming set default apps', () => { }) it('See the default app is changed', () => { + visitSettingsWithDefaultApps('files,dashboard') + cy.findByRole('list', { name: 'Navigation bar app order' }) .findAllByRole('listitem') .then((elements) => { @@ -110,12 +136,14 @@ describe('Admin theming set default apps', () => { }) it('Toggle the "use custom default app" switch back to reset the default apps', () => { - cy.visit('/settings/admin/theming') - getDefaultAppSwitch().scrollIntoView() + visitSettingsWithDefaultApps('files,dashboard') getDefaultAppSwitch().should('be.checked') + cy.intercept('PUT', '**/apps/theming/ajax/updateAppMenu').as('updateAppMenu') getDefaultAppSwitch().uncheck({ force: true }) getDefaultAppSwitch().should('be.not.checked') + // The uncheck persists asynchronously + cy.wait('@updateAppMenu') // Check the redirect to the default app works cy.request({ url: '/', followRedirect: false }).then((response) => { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index e8e66b2b714de..adcaafce345e1 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -16,6 +16,48 @@ addCommands() const url = (Cypress.config('baseUrl') || '').replace(/\/index.php\/?$/g, '') Cypress.env('baseUrl', url) +/** + * Login like `@nextcloud/e2e-test-server` does, but actually verify success. + * TODO: upstream to `@nextcloud/e2e-test-server` + * + * The packaged command never checks the POST /login response and validates + * cached sessions by requesting /apps/files *following redirects* — a + * logged-out session redirects to the login page and still yields 200, so a + * failed login (e.g. the csrf race on a slow server) passes silently and + * detonates much later in unrelated assertions. + * + * @param user the user to log in + */ +Cypress.Commands.overwrite('login', (_originalFn, user: User) => { + cy.session(user, () => { + cy.request('/csrftoken').then(({ body }) => { + cy.request({ + method: 'POST', + url: '/login', + body: { + user: user.userId, + password: user.password, + requesttoken: body.token, + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + // The login POST is rejected without a matching Origin header + Origin: (Cypress.config('baseUrl') ?? '').replace('index.php/', ''), + }, + followRedirect: false, + }) + }) + }, { + validate() { + // Do not follow redirects: a logged-out session would redirect to + // the login page and still return 200. + cy.request({ url: '/apps/files', followRedirect: false }) + .its('status') + .should('eq', 200) + }, + }) +}) + /** * Enable or disable a user * TODO: standardize in `@nextcloud/e2e-test-server` @@ -108,12 +150,17 @@ Cypress.Commands.add('mkdir', (user: User, target: string) => { username: user.userId, password: user.password, }, + // MKCOL answers 405 when the collection already exists. A + // retry re-runs the test body but not the data it created, + // so every attempt after the first would fail on set-up. + validateStatus: (status) => (status >= 200 && status < 300) || status === 405, }) cy.log(`Created directory ${target}`, response) return response } catch (cause) { cy.log('error', cause) - throw new Error('Unable to create directory', { cause }) + const status = axios.isAxiosError(cause) ? cause.response?.status : undefined + throw new Error(`Unable to create directory ${target}${status ? ` (status ${status})` : ''}`, { cause }) } }) }) diff --git a/cypress/support/commonUtils.ts b/cypress/support/commonUtils.ts index ff7de4b9e6c6c..306accc23d689 100644 --- a/cypress/support/commonUtils.ts +++ b/cypress/support/commonUtils.ts @@ -58,6 +58,9 @@ export function installTestApp() { // folder, which 0.5.1 renamed from apps_writable to apps-writable. cy.runCommand('test -d apps-writable && echo -n apps-writable || echo -n apps_writable').then(({ stdout }) => { const appsFolder = stdout.trim() + // Fail here rather than with an appstore error further down if the + // package ever stops providing a writable apps folder altogether. + cy.runCommand(`test -d ${appsFolder}`) cy.exec(`docker cp '${testAppPath}' ${containerName}:/var/www/html/${appsFolder}`, { log: true }) cy.exec(`docker exec --workdir /var/www/html ${containerName} chown -R www-data:www-data /var/www/html/${appsFolder}/testapp`) cy.runCommand(`sed -i -e 's|-version=\\"[0-9]\\+|-version=\\"${version}|g' ${appsFolder}/testapp/appinfo/info.xml`)