From aee5a2633982082084ca2f89cb9dea4870c075b6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 8 Sep 2026 19:28:06 +0200 Subject: [PATCH] fix(files): bring back ownership transfer of all files This was a feature available to allow transfering all files in one go. It was dropped by acciendent in Nextcloud 34. Bringing back the feature as well as adding e2e tests (regression tests) for the overall ownership transfer. AI assisted for the tests part: Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Ferdinand Thiessen --- .../components/TransferOwnershipDialogue.vue | 2 +- .../e2e/files/transfer-ownership.spec.ts | 136 +++++++++++++++++ .../fixtures/transfer-ownership-page.ts | 86 +++++++++++ .../BackgroundFilePickerDialogPage.ts | 32 +--- .../support/sections/FilePickerDialogPage.ts | 74 ++++++++++ .../support/sections/TransferOwnershipPage.ts | 138 ++++++++++++++++++ .../support/utils/transferOwnership.ts | 128 ++++++++++++++++ 7 files changed, 567 insertions(+), 29 deletions(-) create mode 100644 tests/playwright/e2e/files/transfer-ownership.spec.ts create mode 100644 tests/playwright/support/fixtures/transfer-ownership-page.ts create mode 100644 tests/playwright/support/sections/FilePickerDialogPage.ts create mode 100644 tests/playwright/support/sections/TransferOwnershipPage.ts create mode 100644 tests/playwright/support/utils/transferOwnership.ts diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue index c8aa55224db9f..0bda9e68d631e 100644 --- a/apps/files/src/components/TransferOwnershipDialogue.vue +++ b/apps/files/src/components/TransferOwnershipDialogue.vue @@ -31,7 +31,7 @@ const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to trans .allowDirectories() .setMultiSelect(false) .setButtonFactory(([node]) => { - const canPick = !!node?.path && node.path !== '/' && node.owner === getCurrentUser()!.uid + const canPick = !!node?.path && node.owner === getCurrentUser()!.uid return [{ label: canPick ? t('files', 'Transfer "{path}"', { path: node.displayname }) diff --git a/tests/playwright/e2e/files/transfer-ownership.spec.ts b/tests/playwright/e2e/files/transfer-ownership.spec.ts new file mode 100644 index 0000000000000..fed78012840b7 --- /dev/null +++ b/tests/playwright/e2e/files/transfer-ownership.spec.ts @@ -0,0 +1,136 @@ +/* + * 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 { FilesListPage } from '../../support/sections/FilesListPage.ts' + +import { expect, test } from '../../support/fixtures/transfer-ownership-page.ts' +import { getFileContent, mkdir, rm, uploadContent } from '../../support/utils/dav.ts' +import { getToast } from '../../support/utils/toast.ts' +import { completeOwnershipTransfer, transferFolderPattern } from '../../support/utils/transferOwnership.ts' + +/** + * Assert that the only entry in the current list is the folder a transfer from + * `source` created, and return its name — it carries the time of the transfer. + * + * @param filesList - The files list of the new owner, showing their root + * @param source - The user the files were transferred from + */ +async function expectSingleTransferFolder(filesList: FilesListPage, source: User): Promise { + await expect.poll(() => filesList.getRowNames()) + .toEqual([expect.stringMatching(transferFolderPattern(source))]) + + const [name] = await filesList.getRowNames() + return name +} + +test.describe('Files: Transfer ownership', () => { + // Accepting the transfer and running its background job shell out to occ, + // which takes considerably longer than the browser interaction itself + test.slow() + + test.beforeEach(async ({ page, user, recipient, recipientPage }) => { + // Both accounts start with a welcome.txt — remove it so the transferred + // files are the only content of either account + await rm(page.request, user, '/welcome.txt') + await rm(recipientPage.request, recipient, '/welcome.txt') + }) + + test('transfers a single file', async ({ page, user, filesListPage, recipient, recipientFilesList, recipientPage, recipientRequest, transferOwnershipPage }) => { + await uploadContent(page.request, user, 'transferred content', 'text/plain', '/document.txt') + await uploadContent(page.request, user, 'kept content', 'text/plain', '/other.txt') + + await transferOwnershipPage.open() + await expect(transferOwnershipPage.getSubmitButton()).toBeDisabled() + await expect(transferOwnershipPage.getMissingNodeHint()).toHaveCount(1) + await expect(transferOwnershipPage.getMissingOwnerHint()).toHaveCount(1) + + await transferOwnershipPage.selectFile('document.txt') + await expect(transferOwnershipPage.getMissingNodeHint()).toHaveCount(0) + + await transferOwnershipPage.selectNewOwner(recipient) + await expect(transferOwnershipPage.getMissingOwnerHint()).toHaveCount(0) + + await expect(transferOwnershipPage.getSubmitButton()) + .toHaveAccessibleName(`Transfer document.txt to ${recipient.userId}`) + await transferOwnershipPage.submit() + + await expect(getToast(page, 'Ownership transfer request sent')).toBeVisible() + // The form is reset, ready for the next transfer + await expect(transferOwnershipPage.getSubmitButton()).toBeDisabled() + await expect(transferOwnershipPage.getMissingNodeHint()).toHaveCount(1) + await expect(transferOwnershipPage.getMissingOwnerHint()).toHaveCount(1) + + await completeOwnershipTransfer(recipientRequest, user, recipient) + + // The previous owner keeps everything but the transferred file + await filesListPage.open() + await expect(filesListPage.getRowForFile('other.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('document.txt')).toHaveCount(0) + + // The new owner received it, with its content, in the transfer folder + await recipientFilesList.open() + const transferFolder = await expectSingleTransferFolder(recipientFilesList, user) + await recipientFilesList.navigateToFolder(transferFolder) + await expect(recipientFilesList.getRowForFile('document.txt')).toBeVisible() + expect(await getFileContent(recipientPage.request, recipient, `${transferFolder}/document.txt`)) + .toBe('transferred content') + }) + + test('transfers a folder with all of its content', async ({ page, user, filesListPage, recipient, recipientFilesList, recipientRequest, transferOwnershipPage }) => { + await mkdir(page.request, user, '/project') + await uploadContent(page.request, user, 'readme', 'text/plain', '/project/readme.md') + await mkdir(page.request, user, '/project/notes') + await uploadContent(page.request, user, 'todo', 'text/plain', '/project/notes/todo.md') + + await transferOwnershipPage.open() + await transferOwnershipPage.selectFolder('project') + await transferOwnershipPage.selectNewOwner(recipient) + await transferOwnershipPage.submit() + await expect(getToast(page, 'Ownership transfer request sent')).toBeVisible() + + await completeOwnershipTransfer(recipientRequest, user, recipient) + + // The folder is gone for the previous owner + await filesListPage.open() + await expect(filesListPage.getRows()).toHaveCount(0) + + // The new owner received the folder with its whole tree + await recipientFilesList.open() + const transferFolder = await expectSingleTransferFolder(recipientFilesList, user) + await recipientFilesList.navigateToFolder(`${transferFolder}/project`) + await expect(recipientFilesList.getRowForFile('readme.md')).toBeVisible() + + await recipientFilesList.navigateToFolder('notes') + await expect(recipientFilesList.getRowForFile('todo.md')).toBeVisible() + }) + + test('transfers all files at once', async ({ page, user, filesListPage, recipient, recipientFilesList, recipientRequest, transferOwnershipPage }) => { + await uploadContent(page.request, user, 'text', 'text/plain', '/document.txt') + await mkdir(page.request, user, '/pictures') + await uploadContent(page.request, user, 'image', 'image/png', '/pictures/image.png') + + await transferOwnershipPage.open() + await transferOwnershipPage.selectAllFiles(user) + await transferOwnershipPage.selectNewOwner(recipient) + await transferOwnershipPage.submit() + await expect(getToast(page, 'Ownership transfer request sent')).toBeVisible() + + await completeOwnershipTransfer(recipientRequest, user, recipient) + + // The previous owner is left with an empty account + await filesListPage.open() + await expect(filesListPage.getRows()).toHaveCount(0) + + // Everything they owned is now in the new owners transfer folder + await recipientFilesList.open() + const transferFolder = await expectSingleTransferFolder(recipientFilesList, user) + await recipientFilesList.navigateToFolder(transferFolder) + await expect(recipientFilesList.getRowForFile('document.txt')).toBeVisible() + + await recipientFilesList.navigateToFolder('pictures') + await expect(recipientFilesList.getRowForFile('image.png')).toBeVisible() + }) +}) diff --git a/tests/playwright/support/fixtures/transfer-ownership-page.ts b/tests/playwright/support/fixtures/transfer-ownership-page.ts new file mode 100644 index 0000000000000..6db18102daf0d --- /dev/null +++ b/tests/playwright/support/fixtures/transfer-ownership-page.ts @@ -0,0 +1,86 @@ +/* + * 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, Page } from '@playwright/test' + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { FilesListPage } from '../sections/FilesListPage.ts' +import { TransferOwnershipPage } from '../sections/TransferOwnershipPage.ts' +import { test as filesTest } from './files-page.ts' + +type TransferOwnershipFixtures = { + /** A second account, receiving the ownership of the files of `user`. */ + recipient: User + /** + * A request context authenticated as `recipient` via basic auth, with no + * browser session cookies — cookies would otherwise win over basic auth and + * the request would run as the user logged into `page` instead. + */ + recipientRequest: APIRequestContext + /** A second browser session, logged in as `recipient`. */ + recipientPage: Page + /** The files list as seen by `recipient`. */ + recipientFilesList: FilesListPage + /** The ownership transfer form in the personal settings of `user`. */ + transferOwnershipPage: TransferOwnershipPage +} + +/** + * Files fixtures for the ownership transfer: the browser is logged in as `user`, + * who owns the files and requests the transfer, and `recipient` is the account + * receiving them. + */ +export const test = filesTest.extend({ + recipient: async ({}, use) => { + let recipient: User + try { + recipient = await createRandomUser() + } catch { + // Retry once on transient failure, as the `user` fixture does + await new Promise((resolve) => setTimeout(resolve, 800)) + recipient = await createRandomUser() + } + await use(recipient) + await runOcc(['user:delete', recipient.userId], { failOnError: false }) + }, + + recipientRequest: async ({ playwright, recipient, 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: recipient.userId, password: recipient.password, send: 'always' }, + }) + await use(context) + await context.dispose() + }, + + recipientPage: async ({ browser, recipient }, use) => { + const context = await browser.newContext() + const recipientPage = await context.newPage() + try { + await login(recipientPage.request, recipient) + } catch (error) { + // Same transient failure the session of `user` is retried for + console.info('Failed to authenticate as recipient, retrying', error) + await new Promise((resolve) => setTimeout(resolve, 800)) + await login(recipientPage.request, recipient) + } + await use(recipientPage) + await context.close() + }, + + recipientFilesList: async ({ recipientPage }, use) => { + await use(new FilesListPage(recipientPage)) + }, + + transferOwnershipPage: async ({ page }, use) => { + await use(new TransferOwnershipPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/BackgroundFilePickerDialogPage.ts b/tests/playwright/support/sections/BackgroundFilePickerDialogPage.ts index bbeec863414ef..ca494ef06669c 100644 --- a/tests/playwright/support/sections/BackgroundFilePickerDialogPage.ts +++ b/tests/playwright/support/sections/BackgroundFilePickerDialogPage.ts @@ -3,39 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator, Page } from '@playwright/test' +import { FilePickerDialogPage } from './FilePickerDialogPage.ts' /** * The file-picker dialog opened by the "Custom background" card/button on * Personal settings > Appearance and accessibility > Background and color */ -export class BackgroundFilePickerDialogPage { - constructor(private readonly page: Page) {} - - /** The open file-picker dialog. */ - dialog(): Locator { - return this.page.getByRole('dialog') - } - - /** - * Returns a row (file or folder) from inside the picker. - */ - getRow(name: string): Locator { - return this.dialog().getByTestId('row-name').filter({ hasText: name }) - } - - /** Navigate into a folder. */ - async openFolder(name: string): Promise { - await this.getRow(name).click() - } - - /** Select a file row. */ - async selectFile(name: string): Promise { - await this.getRow(name).click() - } - +export class BackgroundFilePickerDialogPage extends FilePickerDialogPage { /** Confirm the current selection as the new background. */ - async confirm(): Promise { - await this.dialog().getByRole('button', { name: 'Select background', exact: true }).click() + override async confirm(): Promise { + await super.confirm('Select background') } } diff --git a/tests/playwright/support/sections/FilePickerDialogPage.ts b/tests/playwright/support/sections/FilePickerDialogPage.ts new file mode 100644 index 0000000000000..67d3b400765d9 --- /dev/null +++ b/tests/playwright/support/sections/FilePickerDialogPage.ts @@ -0,0 +1,74 @@ +/* + * 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' +import { DAV_FILES_ENDPOINT } from '../utils/dav.ts' + +/** + * The file picker dialog of `@nextcloud/dialogs`, used by every feature that + * lets the user choose a file or folder (custom background, ownership + * transfer, …). + * + * The confirm button is provided by the feature opening the picker, so its + * label is passed to {@link confirm} instead of being hardcoded here. + */ +export class FilePickerDialogPage { + constructor(protected readonly page: Page) {} + + /** The open file picker dialog. */ + dialog(): Locator { + return this.page.getByRole('dialog') + } + + /** + * A file or folder entry of the directory currently listed. + * + * Rows are matched by their text rather than by accessible name: the picker + * renders the base name and the extension of a file as two elements, which + * the accessible name computation joins with a space ("file .txt"). + * + * @param name - The name of the file or folder + */ + getRow(name: string): Locator { + return this.dialog().getByRole('row').filter({ hasText: name }) + } + + /** + * Navigate into a folder and wait for its content to be listed. + * + * Clicking a folder always navigates into it — a folder cannot be selected, + * it is picked by navigating into it and confirming with no selection. + * + * @param name - The name of the folder to enter + */ + async openFolder(name: string): Promise { + const listed = this.page.waitForResponse((r) => r.request().method() === 'PROPFIND' && DAV_FILES_ENDPOINT.test(r.url())) + await this.getRow(name).click() + await listed + } + + /** + * Select a file row (only files can be selected, see {@link openFolder}). + * + * @param name - The name of the file to select + */ + async selectFile(name: string): Promise { + const row = this.getRow(name) + await row.click() + await expect(row).toHaveAttribute('aria-selected', 'true') + } + + /** + * Confirm the picker with the button carrying the given label. + * + * @param label - The label of the confirmation button + */ + async confirm(label: string | RegExp): Promise { + await this.dialog().getByRole('button', { name: label }).click() + await expect(this.dialog()).toBeHidden() + } +} diff --git a/tests/playwright/support/sections/TransferOwnershipPage.ts b/tests/playwright/support/sections/TransferOwnershipPage.ts new file mode 100644 index 0000000000000..7f7cf2ce85c19 --- /dev/null +++ b/tests/playwright/support/sections/TransferOwnershipPage.ts @@ -0,0 +1,138 @@ +/* + * 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 { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' +import { FilePickerDialogPage } from './FilePickerDialogPage.ts' + +/** + * The "Transfer ownership of a file or folder" form of the files app, rendered + * in the personal sharing settings. + */ +export class TransferOwnershipPage { + private readonly filePicker: FilePickerDialogPage + + constructor(private readonly page: Page) { + this.filePicker = new FilePickerDialogPage(page) + } + + /** Open the personal settings section hosting the transfer form. */ + async open(): Promise { + await this.page.goto('settings/user/sharing') + await expect(this.getSection()).toBeVisible() + } + + /** The form, rendered as a labelled fieldset. */ + getSection(): Locator { + return this.page.getByRole('group', { name: 'Transfer ownership of a file or folder' }) + } + + /** The button opening the file picker. */ + getNodeButton(): Locator { + return this.getSection().getByRole('button', { name: 'File or folder to transfer' }) + } + + /** The user search picking the account to transfer the ownership to. */ + getNewOwnerCombobox(): Locator { + return this.getSection().getByRole('combobox', { name: 'New owner' }) + } + + /** + * The submit button. Its label names both the file and the new owner as soon + * as the form is complete, which is the only accessible confirmation of what + * is about to be transferred — the description of {@link getNodeButton} is + * not part of the button. + */ + getSubmitButton(): Locator { + return this.getSection().getByRole('button', { name: /^Transfer\b/ }) + } + + /** + * The hints of the live region naming what is still missing to submit. They + * are rendered only while the respective input is empty, so assert on their + * count: being visually hidden they always count as visible. + */ + getMissingNodeHint(): Locator { + return this.getSection().getByText('You need to select a file or folder to transfer ownership.') + } + + /** See {@link getMissingNodeHint}. */ + getMissingOwnerHint(): Locator { + return this.getSection().getByText('You need to select a new owner for the file or folder.') + } + + /** + * Pick a file of the users root folder for transfer. + * + * @param name - The name of the file to transfer + */ + async selectFile(name: string): Promise { + await this.openFilePicker() + await this.filePicker.selectFile(name) + await this.filePicker.confirm(`Transfer "${name}"`) + } + + /** + * Pick a folder for transfer. + * + * The picker has no way to select a folder, so the folder is navigated into + * and then confirmed as the current directory. + * + * @param path - The path of the folder, relative to the users root folder + */ + async selectFolder(path: string): Promise { + const segments = path.split('/').filter(Boolean) + await this.openFilePicker() + for (const segment of segments) { + await this.filePicker.openFolder(segment) + } + await this.filePicker.confirm(`Transfer "${segments.at(-1)}"`) + } + + /** + * Pick the users root folder — transferring all of their files at once. + * It is confirmed as the picker's initial directory, without a selection. + * + * @param user - The owner of the files, whose id names their root folder + */ + async selectAllFiles(user: User): Promise { + await this.openFilePicker() + await this.filePicker.confirm(`Transfer "${user.userId}"`) + } + + /** + * Search for a user and pick them as the new owner. The suggestions are + * fetched from the sharees API, debounced, so the request is awaited before + * the matching option is picked. + * + * @param user - The user to receive the ownership + */ + async selectNewOwner(user: User): Promise { + const suggestions = this.page.waitForResponse((r) => r.url().includes('/apps/files_sharing/api/v1/sharees') + && new URL(r.url()).searchParams.get('search') === user.userId) + + await this.getNewOwnerCombobox().fill(user.userId) + await suggestions + + await this.page.getByRole('option', { name: user.userId }).click() + } + + /** Submit the form and wait for the transfer request to be created. */ + async submit(): Promise { + const requested = this.page.waitForResponse((r) => r.request().method() === 'POST' + && r.url().includes('/apps/files/api/v1/transferownership')) + + await this.getSubmitButton().click() + + expect((await requested).status()).toBe(200) + } + + private async openFilePicker(): Promise { + await this.getNodeButton().click() + await expect(this.filePicker.dialog()).toBeVisible() + } +} diff --git a/tests/playwright/support/utils/transferOwnership.ts b/tests/playwright/support/utils/transferOwnership.ts new file mode 100644 index 0000000000000..6f114f48ec80d --- /dev/null +++ b/tests/playwright/support/utils/transferOwnership.ts @@ -0,0 +1,128 @@ +/* + * 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 { runExec } from '@nextcloud/e2e-test-server/docker' + +const TRANSFER_JOB_CLASS = 'OCA\\Files\\BackgroundJob\\TransferOwnership' + +/** + * Carry out the ownership transfer a user requested: accept it as the new owner + * and run the background job that moves the files. + * + * Returns once the files have been moved, so callers can assert on the result + * without polling. + * + * @param request - A request context authenticated as `target` + * @param source - The user who requested the transfer + * @param target - The user receiving the ownership + */ +export async function completeOwnershipTransfer( + request: APIRequestContext, + source: User, + target: User, +): Promise { + const transferId = await getPendingTransferId(source, target) + await acceptOwnershipTransfer(request, transferId) + await runTransferJob(transferId) +} + +/** + * The name of the folder an ownership transfer creates in the new owners root, + * as a pattern — the folder is suffixed with the time of the transfer. + * + * @param source - The user who transferred the files + */ +export function transferFolderPattern(source: User): RegExp { + return new RegExp(`^Transferred from ${source.userId} on `) +} + +/** + * Read the id of the ownership transfer pending between two users. + * + * A transfer is normally accepted from the notification it creates, but the + * notifications app is not installed on the test instance, so there is no API + * to look the id up with and it is read from the database instead. + * + * @param source - The user who requested the transfer + * @param target - The user receiving the ownership + */ +async function getPendingTransferId(source: User, target: User): Promise { + const script = ` + require_once "/var/www/html/lib/base.php"; + $query = \\OCP\\Server::get(\\OCP\\IDBConnection::class)->getQueryBuilder(); + $query->select("id") + ->from("user_transfer_owner") + ->where($query->expr()->eq("source_user", $query->createNamedParameter($argv[1]))) + ->andWhere($query->expr()->eq("target_user", $query->createNamedParameter($argv[2]))); + echo json_encode(array_column($query->executeQuery()->fetchAll(), "id")); + ` + const stdout = await run(['php', '-r', script, '--', source.userId, target.userId], { retry: true }) + + const ids = JSON.parse(stdout.trim()) as number[] + if (ids.length !== 1) { + throw new Error(`Expected one pending transfer from ${source.userId} to ${target.userId}, found ${ids.length}`) + } + return Number(ids[0]) +} + +/** + * Accept an ownership transfer, which queues the job doing the actual transfer. + * + * @param request - A request context authenticated as the receiving user + * @param transferId - The id of the transfer to accept + */ +async function acceptOwnershipTransfer(request: APIRequestContext, transferId: number): Promise { + const response = await request.post(`/ocs/v2.php/apps/files/api/v1/transferownership/${transferId}`, { + headers: { + Accept: 'application/json', + 'OCS-APIRequest': 'true', + }, + }) + const meta = (await response.json()).ocs?.meta + if (meta?.statuscode !== 200) { + throw new Error(`Accepting transfer ${transferId} failed: ${meta?.statuscode} ${meta?.message}`) + } +} + +/** + * Run the queued background job of a transfer. + * + * The job is addressed by its id instead of running a worker for the job class, + * so that parallel tests never execute each others transfers. + * + * @param transferId - The id of the accepted transfer + */ +async function runTransferJob(transferId: number): Promise { + const stdout = await run(['php', 'occ', 'background-job:list', '--class', TRANSFER_JOB_CLASS, '--output', 'json'], { retry: true }) + const jobs = JSON.parse(stdout) as { id: number, argument: string }[] + const job = jobs.find(({ argument }) => JSON.parse(argument).id === transferId) + if (!job) { + throw new Error(`No background job queued for transfer ${transferId}`) + } + + await run(['php', 'occ', 'background-job:execute', String(job.id), '--force-execute']) +} + +/** + * Run a command in the Nextcloud container, reporting its output on failure. + * + * @param command - The command to run + * @param options - Whether to run the command a second time if it failed. Only + * for commands that read, they can fail spuriously when the SQLite database + * of the test instance is busy with the transfers of parallel tests. + */ +async function run(command: string[], { retry = false } = {}): Promise { + const { stdout, stderr, exitCode } = await runExec(command, { failOnError: false }) + if (exitCode === 0) { + return stdout + } + if (retry) { + return await run(command) + } + throw new Error(`"${command.join(' ')}" exited with ${exitCode}: ${stderr || stdout}`) +}