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 }}
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}${key}>`).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
+}