From d2e9b78e2c9ea664992169cfdf1fb1673088c342 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 3 Jul 2026 16:27:51 +0200 Subject: [PATCH 1/2] test(files): migrate end-to-end tests to PlayWright Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Ferdinand Thiessen --- cypress/e2e/files/LivePhotosUtils.ts | 116 ------ cypress/e2e/files/drag-n-drop.cy.ts | 246 ------------- cypress/e2e/files/files-filtering.cy.ts | 280 --------------- cypress/e2e/files/files-settings.cy.ts | 159 --------- cypress/e2e/files/files-sorting.cy.ts | 330 ------------------ cypress/e2e/files/files.cy.ts | 58 --- cypress/e2e/files/hotkeys.cy.ts | 113 ------ cypress/e2e/files/live_photos.cy.ts | 175 ---------- cypress/e2e/files/new-menu.cy.ts | 122 ------- cypress/e2e/files/router-query.cy.ts | 181 ---------- cypress/e2e/files/scrolling.cy.ts | 215 ------------ cypress/e2e/files/search.cy.ts | 217 ------------ .../playwright/e2e/files/drag-n-drop.spec.ts | 125 +++++++ .../e2e/files/files-filtering.spec.ts | 155 ++++++++ .../e2e/files/files-settings.spec.ts | 94 +++++ .../e2e/files/files-sorting.spec.ts | 178 ++++++++++ tests/playwright/e2e/files/files.spec.ts | 43 +++ tests/playwright/e2e/files/hotkeys.spec.ts | 74 ++++ .../playwright/e2e/files/live-photos.spec.ts | 185 ++++++++++ tests/playwright/e2e/files/new-menu.spec.ts | 63 ++++ .../playwright/e2e/files/router-query.spec.ts | 102 ++++++ tests/playwright/e2e/files/scrolling.spec.ts | 93 +++++ tests/playwright/e2e/files/search.spec.ts | 144 ++++++++ .../e2e/systemtags/files-bulk-action.spec.ts | 14 +- .../playwright/support/fixtures/files-page.ts | 6 + .../support/sections/FilesFilterPage.ts | 49 +++ .../support/sections/FilesListPage.ts | 182 +++++++++- .../support/sections/FilesNavigationPage.ts | 56 +++ .../sections/SystemTagsFilesListPage.ts | 18 + tests/playwright/support/utils/dav.ts | 53 ++- tests/playwright/support/utils/drag-drop.ts | 45 +++ tests/playwright/support/utils/live-photos.ts | 38 ++ tests/playwright/support/utils/viewport.ts | 76 ++++ 33 files changed, 1763 insertions(+), 2242 deletions(-) delete mode 100644 cypress/e2e/files/LivePhotosUtils.ts delete mode 100644 cypress/e2e/files/drag-n-drop.cy.ts delete mode 100644 cypress/e2e/files/files-filtering.cy.ts delete mode 100644 cypress/e2e/files/files-settings.cy.ts delete mode 100644 cypress/e2e/files/files-sorting.cy.ts delete mode 100644 cypress/e2e/files/files.cy.ts delete mode 100644 cypress/e2e/files/hotkeys.cy.ts delete mode 100644 cypress/e2e/files/live_photos.cy.ts delete mode 100644 cypress/e2e/files/new-menu.cy.ts delete mode 100644 cypress/e2e/files/router-query.cy.ts delete mode 100644 cypress/e2e/files/scrolling.cy.ts delete mode 100644 cypress/e2e/files/search.cy.ts create mode 100644 tests/playwright/e2e/files/drag-n-drop.spec.ts create mode 100644 tests/playwright/e2e/files/files-filtering.spec.ts create mode 100644 tests/playwright/e2e/files/files-settings.spec.ts create mode 100644 tests/playwright/e2e/files/files-sorting.spec.ts create mode 100644 tests/playwright/e2e/files/files.spec.ts create mode 100644 tests/playwright/e2e/files/hotkeys.spec.ts create mode 100644 tests/playwright/e2e/files/live-photos.spec.ts create mode 100644 tests/playwright/e2e/files/new-menu.spec.ts create mode 100644 tests/playwright/e2e/files/router-query.spec.ts create mode 100644 tests/playwright/e2e/files/scrolling.spec.ts create mode 100644 tests/playwright/e2e/files/search.spec.ts create mode 100644 tests/playwright/support/sections/FilesFilterPage.ts create mode 100644 tests/playwright/support/utils/drag-drop.ts create mode 100644 tests/playwright/support/utils/live-photos.ts create mode 100644 tests/playwright/support/utils/viewport.ts diff --git a/cypress/e2e/files/LivePhotosUtils.ts b/cypress/e2e/files/LivePhotosUtils.ts deleted file mode 100644 index b551cbbdd9db3..0000000000000 --- a/cypress/e2e/files/LivePhotosUtils.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { randomString } from '../../support/utils/randomString.ts' - -type SetupInfo = { - snapshot: string - jpgFileId: number - movFileId: number - fileName: string - user: User -} - -/** - * @param user - * @param fileName - * @param requesttoken - * @param metadata - */ -function setMetadata(user: User, fileName: string, requesttoken: string, metadata: object) { - const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?/, '') - cy.request({ - method: 'PROPPATCH', - url: `${base}/remote.php/dav/files/${user.userId}/${fileName}`, - auth: { user: user.userId, pass: user.password }, - headers: { - requesttoken, - }, - body: ` - - - - ${Object.entries(metadata).map(([key, value]) => `<${key}>${value}`).join('\n')} - - - `, - }) -} - -/** - * - * @param enable - */ -export function setShowHiddenFiles(enable: boolean) { - cy.request('/csrftoken').then(({ body }) => { - const requestToken = body.token - const url = `${Cypress.config('baseUrl')}/apps/files/api/v1/config/show_hidden` - cy.request({ - method: 'PUT', - url, - headers: { - 'Content-Type': 'application/json', - requesttoken: requestToken, - }, - body: { value: enable }, - }) - }) - cy.reload() -} - -/** - * - */ -export function setupLivePhotos(): Cypress.Chainable { - return cy.task('getVariable', { key: 'live-photos-data' }) - .then((_setupInfo) => { - const setupInfo = _setupInfo as SetupInfo || {} - if (setupInfo.snapshot) { - cy.restoreState(setupInfo.snapshot) - } else { - let requesttoken: string - - setupInfo.fileName = randomString(10) - - cy.createRandomUser().then((_user) => { - setupInfo.user = _user - }) - - cy.then(() => { - cy.uploadContent(setupInfo.user, new Blob(['jpg file'], { type: 'image/jpg' }), 'image/jpg', `/${setupInfo.fileName}.jpg`) - .then((response) => { setupInfo.jpgFileId = parseInt(response.headers['oc-fileid']) }) - cy.uploadContent(setupInfo.user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/${setupInfo.fileName}.mov`) - .then((response) => { setupInfo.movFileId = parseInt(response.headers['oc-fileid']) }) - - cy.login(setupInfo.user) - }) - - cy.visit('/apps/files') - - cy.get('head').invoke('attr', 'data-requesttoken').then((_requesttoken) => { - requesttoken = _requesttoken as string - }) - - cy.then(() => { - setMetadata(setupInfo.user, `${setupInfo.fileName}.jpg`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.movFileId }) - setMetadata(setupInfo.user, `${setupInfo.fileName}.mov`, requesttoken, { 'nc:metadata-files-live-photo': setupInfo.jpgFileId }) - }) - - cy.then(() => { - cy.saveState().then((value) => { - setupInfo.snapshot = value - }) - cy.task('setVariable', { key: 'live-photos-data', value: setupInfo }) - }) - } - return cy.then(() => { - cy.login(setupInfo.user) - cy.visit('/apps/files') - return cy.wrap(setupInfo) - }) - }) -} diff --git a/cypress/e2e/files/drag-n-drop.cy.ts b/cypress/e2e/files/drag-n-drop.cy.ts deleted file mode 100644 index 6f648aca8d238..0000000000000 --- a/cypress/e2e/files/drag-n-drop.cy.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile, navigateToFolder } from './FilesUtils.ts' - -describe('files: Drag and Drop', { testIsolation: true }, () => { - beforeEach(() => { - cy.createRandomUser().then((user) => { - cy.login(user) - }) - cy.visit('/apps/files') - }) - - it('can drop a file', () => { - const dataTransfer = new DataTransfer() - dataTransfer.items.add(new File([], 'single-file.txt')) - - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - // Make sure the drop notice is not visible - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - - // Trigger the drop notice - cy.get('main.app-content').trigger('dragover', { dataTransfer }) - cy.get('[data-cy-files-drag-drop-area]').should('be.visible') - - // Upload drop a file - cy.get('[data-cy-files-drag-drop-area]').selectFile({ - fileName: 'single-file.txt', - contents: ['hello '.repeat(1024)], - }, { action: 'drag-drop' }) - - cy.wait('@uploadFile') - - // Make sure the upload is finished - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - cy.get('@uploadFile.all').should('have.length', 1) - - getRowForFile('single-file.txt').should('be.visible') - getRowForFile('single-file.txt').find('[data-cy-files-list-row-size]').should('contain', '6 KB') - }) - - it('can drop multiple files', () => { - const dataTransfer = new DataTransfer() - dataTransfer.items.add(new File([], 'first.txt')) - dataTransfer.items.add(new File([], 'second.txt')) - - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - // Make sure the drop notice is not visible - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - - // Trigger the drop notice - cy.get('main.app-content').trigger('dragover', { dataTransfer }) - cy.get('[data-cy-files-drag-drop-area]').should('be.visible') - - // Upload drop a file - cy.get('[data-cy-files-drag-drop-area]').selectFile([ - { - fileName: 'first.txt', - contents: ['Hello'], - }, - { - fileName: 'second.txt', - contents: ['World'], - }, - ], { action: 'drag-drop' }) - - cy.wait('@uploadFile') - - // Make sure the upload is finished - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - cy.get('@uploadFile.all').should('have.length', 2) - - getRowForFile('first.txt').should('be.visible') - getRowForFile('second.txt').should('be.visible') - }) - - it('will ignore legacy Folders', () => { - cy.window().then((win) => { - // Remove the Filesystem API to force the legacy File API - // See how cypress mocks the Filesystem API in https://github.com/cypress-io/cypress/blob/74109094a92df3bef073dda15f17194f31850d7d/packages/driver/src/cy/commands/actions/selectFile.ts#L24-L37 - Object.defineProperty(win.DataTransferItem.prototype, 'getAsEntry', { get: undefined }) - Object.defineProperty(win.DataTransferItem.prototype, 'webkitGetAsEntry', { get: undefined }) - }) - - const dataTransfer = new DataTransfer() - dataTransfer.items.add(new File([], 'first.txt')) - dataTransfer.items.add(new File([], 'second.txt')) - - // Legacy File API (not FileSystem API), will treat Folders as Files - // with empty type and empty content - dataTransfer.items.add(new File([], 'Foo', { type: 'httpd/unix-directory' })) - dataTransfer.items.add(new File([], 'Bar')) - - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - // Make sure the drop notice is not visible - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - - // Trigger the drop notice - cy.get('main.app-content').trigger('dragover', { dataTransfer }) - cy.get('[data-cy-files-drag-drop-area]').should('be.visible') - - // Upload drop a file - cy.get('[data-cy-files-drag-drop-area]').selectFile([ - { - fileName: 'first.txt', - contents: ['Hello'], - }, - { - fileName: 'second.txt', - contents: ['World'], - }, - { - fileName: 'Foo', - contents: {}, - }, - { - fileName: 'Bar', - contents: { mimeType: 'httpd/unix-directory' }, - }, - ], { action: 'drag-drop' }) - - cy.wait('@uploadFile') - - // Make sure the upload is finished - cy.get('[data-cy-files-drag-drop-area]').should('not.be.visible') - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - cy.get('@uploadFile.all').should('have.length', 2) - - // see the warning - cy.get('.toast-warning').should('exist') - - // close all toasts - cy.get('.toastify') - .findAllByRole('button', { name: 'Close' }) - .click({ multiple: true }) - - getRowForFile('first.txt').should('be.visible') - getRowForFile('second.txt').should('be.visible') - getRowForFile('Foo').should('not.exist') - getRowForFile('Bar').should('not.exist') - }) -}) - -// Regression coverage for https://github.com/nextcloud/server/issues/60139 -// The per-row drop handler in FileEntryMixin used to pass raw FileSystemEntry -// objects to @nextcloud/upload's batchUpload; on some Chromium builds the -// instanceof-based conversion silently failed and the chunk uploader crashed -// with "e.slice is not a function". The fix routes the per-row drop through -// the same dataTransferToFileTree pipeline as the main file-list drop. -// -// Sibling describe (not nested) so the outer suite's `beforeEach` doesn't -// spin up an unused user before each test in this block. -describe('files: Drag and Drop onto a folder row', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then((u) => { - user = u - cy.mkdir(user, '/subfolder') - cy.login(user) - }) - cy.visit('/apps/files') - getRowForFile('subfolder').should('be.visible') - }) - - it('can drop a single file onto a subfolder row', () => { - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - getRowForFile('subfolder').selectFile({ - fileName: 'dropped-into-subfolder.txt', - contents: ['hello '.repeat(1024)], - }, { action: 'drag-drop' }) - - cy.wait('@uploadFile').its('request.url') - .should('match', /\/subfolder\/dropped-into-subfolder\.txt$/) - - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - - navigateToFolder('/subfolder') - getRowForFile('dropped-into-subfolder.txt').should('be.visible') - }) - - it('can drop multiple files onto a subfolder row', () => { - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - getRowForFile('subfolder').selectFile([ - { fileName: 'one.txt', contents: ['A'.repeat(1024)] }, - { fileName: 'two.txt', contents: ['B'.repeat(1024)] }, - ], { action: 'drag-drop' }) - - // Both files must land under the subfolder, not the current dir. - cy.wait(['@uploadFile', '@uploadFile']).then((intercepts) => { - const urls = intercepts.map((i) => i.request.url).sort() - expect(urls).to.have.length(2) - urls.forEach((url) => { - expect(url).to.match(/\/subfolder\/(one|two)\.txt$/) - }) - }) - - cy.get('[data-cy-upload-picker] progress').should('not.be.visible') - - navigateToFolder('/subfolder') - getRowForFile('one.txt').should('be.visible') - getRowForFile('two.txt').should('be.visible') - }) - - it('opens the conflict picker when dropping a colliding name onto a subfolder row', () => { - // Pre-populate the subfolder with a file the drop will collide with. - // cy.uploadContent internally clears session cookies, so re-login - // before revisiting so it matches the uploadContent > login > visit - // pattern used elsewhere in the suite. - cy.uploadContent(user, new Blob(['original']), 'text/plain', '/subfolder/collide.txt') - cy.login(user) - - // Reload so the pre-populated file lands in the store before the drop. - // The drop handler reads filesStore.getNodesByPath first and only - // fetches fresh contents when the cache is empty, so a stale cache - // from the beforeEach visit would let the upload proceed without - // triggering the conflict picker. If this ever flaps on CI, replace - // the visit with cy.reload() + an explicit wait on store settlement. - cy.visit('/apps/files') - getRowForFile('subfolder').should('be.visible') - - cy.intercept('PUT', /\/remote.php\/dav\/files\//).as('uploadFile') - - getRowForFile('subfolder').selectFile({ - fileName: 'collide.txt', - contents: ['replacement '.repeat(1024)], - }, { action: 'drag-drop' }) - - // Wait for the conflict picker to appear, then assert no PUT has - // fired yet — chained so the upload-count check happens *after* the - // dialog is visible, enforcing the "dialog blocks upload" invariant. - cy.findByRole('dialog').should('be.visible').then(() => { - cy.get('@uploadFile.all').should('have.length', 0) - }) - }) -}) diff --git a/cypress/e2e/files/files-filtering.cy.ts b/cypress/e2e/files/files-filtering.cy.ts deleted file mode 100644 index 0b6397a2b73a7..0000000000000 --- a/cypress/e2e/files/files-filtering.cy.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { FilesFilterPage } from '../../pages/FilesFilters.ts' -import { FilesNavigationPage } from '../../pages/FilesNavigation.ts' -import { getRowForFile, navigateToFolder } from './FilesUtils.ts' - -describe('files: Filter in files list', { testIsolation: true }, () => { - const appNavigation = new FilesNavigationPage() - const filesFilters = new FilesFilterPage() - let user: User - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.uploadContent(user, new Blob([]), 'text/csv', '/spreadsheet.csv') - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/text.txt') - cy.login(user) - cy.visit('/apps/files') - })) - - it('filters current view by name', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // Set up a search query - appNavigation.searchInput() - .type('folder') - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - getRowForFile('spreadsheet.csv').should('not.exist') - }) - - it('can reset name filter', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // Set up a search query - appNavigation.searchInput() - .type('folder') - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // reset the filter - appNavigation.searchInput().should('have.value', 'folder') - appNavigation.searchClearButton().should('exist').click() - appNavigation.searchInput().should('have.value', '') - - // All are visible again - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - }) - - it('filters current view by type', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - getRowForFile('spreadsheet.csv').should('be.visible') - - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Spreadsheets' }) - .should('be.visible') - .and('have.attr', 'aria-pressed', 'false') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See that only the spreadsheet is visible - getRowForFile('spreadsheet.csv').should('be.visible') - getRowForFile('file.txt').should('not.exist') - getRowForFile('folder').should('not.exist') - }) - - it('can reset filter by type', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Spreadsheets' }) - .should('be.visible') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See folder is not visible - getRowForFile('folder').should('not.exist') - - // clear filter - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Spreadsheets' }) - .should('be.visible') - .and('have.attr', 'aria-pressed', 'true') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'false') - - filesFilters.closeFilterMenu() - - // See folder is visible again - getRowForFile('folder').should('be.visible') - }) - - it('can reset filter by clicking chip button', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Spreadsheets' }) - .should('be.visible') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See folder is not visible - getRowForFile('folder').should('not.exist') - - // clear filter - filesFilters.removeFilter('Spreadsheets') - - // See folder is visible again - getRowForFile('folder').should('be.visible') - }) - - it('keeps type filter when changing the directory', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Folders' }) - .should('be.visible') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // see filter is active - filesFilters.activeFilters().contains(/Folder/).should('be.visible') - - // go to that folder - navigateToFolder('folder') - - // see filter is still active - filesFilters.activeFilters().contains(/Folder/).should('be.visible') - - // see that the folder is filtered - getRowForFile('text.txt').should('not.exist') - }) - - /** Regression test of https://github.com/nextcloud/server/issues/47251 */ - it('keeps filter state when changing the directory', () => { - // files are visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // enable type filter for folders - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Folders' }) - .should('be.visible') - .as('spreadsheetsFilterButton') - .click() - cy.get('@spreadsheetsFilterButton') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - - // See the chips are active - filesFilters.activeFilters() - .should('have.length', 1) - .contains(/Folder/).should('be.visible') - - // See that folder is visible but file not - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // Change the directory - navigateToFolder('folder') - getRowForFile('folder').should('not.exist') - - // See that the chip is still active - filesFilters.activeFilters() - .should('have.length', 1) - .contains(/Folder/).should('be.visible') - // And also the button should be active - filesFilters.triggerFilter('Type') - - cy.findByRole('button', { name: 'Folders' }) - .should('be.visible') - .should('have.attr', 'aria-pressed', 'true') - - filesFilters.closeFilterMenu() - }) - - /** Regression test of https://github.com/nextcloud/server/issues/53038 */ - it('resets name filter when changing the directory', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // Set up a search query - appNavigation.searchInput() - .type('folder') - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // go to that folder - navigateToFolder('folder') - - // see the search is cleared - appNavigation.searchInput() - .should('have.value', '') - - // see that the folder content is showed - getRowForFile('text.txt').should('be.visible') - }) - - it('resets filter when changing the view', () => { - // All are visible by default - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // Set up a search query - appNavigation.searchInput() - .type('folder') - - // See that only the folder is visible - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('not.exist') - - // go to other view - appNavigation.views() - .findByRole('link', { name: /personal files/i }) - .click() - // wait for view changed - cy.url().should('match', /apps\/files\/personal/) - - // see that the folder is not filtered - getRowForFile('folder').should('be.visible') - getRowForFile('file.txt').should('be.visible') - - // see the filter bar is gone - appNavigation.searchInput().should('have.value', '') - }) -}) diff --git a/cypress/e2e/files/files-settings.cy.ts b/cypress/e2e/files/files-settings.cy.ts deleted file mode 100644 index 299609e9d691b..0000000000000 --- a/cypress/e2e/files/files-settings.cy.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { getRowForFile } from './FilesUtils.ts' - -describe('files: Set default view', { testIsolation: true }, () => { - beforeEach(() => { - cy.createRandomUser().then(($user) => { - cy.login($user) - }) - }) - - it('Defaults to the "files" view', () => { - cy.visit('/apps/files') - - // See URL and current view - cy.url().should('match', /\/apps\/files\/files/) - cy.findByRole('navigation', { name: 'Current directory path' }) - .findAllByRole('button') - .first() - .should('have.text', 'All files') - - // See the option is also selected - // Open the files settings - cy.findByRole('link', { name: 'Files settings' }).click({ force: true }) - // Toggle the setting - cy.findByRole('dialog', { name: 'Files settings' }) - .should('be.visible') - .within(() => { - cy.findByRole('group', { name: 'Default view' }) - .findByRole('radio', { name: 'All files' }) - .should('be.checked') - }) - }) - - it('Can set it to personal files', () => { - cy.visit('/apps/files') - - // Open the files settings - cy.findByRole('link', { name: 'Files settings' }).click({ force: true }) - // Toggle the setting - cy.findByRole('dialog', { name: 'Files settings' }) - .should('be.visible') - .within(() => { - cy.findByRole('group', { name: 'Default view' }) - .findByRole('radio', { name: 'Personal files' }) - .check({ force: true }) - }) - - cy.visit('/apps/files') - cy.url().should('match', /\/apps\/files\/personal/) - cy.findByRole('navigation', { name: 'Current directory path' }) - .findAllByRole('button') - .first() - .should('have.text', 'Personal files') - }) -}) - -describe('files: Hide or show hidden files', { testIsolation: true }, () => { - let user: User - - const setupFiles = () => cy.createRandomUser().then(($user) => { - user = $user - - cy.uploadContent(user, new Blob([]), 'text/plain', '/.file') - cy.uploadContent(user, new Blob([]), 'text/plain', '/visible-file') - cy.mkdir(user, '/.folder') - cy.login(user) - }) - - context('view: All files', { testIsolation: false }, () => { - before(setupFiles) - - it('hides dot-files by default', () => { - cy.visit('/apps/files') - - getRowForFile('visible-file').should('be.visible') - getRowForFile('.file').should('not.exist') - getRowForFile('.folder').should('not.exist') - }) - - it('can show hidden files', () => { - showHiddenFiles() - // Now the files should be visible - getRowForFile('.file').should('be.visible') - getRowForFile('.folder').should('be.visible') - }) - }) - - context('view: Personal files', { testIsolation: false }, () => { - before(setupFiles) - - it('hides dot-files by default', () => { - cy.visit('/apps/files/personal') - - getRowForFile('visible-file').should('be.visible') - getRowForFile('.file').should('not.exist') - getRowForFile('.folder').should('not.exist') - }) - - it('can show hidden files', () => { - showHiddenFiles() - // Now the files should be visible - getRowForFile('.file').should('be.visible') - getRowForFile('.folder').should('be.visible') - }) - }) - - context('view: Recent files', { testIsolation: false }, () => { - before(() => { - setupFiles().then(() => { - // also add hidden file in hidden folder - cy.uploadContent(user, new Blob([]), 'text/plain', '/.folder/other-file') - cy.login(user) - }) - }) - - it('hides dot-files by default', () => { - cy.visit('/apps/files/recent') - - getRowForFile('visible-file').should('be.visible') - getRowForFile('.file').should('not.exist') - getRowForFile('.folder').should('not.exist') - getRowForFile('other-file').should('not.exist') - }) - - it('can show hidden files', () => { - showHiddenFiles() - - getRowForFile('visible-file').should('be.visible') - // Now the files should be visible - getRowForFile('.file').should('be.visible') - getRowForFile('.folder').should('be.visible') - getRowForFile('other-file').should('be.visible') - }) - }) -}) - -/** - * Helper to toggle the hidden files settings - */ -function showHiddenFiles() { - // Open the files settings - cy.get('[data-cy-files-navigation-settings-button] a').click({ force: true }) - // Toggle the hidden files setting - cy.findByRole('switch', { name: /show hidden files/i }) - .as('hiddenFiles') - .scrollIntoView() - cy.get('@hiddenFiles') - .should('not.be.checked') - .check({ force: true }) - - // Close the dialog - cy.get('[data-cy-files-navigation-settings] button[aria-label="Close"]').click() -} diff --git a/cypress/e2e/files/files-sorting.cy.ts b/cypress/e2e/files/files-sorting.cy.ts deleted file mode 100644 index a7e699f055083..0000000000000 --- a/cypress/e2e/files/files-sorting.cy.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -describe('Files: Sorting the file list', { testIsolation: true }, () => { - let currentUser - beforeEach(() => { - cy.createRandomUser().then((user) => { - currentUser = user - cy.login(user) - }) - }) - - it('Files are sorted by name ascending by default', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 first.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/z last.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/A.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/Ä.txt') - .mkdir(currentUser, '/m') - .mkdir(currentUser, '/4') - cy.login(currentUser) - cy.visit('/apps/files') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('4') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('m') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 first.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('A.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('Ä.txt') - break - case 5: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 6: expect($row.attr('data-cy-files-list-row-name')).to.eq('z last.txt') - break - } - }) - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/45829 - */ - it('Filesnames with numbers are sorted by name ascending by default', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/name.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/name_03.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/name_02.txt') - .uploadContent(currentUser, new Blob(), 'text/plain', '/name_01.txt') - cy.login(currentUser) - cy.visit('/apps/files') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('name.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_01.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_02.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('name_03.txt') - break - } - }) - }) - - it('Can sort by size', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt') - .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt') - .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a medium.txt') - .mkdir(currentUser, '/folder') - cy.login(currentUser) - cy.visit('/apps/files') - - // click sort button - cy.get('th').contains('button', 'Size').click() - // sorting is set - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') - break - } - }) - - // click sort button - cy.get('th').contains('button', 'Size').click() - // sorting is set - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') - break - } - }) - }) - - it('Can sort by mtime', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000) - .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400) - .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500) - cy.login(currentUser) - cy.visit('/apps/files') - - // click sort button - cy.get('th').contains('button', 'Modified').click() - // sorting is set - cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago - break - } - }) - - // reverse order - cy.get('th').contains('button', 'Modified').click() - cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending') - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') // uploaded right now - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') // fake time of yesterday - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') // fake time of yesterday and few minutes - break - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') // fake time of yesterday and ~15 minutes ago - break - } - }) - }) - - it('Favorites are sorted first', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1.txt', Date.now() / 1000 - 86400 - 1000) - .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z.txt', Date.now() / 1000 - 86400) - .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a.txt', Date.now() / 1000 - 86400 - 500) - .setFileAsFavorite(currentUser, '/a.txt') - cy.login(currentUser) - cy.visit('/apps/files') - - cy.log('By name - ascending') - cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'ascending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - } - }) - - cy.log('By name - descending') - cy.get('th').contains('button', 'Name').click() - cy.contains('th', 'Name').should('have.attr', 'aria-sort', 'descending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - } - }) - - cy.log('By size - ascending') - cy.get('th').contains('button', 'Size').click() - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - } - }) - - cy.log('By size - descending') - cy.get('th').contains('button', 'Size').click() - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - } - }) - - cy.log('By mtime - ascending') - cy.get('th').contains('button', 'Modified').click() - cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'ascending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - } - }) - - cy.log('By mtime - descending') - cy.get('th').contains('button', 'Modified').click() - cy.contains('th', 'Modified').should('have.attr', 'aria-sort', 'descending') - - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('a.txt') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('z.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - } - }) - }) - - it('Sorting works after switching view twice', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/1 tiny.txt') - .uploadContent(currentUser, new Blob(['a'.repeat(1024)]), 'text/plain', '/z big.txt') - .uploadContent(currentUser, new Blob(['a'.repeat(512)]), 'text/plain', '/a medium.txt') - .mkdir(currentUser, '/folder') - cy.login(currentUser) - cy.visit('/apps/files') - - // click sort button twice - cy.get('th').contains('button', 'Size').click() - cy.get('th').contains('button', 'Size').click() - - // switch to personal and click sort button twice again - cy.get('[data-cy-files-navigation-item="personal"]').click() - cy.get('th').contains('button', 'Size').click() - cy.get('th').contains('button', 'Size').click() - - // switch back to files view and do actual assertions - cy.get('[data-cy-files-navigation-item="files"]').click() - - // click sort button - cy.get('th').contains('button', 'Size').click() - // sorting is set - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'ascending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') - break - } - }) - - // click sort button - cy.get('th').contains('button', 'Size').click() - // sorting is set - cy.contains('th', 'Size').should('have.attr', 'aria-sort', 'descending') - // Files are sorted - cy.get('[data-cy-files-list-row]').each(($row, index) => { - switch (index) { - case 0: expect($row.attr('data-cy-files-list-row-name')).to.eq('folder') - break - case 1: expect($row.attr('data-cy-files-list-row-name')).to.eq('z big.txt') - break - case 2: expect($row.attr('data-cy-files-list-row-name')).to.eq('a medium.txt') - break - case 3: expect($row.attr('data-cy-files-list-row-name')).to.eq('welcome.txt') - break - case 4: expect($row.attr('data-cy-files-list-row-name')).to.eq('1 tiny.txt') - break - } - }) - }) -}) diff --git a/cypress/e2e/files/files.cy.ts b/cypress/e2e/files/files.cy.ts deleted file mode 100644 index 745c330c54319..0000000000000 --- a/cypress/e2e/files/files.cy.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { User } from '@nextcloud/e2e-test-server/cypress' - -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -describe('Files', { testIsolation: true }, () => { - let currentUser: User - - beforeEach(() => { - cy.createRandomUser().then((user) => { - currentUser = user - }) - }) - - it('Login with a user and open the files app', () => { - cy.login(currentUser) - cy.visit('/apps/files') - cy.get('[data-cy-files-list] [data-cy-files-list-row-name="welcome.txt"]').should('be.visible') - }) - - it('Opens a valid file shows it as active', () => { - cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt').then((response) => { - const fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') - - cy.login(currentUser) - cy.visit('/apps/files/files/' + fileId) - - cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) - .should('be.visible') - cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) - .invoke('attr', 'data-cy-files-list-row-name').should('eq', 'original.txt') - cy.get(`[data-cy-files-list-row-fileid=${fileId}]`) - .invoke('attr', 'class').should('contain', 'active') - cy.contains('The file could not be found').should('not.exist') - }) - }) - - it('Opens a valid folder shows its content', () => { - cy.mkdir(currentUser, '/folder').then(() => { - cy.login(currentUser) - cy.visit('/apps/files/files?dir=/folder') - - cy.get('[data-cy-files-content-breadcrumbs]').contains('folder').should('be.visible') - cy.contains('The file could not be found').should('not.exist') - }) - }) - - it('Opens an unknown file show an error', () => { - cy.intercept('PROPFIND', /\/remote.php\/dav\//).as('propfind') - cy.login(currentUser) - cy.visit('/apps/files/files/123456') - - cy.wait('@propfind') - // The toast should be visible - cy.contains('The file could not be found', { timeout: 5000 }).should('be.visible') - }) -}) diff --git a/cypress/e2e/files/hotkeys.cy.ts b/cypress/e2e/files/hotkeys.cy.ts deleted file mode 100644 index c68c006db7dda..0000000000000 --- a/cypress/e2e/files/hotkeys.cy.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { getRowForFileId } from './FilesUtils.ts' - -describe('Files hotkey handling', () => { - before(() => { - cy.createRandomUser().then((user) => { - cy.mkdir(user, '/abcd') - cy.mkdir(user, '/zyx') - cy.rm(user, '/welcome.txt') - cy.login(user) - }) - }) - - beforeEach(() => cy.visit('/apps/files')) - - it('Pressing "arrow down" should go to first file', () => { - cy.get('[data-cy-files-list]') - .press(Cypress.Keyboard.Keys.DOWN) - - cy.url() - .should('match', /\/apps\/files\/files\/\d+/) - .then((url) => new URL(url).pathname.split('/').at(-1)) - .then((fileId) => getRowForFileId(fileId) - .should('exist') - .and('have.attr', 'data-cy-files-list-row-name', 'abcd')) - }) - - it('Pressing "arrow up" should go to first file', () => { - cy.get('[data-cy-files-list]') - .press(Cypress.Keyboard.Keys.UP) - - cy.url() - .should('match', /\/apps\/files\/files\/\d+/) - .then((url) => new URL(url).pathname.split('/').at(-1)) - .then((fileId) => getRowForFileId(fileId) - .should('exist') - .and('have.attr', 'data-cy-files-list-row-name', 'zyx')) - }) - - it('Pressing D should open the sidebar once', () => { - activateFirstRow() - cy.get('[data-cy-files-list]') - .press('d') - - cy.get('[data-cy-sidebar]') - .should('exist') - .and('be.visible') - }) - - it('Pressing F2 should rename the file', () => { - activateFirstRow() - cy.get('[data-cy-files-list]') - .should('exist') - .then(($el) => { - const el = $el.get(0) - // manually dispatch as Cypress refuses to press F-keys for "security reasons" - cy.log('Dispatching F2 keydown/keyup events') - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', bubbles: true })) - el.dispatchEvent(new KeyboardEvent('keyup', { key: 'F2', code: 'F2', bubbles: true })) - el.dispatchEvent(new KeyboardEvent('keypress', { key: 'F2', code: 'F2', bubbles: true })) - }) - - cy.get('[data-cy-files-list-row-name]') - .first() - .findByRole('textbox', { name: /Folder name/ }) - .should('exist') - }) - - it('Pressing S should toggle favorite', () => { - activateFirstRow() - cy.get('[data-cy-files-list]') - .press('s') - - cy.get('[data-cy-files-list-row-name]') - .first() - .as('firstRow') - .findByRole('img', { name: /Favorite/ }) - .should('exist') - - cy.get('[data-cy-files-list]') - .press('s') - - cy.get('@firstRow') - .findByRole('img', { name: /Favorite/ }) - .should('not.exist') - }) - - it('Pressing DELETE should delete the folder', () => { - activateFirstRow() - cy.get('td[data-cy-files-list-row-name]') - .should('have.length', 2) - - cy.get('[data-cy-files-list]') - .press(Cypress.Keyboard.Keys.DELETE) - - cy.get('td[data-cy-files-list-row-name]') - .should('have.length', 1) - }) -}) - -/** - * Activates the first row in the files list by simulating a press of the down arrow key. - */ -function activateFirstRow() { - cy.get('[data-cy-files-list]') - .press(Cypress.Keyboard.Keys.DOWN) - cy.url() - .should('match', /\/apps\/files\/files\/\d+/) -} diff --git a/cypress/e2e/files/live_photos.cy.ts b/cypress/e2e/files/live_photos.cy.ts deleted file mode 100644 index 2e7556f676ba1..0000000000000 --- a/cypress/e2e/files/live_photos.cy.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { - copyFile, - createFolder, - getRowForFile, - getRowForFileId, - moveFile, - navigateToFolder, - reloadCurrentFolder, - renameFile, - triggerActionForFile, - triggerInlineActionForFileId, -} from './FilesUtils.ts' -import { setShowHiddenFiles, setupLivePhotos } from './LivePhotosUtils.ts' - -describe('Files: Live photos', { testIsolation: true }, () => { - let user: User - let randomFileName: string - let jpgFileId: number - let movFileId: number - - beforeEach(() => { - setupLivePhotos() - .then((setupInfo) => { - user = setupInfo.user - randomFileName = setupInfo.fileName - jpgFileId = setupInfo.jpgFileId - movFileId = setupInfo.movFileId - }) - }) - - it('Only renders the .jpg file', () => { - getRowForFileId(jpgFileId).should('have.length', 1) - getRowForFileId(movFileId).should('have.length', 0) - }) - - context("'Show hidden files' is enabled", () => { - beforeEach(() => { - setShowHiddenFiles(true) - }) - - it("Shows both files when 'Show hidden files' is enabled", () => { - getRowForFileId(jpgFileId).should('have.length', 1).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}.jpg`) - 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', () => { - copyFile(`${randomFileName}.jpg`, '.') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 1) - getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1) - }) - - it('Copies both files when copying the .mov', () => { - copyFile(`${randomFileName}.mov`, '.') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 1) - getRowForFile(`${randomFileName} (1).mov`).should('have.length', 1) - }) - - it('Keeps live photo link when copying folder', () => { - createFolder('folder') - moveFile(`${randomFileName}.jpg`, 'folder') - copyFile('folder', '.') - navigateToFolder('folder (1)') - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - - setShowHiddenFiles(false) - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 0) - }) - - it('Block copying live photo in a folder containing a mov file with the same name', () => { - createFolder('folder') - cy.uploadContent(user, new Blob(['mov file'], { type: 'video/mov' }), 'video/mov', `/folder/${randomFileName}.mov`) - cy.login(user) - cy.visit('/apps/files') - copyFile(`${randomFileName}.jpg`, 'folder') - navigateToFolder('folder') - - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) - getRowForFile(`${randomFileName} (1).jpg`).should('have.length', 0) - }) - - it('Moves files when moving the .jpg', () => { - renameFile(`${randomFileName}.jpg`, `${randomFileName}_moved.jpg`) - reloadCurrentFolder() - - getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`) - getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`) - }) - - it('Moves files when moving the .mov', () => { - renameFile(`${randomFileName}.mov`, `${randomFileName}_moved.mov`) - reloadCurrentFolder() - - getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.jpg`) - getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('equal', `${randomFileName}_moved.mov`) - }) - - it('Deletes files when deleting the .jpg', () => { - triggerActionForFile(`${randomFileName}.jpg`, 'delete') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) - getRowForFile(`${randomFileName}.mov`).should('have.length', 0) - - cy.visit('/apps/files/trashbin') - - getRowForFileId(jpgFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.jpg\\.d[0-9]+$`)) - getRowForFileId(movFileId).invoke('attr', 'data-cy-files-list-row-name').should('to.match', new RegExp(`^${randomFileName}.mov\\.d[0-9]+$`)) - }) - - it('Block deletion when deleting the .mov', () => { - triggerActionForFile(`${randomFileName}.mov`, 'delete') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - - cy.visit('/apps/files/trashbin') - - getRowForFileId(jpgFileId).should('have.length', 0) - getRowForFileId(movFileId).should('have.length', 0) - }) - - it('Restores files when restoring the .jpg', () => { - triggerActionForFile(`${randomFileName}.jpg`, 'delete') - cy.visit('/apps/files/trashbin') - - triggerInlineActionForFileId(jpgFileId, 'restore') - reloadCurrentFolder() - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) - getRowForFile(`${randomFileName}.mov`).should('have.length', 0) - - cy.visit('/apps/files') - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 1) - getRowForFile(`${randomFileName}.mov`).should('have.length', 1) - }) - - it('Blocks restoration when restoring the .mov', () => { - triggerActionForFile(`${randomFileName}.jpg`, 'delete') - cy.visit('/apps/files/trashbin') - - triggerInlineActionForFileId(movFileId, 'restore') - reloadCurrentFolder() - - getRowForFileId(jpgFileId).should('have.length', 1) - getRowForFileId(movFileId).should('have.length', 1) - - cy.visit('/apps/files') - - getRowForFile(`${randomFileName}.jpg`).should('have.length', 0) - getRowForFile(`${randomFileName}.mov`).should('have.length', 0) - }) - }) -}) diff --git a/cypress/e2e/files/new-menu.cy.ts b/cypress/e2e/files/new-menu.cy.ts deleted file mode 100644 index 554b023bef093..0000000000000 --- a/cypress/e2e/files/new-menu.cy.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { createFolder, getRowForFile, haveValidity, navigateToFolder } from './FilesUtils.ts' - -describe('"New"-menu', { testIsolation: true }, () => { - beforeEach(() => { - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/apps/files') - }) - }) - - it('Create new folder', () => { - // Click the "new" button - cy.get('[data-cy-upload-picker]') - .findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // Click the "new folder" menu entry - cy.findByRole('menuitem', { name: 'New folder' }) - .should('be.visible') - .click() - // Create a folder - cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir') - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .type('A new folder{enter}') - cy.wait('@mkdir') - // See the folder is visible - getRowForFile('A new folder') - .should('be.visible') - }) - - it('Does not allow creating forbidden folder names', () => { - // Click the "new" button - cy.get('[data-cy-upload-picker]') - .findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // Click the "new folder" menu entry - cy.findByRole('menuitem', { name: 'New folder' }) - .should('be.visible') - .click() - // enter folder name - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .type('.htaccess') - // See that input has invalid state set - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .should(haveValidity(/reserved name/i)) - // See that it can not create - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('button', { name: 'Create' }) - .should('be.disabled') - }) - - it('Does not allow creating folders with already existing names', () => { - createFolder('already exists') - // Click the "new" button - cy.get('[data-cy-upload-picker]') - .findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // Click the "new folder" menu entry - cy.findByRole('menuitem', { name: 'New folder' }) - .should('be.visible') - .click() - // enter folder name - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .type('already exists') - // See that input has invalid state set - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .should(haveValidity(/already in use/i)) - // See that it can not create - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('button', { name: 'Create' }) - .should('be.disabled') - }) - - /** - * Regression test of https://github.com/nextcloud/server/issues/47530 - */ - it('Create same folder in child folder', () => { - // setup other folders - createFolder('folder') - createFolder('other folder') - navigateToFolder('folder') - - // Click the "new" button - cy.get('[data-cy-upload-picker]') - .findByRole('button', { name: 'New' }) - .should('be.visible') - .click() - // Click the "new folder" menu entry - cy.findByRole('menuitem', { name: 'New folder' }) - .should('be.visible') - .click() - // enter folder name - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .type('other folder') - // See that creating is allowed - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('textbox', { name: 'Folder name' }) - .should(haveValidity('')) - // can create - cy.intercept('MKCOL', '**/remote.php/dav/files/**').as('mkdir') - cy.findByRole('dialog', { name: /create new folder/i }) - .findByRole('button', { name: 'Create' }) - .click() - cy.wait('@mkdir') - // see it is created - getRowForFile('other folder') - .should('be.visible') - }) -}) diff --git a/cypress/e2e/files/router-query.cy.ts b/cypress/e2e/files/router-query.cy.ts deleted file mode 100644 index a7c4aae546de9..0000000000000 --- a/cypress/e2e/files/router-query.cy.ts +++ /dev/null @@ -1,181 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { join } from '@nextcloud/paths' -import { getRowForFileId } from './FilesUtils.ts' - -/** - * Check that the sidebar is opened for a specific file - * @param name The name of the file - */ -function sidebarIsOpen(name: string): void { - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name }) - .should('be.visible') -} - -/** - * Skip a test without viewer installed - */ -function skipIfViewerDisabled(this: Mocha.Context): void { - cy.runOccCommand('app:list --enabled --output json') - .then((exec) => exec.stdout) - .then((output) => JSON.parse(output)) - .then((obj) => 'viewer' in obj.enabled) - .then((enabled) => { - if (!enabled) { - this.skip() - } - }) -} - -/** - * Check a file was not downloaded - * @param filename The expected filename - */ -function fileNotDownloaded(filename: string): void { - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(join(downloadsFolder, filename)).should('not.exist') -} - -describe('Check router query flags:', function() { - let user: User - let imageId: number - let archiveId: number - let folderId: number - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.uploadFile(user, 'image.jpg') - .then((response) => { imageId = Number.parseInt(response.headers['oc-fileid']) }) - cy.mkdir(user, '/folder') - .then((response) => { folderId = Number.parseInt(response.headers['oc-fileid']) }) - cy.uploadContent(user, new Blob([]), 'application/zstd', '/archive.zst') - .then((response) => { archiveId = Number.parseInt(response.headers['oc-fileid']) }) - cy.login(user) - }) - }) - - describe('"opendetails"', () => { - it('open details for known file type', () => { - cy.visit(`/apps/files/files/${imageId}?opendetails`) - - // see sidebar - sidebarIsOpen('image.jpg') - - // but no viewer - cy.findByRole('dialog', { name: 'image.jpg' }) - .should('not.exist') - - // and no download - fileNotDownloaded('image.jpg') - }) - - it('open details for unknown file type', () => { - cy.visit(`/apps/files/files/${archiveId}?opendetails`) - - // see sidebar - sidebarIsOpen('archive.zst') - - // but no viewer - cy.findByRole('dialog', { name: 'archive.zst' }) - .should('not.exist') - - // and no download - fileNotDownloaded('archive.zst') - }) - - it('open details for folder', () => { - cy.visit(`/apps/files/files/${folderId}?opendetails`) - - // see sidebar - sidebarIsOpen('folder') - - // but no viewer - cy.findByRole('dialog', { name: 'folder' }) - .should('not.exist') - - // and no download - fileNotDownloaded('folder') - }) - }) - - describe('"openfile"', function() { - /** Check the viewer is open and shows the image */ - function viewerShowsImage(): void { - cy.findByRole('dialog', { name: 'image.jpg' }) - .should('be.visible') - .find(`img[src*="fileId=${imageId}"]`) - .should('be.visible') - } - - it('opens files with default action', function() { - skipIfViewerDisabled.call(this) - - cy.visit(`/apps/files/files/${imageId}?openfile`) - viewerShowsImage() - }) - - it('opens files with default action using explicit query state', function() { - skipIfViewerDisabled.call(this) - - cy.visit(`/apps/files/files/${imageId}?openfile=true`) - viewerShowsImage() - }) - - it('does not open files with default action when using explicitly query value `false`', function() { - skipIfViewerDisabled.call(this) - - cy.visit(`/apps/files/files/${imageId}?openfile=false`) - getRowForFileId(imageId) - .should('be.visible') - .and('have.class', 'files-list__row--active') - - cy.findByRole('dialog', { name: 'image.jpg' }) - .should('not.exist') - }) - - it('does not open folders but shows details', () => { - cy.visit(`/apps/files/files/${folderId}?openfile`) - - // See the URL was replaced - cy.url() - .should('match', /[?&]opendetails(&|=|$)/) - .and('not.match', /openfile/) - - // See the sidebar is correctly opened - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'folder' }) - .should('be.visible') - - // see the folder was not changed - getRowForFileId(imageId).should('exist') - }) - - it('does not open unknown file types but shows details', () => { - cy.visit(`/apps/files/files/${archiveId}?openfile`) - - // See the URL was replaced - cy.url() - .should('match', /[?&]opendetails(&|=|$)/) - .and('not.match', /openfile/) - - // See the sidebar is correctly opened - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'archive.zst' }) - .should('be.visible') - - // See no file was downloaded - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(join(downloadsFolder, 'archive.zst')).should('not.exist') - }) - }) -}) diff --git a/cypress/e2e/files/scrolling.cy.ts b/cypress/e2e/files/scrolling.cy.ts deleted file mode 100644 index d1647e6df3ec2..0000000000000 --- a/cypress/e2e/files/scrolling.cy.ts +++ /dev/null @@ -1,215 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { beFullyInViewport, notBeFullyInViewport } from '../core-utils.ts' -import { calculateViewportHeight, enableGridMode, getRowForFile } from './FilesUtils.ts' - -describe('files: Scrolling to selected file in file list', () => { - const fileIds = new Map() - let viewportHeight: number - - before(() => { - initFilesAndViewport(fileIds) - .then((_viewportHeight) => { - cy.log(`Saving viewport height to ${_viewportHeight}px`) - viewportHeight = _viewportHeight - }) - }) - - beforeEach(() => { - cy.viewport(1200, viewportHeight) - }) - - it('Can see first file in list', () => { - cy.visit(`/apps/files/files/${fileIds.get(1)}`) - - // See file is visible - getRowForFile('1.txt') - .should('be.visible') - - // we expect also element 6 to be visible - getRowForFile('6.txt') - .should('be.visible') - // but not element 7 - though it should exist (be buffered) - getRowForFile('7.txt') - .should('exist') - .and('not.be.visible') - }) - - // For files already in the visible buffer, scrolling is skipped to prevent jumping - // So we only verify the file exists and is in the DOM - for (let i = 2; i <= 5; i++) { - it(`correctly scrolls to row ${i}`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // File should exist in the DOM (scroll is skipped when already in visible buffer) - getRowForFile(`${i}.txt`) - .should('exist') - }) - } - - // Row 6 is at the edge of the initial visible buffer, scroll may be skipped - it('correctly scrolls to row 6', () => { - cy.visit(`/apps/files/files/${fileIds.get(6)}`) - - // File should exist in the DOM (scroll may be skipped when in visible buffer) - getRowForFile('6.txt') - .should('exist') - }) - - // For the last "page" of entries we can not scroll further - // so we show all of the last 4 entries - for (let i = 7; i <= 10; i++) { - it(`correctly scrolls to row ${i}`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // See file is visible - getRowForFile(`${i}.txt`) - .should('be.visible') - .and(notBeOverlappedByTableHeader) - - // there are only max. 4 rows left so also row 6+ should be visible - getRowForFile('6.txt') - .should('be.visible') - getRowForFile('10.txt') - .should('be.visible') - // Also the footer is visible - cy.get('tfoot') - .contains('10 files') - .should(beFullyInViewport) - }) - } -}) - -describe('files: Scrolling to selected file in file list (GRID MODE)', () => { - const fileIds = new Map() - let viewportHeight: number - - before(() => { - initFilesAndViewport(fileIds, true) - .then((_viewportHeight) => { viewportHeight = _viewportHeight }) - }) - - beforeEach(() => { - cy.viewport(768, viewportHeight) - }) - - // First row - for (let i = 1; i <= 3; i++) { - it(`Can see files in first row (file ${i})`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - for (let j = 1; j <= 3; j++) { - // See all files of that row are visible - getRowForFile(`${j}.txt`) - .should('be.visible') - // we expect also the second row to be visible - getRowForFile(`${j + 3}.txt`) - .should('be.visible') - // Because there is no half row on top we also see the third row - getRowForFile(`${j + 6}.txt`) - .should('be.visible') - // But not the forth row - getRowForFile(`${j + 9}.txt`) - .should('exist') - .and(notBeFullyInViewport) - } - }) - } - - // Second row - files already in visible buffer, scroll is skipped - for (let i = 4; i <= 6; i++) { - it(`correctly scrolls to second row (file ${i})`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // File should exist in the DOM (scroll is skipped when in visible buffer) - getRowForFile(`${i}.txt`) - .should('exist') - }) - } - - // Third row - files may be in visible buffer, scroll may be skipped - for (let i = 7; i <= 9; i++) { - it(`correctly scrolls to third row (file ${i})`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // File should exist in the DOM (scroll may be skipped when in visible buffer) - getRowForFile(`${i}.txt`) - .should('exist') - }) - } - - // Forth row - scrolling happens for files outside initial visible buffer - for (let i = 10; i <= 12; i++) { - it(`correctly scrolls to forth row (file ${i})`, () => { - cy.visit(`/apps/files/files/${fileIds.get(i)}`) - - // File should be visible after scrolling - getRowForFile(`${i}.txt`) - .should('be.visible') - }) - } -}) - -/// Some helpers - -/** - * Assert that an element is overlapped by the table header - * @param $el The element - * @param expected if it should be overlapped or NOT - */ -function beOverlappedByTableHeader($el: JQuery, expected = true) { - const headerRect = Cypress.$('thead').get(0)!.getBoundingClientRect() - const elementRect = $el.get(0)!.getBoundingClientRect() - const overlap = !(headerRect.right < elementRect.left - || headerRect.left > elementRect.right - || headerRect.bottom < elementRect.top - || headerRect.top > elementRect.bottom) - - if (expected) { - expect(overlap, 'Overlapped by table header').to.be.true - } else { - expect(overlap, 'Not overlapped by table header').to.be.false - } -} - -/** - * Assert that an element is not overlapped by the table header - * @param $el The element - */ -function notBeOverlappedByTableHeader($el: JQuery) { - return beOverlappedByTableHeader($el, false) -} - -function initFilesAndViewport(fileIds: Map, gridMode = false): Cypress.Chainable { - return cy.createRandomUser().then((user) => { - cy.rm(user, '/welcome.txt') - - // Create files with names 1.txt, 2.txt, ..., 10.txt - const count = gridMode ? 12 : 10 - for (let i = 1; i <= count; i++) { - cy.uploadContent(user, new Blob([]), 'text/plain', `/${i}.txt`) - .then((response) => fileIds.set(i, Number.parseInt(response.headers['oc-fileid']).toString())) - } - - cy.login(user) - cy.viewport(1200, 800) - - cy.visit('/apps/files') - - // If grid mode is requested, enable it - if (gridMode) { - enableGridMode() - } - - // Calculate height to ensure that those 10 elements can not be rendered in one list (only 6 will fit the screen, 3 in grid mode) - return calculateViewportHeight(gridMode ? 3 : 6) - .then((height) => { - // Set viewport height to the calculated height - cy.log(`Setting viewport height to ${height}px`) - cy.wrap(height) - }) - }) -} diff --git a/cypress/e2e/files/search.cy.ts b/cypress/e2e/files/search.cy.ts deleted file mode 100644 index 2800a71a539ee..0000000000000 --- a/cypress/e2e/files/search.cy.ts +++ /dev/null @@ -1,217 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { FilesNavigationPage } from '../../pages/FilesNavigation.ts' -import { getRowForFile, navigateToFolder, reloadCurrentFolder } from './FilesUtils.ts' - -describe('files: search', () => { - let user: User - - const navigation = new FilesNavigationPage() - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.mkdir(user, '/some folder') - cy.mkdir(user, '/some folder/nested folder') - cy.mkdir(user, '/other folder') - cy.mkdir(user, '/12345') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/file.txt') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a file.txt') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/a second file.txt') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/some folder/nested folder/deep file.txt') - cy.uploadContent(user, new Blob(['content']), 'text/plain', '/other folder/another file.txt') - cy.login(user) - }) - }) - - beforeEach(() => { - cy.visit('/apps/files') - }) - - it('updates the query on the URL', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - - navigation.searchInput().type('file') - cy.url().should('match', /query=file($|&)/) - }) - - it('can search globally', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('file') - - getRowForFile('file.txt').should('be.visible') - getRowForFile('a file.txt').should('be.visible') - getRowForFile('a second file.txt').should('be.visible') - getRowForFile('another file.txt').should('be.visible') - }) - - it('filter does also search locally', () => { - navigateToFolder('some folder') - getRowForFile('a file.txt').should('be.visible') - - navigation.searchInput().type('file') - - getRowForFile('a file.txt').should('be.visible') - getRowForFile('a second file.txt').should('be.visible') - getRowForFile('deep file.txt').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3) - }) - - it('See "search everywhere" button', () => { - // Not visible initially - cy.get('.files-list__filters') - .findByRole('button', { name: /Search everywhere/i }) - .should('not.to.exist') - - // add a filter - navigation.searchInput().type('file') - - // see its visible - cy.get('.files-list__filters') - .findByRole('button', { name: /Search everywhere/i }) - .should('be.visible') - - // clear the filter - navigation.searchClearButton().click() - - // see its not visible again - cy.get('.files-list__filters') - .findByRole('button', { name: /Search everywhere/i }) - .should('not.to.exist') - }) - - it('can make local search a global search', () => { - navigateToFolder('some folder') - getRowForFile('a file.txt').should('be.visible') - - navigation.searchInput().type('file') - - // see local results - getRowForFile('a file.txt').should('be.visible') - getRowForFile('a second file.txt').should('be.visible') - getRowForFile('deep file.txt').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 3) - - // toggle global search - cy.get('.files-list__filters') - .findByRole('button', { name: /Search everywhere/i }) - .should('be.visible') - .click() - - // see global results - getRowForFile('file.txt').should('be.visible') - getRowForFile('a file.txt').should('be.visible') - getRowForFile('deep file.txt').should('be.visible') - getRowForFile('a second file.txt').should('be.visible') - getRowForFile('another file.txt').should('be.visible') - }) - - it('shows empty content when there are no results', () => { - navigateToFolder('some folder') - getRowForFile('a file.txt').should('be.visible') - - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('xyz') - - // see the empty content message - cy.contains('[role="note"]', /No search results for .xyz./) - .should('be.visible') - .within(() => { - // see within there is a search box with the same value - cy.findByRole('searchbox', { name: /search for files/i }) - .should('be.visible') - .and('have.value', 'xyz') - }) - }) - - it('can alter search', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('other') - - getRowForFile('another file.txt').should('be.visible') - getRowForFile('other folder').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2) - - navigation.searchInput().type(' file') - navigation.searchInput().should('have.value', 'other file') - getRowForFile('another file.txt').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) - }) - - it('returns to file list if search is cleared', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('other') - - getRowForFile('another file.txt').should('be.visible') - getRowForFile('other folder').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 2) - - navigation.searchClearButton().click() - navigation.searchInput().should('have.value', '') - getRowForFile('file.txt').should('be.visible') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 5) - }) - - /** - * Problem: - * 1. Being on the search view - * 2. Press the refresh button (name of the current view) - * 3. See that the router link does not preserve the query - * - * We fix this with a navigation guard and need to verify that it works - */ - it('keeps the query in the URL', () => { - navigation.searchScopeTrigger().click() - navigation.searchScopeMenu() - .should('be.visible') - .findByRole('menuitem', { name: /search everywhere/i }) - .should('be.visible') - .click() - navigation.searchInput().type('file') - - // see that the search view is loaded - getRowForFile('a file.txt').should('be.visible') - // see the correct url - cy.url().should('match', /query=file($|&)/) - - cy.intercept('SEARCH', '**/remote.php/dav/').as('search') - // refresh the view - reloadCurrentFolder(false) // no PROPFIND intercept here as we want to wait for SEARCH - // wait for the request - cy.wait('@search') - // see that the search view is reloaded - getRowForFile('a file.txt').should('be.visible') - // see the correct url - cy.url().should('match', /query=file($|&)/) - }) -}) diff --git a/tests/playwright/e2e/files/drag-n-drop.spec.ts b/tests/playwright/e2e/files/drag-n-drop.spec.ts new file mode 100644 index 0000000000000..5102ed369fc7f --- /dev/null +++ b/tests/playwright/e2e/files/drag-n-drop.spec.ts @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { createFileDataTransfer, dropFilesOn } from '../../support/utils/drag-drop.ts' + +test.describe('files: Drag and Drop', () => { + test.beforeEach(async ({ filesListPage }) => { + await filesListPage.open() + }) + + test('can drop a file', async ({ page, filesListPage }) => { + const uploaded = page.waitForResponse((r) => r.request().method() === 'PUT' && r.url().includes('/remote.php/dav/files/')) + const dataTransfer = await createFileDataTransfer(page, [{ name: 'single-file.txt', content: 'hello '.repeat(1024) }]) + + await filesListPage.getContentArea().dispatchEvent('dragover', { dataTransfer }) + await expect(filesListPage.getDropArea()).toBeVisible() + + await dropFilesOn(filesListPage.getDropArea(), dataTransfer) + await uploaded + + await expect(filesListPage.getRowForFile('single-file.txt')).toBeVisible() + await expect(filesListPage.getRowSizeForFile('single-file.txt')).toContainText('6 KB') + }) + + test('can drop multiple files', async ({ page, filesListPage }) => { + const dataTransfer = await createFileDataTransfer(page, [ + { name: 'first.txt', content: 'Hello' }, + { name: 'second.txt', content: 'World' }, + ]) + + await filesListPage.getContentArea().dispatchEvent('dragover', { dataTransfer }) + await expect(filesListPage.getDropArea()).toBeVisible() + + await dropFilesOn(filesListPage.getDropArea(), dataTransfer) + + await expect(filesListPage.getRowForFile('first.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('second.txt')).toBeVisible() + }) + + test('ignores dropped folders (legacy File API)', async ({ page, filesListPage }) => { + // A synthetic DataTransfer already uses the legacy File API path; a File + // with the directory mime type stands in for a dropped folder and must be + // skipped with a warning while the real files still upload. + const dataTransfer = await createFileDataTransfer(page, [ + { name: 'first.txt', content: 'Hello' }, + { name: 'second.txt', content: 'World' }, + { name: 'Foo', content: '', type: 'httpd/unix-directory' }, + ]) + + await filesListPage.getContentArea().dispatchEvent('dragover', { dataTransfer }) + await expect(filesListPage.getDropArea()).toBeVisible() + + await dropFilesOn(filesListPage.getDropArea(), dataTransfer) + + await expect(page.locator('.toast-warning')).toBeVisible() + await expect(filesListPage.getRowForFile('first.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('second.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('Foo')).toHaveCount(0) + }) +}) + +// Regression coverage for https://github.com/nextcloud/server/issues/60139: +// per-row drops must route through the same pipeline as the main-list drop and +// upload into the target folder. +test.describe('files: Drag and Drop onto a folder row', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/subfolder') + await filesListPage.open() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + }) + + test('can drop a single file onto a subfolder row', async ({ page, filesListPage }) => { + const uploaded = page.waitForResponse((r) => r.request().method() === 'PUT' && /\/subfolder\/dropped-into-subfolder\.txt$/.test(r.url())) + const dataTransfer = await createFileDataTransfer(page, [{ name: 'dropped-into-subfolder.txt', content: 'hello '.repeat(1024) }]) + + await dropFilesOn(filesListPage.getRowForFile('subfolder'), dataTransfer) + await uploaded + + await filesListPage.navigateToFolder('subfolder') + await expect(filesListPage.getRowForFile('dropped-into-subfolder.txt')).toBeVisible() + }) + + test('can drop multiple files onto a subfolder row', async ({ page, filesListPage }) => { + const uploads = Promise.all([ + page.waitForResponse((r) => r.request().method() === 'PUT' && /\/subfolder\/one\.txt$/.test(r.url())), + page.waitForResponse((r) => r.request().method() === 'PUT' && /\/subfolder\/two\.txt$/.test(r.url())), + ]) + const dataTransfer = await createFileDataTransfer(page, [ + { name: 'one.txt', content: 'A'.repeat(1024) }, + { name: 'two.txt', content: 'B'.repeat(1024) }, + ]) + + await dropFilesOn(filesListPage.getRowForFile('subfolder'), dataTransfer) + await uploads + + await filesListPage.navigateToFolder('subfolder') + await expect(filesListPage.getRowForFile('one.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('two.txt')).toBeVisible() + }) + + test('opens the conflict picker when dropping a colliding name onto a subfolder row', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, 'original', 'text/plain', '/subfolder/collide.txt') + // Reload so the pre-populated file is in the store before the drop + await filesListPage.open() + await expect(filesListPage.getRowForFile('subfolder')).toBeVisible() + + let putFired = false + page.on('request', (r) => { + if (r.method() === 'PUT' && r.url().includes('/remote.php/dav/files/')) { + putFired = true + } + }) + + const dataTransfer = await createFileDataTransfer(page, [{ name: 'collide.txt', content: 'replacement '.repeat(1024) }]) + await dropFilesOn(filesListPage.getRowForFile('subfolder'), dataTransfer) + + // The conflict dialog blocks the upload until resolved + await expect(page.getByRole('dialog')).toBeVisible() + expect(putFired).toBe(false) + }) +}) diff --git a/tests/playwright/e2e/files/files-filtering.spec.ts b/tests/playwright/e2e/files/files-filtering.spec.ts new file mode 100644 index 0000000000000..6fd2852743ddc --- /dev/null +++ b/tests/playwright/e2e/files/files-filtering.spec.ts @@ -0,0 +1,155 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +// A wide viewport keeps the filter categories as inline buttons (rather than +// collapsing into a "Filters" menu), so the interactions are deterministic. +test.use({ viewport: { width: 1920, height: 1080 } }) + +test.describe('files: Filter in files list', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + const request = page.request + await mkdir(request, user, '/folder') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/csv', '/spreadsheet.csv') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/folder/text.txt') + await filesListPage.open() + }) + + test('filters current view by name', async ({ filesNavigation, filesListPage }) => { + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesNavigation.searchInput().fill('folder') + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + await expect(filesListPage.getRowForFile('spreadsheet.csv')).toHaveCount(0) + }) + + test('can reset name filter', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchInput().fill('folder') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + + await expect(filesNavigation.searchInput()).toHaveValue('folder') + await filesNavigation.searchClearButton().click() + await expect(filesNavigation.searchInput()).toHaveValue('') + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + }) + + test('filters current view by type', async ({ filesFilter, filesListPage }) => { + await expect(filesListPage.getRowForFile('spreadsheet.csv')).toBeVisible() + + await filesFilter.openFilter('Type') + const spreadsheets = filesFilter.filterOption('Spreadsheets') + await expect(spreadsheets).toHaveAttribute('aria-pressed', 'false') + await spreadsheets.click() + await expect(spreadsheets).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('spreadsheet.csv')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + await expect(filesListPage.getRowForFile('folder')).toHaveCount(0) + }) + + test('can reset filter by type', async ({ filesFilter, filesListPage }) => { + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Spreadsheets').click() + await expect(filesFilter.filterOption('Spreadsheets')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('folder')).toHaveCount(0) + + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Spreadsheets').click() + await expect(filesFilter.filterOption('Spreadsheets')).toHaveAttribute('aria-pressed', 'false') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + }) + + test('can reset filter by clicking chip button', async ({ filesFilter, filesListPage }) => { + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Spreadsheets').click() + await expect(filesFilter.filterOption('Spreadsheets')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('folder')).toHaveCount(0) + + await filesFilter.removeFilter('Spreadsheets') + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + }) + + test('keeps type filter when changing the directory', async ({ filesFilter, filesListPage }) => { + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Folders').click() + await expect(filesFilter.filterOption('Folders')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + await expect(filesFilter.activeFilters().filter({ hasText: /Folder/ })).toBeVisible() + + await filesListPage.navigateToFolder('folder') + + await expect(filesFilter.activeFilters().filter({ hasText: /Folder/ })).toBeVisible() + await expect(filesListPage.getRowForFile('text.txt')).toHaveCount(0) + }) + + /** Regression test of https://github.com/nextcloud/server/issues/47251 */ + test('keeps filter state when changing the directory', async ({ filesFilter, filesListPage }) => { + await filesFilter.openFilter('Type') + await filesFilter.filterOption('Folders').click() + await expect(filesFilter.filterOption('Folders')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + + await expect(filesFilter.activeFilters()).toHaveCount(1) + await expect(filesFilter.activeFilters().filter({ hasText: /Folder/ })).toBeVisible() + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + + await filesListPage.navigateToFolder('folder') + await expect(filesListPage.getRowForFile('folder')).toHaveCount(0) + + await expect(filesFilter.activeFilters()).toHaveCount(1) + await expect(filesFilter.activeFilters().filter({ hasText: /Folder/ })).toBeVisible() + + // The Folders toggle should still be pressed + await filesFilter.openFilter('Type') + await expect(filesFilter.filterOption('Folders')).toHaveAttribute('aria-pressed', 'true') + await filesFilter.closeFilterMenu() + }) + + /** Regression test of https://github.com/nextcloud/server/issues/53038 */ + test('resets name filter when changing the directory', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchInput().fill('folder') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + + await filesListPage.navigateToFolder('folder') + + await expect(filesNavigation.searchInput()).toHaveValue('') + await expect(filesListPage.getRowForFile('text.txt')).toBeVisible() + }) + + test('resets filter when changing the view', async ({ page, filesNavigation, filesListPage }) => { + await filesNavigation.searchInput().fill('folder') + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + + await filesNavigation.getNavigationItem('personal').click() + await expect(page).toHaveURL(/apps\/files\/personal/) + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesNavigation.searchInput()).toHaveValue('') + }) +}) diff --git a/tests/playwright/e2e/files/files-settings.spec.ts b/tests/playwright/e2e/files/files-settings.spec.ts new file mode 100644 index 0000000000000..2b103d635db04 --- /dev/null +++ b/tests/playwright/e2e/files/files-settings.spec.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('files: Set default view', () => { + test('Defaults to the "files" view', async ({ page, filesListPage, filesNavigation }) => { + await filesListPage.open() + + await expect(page).toHaveURL(/\/apps\/files\/files/) + await expect(filesListPage.getBreadcrumbs().getByRole('button').first()).toHaveText('All files') + + const dialog = await filesNavigation.openSettings() + await expect(dialog.getByRole('group', { name: 'Default view' }).getByRole('radio', { name: 'All files' })).toBeChecked() + }) + + test('Can set it to personal files', async ({ page, filesListPage, filesNavigation }) => { + await filesListPage.open() + + const dialog = await filesNavigation.openSettings() + // The radio input is `hidden-visually` and can sit below the dialog fold, so + // clicking its visible label is more reliable than checking the input. + await dialog.getByRole('group', { name: 'Default view' }) + .getByText('Personal files', { exact: true }) + .click() + await expect(dialog.getByRole('group', { name: 'Default view' }).getByRole('radio', { name: 'Personal files' })).toBeChecked() + await filesNavigation.closeSettings() + + await filesListPage.open() + await expect(page).toHaveURL(/\/apps\/files\/personal/) + await expect(filesListPage.getBreadcrumbs().getByRole('button').first()).toHaveText('Personal files') + }) +}) + +test.describe('files: Hide or show hidden files', () => { + // Seed a hidden file, a visible file and a hidden folder for the acting user. + test.beforeEach(async ({ page, user }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/.file') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/visible-file') + await mkdir(page.request, user, '/.folder') + }) + + for (const { view, viewId } of [ + { view: 'All files', viewId: '' }, + { view: 'Personal files', viewId: 'personal' }, + ]) { + test.describe(`view: ${view}`, () => { + test('hides dot-files by default', async ({ filesListPage }) => { + await filesListPage.open(viewId || undefined) + + await expect(filesListPage.getRowForFile('visible-file')).toBeVisible() + await expect(filesListPage.getRowForFile('.file')).toHaveCount(0) + await expect(filesListPage.getRowForFile('.folder')).toHaveCount(0) + }) + + test('can show hidden files', async ({ filesListPage, filesNavigation }) => { + await filesListPage.open(viewId || undefined) + await filesNavigation.setShowHiddenFiles(true) + + await expect(filesListPage.getRowForFile('.file')).toBeVisible() + await expect(filesListPage.getRowForFile('.folder')).toBeVisible() + }) + }) + } + + test.describe('view: Recent files', () => { + // Recent also surfaces files nested in a hidden folder + test.beforeEach(async ({ page, user }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/.folder/other-file') + }) + + test('hides dot-files by default', async ({ filesListPage }) => { + await filesListPage.open('recent') + + await expect(filesListPage.getRowForFile('visible-file')).toBeVisible() + await expect(filesListPage.getRowForFile('.file')).toHaveCount(0) + await expect(filesListPage.getRowForFile('.folder')).toHaveCount(0) + await expect(filesListPage.getRowForFile('other-file')).toHaveCount(0) + }) + + test('can show hidden files', async ({ filesListPage, filesNavigation }) => { + await filesListPage.open('recent') + await filesNavigation.setShowHiddenFiles(true) + + await expect(filesListPage.getRowForFile('visible-file')).toBeVisible() + await expect(filesListPage.getRowForFile('.file')).toBeVisible() + await expect(filesListPage.getRowForFile('.folder')).toBeVisible() + await expect(filesListPage.getRowForFile('other-file')).toBeVisible() + }) + }) +}) diff --git a/tests/playwright/e2e/files/files-sorting.spec.ts b/tests/playwright/e2e/files/files-sorting.spec.ts new file mode 100644 index 0000000000000..261992c20bd99 --- /dev/null +++ b/tests/playwright/e2e/files/files-sorting.spec.ts @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, setFavorite, uploadContent } from '../../support/utils/dav.ts' + +const DAY = 86400 + +test.describe('Files: Sorting the file list', () => { + test('Files are sorted by name ascending by default', async ({ page, user, filesListPage }) => { + const request = page.request + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1 first.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/z last.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/A.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/Ä.txt') + await mkdir(request, user, '/m') + await mkdir(request, user, '/4') + await filesListPage.open() + + // Folders first (4, m), then files by natural name order + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + '4', + 'm', + '1 first.txt', + 'A.txt', + 'Ä.txt', + 'welcome.txt', + 'z last.txt', + ]) + }) + + /** Regression test of https://github.com/nextcloud/server/issues/45829 */ + test('Filenames with numbers are sorted by name ascending by default', async ({ page, user, filesListPage }) => { + const request = page.request + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/name.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/name_03.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/name_02.txt') + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/name_01.txt') + // remove the default file so only the seeded ones are asserted + await filesListPage.open() + + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'name.txt', + 'name_01.txt', + 'name_02.txt', + 'name_03.txt', + 'welcome.txt', + ]) + }) + + test('Can sort by size', async ({ page, user, filesListPage }) => { + const request = page.request + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1 tiny.txt') + await uploadContent(request, user, Buffer.alloc(1024, 'a'), 'text/plain', '/z big.txt') + await uploadContent(request, user, Buffer.alloc(512, 'a'), 'text/plain', '/a medium.txt') + await mkdir(request, user, '/folder') + await filesListPage.open() + + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'folder', + '1 tiny.txt', + 'welcome.txt', + 'a medium.txt', + 'z big.txt', + ]) + + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'folder', + 'z big.txt', + 'a medium.txt', + 'welcome.txt', + '1 tiny.txt', + ]) + }) + + test('Can sort by mtime', async ({ page, user, filesListPage }) => { + const request = page.request + const now = Date.now() / 1000 + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1.txt', now - DAY - 1000) + await uploadContent(request, user, Buffer.alloc(1024, 'a'), 'text/plain', '/z.txt', now - DAY) + await uploadContent(request, user, Buffer.alloc(512, 'a'), 'text/plain', '/a.txt', now - DAY - 500) + await filesListPage.open() + + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['welcome.txt', 'z.txt', 'a.txt', '1.txt']) + + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['1.txt', 'a.txt', 'z.txt', 'welcome.txt']) + }) + + test('Favorites are sorted first', async ({ page, user, filesListPage }) => { + const request = page.request + const now = Date.now() / 1000 + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1.txt', now - DAY - 1000) + await uploadContent(request, user, Buffer.alloc(1024, 'a'), 'text/plain', '/z.txt', now - DAY) + await uploadContent(request, user, Buffer.alloc(512, 'a'), 'text/plain', '/a.txt', now - DAY - 500) + await setFavorite(request, user, '/a.txt') + await filesListPage.open() + + // By name - ascending (default): favorite a.txt first + await expect(filesListPage.getColumnHeader('Name')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', '1.txt', 'welcome.txt', 'z.txt']) + + // By name - descending + await filesListPage.sortByColumn('Name') + await expect(filesListPage.getColumnHeader('Name')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', 'z.txt', 'welcome.txt', '1.txt']) + + // By size - ascending + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', '1.txt', 'welcome.txt', 'z.txt']) + + // By size - descending + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', 'z.txt', 'welcome.txt', '1.txt']) + + // By mtime - ascending + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', 'welcome.txt', 'z.txt', '1.txt']) + + // By mtime - descending + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual(['a.txt', '1.txt', 'z.txt', 'welcome.txt']) + }) + + test('Sorting works after switching view twice', async ({ page, user, filesListPage, filesNavigation }) => { + const request = page.request + await uploadContent(request, user, Buffer.alloc(0), 'text/plain', '/1 tiny.txt') + await uploadContent(request, user, Buffer.alloc(1024, 'a'), 'text/plain', '/z big.txt') + await uploadContent(request, user, Buffer.alloc(512, 'a'), 'text/plain', '/a medium.txt') + await mkdir(request, user, '/folder') + await filesListPage.open() + + // Toggle size sort twice on the files view + await filesListPage.sortByColumn('Size') + await filesListPage.sortByColumn('Size') + + // Switch to personal and toggle twice again + await filesNavigation.getNavigationItem('personal').click() + await filesListPage.sortByColumn('Size') + await filesListPage.sortByColumn('Size') + + // Back to files view and assert sorting still works + await filesNavigation.getNavigationItem('files').click() + + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'ascending') + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'folder', + '1 tiny.txt', + 'welcome.txt', + 'a medium.txt', + 'z big.txt', + ]) + + await filesListPage.sortByColumn('Size') + await expect(filesListPage.getColumnHeader('Size')).toHaveAttribute('aria-sort', 'descending') + await expect.poll(() => filesListPage.getRowNames()).toEqual([ + 'folder', + 'z big.txt', + 'a medium.txt', + 'welcome.txt', + '1 tiny.txt', + ]) + }) +}) diff --git a/tests/playwright/e2e/files/files.spec.ts b/tests/playwright/e2e/files/files.spec.ts new file mode 100644 index 0000000000000..4c1e10d9784cd --- /dev/null +++ b/tests/playwright/e2e/files/files.spec.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files', () => { + test('Login with a user and open the files app', async ({ filesListPage }) => { + await filesListPage.open() + await expect(filesListPage.getRowForFile('welcome.txt')).toBeVisible() + }) + + test('Opens a valid file shows it as active', async ({ page, user, filesListPage }) => { + const fileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/original.txt') + + await page.goto(`apps/files/files/${fileId}`) + + const row = filesListPage.getRowForFileId(Number(fileId)) + await expect(row).toBeVisible() + await expect(row).toHaveAttribute('data-cy-files-list-row-name', 'original.txt') + await expect(row).toBeActiveRow() + await expect(page.getByText('The file could not be found')).toHaveCount(0) + }) + + test('Opens a valid folder shows its content', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/folder') + + await page.goto('apps/files/files?dir=/folder') + await filesListPage.waitForList() + + await expect(filesListPage.getBreadcrumbs()).toContainText('folder') + await expect(page.getByText('The file could not be found')).toHaveCount(0) + }) + + test('Opens an unknown file show an error', async ({ page }) => { + await page.goto('apps/files/files/123456') + + // The error toast is shown once the (failing) PROPFIND resolves + await expect(page.getByText('The file could not be found')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/files/hotkeys.spec.ts b/tests/playwright/e2e/files/hotkeys.spec.ts new file mode 100644 index 0000000000000..92e4c6f83e27c --- /dev/null +++ b/tests/playwright/e2e/files/hotkeys.spec.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, rm } from '../../support/utils/dav.ts' + +test.describe('Files hotkey handling', () => { + // Each test seeds its own user with exactly two folders (abcd, zyx) and no + // welcome.txt, so the keyboard-navigation and delete assertions are isolated + // and parallel-safe. + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/abcd') + await mkdir(page.request, user, '/zyx') + await rm(page.request, user, '/welcome.txt') + await filesListPage.open() + }) + + test('Pressing "arrow down" should go to first file', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowDown') + + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + const fileId = Number(new URL(page.url()).pathname.split('/').at(-1)) + await expect(filesListPage.getRowForFileId(fileId)).toHaveAttribute('data-cy-files-list-row-name', 'abcd') + }) + + test('Pressing "arrow up" should go to last file', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowUp') + + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + const fileId = Number(new URL(page.url()).pathname.split('/').at(-1)) + await expect(filesListPage.getRowForFileId(fileId)).toHaveAttribute('data-cy-files-list-row-name', 'zyx') + }) + + test('Pressing D should open the sidebar once', async ({ page, filesListPage, filesSidebar }) => { + await filesListPage.getFilesList().press('ArrowDown') + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + + await filesListPage.getFilesList().press('d') + + await expect(filesSidebar.sidebar()).toBeVisible() + }) + + test('Pressing F2 should rename the file', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowDown') + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + + await filesListPage.getFilesList().press('F2') + + await expect(filesListPage.getRenameInputForFolder('abcd')).toBeVisible() + }) + + test('Pressing S should toggle favorite', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowDown') + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + + await filesListPage.getFilesList().press('s') + await expect(filesListPage.getFavoriteIconForFile('abcd')).toBeVisible() + + await filesListPage.getFilesList().press('s') + await expect(filesListPage.getFavoriteIconForFile('abcd')).toHaveCount(0) + }) + + test('Pressing DELETE should delete the folder', async ({ page, filesListPage }) => { + await filesListPage.getFilesList().press('ArrowDown') + await expect(page).toHaveURL(/\/apps\/files\/files\/\d+/) + await expect(filesListPage.getRows()).toHaveCount(2) + + await filesListPage.getFilesList().press('Delete') + + await expect(filesListPage.getRows()).toHaveCount(1) + }) +}) diff --git a/tests/playwright/e2e/files/live-photos.spec.ts b/tests/playwright/e2e/files/live-photos.spec.ts new file mode 100644 index 0000000000000..5ee42bc255420 --- /dev/null +++ b/tests/playwright/e2e/files/live-photos.spec.ts @@ -0,0 +1,185 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { LivePhoto } from '../../support/utils/live-photos.ts' + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { uploadContent } from '../../support/utils/dav.ts' +import { setupLivePhotos } from '../../support/utils/live-photos.ts' + +test.describe('Files: Live photos', () => { + let livePhoto: LivePhoto + + test.beforeEach(async ({ page, user, filesListPage }) => { + livePhoto = await setupLivePhotos(page.request, user) + await filesListPage.open() + }) + + test('Only renders the .jpg file', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(1) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toHaveCount(0) + }) + + test.describe("'Show hidden files' is enabled", () => { + test.beforeEach(async ({ filesNavigation, filesListPage }) => { + await filesNavigation.setShowHiddenFiles(true) + // The .mov becomes visible once hidden files are shown + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toBeVisible() + }) + + test("Shows both files when 'Show hidden files' is enabled", async ({ filesListPage }) => { + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}.jpg`) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}.mov`) + }) + + test('Copies both files when copying the .jpg', async ({ filesListPage, copyMoveDialog }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + await filesListPage.reloadCurrentFolder() + + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).mov`)).toHaveCount(1) + }) + + test('Copies both files when copying the .mov', async ({ filesListPage, copyMoveDialog }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.mov`, 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + await filesListPage.reloadCurrentFolder() + + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).mov`)).toHaveCount(1) + }) + + test('Keeps live photo link when copying folder', async ({ filesNavigation, filesListPage, copyMoveDialog }) => { + await filesListPage.createFolder('folder') + + // Move the pair into the folder (the .mov follows the .jpg) + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'move-copy') + await copyMoveDialog.moveToFolder('folder') + // The linked .mov is moved server-side without a client event, so it lingers + // as a stale row and the folder shows a transient "pending" state. Reload for + // a settled listing before acting on the folder (else its menu won't open). + await filesListPage.reloadCurrentFolder() + + // Copy the folder itself into the current directory → "folder (1)" + await filesListPage.triggerActionForFile('folder', 'move-copy') + await copyMoveDialog.copyToCurrentFolder() + + await filesListPage.navigateToFolder('folder (1)') + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + + // With the link intact, hiding hidden files hides the .mov again + await filesNavigation.setShowHiddenFiles(false) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(0) + }) + + test('Blocks copying live photo into a folder with a colliding .mov', async ({ page, user, filesListPage, copyMoveDialog }) => { + await filesListPage.createFolder('folder') + await uploadContent(page.request, user, 'mov file', 'video/mov', `/folder/${livePhoto.fileName}.mov`) + // Reload so the pre-seeded .mov is in the store before the copy + await filesListPage.reloadCurrentFolder() + + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'move-copy') + await copyMoveDialog.copyToFolder('folder') + + await filesListPage.navigateToFolder('folder') + // The copy is rejected because the .mov would collide: only the + // pre-existing .mov remains, neither the .jpg nor a "(1)" copy appears + await expect(filesListPage.getRows()).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(0) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName} (1).jpg`)).toHaveCount(0) + }) + + test('Moves both files when renaming the .jpg', async ({ filesListPage }) => { + await filesListPage.renameFile(`${livePhoto.fileName}.jpg`, `${livePhoto.fileName}_moved.jpg`) + await filesListPage.reloadCurrentFolder() + + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}_moved.jpg`) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}_moved.mov`) + }) + + test('Moves both files when renaming the .mov', async ({ filesListPage }) => { + await filesListPage.renameFile(`${livePhoto.fileName}.mov`, `${livePhoto.fileName}_moved.mov`) + await filesListPage.reloadCurrentFolder() + + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}_moved.jpg`) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)) + .toHaveAttribute('data-cy-files-list-row-name', `${livePhoto.fileName}_moved.mov`) + }) + + test('Deletes both files when deleting the .jpg', async ({ filesListPage }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'delete') + // The clicked file leaves the list reactively; the linked .mov is deleted + // server-side, so reload to see the cascaded removal + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(0) + await filesListPage.reloadCurrentFolder() + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(0) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(0) + + await filesListPage.open('trashbin') + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)) + .toHaveAttribute('data-cy-files-list-row-name', new RegExp(`^${livePhoto.fileName}\\.jpg\\.d[0-9]+$`)) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)) + .toHaveAttribute('data-cy-files-list-row-name', new RegExp(`^${livePhoto.fileName}\\.mov\\.d[0-9]+$`)) + }) + + test('Blocks deletion when deleting the .mov', async ({ filesListPage }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.mov`, 'delete') + await filesListPage.reloadCurrentFolder() + + // Deletion of the video alone is not allowed: both files stay + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + + await filesListPage.open('trashbin') + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(0) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toHaveCount(0) + }) + + test('Restores both files when restoring the .jpg', async ({ filesListPage }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'delete') + await filesListPage.open('trashbin') + + await filesListPage.triggerInlineActionForFileId(livePhoto.jpgFileId, 'restore') + // The clicked file leaves the trashbin reactively; the linked .mov is + // restored server-side, so reload to see the cascaded removal + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(0) + await filesListPage.open('trashbin') + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(0) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toHaveCount(0) + + await filesListPage.open() + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(1) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(1) + }) + + test('Blocks restoration when restoring the .mov', async ({ filesListPage }) => { + await filesListPage.triggerActionForFile(`${livePhoto.fileName}.jpg`, 'delete') + await filesListPage.open('trashbin') + + await filesListPage.triggerInlineActionForFileId(livePhoto.movFileId, 'restore') + await filesListPage.reloadCurrentFolder() + + // Restoring the video alone is not allowed: both stay in the trashbin + await expect(filesListPage.getRowForFileId(livePhoto.jpgFileId)).toHaveCount(1) + await expect(filesListPage.getRowForFileId(livePhoto.movFileId)).toHaveCount(1) + + await filesListPage.open() + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.jpg`)).toHaveCount(0) + await expect(filesListPage.getRowForFile(`${livePhoto.fileName}.mov`)).toHaveCount(0) + }) + }) +}) diff --git a/tests/playwright/e2e/files/new-menu.spec.ts b/tests/playwright/e2e/files/new-menu.spec.ts new file mode 100644 index 0000000000000..37deb08a4dbe2 --- /dev/null +++ b/tests/playwright/e2e/files/new-menu.spec.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator } from '@playwright/test' + +import { expect, test } from '../../support/fixtures/files-page.ts' + +/** Read a native input's constraint-validation message (set by the app). */ +function validationMessage(input: Locator): Promise { + return input.evaluate((el: HTMLInputElement) => el.validationMessage) +} + +test.describe('"New"-menu', () => { + test.beforeEach(async ({ filesListPage }) => { + await filesListPage.open() + }) + + test('Create new folder', async ({ filesListPage }) => { + await filesListPage.createFolder('A new folder') + await expect(filesListPage.getRowForFile('A new folder')).toBeVisible() + }) + + test('Does not allow creating forbidden folder names', async ({ filesListPage }) => { + const dialog = await filesListPage.openNewFolderDialog() + const input = dialog.getByRole('textbox', { name: 'Folder name' }) + await input.fill('.htaccess') + + await expect.poll(() => validationMessage(input)).toMatch(/reserved name/i) + await expect(dialog.getByRole('button', { name: 'Create' })).toBeDisabled() + }) + + test('Does not allow creating folders with already existing names', async ({ filesListPage }) => { + await filesListPage.createFolder('already exists') + + const dialog = await filesListPage.openNewFolderDialog() + const input = dialog.getByRole('textbox', { name: 'Folder name' }) + await input.fill('already exists') + + await expect.poll(() => validationMessage(input)).toMatch(/already in use/i) + await expect(dialog.getByRole('button', { name: 'Create' })).toBeDisabled() + }) + + /** + * Regression test of https://github.com/nextcloud/server/issues/47530 + */ + test('Create same folder in child folder', async ({ filesListPage }) => { + await filesListPage.createFolder('folder') + await filesListPage.createFolder('other folder') + await filesListPage.navigateToFolder('folder') + + const dialog = await filesListPage.openNewFolderDialog() + const input = dialog.getByRole('textbox', { name: 'Folder name' }) + await input.fill('other folder') + + // A same-named folder in a different parent is allowed + await expect.poll(() => validationMessage(input)).toBe('') + await dialog.getByRole('button', { name: 'Create' }).click() + + await expect(filesListPage.getRowForFile('other folder')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/files/router-query.spec.ts b/tests/playwright/e2e/files/router-query.spec.ts new file mode 100644 index 0000000000000..477f77765d418 --- /dev/null +++ b/tests/playwright/e2e/files/router-query.spec.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { test as baseTest, expect } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +type SeededIds = { imageId: number, folderId: number, archiveId: number } + +// Seed an image (known viewer type), a folder and an archive (unknown type). +// The `viewer` app is enabled by default in the test server. +const test = baseTest.extend<{ ids: SeededIds }>({ + ids: async ({ page, user }, use) => { + const image = readFileSync(resolve(process.cwd(), 'cypress/fixtures/image.jpg')) + const imageId = Number(await uploadContent(page.request, user, image, 'image/jpeg', '/image.jpg')) + const folderId = Number(await mkdir(page.request, user, '/folder')) + const archiveId = Number(await uploadContent(page.request, user, Buffer.alloc(0), 'application/zstd', '/archive.zst')) + await use({ imageId, folderId, archiveId }) + }, +}) + +/** Fails the test if a browser download starts during its lifetime. */ +function assertNoDownload(page: Page): void { + page.on('download', (download) => { + throw new Error(`Unexpected download started: ${download.suggestedFilename()}`) + }) +} + +test.describe('Check router query flags', () => { + test.describe('"opendetails"', () => { + for (const { label, key, name } of [ + { label: 'known file type', key: 'imageId' as const, name: 'image.jpg' }, + { label: 'unknown file type', key: 'archiveId' as const, name: 'archive.zst' }, + { label: 'folder', key: 'folderId' as const, name: 'folder' }, + ]) { + test(`open details for ${label}`, async ({ page, ids, filesSidebar }) => { + assertNoDownload(page) + await page.goto(`apps/files/files/${ids[key]}?opendetails`) + + // Sidebar opens for the node … + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading(name)).toBeVisible() + // … but the viewer does not, and nothing is downloaded + await expect(page.getByRole('dialog', { name })).toHaveCount(0) + }) + } + }) + + test.describe('"openfile"', () => { + const viewerShowsImage = async (page: Page, imageId: number) => { + const dialog = page.getByRole('dialog', { name: 'image.jpg' }) + await expect(dialog).toBeVisible() + await expect(dialog.locator(`img[src*="fileId=${imageId}"]`)).toBeVisible() + } + + test('opens files with default action', async ({ page, ids }) => { + await page.goto(`apps/files/files/${ids.imageId}?openfile`) + await viewerShowsImage(page, ids.imageId) + }) + + test('opens files with default action using explicit query state', async ({ page, ids }) => { + await page.goto(`apps/files/files/${ids.imageId}?openfile=true`) + await viewerShowsImage(page, ids.imageId) + }) + + test('does not open files with default action when using explicit `false`', async ({ page, ids, filesListPage }) => { + await page.goto(`apps/files/files/${ids.imageId}?openfile=false`) + + await expect(filesListPage.getRowForFileId(ids.imageId)).toBeActiveRow() + await expect(page.getByRole('dialog', { name: 'image.jpg' })).toHaveCount(0) + }) + + test('does not open folders but shows details', async ({ page, ids, filesSidebar, filesListPage }) => { + await page.goto(`apps/files/files/${ids.folderId}?openfile`) + + // The query is rewritten to opendetails + await expect(page).toHaveURL(/[?&]opendetails(&|=|$)/) + await expect(page).not.toHaveURL(/openfile/) + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('folder')).toBeVisible() + // the folder was not entered + await expect(filesListPage.getRowForFileId(ids.imageId)).toBeVisible() + }) + + test('does not open unknown file types but shows details', async ({ page, ids, filesSidebar }) => { + assertNoDownload(page) + await page.goto(`apps/files/files/${ids.archiveId}?openfile`) + + await expect(page).toHaveURL(/[?&]opendetails(&|=|$)/) + await expect(page).not.toHaveURL(/openfile/) + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('archive.zst')).toBeVisible() + }) + }) +}) diff --git a/tests/playwright/e2e/files/scrolling.spec.ts b/tests/playwright/e2e/files/scrolling.spec.ts new file mode 100644 index 0000000000000..51010e3010f37 --- /dev/null +++ b/tests/playwright/e2e/files/scrolling.spec.ts @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { rm, uploadContent } from '../../support/utils/dav.ts' +import { fitFilesListToRows, isFullyInViewport } from '../../support/utils/viewport.ts' + +/** + * Seed `count` empty files named `1.txt … {count}.txt` for the given user and + * return their file ids keyed by number. Each test seeds its own user, so the + * data is isolated and the suite is safe to run in parallel. + */ +async function seedNumberedFiles(request: APIRequestContext, user: User, count: number): Promise> { + // Drop the default file so only the numbered ones are present + await rm(request, user, '/welcome.txt') + const fileIds: Record = {} + for (let i = 1; i <= count; i++) { + fileIds[i] = Number(await uploadContent(request, user, Buffer.alloc(0), 'text/plain', `/${i}.txt`)) + } + return fileIds +} + +test.describe('Files: Scrolling to the selected file (list view)', () => { + let fileIds: Record + + test.beforeEach(async ({ page, user, filesListPage }) => { + fileIds = await seedNumberedFiles(page.request, user, 10) + await filesListPage.open() + // Fit exactly six rows so four of the ten files are virtualized off-screen + await fitFilesListToRows(page, 6) + }) + + test('shows the first rows and keeps the rest off-screen', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[1]}`) + await filesListPage.waitForList() + + await expect(filesListPage.getRowForFile('1.txt')).toBeVisible() + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('1.txt'))).toBe(true) + // A file well past the fold exists but is not on screen + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('10.txt'))).toBe(false) + }) + + test('scrolls a file below the fold into view', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[8]}`) + await filesListPage.waitForList() + + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('8.txt'))).toBe(true) + }) + + test('scrolls to the last page and reveals the footer', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[10]}`) + await filesListPage.waitForList() + + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('10.txt'))).toBe(true) + // The last page cannot scroll further, so the summary footer comes into view + const footer = filesListPage.getFilesList().locator('tfoot') + await expect(footer).toContainText('10 files') + await expect.poll(() => isFullyInViewport(footer)).toBe(true) + }) +}) + +test.describe('Files: Scrolling to the selected file (grid view)', () => { + let fileIds: Record + + test.beforeEach(async ({ page, user, filesListPage }) => { + fileIds = await seedNumberedFiles(page.request, user, 12) + await filesListPage.open() + await filesListPage.enableGridView() + // Fit exactly three grid rows so the last row is virtualized off-screen + await fitFilesListToRows(page, 3, true) + }) + + test('shows the first grid rows and keeps the last off-screen', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[1]}`) + await filesListPage.waitForList() + + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('1.txt'))).toBe(true) + // A file in the last grid row exists but is not on screen + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('12.txt'))).toBe(false) + }) + + test('scrolls the last grid row into view', async ({ page, filesListPage }) => { + await page.goto(`apps/files/files/${fileIds[12]}`) + await filesListPage.waitForList() + + await expect.poll(() => isFullyInViewport(filesListPage.getRowForFile('12.txt'))).toBe(true) + }) +}) diff --git a/tests/playwright/e2e/files/search.spec.ts b/tests/playwright/e2e/files/search.spec.ts new file mode 100644 index 0000000000000..0f12b0e04b06f --- /dev/null +++ b/tests/playwright/e2e/files/search.spec.ts @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('files: search', () => { + // Seed the same file tree for each test's own user (read-only tests → isolated + // and parallel-safe). + test.beforeEach(async ({ page, user, filesListPage }) => { + const request = page.request + await mkdir(request, user, '/some folder') + await mkdir(request, user, '/some folder/nested folder') + await mkdir(request, user, '/other folder') + await mkdir(request, user, '/12345') + await uploadContent(request, user, 'content', 'text/plain', '/file.txt') + await uploadContent(request, user, 'content', 'text/plain', '/some folder/a file.txt') + await uploadContent(request, user, 'content', 'text/plain', '/some folder/a second file.txt') + await uploadContent(request, user, 'content', 'text/plain', '/some folder/nested folder/deep file.txt') + await uploadContent(request, user, 'content', 'text/plain', '/other folder/another file.txt') + await filesListPage.open() + }) + + test('updates the query on the URL', async ({ page, filesNavigation }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('file') + await expect(page).toHaveURL(/query=file($|&)/) + }) + + test('can search globally', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('file') + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a second file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + }) + + test('filter does also search locally', async ({ filesNavigation, filesListPage }) => { + await filesListPage.navigateToFolder('some folder') + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + + await filesNavigation.searchInput().fill('file') + + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a second file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('deep file.txt')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(3) + }) + + test('See "search everywhere" button', async ({ filesNavigation, filesListPage }) => { + await expect(filesListPage.getSearchEverywhereButton()).toHaveCount(0) + + await filesNavigation.searchInput().fill('file') + await expect(filesListPage.getSearchEverywhereButton()).toBeVisible() + + await filesNavigation.searchClearButton().click() + await expect(filesListPage.getSearchEverywhereButton()).toHaveCount(0) + }) + + test('can make local search a global search', async ({ filesNavigation, filesListPage }) => { + await filesListPage.navigateToFolder('some folder') + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + + await filesNavigation.searchInput().fill('file') + + // local results + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a second file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('deep file.txt')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(3) + + await filesListPage.getSearchEverywhereButton().click() + + // global results + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('deep file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('a second file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + }) + + test('shows empty content when there are no results', async ({ page, filesNavigation, filesListPage }) => { + await filesListPage.navigateToFolder('some folder') + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('xyz') + + const note = page.getByRole('note').filter({ hasText: /No search results for .xyz./ }) + await expect(note).toBeVisible() + await expect(note.getByRole('searchbox', { name: /search for files/i })).toHaveValue('xyz') + }) + + test('can alter search', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('other') + + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('other folder')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(2) + + await filesNavigation.searchInput().fill('other file') + await expect(filesNavigation.searchInput()).toHaveValue('other file') + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(1) + }) + + test('returns to file list if search is cleared', async ({ filesNavigation, filesListPage }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('other') + + await expect(filesListPage.getRowForFile('another file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('other folder')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(2) + + await filesNavigation.searchClearButton().click() + await expect(filesNavigation.searchInput()).toHaveValue('') + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRows()).toHaveCount(5) + }) + + /** + * Regression: refreshing the search view (via the breadcrumb reload) must keep + * the `query` in the URL — guarded by a navigation guard. + */ + test('keeps the query in the URL', async ({ page, filesNavigation, filesListPage }) => { + await filesNavigation.searchEverywhere() + await filesNavigation.searchInput().fill('file') + + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(page).toHaveURL(/query=file($|&)/) + + const search = page.waitForResponse((r) => r.request().method() === 'SEARCH' && r.url().includes('/remote.php/dav/')) + await filesListPage.reloadCurrentFolder() + await search + + await expect(filesListPage.getRowForFile('a file.txt')).toBeVisible() + await expect(page).toHaveURL(/query=file($|&)/) + }) +}) diff --git a/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts b/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts index 2f44ba4b63b85..7614d8c2c9dfb 100644 --- a/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts +++ b/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts @@ -72,8 +72,7 @@ test.describe('Systemtags: Files bulk action', () => { await filesListPage.selectRowForFile('file2.txt') await filesListPage.openTagPickerForSelection() - await filesListPage.getTagPicker().getByRole('checkbox', { name: tag2 }) - .uncheck({ force: true }) + await filesListPage.unselectTagInPicker(tag2) await filesListPage.applyTagPicker() await filesListPage.expectInlineTagsForFile('file1.txt', [tag1]) @@ -95,10 +94,8 @@ test.describe('Systemtags: Files bulk action', () => { await filesListPage.selectRowForFile('file2.txt') await filesListPage.openTagPickerForSelection() - await filesListPage.getTagPicker().getByRole('checkbox', { name: tag2 }) - .uncheck({ force: true }) - await filesListPage.getTagPicker().getByRole('checkbox', { name: tag3 }) - .uncheck({ force: true }) + await filesListPage.unselectTagInPicker(tag2) + await filesListPage.unselectTagInPicker(tag3) await filesListPage.applyTagPicker() await filesListPage.expectInlineTagsForFile('file1.txt', [tag1]) @@ -120,10 +117,7 @@ test.describe('Systemtags: Files bulk action', () => { await filesListPage.selectRowForFile('file2.txt') await filesListPage.openTagPickerForSelection() - await filesListPage.getTagPicker().getByRole('checkbox', { name: tag2 }) - .scrollIntoViewIfNeeded() - await filesListPage.getTagPicker().getByRole('checkbox', { name: tag2 }) - .uncheck({ force: true }) + await filesListPage.unselectTagInPicker(tag2) await filesListPage.createNewTagInPicker(tag3) await filesListPage.applyTagPicker() diff --git a/tests/playwright/support/fixtures/files-page.ts b/tests/playwright/support/fixtures/files-page.ts index 7388323f8155e..ae22da35b36d8 100644 --- a/tests/playwright/support/fixtures/files-page.ts +++ b/tests/playwright/support/fixtures/files-page.ts @@ -4,6 +4,7 @@ */ import { CopyMoveDialogPage } from '../sections/CopyMoveDialogPage.ts' +import { FilesFilterPage } from '../sections/FilesFilterPage.ts' import { FilesListPage } from '../sections/FilesListPage.ts' import { FilesNavigationPage } from '../sections/FilesNavigationPage.ts' import { FilesSidebarPage } from '../sections/FilesSidebarPage.ts' @@ -12,6 +13,7 @@ import { test as baseTest } from './random-user-session.ts' type FilesFixtures = { filesListPage: FilesListPage filesNavigation: FilesNavigationPage + filesFilter: FilesFilterPage filesSidebar: FilesSidebarPage copyMoveDialog: CopyMoveDialogPage } @@ -25,6 +27,10 @@ export const test = baseTest.extend({ await use(new FilesNavigationPage(page)) }, + filesFilter: async ({ page }, use) => { + await use(new FilesFilterPage(page)) + }, + filesSidebar: async ({ page }, use) => { await use(new FilesSidebarPage(page)) }, diff --git a/tests/playwright/support/sections/FilesFilterPage.ts b/tests/playwright/support/sections/FilesFilterPage.ts new file mode 100644 index 0000000000000..d0f1f088423ad --- /dev/null +++ b/tests/playwright/support/sections/FilesFilterPage.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +/** + * The files list filter bar (name filter chip, "Type" filter, active filters). + * Assumes a wide layout where filter categories render as inline buttons — pin a + * wide viewport in specs using it (see files-filtering.spec.ts). + */ +export class FilesFilterPage { + constructor(private readonly page: Page) {} + + private container(): Locator { + return this.page.locator('[data-test-id="files-list-filters"]') + } + + /** Open a filter category's popover (e.g. "Type"). */ + async openFilter(category: string): Promise { + await this.container().getByRole('button', { name: category }).click() + } + + /** A filter option toggle (e.g. "Spreadsheets", "Folders") in the open popover. */ + filterOption(name: string): Locator { + return this.page.getByRole('button', { name }) + } + + /** Close any open filter category popover (clicks the expanded toggles). */ + async closeFilterMenu(): Promise { + for (const toggle of await this.container().locator('button[aria-expanded="true"]').all()) { + await toggle.click() + } + } + + /** The active-filter chips (each an "Active filters" listitem). */ + activeFilters(): Locator { + return this.page.getByRole('list', { name: 'Active filters' }).getByRole('listitem') + } + + /** Remove an active filter by clicking the chip's "Remove filter" button. */ + async removeFilter(name: string | RegExp): Promise { + await this.activeFilters() + .filter({ hasText: name }) + .getByRole('button', { name: 'Remove filter' }) + .click({ force: true }) + } +} diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts index 9ddf94900560c..eccbdd379139c 100644 --- a/tests/playwright/support/sections/FilesListPage.ts +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -17,13 +17,74 @@ export class FilesListPage { */ async open(viewId?: string): Promise { await this.page.goto(viewId ? `apps/files/${viewId}` : 'apps/files') - await this.page.locator('[data-cy-files-list]').waitFor({ state: 'visible' }) + await this.waitForList() + } + + getFilesList(): Locator { + return this.page.locator('[data-cy-files-list]') + } + + /** Wait for the file list container to be rendered (e.g. after a direct goto). */ + async waitForList(): Promise { + await this.getFilesList().waitFor({ state: 'visible' }) + } + + /** + * Switch the list to grid view and wait for the preference to persist. The + * toggle button is labelled "Switch to grid view" (only present in list view). + */ + async enableGridView(): Promise { + const saved = this.page.waitForResponse((r) => r.url().includes('/apps/files/api/v1/config/grid_view')) + await this.page.getByRole('button', { name: 'Switch to grid view' }).click() + await saved + } + + /** The breadcrumbs navigation ("All files › folder › …"). */ + getBreadcrumbs(): Locator { + return this.page.getByRole('navigation', { name: 'Current directory path' }) } getRowForFile(filename: string): Locator { return this.page.locator(`[data-cy-files-list-row-name="${escapeAttributeValue(filename)}"]`) } + /** + * The "Search everywhere" chip shown in the list filter bar once a filter is + * active (turns a local filter into a global search). It is a button, distinct + * from the "Search everywhere" menuitem in the navigation scope menu. + */ + getSearchEverywhereButton(): Locator { + return this.page.getByRole('button', { name: /Search everywhere/i }) + } + + /** + * Reload the current folder via the breadcrumb's menu ("Reload content"). The + * menu toggle is the current-directory breadcrumb carrying `aria-haspopup="menu"` + * (`.last()` skips the collapsed-crumbs overflow menu when the path is deep). + * + * Opening is retried until the "Reload content" item is visible: like the row + * actions menu, the breadcrumb's NcActions can drop the first click while it is + * still (re-)mounting — notably in the search view on slower (CI) machines. Only + * click while the item is hidden so an already-open menu is never toggled shut. + * + * This is fire-and-forget: the reload request differs by view (PROPFIND for a + * folder, SEARCH for the search view, REPORT for favorites), so callers that + * need to await the refetch should wait on the specific response themselves. + */ + async reloadCurrentFolder(): Promise { + const toggle = this.getBreadcrumbs().locator('button[aria-haspopup="menu"]').last() + const reloadItem = this.page.getByRole('menuitem', { name: 'Reload content' }) + + await expect(async () => { + if (!(await reloadItem.isVisible())) { + await toggle.click() + } + await expect(reloadItem).toBeVisible({ timeout: 2000 }) + }).toPass({ timeout: 15000 }) + + await reloadItem.click() + } + getRowForFileId(fileid: number): Locator { return this.page.locator(`[data-cy-files-list-row-fileid="${fileid}"]`) } @@ -33,6 +94,21 @@ export class FilesListPage { return this.page.locator('[data-cy-files-list-row-fileid]') } + /** The rendered row names in visual (DOM) order — for sort assertions. */ + async getRowNames(): Promise { + return this.getRows().evaluateAll((rows) => rows.map((row) => row.getAttribute('data-cy-files-list-row-name') ?? '')) + } + + /** A sortable column header (e.g. "Name", "Size", "Modified") for aria-sort assertions. */ + getColumnHeader(name: string): Locator { + return this.page.getByRole('columnheader', { name }) + } + + /** Click a column header's sort button to toggle sorting by that column. */ + async sortByColumn(name: string): Promise { + await this.getColumnHeader(name).getByRole('button', { name }).click() + } + /** The per-row selection checkboxes. */ getRowCheckboxes(): Locator { return this.page.locator('[data-cy-files-list-row-checkbox]') @@ -57,12 +133,25 @@ export class FilesListPage { const actionsButton = row.getByRole('button', { name: 'Actions' }) await actionsButton.scrollIntoViewIfNeeded() - // force: true to avoid issues with the sticky file list header - await actionsButton.click({ force: true }) - const menuId = await actionsButton.getAttribute('aria-controls') - const menu = this.page.locator(`#${menuId}`) - await menu.waitFor({ state: 'visible' }) + // A row's NcActions can still be (re-)mounting right after a list render + // (e.g. a freshly reloaded folder), so the first click may be a no-op and + // `aria-controls` is present even while the menu is closed. Retry opening + // until the teleported menu is actually visible, clicking only while it is + // closed so we never toggle an open menu shut. force: true dodges the + // sticky list header overlapping the button. + let menu!: Locator + await expect(async () => { + let menuId = await actionsButton.getAttribute('aria-controls') + const alreadyOpen = !!menuId && await this.page.locator(`#${menuId}`).isVisible() + if (!alreadyOpen) { + await actionsButton.click({ force: true }) + menuId = await actionsButton.getAttribute('aria-controls') + } + expect(menuId).toBeTruthy() + menu = this.page.locator(`#${menuId}`) + await expect(menu).toBeVisible({ timeout: 2000 }) + }).toPass({ timeout: 15000 }) return menu } @@ -138,16 +227,58 @@ export class FilesListPage { * renders on hover, so the row must be hovered first. */ async triggerInlineActionForFile(filename: string, actionId: string): Promise { - const row = this.getRowForFile(filename) + await this.triggerInlineActionForRow(this.getRowForFile(filename), actionId) + } + + /** + * Like {@link triggerInlineActionForFile} but addresses the row by file id. + * Trashbin rows carry a `.dNNN` deletion suffix on their name, so they are + * keyed by id (e.g. to restore a specific trashed file). + */ + async triggerInlineActionForFileId(fileid: number, actionId: string): Promise { + await this.triggerInlineActionForRow(this.getRowForFileId(fileid), actionId) + } + + private async triggerInlineActionForRow(row: Locator, actionId: string): Promise { await row.hover() const button = row.locator(`button[data-cy-files-list-row-action="${actionId}"]`) await button.click() } + /** + * Rename a file via its row action and wait for the MOVE to land. `fill` + * replaces the whole name, then Enter commits the rename. + */ + async renameFile(oldName: string, newName: string): Promise { + const moved = this.page.waitForResponse((r) => r.request().method() === 'MOVE' && r.url().includes('/remote.php/dav/files/')) + + await this.triggerActionForFile(oldName, 'rename') + const input = this.getRenameInputForFile(oldName) + await input.fill(newName) + await input.press('Enter') + + await moved + } + getFavoriteIconForFile(filename: string): Locator { return this.getRowForFile(filename).getByRole('img', { name: 'Favorite' }) } + /** The overlay shown while dragging files over the list (product-owned hook). */ + getDropArea(): Locator { + return this.page.locator('[data-cy-files-drag-drop-area]') + } + + /** The main content area that accepts file drops. */ + getContentArea(): Locator { + return this.page.locator('main.app-content') + } + + /** The size cell of a file row (e.g. to assert an uploaded file's size). */ + getRowSizeForFile(filename: string): Locator { + return this.getRowForFile(filename).locator('[data-cy-files-list-row-size]') + } + /** * The inline "Download" button rendered on a row for the default download action. */ @@ -258,23 +389,36 @@ export class FilesListPage { } /** - * Create a folder through the upload picker's "New" menu and wait for the - * MKCOL to land. The upload-picker and new-node-dialog hooks are product-owned - * data-cy attributes (no stable accessible name to target by role). + * Open the upload picker's "New" menu and its "New folder" dialog, returning + * the dialog locator. Use for cases that inspect validation before submitting; + * for the happy path use {@link createFolder}. The upload-picker container has + * no accessible name, so it is still scoped by its product-owned data-cy hook. */ - async createFolder(folderName: string): Promise { - const created = this.page.waitForResponse((r) => r.request().method() === 'MKCOL' && r.url().includes('/remote.php/dav/files/')) - + async openNewFolderDialog(): Promise { + // An empty folder renders a second UploadPicker inside its "no files here" + // placeholder, so two "New" buttons can exist. The list-header picker is + // always present and comes first in the DOM — target it. await this.page.locator('[data-cy-upload-picker]') .getByRole('button', { name: 'New' }) + .first() .click() - await this.page.locator('[data-cy-upload-picker-menu-entry="newFolder"]') - .getByRole('menuitem') - .click() + await this.page.getByRole('menuitem', { name: 'New folder' }).click() + + const dialog = this.page.getByRole('dialog', { name: /create new folder/i }) + await dialog.waitFor({ state: 'visible' }) + return dialog + } + + /** + * Create a folder through the upload picker's "New" menu and wait for the + * MKCOL to land. + */ + async createFolder(folderName: string): Promise { + const created = this.page.waitForResponse((r) => r.request().method() === 'MKCOL' && r.url().includes('/remote.php/dav/files/')) - const dialog = this.page.locator('[data-cy-files-new-node-dialog]') - await dialog.getByRole('textbox').fill(folderName) - await dialog.locator('[data-cy-files-new-node-dialog-submit]').click() + const dialog = await this.openNewFolderDialog() + await dialog.getByRole('textbox', { name: 'Folder name' }).fill(folderName) + await dialog.getByRole('button', { name: 'Create' }).click() await created await this.getRowForFile(folderName).waitFor({ state: 'visible' }) diff --git a/tests/playwright/support/sections/FilesNavigationPage.ts b/tests/playwright/support/sections/FilesNavigationPage.ts index 49c65b0722acb..23f32fafd986c 100644 --- a/tests/playwright/support/sections/FilesNavigationPage.ts +++ b/tests/playwright/support/sections/FilesNavigationPage.ts @@ -12,6 +12,27 @@ import type { Locator, Page } from '@playwright/test' export class FilesNavigationPage { constructor(private readonly page: Page) {} + /** The left-hand "Files" navigation region. */ + navigation(): Locator { + return this.page.getByRole('navigation', { name: 'Files' }) + } + + /** The search/filter input in the navigation. */ + searchInput(): Locator { + return this.navigation().getByRole('searchbox') + } + + searchClearButton(): Locator { + return this.navigation().getByRole('button', { name: /clear search/i }) + } + + /** Switch the search scope to "everywhere" (global search) via the scope menu. */ + async searchEverywhere(): Promise { + await this.navigation().getByRole('button', { name: /search scope options/i }).click() + const menu = this.page.getByRole('menu', { name: /search scope options/i }) + await menu.getByRole('menuitem', { name: /search everywhere/i }).click() + } + /** * A navigation entry, e.g. the "favorites" view. * Uses the product-owned data-cy attribute set on NcAppNavigationItem. @@ -30,4 +51,39 @@ export class FilesNavigationPage { .getByRole('button', { name: 'Open menu' }) .click() } + + /** The "Files settings" dialog opened from the navigation footer. */ + settingsDialog(): Locator { + return this.page.getByRole('dialog', { name: 'Files settings' }) + } + + /** Open the "Files settings" dialog and return its locator. */ + async openSettings(): Promise { + await this.page.getByRole('link', { name: 'Files settings' }).click() + const dialog = this.settingsDialog() + await dialog.waitFor({ state: 'visible' }) + return dialog + } + + async closeSettings(): Promise { + const dialog = this.settingsDialog() + await dialog.getByRole('button', { name: 'Close' }).click() + await dialog.waitFor({ state: 'hidden' }) + } + + /** + * Toggle "Show hidden files" in the Files settings dialog, then close it. + * Opens the dialog itself, so call from any files view. + */ + async setShowHiddenFiles(show: boolean): Promise { + const dialog = await this.openSettings() + const toggle = dialog.getByRole('switch', { name: /show hidden files/i }) + await toggle.scrollIntoViewIfNeeded() + if (show) { + await toggle.check({ force: true }) + } else { + await toggle.uncheck({ force: true }) + } + await this.closeSettings() + } } diff --git a/tests/playwright/support/sections/SystemTagsFilesListPage.ts b/tests/playwright/support/sections/SystemTagsFilesListPage.ts index 9be745a0a92f2..8bda7103d9110 100644 --- a/tests/playwright/support/sections/SystemTagsFilesListPage.ts +++ b/tests/playwright/support/sections/SystemTagsFilesListPage.ts @@ -85,6 +85,24 @@ export class SystemTagsFilesListPage extends FilesListPage { await picker.getByRole('checkbox', { name: new RegExp(tagName, 'i') }).check({ force: true }) } + /** + * Unassign a tag in the already-open picker. Filters the list to the single + * matching tag first: force-clicking a NcCheckboxRadioSwitch's visually-hidden + * input is only reliable when the row is isolated and unobstructed — toggling + * within the full, scrollable list can leave the state unchanged. Asserts the + * checkbox actually cleared so a missed toggle fails loudly. + */ + async unselectTagInPicker(tagName: string): Promise { + const picker = this.getTagPicker() + await picker.waitFor({ state: 'visible' }) + + await picker.getByLabel(/Search.*tag/i).fill(tagName) + const checkbox = picker.getByRole('checkbox', { name: new RegExp(tagName, 'i') }) + await expect(checkbox).toHaveCount(1) + await checkbox.uncheck({ force: true }) + await expect(checkbox).not.toBeChecked() + } + /** * Clicks Apply on the already-open picker and waits for the dialog to close. */ diff --git a/tests/playwright/support/utils/dav.ts b/tests/playwright/support/utils/dav.ts index af7c4efa292e4..5d09efea188aa 100644 --- a/tests/playwright/support/utils/dav.ts +++ b/tests/playwright/support/utils/dav.ts @@ -14,7 +14,7 @@ import type { APIRequestContext } from '@playwright/test' * @param user - The user whose root the path is relative to * @param path - The path of the directory to create (relative to user root) */ -export async function mkdir(request: APIRequestContext, user: User, path: string): Promise { +export async function mkdir(request: APIRequestContext, user: User, path: string): Promise { const requesttoken = await getRequestToken(request) const response = await request.fetch(davUrl(user, path), { method: 'MKCOL', @@ -23,6 +23,8 @@ export async function mkdir(request: APIRequestContext, user: User, path: string if (!response.ok()) { throw new Error(`MKCOL ${path} failed with status ${response.status()}`) } + const fileId = response.headers()['oc-fileid'] + return fileId ? String(parseInt(fileId, 10)) : '0' } /** @@ -33,6 +35,7 @@ export async function mkdir(request: APIRequestContext, user: User, path: string * @param content The content to upload * @param mimeType The MIME type of the content * @param path The path to upload to (relative to user root) + * @param mtime Optional modification time in seconds (sets the `X-OC-MTime` header) * @return The file ID from the oc-fileid response header */ export async function uploadContent( @@ -41,6 +44,7 @@ export async function uploadContent( content: Buffer | string, mimeType: string, path: string, + mtime?: number, ): Promise { const requesttoken = await getRequestToken(request) const response = await request.fetch(davUrl(user, path), { @@ -48,6 +52,7 @@ export async function uploadContent( headers: { 'Content-Type': mimeType, requesttoken, + ...(mtime !== undefined ? { 'X-OC-MTime': String(Math.floor(mtime)) } : {}), }, data: content, }) @@ -58,6 +63,52 @@ export async function uploadContent( return fileId ? String(parseInt(fileId, 10)) : '0' } +/** + * Mark a file or folder as (un)favorite via PROPPATCH of the `oc:favorite` prop. + * + * @param request - The Playwright API request context + * @param user - The user whose root the path is relative to + * @param path - The path to (un)favorite (relative to user root) + * @param favorite - Whether to set (default) or clear the favorite flag + */ +export async function setFavorite(request: APIRequestContext, user: User, path: string, favorite = true): Promise { + const requesttoken = await getRequestToken(request) + const response = await request.fetch(davUrl(user, path), { + method: 'PROPPATCH', + headers: { requesttoken, 'Content-Type': 'application/xml' }, + data: `${favorite ? 1 : 0}`, + }) + if (!response.ok()) { + throw new Error(`PROPPATCH (favorite) ${path} failed with status ${response.status()}`) + } +} + +/** + * Link a file to its live-photo counterpart via PROPPATCH of the + * `nc:metadata-files-live-photo` prop (the file id of the paired media file). + * + * A live photo is a still image (.jpg) paired with a short video (.mov); each + * file stores the other's file id in this property, and the Files app treats the + * pair as a single unit (hiding the video, and copying/moving/deleting/restoring + * both together). Seed the link on both files for a complete pair. + * + * @param request - The Playwright API request context + * @param user - The user whose root the path is relative to + * @param path - The path of the file to annotate (relative to user root) + * @param linkedFileId - The file id of the paired media file + */ +export async function setLivePhotoMetadata(request: APIRequestContext, user: User, path: string, linkedFileId: number): Promise { + const requesttoken = await getRequestToken(request) + const response = await request.fetch(davUrl(user, path), { + method: 'PROPPATCH', + headers: { requesttoken, 'Content-Type': 'application/xml' }, + data: `${linkedFileId}`, + }) + if (!response.ok()) { + throw new Error(`PROPPATCH (live-photo) ${path} failed with status ${response.status()}`) + } +} + /** * Delete a file or directory at the given path for the given user. * diff --git a/tests/playwright/support/utils/drag-drop.ts b/tests/playwright/support/utils/drag-drop.ts new file mode 100644 index 0000000000000..0abccd5231618 --- /dev/null +++ b/tests/playwright/support/utils/drag-drop.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { JSHandle, Locator, Page } from '@playwright/test' + +export interface DroppedFile { + name: string + content: string + /** MIME type; use `httpd/unix-directory` to simulate a dropped folder. */ + type?: string +} + +/** + * Build a `DataTransfer` (as a JSHandle) populated with the given files, for + * dispatching synthetic drag/drop events. A programmatically-created + * DataTransfer has no working FileSystem API (`webkitGetAsEntry`), so the app + * falls back to the legacy File API path — which is what these tests exercise. + * + * @param page - The Playwright page + * @param files - The files to place on the DataTransfer + */ +export function createFileDataTransfer(page: Page, files: DroppedFile[]): Promise> { + return page.evaluateHandle((files) => { + const dataTransfer = new DataTransfer() + for (const file of files) { + dataTransfer.items.add(new File([file.content], file.name, { type: file.type || 'text/plain' })) + } + return dataTransfer + }, files) +} + +/** + * Simulate dropping files onto a target by dispatching a synthetic `drop` event + * carrying a file-populated DataTransfer. + * + * @param target - The element to drop onto + * @param dataTransfer - A DataTransfer handle from {@link createFileDataTransfer} + */ +export async function dropFilesOn(target: Locator, dataTransfer: JSHandle): Promise { + await target.dispatchEvent('dragenter', { dataTransfer }) + await target.dispatchEvent('dragover', { dataTransfer }) + await target.dispatchEvent('drop', { dataTransfer }) +} diff --git a/tests/playwright/support/utils/live-photos.ts b/tests/playwright/support/utils/live-photos.ts new file mode 100644 index 0000000000000..15acfc16e204f --- /dev/null +++ b/tests/playwright/support/utils/live-photos.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { setLivePhotoMetadata, uploadContent } from './dav.ts' + +export interface LivePhoto { + /** The base name shared by the .jpg and .mov (without extension). */ + fileName: string + jpgFileId: number + movFileId: number +} + +/** + * Seed a live photo for the given user: upload a paired .jpg + .mov at the user + * root and cross-link them via the `nc:metadata-files-live-photo` metadata so the + * Files app treats them as a single unit. Returns the base name and both file ids. + * + * The acting user is isolated per test, so a fixed base name is unambiguous; pass + * one only when a test needs a specific value. + * + * @param request - The Playwright API request context (authenticated as the user) + * @param user - The user to seed the live photo for + * @param fileName - The base name to use (defaults to `live-photo`) + */ +export async function setupLivePhotos(request: APIRequestContext, user: User, fileName = 'live-photo'): Promise { + const jpgFileId = Number(await uploadContent(request, user, 'jpg file', 'image/jpg', `/${fileName}.jpg`)) + const movFileId = Number(await uploadContent(request, user, 'mov file', 'video/mov', `/${fileName}.mov`)) + + await setLivePhotoMetadata(request, user, `/${fileName}.jpg`, movFileId) + await setLivePhotoMetadata(request, user, `/${fileName}.mov`, jpgFileId) + + return { fileName, jpgFileId, movFileId } +} diff --git a/tests/playwright/support/utils/viewport.ts b/tests/playwright/support/utils/viewport.ts new file mode 100644 index 0000000000000..ce61737242b38 --- /dev/null +++ b/tests/playwright/support/utils/viewport.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' + +/** + * The fixed per-item heights the files virtual list renders with, mirroring the + * `itemHeight` getter in `apps/files/src/components/VirtualList.vue`. Using the + * product's own constants keeps the viewport maths in step with the component + * instead of measuring a rendered row (which is unreliable in grid mode). + */ +const ITEM_HEIGHT = { list: 44, grid: 166 + 32 + 16 + 16 + 16 } + +/** The list widths that yield a stable column count: 1 column in list mode, 3 in grid mode. */ +const WIDTH = { list: 1280, grid: 768 } + +/** + * Size the viewport so the files list shows exactly `rows` item rows, forcing the + * rest to be virtualized off-screen. This makes "scroll to the selected file" + * assertions deterministic regardless of the runner's default window size. + * + * The list fills a flex area, so the chrome above/around it is constant: we + * measure it once, then set the height to `chrome + stickyHeader + rows * itemHeight`. + * The viewport persists across navigations, so callers set it once before visiting. + * + * @param page - The Playwright page + * @param rows - The number of item rows that should fit + * @param gridMode - Whether the list is in grid mode (taller items, 3 columns) + */ +export async function fitFilesListToRows(page: Page, rows: number, gridMode = false): Promise { + const width = gridMode ? WIDTH.grid : WIDTH.list + const itemHeight = gridMode ? ITEM_HEIGHT.grid : ITEM_HEIGHT.list + + await page.setViewportSize({ width, height: 720 }) + await page.locator('[data-cy-files-list-tbody] tr').first().waitFor({ state: 'visible' }) + + // `visibleRows` in VirtualList is floor((tableHeight - thead - filters) / itemHeight), + // where tableHeight is the list's own height and the chrome around it is fixed. + const chromeAndHeader = await page.evaluate(() => { + const height = (selector: string) => document.querySelector(selector)?.offsetHeight ?? 0 + const list = document.querySelector('[data-cy-files-list]') + const chrome = window.innerHeight - (list?.offsetHeight ?? 0) + return chrome + height('[data-cy-files-list-thead]') + height('.files-list__filters') + }) + + await page.setViewportSize({ width, height: Math.ceil(chromeAndHeader + rows * itemHeight) }) +} + +/** + * Whether the locator's box lies fully within the current viewport (top-to-bottom + * and left-to-right). Playwright's `toBeVisible` only checks the DOM/paint state, + * not the scroll position, so this is what tells a scrolled-off buffered row from + * a genuinely on-screen one. + * + * @param locator - The element to test + */ +export async function isFullyInViewport(locator: Locator): Promise { + await expect(locator).toHaveCount(1) // ensure the locator resolves to a single element + + const box = await locator.boundingBox() + if (!box) { + return false + } + const size = locator.page().viewportSize() + if (!size) { + return false + } + return box.x >= 0 + && box.y >= 0 + && box.x + box.width <= size.width + && box.y + box.height <= size.height +} From 3ce99632d8d4029dd0dadd02256493a5a3df3307 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 3 Jul 2026 16:34:42 +0200 Subject: [PATCH 2/2] ci: move one runner from cypress to playwright Signed-off-by: Ferdinand Thiessen --- .github/workflows/cypress.yml | 4 ++-- .github/workflows/playwright.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 160c3c4dfdb88..a819385947434 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -141,10 +141,10 @@ jobs: matrix: # Run multiple copies of the current job in parallel # Please increase the number or runners as your tests suite grows (0 based index for e2e tests) - containers: ['setup', '0', '1', '2', '3', '4'] + containers: ['setup', '0', '1', '2', '3'] # Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions # Always align this number with the total of e2e runners (max. index + 1) - total-containers: [5] + total-containers: [4] services: mysql: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 7f36a406a797c..a4f203b690ed7 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -82,8 +82,8 @@ jobs: strategy: fail-fast: false matrix: - shardIndex: [1, 2, 3, 4, 5] - shardTotal: [5] + shardIndex: [1, 2, 3, 4, 5, 6] + shardTotal: [6] outputs: node-version: ${{ steps.versions.outputs.node-version }} package-manager-version: ${{ steps.versions.outputs.package-manager-version }}