From 7faa3d896f900837cd90f469ae1b38644f93ff4b Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Thu, 11 Jun 2026 18:38:37 +0200 Subject: [PATCH] test(files_trashbin): migrate trashbin e2e from Cypress to Playwright Signed-off-by: Peter Ringelmann --- .../files_trashbin/files-trash-action.cy.ts | 69 --------- cypress/e2e/files_trashbin/files.cy.ts | 137 ------------------ .../files_trashbin/files-trash-action.spec.ts | 56 +++++++ .../e2e/files_trashbin/files.spec.ts | 102 +++++++++++++ .../support/fixtures/files-trashbin-page.ts | 82 +++++++++++ .../support/sections/FilesListPage.ts | 65 +++++++-- tests/playwright/support/utils/sharing.ts | 16 +- tests/playwright/support/utils/users.ts | 30 ++++ 8 files changed, 334 insertions(+), 223 deletions(-) delete mode 100644 cypress/e2e/files_trashbin/files-trash-action.cy.ts delete mode 100644 cypress/e2e/files_trashbin/files.cy.ts create mode 100644 tests/playwright/e2e/files_trashbin/files-trash-action.spec.ts create mode 100644 tests/playwright/e2e/files_trashbin/files.spec.ts create mode 100644 tests/playwright/support/fixtures/files-trashbin-page.ts create mode 100644 tests/playwright/support/utils/users.ts diff --git a/cypress/e2e/files_trashbin/files-trash-action.cy.ts b/cypress/e2e/files_trashbin/files-trash-action.cy.ts deleted file mode 100644 index fff87776c91ff..0000000000000 --- a/cypress/e2e/files_trashbin/files-trash-action.cy.ts +++ /dev/null @@ -1,69 +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 { deleteFileWithRequest, triggerFileListAction } from '../files/FilesUtils.ts' - -const FILE_COUNT = 5 -describe('files_trashbin: Empty trashbin action', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - // create 5 fake files and move them to trash - for (let index = 0; index < FILE_COUNT; index++) { - cy.uploadContent(user, new Blob(['']), 'text/plain', `/file${index}.txt`) - deleteFileWithRequest(user, `/file${index}.txt`) - } - // login - cy.login(user) - }) - }) - - it('Can empty trashbin', () => { - cy.visit('/apps/files') - // Home have no files (or the default welcome file) - cy.get('[data-cy-files-list-row-fileid]').should('have.length', 1) - cy.get('[data-cy-files-list-action="empty-trash"]').should('not.exist') - - // Go to trashbin, and see our deleted files - cy.visit('/apps/files/trashbin') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT) - - // Empty trashbin - cy.intercept('DELETE', '**/remote.php/dav/trashbin/**').as('emptyTrash') - triggerFileListAction('empty-trash') - - // Confirm dialog - cy.get('[role=dialog]').should('be.visible') - .findByRole('button', { name: 'Empty deleted files' }).click() - - // Wait for the request to finish - cy.wait('@emptyTrash').its('response.statusCode').should('eq', 204) - cy.get('@emptyTrash.all').should('have.length', 1) - - // Trashbin should be empty - cy.get('[data-cy-files-list-row-fileid]').should('not.exist') - }) - - it('Cancelling empty trashbin action does not delete anything', () => { - // Go to trashbin, and see our deleted files - cy.visit('/apps/files/trashbin') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT) - - // Empty trashbin - cy.intercept('DELETE', '**/remote.php/dav/trashbin/**').as('emptyTrash') - triggerFileListAction('empty-trash') - - // Cancel dialog - cy.get('[role=dialog]').should('be.visible') - .findByRole('button', { name: 'Cancel' }).click() - - // request was never sent - cy.get('@emptyTrash').should('not.exist') - cy.get('[data-cy-files-list-row-fileid]').should('have.length', FILE_COUNT) - }) -}) diff --git a/cypress/e2e/files_trashbin/files.cy.ts b/cypress/e2e/files_trashbin/files.cy.ts deleted file mode 100644 index d5290b6d6a2a6..0000000000000 --- a/cypress/e2e/files_trashbin/files.cy.ts +++ /dev/null @@ -1,137 +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 { ShareType } from '@nextcloud/sharing' -import { deleteDownloadsFolderBeforeEach } from '../../support/utils/deleteDownloadsFolder.ts' -import { randomString } from '../../support/utils/randomString.ts' -import { deleteFileWithRequest, getRowForFileId, selectAllFiles, triggerActionForFileId } from '../files/FilesUtils.ts' - -describe('files_trashbin: download files', { testIsolation: true }, () => { - let user: User - const fileids: [number, number] = [0, 0] - - deleteDownloadsFolderBeforeEach() - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - - cy.uploadContent(user, new Blob(['']), 'text/plain', '/file.txt') - .then(({ headers }) => fileids[0] = Number.parseInt(headers['oc-fileid'])) - .then(() => deleteFileWithRequest(user, '/file.txt')) - cy.uploadContent(user, new Blob(['']), 'text/plain', '/other-file.txt') - .then(({ headers }) => fileids[1] = Number.parseInt(headers['oc-fileid'])) - .then(() => deleteFileWithRequest(user, '/other-file.txt')) - }) - }) - - beforeEach(() => { - cy.login(user) - cy.visit('/apps/files/trashbin') - }) - - it('can download file', () => { - getRowForFileId(fileids[0]).should('be.visible') - getRowForFileId(fileids[1]).should('be.visible') - - triggerActionForFileId(fileids[0], 'download') - - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) - - it('can download a file using default action', () => { - getRowForFileId(fileids[0]) - .should('be.visible') - .findByRole('button', { name: 'Download' }) - .click({ force: true }) - - const downloadsFolder = Cypress.config('downloadsFolder') - cy.readFile(`${downloadsFolder}/file.txt`, { timeout: 15000 }) - .should('exist') - .and('have.length.gt', 8) - .and('equal', '') - }) - - // TODO: Fix this as this dependens on the webdav zip folder plugin not working for trashbin (and never worked with old NC legacy download ajax as well) - it('does not offer bulk download', () => { - cy.get('[data-cy-files-list-row-checkbox]').should('have.length', 2) - selectAllFiles() - cy.get('.files-list__selected').should('contain.text', '2 selected') - cy.get('[data-cy-files-list-selection-action="restore"]').should('be.visible') - cy.get('[data-cy-files-list-selection-action="download"]').should('not.exist') - }) -}) - -describe('files_trashbin: file row', { testIsolation: true }, () => { - let alice: User - let bob: User - let randomGroupName: string - let fileId: number - - before(() => { - randomGroupName = randomString(10) - cy.runOccCommand(`group:add ${randomGroupName}`) - - cy.createRandomUser().then((user) => { - alice = user - - cy.modifyUser(alice, 'display', 'Alice') - - cy.mkdir(alice, '/Shared') - }) - - cy.createRandomUser().then((user) => { - bob = user - - cy.modifyUser(bob, 'display', 'Bob') - - cy.runOccCommand(`group:adduser ${randomGroupName} ${bob.userId}`) - }) - }) - - it('shows data for file deleted by owner', () => { - cy.uploadContent(alice, new Blob(['']), 'text/plain', '/test-file.txt') - .then(({ headers }) => fileId = Number.parseInt(headers['oc-fileid'])) - .then(() => deleteFileWithRequest(alice, '/test-file.txt')) - - cy.login(alice) - cy.visit('/apps/files/trashbin') - - getRowForFileId(fileId).should('be.visible') - // The full name includes one span for the name and one span for the - // extension, so text() returns a space when composing them even if it - // will not be visible when rendered in the browser. - getRowForFileId(fileId).find('[data-cy-files-list-row-name]').should((element) => expect(element.text().trim()).to.equal('test-file .txt')) - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--original-location"]').should('have.text', 'All files') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted-by"]').should('have.text', 'You') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted"]').should('have.text', 'few seconds ago') - }) - - it('shows data for file deleted by sharee in a folder shared with a group', () => { - cy.createShare(alice, '/Shared', ShareType.Group, randomGroupName) - - cy.uploadContent(alice, new Blob(['']), 'text/plain', '/Shared/test-file.txt') - .then(({ headers }) => fileId = Number.parseInt(headers['oc-fileid'])) - .then(() => deleteFileWithRequest(bob, '/Shared/test-file.txt')) - - cy.login(alice) - cy.visit('/apps/files/trashbin') - - getRowForFileId(fileId).should('be.visible') - // The full name includes one span for the name and one span for the - // extension, so text() returns a space when composing them even if it - // will not be visible when rendered in the browser. - getRowForFileId(fileId).find('[data-cy-files-list-row-name]').should((element) => expect(element.text().trim()).to.equal('test-file .txt')) - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--original-location"]').should('have.text', 'Shared') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted-by"]').should('have.text', 'Bob') - getRowForFileId(fileId).find('[data-cy-files-list-row-column-custom="files_trashbin--deleted"]').should('have.text', 'few seconds ago') - }) -}) diff --git a/tests/playwright/e2e/files_trashbin/files-trash-action.spec.ts b/tests/playwright/e2e/files_trashbin/files-trash-action.spec.ts new file mode 100644 index 0000000000000..cbc4c688a3e14 --- /dev/null +++ b/tests/playwright/e2e/files_trashbin/files-trash-action.spec.ts @@ -0,0 +1,56 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/files-page.ts' +import { rm, uploadContent } from '../../support/utils/dav.ts' + +const FILE_COUNT = 5 + +test.describe('files_trashbin: empty trashbin action', () => { + test.beforeEach(async ({ page, user }) => { + // Create FILE_COUNT files and move them all to the trash + for (let index = 0; index < FILE_COUNT; index++) { + await uploadContent(page.request, user, '', 'text/plain', `/file${index}.txt`) + await rm(page.request, user, `/file${index}.txt`) + } + }) + + test('can empty trashbin', async ({ page, filesListPage }) => { + await filesListPage.open() + // Home holds only the default welcome file and offers no empty-trash action + await expect(filesListPage.getRows()).toHaveCount(1) + await expect(filesListPage.getListActionButton('empty-trash')).toHaveCount(0) + + await filesListPage.open('trashbin') + await expect(filesListPage.getRows()).toHaveCount(FILE_COUNT) + + const emptied = page.waitForResponse( + (r) => r.request().method() === 'DELETE' && r.url().includes('/remote.php/dav/trashbin/'), + ) + await filesListPage.triggerListAction('empty-trash') + + // Confirm in the dialog + await page.getByRole('dialog') + .getByRole('button', { name: 'Empty deleted files' }) + .click() + + expect((await emptied).status()).toBe(204) + await expect(filesListPage.getRows()).toHaveCount(0) + }) + + test('cancelling the empty trashbin action does not delete anything', async ({ page, filesListPage }) => { + await filesListPage.open('trashbin') + await expect(filesListPage.getRows()).toHaveCount(FILE_COUNT) + + await filesListPage.triggerListAction('empty-trash') + + // Cancel the dialog: no request is sent and the files remain + await page.getByRole('dialog') + .getByRole('button', { name: 'Cancel' }) + .click() + + await expect(filesListPage.getRows()).toHaveCount(FILE_COUNT) + }) +}) diff --git a/tests/playwright/e2e/files_trashbin/files.spec.ts b/tests/playwright/e2e/files_trashbin/files.spec.ts new file mode 100644 index 0000000000000..c2a56b6ce678d --- /dev/null +++ b/tests/playwright/e2e/files_trashbin/files.spec.ts @@ -0,0 +1,102 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' +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, ShareType, createShare } from '../../support/utils/sharing.ts' +import { setUserDisplayName } from '../../support/utils/users.ts' + +// Trashbin custom columns, keyed by their product-owned column ids +const ORIGINAL_LOCATION = '[data-cy-files-list-row-column-custom="files_trashbin--original-location"]' +const DELETED_BY = '[data-cy-files-list-row-column-custom="files_trashbin--deleted-by"]' +const DELETED_AT = '[data-cy-files-list-row-column-custom="files_trashbin--deleted"]' + +/** Run `trigger`, then assert it downloaded `file.txt` with the expected content. */ +async function expectFileDownload(page: Page, trigger: () => Promise) { + const downloadPromise = page.waitForEvent('download') + await trigger() + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('file.txt') + expect(await readFile(await download.path(), 'utf-8')).toBe('') +} + +/** Assert a trashbin row's name and custom columns (the deleted time is always recent). */ +async function expectTrashbinRow(row: Locator, name: string, location: string, deletedBy: string) { + await expect(row).toBeVisible() + // Name and extension render as separate spans, so the composed text has a space + await expect(row.locator('[data-cy-files-list-row-name]')).toHaveText(name) + await expect(row.locator(ORIGINAL_LOCATION)).toHaveText(location) + await expect(row.locator(DELETED_BY)).toHaveText(deletedBy) + await expect(row.locator(DELETED_AT)).toHaveText('few seconds ago') +} + +test.describe('files_trashbin: download files', () => { + let fileIds: [number, number] + + test.beforeEach(async ({ page, user, filesListPage }) => { + const first = await uploadContent(page.request, user, '', 'text/plain', '/file.txt') + await rm(page.request, user, '/file.txt') + const second = await uploadContent(page.request, user, '', 'text/plain', '/other-file.txt') + await rm(page.request, user, '/other-file.txt') + fileIds = [Number(first), Number(second)] + + await filesListPage.open('trashbin') + }) + + test('can download a file', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFileId(fileIds[0])).toBeVisible() + await expect(filesListPage.getRowForFileId(fileIds[1])).toBeVisible() + + await expectFileDownload(page, () => filesListPage.triggerActionForFileId(fileIds[0], 'download')) + }) + + test('can download a file using the default action', async ({ page, filesListPage }) => { + await expectFileDownload(page, () => + // The inline "Download" button is the row's default action; force past the sticky header + filesListPage.getRowForFileId(fileIds[0]) + .getByRole('button', { name: 'Download' }) + .click({ force: true }), + ) + }) + + // Trashbin has no bulk download: the webdav zip-folder plugin does not work for + // the trashbin (and never did with the legacy ajax download either). + test('does not offer bulk download', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowCheckboxes()).toHaveCount(2) + await filesListPage.selectAll() + await expect(page.getByText('2 selected')).toBeVisible() + + await expect(filesListPage.getSelectionActionEntry('restore')).toBeVisible() + await expect(filesListPage.getSelectionActionEntry('download')).toHaveCount(0) + }) +}) + +test.describe('files_trashbin: file row', () => { + test('shows data for a file deleted by the owner', async ({ user, aliceRequest, filesListPage }) => { + const fileId = Number(await uploadContent(aliceRequest, user, '', 'text/plain', '/test-file.txt')) + await rm(aliceRequest, user, '/test-file.txt') + + await filesListPage.open('trashbin') + + // The owner's own deletions render as "You" regardless of display name + await expectTrashbinRow(filesListPage.getRowForFileId(fileId), 'test-file .txt', 'All files', 'You') + }) + + 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) + + 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 + await rm(bobRequest, bob, '/Shared/test-file.txt') + + await filesListPage.open('trashbin') + + await expectTrashbinRow(filesListPage.getRowForFileId(fileId), 'test-file .txt', 'Shared', 'Bob') + }) +}) diff --git a/tests/playwright/support/fixtures/files-trashbin-page.ts b/tests/playwright/support/fixtures/files-trashbin-page.ts new file mode 100644 index 0000000000000..046a6688bb646 --- /dev/null +++ b/tests/playwright/support/fixtures/files-trashbin-page.ts @@ -0,0 +1,82 @@ +/* + * 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, PlaywrightWorkerArgs } from '@playwright/test' +import type { User } from '@nextcloud/e2e-test-server' +import { test as filesTest } from './files-page.ts' + +/** + * Build a request context authenticated as `user` via basic auth, with no + * browser cookies (cookies would otherwise win over basic auth). Used to seed + * data as a given user without driving the UI. + */ +function basicAuthContext( + playwright: PlaywrightWorkerArgs['playwright'], + baseURL: string | undefined, + user: User, +): Promise { + return playwright.request.newContext({ + baseURL, + // send: 'always' — OCS issues no Basic auth challenge, so send credentials preemptively + httpCredentials: { username: user.userId, password: user.password, send: 'always' }, + }) +} + +type TrashbinFixtures = { + /** + * A request context authenticated as `user` (the trashbin owner, "alice") via + * basic auth, with no browser cookies — used to seed the group share without + * the (flaky) sharing sidebar. + */ + aliceRequest: APIRequestContext + /** A second user ("bob") who receives the group share and deletes a file in it. */ + bob: User + /** + * A request context authenticated as `bob` via basic auth, with no browser + * cookies — bob deletes the shared file (and sets his display name) through it. + */ + bobRequest: APIRequestContext + /** A group containing `bob`, used to share a folder with him. */ + group: string +} + +/** + * Files fixtures for the trashbin "file row" scenarios. The browser is logged in + * as `user` (the owner, "alice") who views the trash; `bob` and the `group` model + * a file deleted by a sharee. All fixtures are lazy, so the simpler single-user + * trashbin tests pull none of this setup. + */ +export const test = filesTest.extend({ + aliceRequest: async ({ playwright, user, baseURL }, use) => { + const context = await basicAuthContext(playwright, baseURL, user) + await use(context) + await context.dispose() + }, + + bob: async ({}, use) => { + const bob = await createRandomUser() + await use(bob) + await runOcc(['user:delete', bob.userId]) + }, + + bobRequest: async ({ playwright, bob, baseURL }, use) => { + const context = await basicAuthContext(playwright, baseURL, bob) + await use(context) + await context.dispose() + }, + + group: async ({ bob }, use) => { + // Derive the group id from bob's random id so parallel workers never collide + const group = `trashbin-group-${bob.userId}` + await runOcc(['group:add', group]) + await runOcc(['group:adduser', group, bob.userId]) + await use(group) + await runOcc(['group:delete', group]) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts index ff26be663ff66..e4d687916b56b 100644 --- a/tests/playwright/support/sections/FilesListPage.ts +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -26,21 +26,24 @@ export class FilesListPage { return this.page.locator(`[data-cy-files-list-row-fileid="${fileid}"]`) } - private getActionsButtonForFile(filename: string): Locator { - return this.getRowForFile(filename) - .getByRole('button', { name: 'Actions' }) + /** All file rows currently rendered in the list (e.g. for count assertions). */ + getRows(): Locator { + return this.page.locator('[data-cy-files-list-row-fileid]') + } + + /** The per-row selection checkboxes. */ + getRowCheckboxes(): Locator { + return this.page.locator('[data-cy-files-list-row-checkbox]') } /** - * Open the row actions menu for a file and return the menu popover locator. - * Use this when a test needs to inspect a menu entry (e.g. its label) before - * clicking; for a plain "open and click" use {@link triggerActionForFile}. + * Open a row's actions menu and return the menu popover locator. Keyed on a + * row Locator so it serves both name- and fileid-addressed rows. */ - async openActionsMenuForFile(filename: string): Promise { - const row = this.getRowForFile(filename) + private async openActionsMenuForRow(row: Locator): Promise { await row.hover() - const actionsButton = this.getActionsButtonForFile(filename) + const actionsButton = row.getByRole('button', { name: 'Actions' }) await actionsButton.scrollIntoViewIfNeeded() // force: true to avoid issues with the sticky file list header await actionsButton.click({ force: true }) @@ -51,16 +54,52 @@ export class FilesListPage { return menu } + private async triggerActionForRow(row: Locator, actionId: string): Promise { + const menu = await this.openActionsMenuForRow(row) + const actionEntry = this.getActionButtonInMenu(menu, actionId) + await actionEntry.waitFor({ state: 'visible' }) + await actionEntry.click() + } + + /** + * Open the row actions menu for a file and return the menu popover locator. + * Use this when a test needs to inspect a menu entry (e.g. its label) before + * clicking; for a plain "open and click" use {@link triggerActionForFile}. + */ + async openActionsMenuForFile(filename: string): Promise { + return this.openActionsMenuForRow(this.getRowForFile(filename)) + } + getActionButtonInMenu(menu: Locator, actionId: string): Locator { // The action button has role="menuitem", so use tag selector not getByRole return menu.locator(`[data-cy-files-list-row-action="${actionId}"] button`) } async triggerActionForFile(filename: string, actionId: string): Promise { - const menu = await this.openActionsMenuForFile(filename) - const actionEntry = this.getActionButtonInMenu(menu, actionId) - await actionEntry.waitFor({ state: 'visible' }) - await actionEntry.click() + await this.triggerActionForRow(this.getRowForFile(filename), actionId) + } + + /** + * Like {@link triggerActionForFile} but addresses the row by file id. Trashbin + * rows are keyed by id because a deleted file's name is no longer unique (the + * same name can be trashed several times). + */ + async triggerActionForFileId(fileid: number, actionId: string): Promise { + await this.triggerActionForRow(this.getRowForFileId(fileid), actionId) + } + + /** + * A file-list-level action button rendered in the list header (e.g. + * "empty-trash"), as opposed to a per-row or selection action. + */ + getListActionButton(actionId: string): Locator { + return this.page.locator(`[data-cy-files-list-action="${actionId}"]`) + } + + async triggerListAction(actionId: string): Promise { + // .last(): the action can render both inline and inside the overflow menu; + // the last match is the actionable one + await this.getListActionButton(actionId).last().click({ force: true }) } getFavoriteIconForFile(filename: string): Locator { diff --git a/tests/playwright/support/utils/sharing.ts b/tests/playwright/support/utils/sharing.ts index d2523082aaea0..53ba2379ed0de 100644 --- a/tests/playwright/support/utils/sharing.ts +++ b/tests/playwright/support/utils/sharing.ts @@ -23,27 +23,35 @@ export const ALL_PERMISSIONS = SharePermission.READ | SharePermission.DELETE | SharePermission.SHARE +/** OCS Share API share types (subset we seed in tests). */ +export const ShareType = { + USER: 0, + GROUP: 1, +} as const + /** - * Create a user-to-user share via the OCS Share API. Seeding shares through the - * API avoids driving the (flaky) share-editor sidebar. + * Create a 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 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) */ export async function createShare( request: APIRequestContext, path: string, shareWith: string, permissions: number = ALL_PERMISSIONS, + shareType: number = ShareType.USER, ): 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 + shareType, shareWith, permissions, }, diff --git a/tests/playwright/support/utils/users.ts b/tests/playwright/support/utils/users.ts new file mode 100644 index 0000000000000..a484145050f7d --- /dev/null +++ b/tests/playwright/support/utils/users.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' + +/** + * Set a user's display name through the OCS Provisioning API. A user may edit + * their own display name, so `request` must be authenticated as `userId`. + * + * @param request - A request context authenticated as the user being modified + * @param userId - The id of the user whose display name to set + * @param displayName - The new display name + */ +export async function setUserDisplayName( + request: APIRequestContext, + userId: string, + displayName: string, +): Promise { + const response = await request.put(`/ocs/v2.php/cloud/users/${userId}?format=json`, { + headers: { 'OCS-APIRequest': 'true' }, + form: { key: 'display', value: displayName }, + }) + // OCS returns HTTP 200 even on failure; the real status lives in ocs.meta + const meta = (await response.json()).ocs?.meta + if (meta?.statuscode !== 200) { + throw new Error(`Setting display name for ${userId} failed: ${meta?.statuscode} ${meta?.message}`) + } +}