diff --git a/cypress/e2e/files/files-delete.cy.ts b/cypress/e2e/files/files-delete.cy.ts deleted file mode 100644 index b1af310d9b6..00000000000 --- a/cypress/e2e/files/files-delete.cy.ts +++ /dev/null @@ -1,70 +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 { getRowForFile, navigateToFolder, selectAllFiles, triggerActionForFile, triggerSelectionAction } from './FilesUtils.ts' - -describe('files: Delete files using file actions', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - }) - - it('can delete file', () => { - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.login(user) - cy.visit('/apps/files') - - // The file must exist and the preview loaded as it locks the file - getRowForFile('file.txt') - .should('be.visible') - .find('.files-list__row-icon-preview--loaded') - .should('exist') - - cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') - - triggerActionForFile('file.txt', 'delete') - cy.wait('@deleteFile').its('response.statusCode').should('eq', 204) - }) - - it('can delete multiple files', () => { - cy.mkdir(user, '/root') - for (let i = 0; i < 5; i++) { - cy.uploadContent(user, new Blob([]), 'text/plain', `/root/file${i}.txt`) - } - cy.login(user) - cy.visit('/apps/files') - navigateToFolder('/root') - - // The file must exist and the preview loaded as it locks the file - cy.get('.files-list__row-icon-preview--loaded') - .should('have.length', 5) - - cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') - - // select all - selectAllFiles() - triggerSelectionAction('delete') - - // see dialog for confirmation - cy.findByRole('dialog', { name: 'Confirm deletion' }) - .findByRole('button', { name: 'Delete files' }) - .click() - - cy.wait('@deleteFile') - cy.get('@deleteFile.all') - .should('have.length', 5) - - .should((all: any) => { - for (const call of all) { - expect(call.response.statusCode).to.equal(204) - } - }) - }) -}) diff --git a/cypress/e2e/files/files-navigation.cy.ts b/cypress/e2e/files/files-navigation.cy.ts deleted file mode 100644 index 9fd74097deb..00000000000 --- a/cypress/e2e/files/files-navigation.cy.ts +++ /dev/null @@ -1,56 +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 { getRowForFile, navigateToFolder } from './FilesUtils.ts' - -describe('files: Navigate through folders and observe behavior', () => { - let user: User - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.mkdir(user, '/foo') - cy.mkdir(user, '/foo/bar') - cy.mkdir(user, '/foo/bar/baz') - }) - }) - - it('Shows root folder and we can navigate to the last folder', () => { - cy.login(user) - cy.visit('/apps/files/') - - getRowForFile('foo').should('be.visible') - navigateToFolder('/foo/bar/baz') - - // Last folder is empty - cy.get('[data-cy-files-list-row-fileid]').should('not.exist') - }) - - it('Highlight the previous folder when navigating back', () => { - cy.go('back') - getRowForFile('baz').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - - cy.go('back') - getRowForFile('bar').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - - cy.go('back') - getRowForFile('foo').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - }) - - it('Can navigate forward again', () => { - cy.go('forward') - getRowForFile('bar').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - - cy.go('forward') - getRowForFile('baz').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - }) -}) diff --git a/cypress/e2e/files/files-renaming.cy.ts b/cypress/e2e/files/files-renaming.cy.ts deleted file mode 100644 index 200bf6779dc..00000000000 --- a/cypress/e2e/files/files-renaming.cy.ts +++ /dev/null @@ -1,288 +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 { calculateViewportHeight, createFolder, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils.ts' - -describe('files: Rename nodes', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - - // remove welcome file - cy.rm(user, '/welcome.txt') - // create a file called "file.txt" - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - - // login and visit files app - cy.login(user) - }) - cy.visit('/apps/files') - }) - - it('can rename a file', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}other.txt') - .should(haveValidity('')) - .type('{enter}') - - // See it is renamed - getRowForFile('other.txt').should('be.visible') - }) - - /** - * If this test gets flaky than we have a problem: - * It means that the selection is not reliable set to the basename - */ - it('only selects basename of file', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .should((el) => { - const input = el.get(0) as HTMLInputElement - expect(input.selectionStart).to.equal(0) - expect(input.selectionEnd).to.equal('file'.length) - }) - }) - - it('show validation error on file rename', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}.htaccess') - // See validity - .should(haveValidity(/reserved name/i)) - }) - - it('shows accessible loading information', () => { - const { resolve, promise } = Promise.withResolvers() - - getRowForFile('file.txt').should('be.visible') - - // intercept the rename (MOVE) - // the callback will wait until the promise resolve (so we have time to check the loading state) - cy.intercept( - 'MOVE', - /\/remote.php\/dav\/files\//, - (request) => { - // we need to wait in the onResponse handler as the intercept handler times out otherwise - request.on('response', async () => { - await promise - }) - }, - ).as('moveFile') - - // Start the renaming - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}new-name.txt{enter}') - - // Loading state is visible - getRowForFile('new-name.txt') - .findByRole('img', { name: 'File is loading' }) - .should('be.visible') - // checkbox is not visible - getRowForFile('new-name.txt') - .findByRole('checkbox', { name: /^Toggle selection/ }) - .should('not.exist') - - cy.log('Resolve promise to preoceed with MOVE request') - .then(() => resolve()) - - // Ensure the request is done (file renamed) - cy.wait('@moveFile') - - // checkbox visible again - getRowForFile('new-name.txt') - .findByRole('checkbox', { name: /^Toggle selection/ }) - .should('exist') - // see the loading state is gone - getRowForFile('new-name.txt') - .findByRole('img', { name: 'File is loading' }) - .should('not.exist') - }) - - it('cancel renaming on esc press', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}other.txt') - .should(haveValidity('')) - .type('{esc}') - - // See it is not renamed - getRowForFile('other.txt').should('not.exist') - getRowForFile('file.txt') - .should('be.visible') - .find('input[type="text"]') - .should('not.exist') - }) - - it('cancel on enter if no new name is entered', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{enter}') - - // See it is not renamed - getRowForFile('file.txt') - .should('be.visible') - .find('input[type="text"]') - .should('not.exist') - }) - - /** - * This is a regression test of: https://github.com/nextcloud/server/issues/47438 - * The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list - * due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling). - */ - it('correctly resets renaming state', () => { - // Create 19 additional files - for (let i = 1; i <= 19; i++) { - cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`) - } - - // Calculate and setup a viewport where only the first 4 files are visible, causing 6 rows to be rendered - cy.viewport(768, 500) - cy.login(user) - calculateViewportHeight(4) - .then((height) => cy.viewport(768, height)) - - cy.visit('/apps/files') - - getRowForFile('file.txt') - .should('be.visible') - // Z so it is shown last - renameFile('file.txt', 'zzz.txt') - // not visible any longer - getRowForFile('zzz.txt') - .should('not.exist') - // scroll file list to bottom - cy.get('[data-cy-files-list]') - .scrollTo('bottom') - cy.screenshot() - // The file is no longer in rename state - getRowForFile('zzz.txt') - .should('be.visible') - .findByRole('textbox', { name: 'Filename' }) - .should('not.exist') - }) - - it('shows warning on extension change - select new extension', () => { - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}file.md') - .type('{enter}') - - // See warning dialog - cy.findByRole('dialog', { name: 'Change file extension' }) - .should('be.visible') - .findByRole('button', { name: 'Use .md' }) - .click() - - // See it is renamed - getRowForFile('file.md').should('be.visible') - }) - - it('shows warning on extension change - select old extension', () => { - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}document.md') - .type('{enter}') - - // See warning dialog - cy.findByRole('dialog', { name: 'Change file extension' }) - .should('be.visible') - .findByRole('button', { name: 'Keep .txt' }) - .click() - - // See it is renamed - getRowForFile('document.txt').should('be.visible') - }) - - it('shows warning on extension removal', () => { - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}file') - .type('{enter}') - - cy.findByRole('dialog', { name: 'Change file extension' }) - .should('be.visible') - .findByRole('button', { name: 'Keep .txt' }) - .should('be.visible') - cy.findByRole('dialog', { name: 'Change file extension' }) - .findByRole('button', { name: 'Remove extension' }) - .should('be.visible') - .click() - - // See it is renamed - getRowForFile('file').should('be.visible') - getRowForFile('file.txt').should('not.exist') - }) - - it('does not show warning on folder renaming with a dot', () => { - createFolder('folder.2024') - - getRowForFile('folder.2024').should('be.visible') - - triggerActionForFile('folder.2024', 'rename') - getRowForFile('folder.2024') - .findByRole('textbox', { name: 'Folder name' }) - .should('be.visible') - .type('{selectAll}folder.2025') - .should(haveValidity('')) - .type('{enter}') - - // See warning dialog - cy.get('[role=dialog]').should('not.exist') - - // See it is not renamed - getRowForFile('folder.2025').should('be.visible') - }) -}) diff --git a/cypress/e2e/files/files-sidebar.cy.ts b/cypress/e2e/files/files-sidebar.cy.ts deleted file mode 100644 index 69be3473a80..00000000000 --- a/cypress/e2e/files/files-sidebar.cy.ts +++ /dev/null @@ -1,137 +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 { assertNotExistOrNotVisible } from '../settings/usersUtils.ts' -import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils.ts' - -describe('Files: Sidebar', { testIsolation: true }, () => { - let user: User - let fileId: number = 0 - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/file').then((response) => { - fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') - }) - cy.login(user) - })) - - it('opens the sidebar', () => { - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - - triggerActionForFile('file', 'details') - - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'file' }) - .should('be.visible') - }) - - it('changes the current fileid', () => { - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - - triggerActionForFile('file', 'details') - - cy.get('[data-cy-sidebar]').should('be.visible') - cy.url().should('contain', `apps/files/files/${fileId}`) - }) - - it('changes the sidebar content on other file', () => { - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - - triggerActionForFile('file', 'details') - - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'file' }) - .should('be.visible') - - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(600) // wait for a bit to avoid flakiness - - triggerActionForFile('folder', 'details') - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'folder' }) - .should('be.visible') - }) - - it('closes the sidebar on navigation', () => { - cy.visit('/apps/files') - - getRowForFile('file').should('be.visible') - getRowForFile('folder').should('be.visible') - - // open the sidebar - triggerActionForFile('file', 'details') - // validate it is open - cy.get('[data-cy-sidebar]') - .should('be.visible') - - // if we navigate to the folder - navigateToFolder('folder') - // the sidebar should not be visible anymore - cy.get('[data-cy-sidebar]') - .should(assertNotExistOrNotVisible) - }) - - it('closes the sidebar on delete', () => { - cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/file`).as('deleteFile') - // visit the files app - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - // open the sidebar - triggerActionForFile('file', 'details') - // validate it is open - cy.get('[data-cy-sidebar]') - .should('be.visible') - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(600) // wait for a bit to avoid flakiness - - // delete the file - triggerActionForFile('file', 'delete') - cy.wait('@deleteFile', { timeout: 10000 }) - // see the sidebar is closed - cy.get('[data-cy-sidebar]') - .should(assertNotExistOrNotVisible) - }) - - it('changes the fileid on delete', () => { - cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/folder/other`).as('deleteFile') - - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/other').then((response) => { - const otherFileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') - cy.login(user) - cy.visit('/apps/files') - - getRowForFile('folder').should('be.visible') - navigateToFolder('folder') - getRowForFile('other').should('be.visible') - - // open the sidebar - triggerActionForFile('other', 'details') - // validate it is open - cy.get('[data-cy-sidebar]').should('be.visible') - cy.url().should('contain', `apps/files/files/${otherFileId}`) - - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(600) // wait for a bit to avoid flakiness - - triggerActionForFile('other', 'delete') - cy.wait('@deleteFile') - - cy.get('[data-cy-sidebar]').should('not.be.visible') - // Ensure the URL is changed - cy.url().should('not.contain', `apps/files/files/${otherFileId}`) - }) - }) -}) diff --git a/tests/playwright/e2e/files/files-delete.spec.ts b/tests/playwright/e2e/files/files-delete.spec.ts new file mode 100644 index 00000000000..f62bff950ef --- /dev/null +++ b/tests/playwright/e2e/files/files-delete.spec.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files: Delete', () => { + test('can delete a file', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await filesListPage.open() + + const row = filesListPage.getRowForFile('file.txt') + await expect(row).toBeVisible() + // Preview must finish loading before delete — a loading preview can lock the file + await expect(row.locator('.files-list__row-icon-preview--loaded')).toBeVisible() + + const deleteResponse = page.waitForResponse( + (r) => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'DELETE', + { timeout: 10000 }, + ) + await filesListPage.triggerActionForFile('file.txt', 'delete') + expect((await deleteResponse).status()).toBe(204) + }) + + test('can delete multiple files', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/root') + for (let i = 0; i < 5; i++) { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/root/file${i}.txt`) + } + await filesListPage.open() + await filesListPage.navigateToFolder('root') + + // All 5 preview thumbnails must finish loading before we delete + await expect(page.locator('.files-list__row-icon-preview--loaded')).toHaveCount(5) + + // Set up listeners for all 5 DELETE responses before triggering the action + const deleteResponses = Promise.all( + Array.from({ length: 5 }, () => + page.waitForResponse( + (r) => r.url().includes(`/remote.php/dav/files/${user.userId}/root/`) && r.request().method() === 'DELETE', + { timeout: 15000 }, + ), + ), + ) + + await filesListPage.selectAll() + await filesListPage.triggerSelectionAction('delete') + + await page.getByRole('dialog', { name: 'Confirm deletion' }) + .getByRole('button', { name: 'Delete files' }) + .click() + + const responses = await deleteResponses + for (const response of responses) { + expect(response.status()).toBe(204) + } + }) +}) diff --git a/tests/playwright/e2e/files/files-navigation.spec.ts b/tests/playwright/e2e/files/files-navigation.spec.ts new file mode 100644 index 00000000000..6a867404951 --- /dev/null +++ b/tests/playwright/e2e/files/files-navigation.spec.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../../support/fixtures/files-page.ts' +import { mkdir } from '../../support/utils/dav.ts' + +test.describe('Files: Navigation', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/foo') + await mkdir(page.request, user, '/foo/bar') + await mkdir(page.request, user, '/foo/bar/baz') + await filesListPage.open() + }) + + test('shows root folder and can navigate to a deeply nested folder', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo')).toBeVisible() + await filesListPage.navigateToFolder('foo/bar/baz') + + // deepest folder is empty — no file rows rendered + await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0) + }) + + test('highlights the previous folder when navigating back and forward', async ({ page, filesListPage }) => { + await filesListPage.navigateToFolder('foo/bar/baz') + await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0) + + // Navigate back through each level — the folder we came from is highlighted + await page.goBack() + await expect(filesListPage.getRowForFile('baz')).toBeVisible() + await expect(filesListPage.getRowForFile('baz')).toBeActiveRow() + + await page.goBack() + await expect(filesListPage.getRowForFile('bar')).toBeVisible() + await expect(filesListPage.getRowForFile('bar')).toBeActiveRow() + + await page.goBack() + await expect(filesListPage.getRowForFile('foo')).toBeVisible() + await expect(filesListPage.getRowForFile('foo')).toBeActiveRow() + + // Navigate forward — the folder we re-entered is highlighted + await page.goForward() + await expect(filesListPage.getRowForFile('bar')).toBeVisible() + await expect(filesListPage.getRowForFile('bar')).toBeActiveRow() + + await page.goForward() + await expect(filesListPage.getRowForFile('baz')).toBeVisible() + await expect(filesListPage.getRowForFile('baz')).toBeActiveRow() + }) +}) diff --git a/tests/playwright/e2e/files/files-renaming.spec.ts b/tests/playwright/e2e/files/files-renaming.spec.ts new file mode 100644 index 00000000000..ffe9d1ef82a --- /dev/null +++ b/tests/playwright/e2e/files/files-renaming.spec.ts @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../../support/fixtures/files-page.ts' +import { mkdir, rm, uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files: Rename nodes', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + // New users get welcome.txt — remove it so the list contains only our test files + await rm(page.request, user, '/welcome.txt') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await filesListPage.open() + }) + + test('can rename a file', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.fill('other.txt') + await expect(input).toHaveValidationMessage('') + await input.press('Enter') + + await expect(filesListPage.getRowForFile('other.txt')).toBeVisible() + }) + + /** + * If this test gets flaky then the selection is not reliably set to the basename. + * The selection should cover only the name part (without extension) when rename opens. + */ + test('only selects basename of file on rename open', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + + const { selectionStart, selectionEnd } = await input.evaluate( + (el) => ({ selectionStart: (el as HTMLInputElement).selectionStart, selectionEnd: (el as HTMLInputElement).selectionEnd }), + ) + expect(selectionStart).toBe(0) + expect(selectionEnd).toBe('file'.length) + }) + + test('shows validation error on invalid filename', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.fill('.htaccess') + + await expect(input).toHaveValidationMessage(/reserved name/i) + }) + + test('shows accessible loading state while rename MOVE is in-flight', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + // Hold MOVE requests until we explicitly release them + let resolveMove!: () => void + const moveAllowed = new Promise(resolve => { resolveMove = resolve }) + await page.route(/remote\.php\/dav\/files\//, async (route) => { + if (route.request().method() === 'MOVE') { + await moveAllowed + } + await route.continue() + }) + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('new-name.txt') + await input.press('Enter') + + // While MOVE is blocked: row shows loading icon, checkbox is hidden + const loadingRow = filesListPage.getRowForFile('new-name.txt') + await expect(loadingRow.getByRole('img', { name: 'File is loading' })).toBeVisible() + await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).not.toBeVisible() + + // Release the MOVE and wait for it to complete + const moveResponse = page.waitForResponse( + r => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE', + ) + resolveMove() + await moveResponse + await page.unroute(/remote\.php\/dav\/files\//) + + // Loading state clears: checkbox reappears, loading icon gone + await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).toBeVisible() + await expect(loadingRow.getByRole('img', { name: 'File is loading' })).not.toBeVisible() + }) + + test('cancel renaming on Escape', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.fill('other.txt') + await expect(input).toHaveValidationMessage('') + await input.press('Escape') + + // Original name kept, rename input removed + await expect(filesListPage.getRowForFile('other.txt')).toHaveCount(0) + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible() + }) + + test('cancel renaming on Enter when name is unchanged', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.press('Enter') + + // No rename happened, input is gone + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible() + }) + + /** + * Regression: https://github.com/nextcloud/server/issues/47438 + * Virtual scrolling removed the renaming component from DOM before state reset, + * leaving the row permanently stuck in rename mode. + */ + test('correctly resets renaming state after virtual-scroll re-render', async ({ page, user, filesListPage }) => { + // Create 19 more files so virtual scrolling kicks in with a small viewport + for (let i = 1; i <= 19; i++) { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/file${i}.txt`) + } + + // Start with a small viewport so only a few rows fit + await page.setViewportSize({ width: 768, height: 500 }) + await filesListPage.open() + + // Measure the DOM to calculate the exact height that shows only 4 rows + const viewportHeight = await page.evaluate(() => { + const filesList = document.querySelector('[data-cy-files-list]') as HTMLElement + const outerHeight = window.innerHeight - filesList.clientHeight + const beforeHeight = (document.querySelector('.files-list__before') as HTMLElement)?.offsetHeight ?? 0 + const filterHeight = (document.querySelector('.files-list__filters') as HTMLElement)?.offsetHeight ?? 0 + const theadHeight = (document.querySelector('[data-cy-files-list-thead]') as HTMLElement)?.offsetHeight ?? 0 + const rowHeight = (document.querySelector('[data-cy-files-list-tbody] tr') as HTMLElement)?.offsetHeight ?? 0 + return outerHeight + beforeHeight + filterHeight + theadHeight + 4 * rowHeight + }) + await page.setViewportSize({ width: 768, height: viewportHeight }) + await filesListPage.open() + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + // Rename to 'zzz.txt' — sorts last, scrolls out of the visible area + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + const moveResponse = page.waitForResponse( + r => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE', + ) + await input.fill('zzz.txt') + await input.press('Enter') + await moveResponse + + // After rename zzz.txt is sorted to the end — no longer in the visible viewport + await expect(filesListPage.getRowForFile('zzz.txt')).toHaveCount(0) + + // Scroll to the bottom to bring zzz.txt into view + await page.locator('[data-cy-files-list]').evaluate(el => el.scrollTo(0, el.scrollHeight)) + + // Row must be visible and NOT in rename state + await expect(filesListPage.getRowForFile('zzz.txt')).toBeVisible() + await expect(filesListPage.getRenameInputForFile('zzz.txt')).not.toBeVisible() + }) + + test('shows extension-change warning — keep new extension', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('file.md') + await input.press('Enter') + + await page.getByRole('dialog', { name: 'Change file extension' }) + .getByRole('button', { name: 'Use .md' }) + .click() + + await expect(filesListPage.getRowForFile('file.md')).toBeVisible() + }) + + test('shows extension-change warning — keep old extension', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('document.md') + await input.press('Enter') + + await page.getByRole('dialog', { name: 'Change file extension' }) + .getByRole('button', { name: 'Keep .txt' }) + .click() + + await expect(filesListPage.getRowForFile('document.txt')).toBeVisible() + }) + + test('shows extension-removal warning', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('file') + await input.press('Enter') + + const dialog = page.getByRole('dialog', { name: 'Change file extension' }) + await expect(dialog.getByRole('button', { name: 'Keep .txt' })).toBeVisible() + await dialog.getByRole('button', { name: 'Remove extension' }).click() + + await expect(filesListPage.getRowForFile('file')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + }) + + test('does not show extension warning when renaming a folder with a dot', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/folder.2024') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('folder.2024')).toBeVisible() + + await filesListPage.triggerActionForFile('folder.2024', 'rename') + const input = filesListPage.getRenameInputForFolder('folder.2024') + await expect(input).toBeVisible() + await input.fill('folder.2025') + await expect(input).toHaveValidationMessage('') + await input.press('Enter') + + await expect(page.locator('[role="dialog"]')).toHaveCount(0) + await expect(filesListPage.getRowForFile('folder.2025')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/files/files-sidebar.spec.ts b/tests/playwright/e2e/files/files-sidebar.spec.ts new file mode 100644 index 00000000000..e94f57c0b89 --- /dev/null +++ b/tests/playwright/e2e/files/files-sidebar.spec.ts @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect } from '../../support/fixtures/files-page.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files: Sidebar', () => { + let fileId: number + + test.beforeEach(async ({ user, page, filesListPage }) => { + await mkdir(page.request, user, '/folder') + fileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file') + await filesListPage.open() + }) + + test('opens the sidebar', async ({ filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + await filesListPage.triggerActionForFile('file', 'details') + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('file')).toBeVisible() + }) + + test('changes the current fileid', async ({ page, filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + await filesListPage.triggerActionForFile('file', 'details') + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(page).toHaveURL(new RegExp(`apps/files/files/${fileId}`)) + }) + + test('changes the sidebar content on other file', async ({ filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + await filesListPage.triggerActionForFile('file', 'details') + + await expect(filesSidebar.sidebar()).toBeVisible() + // Wait for the first file's heading to be stable before switching + await expect(filesSidebar.heading('file')).toBeVisible() + + await filesListPage.triggerActionForFile('folder', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('folder')).toBeVisible() + }) + + test('closes the sidebar on navigation', async ({ filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + + // Open the sidebar + await filesListPage.triggerActionForFile('file', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + + // Navigate into the folder — sidebar should close + await filesListPage.navigateToFolder('folder') + await expect(filesSidebar.sidebar()).not.toBeVisible() + }) + + test('closes the sidebar on delete', async ({ page, filesListPage, filesSidebar, user }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + // Open the sidebar + await filesListPage.triggerActionForFile('file', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + // Wait for the sidebar to be fully rendered before deleting + await expect(filesSidebar.heading('file')).toBeVisible() + + const deleteResponse = page.waitForResponse( + (response) => + response.url().includes(`/remote.php/dav/files/${user.userId}/file`) + && response.request().method() === 'DELETE', + { timeout: 10000 }, + ) + + await filesListPage.triggerActionForFile('file', 'delete') + await deleteResponse + + await expect(filesSidebar.sidebar()).not.toBeVisible() + }) + + test('changes the fileid on delete', async ({ page, filesListPage, filesSidebar, user }) => { + const otherFileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/folder/other') + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await filesListPage.navigateToFolder('folder') + await expect(filesListPage.getRowForFile('other')).toBeVisible() + + // Open the sidebar for the inner file + await filesListPage.triggerActionForFile('other', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(page).toHaveURL(new RegExp(`apps/files/files/${otherFileId}`)) + // Wait for the sidebar to be fully rendered before deleting + await expect(filesSidebar.heading('other')).toBeVisible() + + const deleteResponse = page.waitForResponse( + (response) => + response.url().includes(`/remote.php/dav/files/${user.userId}/folder/other`) + && response.request().method() === 'DELETE', + { timeout: 10000 }, + ) + + await filesListPage.triggerActionForFile('other', 'delete') + await deleteResponse + + await expect(filesSidebar.sidebar()).not.toBeVisible() + await expect(page).not.toHaveURL(new RegExp(`apps/files/files/${otherFileId}`)) + }) +}) diff --git a/tests/playwright/support/fixtures/files-page.ts b/tests/playwright/support/fixtures/files-page.ts new file mode 100644 index 00000000000..c1eba8c96a5 --- /dev/null +++ b/tests/playwright/support/fixtures/files-page.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { test as baseTest } from '@playwright/test' +import type { User } from '@nextcloud/e2e-test-server' +import { FilesListPage } from '../sections/FilesListPage.ts' +import { FilesSidebarPage } from '../sections/FilesSidebarPage.ts' + +type FilesFixtures = { + user: User + filesListPage: FilesListPage + filesSidebar: FilesSidebarPage +} + +export const test = baseTest.extend({ + user: async ({ context }, use) => { + const user = await createRandomUser() + try { + await login(context.request, user) + } catch { + // Retry once on transient auth failure + await new Promise((resolve) => setTimeout(resolve, 800)) + await login(context.request, user) + } + await use(user) + await runOcc(['user:delete', user.userId]) + }, + + filesListPage: async ({ page }, use) => { + await use(new FilesListPage(page)) + }, + + filesSidebar: async ({ page }, use) => { + await use(new FilesSidebarPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/matchers.ts b/tests/playwright/support/matchers.ts new file mode 100644 index 00000000000..491cf431ec9 --- /dev/null +++ b/tests/playwright/support/matchers.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect as baseExpect, type Locator } from '@playwright/test' + +export const expect = baseExpect.extend({ + /** + * Asserts that a file-list row has the active highlight class. + * A row becomes active when it was the last folder navigated into + * (e.g. after a browser back/forward traversal). + */ + async toBeActiveRow(received: Locator, options?: { timeout?: number }) { + let pass: boolean + let failMessage: string | undefined + try { + await baseExpect(received).toHaveClass(/files-list__row--active/, options) + pass = true + } catch (e: unknown) { + pass = false + failMessage = (e as Error).message + } + return { + message: () => pass + ? `Expected row not to have class 'files-list__row--active'` + : failMessage ?? `Expected row to have class 'files-list__row--active'`, + pass, + } + }, + /** + * Asserts that an input element has a specific HTML5 validation message. + * An empty string means the input is valid (no validation error). + * Retries until the message matches or the timeout expires. + */ + async toHaveValidationMessage(received: Locator, expected: string | RegExp, options?: { timeout?: number }) { + let pass = false + let actual = '' + const getMsg = async () => received.evaluate((el) => (el as HTMLInputElement).validationMessage) + try { + if (typeof expected === 'string') { + await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toBe(expected) + } else { + await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toMatch(expected) + } + pass = true + } catch { + actual = await getMsg().catch(() => '') + } + return { + message: () => pass + ? `Expected validation message not to equal ${JSON.stringify(expected)}` + : `Expected validation message ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + pass, + } + }, +}) + +declare module '@playwright/test' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Matchers { + toBeActiveRow(options?: { timeout?: number }): R + toHaveValidationMessage(expected: string | RegExp, options?: { timeout?: number }): R + } +} diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts new file mode 100644 index 00000000000..3f8918e81a8 --- /dev/null +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +export class FilesListPage { + constructor(private readonly page: Page) {} + + async open(): Promise { + await this.page.goto('apps/files') + await this.page.locator('[data-cy-files-list]').waitFor({ state: 'visible' }) + } + + getRowForFile(filename: string): Locator { + return this.page.locator(`[data-cy-files-list-row-name="${filename}"]`) + } + + getRowForFileId(fileid: number): Locator { + return this.page.locator(`[data-cy-files-list-row-fileid="${fileid}"]`) + } + + private getActionsButtonForFile(filename: string): Locator { + return this.getRowForFile(filename) + .getByRole('button', { name: 'Actions' }) + } + + async triggerActionForFile(filename: string, actionId: string): Promise { + const row = this.getRowForFile(filename) + await row.hover() + + const actionsButton = this.getActionsButtonForFile(filename) + 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') + // The action button has role="menuitem", so use tag selector not getByRole + const actionEntry = this.page + .locator(`#${menuId} [data-cy-files-list-row-action="${actionId}"] button`) + await actionEntry.waitFor({ state: 'visible' }) + await actionEntry.click() + } + + async selectAll(): Promise { + await this.page.locator('[data-cy-files-list-selection-checkbox]') + .getByRole('checkbox') + .click({ force: true }) + } + + async triggerSelectionAction(actionId: string): Promise { + const actionsButton = this.page.locator('[data-cy-files-list-selection-actions]') + .getByRole('button', { name: 'Actions' }) + await actionsButton.click({ force: true }) + // NcActionButton renders as