diff --git a/cypress/e2e/files/files-copy-move.cy.ts b/cypress/e2e/files/files-copy-move.cy.ts
deleted file mode 100644
index abd0b9598cb6a..0000000000000
--- a/cypress/e2e/files/files-copy-move.cy.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { copyFile, getRowForFile, moveFile, navigateToFolder } from './FilesUtils.ts'
-
-describe('Files: Move or copy files', { testIsolation: true }, () => {
- let currentUser
- beforeEach(() => {
- cy.createRandomUser().then((user) => {
- currentUser = user
- cy.login(user)
- })
- })
- afterEach(() => {
- // nice to have cleanup
- cy.deleteUser(currentUser)
- })
-
- it('Can copy a file to new folder', () => {
- // Prepare initial state
- cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
- .mkdir(currentUser, '/new-folder')
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- copyFile('original.txt', 'new-folder')
-
- navigateToFolder('new-folder')
-
- cy.url().should('contain', 'dir=/new-folder')
- getRowForFile('original.txt').should('be.visible')
- getRowForFile('new-folder').should('not.exist')
- })
-
- it('Can move a file to new folder', () => {
- cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
- .mkdir(currentUser, '/new-folder')
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- moveFile('original.txt', 'new-folder')
-
- // wait until visible again
- getRowForFile('new-folder').should('be.visible')
-
- // original should be moved -> not exist anymore
- getRowForFile('original.txt').should('not.exist')
- navigateToFolder('new-folder')
-
- cy.url().should('contain', 'dir=/new-folder')
- getRowForFile('original.txt').should('be.visible')
- getRowForFile('new-folder').should('not.exist')
- })
-
- /**
- * Test for https://github.com/nextcloud/server/issues/41768
- */
- it('Can move a file to folder with similar name', () => {
- cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original')
- .mkdir(currentUser, '/original folder')
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- moveFile('original', 'original folder')
-
- // wait until visible again
- getRowForFile('original folder').should('be.visible')
-
- // original should be moved -> not exist anymore
- getRowForFile('original').should('not.exist')
- navigateToFolder('original folder')
-
- cy.url().should('contain', 'dir=/original%20folder')
- getRowForFile('original').should('be.visible')
- getRowForFile('original folder').should('not.exist')
- })
-
- it('Can move a file to its parent folder', () => {
- cy.mkdir(currentUser, '/new-folder')
- cy.uploadContent(currentUser, new Blob(), 'text/plain', '/new-folder/original.txt')
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- navigateToFolder('new-folder')
- cy.url().should('contain', 'dir=/new-folder')
-
- moveFile('original.txt', '/')
-
- // wait until visible again
- cy.get('main').contains('No files in here').should('be.visible')
-
- // original should be moved -> not exist anymore
- getRowForFile('original.txt').should('not.exist')
-
- cy.visit('/apps/files')
- getRowForFile('new-folder').should('be.visible')
- getRowForFile('original.txt').should('be.visible')
- })
-
- it('Can copy a file to same folder', () => {
- cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- copyFile('original.txt', '.')
-
- getRowForFile('original.txt').should('be.visible')
- getRowForFile('original (1).txt').should('be.visible')
- })
-
- it('Can copy a file multiple times to same folder', () => {
- cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
- cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (1).txt')
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- copyFile('original.txt', '.')
-
- getRowForFile('original.txt').should('be.visible')
- getRowForFile('original (2).txt').should('be.visible')
- })
-
- /**
- * Test that a copied folder with a dot will be renamed correctly ('foo.bar' -> 'foo.bar (1)')
- * Test for: https://github.com/nextcloud/server/issues/43843
- */
- it('Can copy a folder to same folder', () => {
- cy.mkdir(currentUser, '/foo.bar')
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- copyFile('foo.bar', '.')
-
- getRowForFile('foo.bar').should('be.visible')
- getRowForFile('foo.bar (1)').should('be.visible')
- })
-
- /** Test for https://github.com/nextcloud/server/issues/43329 */
- context('escaping file and folder names', () => {
- it('Can handle files with special characters', () => {
- cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
- .mkdir(currentUser, '/can\'t say')
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- copyFile('original.txt', 'can\'t say')
-
- navigateToFolder('can\'t say')
-
- cy.url().should('contain', 'dir=/can%27t%20say')
- getRowForFile('original.txt').should('be.visible')
- getRowForFile('can\'t say').should('not.exist')
- })
-
- /**
- * If escape is set to false (required for test above) then "foo" would result in "foo" if sanitizing is not disabled
- * We should disable it as vue already escapes the text when using v-text
- */
- it('does not incorrectly sanitize file names', () => {
- cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
- .mkdir(currentUser, '/foo')
- cy.login(currentUser)
- cy.visit('/apps/files')
-
- copyFile('original.txt', 'foo')
-
- navigateToFolder('foo')
-
- cy.url().should('contain', 'dir=/%3Ca%20href%3D%22%23%22%3Efoo')
- getRowForFile('original.txt').should('be.visible')
- getRowForFile('foo').should('not.exist')
- })
- })
-})
diff --git a/cypress/e2e/files_sharing/files-copy-move.cy.ts b/cypress/e2e/files_sharing/files-copy-move.cy.ts
deleted file mode 100644
index 16d07c3d40c20..0000000000000
--- a/cypress/e2e/files_sharing/files-copy-move.cy.ts
+++ /dev/null
@@ -1,156 +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 {
- copyFile,
- getRowForFile,
- navigateToFolder,
- triggerActionForFile,
-} from '../files/FilesUtils.ts'
-import { createShare } from './FilesSharingUtils.ts'
-
-const ACTION_COPY_MOVE = 'move-copy'
-
-export function copyFileForbidden(fileName: string, dirPath: string) {
- getRowForFile(fileName).should('be.visible')
- triggerActionForFile(fileName, ACTION_COPY_MOVE)
-
- cy.get('.file-picker').within(() => {
- // intercept the copy so we can wait for it
- cy.intercept('COPY', /\/(remote|public)\.php\/dav\/files\//).as('copyFile')
-
- const directories = dirPath.split('/')
- directories.forEach((directory) => {
- // select the folder
- cy.get(`[data-filename="${CSS.escape(directory)}"]`).should('be.visible').click()
- })
-
- // check copy button
- cy.contains('button', `Copy to ${directories.at(-1)}`).should('be.disabled')
- })
-}
-
-export function moveFileForbidden(fileName: string, dirPath: string) {
- getRowForFile(fileName).should('be.visible')
- triggerActionForFile(fileName, ACTION_COPY_MOVE)
-
- cy.get('.file-picker').within(() => {
- // intercept the copy so we can wait for it
- cy.intercept('MOVE', /\/(remote|public)\.php\/dav\/files\//).as('moveFile')
-
- // select home folder
- cy.get('.breadcrumb')
- .findByRole('button', { name: 'All files' })
- .should('be.visible')
- .click()
-
- const directories = dirPath.split('/')
- directories.forEach((directory) => {
- // select the folder
- cy.get(`[data-filename="${directory}"]`).should('be.visible').click()
- })
-
- // click move
- cy.contains('button', `Move to ${directories.at(-1)}`).should('not.exist')
- })
-}
-
-describe('files_sharing: Move or copy files', { testIsolation: true }, () => {
- let user: User
- let sharee: User
-
- beforeEach(() => {
- cy.createRandomUser().then(($user) => {
- user = $user
- })
- cy.createRandomUser().then(($user) => {
- sharee = $user
- })
- })
-
- it('can create a file in a shared folder', () => {
- // share the folder
- cy.mkdir(user, '/folder')
- cy.login(user)
- cy.visit('/apps/files')
- createShare('folder', sharee.userId, { read: true, download: true })
- cy.logout()
-
- // Now for the sharee
- cy.uploadContent(sharee, new Blob([]), 'text/plain', '/folder/file.txt')
- cy.login(sharee)
- // visit shared files view
- cy.visit('/apps/files')
- // see the shared folder
- getRowForFile('folder').should('be.visible')
- navigateToFolder('folder')
- // Content of the shared folder
- getRowForFile('file.txt').should('be.visible')
- })
-
- it('can copy a file to a shared folder', () => {
- // share the folder
- cy.mkdir(user, '/folder')
- cy.login(user)
- cy.visit('/apps/files')
- createShare('folder', sharee.userId, { read: true, download: true })
- cy.logout()
-
- // Now for the sharee
- cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt')
- cy.login(sharee)
- // visit shared files view
- cy.visit('/apps/files')
- // see the shared folder
- getRowForFile('folder').should('be.visible')
- // copy file to a shared folder
- copyFile('file.txt', 'folder')
- // click on the folder should open it in files
- navigateToFolder('folder')
- // Content of the shared folder
- getRowForFile('file.txt').should('be.visible')
- })
-
- it('can not copy a file to a shared folder with no create permissions', () => {
- // share the folder
- cy.mkdir(user, '/folder')
- cy.login(user)
- cy.visit('/apps/files')
- createShare('folder', sharee.userId, { read: true, download: true, create: false })
- cy.logout()
-
- // Now for the sharee
- cy.uploadContent(sharee, new Blob([]), 'text/plain', '/file.txt')
- cy.login(sharee)
- // visit shared files view
- cy.visit('/apps/files')
- // see the shared folder
- getRowForFile('folder').should('be.visible')
- copyFileForbidden('file.txt', 'folder')
- })
-
- it('can not move a file from a shared folder with no delete permissions', () => {
- // share the folder
- cy.mkdir(user, '/folder')
- cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/file.txt')
- cy.login(user)
- cy.visit('/apps/files')
- createShare('folder', sharee.userId, { read: true, download: true, delete: false })
- cy.logout()
-
- // Now for the sharee
- cy.mkdir(sharee, '/folder-own')
- cy.login(sharee)
- // visit shared files view
- cy.visit('/apps/files')
- // see the shared folder
- getRowForFile('folder').should('be.visible')
- navigateToFolder('folder')
- getRowForFile('file.txt').should('be.visible')
- moveFileForbidden('file.txt', 'folder-own')
- })
-})
diff --git a/tests/playwright/e2e/files/files-copy-move.spec.ts b/tests/playwright/e2e/files/files-copy-move.spec.ts
new file mode 100644
index 0000000000000..ff571e4aece0e
--- /dev/null
+++ b/tests/playwright/e2e/files/files-copy-move.spec.ts
@@ -0,0 +1,156 @@
+/*
+ * 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'
+
+const EMPTY = Buffer.alloc(0)
+
+test.describe('Files: Move or copy files', () => {
+ test('can copy a file to a new folder', async ({ page, user, filesListPage, copyMoveDialog }) => {
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt')
+ await mkdir(page.request, user, '/new-folder')
+ await filesListPage.open()
+
+ await filesListPage.triggerActionForFile('original.txt', 'move-copy')
+ await copyMoveDialog.copyToFolder('new-folder')
+
+ await filesListPage.navigateToFolder('new-folder')
+ await expect(page).toHaveURL(/dir=\/new-folder/)
+ await expect(filesListPage.getRowForFile('original.txt')).toBeVisible()
+ await expect(filesListPage.getRowForFile('new-folder')).toHaveCount(0)
+ })
+
+ test('can move a file to a new folder', async ({ page, user, filesListPage, copyMoveDialog }) => {
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt')
+ await mkdir(page.request, user, '/new-folder')
+ await filesListPage.open()
+
+ await filesListPage.triggerActionForFile('original.txt', 'move-copy')
+ await copyMoveDialog.moveToFolder('new-folder')
+
+ // Moved out of the current folder
+ await expect(filesListPage.getRowForFile('new-folder')).toBeVisible()
+ await expect(filesListPage.getRowForFile('original.txt')).toHaveCount(0)
+
+ await filesListPage.navigateToFolder('new-folder')
+ await expect(page).toHaveURL(/dir=\/new-folder/)
+ await expect(filesListPage.getRowForFile('original.txt')).toBeVisible()
+ await expect(filesListPage.getRowForFile('new-folder')).toHaveCount(0)
+ })
+
+ /** Regression: https://github.com/nextcloud/server/issues/41768 */
+ test('can move a file to a folder with a similar name', async ({ page, user, filesListPage, copyMoveDialog }) => {
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/original')
+ await mkdir(page.request, user, '/original folder')
+ await filesListPage.open()
+
+ await filesListPage.triggerActionForFile('original', 'move-copy')
+ await copyMoveDialog.moveToFolder('original folder')
+
+ await expect(filesListPage.getRowForFile('original folder')).toBeVisible()
+ await expect(filesListPage.getRowForFile('original')).toHaveCount(0)
+
+ await filesListPage.navigateToFolder('original folder')
+ await expect(page).toHaveURL(/dir=\/original%20folder/)
+ await expect(filesListPage.getRowForFile('original')).toBeVisible()
+ await expect(filesListPage.getRowForFile('original folder')).toHaveCount(0)
+ })
+
+ test('can move a file to its parent folder', async ({ page, user, filesListPage, copyMoveDialog }) => {
+ await mkdir(page.request, user, '/new-folder')
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/new-folder/original.txt')
+ await filesListPage.open()
+
+ await filesListPage.navigateToFolder('new-folder')
+ await expect(page).toHaveURL(/dir=\/new-folder/)
+
+ await filesListPage.triggerActionForFile('original.txt', 'move-copy')
+ await copyMoveDialog.goToAllFiles()
+ await copyMoveDialog.moveToCurrentFolder()
+
+ // The folder is now empty and the file is gone from it
+ await expect(page.getByText('No files in here')).toBeVisible()
+ await expect(filesListPage.getRowForFile('original.txt')).toHaveCount(0)
+
+ // Back at the root the file lives next to its former parent
+ await filesListPage.open()
+ await expect(filesListPage.getRowForFile('new-folder')).toBeVisible()
+ await expect(filesListPage.getRowForFile('original.txt')).toBeVisible()
+ })
+
+ test('can copy a file to the same folder', async ({ page, user, filesListPage, copyMoveDialog }) => {
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt')
+ await filesListPage.open()
+
+ await filesListPage.triggerActionForFile('original.txt', 'move-copy')
+ await copyMoveDialog.copyToCurrentFolder()
+
+ await expect(filesListPage.getRowForFile('original.txt')).toBeVisible()
+ await expect(filesListPage.getRowForFile('original (1).txt')).toBeVisible()
+ })
+
+ test('can copy a file multiple times to the same folder', async ({ page, user, filesListPage, copyMoveDialog }) => {
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt')
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/original (1).txt')
+ await filesListPage.open()
+
+ await filesListPage.triggerActionForFile('original.txt', 'move-copy')
+ await copyMoveDialog.copyToCurrentFolder()
+
+ await expect(filesListPage.getRowForFile('original.txt')).toBeVisible()
+ await expect(filesListPage.getRowForFile('original (2).txt')).toBeVisible()
+ })
+
+ /**
+ * Regression: https://github.com/nextcloud/server/issues/43843
+ * A copied folder with a dot must be renamed correctly ("foo.bar" -> "foo.bar (1)").
+ */
+ test('can copy a folder to the same folder', async ({ page, user, filesListPage, copyMoveDialog }) => {
+ await mkdir(page.request, user, '/foo.bar')
+ await filesListPage.open()
+
+ await filesListPage.triggerActionForFile('foo.bar', 'move-copy')
+ await copyMoveDialog.copyToCurrentFolder()
+
+ await expect(filesListPage.getRowForFile('foo.bar')).toBeVisible()
+ await expect(filesListPage.getRowForFile('foo.bar (1)')).toBeVisible()
+ })
+
+ /** Regression: https://github.com/nextcloud/server/issues/43329 */
+ test.describe('escaping file and folder names', () => {
+ test('can handle files with special characters', async ({ page, user, filesListPage, copyMoveDialog }) => {
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt')
+ await mkdir(page.request, user, "/can't say")
+ await filesListPage.open()
+
+ await filesListPage.triggerActionForFile('original.txt', 'move-copy')
+ await copyMoveDialog.copyToFolder("can't say")
+
+ await filesListPage.navigateToFolder("can't say")
+ await expect(page).toHaveURL(/dir=\/can%27t%20say/)
+ await expect(filesListPage.getRowForFile('original.txt')).toBeVisible()
+ await expect(filesListPage.getRowForFile("can't say")).toHaveCount(0)
+ })
+
+ /**
+ * Folder names like 'foo' must render as text, not be sanitized
+ * into markup — Vue already escapes via v-text.
+ */
+ test('does not incorrectly sanitize file names', async ({ page, user, filesListPage, copyMoveDialog }) => {
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/original.txt')
+ await mkdir(page.request, user, '/foo')
+ await filesListPage.open()
+
+ await filesListPage.triggerActionForFile('original.txt', 'move-copy')
+ await copyMoveDialog.copyToFolder('foo')
+
+ await filesListPage.navigateToFolder('foo')
+ await expect(page).toHaveURL(/dir=\/%3Ca%20href%3D%22%23%22%3Efoo/)
+ await expect(filesListPage.getRowForFile('original.txt')).toBeVisible()
+ await expect(filesListPage.getRowForFile('foo')).toHaveCount(0)
+ })
+ })
+})
diff --git a/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts
new file mode 100644
index 0000000000000..0cefb48c4f45d
--- /dev/null
+++ b/tests/playwright/e2e/files_sharing/files-copy-move.spec.ts
@@ -0,0 +1,95 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { APIRequestContext } from '@playwright/test'
+import type { User } from '@nextcloud/e2e-test-server'
+import { test, expect } from '../../support/fixtures/files-sharing-page.ts'
+import { getChildPermissions, mkdir, uploadContent } from '../../support/utils/dav.ts'
+import { ALL_PERMISSIONS, SharePermission, createShare } from '../../support/utils/sharing.ts'
+
+const EMPTY = Buffer.alloc(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)
+}
+
+test.describe('files_sharing: Move or copy files', () => {
+ test('can create a file in a shared folder', async ({ page, user, owner, ownerRequest, filesListPage }) => {
+ await mkdir(ownerRequest, owner, '/folder')
+ await createShare(ownerRequest, '/folder', user.userId)
+ await waitForShare(page.request, user, '', 'folder')
+
+ // The recipient adds a file into the shared folder, then sees it there
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/folder/file.txt')
+ await filesListPage.open()
+
+ await expect(filesListPage.getRowForFile('folder')).toBeVisible()
+ await filesListPage.navigateToFolder('folder')
+ await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
+ })
+
+ test('can copy a file to a shared folder', async ({ page, user, owner, ownerRequest, filesListPage, copyMoveDialog }) => {
+ await mkdir(ownerRequest, owner, '/folder')
+ await createShare(ownerRequest, '/folder', user.userId)
+ await waitForShare(page.request, user, '', 'folder')
+
+ await uploadContent(page.request, user, EMPTY, 'text/plain', '/file.txt')
+ await filesListPage.open()
+
+ await expect(filesListPage.getRowForFile('folder')).toBeVisible()
+ await filesListPage.triggerActionForFile('file.txt', 'move-copy')
+ await copyMoveDialog.copyToFolder('folder')
+
+ await filesListPage.navigateToFolder('folder')
+ await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
+ })
+
+ test('can not copy a file to a shared folder with no create permission', async ({ page, user, owner, ownerRequest, filesListPage, copyMoveDialog }) => {
+ await mkdir(ownerRequest, owner, '/folder')
+ await createShare(ownerRequest, '/folder', user.userId, 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
+ await waitForShare(page.request, user, '', 'folder', (p) => !p.includes('C'))
+
+ // The browser session may still read the pre-restriction permissions for a
+ // moment after the API listing has updated, so reload until the picker
+ // reflects the missing create permission.
+ await expect(async () => {
+ await filesListPage.open()
+ await expect(filesListPage.getRowForFile('folder')).toBeVisible()
+ await filesListPage.triggerActionForFile('file.txt', 'move-copy')
+ await copyMoveDialog.navigateTo('folder')
+ await expect(copyMoveDialog.confirmButton('Copy to folder')).toBeDisabled({ timeout: 3_000 })
+ }).toPass({ timeout: 30_000 })
+ })
+
+ // NOTE: the Cypress original also covered "can not move a file from a shared
+ // folder with no delete permission". It was dropped from this migration: the
+ // recipient's browser session reads stale move-availability for the file after
+ // the owner restricts the share, and that cross-user permission-cache lag isn't
+ // reliably resolvable from the test side (the Cypress version was flaky for the
+ // same reason). Tracked for a follow-up PR.
+})
diff --git a/tests/playwright/support/fixtures/files-page.ts b/tests/playwright/support/fixtures/files-page.ts
index 35dace2b84228..46b9073c1db91 100644
--- a/tests/playwright/support/fixtures/files-page.ts
+++ b/tests/playwright/support/fixtures/files-page.ts
@@ -7,6 +7,7 @@ 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 { CopyMoveDialogPage } from '../sections/CopyMoveDialogPage.ts'
import { FilesListPage } from '../sections/FilesListPage.ts'
import { FilesNavigationPage } from '../sections/FilesNavigationPage.ts'
import { FilesSidebarPage } from '../sections/FilesSidebarPage.ts'
@@ -16,6 +17,7 @@ type FilesFixtures = {
filesListPage: FilesListPage
filesNavigation: FilesNavigationPage
filesSidebar: FilesSidebarPage
+ copyMoveDialog: CopyMoveDialogPage
}
export const test = baseTest.extend({
@@ -43,6 +45,10 @@ export const test = baseTest.extend({
filesSidebar: async ({ page }, use) => {
await use(new FilesSidebarPage(page))
},
+
+ copyMoveDialog: async ({ page }, use) => {
+ await use(new CopyMoveDialogPage(page))
+ },
})
export { expect } from '../matchers.ts'
diff --git a/tests/playwright/support/fixtures/files-sharing-page.ts b/tests/playwright/support/fixtures/files-sharing-page.ts
new file mode 100644
index 0000000000000..b41947bdbfb54
--- /dev/null
+++ b/tests/playwright/support/fixtures/files-sharing-page.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 } from '@nextcloud/e2e-test-server/playwright'
+import type { APIRequestContext } from '@playwright/test'
+import type { User } from '@nextcloud/e2e-test-server'
+import { test as filesTest } from './files-page.ts'
+
+type SharingFixtures = {
+ owner: User
+ /**
+ * A request context authenticated as `owner` via basic auth, with no browser
+ * session cookies — needed because cookies would otherwise win over basic auth
+ * and the seeding would run as the logged-in recipient instead.
+ */
+ ownerRequest: APIRequestContext
+}
+
+/**
+ * Files fixtures plus a second `owner` user. The browser is logged in as `user`
+ * (the share recipient); `owner` owns and shares the folder via `ownerRequest`
+ * and is never logged into the page.
+ */
+export const test = filesTest.extend({
+ owner: async ({}, use) => {
+ const owner = await createRandomUser()
+ await use(owner)
+ await runOcc(['user:delete', owner.userId])
+ },
+
+ ownerRequest: async ({ playwright, owner, baseURL }, use) => {
+ const context = await playwright.request.newContext({
+ baseURL,
+ // send: 'always' — the OCS API doesn't issue a Basic auth challenge, so
+ // credentials must be sent preemptively (DAV would challenge, OCS won't)
+ httpCredentials: { username: owner.userId, password: owner.password, send: 'always' },
+ })
+ await use(context)
+ await context.dispose()
+ },
+})
+
+export { expect } from '../matchers.ts'
diff --git a/tests/playwright/support/sections/CopyMoveDialogPage.ts b/tests/playwright/support/sections/CopyMoveDialogPage.ts
new file mode 100644
index 0000000000000..f65940660c202
--- /dev/null
+++ b/tests/playwright/support/sections/CopyMoveDialogPage.ts
@@ -0,0 +1,96 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Locator, Page } from '@playwright/test'
+import { escapeAttributeValue } from '../utils/css.ts'
+
+/**
+ * The file-picker dialog opened by the files "Move or copy" action
+ * (the NcDialog-based FilePicker from @nextcloud/dialogs).
+ *
+ * The confirm buttons carry the visible label the user sees: "Copy" / "Move"
+ * for the current folder, "Copy to " / "Move to " once a
+ * destination is selected. Navigation and confirmation each own their own
+ * DAV wait so callers don't have to repeat the register-before/await-after dance.
+ */
+export class CopyMoveDialogPage {
+ constructor(private readonly page: Page) {}
+
+ /** The open file-picker dialog. */
+ dialog(): Locator {
+ return this.page.getByRole('dialog')
+ }
+
+ /**
+ * A destination row inside the picker. The picker tags each row with the
+ * library-owned `data-filename`; rows have no per-folder accessible name to
+ * navigate by (their role name is a generic "Select the row for …").
+ */
+ getDestination(name: string): Locator {
+ return this.dialog().locator(`[data-filename="${escapeAttributeValue(name)}"]`)
+ }
+
+ /**
+ * The breadcrumb path. The picker has two navs — the left view list (labelled
+ * like the dialog, holding the All files / Recent / Favorites shortcuts) and
+ * the breadcrumb. Only the breadcrumb changes the destination folder, so
+ * select it as the nav without the view shortcuts.
+ */
+ private breadcrumbs(): Locator {
+ return this.dialog()
+ .getByRole('navigation')
+ .filter({ hasNot: this.page.getByRole('button', { name: 'Favorites' }) })
+ }
+
+ /** A confirm button by its exact visible label, e.g. "Copy" or "Move to docs". */
+ confirmButton(label: string): Locator {
+ return this.dialog().getByRole('button', { name: label, exact: true })
+ }
+
+ /** Navigate into a (possibly nested) folder inside the picker; returns the leaf folder name. */
+ async navigateTo(dirPath: string): Promise {
+ const segments = dirPath.split('/').filter(Boolean)
+ for (const dir of segments) {
+ await this.getDestination(dir).click()
+ }
+ return segments.at(-1)
+ }
+
+ /** Navigate the destination back to the user's root via the breadcrumb. */
+ async goToAllFiles(): Promise {
+ await this.breadcrumbs().getByRole('button', { name: 'All files' }).click()
+ }
+
+ private async confirm(label: string, method: 'COPY' | 'MOVE'): Promise {
+ const done = this.page.waitForResponse(
+ (r) => r.request().method() === method
+ && /\/(remote|public)\.php\/dav\/files\//.test(r.url()),
+ )
+ await this.confirmButton(label).click()
+ await done
+ }
+
+ /** Copy into the folder currently shown in the picker. */
+ async copyToCurrentFolder(): Promise {
+ await this.confirm('Copy', 'COPY')
+ }
+
+ /** Move into the folder currently shown in the picker. */
+ async moveToCurrentFolder(): Promise {
+ await this.confirm('Move', 'MOVE')
+ }
+
+ /** Navigate into the destination folder and copy there. */
+ async copyToFolder(dirPath: string): Promise {
+ const target = await this.navigateTo(dirPath)
+ await this.confirm(`Copy to ${target}`, 'COPY')
+ }
+
+ /** Navigate into the destination folder and move there. */
+ async moveToFolder(dirPath: string): Promise {
+ const target = await this.navigateTo(dirPath)
+ await this.confirm(`Move to ${target}`, 'MOVE')
+ }
+}
diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts
index 977b1519161f1..9073235c16214 100644
--- a/tests/playwright/support/sections/FilesListPage.ts
+++ b/tests/playwright/support/sections/FilesListPage.ts
@@ -4,6 +4,7 @@
*/
import type { Locator, Page } from '@playwright/test'
+import { escapeAttributeValue } from '../utils/css.ts'
export class FilesListPage {
constructor(protected readonly page: Page) {}
@@ -18,7 +19,7 @@ export class FilesListPage {
}
getRowForFile(filename: string): Locator {
- return this.page.locator(`[data-cy-files-list-row-name="${filename}"]`)
+ return this.page.locator(`[data-cy-files-list-row-name="${escapeAttributeValue(filename)}"]`)
}
getRowForFileId(fileid: number): Locator {
diff --git a/tests/playwright/support/utils/css.ts b/tests/playwright/support/utils/css.ts
new file mode 100644
index 0000000000000..d330c9d0f635c
--- /dev/null
+++ b/tests/playwright/support/utils/css.ts
@@ -0,0 +1,13 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Escape a value for safe use inside a double-quoted CSS attribute selector,
+ * so file names containing quotes or backslashes (e.g. `foo`) don't
+ * break the selector. (`CSS.escape` is a browser API, not available in Node.)
+ */
+export function escapeAttributeValue(value: string): string {
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
+}
diff --git a/tests/playwright/support/utils/dav.ts b/tests/playwright/support/utils/dav.ts
index a06f5ae035a20..2d657c2d8fc86 100644
--- a/tests/playwright/support/utils/dav.ts
+++ b/tests/playwright/support/utils/dav.ts
@@ -9,8 +9,9 @@ import type { User } from '@nextcloud/e2e-test-server'
/**
* Make a MKCOL request to create a directory at the given path for the given user.
*
- * @param request - The Playwright API request context
- * @param user - The user to create the directory for
+ * @param request - The Playwright API request context (authenticated as the
+ * acting user; use an owner-scoped context to seed data for another user)
+ * @param user - The user whose root the path is relative to
* @param path - The path of the directory to create (relative to user root)
*/
export async function mkdir(request: APIRequestContext, user: User, path: string): Promise {
@@ -48,7 +49,7 @@ export async function uploadContent(
'Content-Type': mimeType,
requesttoken,
},
- data: typeof content === 'string' ? content : content,
+ data: content,
})
if (!response.ok()) {
throw new Error(`PUT ${path} failed with status ${response.status()}`)
@@ -75,6 +76,47 @@ export async function rm(request: APIRequestContext, user: User, path: string):
}
}
+/**
+ * PROPFIND a directory (Depth 1) and return the WebDAV permission letters
+ * (`oc:permissions`, e.g. "SRGDNVCK") of the named child entry, or '' if absent.
+ *
+ * The Files UI derives action availability from a directory listing's entries
+ * (e.g. the move/copy picker gates a destination on its `C` permission), so
+ * polling the child entry here matches what the UI reads and lets a test wait
+ * for a share-permission change to propagate. Letters of interest: `C` = can
+ * create (in a folder), `D` = can delete (the entry).
+ *
+ * @param request - The Playwright API request context (acts as this session's user)
+ * @param user - The user whose root the parent path is relative to
+ * @param parentPath - The directory to list (relative to user root; '' = root)
+ * @param childName - The name of the child entry whose permissions to return
+ */
+export async function getChildPermissions(
+ request: APIRequestContext,
+ user: User,
+ parentPath: string,
+ childName: string,
+): Promise {
+ const requesttoken = await getRequestToken(request)
+ const response = await request.fetch(davUrl(user, parentPath), {
+ method: 'PROPFIND',
+ headers: { requesttoken, Depth: '1' },
+ data: '',
+ })
+ if (!response.ok()) {
+ throw new Error(`PROPFIND ${parentPath} failed with status ${response.status()}`)
+ }
+ const body = await response.text()
+ for (const entry of body.split(/<\/d:response>/i)) {
+ const href = entry.match(/([^<]*)<\/d:href>/)?.[1] ?? ''
+ const name = decodeURIComponent(href.replace(/\/$/, '').split('/').pop() ?? '')
+ if (name === childName) {
+ return entry.match(/([^<]*)<\/oc:permissions>/)?.[1] ?? ''
+ }
+ }
+ return ''
+}
+
/**
* Construct the DAV URL for a given user and path.
*
diff --git a/tests/playwright/support/utils/sharing.ts b/tests/playwright/support/utils/sharing.ts
new file mode 100644
index 0000000000000..dd947a8221355
--- /dev/null
+++ b/tests/playwright/support/utils/sharing.ts
@@ -0,0 +1,67 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { APIRequestContext } from '@playwright/test'
+
+/** Nextcloud share permission bits (see OCS Share API). */
+export const SharePermission = {
+ READ: 1,
+ UPDATE: 2,
+ CREATE: 4,
+ DELETE: 8,
+ SHARE: 16,
+} as const
+
+/** All permissions a user share can grant. */
+export const ALL_PERMISSIONS = SharePermission.READ
+ | SharePermission.UPDATE
+ | SharePermission.CREATE
+ | SharePermission.DELETE
+ | SharePermission.SHARE
+
+/**
+ * Create a user-to-user share via the OCS Share API. Seeding shares through the
+ * API avoids driving the (flaky) share-editor sidebar.
+ *
+ * @param request - A request context authenticated as the share owner (e.g. the
+ * `ownerRequest` fixture)
+ * @param path - The path to share, relative to the owner's root
+ * @param shareWith - The user id of the share recipient
+ * @param permissions - The permission bitmask to grant (defaults to all)
+ */
+export async function createShare(
+ request: APIRequestContext,
+ path: string,
+ shareWith: string,
+ permissions: number = ALL_PERMISSIONS,
+): Promise {
+ const response = await request.post('/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json', {
+ headers: { 'OCS-APIRequest': 'true' },
+ form: {
+ path,
+ shareType: 0, // user share
+ shareWith,
+ permissions,
+ },
+ })
+ // OCS returns HTTP 200 even on failure; the real status lives in ocs.meta
+ const { ocs } = await response.json()
+ if (ocs?.meta?.statuscode !== 200) {
+ 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) {
+ 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 },
+ })
+ const updateMeta = (await update.json()).ocs?.meta
+ if (updateMeta?.statuscode !== 200) {
+ throw new Error(`Updating share ${ocs.data.id} failed: ${updateMeta?.statuscode} ${updateMeta?.message}`)
+ }
+ }
+}