diff --git a/cypress.config.ts b/cypress.config.ts index e3413bd48fa42..df5085bc5d76d 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -59,6 +59,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 8bc5ffc706765..1c1f0e2fa9e65 100644 --- a/cypress/e2e/files/FilesUtils.ts +++ b/cypress/e2e/files/FilesUtils.ts @@ -121,16 +121,10 @@ export const 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) => { @@ -139,7 +133,7 @@ export const 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') @@ -155,16 +149,10 @@ export const 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) => { @@ -173,7 +161,7 @@ export const 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 a2005d4c0fde7..f772a1071b104 100644 --- a/cypress/e2e/files/files-copy-move.cy.ts +++ b/cypress/e2e/files/files-copy-move.cy.ts @@ -100,7 +100,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') @@ -111,7 +112,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) @@ -127,7 +129,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/files-download.cy.ts b/cypress/e2e/files/files-download.cy.ts index 06eb62094b849..bd59129609370 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/live_photos.cy.ts b/cypress/e2e/files/live_photos.cy.ts index 4fb000e065c8d..529a450fea5b0 100644 --- a/cypress/e2e/files/live_photos.cy.ts +++ b/cypress/e2e/files/live_photos.cy.ts @@ -13,6 +13,7 @@ import { moveFile, navigateToFolder, renameFile, + skipOnKnownFilePickerRace, triggerActionForFile, triggerInlineActionForFileId, } from './FilesUtils' @@ -49,7 +50,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`, '.') clickOnBreadcrumbs('All files') @@ -59,7 +61,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`, '.') clickOnBreadcrumbs('All files') @@ -68,7 +71,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', '.') @@ -83,7 +87,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 9c6564c8ecf36..a0716505f64d6 100644 --- a/cypress/e2e/files/router-query.cy.ts +++ b/cypress/e2e/files/router-query.cy.ts @@ -110,7 +110,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 29e5454dd6031..1b61d510ad599 100644 --- a/cypress/e2e/files_external/files-external-failed.cy.ts +++ b/cypress/e2e/files_external/files-external-failed.cy.ts @@ -7,6 +7,8 @@ import { User } from '@nextcloud/cypress' import { AuthBackend, createStorageWithConfig, StorageBackend } from './StorageUtils' import { getRowForFile } from '../files/FilesUtils' +const CRON_TIMEOUT = 240000 + describe('Files user credentials', { testIsolation: true }, () => { let currentUser: User 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 f95115ee59103..e8dbee380c360 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 @@ -130,9 +130,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 4c2bce7df7a23..66f14804939f6 100644 --- a/cypress/e2e/files_trashbin/files.cy.ts +++ b/cypress/e2e/files_trashbin/files.cy.ts @@ -49,7 +49,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/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts index 75c76b7e97c2f..7eb0b82595cc8 100644 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ b/cypress/e2e/files_versions/filesVersionsUtils.ts @@ -37,18 +37,29 @@ export function toggleVersionMenu(index: number) { cy.get('#tab-version_vue [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().type(`${name}{enter}`) cy.wait('@labelVersion') } @@ -65,9 +76,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) { @@ -85,6 +98,33 @@ export function setupTestSharedFileFromUser(owner: User, randomFileName: string, createShare(randomFileName, recipient.userId, shareOptions) 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 ac85cf34d6535..42d67f57c294c 100644 --- a/cypress/e2e/systemtags/admin-settings.cy.ts +++ b/cypress/e2e/systemtags/admin-settings.cy.ts @@ -7,13 +7,62 @@ import { User } from '@nextcloud/cypress' 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)}` + +/** + * 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}`) + }) + }) +} + +/** + * 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 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. + * + * @param inputId id of the dropdown's input element + * @return the open list box + */ +function openDropdown(inputId: string) { + cy.get(`input#${inputId}`).click() + return cy.get(`input#${inputId}`) + .invoke('attr', 'aria-controls') + .then((id) => cy.get(`ul#${id}`).should('be.visible')) +} describe('Create system tags', () => { before(() => { cy.login(admin) - cy.visit('/settings/admin') + }) + + // Reset both browser and server state for every attempt: the suite runs + // with `testIsolation: false`, so a retry would otherwise inherit the + // half-filled form and the already created tag of the attempt that just + // failed - and fail with 409 on creating it again. + beforeEach(() => { + deleteAllTags() + visitTagSettings() }) it('Can create a tag', () => { @@ -36,9 +85,13 @@ describe('Create system tags', () => { }) describe('Update system tags', { testIsolation: false }, () => { + // Create the tag this block operates on instead of inheriting it from the + // previous block, so a failure there cannot cascade into these tests. before(() => { + deleteAllTags() + cy.runOccCommand(`tag:add '${tagName}' public`) cy.login(admin) - cy.visit('/settings/admin') + visitTagSettings() }) it('select the tag', () => { @@ -84,9 +137,13 @@ describe('Update system tags', { testIsolation: false }, () => { }) describe('Delete system tags', { testIsolation: false }, () => { + // Same here: bring the updated tag into existence directly rather than + // depending on the previous block having produced it. before(() => { + deleteAllTags() + cy.runOccCommand(`tag:add '${updatedTagName}' invisible`) cy.login(admin) - cy.visit('/settings/admin') + visitTagSettings() }) it('select the tag', () => { diff --git a/cypress/e2e/theming/admin-settings_default-app.cy.ts b/cypress/e2e/theming/admin-settings_default-app.cy.ts index 702f737bc15e6..ac73b1ab2ba6a 100644 --- a/cypress/e2e/theming/admin-settings_default-app.cy.ts +++ b/cypress/e2e/theming/admin-settings_default-app.cy.ts @@ -72,6 +72,10 @@ describe('Admin theming set default apps', () => { }) it('Toggle the "use custom default app" switch back to reset the default apps', () => { + // Establish the precondition instead of relying on the previous test: + // the uncheck below persists an empty default-app config, so a failing + // first attempt would poison the precondition of every retry. + cy.runOccCommand('config:system:set defaultapp --value \'files,dashboard\'') cy.visit('/settings/admin/theming') cy.get('[data-cy-switch-default-app]').scrollIntoView() diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index b09a165451f09..0dea020847c7a 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/cypress