diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index fa688ade..da4baf1c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -92,7 +92,7 @@ jobs: merge-multiple: true - name: Merge into HTML Report - run: npx playwright merge-reports --reporter html ./all-blob-reports + run: npx playwright merge-reports --config tests/playwright/merge.config.ts ./all-blob-reports - name: Upload HTML report uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 diff --git a/tests/playwright/e2e/create-new-folder.spec.ts b/tests/playwright/e2e/create-new-folder.spec.ts new file mode 100644 index 00000000..7680b6cf --- /dev/null +++ b/tests/playwright/e2e/create-new-folder.spec.ts @@ -0,0 +1,88 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect, mergeTests } from '@playwright/test' +import { test as e2eeTest } from '../support/fixtures/e2ee-user.ts' +import { test as filesTest } from '../support/fixtures/files-app.ts' +import { test as settingsTest } from '../support/fixtures/personal-settings.ts' + +const test = mergeTests(e2eeTest, filesTest, settingsTest) + +test.describe('creating subfolders', () => { + test.beforeAll(async () => { + await runOcc(['config:system:set', 'skeletondirectory', '--value='], { verbose: true }) + await runOcc(['config:system:set', 'templatedirectory', '--value='], { verbose: true }) + }) + + test('create a subfolder', async ({ filesApp, page, mnemonic }) => { + const folderName = globalThis.crypto.randomUUID() + + await filesApp.openFilesApp() + await filesApp.openNewMenu() + .then((menu) => menu.createNewE2eeFolder()) + .then((dialog) => dialog.fillMnemonic(mnemonic)) + .then((dialog) => dialog.createFolder(folderName)) + await filesApp.openFileOrFolder(folderName) + + // create subfolder + const metadataUpdate = page.waitForResponse((res) => res.request().method() === 'PUT' + && res.url().includes('/ocs/v2.php/apps/end_to_end_encryption/api/v2/meta-data/')) + await filesApp.openNewMenu() + .then((menu) => menu.createNewFolder()) + .then((dialog) => dialog.createFolder('subfolder')) + await metadataUpdate + + // see subfolder is created + await expect(filesApp.getFileOrFolder('subfolder')).toBeVisible() + + await filesApp.openFilesApp() + await filesApp.openFileOrFolder(folderName) + await filesApp.getMnemonicDialog() + .fillAndSubmit(mnemonic) + + // still visible after reload + await expect(filesApp.getFileOrFolder('subfolder')).toBeVisible() + }) + + test('create a sub-subfolder', async ({ filesApp, page, mnemonic }) => { + const folderName = globalThis.crypto.randomUUID() + + await filesApp.openFilesApp() + await filesApp.openNewMenu() + .then((menu) => menu.createNewE2eeFolder()) + .then((dialog) => dialog.fillMnemonic(mnemonic)) + .then((dialog) => dialog.createFolder(folderName)) + await filesApp.openFileOrFolder(folderName) + + // create subfolder + await filesApp.openNewMenu() + .then((menu) => menu.createNewFolder()) + .then((dialog) => dialog.createFolder('subfolder')) + await page.waitForResponse((res) => res.request().method() === 'PUT' + && res.url().includes('/ocs/v2.php/apps/end_to_end_encryption/api/v2/meta-data/')) + + // create sub-subfolder + await filesApp.openFileOrFolder('subfolder') + await filesApp.openNewMenu() + .then((menu) => menu.createNewFolder()) + .then((dialog) => dialog.createFolder('sub-subfolder')) + await page.waitForResponse((res) => res.request().method() === 'PUT' + && res.url().includes('/ocs/v2.php/apps/end_to_end_encryption/api/v2/meta-data/')) + + // see sub-subfolder is created + await expect(filesApp.getFileOrFolder('sub-subfolder')).toBeVisible() + + await filesApp.openFilesApp() + await filesApp.openFileOrFolder(folderName) + await filesApp.getMnemonicDialog() + .fillAndSubmit(mnemonic) + + // still visible after reload + await expect(filesApp.getFileOrFolder('subfolder')).toBeVisible() + await filesApp.openFileOrFolder('subfolder') + await expect(filesApp.getFileOrFolder('sub-subfolder')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/setup-e2ee.spec.ts b/tests/playwright/e2e/e2ee.setup.spec.ts similarity index 100% rename from tests/playwright/e2e/setup-e2ee.spec.ts rename to tests/playwright/e2e/e2ee.setup.spec.ts diff --git a/tests/playwright/e2e/personal-settings.spec.ts b/tests/playwright/e2e/personal-settings.setup.spec.ts similarity index 100% rename from tests/playwright/e2e/personal-settings.spec.ts rename to tests/playwright/e2e/personal-settings.setup.spec.ts diff --git a/tests/playwright/merge.config.ts b/tests/playwright/merge.config.ts new file mode 100644 index 00000000..6d99e688 --- /dev/null +++ b/tests/playwright/merge.config.ts @@ -0,0 +1,11 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// Needed to merge multiple Playwright reports +// when they are ran on self-hosted and github runners (different test directories are used) +export default { + testDir: 'tests/playwright/e2e', + reporter: [['html', { open: 'never' }]], +} diff --git a/tests/playwright/support/fixtures/e2ee-user.ts b/tests/playwright/support/fixtures/e2ee-user.ts new file mode 100644 index 00000000..08f41b81 --- /dev/null +++ b/tests/playwright/support/fixtures/e2ee-user.ts @@ -0,0 +1,73 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { test as baseTest, expect } from '@playwright/test' +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { FilesAppPage } from '../sections/FilesAppPage.ts' +import { PersonalSettingsPage } from '../sections/PersonalSettingsPage.ts' + +export const test = baseTest.extend<{ mnemonic: string }, { workerStorageState: { path: string, mnemonic: string } }>({ + // Use the same storage state for all tests in this worker. + storageState: ({ workerStorageState }, use) => use(workerStorageState.path), + + mnemonic: ({ workerStorageState }, use) => use(workerStorageState.mnemonic), + + // Authenticate once per worker with a worker-scoped fixture. + workerStorageState: [async ({ browser }, use) => { + // Use parallelIndex as a unique identifier for each worker. + const id = test.info().parallelIndex + const path = resolve(test.info().project.outputDir, `.auth/${id}.json`) + + if (existsSync(path)) { + // Reuse existing authentication state if any. + await use({ path, mnemonic: await readFile(path.replace('.json', '.mnemonic'), 'utf-8') }) + return + } + + // Important: make sure we authenticate in a clean environment by unsetting storage state. + const page = await browser.newPage({ storageState: undefined, baseURL: baseTest.info().project.use.baseURL }) + + // Acquire a unique account, for example create a new one. + // Alternatively, you can have a list of precreated accounts for testing. + // Make sure that accounts are unique, so that multiple team members + // can run tests at the same time without interference. + const account = await createRandomUser() + await login(page.request, account) + + // setup e2ee + const personalSettings = new PersonalSettingsPage(page) + await personalSettings.openSettingsPage() + await personalSettings.enableBrowserE2ee() + const filesApp = new FilesAppPage(page) + await filesApp.openFilesApp() + // setup e2ee + const newMenu = await filesApp.openNewMenu() + await expect(newMenu.getNewEncryptedFolderEntry()).toBeVisible() + const dialog = await newMenu.createNewE2eeFolder() + await dialog.buttonSetupEncryption.click() + // see the recovery phrase + await expect(dialog.codeRecoveryPhrase).toHaveText(/(\w+ ){11}\w+/) + const mnemonic = (await dialog.codeRecoveryPhrase.textContent())! + await expect(dialog.buttonContinue).not.toBeDisabled({ timeout: 10000 }) + await dialog.buttonContinue.click() + await expect(dialog.inputFolderName).toBeVisible() + await dialog.inputFolderName.fill('test-folder') + await dialog.createFolder('test-folder') + await expect(dialog.dialogLocator).toHaveCount(0) + + // wait for the folder to appear in the file list + await expect(filesApp.tableFilesList).toBeVisible() + await expect(filesApp.getFileOrFolder('test-folder')).toBeVisible() + + await page.context().storageState({ path }) + await page.close() + await writeFile(path.replace('.json', '.mnemonic'), mnemonic) + + await use({ path, mnemonic }) + }, { scope: 'worker' }], +}) diff --git a/tests/playwright/support/sections/FilesAppPage.ts b/tests/playwright/support/sections/FilesAppPage.ts index f32e5cbf..92dd29a3 100644 --- a/tests/playwright/support/sections/FilesAppPage.ts +++ b/tests/playwright/support/sections/FilesAppPage.ts @@ -5,10 +5,13 @@ import type { Locator, Page } from '@playwright/test' +import { expect } from '@playwright/test' +import { SectionMnemonicDialog } from './SectionMnemonicDialog.ts' import { SectionNewMenu } from './SectionNewMenu.ts' export class FilesAppPage { public readonly buttonNewMenuLocator: Locator + public readonly dialogMnemonicLocator: Locator public readonly tableFilesList: Locator constructor(public readonly page: Page) { @@ -16,6 +19,7 @@ export class FilesAppPage { this.buttonNewMenuLocator = this.page.getByRole('button', { name: 'New', }) + this.dialogMnemonicLocator = this.page.getByRole('dialog', { name: 'Enter your 12 words mnemonic' }) } public async openFilesApp(): Promise { @@ -23,8 +27,10 @@ export class FilesAppPage { } public async openNewMenu(): Promise { - await this.buttonNewMenuLocator.click() - return new SectionNewMenu(this.page) + await this.buttonNewMenuLocator.first().click() + const menu = new SectionNewMenu(this.page) + await expect(menu.menuLocator).toBeVisible() + return menu } public getFileOrFolder(name: string): Locator { @@ -32,4 +38,14 @@ export class FilesAppPage { .getByRole('row') .filter({ has: this.page.getByRole('cell', { name }) }) } + + public openFileOrFolder(name: string): Promise { + return this.getFileOrFolder(name) + .getByRole('button', { name: `Open folder ${name}` }) + .click() + } + + public getMnemonicDialog(): SectionMnemonicDialog { + return new SectionMnemonicDialog(this.dialogMnemonicLocator) + } } diff --git a/tests/playwright/support/sections/SectionCreateE2eeFolderDialog.ts b/tests/playwright/support/sections/SectionCreateE2eeFolderDialog.ts index b5e9aa64..f4746f4a 100644 --- a/tests/playwright/support/sections/SectionCreateE2eeFolderDialog.ts +++ b/tests/playwright/support/sections/SectionCreateE2eeFolderDialog.ts @@ -5,20 +5,49 @@ import type { Locator, Page } from '@playwright/test' +import { expect } from '@playwright/test' + export class SectionCreateE2eeFolderDialog { public readonly dialogLocator: Locator public readonly buttonContinue: Locator public readonly buttonCreateFolder: Locator public readonly buttonSetupEncryption: Locator + public readonly buttonSubmitMnemonic: Locator + public readonly checkboxConsent: Locator public readonly codeRecoveryPhrase: Locator public readonly inputFolderName: Locator + public readonly inputMnemonic: Locator constructor(public readonly page: Page) { this.dialogLocator = page.getByRole('dialog', { name: 'Create new encrypted folder' }) this.buttonSetupEncryption = this.dialogLocator.getByRole('button', { name: /Setup encryption/i }) this.buttonContinue = this.dialogLocator.getByRole('button', { name: /Continue/i }) this.buttonCreateFolder = this.dialogLocator.getByRole('button', { name: /Create folder/i }) + this.buttonSubmitMnemonic = this.dialogLocator.getByRole('button', { name: /Submit/i }) + this.checkboxConsent = this.dialogLocator.getByRole('checkbox', { name: /I understand the risks/i }) this.codeRecoveryPhrase = this.dialogLocator.getByRole('code') this.inputFolderName = this.dialogLocator.getByRole('textbox', { name: /Folder name/i }) + this.inputMnemonic = this.dialogLocator.getByRole('textbox', { name: /Mnemonic/i }) + } + + public async fillMnemonic(mnemonic: string): Promise { + await expect(this.inputMnemonic).toBeVisible() + await this.inputMnemonic.fill(mnemonic) + await this.checkboxConsent.click({ force: true }) + await expect(this.buttonSubmitMnemonic).not.toBeDisabled() + await this.buttonSubmitMnemonic.click() + return this + } + + public async createFolder(folderName: string): Promise { + const response = this.page.waitForResponse((res) => res.request().method() === 'POST' + && res.url().includes('/ocs/v2.php/apps/end_to_end_encryption/api/v2/meta-data/')) + + await expect(this.inputFolderName).toBeVisible() + await this.inputFolderName.fill(folderName) + await this.buttonCreateFolder.click() + await response + + return this } } diff --git a/tests/playwright/support/sections/SectionCreateFolderDialog.ts b/tests/playwright/support/sections/SectionCreateFolderDialog.ts new file mode 100644 index 00000000..61559eec --- /dev/null +++ b/tests/playwright/support/sections/SectionCreateFolderDialog.ts @@ -0,0 +1,30 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +import type { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' + +export class SectionCreateFolderDialog { + public readonly dialogLocator: Locator + public readonly buttonCreateFolder: Locator + public readonly inputFolderName: Locator + + constructor(public readonly page: Page) { + this.dialogLocator = page.getByRole('dialog', { name: 'Create new folder' }) + this.buttonCreateFolder = this.dialogLocator.getByRole('button', { name: /Create/i }) + this.inputFolderName = this.dialogLocator.getByRole('textbox', { name: /Folder name/i }) + } + + public async createFolder(folderName: string): Promise { + const response = this.page.waitForResponse((res) => res.request().method() === 'MKCOL') + await this.inputFolderName.fill(folderName) + await expect(this.buttonCreateFolder).not.toBeDisabled() + await this.buttonCreateFolder.click() + await response + + return this + } +} diff --git a/tests/playwright/support/sections/SectionMnemonicDialog.ts b/tests/playwright/support/sections/SectionMnemonicDialog.ts new file mode 100644 index 00000000..301f7be5 --- /dev/null +++ b/tests/playwright/support/sections/SectionMnemonicDialog.ts @@ -0,0 +1,29 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +import type { Locator } from '@playwright/test' + +import { expect } from '@playwright/test' + +export class SectionMnemonicDialog { + public readonly buttonSubmit: Locator + public readonly checkboxConsent: Locator + public readonly inputMnemonic: Locator + + constructor(public readonly dialogLocator: Locator) { + this.buttonSubmit = this.dialogLocator.getByRole('button', { name: /Submit/i }) + this.checkboxConsent = this.dialogLocator.getByRole('checkbox', { name: /I understand the risks/i }) + this.inputMnemonic = this.dialogLocator.getByRole('textbox', { name: /Mnemonic/i }) + } + + public async fillAndSubmit(mnemonic: string): Promise { + await expect(this.dialogLocator).toBeVisible() + await this.inputMnemonic.fill(mnemonic) + await this.checkboxConsent.check({ force: true }) + await expect(this.buttonSubmit).not.toBeDisabled() + await this.buttonSubmit.click() + await expect(this.dialogLocator).toHaveCount(0) + } +} diff --git a/tests/playwright/support/sections/SectionNewMenu.ts b/tests/playwright/support/sections/SectionNewMenu.ts index 3f72240c..24727562 100644 --- a/tests/playwright/support/sections/SectionNewMenu.ts +++ b/tests/playwright/support/sections/SectionNewMenu.ts @@ -7,6 +7,7 @@ import type { Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' import { SectionCreateE2eeFolderDialog } from './SectionCreateE2eeFolderDialog.ts' +import { SectionCreateFolderDialog } from './SectionCreateFolderDialog.ts' export class SectionNewMenu { public readonly menuLocator: Locator @@ -23,10 +24,21 @@ export class SectionNewMenu { return this.getMenuEntry(/New encrypted folder/i) } + public getNewFolderEntry(): Locator { + return this.getMenuEntry(/New folder/i) + } + public async createNewE2eeFolder(): Promise { this.getNewEncryptedFolderEntry().click() const section = new SectionCreateE2eeFolderDialog(this.page) await expect(section.dialogLocator).toBeVisible() return section } + + public async createNewFolder(): Promise { + this.getNewFolderEntry().click() + const section = new SectionCreateFolderDialog(this.page) + await expect(section.dialogLocator).toBeVisible() + return section + } }