From 82873f8c032d7240f209b3ea505bbbe9883472c0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 22 Jul 2026 20:17:19 +0200 Subject: [PATCH 1/4] test(files_versions): migrate to PlayWright Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Ferdinand Thiessen --- .../e2e/files_versions/filesVersionsUtils.ts | 97 --------- .../e2e/files_versions/version_creation.cy.ts | 48 ---- .../version_cross_share_move_and_copy.cy.ts | 103 --------- .../e2e/files_versions/version_deletion.cy.ts | 61 ------ .../e2e/files_versions/version_download.cy.ts | 59 ----- .../files_versions/version_expiration.cy.ts | 57 ----- .../e2e/files_versions/version_naming.cy.ts | 91 -------- .../files_versions/version_restoration.cy.ts | 75 ------- .../e2e/files_versions/version_sharing.cy.ts | 48 ---- .../files_versions/version-creation.spec.ts | 28 +++ .../version-cross-share-move-and-copy.spec.ts | 174 +++++++++++++++ .../files_versions/version-deletion.spec.ts | 66 ++++++ .../files_versions/version-download.spec.ts | 58 +++++ .../files_versions/version-expiration.spec.ts | 62 ++++++ .../e2e/files_versions/version-naming.spec.ts | 79 +++++++ .../version-restoration.spec.ts | 88 ++++++++ .../files_versions/version-sharing.spec.ts | 33 +++ .../fixtures/files-versions-tab-page.ts | 20 ++ .../support/sections/VersionsTab.ts | 205 ++++++++++++++++++ tests/playwright/support/utils/versions.ts | 75 +++++++ 20 files changed, 888 insertions(+), 639 deletions(-) delete mode 100644 cypress/e2e/files_versions/filesVersionsUtils.ts delete mode 100644 cypress/e2e/files_versions/version_creation.cy.ts delete mode 100644 cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts delete mode 100644 cypress/e2e/files_versions/version_deletion.cy.ts delete mode 100644 cypress/e2e/files_versions/version_download.cy.ts delete mode 100644 cypress/e2e/files_versions/version_expiration.cy.ts delete mode 100644 cypress/e2e/files_versions/version_naming.cy.ts delete mode 100644 cypress/e2e/files_versions/version_restoration.cy.ts delete mode 100644 cypress/e2e/files_versions/version_sharing.cy.ts create mode 100644 tests/playwright/e2e/files_versions/version-creation.spec.ts create mode 100644 tests/playwright/e2e/files_versions/version-cross-share-move-and-copy.spec.ts create mode 100644 tests/playwright/e2e/files_versions/version-deletion.spec.ts create mode 100644 tests/playwright/e2e/files_versions/version-download.spec.ts create mode 100644 tests/playwright/e2e/files_versions/version-expiration.spec.ts create mode 100644 tests/playwright/e2e/files_versions/version-naming.spec.ts create mode 100644 tests/playwright/e2e/files_versions/version-restoration.spec.ts create mode 100644 tests/playwright/e2e/files_versions/version-sharing.spec.ts create mode 100644 tests/playwright/support/fixtures/files-versions-tab-page.ts create mode 100644 tests/playwright/support/sections/VersionsTab.ts create mode 100644 tests/playwright/support/utils/versions.ts diff --git a/cypress/e2e/files_versions/filesVersionsUtils.ts b/cypress/e2e/files_versions/filesVersionsUtils.ts deleted file mode 100644 index ae23dca409789..0000000000000 --- a/cypress/e2e/files_versions/filesVersionsUtils.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' -import type { ShareSetting } from '../files_sharing/FilesSharingUtils.ts' - -import { basename } from '@nextcloud/paths' -import { triggerActionForFile } from '../files/FilesUtils.ts' -import { createShare } from '../files_sharing/FilesSharingUtils.ts' - -export function uploadThreeVersions(user: User, fileName: string) { - // A new version will not be created if the changes occur - // within less than one second of each other. - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.uploadContent(user, new Blob(['v1'], { type: 'text/plain' }), 'text/plain', `/${fileName}`) - .wait(1100) - .uploadContent(user, new Blob(['v2'], { type: 'text/plain' }), 'text/plain', `/${fileName}`) - .wait(1100) - .uploadContent(user, new Blob(['v3'], { type: 'text/plain' }), 'text/plain', `/${fileName}`) - cy.login(user) -} - -export function openVersionsPanel(fileName: string) { - // Detect the versions list fetch - cy.intercept('PROPFIND', '**/dav/versions/*/versions/**').as('getVersions') - - triggerActionForFile(basename(fileName), 'details') - cy.get('[data-cy-sidebar]') - .as('sidebar') - .should('be.visible') - cy.get('@sidebar') - .find('[aria-controls="tab-files_versions"]') - .click() - - // Wait for the versions list to be fetched - cy.wait('@getVersions') - cy.get('#tab-files_versions').should('be.visible', { timeout: 10000 }) -} - -export function toggleVersionMenu(index: number) { - cy.get('#tab-files_versions [data-files-versions-version]') - .eq(index) - .find('button') - .click() -} - -export function triggerVersionAction(index: number, actionName: string) { - toggleVersionMenu(index) - cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).filter(':visible').click() -} - -export function nameVersion(index: number, name: string) { - cy.intercept('PROPPATCH', '**/dav/versions/*/versions/**').as('labelVersion') - triggerVersionAction(index, 'label') - cy.get(':focused').type(`${name}{enter}`) - cy.wait('@labelVersion') -} - -export function restoreVersion(index: number) { - cy.intercept('MOVE', '**/dav/versions/*/versions/**').as('restoreVersion') - triggerVersionAction(index, 'restore') - cy.wait('@restoreVersion') -} - -export function deleteVersion(index: number) { - cy.intercept('DELETE', '**/dav/versions/*/versions/**').as('deleteVersion') - triggerVersionAction(index, 'delete') - cy.wait('@deleteVersion') -} - -export function doesNotHaveAction(index: number, actionName: string) { - toggleVersionMenu(index) - cy.get(`[data-cy-files-versions-version-action="${actionName}"]`).should('not.exist') - toggleVersionMenu(index) -} - -export function assertVersionContent(index: number, expectedContent: string) { - cy.intercept({ method: 'GET', times: 1, url: 'remote.php/**' }).as('downloadVersion') - triggerVersionAction(index, 'download') - cy.wait('@downloadVersion') - .then(({ response }) => expect(response?.body).to.equal(expectedContent)) -} - -export function setupTestSharedFileFromUser(owner: User, randomFileName: string, shareOptions: Partial) { - return cy.createRandomUser() - .then((recipient) => { - cy.login(owner) - cy.visit('/apps/files') - createShare(randomFileName, recipient.userId, shareOptions) - - cy.login(recipient) - cy.visit('/apps/files') - return cy.wrap(recipient) - }) -} diff --git a/cypress/e2e/files_versions/version_creation.cy.ts b/cypress/e2e/files_versions/version_creation.cy.ts deleted file mode 100644 index f1c578f1f0cd9..0000000000000 --- a/cypress/e2e/files_versions/version_creation.cy.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { randomString } from '../../support/utils/randomString.ts' -import { openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions creation', () => { - let randomFileName = '' - - before(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((user) => { - uploadThreeVersions(user, randomFileName) - cy.login(user) - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - }) - }) - - it('Opens the versions panel and sees the versions', () => { - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 3) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version') - }) - }) - - it('See yourself as version author', () => { - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - cy.findByRole('tabpanel', { name: 'Versions' }) - .findByRole('list', { name: 'File versions' }) - .findAllByRole('listitem') - .should('have.length', 3) - .first() - .find('[data-cy-files-version-author-name]') - .should('exist') - .and('contain.text', 'You') - }) -}) diff --git a/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts b/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts deleted file mode 100644 index d09b9c6aac933..0000000000000 --- a/cypress/e2e/files_versions/version_cross_share_move_and_copy.cy.ts +++ /dev/null @@ -1,103 +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' -import { clickOnBreadcrumbs, closeSidebar, copyFile, moveFile, navigateToFolder } from '../files/FilesUtils.ts' -import { assertVersionContent, nameVersion, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions cross share move and copy', () => { - let randomSharedFolderName = '' - let randomFileName = '' - let randomFilePath = '' - let alice: User - let bob: User - - before(() => { - randomSharedFolderName = randomString(10) - - cy.createRandomUser() - .then((user) => { - alice = user - cy.mkdir(alice, `/${randomSharedFolderName}`) - setupTestSharedFileFromUser(alice, randomSharedFolderName, {}) - }) - .then((user) => { bob = user }) - }) - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - randomFilePath = `${randomSharedFolderName}/${randomFileName}` - uploadThreeVersions(alice, randomFilePath) - - cy.login(bob) - cy.visit('/apps/files') - navigateToFolder(randomSharedFolderName) - openVersionsPanel(randomFilePath) - nameVersion(2, 'v1') - closeSidebar() - }) - - it('Also moves versions when bob moves the file out of a received share', () => { - moveFile(randomFileName, '/') - assertVersionsContent(randomFileName) - // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata - cy.get('[data-files-versions-version]').eq(2).contains('v1') - }) - - it('Also copies versions when bob copies the file out of a received share', () => { - copyFile(randomFileName, '/') - assertVersionsContent(randomFileName) - }) - - context('When a file is in a subfolder', () => { - let randomSubFolderName - let randomSubSubFolderName - - beforeEach(() => { - randomSubFolderName = randomString(10) - randomSubSubFolderName = randomString(10) - clickOnBreadcrumbs('All files') - cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}`) - cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}/${randomSubSubFolderName}`) - cy.login(bob) - navigateToFolder(randomSharedFolderName) - moveFile(randomFileName, `${randomSubFolderName}/${randomSubSubFolderName}`) - }) - - it('Also moves versions when bob moves the containing folder out of a received share', () => { - moveFile(randomSubFolderName, '/') - assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`) - // TODO: move that in assertVersionsContent when copying files keeps the versions' metadata - cy.get('[data-files-versions-version]').eq(2).contains('v1') - }) - - it('Also copies versions when bob copies the containing folder out of a received share', () => { - copyFile(randomSubFolderName, '/') - assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`) - }) - }) -}) - -/** - * @param filePath - */ -function assertVersionsContent(filePath: string) { - const path = filePath.split('/').slice(0, -1).join('/') - - clickOnBreadcrumbs('All files') - - if (path !== '') { - navigateToFolder(path) - } - - openVersionsPanel(filePath) - - cy.get('[data-files-versions-version]').should('have.length', 3) - assertVersionContent(0, 'v3') - assertVersionContent(1, 'v2') - assertVersionContent(2, 'v1') -} diff --git a/cypress/e2e/files_versions/version_deletion.cy.ts b/cypress/e2e/files_versions/version_deletion.cy.ts deleted file mode 100644 index c2779d7e8032c..0000000000000 --- a/cypress/e2e/files_versions/version_deletion.cy.ts +++ /dev/null @@ -1,61 +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' -import { navigateToFolder } from '../files/FilesUtils.ts' -import { deleteVersion, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions deletion', () => { - const folderName = 'shared_folder' - const randomFileName = randomString(10) + '.txt' - const randomFilePath = `/${folderName}/${randomFileName}` - let user: User - let versionCount = 0 - - beforeEach(() => { - cy.createRandomUser() - .then((_user) => { - user = _user - cy.mkdir(user, `/${folderName}`) - uploadThreeVersions(user, randomFilePath) - versionCount = 3 - cy.login(user) - cy.visit('/apps/files') - }) - }) - - it('Delete initial version', () => { - navigateToFolder(folderName) - openVersionsPanel(randomFilePath) - - cy.get('[data-files-versions-version]') - .should('have.length', versionCount) - deleteVersion(--versionCount) - cy.get('[data-files-versions-version]') - .should('have.length', versionCount) - }) - - it('Delete versions of shared file with delete permission', () => { - setupTestSharedFileFromUser(user, folderName, { delete: true }) - navigateToFolder(folderName) - openVersionsPanel(randomFilePath) - - cy.get('[data-files-versions-version]').should('have.length', versionCount) - deleteVersion(--versionCount) - cy.get('[data-files-versions-version]').should('have.length', versionCount) - }) - - it('Delete versions of shared file without delete permission', () => { - setupTestSharedFileFromUser(user, folderName, { delete: false }) - navigateToFolder(folderName) - openVersionsPanel(randomFilePath) - - doesNotHaveAction(0, 'delete') - doesNotHaveAction(1, 'delete') - doesNotHaveAction(2, 'delete') - }) -}) diff --git a/cypress/e2e/files_versions/version_download.cy.ts b/cypress/e2e/files_versions/version_download.cy.ts deleted file mode 100644 index 6aa51a89a1737..0000000000000 --- a/cypress/e2e/files_versions/version_download.cy.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 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' -import { assertVersionContent, doesNotHaveAction, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions download', () => { - let randomFileName = '' - let user: User - - before(() => cy.runOccCommand('config:app:set --value no core shareapi_allow_view_without_download')) - after(() => { - cy.runOccCommand('config:app:delete core shareapi_allow_view_without_download') - }) - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((_user) => { - user = _user - uploadThreeVersions(user, randomFileName) - }) - }) - - it('Download versions and assert their content', () => { - cy.login(user) - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - assertVersionContent(0, 'v3') - assertVersionContent(1, 'v2') - assertVersionContent(2, 'v1') - }) - - it('Download versions of shared file with download permission', () => { - setupTestSharedFileFromUser(user, randomFileName, { download: true }) - openVersionsPanel(randomFileName) - - assertVersionContent(0, 'v3') - assertVersionContent(1, 'v2') - assertVersionContent(2, 'v1') - }) - - it('Does not show action without download permission', () => { - setupTestSharedFileFromUser(user, randomFileName, { download: false }) - openVersionsPanel(randomFileName) - - cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist') - cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="download"]').should('not.exist') - - doesNotHaveAction(1, 'download') - doesNotHaveAction(2, 'download') - }) -}) diff --git a/cypress/e2e/files_versions/version_expiration.cy.ts b/cypress/e2e/files_versions/version_expiration.cy.ts deleted file mode 100644 index 2a974052fe340..0000000000000 --- a/cypress/e2e/files_versions/version_expiration.cy.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { randomString } from '../../support/utils/randomString.ts' -import { assertVersionContent, nameVersion, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions expiration', () => { - let randomFileName = '' - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((user) => { - uploadThreeVersions(user, randomFileName) - cy.login(user) - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - }) - }) - - it('Expire all versions', () => { - cy.runOccCommand('config:system:set versions_retention_obligation --value \'0, 0\'') - cy.runOccCommand('versions:expire') - cy.runOccCommand('config:system:set versions_retention_obligation --value auto') - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 1) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - }) - - assertVersionContent(0, 'v3') - }) - - it('Expire versions v2', () => { - nameVersion(2, 'v1') - - cy.runOccCommand('config:system:set versions_retention_obligation --value \'0, 0\'') - cy.runOccCommand('versions:expire') - cy.runOccCommand('config:system:set versions_retention_obligation --value auto') - cy.visit('/apps/files') - openVersionsPanel(randomFileName) - - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 2) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - cy.get('[data-files-versions-version]').eq(1).contains('v1') - }) - - assertVersionContent(0, 'v3') - assertVersionContent(1, 'v1') - }) -}) diff --git a/cypress/e2e/files_versions/version_naming.cy.ts b/cypress/e2e/files_versions/version_naming.cy.ts deleted file mode 100644 index 3d5d80bc3d19e..0000000000000 --- a/cypress/e2e/files_versions/version_naming.cy.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 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' -import { navigateToFolder } from '../files/FilesUtils.ts' -import { doesNotHaveAction, nameVersion, openVersionsPanel, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions naming', () => { - let randomFileName = '' - let user: User - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((_user) => { - user = _user - cy.mkdir(_user, '/share') - uploadThreeVersions(user, `share/${randomFileName}`) - }) - }) - - it('Names the versions', () => { - cy.login(user) - cy.visit('/apps/files') - navigateToFolder('share') - openVersionsPanel(randomFileName) - - nameVersion(2, 'v1') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(2).contains('v1') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') - }) - - nameVersion(1, 'v2') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(1).contains('v2') - }) - - nameVersion(0, 'v3') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(0).contains('v3 (Current version)') - }) - }) - - it('Name versions of shared file with edit permission', () => { - setupTestSharedFileFromUser(user, 'share', { update: true }) - - navigateToFolder('share') - openVersionsPanel(randomFileName) - - nameVersion(2, 'v1 - shared') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(2).contains('v1 - shared') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') - }) - - nameVersion(1, 'v2 - shared') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(1).contains('v2 - shared') - }) - - nameVersion(0, 'v3 - shared') - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').eq(0).contains('v3 - shared (Current version)') - }) - }) - - it('Name versions without edit permission fails', () => { - setupTestSharedFileFromUser(user, 'share', { update: false }) - - navigateToFolder('share') - openVersionsPanel(randomFileName) - - cy.get('[data-files-versions-version]') - .eq(0) - .as('firstVersion') - .find('.action-item__menutoggle') - .should('not.exist') - cy.get('@firstVersion') - .find('[data-cy-version-action="label"]') - .should('not.exist') - - doesNotHaveAction(1, 'label') - doesNotHaveAction(2, 'label') - }) -}) diff --git a/cypress/e2e/files_versions/version_restoration.cy.ts b/cypress/e2e/files_versions/version_restoration.cy.ts deleted file mode 100644 index 2d7e66068d90a..0000000000000 --- a/cypress/e2e/files_versions/version_restoration.cy.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 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' -import { navigateToFolder } from '../files/FilesUtils.ts' -import { assertVersionContent, doesNotHaveAction, openVersionsPanel, restoreVersion, setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions restoration', () => { - let randomFileName = '' - let user: User - - beforeEach(() => { - randomFileName = randomString(10) + '.txt' - - cy.createRandomUser() - .then((_user) => { - user = _user - cy.mkdir(_user, '/share') - uploadThreeVersions(user, `share/${randomFileName}`) - cy.login(user) - cy.visit('/apps/files') - }) - }) - - it('Restores initial version', () => { - navigateToFolder('share') - openVersionsPanel(randomFileName) - // Current version does not have restore action - doesNotHaveAction(0, 'restore') - restoreVersion(2) - - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 3) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') - }) - - // Downloads versions and assert there content - assertVersionContent(0, 'v1') - assertVersionContent(1, 'v3') - assertVersionContent(2, 'v2') - }) - - it('Restore versions of shared file with update permission', () => { - setupTestSharedFileFromUser(user, 'share', { update: true }) - navigateToFolder('share') - openVersionsPanel(randomFileName) - - restoreVersion(2) - cy.get('#tab-files_versions').within(() => { - cy.get('[data-files-versions-version]').should('have.length', 3) - cy.get('[data-files-versions-version]').eq(0).contains('Current version') - cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist') - }) - assertVersionContent(0, 'v1') - assertVersionContent(1, 'v3') - assertVersionContent(2, 'v2') - }) - - it('Does not show action without delete permission', () => { - setupTestSharedFileFromUser(user, 'share', { update: false }) - navigateToFolder('share') - openVersionsPanel(randomFileName) - - cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist') - cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="restore"]').should('not.exist') - - doesNotHaveAction(2, 'restore') - doesNotHaveAction(1, 'restore') - }) -}) diff --git a/cypress/e2e/files_versions/version_sharing.cy.ts b/cypress/e2e/files_versions/version_sharing.cy.ts deleted file mode 100644 index 4aa762cd28cf1..0000000000000 --- a/cypress/e2e/files_versions/version_sharing.cy.ts +++ /dev/null @@ -1,48 +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 { randomString } from '../../support/utils/randomString.ts' -import { navigateToFolder, triggerActionForFile } from '../files/FilesUtils.ts' -import { setupTestSharedFileFromUser, uploadThreeVersions } from './filesVersionsUtils.ts' - -describe('Versions on shares', () => { - const randomSharedFolderName = randomString(10) - const randomFileName = randomString(10) + '.txt' - const randomFilePath = `${randomSharedFolderName}/${randomFileName}` - let alice: User - let bob: User - - before(() => { - cy.createRandomUser() - .then((user) => { - alice = user - }) - .then(() => { - cy.mkdir(alice, `/${randomSharedFolderName}`) - return setupTestSharedFileFromUser(alice, randomSharedFolderName, {}) - }) - .then((user) => { bob = user }) - .then(() => uploadThreeVersions(alice, randomFilePath)) - }) - - it('See sharees display name as author', () => { - cy.login(bob) - cy.visit('/apps/files') - - navigateToFolder(randomSharedFolderName) - - triggerActionForFile(randomFileName, 'details') - cy.findByRole('tab', { name: 'Versions' }).click() - - cy.findByRole('tabpanel', { name: 'Versions' }) - .findByRole('list', { name: 'File versions' }) - .findAllByRole('listitem') - .first() - .find('[data-cy-files-version-author-name]') - .should('be.visible') - .and('contain.text', alice.userId) - }) -}) diff --git a/tests/playwright/e2e/files_versions/version-creation.spec.ts b/tests/playwright/e2e/files_versions/version-creation.spec.ts new file mode 100644 index 0000000000000..0862d80813a65 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-creation.spec.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-versions-tab-page.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const FILE_NAME = 'creation.txt' + +test.describe('files_versions: versions creation', () => { + test.beforeEach(async ({ page, user, filesListPage, versionsTab }) => { + await seedThreeVersions(page.request, user, `/${FILE_NAME}`) + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + }) + + test('opens the versions panel and shows the three versions', async ({ versionsTab }) => { + await expect(versionsTab.versions()).toHaveCount(3) + await expect(versionsTab.version(0)).toContainText('Current version') + await expect(versionsTab.version(2)).toContainText('Initial version') + }) + + test('shows yourself as the version author', async ({ versionsTab }) => { + await expect(versionsTab.versions()).toHaveCount(3) + await expect(versionsTab.authorName(0)).toContainText('You') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-cross-share-move-and-copy.spec.ts b/tests/playwright/e2e/files_versions/version-cross-share-move-and-copy.spec.ts new file mode 100644 index 0000000000000..632dd2c54d848 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-cross-share-move-and-copy.spec.ts @@ -0,0 +1,174 @@ +/* + * 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 type { CopyMoveDialogPage } from '../../support/sections/CopyMoveDialogPage.ts' +import type { FilesListPage } from '../../support/sections/FilesListPage.ts' +import type { VersionsTab } from '../../support/sections/VersionsTab.ts' + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const SHARED_FOLDER = 'cross-share' +const FILE_NAME = 'file.txt' + +/** + * Seed a versioned file inside the shared folder for the owner, share the folder + * with the recipient (full permissions), and wait for the file to propagate. + * Parent directories of `filePath` (relative to the shared folder) are created + * first. + * + * @param owner - The file owner + * @param ownerRequest - A request context authenticated as the owner + * @param recipient - The share recipient + * @param recipientRequest - A request context authenticated as the recipient + * @param filePath - The file path relative to the shared folder (e.g. "sub/deep/file.txt") + */ +async function seedSharedVersionedFile( + owner: User, + ownerRequest: APIRequestContext, + recipient: User, + recipientRequest: APIRequestContext, + filePath: string, +): Promise { + await mkdir(ownerRequest, owner, `/${SHARED_FOLDER}`) + // Create any intermediate folders of the file path inside the shared folder + const segments = filePath.split('/') + let current = SHARED_FOLDER + for (const segment of segments.slice(0, -1)) { + current += `/${segment}` + await mkdir(ownerRequest, owner, `/${current}`) + } + await seedThreeVersions(ownerRequest, owner, `${SHARED_FOLDER}/${filePath}`) + await createShare(ownerRequest, `/${SHARED_FOLDER}`, recipient.userId) + + const parent = [SHARED_FOLDER, ...segments.slice(0, -1)].join('/') + await waitForShare(recipientRequest, recipient, parent, segments.at(-1)!) +} + +/** + * As the recipient, open the versions panel of the file, name its initial + * version "v1", and close the sidebar. + */ +async function nameInitialVersion( + filesListPage: FilesListPage, + versionsTab: VersionsTab, + folderPath: string, + fileName: string, +): Promise { + await filesListPage.open() + await filesListPage.navigateToFolder(folderPath) + await openVersionsPanel(filesListPage, versionsTab, fileName) + await expect(versionsTab.versions()).toHaveCount(3) + await versionsTab.nameVersion(2, 'v1') + await expect(versionsTab.version(2)).toContainText('v1') +} + +/** + * Reload from the recipient's root, open the versions of the file at `filePath` + * and assert all three versions travelled with the move/copy (content v3/v2/v1). + * A fresh reload avoids the stale sibling rows a cross-storage move can leave + * behind. `expectLabel` asserts the "v1" label survived — only moves preserve + * version metadata, copies do not. + */ +async function assertVersionsContent( + filesListPage: FilesListPage, + versionsTab: VersionsTab, + filePath: string, + { expectLabel }: { expectLabel: boolean }, +): Promise { + const segments = filePath.split('/') + const fileName = segments.at(-1)! + const folderPath = segments.slice(0, -1).join('/') + + await filesListPage.open() + if (folderPath) { + await filesListPage.navigateToFolder(folderPath) + } + await openVersionsPanel(filesListPage, versionsTab, fileName) + + await expect(versionsTab.versions()).toHaveCount(3) + expect(await versionsTab.getVersionContent(0)).toBe('v3') + expect(await versionsTab.getVersionContent(1)).toBe('v2') + expect(await versionsTab.getVersionContent(2)).toBe('v1') + + if (expectLabel) { + await expect(versionsTab.version(2)).toContainText('v1') + } +} + +/** Move the given entry out of the current folder to the recipient's root. */ +async function moveToRoot(filesListPage: FilesListPage, copyMoveDialog: CopyMoveDialogPage, name: string): Promise { + await filesListPage.triggerActionForFile(name, 'move-copy') + await copyMoveDialog.goToAllFiles() + await copyMoveDialog.moveToCurrentFolder() +} + +/** Copy the given entry to the recipient's root. */ +async function copyToRoot(filesListPage: FilesListPage, copyMoveDialog: CopyMoveDialogPage, name: string): Promise { + await filesListPage.triggerActionForFile(name, 'move-copy') + await copyMoveDialog.goToAllFiles() + await copyMoveDialog.copyToCurrentFolder() +} + +test.describe('files_versions: versions across a share move/copy', () => { + test('moves the versions when the file is moved out of a received share', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab, filesSidebar, copyMoveDialog }) => { + await seedSharedVersionedFile(owner, ownerRequest, user, page.request, FILE_NAME) + await nameInitialVersion(filesListPage, versionsTab, SHARED_FOLDER, FILE_NAME) + await filesSidebar.close() + + await moveToRoot(filesListPage, copyMoveDialog, FILE_NAME) + + await assertVersionsContent(filesListPage, versionsTab, FILE_NAME, { expectLabel: true }) + }) + + test('copies the versions when the file is copied out of a received share', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab, filesSidebar, copyMoveDialog }) => { + await seedSharedVersionedFile(owner, ownerRequest, user, page.request, FILE_NAME) + await nameInitialVersion(filesListPage, versionsTab, SHARED_FOLDER, FILE_NAME) + await filesSidebar.close() + + await copyToRoot(filesListPage, copyMoveDialog, FILE_NAME) + + // A copy keeps version content but not the version metadata (label) + await assertVersionsContent(filesListPage, versionsTab, FILE_NAME, { expectLabel: false }) + }) + + test('moves the versions when a containing folder is moved out of a received share', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab, filesSidebar, copyMoveDialog }) => { + const subFolder = 'sub' + const subSubFolder = 'deep' + const relPath = `${subFolder}/${subSubFolder}/${FILE_NAME}` + await seedSharedVersionedFile(owner, ownerRequest, user, page.request, relPath) + await nameInitialVersion(filesListPage, versionsTab, `${SHARED_FOLDER}/${subFolder}/${subSubFolder}`, FILE_NAME) + await filesSidebar.close() + + await filesListPage.open() + await filesListPage.navigateToFolder(SHARED_FOLDER) + await moveToRoot(filesListPage, copyMoveDialog, subFolder) + + await assertVersionsContent(filesListPage, versionsTab, relPath, { expectLabel: true }) + }) + + test('copies the versions when a containing folder is copied out of a received share', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab, filesSidebar, copyMoveDialog }) => { + const subFolder = 'sub' + const subSubFolder = 'deep' + const relPath = `${subFolder}/${subSubFolder}/${FILE_NAME}` + await seedSharedVersionedFile(owner, ownerRequest, user, page.request, relPath) + await nameInitialVersion(filesListPage, versionsTab, `${SHARED_FOLDER}/${subFolder}/${subSubFolder}`, FILE_NAME) + await filesSidebar.close() + + await filesListPage.open() + await filesListPage.navigateToFolder(SHARED_FOLDER) + await copyToRoot(filesListPage, copyMoveDialog, subFolder) + + await assertVersionsContent(filesListPage, versionsTab, relPath, { expectLabel: false }) + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-deletion.spec.ts b/tests/playwright/e2e/files_versions/version-deletion.spec.ts new file mode 100644 index 0000000000000..ce5e5c2c436da --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-deletion.spec.ts @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { ALL_PERMISSIONS, createShare, SharePermission, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FOLDER_NAME = 'shared_folder' +const FILE_NAME = 'file.txt' +const FILE_PATH = `/${FOLDER_NAME}/${FILE_NAME}` + +test.describe('files_versions: versions deletion', () => { + test('deletes the initial version of an own file', async ({ page, user, filesListPage, versionsTab }) => { + await mkdir(page.request, user, `/${FOLDER_NAME}`) + await seedThreeVersions(page.request, user, FILE_PATH) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(3) + // The initial version is the oldest (last) entry + await versionsTab.delete(2) + await expect(versionsTab.versions()).toHaveCount(2) + }) + + test('deletes versions of a shared file with delete permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId) + // Wait for the delete permission (D) to reach the recipient's listing + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => p.includes('D')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(3) + await versionsTab.delete(2) + await expect(versionsTab.versions()).toHaveCount(2) + }) + + test('cannot delete versions of a shared file without delete permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.DELETE }) + // Wait for the delete restriction (no D) to reach the recipient's listing + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => !p.includes('D')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(3) + await versionsTab.expectActionMissing(0, 'delete') + await versionsTab.expectActionMissing(1, 'delete') + await versionsTab.expectActionMissing(2, 'delete') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-download.spec.ts b/tests/playwright/e2e/files_versions/version-download.spec.ts new file mode 100644 index 0000000000000..6b737b707e7e2 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-download.spec.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { createShare, DOWNLOAD_DISABLED_ATTRIBUTE, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FILE_NAME = 'download.txt' + +test.describe('files_versions: versions download', () => { + test('downloads versions of an own file and asserts their content', async ({ page, user, filesListPage, versionsTab }) => { + await seedThreeVersions(page.request, user, `/${FILE_NAME}`) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + expect(await versionsTab.getVersionContent(0)).toBe('v3') + expect(await versionsTab.getVersionContent(1)).toBe('v2') + expect(await versionsTab.getVersionContent(2)).toBe('v1') + }) + + test('downloads versions of a shared file with download permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await seedThreeVersions(ownerRequest, owner, `/${FILE_NAME}`) + await createShare(ownerRequest, `/${FILE_NAME}`, user.userId) + await waitForShare(page.request, user, '', FILE_NAME) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + expect(await versionsTab.getVersionContent(0)).toBe('v3') + expect(await versionsTab.getVersionContent(1)).toBe('v2') + expect(await versionsTab.getVersionContent(2)).toBe('v1') + }) + + test('does not offer download of a shared file without download permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await seedThreeVersions(ownerRequest, owner, `/${FILE_NAME}`) + await createShare(ownerRequest, `/${FILE_NAME}`, user.userId, { attributes: DOWNLOAD_DISABLED_ATTRIBUTE }) + await waitForShare(page.request, user, '', FILE_NAME) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // The current version's only possible actions (label, download) are both + // unavailable here, so it offers no actions menu at all + await versionsTab.expectNoActionsMenu(0) + await versionsTab.expectActionMissing(1, 'download') + await versionsTab.expectActionMissing(2, 'download') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-expiration.spec.ts b/tests/playwright/e2e/files_versions/version-expiration.spec.ts new file mode 100644 index 0000000000000..6ec8ecf4722c3 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-expiration.spec.ts @@ -0,0 +1,62 @@ +/* + * 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 { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect, test } from '../../support/fixtures/files-versions-tab-page.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const FILE_NAME = 'expiration.txt' + +/** + * Run the versioning expiration for a single user with a retention obligation + * that keeps only the current version (and any named versions). The obligation + * is a system config, so it is reset to the default afterwards even on failure; + * the expiry itself is scoped to `user` so it never touches other tests' files. + */ +async function expireVersions(user: User): Promise { + await runOcc(['config:system:set', 'versions_retention_obligation', '--value', '0, 0']) + try { + await runOcc(['versions:expire', user.userId]) + } finally { + await runOcc(['config:system:set', 'versions_retention_obligation', '--value', 'auto']) + } +} + +test.describe('files_versions: versions expiration', () => { + test('expires all but the current version', async ({ page, user, filesListPage, versionsTab }) => { + await seedThreeVersions(page.request, user, `/${FILE_NAME}`) + await expireVersions(user) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(1) + await expect(versionsTab.version(0)).toContainText('Current version') + expect(await versionsTab.getVersionContent(0)).toBe('v3') + }) + + test('keeps named versions when expiring', async ({ page, user, filesListPage, versionsTab }) => { + await seedThreeVersions(page.request, user, `/${FILE_NAME}`) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // Name the initial version so it survives expiration + await versionsTab.nameVersion(2, 'v1') + await expireVersions(user) + + await filesListPage.open() + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(2) + await expect(versionsTab.version(0)).toContainText('Current version') + await expect(versionsTab.version(1)).toContainText('v1') + expect(await versionsTab.getVersionContent(0)).toBe('v3') + expect(await versionsTab.getVersionContent(1)).toBe('v1') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-naming.spec.ts b/tests/playwright/e2e/files_versions/version-naming.spec.ts new file mode 100644 index 0000000000000..3ec476e3da809 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-naming.spec.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { ALL_PERMISSIONS, createShare, SharePermission, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FOLDER_NAME = 'share' +const FILE_NAME = 'file.txt' +const FILE_PATH = `${FOLDER_NAME}/${FILE_NAME}` + +test.describe('files_versions: versions naming', () => { + test('names the versions of an own file', async ({ page, user, filesListPage, versionsTab }) => { + await mkdir(page.request, user, `/${FOLDER_NAME}`) + await seedThreeVersions(page.request, user, FILE_PATH) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + await versionsTab.nameVersion(2, 'v1') + await expect(versionsTab.version(2)).toContainText('v1') + await expect(versionsTab.version(2)).not.toContainText('Initial version') + + await versionsTab.nameVersion(1, 'v2') + await expect(versionsTab.version(1)).toContainText('v2') + + await versionsTab.nameVersion(0, 'v3') + await expect(versionsTab.version(0)).toContainText('v3 (Current version)') + }) + + test('names the versions of a shared file with edit permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => p.includes('W')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + await versionsTab.nameVersion(2, 'v1 - shared') + await expect(versionsTab.version(2)).toContainText('v1 - shared') + await expect(versionsTab.version(2)).not.toContainText('Initial version') + + await versionsTab.nameVersion(1, 'v2 - shared') + await expect(versionsTab.version(1)).toContainText('v2 - shared') + + await versionsTab.nameVersion(0, 'v3 - shared') + await expect(versionsTab.version(0)).toContainText('v3 - shared (Current version)') + }) + + test('cannot name versions of a shared file without edit permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.UPDATE }) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => !p.includes('W')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // Without edit permission the current version offers no actions menu, and + // the older versions offer no label action + await versionsTab.expectNoActionsMenu(0) + await versionsTab.expectActionMissing(1, 'label') + await versionsTab.expectActionMissing(2, 'label') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-restoration.spec.ts b/tests/playwright/e2e/files_versions/version-restoration.spec.ts new file mode 100644 index 0000000000000..c59ff3e27e098 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-restoration.spec.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { VersionsTab } from '../../support/sections/VersionsTab.ts' + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { ALL_PERMISSIONS, createShare, SharePermission, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FOLDER_NAME = 'share' +const FILE_NAME = 'file.txt' +const FILE_PATH = `${FOLDER_NAME}/${FILE_NAME}` + +/** + * Assert the versions list after restoring the initial version ("v1"): the + * restored content becomes the current version, the previous current ("v3") and + * "v2" follow. + * + * @param versionsTab - The versions tab page object + */ +async function expectRestoredToInitial(versionsTab: VersionsTab): Promise { + await expect(versionsTab.versions()).toHaveCount(3) + await expect(versionsTab.version(0)).toContainText('Current version') + await expect(versionsTab.version(2)).not.toContainText('Initial version') + + expect(await versionsTab.getVersionContent(0)).toBe('v1') + expect(await versionsTab.getVersionContent(1)).toBe('v3') + expect(await versionsTab.getVersionContent(2)).toBe('v2') +} + +test.describe('files_versions: versions restoration', () => { + test('restores the initial version of an own file', async ({ page, user, filesListPage, versionsTab }) => { + await mkdir(page.request, user, `/${FOLDER_NAME}`) + await seedThreeVersions(page.request, user, FILE_PATH) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // The current version cannot be restored onto itself + await versionsTab.expectActionMissing(0, 'restore') + await versionsTab.restore(2) + + await expectRestoredToInitial(versionsTab) + }) + + test('restores versions of a shared file with update permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => p.includes('W')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + await versionsTab.restore(2) + + await expectRestoredToInitial(versionsTab) + }) + + test('cannot restore versions of a shared file without update permission', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await seedThreeVersions(ownerRequest, owner, FILE_PATH) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.UPDATE }) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME, (p) => !p.includes('W')) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + await expect(versionsTab.versions()).toHaveCount(3) + + // Without update permission the current version offers no actions menu, and + // the older versions offer no restore action + await versionsTab.expectNoActionsMenu(0) + await versionsTab.expectActionMissing(1, 'restore') + await versionsTab.expectActionMissing(2, 'restore') + }) +}) diff --git a/tests/playwright/e2e/files_versions/version-sharing.spec.ts b/tests/playwright/e2e/files_versions/version-sharing.spec.ts new file mode 100644 index 0000000000000..ce4267b6469f5 --- /dev/null +++ b/tests/playwright/e2e/files_versions/version-sharing.spec.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeTests } from '@playwright/test' +import { test as sharingTest } from '../../support/fixtures/files-sharing-page.ts' +import { expect, test as versionsTest } from '../../support/fixtures/files-versions-tab-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createShare, waitForShare } from '../../support/utils/sharing.ts' +import { openVersionsPanel, seedThreeVersions } from '../../support/utils/versions.ts' + +const test = mergeTests(versionsTest, sharingTest) + +const FOLDER_NAME = 'shared-folder' +const FILE_NAME = 'file.txt' + +test.describe('files_versions: versions on shares', () => { + test('shows the version author display name to the sharee', async ({ page, user, owner, ownerRequest, filesListPage, versionsTab }) => { + // The owner creates the versions, so the recipient must see the owner as author + await mkdir(ownerRequest, owner, `/${FOLDER_NAME}`) + await createShare(ownerRequest, `/${FOLDER_NAME}`, user.userId) + await seedThreeVersions(ownerRequest, owner, `${FOLDER_NAME}/${FILE_NAME}`) + await waitForShare(page.request, user, FOLDER_NAME, FILE_NAME) + + await filesListPage.open() + await filesListPage.navigateToFolder(FOLDER_NAME) + await openVersionsPanel(filesListPage, versionsTab, FILE_NAME) + + await expect(versionsTab.versions()).toHaveCount(3) + await expect(versionsTab.authorName(0)).toContainText(owner.userId) + }) +}) diff --git a/tests/playwright/support/fixtures/files-versions-tab-page.ts b/tests/playwright/support/fixtures/files-versions-tab-page.ts new file mode 100644 index 0000000000000..12d2812952797 --- /dev/null +++ b/tests/playwright/support/fixtures/files-versions-tab-page.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { VersionsTab } from '../sections/VersionsTab.ts' +import { test as filesTest } from './files-page.ts' + +type VersionsFixtures = { + versionsTab: VersionsTab +} + +/** Files fixtures plus the `versionsTab` page object, for single-user version tests. */ +export const test = filesTest.extend({ + versionsTab: async ({ page }, use) => { + await use(new VersionsTab(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/VersionsTab.ts b/tests/playwright/support/sections/VersionsTab.ts new file mode 100644 index 0000000000000..1a44800161ffa --- /dev/null +++ b/tests/playwright/support/sections/VersionsTab.ts @@ -0,0 +1,205 @@ +/* + * 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 version actions, keyed by the stable action id (matching the app's + * `data-cy-files-versions-version-action`), mapped to the accessible name of the + * corresponding menu item. Every entry has a distinct label, so version actions + * are addressed by role + name rather than by data-* selectors. + */ +const VERSION_ACTION_NAMES = { + label: /Name this version|Edit version name/, + compare: 'Compare to current version', + restore: 'Restore version', + download: 'Download version', + delete: 'Delete version', +} as const + +export type VersionAction = keyof typeof VERSION_ACTION_NAMES + +/** + * A DAV request against the versions collection (list / restore / label / delete). + */ +function isVersionsRequest(url: string) { + return /\/dav\/versions\/[^/]+\/versions\//.test(url) +} + +/** + * The "Versions" tab of the Files right sidebar (the files_versions app). + * + * The tab must already be reachable — i.e. the sidebar is open for a file (via a + * row's "Details" action). {@link open} then selects the Versions tab and waits + * for the version list to be fetched. + */ +export class VersionsTab { + constructor(private readonly page: Page) {} + + /** The Versions tab panel (`role="tabpanel"`, accessible name "Versions"). */ + panel(): Locator { + return this.page.getByRole('tabpanel', { name: 'Versions' }) + } + + /** The `
    ` holding the version entries (accessible name "File versions"). */ + list(): Locator { + return this.panel().getByRole('list', { name: 'File versions' }) + } + + /** All version entries, newest first (index 0 is the current version). */ + versions(): Locator { + return this.list().getByRole('listitem') + } + + /** The version entry at the given index (0 = current version). */ + version(index: number): Locator { + return this.versions().nth(index) + } + + /** + * The author element of a version entry (avatar + display name, or "You" for + * the current user). Scoped by the app's `data-cy-files-version-author-name` + * hook — the author has no accessible role of its own. + */ + authorName(index: number): Locator { + return this.version(index).locator('[data-cy-files-version-author-name]') + } + + /** The actions-menu toggle button of a version entry. */ + private menuToggle(index: number): Locator { + return this.version(index).getByRole('button', { name: /^Actions for version/ }) + } + + /** + * Select the Versions tab in the already-open sidebar and wait for the + * version list PROPFIND to land, so the entries are present before any + * assertion runs. + */ + async open(): Promise { + const fetched = this.page.waitForResponse((r) => r.request().method() === 'PROPFIND' && isVersionsRequest(r.url())) + await this.page.getByRole('tab', { name: 'Versions' }).click() + await fetched + await expect(this.panel()).toBeVisible() + } + + /** + * Open a version's actions menu and return the (teleported) menu locator. + * + * The NcActions toggle can drop its first click while still (re-)mounting, and + * `aria-controls` is set even while the menu is closed, so retry opening until + * the referenced menu is actually visible — clicking only while it is closed so + * an already-open menu is never toggled shut. Mirrors the files list row menu. + */ + private async openMenu(index: number): Promise { + const toggle = this.menuToggle(index) + await toggle.scrollIntoViewIfNeeded() + + let menu!: Locator + await expect(async () => { + let menuId = await toggle.getAttribute('aria-controls') + const alreadyOpen = !!menuId && await this.page.locator(`#${menuId}`).isVisible() + if (!alreadyOpen) { + await toggle.click() + menuId = await toggle.getAttribute('aria-controls') + } + expect(menuId).toBeTruthy() + menu = this.page.locator(`#${menuId}`) + await expect(menu).toBeVisible({ timeout: 2000 }) + }).toPass({ timeout: 15000 }) + return menu + } + + /** + * Close the actions menu of the version at `index` by toggling its button. + * + * The menu is closed with a second click on its own toggle rather than with + * Escape: Escape bubbles up and closes the whole sidebar, which would remove + * the other version rows a caller may still want to inspect. + */ + private async closeMenu(index: number): Promise { + const toggle = this.menuToggle(index) + const menuId = await toggle.getAttribute('aria-controls') + if (menuId && await this.page.locator(`#${menuId}`).isVisible()) { + await toggle.click() + await expect(this.page.locator(`#${menuId}`)).toBeHidden() + } + } + + private actionItem(menu: Locator, action: VersionAction): Locator { + return menu.getByRole('menuitem', { name: VERSION_ACTION_NAMES[action] }) + } + + /** + * Restore the version at `index` and wait for the restore MOVE to complete. + * Only non-current versions offer this action. + */ + async restore(index: number): Promise { + const restored = this.page.waitForResponse((r) => r.request().method() === 'MOVE' && isVersionsRequest(r.url())) + const menu = await this.openMenu(index) + await this.actionItem(menu, 'restore').click() + await restored + } + + /** + * Delete the version at `index` and wait for the DELETE to complete. + * Only non-current versions offer this action. + */ + async delete(index: number): Promise { + const deleted = this.page.waitForResponse((r) => r.request().method() === 'DELETE' && isVersionsRequest(r.url())) + const menu = await this.openMenu(index) + await this.actionItem(menu, 'delete').click() + await deleted + } + + /** + * Set (or edit) the label of the version at `index` through the label dialog + * and wait for the PROPPATCH to complete. + */ + async nameVersion(index: number, name: string): Promise { + const labelled = this.page.waitForResponse((r) => r.request().method() === 'PROPPATCH' && isVersionsRequest(r.url())) + const menu = await this.openMenu(index) + await this.actionItem(menu, 'label').click() + + const dialog = this.page.getByRole('dialog', { name: 'Name this version' }) + await dialog.getByRole('textbox', { name: 'Version name' }).fill(name) + await dialog.getByRole('button', { name: 'Save version name' }).click() + + await labelled + await expect(dialog).toBeHidden() + } + + /** + * Fetch the content served by the version's "Download version" action. + * + * Rather than driving a browser download (which is racy to read back), this + * reads the download link's href and fetches it with the page's own + * (authenticated) request context — verifying the exact URL the UI would open + * serves the expected bytes. + */ + async getVersionContent(index: number): Promise { + const menu = await this.openMenu(index) + const href = await this.actionItem(menu, 'download').getAttribute('href') + await this.closeMenu(index) + if (!href) { + throw new Error(`Version ${index} has no download link`) + } + const response = await this.page.request.get(href) + return await response.text() + } + + /** Assert the version at `index` offers no actions menu at all. */ + async expectNoActionsMenu(index: number): Promise { + await expect(this.menuToggle(index)).toHaveCount(0) + } + + /** Assert the version at `index` does not offer the given action. */ + async expectActionMissing(index: number, action: VersionAction): Promise { + const menu = await this.openMenu(index) + await expect(this.actionItem(menu, action)).toHaveCount(0) + await this.closeMenu(index) + } +} diff --git a/tests/playwright/support/utils/versions.ts b/tests/playwright/support/utils/versions.ts new file mode 100644 index 0000000000000..32461d4f498cd --- /dev/null +++ b/tests/playwright/support/utils/versions.ts @@ -0,0 +1,75 @@ +/* + * 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 type { FilesListPage } from '../sections/FilesListPage.ts' +import type { VersionsTab } from '../sections/VersionsTab.ts' + +import { uploadContent } from './dav.ts' + +/** + * The three payloads seeded by {@link seedThreeVersions}, oldest ("v1") to + * newest ("v3"). The newest is the current file content; "v1" and "v2" become + * the two older versions. + */ +export const VERSION_CONTENTS = ['v1', 'v2', 'v3'] as const + +/** + * Seed a file with three distinct versions by uploading it three times. + * + * The Files versioning backend keys each stored version on the file's mtime at + * the moment it is overwritten (`files_versions/.v`). The Cypress + * original waited 1.1s of real time between uploads so consecutive versions got + * distinct mtimes and survived the versioning auto-expiration — a slow and flaky + * approach. + * + * Instead we set explicit, widely-spaced mtimes via the `X-OC-MTime` header: the + * three uploads land 120s apart in the recent past. That guarantees three + * distinct version files with no real waiting, and keeps them clear of the + * auto-expiration tiers (which keep one version per 60s within the last hour), + * so exactly three versions reliably survive. + * + * After the three uploads the versions list shows: the current file ("v3"), plus + * the two stored versions "v2" and "v1" — three entries total, newest first. + * + * @param request - A request context authenticated as the file owner + * @param user - The owner whose root `path` is relative to + * @param path - The file path to create versions for (relative to user root) + */ +export async function seedThreeVersions( + request: APIRequestContext, + user: User, + path: string, +): Promise { + const base = Math.floor(Date.now() / 1000) + for (const [index, content] of VERSION_CONTENTS.entries()) { + // Oldest first: v1 at base-360, v2 at base-240, v3 (current) at base-120. + const mtime = base - (VERSION_CONTENTS.length - index) * 120 + await uploadContent(request, user, content, 'text/plain', path, mtime) + } +} + +/** + * Open the Versions tab of the sidebar for the file at `path`. + * + * Triggers the file row's "Details" action to open the sidebar, then selects the + * Versions tab (waiting for the version list to load). `path` may be a full path; + * only its last segment (the file name) is used to find the row, so the caller + * must already be in the containing folder. + * + * @param filesList - The files list page object + * @param versionsTab - The versions tab page object + * @param path - The file path (or bare name) whose versions to open + */ +export async function openVersionsPanel( + filesList: FilesListPage, + versionsTab: VersionsTab, + path: string, +): Promise { + const name = path.split('/').filter(Boolean).pop() ?? path + await filesList.triggerActionForFile(name, 'details') + await versionsTab.open() +} From 594426032642d7cb93abaf67ee6df9491156d2e1 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 22 Jul 2026 20:22:35 +0200 Subject: [PATCH 2/4] test: consolidate helpers for files_* related tests Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Ferdinand Thiessen --- REUSE.toml | 8 +- .../e2e/files/files-sidebar.spec.ts | 2 +- .../e2e/files_sharing/files-copy-move.spec.ts | 35 +------- .../e2e/files_trashbin/files.spec.ts | 4 +- .../support/sections/FilesListPage.ts | 9 +- tests/playwright/support/utils/sharing.ts | 89 +++++++++++++++++-- tests/playwright/tsconfig.json | 8 ++ 7 files changed, 108 insertions(+), 47 deletions(-) create mode 100644 tests/playwright/tsconfig.json diff --git a/REUSE.toml b/REUSE.toml index afd8302e10155..e9117789e80f7 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -189,6 +189,12 @@ precedence = "aggregate" SPDX-FileCopyrightText = "2022 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" +[[annotations]] +path = ["tests/playwright/tsconfig.json"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + [[annotations]] path = ["build/stubs/pcntl.php", "build/stubs/zip.php"] precedence = "aggregate" @@ -220,7 +226,7 @@ SPDX-FileCopyrightText = "2017 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] -path = ["core/src/systemtags/templates/result.handlebars", "core/src/systemtags/templates/result_form.handlebars", "core/src/systemtags/templates/selection.handlebars", "apps/files/js/templates.js", "apps/files/js/templates/detailsview.handlebars", "apps/files/js/templates/favorite_mark.handlebars", "apps/files/js/templates/file_action_trigger.handlebars", "apps/files/js/templates/fileactionsmenu.handlebars", "apps/files/js/templates/filemultiselectmenu.handlebars", "apps/files/js/templates/filesummary.handlebars", "apps/files/js/templates/mainfileinfodetailsview.handlebars", "apps/files/js/templates/newfilemenu.handlebars", "apps/files/js/templates/newfilemenu_filename_form.handlebars", "apps/files/js/templates/operationprogressbar.handlebars", "apps/files/js/templates/operationprogressbarlabel.handlebars", "apps/files/js/templates/template_addbutton.handlebars", "apps/files_external/js/templates.js", "apps/files_external/js/templates/credentialsDialog.handlebars", "apps/files_external/js/templates/mountOptionsDropDown.handlebars", "apps/files_external/js/templates.js", "apps/files_sharing/js/templates/files_drop.handlebars", "apps/files_sharing/js/templates.js", "tests/data/testimage.heic", "package.json", "package-lock.json"] +path = ["tests/data/testimage.heic", "package.json", "package-lock.json"] precedence = "aggregate" SPDX-FileCopyrightText = "2018 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/tests/playwright/e2e/files/files-sidebar.spec.ts b/tests/playwright/e2e/files/files-sidebar.spec.ts index 5e82f3959df21..bf7bc8df6fc93 100644 --- a/tests/playwright/e2e/files/files-sidebar.spec.ts +++ b/tests/playwright/e2e/files/files-sidebar.spec.ts @@ -7,7 +7,7 @@ import { expect, test } from '../../support/fixtures/files-page.ts' import { mkdir, uploadContent } from '../../support/utils/dav.ts' test.describe('Files: Sidebar', () => { - let fileId: number + let fileId: string test.beforeEach(async ({ user, page, filesListPage }) => { await mkdir(page.request, user, '/folder') diff --git a/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts index e16200774d5f9..f666653aa2f03 100644 --- a/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts +++ b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts @@ -3,12 +3,9 @@ * 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-sharing-page.ts' -import { getChildPermissions, mkdir, uploadContent } from '../../support/utils/dav.ts' -import { ALL_PERMISSIONS, createShare, SharePermission } from '../../support/utils/sharing.ts' +import { mkdir, uploadContent } from '../../support/utils/dav.ts' +import { ALL_PERMISSIONS, createShare, SharePermission, waitForShare } from '../../support/utils/sharing.ts' const EMPTY = Buffer.alloc(0) @@ -46,7 +43,7 @@ test.describe('files_sharing: Move or copy files', () => { test('cannot copy a file to a shared folder with no create permission', async ({ page, user, owner, ownerRequest, filesListPage, copyMoveDialog }) => { await mkdir(ownerRequest, owner, '/folder') await mkdir(ownerRequest, owner, '/folder/inner-folder') - await createShare(ownerRequest, '/folder', user.userId, ALL_PERMISSIONS & ~SharePermission.CREATE) + await createShare(ownerRequest, '/folder', user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.CREATE }) await uploadContent(page.request, user, EMPTY, 'text/plain', '/file.txt') // Wait for the create restriction (no C) to reach the recipient's listing @@ -69,7 +66,7 @@ test.describe('files_sharing: Move or copy files', () => { test('cannot move a file from shared folder with no delete permission', async ({ page, user, owner, ownerRequest, filesListPage, copyMoveDialog }) => { await mkdir(ownerRequest, owner, '/folder') await uploadContent(ownerRequest, owner, EMPTY, 'text/plain', '/folder/file.txt') - await createShare(ownerRequest, '/folder', user.userId, ALL_PERMISSIONS & ~SharePermission.DELETE) + await createShare(ownerRequest, '/folder', user.userId, { permissions: ALL_PERMISSIONS & ~SharePermission.DELETE }) // create the target await mkdir(page.request, user, '/owned-folder') @@ -92,27 +89,3 @@ test.describe('files_sharing: Move or copy files', () => { await expect(copyMoveDialog.confirmButton('Move to owned-folder')).toHaveCount(0) }) }) - -/** - * A share mounts into the recipient's tree asynchronously, and permission changes - * propagate after that. Poll the recipient's directory listing for the entry's - * `oc:permissions` (the same source the Files UI reads) until it exists and - * satisfies `ready`, before driving the UI. Transient errors (mount not there - * yet) are swallowed so the poll keeps waiting. - */ -async function waitForShare( - request: APIRequestContext, - user: User, - parentPath: string, - childName: string, - ready: (permissions: string) => boolean = () => true, -): Promise { - await expect.poll(async () => { - try { - const permissions = await getChildPermissions(request, user, parentPath, childName) - return permissions !== '' && ready(permissions) - } catch { - return false - } - }, { message: `share ${parentPath}/${childName} did not propagate to ${user.userId}`, timeout: 20_000 }).toBe(true) -} diff --git a/tests/playwright/e2e/files_trashbin/files.spec.ts b/tests/playwright/e2e/files_trashbin/files.spec.ts index 45545d8db69e6..69f536c37cb93 100644 --- a/tests/playwright/e2e/files_trashbin/files.spec.ts +++ b/tests/playwright/e2e/files_trashbin/files.spec.ts @@ -9,7 +9,7 @@ import type { TrashbinListPage } from '../../support/sections/TrashbinListPage.t import { readFile } from 'node:fs/promises' import { expect, test } from '../../support/fixtures/files-trashbin-page.ts' import { mkdir, rm, uploadContent } from '../../support/utils/dav.ts' -import { ALL_PERMISSIONS, createShare, ShareType } from '../../support/utils/sharing.ts' +import { createShare, ShareType } from '../../support/utils/sharing.ts' import { setUserDisplayName } from '../../support/utils/users.ts' test.describe('files_trashbin: download files', () => { @@ -67,7 +67,7 @@ test.describe('files_trashbin: file row', () => { test('shows data for a file deleted by a sharee in a group share', async ({ user, aliceRequest, bob, bobRequest, group, filesListPage }) => { await setUserDisplayName(bobRequest, bob.userId, 'Bob') await mkdir(aliceRequest, user, '/Shared') - await createShare(aliceRequest, '/Shared', group, ALL_PERMISSIONS, ShareType.GROUP) + await createShare(aliceRequest, '/Shared', group, { shareType: ShareType.GROUP }) const fileId = Number(await uploadContent(aliceRequest, user, '', 'text/plain', '/Shared/test-file.txt')) // Bob (the sharee) deletes the file from his view of the shared folder diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts index dfa521366d87e..1a3e711b81a7e 100644 --- a/tests/playwright/support/sections/FilesListPage.ts +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -373,10 +373,11 @@ export class FilesListPage { async navigateToFolder(dirPath: string): Promise { for (const directory of dirPath.split('/').filter(Boolean)) { - await this.getRowForFile(directory) - .getByRole('button') - .filter({ hasText: directory }) - .click() + // Click the row's name link (the folder-open action) directly. Filtering + // the row's buttons by the folder name is ambiguous for shared folders, + // whose row also carries a "Shared by …" action button that can contain + // the same text. + await this.getRowNameLinkForFile(directory).click() // Assert the deepest segment of the `dir` query param matches the folder // we just opened. Comparing the decoded value (URLSearchParams decodes diff --git a/tests/playwright/support/utils/sharing.ts b/tests/playwright/support/utils/sharing.ts index 584b6c81ec81d..42b4780f2a357 100644 --- a/tests/playwright/support/utils/sharing.ts +++ b/tests/playwright/support/utils/sharing.ts @@ -3,9 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { User } from '@nextcloud/e2e-test-server' import type { Permission } from '@nextcloud/files' import type { APIRequestContext } from '@playwright/test' +import { expect } from '@playwright/test' +import { getChildPermissions } from './dav.ts' + // we cannot import the enum directly from the files app. // It references the window object and causes errors when imported in a node context, // so we re-declare the relevant values here. The type assertion ensures we stay in sync. @@ -30,6 +34,29 @@ export const ShareType = { GROUP: 1, } as const +/** + * The share attribute that forbids downloading (and thus opens the file + * view-only). It mirrors the "allow download" toggle in the share editor and is + * what the versions sidebar reads to decide whether a "Download version" action + * is offered. Pass it as the `attributes` option to {@link createShare}. + */ +export const DOWNLOAD_DISABLED_ATTRIBUTE = [ + { scope: 'permissions', key: 'download', value: false }, +] as const + +/** Options for {@link createShare}. */ +export interface CreateShareOptions { + /** The permission bitmask to grant (defaults to all). */ + permissions?: number + /** The OCS share type (defaults to a user share). */ + shareType?: number + /** + * Share attributes (e.g. {@link DOWNLOAD_DISABLED_ATTRIBUTE}). Serialized to + * the OCS `attributes` field. + */ + attributes?: readonly { scope: string, key: string, value: boolean }[] +} + /** * Create a share via the OCS Share API. Seeding shares through the API avoids * driving the (flaky) share-editor sidebar. @@ -38,16 +65,20 @@ export const ShareType = { * `ownerRequest` fixture) * @param path - The path to share, relative to the owner's root * @param shareWith - The recipient: a user id for a user share, a group id for a group share - * @param permissions - The permission bitmask to grant (defaults to all) - * @param shareType - The OCS share type (defaults to a user share) + * @param options - Permission bitmask, share type and/or share attributes */ export async function createShare( request: APIRequestContext, path: string, shareWith: string, - permissions: number = ALL_PERMISSIONS, - shareType: number = ShareType.USER, + options: CreateShareOptions = {}, ): Promise { + const { + permissions = ALL_PERMISSIONS, + shareType = ShareType.USER, + attributes, + } = options + const response = await request.post('/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json', { headers: { 'OCS-APIRequest': 'true' }, form: { @@ -63,12 +94,24 @@ export async function createShare( throw new Error(`Creating share for ${path} failed: ${ocs?.meta?.statuscode} ${ocs?.meta?.message}`) } - // A new share ignores the create-time permissions and always starts with the - // full set, so restricted permissions must be applied with a follow-up update. - if (permissions !== ALL_PERMISSIONS) { + // A new share ignores the create-time permissions/attributes and always + // starts with the full set, so anything restricted must be applied with a + // follow-up update. Only send `permissions` when actually restricting: the + // server clamps the natural full set to what the node allows (e.g. a file + // share cannot carry DELETE/CREATE), so forcing ALL_PERMISSIONS would be + // rejected on a file. + const restrictsPermissions = permissions !== ALL_PERMISSIONS + if (restrictsPermissions || attributes !== undefined) { + const form: Record = {} + if (restrictsPermissions) { + form.permissions = permissions + } + if (attributes !== undefined) { + form.attributes = JSON.stringify(attributes) + } const update = await request.put(`/ocs/v2.php/apps/files_sharing/api/v1/shares/${ocs.data.id}?format=json`, { headers: { 'OCS-APIRequest': 'true' }, - form: { permissions }, + form, }) const updateMeta = (await update.json()).ocs?.meta if (updateMeta?.statuscode !== 200) { @@ -76,3 +119,33 @@ export async function createShare( } } } + +/** + * A share mounts into the recipient's tree asynchronously, and permission changes + * propagate after that. Poll the recipient's directory listing for the entry's + * `oc:permissions` (the same source the Files UI reads) until it exists and + * satisfies `ready`, before driving the UI. Transient errors (mount not there + * yet) are swallowed so the poll keeps waiting. + * + * @param request - A request context authenticated as the recipient + * @param user - The recipient user + * @param parentPath - The directory to list (relative to recipient root; '' = root) + * @param childName - The shared entry to wait for + * @param ready - Optional predicate on the entry's `oc:permissions` letters + */ +export async function waitForShare( + request: APIRequestContext, + user: User, + parentPath: string, + childName: string, + ready: (permissions: string) => boolean = () => true, +): Promise { + await expect.poll(async () => { + try { + const permissions = await getChildPermissions(request, user, parentPath, childName) + return permissions !== '' && ready(permissions) + } catch { + return false + } + }, { message: `share ${parentPath}/${childName} did not propagate to ${user.userId}`, timeout: 20_000 }).toBe(true) +} diff --git a/tests/playwright/tsconfig.json b/tests/playwright/tsconfig.json new file mode 100644 index 0000000000000..27333adfaef9a --- /dev/null +++ b/tests/playwright/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./**/*.ts"], + "exclude": [], + "compilerOptions": { + "rootDir": "." + } +} From 2bd82c7edd46c7419451f3a5949f7421051454da Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 22 Jul 2026 20:22:53 +0200 Subject: [PATCH 3/4] ci: adjust runner usage for e2e tests We moved more tests from Cypress -> PlayWright so also adjust the available runners for those workflows. Signed-off-by: Ferdinand Thiessen --- .github/workflows/cypress.yml | 4 ++-- .github/workflows/playwright.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index cfca039a4b51e..9af0b0cea5cf6 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -141,9 +141,9 @@ 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: ['0', '1', '2', '3'] + containers: ['0', '1'] # Always align this number with the total of e2e runners (max. index + 1) - total-containers: [4] + total-containers: [2] name: runner ${{ matrix.containers }} diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 8b4b580d76854..fc5aac80f9017 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, 6] - shardTotal: [6] + shardIndex: [1, 2, 3, 4, 5, 6, 7] + shardTotal: [7] outputs: node-version: ${{ steps.versions.outputs.node-version }} package-manager-version: ${{ steps.versions.outputs.package-manager-version }} From 4dce4746ca4b90d2b860b5ecc1cd144aaf357dcc Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 22 Jul 2026 23:01:54 +0200 Subject: [PATCH 4/4] test(files): fix flaky playwright tests Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Ferdinand Thiessen --- .../playwright/e2e/files/files-delete.spec.ts | 37 +++++++------------ .../playwright/e2e/files/router-query.spec.ts | 14 +++++-- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/tests/playwright/e2e/files/files-delete.spec.ts b/tests/playwright/e2e/files/files-delete.spec.ts index 1b1a844ce8e2e..e22df5e33a545 100644 --- a/tests/playwright/e2e/files/files-delete.spec.ts +++ b/tests/playwright/e2e/files/files-delete.spec.ts @@ -36,28 +36,19 @@ test.describe('Files: Delete', () => { // All 5 preview thumbnails must finish loading before we delete await expect(page.locator('.files-list__row-icon-preview--loaded')).toHaveCount(5) - // One listener per file, registered before triggering the action. Each - // predicate matches its own file's URL — with identical predicates all - // listeners can resolve to the same first response, so distinct DELETE - // requests would never actually be verified. - const deleteResponses = Promise.all(files.map((file) => page.waitForResponse( - (r) => r.url().includes(`/remote.php/dav/files/${user.userId}/root/${file}`) && 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() - - await deleteResponses - - // Assert the user-visible end state (rows gone) rather than raw response - // codes — a one-shot status check flakes on transient DAV lock responses. - for (const file of files) { - await expect(filesListPage.getRowForFile(file)).toHaveCount(0) - } + // Retry the bulk delete until the folder is empty. A transient DAV lock + // (423) on a freshly-uploaded file makes its DELETE fail and the app keeps + // the row, so a single pass can leave a file behind. Re-selecting and + // re-deleting whatever remains converges on the empty end state without + // depending on every concurrent DELETE succeeding on the first try. + await expect(async () => { + await filesListPage.selectAll() + await filesListPage.triggerSelectionAction('delete') + await page.getByRole('dialog', { name: 'Confirm deletion' }) + .getByRole('button', { name: 'Delete files' }) + .click() + + await expect(filesListPage.getRows()).toHaveCount(0) + }).toPass({ timeout: 30_000 }) }) }) diff --git a/tests/playwright/e2e/files/router-query.spec.ts b/tests/playwright/e2e/files/router-query.spec.ts index 477f77765d418..047c6205b7a5c 100644 --- a/tests/playwright/e2e/files/router-query.spec.ts +++ b/tests/playwright/e2e/files/router-query.spec.ts @@ -52,20 +52,26 @@ test.describe('Check router query flags', () => { }) test.describe('"openfile"', () => { - const viewerShowsImage = async (page: Page, imageId: number) => { + const viewerShowsImage = async (page: Page) => { const dialog = page.getByRole('dialog', { name: 'image.jpg' }) await expect(dialog).toBeVisible() - await expect(dialog.locator(`img[src*="fileId=${imageId}"]`)).toBeVisible() + // The viewer shows a server-rendered preview, or falls back to the + // original file; either way the only gains a box (and so becomes + // visible) once it finishes loading, and a cold preview render on CI can + // exceed the default 5s timeout. Assert the displayed image by its alt + // rather than pinning to the preview URL — the preview-specific `fileId=` + // selector both flakes on slow loads and misses the fallback source. + await expect(dialog.getByRole('img', { name: 'image.jpg' })).toBeVisible({ timeout: 15_000 }) } test('opens files with default action', async ({ page, ids }) => { await page.goto(`apps/files/files/${ids.imageId}?openfile`) - await viewerShowsImage(page, ids.imageId) + await viewerShowsImage(page) }) 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) + await viewerShowsImage(page) }) test('does not open files with default action when using explicit `false`', async ({ page, ids, filesListPage }) => {