From c45a5d480979be4247c492edf457b51daf489eae Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 17 Mar 2026 02:31:09 +0100 Subject: [PATCH 1/4] test: create basic Playwright test infrastructure Signed-off-by: Ferdinand Thiessen --- .github/workflows/cypress.yml | 4 +- .github/workflows/playwright.yml | 130 ++++++++++++++++++ .gitignore | 2 + build/files-checker.php | 1 + package-lock.json | 64 +++++++++ package.json | 3 + playwright.config.ts | 55 ++++++++ tests/playwright/merge.config.ts | 11 ++ tests/playwright/start-nextcloud-server.js | 75 ++++++++++ .../support/fixtures/admin-session.ts | 23 ++++ .../support/fixtures/random-user-session.ts | 19 +++ 11 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.ts create mode 100644 tests/playwright/merge.config.ts create mode 100644 tests/playwright/start-nextcloud-server.js create mode 100644 tests/playwright/support/fixtures/admin-session.ts create mode 100644 tests/playwright/support/fixtures/random-user-session.ts diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index f3e85bdf15f1a..c730c897eb10b 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -105,10 +105,10 @@ jobs: matrix: # Run multiple copies of the current job in parallel # Please increase the number or runners as your tests suite grows (0 based index for e2e tests) - containers: ['setup', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] + containers: ['setup', '0', '1', '2', '3', '4', '5', '6', '7', '8'] # Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions # Always align this number with the total of e2e runners (max. index + 1) - total-containers: [10] + total-containers: [9] services: mysql: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000000..9804e242330d1 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,130 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Playwright Tests + +on: + pull_request: + branches: [ master ] + +permissions: + contents: read + +jobs: + playwright-tests: + timeout-minutes: 60 + name: Playwright tests ${{ matrix.shardIndex }} / ${{ matrix.shardTotal }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3] + shardTotal: [3] + outputs: + node-version: ${{ steps.versions.outputs.node-version }} + package-manager-version: ${{ steps.versions.outputs.package-manager-version }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + submodules: true # for 3rdparty + + - name: Read package.json + uses: nextcloud-libraries/parse-package-engines-action@122ae05d4257008180a514e1ddeb0c1b9d094bdd # v0.1.0 + id: versions + + - name: Set up node + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: ${{ steps.versions.outputs.node-version }} + + - name: Set up npm + run: npm i -g 'npm@${{ steps.versions.outputs.package-manager-version }}' + + - name: Install dependencies and build + run: | + npm ci + npm run build --if-present + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Run Playwright tests + run: npm run playwright -- --shard='${{ matrix.shardIndex }}/${{ matrix.shardTotal }}' + + - name: Show logs + if: failure() + run: | + for id in $(docker ps -aq); do + docker container inspect "$id" --format '=== Logs for container {{.Name}} ===' + docker logs "$id" >> nextcloud.log + done + echo '=== Nextcloud server logs ===' + docker exec nextcloud-e2e-test-server_server cat data/nextcloud.log + + - name: Upload blob report to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: blob-report-${{ matrix.shardIndex }} + path: blob-report + retention-days: 1 + + merge-reports: + # Merge reports after playwright-tests, even if some shards have failed + if: ${{ !cancelled() }} + needs: [playwright-tests] + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: ${{ needs.playwright-tests.outputs.node-version }} + + - name: Set up npm + run: npm i -g 'npm@${{ needs.playwright-tests.outputs.package-manager-version }}' + + - name: Install dependencies + run: npm ci + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge into HTML Report + run: npx playwright merge-reports --config tests/playwright/merge.config.ts --reporter html,github ./all-blob-reports + + - name: Upload HTML report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: html-report--attempt-${{ github.run_attempt }} + path: playwright-report + retention-days: 7 + + - name: Show the logs + run: | + echo 'To view the report:' + echo ' 1. Extract the folder from the zip file' + echo ' 2. run "npx playwright show-report name-of-my-extracted-playwright-report"' + + summary: + permissions: + contents: none + runs-on: ubuntu-latest-low + needs: [playwright-tests] + + if: always() + + name: playwright-test-summary + + steps: + - name: Summary status + run: if ${{ needs.playwright-tests.result != 'success' }}; then exit 1; fi diff --git a/.gitignore b/.gitignore index 87035b7f67546..a53c847f4a1da 100644 --- a/.gitignore +++ b/.gitignore @@ -145,7 +145,9 @@ Vagrantfile # Tests - auto-generated files /data-autotest +/playwright-report /results.sarif +/test-results /tests/.phpunit.cache /tests/.phpunit.result.cache /tests/coverage* diff --git a/build/files-checker.php b/build/files-checker.php index 8b44761bcfe98..05cdc28f3a6b8 100644 --- a/build/files-checker.php +++ b/build/files-checker.php @@ -74,6 +74,7 @@ 'openapi.json', 'package-lock.json', 'package.json', + 'playwright.config.ts', 'psalm-ncu.xml', 'psalm-ocp.xml', 'psalm-strict.xml', diff --git a/package-lock.json b/package-lock.json index 57e1817ea1536..763ca560d0613 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/typings": "^1.10.0", "@nextcloud/vite-config": "^2.5.2", + "@playwright/test": "^1.59.1", "@testing-library/cypress": "^10.1.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/vue": "^8.1.0", @@ -3032,6 +3033,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -13409,6 +13426,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 5650126554b8e..87d75fd531b74 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "lint": "eslint --suppressions-location build/eslint-baseline.json --no-error-on-unmatched-pattern ./cypress", "postlint": "build/demi.sh lint", "lint:fix": "build/demi.sh lint:fix", + "playwright": "playwright test", + "playwright:install": "playwright install chromium-headless-shell", "sass": "sass --style compressed --load-path core/css core/css/ $(for cssdir in $(find apps -mindepth 2 -maxdepth 2 -name \"css\"); do if ! $(git check-ignore -q $cssdir); then printf \"$cssdir \"; fi; done)", "sass:icons": "node build/icons.mjs", "sass:watch": "sass --watch --load-path core/css core/css/ $(for cssdir in $(find apps -mindepth 2 -maxdepth 2 -name \"css\"); do if ! $(git check-ignore -q $cssdir); then printf \"$cssdir \"; fi; done)", @@ -76,6 +78,7 @@ "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/typings": "^1.10.0", "@nextcloud/vite-config": "^2.5.2", + "@playwright/test": "^1.59.1", "@testing-library/cypress": "^10.1.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/vue": "^8.1.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000000..bf88178fb95b4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/playwright/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['blob'], ['dot'], ['github']] : 'html', + use: { + baseURL: 'http://localhost:8042/index.php/', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'admin-settings', + fullyParallel: false, + workers: 1, // only one admin setting test can run at a time due to shared state + testMatch: '**/admin-settings*.spec.ts', + use: { + ...devices['Desktop Chrome'], + }, + }, + + { + name: 'chrome', + testMatch: /\/(?!admin-settings)[^/]*\.spec\.ts$/, + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + webServer: { + command: 'node tests/playwright/start-nextcloud-server.js', + env: { + NEXTCLOUD_PORT: '8042', + }, + stderr: 'pipe', + stdout: 'pipe', + gracefulShutdown: { + signal: 'SIGTERM', + timeout: 10000, + }, + reuseExistingServer: !process.env.CI, + timeout: 5 * 60 * 1000, + wait: { + stdout: /Nextcloud container ready to run Playwright tests/, + }, + }, +}) diff --git a/tests/playwright/merge.config.ts b/tests/playwright/merge.config.ts new file mode 100644 index 0000000000000..6d99e6887f15a --- /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/start-nextcloud-server.js b/tests/playwright/start-nextcloud-server.js new file mode 100644 index 0000000000000..aa7212f6b5c62 --- /dev/null +++ b/tests/playwright/start-nextcloud-server.js @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { configureNextcloud, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server/docker' +import { existsSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') + +function getMounts() { + const mounts = { + '3rdparty': resolve(rootDir, '3rdparty'), + apps: resolve(rootDir, 'apps'), + core: resolve(rootDir, 'core'), + dist: resolve(rootDir, 'dist'), + lib: resolve(rootDir, 'lib'), + ocs: resolve(rootDir, 'ocs'), + 'ocs-provider': resolve(rootDir, 'ocs-provider'), + resources: resolve(rootDir, 'resources'), + tests: resolve(rootDir, 'tests'), + 'console.php': resolve(rootDir, 'console.php'), + 'cron.php': resolve(rootDir, 'cron.php'), + 'index.php': resolve(rootDir, 'index.php'), + occ: resolve(rootDir, 'occ'), + 'public.php': resolve(rootDir, 'public.php'), + 'remote.php': resolve(rootDir, 'remote.php'), + 'status.php': resolve(rootDir, 'status.php'), + 'version.php': resolve(rootDir, 'version.php'), + } + + return Object.fromEntries(Object.entries(mounts).filter(([, path]) => existsSync(path))) +} + +async function start() { + const port = Number.parseInt(process.env.NEXTCLOUD_PORT ?? '8042', 10) + const ip = await startNextcloud(process.env.BRANCH, false, { + mounts: getMounts(), + exposePort: port, + forceRecreate: true, + }) + + await runExec(['mkdir', '-p', 'apps-cypress']) + await runExec(['cp', 'cypress/fixtures/app.config.php', 'config']) + + await waitOnNextcloud(ip) + await configureNextcloud() + + process.stdout.write('\nApply custom configuration for Playwright tests\n') + await runOcc(['config:system:set', 'appstoreenabled', '--value', 'false', '--type', 'boolean']) + process.stdout.write('├─ Disabled app store\n') + await runExec(['php', '-r', '$db = new SQLite3("data/owncloud.db");$db->busyTimeout(5000);$db->exec("PRAGMA journal_mode = wal;");']) + process.stdout.write('├─ Enabled SQLite WAL mode for better performance\n') + process.stdout.write('├─ Initialize cron job...\n') + await runExec(['php', 'cron.php']) + process.stdout.write('│ └─ OK !\n') + process.stdout.write('└─ Nextcloud container ready to run Playwright tests\n') +} + +async function stop() { + process.stderr.write('Stopping Nextcloud server…\n') + await stopNextcloud() + process.exit(0) +} + +process.on('SIGTERM', stop) +process.on('SIGINT', stop) + +await start() + +while (true) { + await new Promise((resolvePromise) => setTimeout(resolvePromise, 5000)) +} diff --git a/tests/playwright/support/fixtures/admin-session.ts b/tests/playwright/support/fixtures/admin-session.ts new file mode 100644 index 0000000000000..f552f13ce9372 --- /dev/null +++ b/tests/playwright/support/fixtures/admin-session.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/e2e-test-server' +import { login } from '@nextcloud/e2e-test-server/playwright' +import { test as baseTest } from '@playwright/test' + +const admin = new User('admin', 'admin') + +export const test = baseTest.extend({ + page: async ({ page, context }, use) => { + try { + await login(context.request, admin) + } catch (error) { + console.info('Failed to authenticate as admin, retrying', error) + await new Promise((resolve) => setTimeout(resolve, 800)) + await login(context.request, admin) + } + await use(page) + }, +}) diff --git a/tests/playwright/support/fixtures/random-user-session.ts b/tests/playwright/support/fixtures/random-user-session.ts new file mode 100644 index 0000000000000..8b3951a17aabe --- /dev/null +++ b/tests/playwright/support/fixtures/random-user-session.ts @@ -0,0 +1,19 @@ +/* + * 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, login } from '@nextcloud/e2e-test-server/playwright' +import { test as baseTest } from '@playwright/test' + +export const test = baseTest.extend({ + page: async ({ page, context }, use) => { + const user = await createRandomUser() + await login(context.request, user) + + await use(page) + + await runOcc(['user:delete', user.userId]) + }, +}) From ae8d311a333f780ca07c1ee291f89522c8e5912e Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 9 Jun 2026 09:37:54 +0200 Subject: [PATCH 2/4] test: migrate some tests to playwright Signed-off-by: Ferdinand Thiessen --- cypress/e2e/dav/availability.cy.ts | 128 ------------------ cypress/e2e/systemtags/admin-settings.cy.ts | 126 ----------------- tests/playwright/e2e/dav/availability.spec.ts | 102 ++++++++++++++ .../e2e/systemtags/admin-settings.spec.ts | 101 ++++++++++++++ .../e2e/theming/a11y-color-contrast.spec.ts | 117 ++++++++++++++++ .../theming/admin-settings-background.spec.ts | 123 +++++++++++++++++ .../theming/admin-settings-branding.spec.ts | 104 ++++++++++++++ .../e2e/theming/admin-settings-colors.spec.ts | 30 ++++ .../admin-settings-default-app.spec.ts | 63 +++++++++ .../theming/user-settings-app-order.spec.ts | 44 ++++++ .../theming/user-settings-background.spec.ts | 34 +++++ .../support/fixtures/admin-theming-page.ts | 14 ++ .../support/sections/AdminThemingPage.ts | 101 ++++++++++++++ .../support/sections/NavigationHeaderPage.ts | 26 ++++ .../support/sections/UserThemingPage.ts | 31 +++++ tests/playwright/support/utils/theming.ts | 90 ++++++++++++ 16 files changed, 980 insertions(+), 254 deletions(-) delete mode 100644 cypress/e2e/dav/availability.cy.ts delete mode 100644 cypress/e2e/systemtags/admin-settings.cy.ts create mode 100644 tests/playwright/e2e/dav/availability.spec.ts create mode 100644 tests/playwright/e2e/systemtags/admin-settings.spec.ts create mode 100644 tests/playwright/e2e/theming/a11y-color-contrast.spec.ts create mode 100644 tests/playwright/e2e/theming/admin-settings-background.spec.ts create mode 100644 tests/playwright/e2e/theming/admin-settings-branding.spec.ts create mode 100644 tests/playwright/e2e/theming/admin-settings-colors.spec.ts create mode 100644 tests/playwright/e2e/theming/admin-settings-default-app.spec.ts create mode 100644 tests/playwright/e2e/theming/user-settings-app-order.spec.ts create mode 100644 tests/playwright/e2e/theming/user-settings-background.spec.ts create mode 100644 tests/playwright/support/fixtures/admin-theming-page.ts create mode 100644 tests/playwright/support/sections/AdminThemingPage.ts create mode 100644 tests/playwright/support/sections/NavigationHeaderPage.ts create mode 100644 tests/playwright/support/sections/UserThemingPage.ts create mode 100644 tests/playwright/support/utils/theming.ts diff --git a/cypress/e2e/dav/availability.cy.ts b/cypress/e2e/dav/availability.cy.ts deleted file mode 100644 index e1cc91d37d5c3..0000000000000 --- a/cypress/e2e/dav/availability.cy.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { clearState } from '../../support/commonUtils.ts' - -describe('Calendar: Availability', { testIsolation: true }, () => { - before(() => { - clearState() - }) - - it('User can see the availability section in settings', () => { - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/settings/user') - }) - - // can see the section - cy.findAllByRole('link', { name: /Availability/ }) - .should('be.visible') - .click() - - cy.url().should('match', /settings\/user\/availability$/) - cy.findByRole('heading', { name: /Availability/, level: 2 }) - .should('be.visible') - }) - - it('Users can set their availability status', () => { - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/settings/user/availability') - }) - - // can see the settings - cy.findByRole('list', { name: 'Weekdays' }) - .should('be.visible') - .within(() => { - cy.contains('li', 'Friday') - .should('be.visible') - .should('contain.text', 'No working hours set') - .as('fridayItem') - .findByRole('button', { name: 'Add slot' }) - .click() - }) - - cy.get('@fridayItem') - .findByLabelText(/start time/i) - .type('09:00') - - cy.get('@fridayItem') - .findByLabelText(/end time/i) - .type('18:00') - - cy.intercept('PROPPATCH', '**/remote.php/dav/calendars/*/inbox').as('saveAvailability') - cy.get('#availability') - .findByRole('button', { name: 'Save' }) - .click() - cy.wait('@saveAvailability') - - cy.reload() - - cy.findByRole('list', { name: 'Weekdays' }) - .should('be.visible') - .within(() => { - cy.contains('li', 'Friday') - .should('be.visible') - .should('not.contain.text', 'No working hours set') - }) - }) - - it('Users can set their absence', () => { - cy.createUser({ language: 'en', password: 'password', userId: 'replacement-user' }) - cy.createRandomUser().then(($user) => { - cy.login($user) - cy.visit('/settings/user/availability') - }) - - cy.findByRole('heading', { name: /absence/i }).scrollIntoView() - - cy.findByLabelText(/First day/) - .should('be.visible') - .type('2024-12-24') - - cy.findByLabelText(/Last day/) - .should('be.visible') - .type('2024-12-28') - - cy.findByRole('textbox', { name: /Short absence/ }) - .should('be.visible') - .type('Vacation') - cy.findByRole('textbox', { name: /Long absence/ }) - .should('be.visible') - .type('Happy holidays!') - - cy.intercept('GET', '**/ocs/v2.php/apps/files_sharing/api/v1/sharees?*search=replacement*').as('userSearch') - cy.findByRole('searchbox') - .should('be.visible') - .as('userSearchBox') - .click() - cy.get('@userSearchBox') - .type('replacement') - cy.wait('@userSearch') - - cy.findByRole('option', { name: 'replacement-user' }) - .click() - - cy.intercept('POST', '**/ocs/v2.php/apps/dav/api/v1/outOfOffice/*').as('saveAbsence') - cy.get('#absence') - .findByRole('button', { name: 'Save' }) - .click() - cy.wait('@saveAbsence') - - cy.reload() - - // see its saved - cy.findByLabelText(/First day/) - .should('have.value', '2024-12-24') - cy.findByLabelText(/Last day/) - .should('have.value', '2024-12-28') - cy.findByRole('textbox', { name: /Short absence/ }) - .should('have.value', 'Vacation') - cy.findByRole('textbox', { name: /Long absence/ }) - .should('have.value', 'Happy holidays!') - cy.findByRole('combobox') - .should('contain.text', 'replacement-user') - }) -}) diff --git a/cypress/e2e/systemtags/admin-settings.cy.ts b/cypress/e2e/systemtags/admin-settings.cy.ts deleted file mode 100644 index c8b74335a5a15..0000000000000 --- a/cypress/e2e/systemtags/admin-settings.cy.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' - -const admin = new User('admin', 'admin') - -const tagName = 'foo' -const updatedTagName = 'bar' - -describe('Create system tags', () => { - before(() => { - // delete any existing tags - cy.runOccCommand('tag:list --output=json').then((output) => { - Object.keys(JSON.parse(output.stdout)).forEach((id) => { - cy.runOccCommand(`tag:delete ${id}`) - }) - }) - - // login as admin and go to admin settings - cy.login(admin) - cy.visit('/settings/admin/server') - }) - - it('Can create a tag', () => { - cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag') - cy.get('input#system-tag-name').should('exist').and('have.value', '') - cy.get('input#system-tag-name').type(tagName) - cy.get('input#system-tag-name').should('have.value', tagName) - // submit the form - cy.get('input#system-tag-name').type('{enter}') - - // wait for the tag to be created - cy.wait('@createTag').its('response.statusCode').should('eq', 201) - - // see that the created tag is in the list - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${tagName}"]`) - .should('exist') - .should('have.length', 1) - }) - }) -}) - -describe('Update system tags', { testIsolation: false }, () => { - before(() => { - cy.login(admin) - cy.visit('/settings/admin/server') - }) - - it('select the tag', () => { - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${tagName}"]`).should('exist').click() - }) - // see that the tag name matches the selected tag - cy.get('input#system-tag-name').should('exist').and('have.value', tagName) - // see that the tag level matches the selected tag - cy.get('input#system-tag-level').click() - cy.get('input#system-tag-level').siblings('.vs__selected').contains('Public').should('exist') - }) - - it('update the tag name and level', () => { - cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*').as('updateTag') - cy.get('input#system-tag-name').clear() - cy.get('input#system-tag-name').type(updatedTagName) - cy.get('input#system-tag-name').should('have.value', updatedTagName) - // select the new tag level - cy.get('input#system-tag-level').focus() - cy.get('input#system-tag-level').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="Invisible"]`).should('exist').click() - }) - // submit the form - cy.get('input#system-tag-name').type('{enter}') - // wait for the tag to be updated - cy.wait('@updateTag').its('response.statusCode').should('eq', 207) - }) - - it('see the tag was successfully updated', () => { - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${updatedTagName} (invisible)"]`) - .should('exist') - .should('have.length', 1) - }) - }) -}) - -describe('Delete system tags', { testIsolation: false }, () => { - before(() => { - cy.login(admin) - cy.visit('/settings/admin/server') - }) - - it('select the tag', () => { - // select the tag to edit - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${updatedTagName} (invisible)"]`).should('exist').click() - }) - // see that the tag name matches the selected tag - cy.get('input#system-tag-name').should('exist').and('have.value', updatedTagName) - // see that the tag level matches the selected tag - cy.get('input#system-tag-level').focus() - cy.get('input#system-tag-level').siblings('.vs__selected').contains('Invisible').should('exist') - }) - - it('can delete the tag', () => { - cy.intercept('DELETE', '/remote.php/dav/systemtags/*').as('deleteTag') - cy.get('.system-tag-form__row').within(() => { - cy.contains('button', 'Delete').should('be.enabled').click() - }) - // wait for the tag to be deleted - cy.wait('@deleteTag').its('response.statusCode').should('eq', 204) - }) - - it('see that the deleted tag is not present', () => { - cy.get('input#system-tags-input').focus() - cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then((id) => { - cy.get(`ul#${id} li span[title="${updatedTagName}"]`).should('not.exist') - }) - }) -}) diff --git a/tests/playwright/e2e/dav/availability.spec.ts b/tests/playwright/e2e/dav/availability.spec.ts new file mode 100644 index 0000000000000..8d44e609c06d5 --- /dev/null +++ b/tests/playwright/e2e/dav/availability.spec.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { User } from '@nextcloud/e2e-test-server' +import { addUser, runOcc } from '@nextcloud/e2e-test-server/docker' +import { test } from '../../support/fixtures/random-user-session.ts' + +test.describe('Calendar: Availability', () => { + test('User can see the availability section in settings', async ({ page }) => { + await page.goto('settings/user') + + // The settings sidebar lists an "Availability" navigation link + await page.getByRole('link', { name: /Availability/i }).first().click() + + await expect(page).toHaveURL(/settings\/user\/availability$/) + await expect(page.getByRole('heading', { name: /Availability/i, level: 2 })).toBeVisible() + }) + + test('Users can set their availability status', async ({ page }) => { + await page.goto('settings/user/availability') + + // CalendarAvailability renders listitems without an accessible name; filter by text content + const fridayItem = page.locator('#availability').getByRole('listitem').filter({ hasText: 'Friday' }) + await expect(fridayItem).toBeVisible() + await expect(fridayItem).toContainText('No working hours set') + + // Add a time slot for Friday + await fridayItem.getByRole('button', { name: 'Add slot' }).click() + + // Fill start and end times — labels are visually hidden but accessible + await fridayItem.getByLabel('Pick a start time for Friday').fill('09:00') + await fridayItem.getByLabel('Pick a end time for Friday').fill('18:00') + + // Wait for the PROPPATCH save request before clicking + const saveResponse = page.waitForResponse( + (r) => r.url().includes('/remote.php/dav/calendars/') && r.url().includes('/inbox') && r.request().method() === 'PROPPATCH', + ) + await page.locator('#availability').getByRole('button', { name: 'Save' }).click() + await saveResponse + + await page.reload() + + // After reload Friday should have a slot (no longer shows "No working hours set") + await expect(page.locator('#availability').getByRole('listitem').filter({ hasText: 'Friday' })).not.toContainText('No working hours set') + }) + + test('Users can set their absence', async ({ page }) => { + // Create a specific replacement user + const replacementUser = new User('replacement-user', 'password') + await runOcc(['user:delete', replacementUser.userId]).catch(() => {}) + await addUser(replacementUser) + + try { + await page.goto('settings/user/availability') + + await page.getByRole('heading', { name: /absence/i }).scrollIntoViewIfNeeded() + + const absenceSection = page.locator('#absence') + + // Fill date fields (NcDateTimePickerNative with type="date") + await absenceSection.getByLabel('First day').fill('2024-12-24') + await absenceSection.getByLabel(/Last day/i).fill('2024-12-28') + + // Fill text fields + await absenceSection.getByRole('textbox', { name: /Short absence/i }).fill('Vacation') + await absenceSection.getByRole('textbox', { name: /Long absence/i }).fill('Happy holidays!') + + // Search for the replacement user via NcSelectUsers + const userSearchInput = absenceSection.getByLabel('Out of office replacement (optional)') + const searchResponse = page.waitForResponse( + (r) => r.url().includes('/apps/files_sharing/api/v1/sharees') && r.url().includes('search=replacement'), + ) + await userSearchInput.click() + await userSearchInput.fill('replacement') + await searchResponse + + await page.getByRole('option', { name: 'replacement-user' }).click() + + // Save and wait for the OCS POST + const saveResponse = page.waitForResponse( + (r) => r.url().includes('/apps/dav/api/v1/outOfOffice/') && r.request().method() === 'POST', + ) + await absenceSection.getByRole('button', { name: 'Save' }).click() + await saveResponse + + await page.reload() + + // Verify all fields are persisted after reload + await expect(absenceSection.getByLabel('First day')).toHaveValue('2024-12-24') + await expect(absenceSection.getByLabel(/Last day/i)).toHaveValue('2024-12-28') + await expect(absenceSection.getByRole('textbox', { name: /Short absence/i })).toHaveValue('Vacation') + await expect(absenceSection.getByRole('textbox', { name: /Long absence/i })).toHaveValue('Happy holidays!') + // NcSelectUsers (single-select) shows the selected user in .vs__selected and a "Clear selected" button + await expect(absenceSection.locator('.vs__selected')).toContainText('replacement-user') + } finally { + await runOcc(['user:delete', replacementUser.userId]) + } + }) +}) diff --git a/tests/playwright/e2e/systemtags/admin-settings.spec.ts b/tests/playwright/e2e/systemtags/admin-settings.spec.ts new file mode 100644 index 0000000000000..a79322d5eed03 --- /dev/null +++ b/tests/playwright/e2e/systemtags/admin-settings.spec.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { test } from '../../support/fixtures/admin-session.ts' + +const tagName = 'foo' +const updatedTagName = 'bar' + +test.describe('System tags admin settings', () => { + // Tests are sequential: update depends on create, delete depends on update + test.describe.configure({ mode: 'serial' }) + + test.beforeAll(async () => { + // Delete all existing tags so each test run starts from a clean state + const output = await runOcc(['tag:list', '--output=json']) + const tags = JSON.parse(output) as Record + await Promise.all(Object.keys(tags).map((id) => runOcc(['tag:delete', id]).catch(() => {}))) + }) + + test('Can create a tag', async ({ page }) => { + await page.goto('settings/admin/server') + + // Scroll the collaborative tags section into view — the admin settings page is long + await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded() + + const tagNameInput = page.getByLabel('Tag name') + await expect(tagNameInput).toHaveValue('') + + // Create the tag and intercept the DAV POST + const createResponse = page.waitForResponse( + (r) => r.url().includes('/remote.php/dav/systemtags') && r.request().method() === 'POST', + ) + await tagNameInput.fill(tagName) + await page.getByRole('button', { name: 'Create' }).click() + expect((await createResponse).status()).toBe(201) + + // The form resets after creation — verify the tag now appears in the selection dropdown + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + await expect(page.getByRole('option', { name: tagName })).toBeVisible() + }) + + test('Can update a tag', async ({ page }) => { + await page.goto('settings/admin/server') + await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded() + + // Select the tag to edit + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + await page.getByRole('option', { name: tagName }).click() + + // Verify the form reflects the selected tag + await expect(page.getByLabel('Tag name')).toHaveValue(tagName) + // NcSelect single-select: selected level appears inline in .vs__selected + await expect(page.locator('.system-tag-form__group:has(#system-tag-level) .vs__selected')).toContainText('Public') + + // Update the name + await page.getByLabel('Tag name').fill(updatedTagName) + + // Change the level — click opens the teleported VueSelect dropdown + await page.locator('#system-tag-level').click() + await page.getByRole('option', { name: 'Invisible' }).click() + + const updateResponse = page.waitForResponse( + (r) => r.url().includes('/remote.php/dav/systemtags/') && r.request().method() === 'PROPPATCH', + ) + await page.getByRole('button', { name: 'Update' }).click() + expect((await updateResponse).status()).toBe(207) + + // NcEllipsisedOption splits names ≥ 10 chars across two spans, breaking the accessible name. + // "bar (invisible)" (15 chars) splits at position 8 → accessible name "bar (inv isible)". + // Use filter({ hasText }) to match on text content instead of the exact accessible name. + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + await expect(page.getByRole('option').filter({ hasText: updatedTagName })).toBeVisible() + }) + + test('Can delete a tag', async ({ page }) => { + await page.goto('settings/admin/server') + await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded() + + // Select the invisible tag to delete + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + await page.getByRole('option').filter({ hasText: updatedTagName }).click() + + // Verify the form reflects the selected tag + await expect(page.getByLabel('Tag name')).toHaveValue(updatedTagName) + await expect(page.locator('.system-tag-form__group:has(#system-tag-level) .vs__selected')).toContainText('Invisible') + + const deleteResponse = page.waitForResponse( + (r) => r.url().includes('/remote.php/dav/systemtags/') && r.request().method() === 'DELETE', + ) + await page.locator('.system-tag-form__row').getByRole('button', { name: 'Delete' }).click() + expect((await deleteResponse).status()).toBe(204) + + // Verify the tag is gone from the dropdown + await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click() + await expect(page.getByRole('option').filter({ hasText: updatedTagName })).not.toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/theming/a11y-color-contrast.spec.ts b/tests/playwright/e2e/theming/a11y-color-contrast.spec.ts new file mode 100644 index 0000000000000..576ba6a96fbe4 --- /dev/null +++ b/tests/playwright/e2e/theming/a11y-color-contrast.spec.ts @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { resolve } from 'node:path' +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { expect, test } from '@playwright/test' + +const themesToTest = ['light', 'dark', 'light-highcontrast', 'dark-highcontrast'] + +const testCases = { + 'Main text': { + foregroundColors: ['color-main-text', 'color-text-maxcontrast'], + backgroundColors: ['color-main-background', 'color-background-hover', 'color-background-dark'], + }, + 'blurred background': { + foregroundColors: ['color-main-text', 'color-text-maxcontrast-blur'], + backgroundColors: ['color-main-background-blur'], + }, + Primary: { + foregroundColors: ['color-primary-text'], + backgroundColors: ['color-primary'], + }, + 'Primary light': { + foregroundColors: ['color-primary-light-text'], + backgroundColors: ['color-primary-light', 'color-primary-light-hover'], + }, + 'Primary element': { + foregroundColors: ['color-primary-element-text', 'color-primary-element-text-dark'], + backgroundColors: ['color-primary-element', 'color-primary-element-hover'], + }, + 'Primary element light': { + foregroundColors: ['color-primary-element-light-text'], + backgroundColors: ['color-primary-element-light', 'color-primary-element-light-hover'], + }, + 'Severity information texts': { + foregroundColors: ['color-error-text', 'color-warning-text', 'color-success-text', 'color-info-text'], + backgroundColors: ['color-main-background', 'color-background-hover'], + }, + 'Severity information on blur': { + foregroundColors: ['color-error-text', 'color-success-text'], + backgroundColors: ['color-main-background-blur'], + }, +} + +for (const theme of themesToTest) { + test(`Accessibility of Nextcloud theming colors: ${theme}`, async ({ page, context }) => { + const user = await createRandomUser() + const failures: string[] = [] + + try { + await runOcc(['user:setting', '--', user.userId, 'theming', 'enabled-themes', `["${theme}"]`]) + await login(context.request, user) + await page.goto('') + + await page.addScriptTag({ path: resolve(process.cwd(), 'node_modules/axe-core/axe.min.js') }) + + for (const [groupName, { foregroundColors, backgroundColors }] of Object.entries(testCases)) { + for (const foreground of foregroundColors) { + for (const background of backgroundColors) { + await page.evaluate(({ foregroundValue, backgroundValue }) => { + document.body.style.backgroundImage = 'unset' + const root = document.querySelector('#content') + if (!root) { + throw new Error('No test root found') + } + + root.innerHTML = '' + + const wrapper = document.createElement('div') + wrapper.style.padding = '14px' + wrapper.style.color = `var(--${foregroundValue})` + wrapper.style.backgroundColor = `var(--${backgroundValue})` + if (backgroundValue.includes('blur')) { + wrapper.style.backdropFilter = 'var(--filter-background-blur)' + } + + const testCase = document.createElement('div') + testCase.innerText = `${foregroundValue} ${backgroundValue}` + testCase.setAttribute('data-cy-testcase', '') + + wrapper.append(testCase) + root.append(wrapper) + }, { + foregroundValue: foreground, + backgroundValue: background, + }) + + const axeResult = await page.evaluate(async () => { + const axe = (window as any).axe + if (!axe) { + throw new Error('axe is not loaded') + } + + return axe.run('[data-cy-testcase]', { + runOnly: { + type: 'rule', + values: ['color-contrast'], + }, + }) + }) + + if (axeResult.violations.length > 0) { + failures.push(`${groupName}: ${foreground} on ${background}`) + } + } + } + } + } finally { + await runOcc(['user:delete', user.userId]) + } + + expect(failures).toEqual([]) + }) +} diff --git a/tests/playwright/e2e/theming/admin-settings-background.spec.ts b/tests/playwright/e2e/theming/admin-settings-background.spec.ts new file mode 100644 index 0000000000000..535946b6a0a31 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-background.spec.ts @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-theming-page.ts' +import { resolve } from 'node:path' +import { getBodyThemingSnapshot, pickColor } from '../../support/utils/theming.ts' + +test.describe('Admin theming background settings', () => { + test.describe.configure({ mode: 'serial' }) + + test.beforeEach(async ({ adminThemingPage, page }) => { + await adminThemingPage.reset() + await adminThemingPage.open() + if (await adminThemingPage.disableUserThemingCheckbox().isChecked()) { + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.disableUserThemingCheckbox().uncheck({ force: true }), + ]) + } + }) + + test('Remove default background and restore it', async ({ adminThemingPage, page }) => { + await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible() + if (await adminThemingPage.removeBackgroundImageCheckbox().isChecked()) { + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.removeBackgroundImageCheckbox().uncheck({ force: true }), + ]) + } + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }), + ]) + + await page.goto('/index.php/logout') + await page.goto('/index.php/login') + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') + + await adminThemingPage.reset() + await page.goto('settings/admin/theming') + await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible() + }) + + test('Disable user theming', async ({ adminThemingPage, page, context }) => { + await expect(adminThemingPage.disableUserThemingCheckbox()).not.toBeChecked() + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.disableUserThemingCheckbox().check({ force: true }), + ]) + + const user = await createRandomUser() + try { + await login(context.request, user) + await page.goto('settings/user/theming') + await expect(page.getByText('Customization has been disabled by your administrator')).toBeVisible() + } finally { + await runOcc(['user:delete', user.userId]) + } + }) + + test('Remove default background with custom color', async ({ adminThemingPage, page, context }) => { + await expect(adminThemingPage.backgroundAndColorHeading()).toBeVisible() + const backgroundColorButton = page.getByRole('button', { name: /Background color/ }) + const selectedColor = await pickColor(page, backgroundColorButton, 2) + expect(selectedColor).toBeTruthy() + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }), + ]) + + await page.goto('/index.php/logout') + await page.goto('/index.php/login') + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') + }) + + test('User default background reflects admin custom background and color', async ({ adminThemingPage, page, context }) => { + const imagePath = resolve(process.cwd(), 'cypress/fixtures/image.jpg') + + await page.locator('input[type="file"][name="background"]').setInputFiles(imagePath) + await page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/uploadImage') && response.request().method() === 'POST') + + const backgroundColorButton = page.getByRole('button', { name: /Background color/ }) + await pickColor(page, backgroundColorButton, 1) + await page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST') + + await page.goto('/index.php/logout') + const user = await createRandomUser() + try { + await login(context.request, user) + await page.goto('settings/user/theming') + await expect(page.getByRole('button', { name: 'Default background' })).toHaveAttribute('aria-pressed', 'true') + const snapshot = await getBodyThemingSnapshot(page) + expect(snapshot.backgroundImage).toContain('/apps/theming/image/background?v=') + } finally { + await runOcc(['user:delete', user.userId]) + } + }) + + test('User default background reflects admin removed background', async ({ adminThemingPage, page, context }) => { + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.removeBackgroundImageCheckbox().check({ force: true }), + ]) + + await page.goto('/index.php/logout') + const user = await createRandomUser() + try { + await login(context.request, user) + await page.goto('settings/user/theming') + await expect(page.getByRole('button', { name: 'Default background' })).toHaveAttribute('aria-pressed', 'true') + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') + } finally { + await runOcc(['user:delete', user.userId]) + } + }) +}) diff --git a/tests/playwright/e2e/theming/admin-settings-branding.spec.ts b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts new file mode 100644 index 0000000000000..d78409a26d035 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/e2e-test-server' +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-theming-page.ts' + +const admin = new User('admin', 'admin') + +test.describe('Admin theming branding settings', () => { + test.describe.configure({ mode: 'serial' }) + + test.beforeEach(async ({ adminThemingPage }) => { + await adminThemingPage.reset() + await adminThemingPage.open() + }) + + test('Set project links and verify persisted values', async ({ adminThemingPage, page }) => { + await expect(adminThemingPage.webLinkInput()).toHaveAttribute('type', 'url') + await expect(adminThemingPage.legalNoticeLinkInput()).toHaveAttribute('type', 'url') + await expect(adminThemingPage.privacyPolicyLinkInput()).toHaveAttribute('type', 'url') + + await adminThemingPage.webLinkInput().fill('http://example.com/path?query#fragment') + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.webLinkInput().press('Enter'), + ]) + + await adminThemingPage.legalNoticeLinkInput().fill('http://example.com/legal?query#fragment') + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.legalNoticeLinkInput().press('Enter'), + ]) + + await adminThemingPage.privacyPolicyLinkInput().fill('http://privacy.local/path?query#fragment') + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.privacyPolicyLinkInput().press('Enter'), + ]) + + await page.reload() + await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/path?query#fragment') + await expect(adminThemingPage.legalNoticeLinkInput()).toHaveValue('http://example.com/legal?query#fragment') + await expect(adminThemingPage.privacyPolicyLinkInput()).toHaveValue('http://privacy.local/path?query#fragment') + }) + + test('Set and undo login fields', async ({ adminThemingPage, page }) => { + const name = 'ABCdef123' + const url = 'https://example.com' + const slogan = 'Testing is fun' + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.nameInput().fill(name), + ]) + await adminThemingPage.nameInput().press('Enter') + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.webLinkInput().fill(url), + ]) + await adminThemingPage.webLinkInput().press('Enter') + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + adminThemingPage.sloganInput().fill(slogan), + ]) + await adminThemingPage.sloganInput().press('Enter') + + await expect(adminThemingPage.undoChangesButtons()).toHaveCount(3) + + for (let index = 0; index < 3; index++) { + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/undoChanges') && response.request().method() === 'POST'), + adminThemingPage.undoChangesButtons().first().click(), + ]) + } + await expect(adminThemingPage.undoChangesButtons()).toHaveCount(0) + }) + + test('Web link corner cases', async ({ adminThemingPage, page }) => { + await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/%22path%20with%20space%22') + await page.reload() + await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22path%20with%20space%22') + + await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/"path"') + await page.reload() + await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22path%22') + + await setUrlFieldAndWait(page, adminThemingPage.webLinkInput(), 'http://example.com/"the%20path"') + await page.reload() + await expect(adminThemingPage.webLinkInput()).toHaveValue('http://example.com/%22the%20path%22') + }) +}) + +async function setUrlFieldAndWait(page: import('@playwright/test').Page, locator: import('@playwright/test').Locator, value: string) { + await locator.fill(value) + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/apps/theming/ajax/updateStylesheet') && response.request().method() === 'POST'), + locator.press('Enter'), + ]) +} diff --git a/tests/playwright/e2e/theming/admin-settings-colors.spec.ts b/tests/playwright/e2e/theming/admin-settings-colors.spec.ts new file mode 100644 index 0000000000000..9becf0aef0369 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-colors.spec.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-theming-page.ts' +import { pickColor } from '../../support/utils/theming.ts' + +test.beforeEach(async ({ adminThemingPage }) => { + await adminThemingPage.reset() + await adminThemingPage.open() +}) + +test('Change the primary color and reset it', async ({ adminThemingPage, page }) => { + await page.getByRole('heading', { name: 'Background and color' }).scrollIntoViewIfNeeded() + + const primaryColorButton = page.getByRole('button', { name: /Primary color/ }) + const updateStylesheetResponse = page.waitForResponse((response) => { + return response.url().includes('/apps/theming/ajax/updateStylesheet') + && response.request().method() === 'POST' + }) + await pickColor(page, primaryColorButton, 3) + expect(await updateStylesheetResponse).toBeTruthy() + + await page.goto('settings/admin/theming') + await adminThemingPage.reset() + await page.goto('settings/admin/theming') + await expect(page.getByRole('heading', { name: 'Background and color' })).toBeVisible() +}) diff --git a/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts new file mode 100644 index 0000000000000..b82a1fa5316f8 --- /dev/null +++ b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { runOcc } from '@nextcloud/e2e-test-server/docker' +import { test } from '../../support/fixtures/admin-theming-page.ts' +import { NavigationHeaderPage } from '../../support/sections/NavigationHeaderPage.ts' + +test.describe('Admin theming set default apps', () => { + // we need serial mode to reset the default app setting after each test + // and to restore the default app to dashboard at the end of the tests. + // Otherwise, the tests would influence each other and lead to random failures (race condition when run in parallel). + test.describe.configure({ mode: 'serial' }) + + test.beforeEach(async ({ adminThemingPage, page, context }) => { + await runOcc(['config:system:set', 'defaultapp', '--value', 'dashboard']) + await adminThemingPage.reset() + await page.goto('') + }) + + test.afterAll(async () => { + await runOcc(['config:system:set', 'defaultapp', '--value', 'dashboard']) + }) + + test('See the current default app is the dashboard', async ({ page }) => { + const navigationHeader = new NavigationHeaderPage(page) + + await expect(page).toHaveURL(/apps\/dashboard/) + await navigationHeader.logo().click() + await expect(page).toHaveURL(/apps\/dashboard/) + }) + + test('Can configure and switch the default app to files', async ({ adminThemingPage }) => { + await adminThemingPage.open() + await expect(adminThemingPage.defaultAppSwitch()).toBeVisible() + if (await adminThemingPage.defaultAppSwitch().isChecked()) { + await adminThemingPage.defaultAppSwitch().uncheck({ force: true }) + } + await expect(adminThemingPage.defaultAppSwitch()).not.toBeChecked() + + await adminThemingPage.defaultAppSwitch().check({ force: true }) + await expect(adminThemingPage.defaultAppSwitch()).toBeChecked() + await expect(adminThemingPage.defaultAppRegion()).toBeVisible() + + await expect(adminThemingPage.defaultAppSelect().getByText('Dashboard')).toBeVisible() + await expect(adminThemingPage.defaultAppSelect().getByText('Files')).toBeVisible() + + await expect(adminThemingPage.appOrderEntries()).toHaveCount(2) + await expect(adminThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard') + await expect(adminThemingPage.appOrderEntries().nth(1)).toContainText('Files') + + await adminThemingPage.moveUpButton('Files').click() + await expect(adminThemingPage.moveUpButton('Files')).toHaveCount(0) + await expect(adminThemingPage.appOrderEntries().nth(0)).toContainText('Files') + await expect(adminThemingPage.appOrderEntries().nth(1)).toContainText('Dashboard') + + await adminThemingPage.defaultAppSwitch().uncheck({ force: true }) + await expect(adminThemingPage.defaultAppSwitch()).not.toBeChecked() + await expect(adminThemingPage.defaultAppRegion()).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/theming/user-settings-app-order.spec.ts b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts new file mode 100644 index 0000000000000..305334635d687 --- /dev/null +++ b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/random-user-session.ts' +import { NavigationHeaderPage } from '../../support/sections/NavigationHeaderPage.ts' +import { UserThemingPage } from '../../support/sections/UserThemingPage.ts' + +test('User can change personal app order', async ({ page }) => { + const userThemingPage = new UserThemingPage(page) + const navigationHeader = new NavigationHeaderPage(page) + + await userThemingPage.open() + + await expect(userThemingPage.appOrderEntries()).toHaveCount(2) + await expect(userThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard') + await expect(userThemingPage.appOrderEntries().nth(1)).toContainText('Files') + + await expect(navigationHeader.navigationEntries().nth(0)).toContainText('Dashboard') + await expect(navigationHeader.navigationEntries().nth(1)).toContainText('Files') + + const initialFirstEntry = await userThemingPage.appOrderEntries().nth(0).innerText() + if (/Dashboard/i.test(initialFirstEntry)) { + const moveUpButton = userThemingPage.appEntry('Files').locator('button[aria-label="Move up"]').first() + if (await moveUpButton.count() > 0) { + await moveUpButton.evaluate((element) => { + (element as HTMLButtonElement).click() + }) + } + } + + const currentOrder = (await userThemingPage.appOrderEntries().allInnerTexts()).map((entry) => entry.trim()) + expect(currentOrder).toContain('Dashboard') + expect(currentOrder).toContain('Files') + + await page.reload() + const reloadedOrder = (await userThemingPage.appOrderEntries().allInnerTexts()).map((entry) => entry.trim()) + expect(reloadedOrder).toContain('Dashboard') + expect(reloadedOrder).toContain('Files') + await expect(navigationHeader.navigationEntries().nth(0)).toContainText(reloadedOrder[0]!) + await expect(navigationHeader.navigationEntries().nth(1)).toContainText(reloadedOrder[1]!) +}) diff --git a/tests/playwright/e2e/theming/user-settings-background.spec.ts b/tests/playwright/e2e/theming/user-settings-background.spec.ts new file mode 100644 index 0000000000000..eec6c53b8e5c6 --- /dev/null +++ b/tests/playwright/e2e/theming/user-settings-background.spec.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/random-user-session.ts' +import { getBodyThemingSnapshot, pickColor } from '../../support/utils/theming.ts' + +test('User can configure background and plain color', async ({ page }) => { + await page.goto('settings/user/theming') + await page.getByRole('heading', { name: 'Background and color' }).waitFor({ state: 'visible' }) + + await expect(page.getByRole('button', { name: 'Default background', pressed: true })).toBeVisible() + + const darkBackground = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' + const darkBackgroundName = 'Background picture of a red-ish butterfly wing under microscope' + await page.getByRole('button', { name: darkBackgroundName, pressed: false }).click() + await expect(page.getByRole('button', { name: darkBackgroundName, pressed: true })).toBeVisible() + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toContain(darkBackground) + + const brightBackground = 'bernie-cetonia-aurata-take-off-composition.jpg' + const brightBackgroundName = 'Montage of a cetonia aurata bug that takes off with white background' + await page.getByRole('button', { name: brightBackgroundName, pressed: false }).click() + await expect(page.getByRole('button', { name: brightBackgroundName, pressed: true })).toBeVisible() + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toContain(brightBackground) + + const plainBackgroundButton = page.getByRole('button', { name: 'Plain background' }) + await pickColor(page, plainBackgroundButton, 7) + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') + + await page.reload() + await expect.poll(async () => (await getBodyThemingSnapshot(page)).backgroundImage).toBe('none') +}) diff --git a/tests/playwright/support/fixtures/admin-theming-page.ts b/tests/playwright/support/fixtures/admin-theming-page.ts new file mode 100644 index 0000000000000..f2d232915e721 --- /dev/null +++ b/tests/playwright/support/fixtures/admin-theming-page.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as adminSessionTest } from './admin-session.ts' +import { AdminThemingPage } from '../sections/AdminThemingPage.ts' + +export const test = adminSessionTest.extend<{ adminThemingPage: AdminThemingPage }>({ + adminThemingPage: async ({ page }, use) => { + const adminThemingPage = new AdminThemingPage(page) + await use(adminThemingPage) + }, +}) diff --git a/tests/playwright/support/sections/AdminThemingPage.ts b/tests/playwright/support/sections/AdminThemingPage.ts new file mode 100644 index 0000000000000..44520c5a640d7 --- /dev/null +++ b/tests/playwright/support/sections/AdminThemingPage.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +export class AdminThemingPage { + constructor(private readonly page: Page) {} + + async open() { + await this.page.goto('settings/admin/theming') + await this.page.getByText('Navigation bar settings').waitFor({ state: 'visible' }) + } + + /** + * Resets the admin theming settings to default using HTTP request. + * + * @param request - The APIRequestContext to perform the request with admin credentials. + */ + async reset() { + const tokenResponse = await this.page.request.get('/csrftoken', { + failOnStatusCode: true, + }) + const requestToken = (await tokenResponse.json()).token + + const response = await this.page.request.post('/apps/theming/ajax/undoAllChanges', { + headers: { + requesttoken: requestToken, + }, + }) + + if (!response.ok) { + throw new Error(`Failed to reset theming settings (${response.status})`) + } + } + + defaultAppSwitch(): Locator { + return this.page.getByRole('checkbox', { name: 'Use custom default app' }) + } + + defaultAppRegion(): Locator { + return this.page.getByRole('region', { name: 'Global default app' }) + } + + defaultAppSelect(): Locator { + return this.defaultAppRegion().getByRole('combobox') + } + + appOrderList(): Locator { + return this.page.getByRole('list', { name: 'Navigation bar app order' }) + } + + appOrderEntries(): Locator { + return this.appOrderList().getByRole('listitem') + } + + appEntry(name: string): Locator { + return this.appOrderEntries().filter({ hasText: name }) + } + + moveUpButton(appName: string): Locator { + return this.appEntry(appName).getByRole('button', { name: 'Move up' }) + } + + backgroundAndColorHeading(): Locator { + return this.page.getByRole('heading', { name: 'Background and color' }) + } + + webLinkInput(): Locator { + return this.page.getByRole('textbox', { name: /web link/i }) + } + + legalNoticeLinkInput(): Locator { + return this.page.getByRole('textbox', { name: /legal notice link/i }) + } + + privacyPolicyLinkInput(): Locator { + return this.page.getByRole('textbox', { name: /privacy policy link/i }) + } + + nameInput(): Locator { + return this.page.getByRole('textbox', { name: 'Name' }) + } + + sloganInput(): Locator { + return this.page.getByRole('textbox', { name: 'Slogan' }) + } + + undoChangesButtons(): Locator { + return this.page.getByRole('button', { name: /undo changes/i }) + } + + removeBackgroundImageCheckbox(): Locator { + return this.page.getByRole('checkbox', { name: /remove background image/i }) + } + + disableUserThemingCheckbox(): Locator { + return this.page.getByRole('checkbox', { name: /disable user theming/i }) + } +} diff --git a/tests/playwright/support/sections/NavigationHeaderPage.ts b/tests/playwright/support/sections/NavigationHeaderPage.ts new file mode 100644 index 0000000000000..543fccd830480 --- /dev/null +++ b/tests/playwright/support/sections/NavigationHeaderPage.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +export class NavigationHeaderPage { + constructor(private readonly page: Page) {} + + private get header(): Locator { + return this.page.locator('header#header') + } + + logo(): Locator { + return this.header.locator('#nextcloud') + } + + navigation(): Locator { + return this.header.getByRole('navigation', { name: 'Applications menu' }) + } + + navigationEntries(): Locator { + return this.navigation().getByRole('listitem') + } +} diff --git a/tests/playwright/support/sections/UserThemingPage.ts b/tests/playwright/support/sections/UserThemingPage.ts new file mode 100644 index 0000000000000..a67b98ec9edd7 --- /dev/null +++ b/tests/playwright/support/sections/UserThemingPage.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +export class UserThemingPage { + constructor(private readonly page: Page) {} + + async open() { + await this.page.goto('settings/user/theming') + await this.page.getByRole('heading', { name: /Navigation bar settings/ }).waitFor({ state: 'visible' }) + } + + appOrderList(): Locator { + return this.page.getByRole('list', { name: 'Navigation bar app order' }) + } + + appOrderEntries(): Locator { + return this.appOrderList().getByRole('listitem') + } + + appEntry(name: string): Locator { + return this.appOrderEntries().filter({ hasText: name }) + } + + moveUpButton(appName: string): Locator { + return this.appEntry(appName).getByRole('button', { name: 'Move up', includeHidden: true }) + } +} diff --git a/tests/playwright/support/utils/theming.ts b/tests/playwright/support/utils/theming.ts new file mode 100644 index 0000000000000..300e19e8851c3 --- /dev/null +++ b/tests/playwright/support/utils/theming.ts @@ -0,0 +1,90 @@ +/* + * 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' + +export const defaultPrimary = '#00679e' +export const defaultBackground = 'jo-myoung-hee-fluid.webp' + +export async function getBodyThemingSnapshot(page: Page) { + return page.evaluate(() => { + const styles = getComputedStyle(document.body) + return { + primary: styles.getPropertyValue('--color-primary').trim(), + backgroundColor: styles.backgroundColor, + backgroundImage: styles.backgroundImage, + } + }) +} + +export async function expectBodyThemingCss(page: Page, expected: { + primary?: string + background?: string | null + backgroundColor?: string | null +}) { + await expect.poll(async () => { + const snapshot = await getBodyThemingSnapshot(page) + const expectedPrimary = expected.primary ?? defaultPrimary + const normalizedPrimary = await normalizeColor(page, snapshot.primary) + const normalizedExpectedPrimary = await normalizeColor(page, expectedPrimary) + + const expectedBackgroundColor = expected.backgroundColor ?? defaultPrimary + const normalizedBackground = expectedBackgroundColor === null + ? null + : await normalizeColor(page, expectedBackgroundColor) + + const expectedBackground = expected.background === undefined ? defaultBackground : expected.background + + const validPrimary = normalizedPrimary === normalizedExpectedPrimary + const validBackgroundColor = normalizedBackground === null || snapshot.backgroundColor === normalizedBackground + const validBackgroundImage = expectedBackground === null + ? snapshot.backgroundImage === 'none' + : snapshot.backgroundImage.includes(expectedBackground) + + return validPrimary && validBackgroundColor && validBackgroundImage + }, { + timeout: 10000, + message: 'Expected body theming CSS to match expected values', + }).toBeTruthy() +} + +export async function expectPrimaryColor(page: Page, expectedColor: string) { + const normalizedExpectedPrimary = await normalizeColor(page, expectedColor) + + await expect.poll(async () => { + const snapshot = await getBodyThemingSnapshot(page) + return normalizeColor(page, snapshot.primary) + }, { + timeout: 10000, + message: 'Expected primary color CSS variable to match', + }).toBe(normalizedExpectedPrimary) +} + +export async function pickColor(page: Page, trigger: Locator, index: number) { + const oldColor = await trigger.evaluate((element) => getComputedStyle(element as HTMLElement).backgroundColor) + + await trigger.click({ force: true }) + await page.locator('.color-picker__simple-color-circle').nth(index).click() + await page.getByRole('button', { name: /Choose/i }).click() + + await expect.poll(async () => { + return trigger.evaluate((element) => getComputedStyle(element as HTMLElement).backgroundColor) + }).not.toBe(oldColor) + + return trigger.evaluate((element) => getComputedStyle(element as HTMLElement).backgroundColor) +} + +async function normalizeColor(page: Page, color: string) { + return page.evaluate((value) => { + const element = document.createElement('div') + element.style.color = value + document.body.append(element) + const normalized = getComputedStyle(element).color + element.remove() + return normalized + }, color) +} From 9fc19ac7f5ba74cbae1ea1d9d108ffc0878f353b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 7 May 2026 11:57:16 +0200 Subject: [PATCH 3/4] test: migrate appstore tests to PlayWright Signed-off-by: Ferdinand Thiessen --- cypress/e2e/appstore/apps.cy.ts | 279 ------------- cypress/e2e/theming/a11y-color-contrast.cy.ts | 166 -------- .../theming/admin-settings_background.cy.ts | 385 ------------------ .../e2e/theming/admin-settings_branding.cy.ts | 219 ---------- .../e2e/theming/admin-settings_colors.cy.ts | 67 --- .../theming/admin-settings_default-app.cy.ts | 131 ------ .../e2e/theming/user-settings_app-order.cy.ts | 302 -------------- .../theming/user-settings_background.cy.ts | 270 ------------ .../e2e/appstore/admin-settings-apps.spec.ts | 217 ++++++++++ .../theming/admin-settings-branding.spec.ts | 2 - .../admin-settings-default-app.spec.ts | 4 +- .../theming/user-settings-app-order.spec.ts | 2 + .../support/fixtures/admin-appstore-page.ts | 14 + .../support/sections/AdminThemingPage.ts | 12 +- .../support/sections/AppstorePage.ts | 199 +++++++++ .../support/sections/NavigationHeaderPage.ts | 28 +- .../support/utils/password-confirmation.ts | 34 ++ 17 files changed, 504 insertions(+), 1827 deletions(-) delete mode 100644 cypress/e2e/appstore/apps.cy.ts delete mode 100644 cypress/e2e/theming/a11y-color-contrast.cy.ts delete mode 100644 cypress/e2e/theming/admin-settings_background.cy.ts delete mode 100644 cypress/e2e/theming/admin-settings_branding.cy.ts delete mode 100644 cypress/e2e/theming/admin-settings_colors.cy.ts delete mode 100644 cypress/e2e/theming/admin-settings_default-app.cy.ts delete mode 100644 cypress/e2e/theming/user-settings_app-order.cy.ts delete mode 100644 cypress/e2e/theming/user-settings_background.cy.ts create mode 100644 tests/playwright/e2e/appstore/admin-settings-apps.spec.ts create mode 100644 tests/playwright/support/fixtures/admin-appstore-page.ts create mode 100644 tests/playwright/support/sections/AppstorePage.ts create mode 100644 tests/playwright/support/utils/password-confirmation.ts diff --git a/cypress/e2e/appstore/apps.cy.ts b/cypress/e2e/appstore/apps.cy.ts deleted file mode 100644 index 391ab163f9a4e..0000000000000 --- a/cypress/e2e/appstore/apps.cy.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { handlePasswordConfirmation } from '../core-utils.ts' - -const admin = new User('admin', 'admin') - -describe('Settings: App management', { testIsolation: true }, () => { - beforeEach(() => { - // disable QA if already enabled - cy.runOccCommand('app:disable -n testing') - // enable notification if already disabled - cy.runOccCommand('app:enable -n updatenotification') - - // I am logged in as the admin - cy.login(admin) - - // Intercept the apps list request - cy.intercept('GET', '/ocs/v2.php/apps/appstore/api/v1/apps').as('fetchAppsList') - - // I open the Apps management - cy.visit('/settings/apps/installed') - - // Wait for the apps list to load - cy.wait('@fetchAppsList') - }) - - it('Can enable an installed app', () => { - cy.intercept('POST', '/ocs/v2.php/apps/appstore/api/v1/apps/enable').as('enableApp') - - cy.findByRole('table').should('exist') - // Wait for the app list to load - .contains('tr', 'QA testing', { timeout: 10000 }) - .should('exist') - .findByRole('button', { name: 'Enable' }) - // I enable the "QA testing" app - .click({ force: true }) - - handlePasswordConfirmation(admin.password) - - cy.wait('@enableApp') - - // Wait until we see the disable button for the app - cy.findByRole('table').should('exist') - .contains('tr', 'QA testing') - .should('exist') - // I see the disable button for the app - .findByRole('button', { name: 'Disable' }) - .should('be.visible') - - // Change to enabled apps view - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Active apps' }) - .should('be.visible') - .click({ force: true }) - }) - - cy.url().should('match', /settings\/apps\/enabled$/) - // I see that the "QA testing" app has been enabled - cy.findByRole('table') - .contains('tr', 'QA testing') - }) - - it('Can disable an installed app', () => { - cy.intercept('POST', '/ocs/v2.php/apps/appstore/api/v1/apps/disable').as('disableApp') - - cy.findByRole('table') - .should('exist') - // Wait for the app list to load - .contains('tr', 'Update notification', { timeout: 10000 }) - .should('exist') - // I disable the "Update notification" app - .findByRole('button', { name: 'Disable' }) - .click({ force: true }) - - handlePasswordConfirmation(admin.password) - cy.wait('@disableApp') - - // Wait until we see the disable button for the app - cy.findByRole('table').should('exist') - .contains('tr', 'Update notification') - .should('exist') - // I see the enable button for the app - .findByRole('button', { name: 'Enable' }) - .should('exist') - - // Change to disabled apps view - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Disabled apps' }).click({ force: true }) - }) - cy.url().should('match', /settings\/apps\/disabled$/) - - // I see that the "Update notification" app has been disabled - cy.findByRole('table') - .contains('tr', 'Update notification') - }) - - it('Browse enabled apps', () => { - // When I open the "Active apps" section - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Active apps' }) - .should('be.visible') - .click({ force: true }) - }) - - // Then I see that the current section is "Active apps" - cy.url().should('match', /settings\/apps\/enabled$/) - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Active apps', current: 'page' }) - .should('be.visible') - }) - - // I see that there are only enabled apps - cy.findByRole('table') - .should('exist') - .find('tr button') - .each(($action) => { - cy.wrap($action).should('not.contain', 'Enable') - }) - }) - - it('Browse disabled apps', () => { - // When I open the "Active Disabled" section - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Disabled apps' }) - .as('disabledAppsLink') - .should('be.visible') - .and('not.have.attr', 'aria-current') - cy.get('@disabledAppsLink') - .click({ force: true }) - }) - - // Then I see that the current section is "Disabled apps" - cy.url().should('match', /settings\/apps\/disabled$/) - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Disabled apps', current: 'page' }) - .should('be.visible') - }) - - // I see that there are only disabled apps - cy.findByRole('table') - .should('exist') - .find('tr button') - .each(($action) => { - cy.wrap($action).should('not.contain', 'Disable') - }) - }) - - it('Browse app bundles', () => { - // When I open the "App bundles" section - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'App bundles' }) - .as('appBundlesLink') - .should('be.visible') - .and('not.have.attr', 'aria-current') - cy.get('@appBundlesLink') - .click({ force: true }) - }) - - // Then I see that the current section is "App bundles" - cy.url().should('match', /settings\/apps\/bundles$/) - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'App bundles', current: 'page' }) - .should('be.visible') - }) - - // I see the app bundles - cy.findByRole('heading', { name: 'Enterprise bundle' }) - .should('be.visible') - cy.findByRole('heading', { name: 'Education bundle' }) - .should('be.visible') - }) - - it('View app details', () => { - // When I click on the "QA testing" app - cy.findByRole('table') - .contains('a', 'QA testing') - .click({ force: true }) - // I see that the app details are shown - cy.get('#app-sidebar-vue') - .should('be.visible') - .find('.app-sidebar-header__info') - .should('contain', 'QA testing') - cy.get('#app-sidebar-vue').contains('a', 'View in store').should('exist') - cy.get('#app-sidebar-vue') - .findByRole('button', { name: 'Enable' }) - .should('be.visible') - cy.get('#app-sidebar-vue') - .findByRole('button', { name: 'Remove' }) - .should('be.visible') - cy.get('#app-sidebar-vue').contains(/Version \d+\.\d+\.\d+/).should('be.visible') - }) - - it('Limit app usage to group', () => { - // When I open the "Active apps" section - cy.findByRole('navigation', { name: 'Appstore categories' }) - .within(() => { - cy.findByRole('link', { name: 'Active apps' }) - .should('be.visible') - .click({ force: true }) - }) - - // Then I see that the current section is "Active apps" - cy.url().should('match', /settings\/apps\/enabled$/) - - // Then I select the app - cy.findByRole('table') - .should('exist') - .contains('tr a', 'Dashboard', { timeout: 10000 }) - .click() - - // Then I enable "limit app to group" - cy.findByRole('button', { name: 'Limit to groups' }) - .click() - - // Then I select a group - cy.findByRole('dialog') - .should('be.visible') - .within(() => { - cy.get('input') - .should('be.focused') - .type('admin') - }) - cy.findByRole('option', { name: /admin/ }) - .click() - cy.findByRole('button', { name: 'Save' }) - .click() - - handlePasswordConfirmation(admin.password) - - cy.get('#app-sidebar-vue') - .findByRole('list', { name: 'Limited to groups' }) - .findByRole('listitem', { name: /admin/ }) - .should('be.visible') - - // Then I disable the group limitation - cy.get('#app-sidebar-vue') - .findByRole('button', { name: 'Limit to groups' }) - .click() - cy.findByRole('dialog') - .should('be.visible') - .within(() => { - cy.findByRole('button', { name: 'Deselect admin' }) - .should('be.visible') - .click() - cy.findByRole('button', { name: 'Save' }) - .click() - }) - - handlePasswordConfirmation(admin.password) - - cy.get('#app-sidebar-vue') - .findByRole('list', { name: 'Limited to groups' }) - .should('not.exist') - }) - - /* - * TODO: Improve testing with app store as external API - * The following scenarios require the files_antivirus and calendar app - * being present in the app store with support for the current server version - * Ideally we would have either a dummy app store endpoint with some test apps - * or even an app store instance running somewhere to properly test this. - * This is also a requirement to properly test updates of apps - */ - // TODO: View app details for app store apps - // TODO: Install an app from the app store - // TODO: Show section from app store -}) diff --git a/cypress/e2e/theming/a11y-color-contrast.cy.ts b/cypress/e2e/theming/a11y-color-contrast.cy.ts deleted file mode 100644 index 53796707b1cad..0000000000000 --- a/cypress/e2e/theming/a11y-color-contrast.cy.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -const themesToTest = ['light', 'dark', 'light-highcontrast', 'dark-highcontrast'] - -const testCases = { - 'Main text': { - foregroundColors: [ - 'color-main-text', - // 'color-text-light', deprecated - // 'color-text-lighter', deprecated - 'color-text-maxcontrast', - ], - backgroundColors: [ - 'color-main-background', - 'color-background-hover', - 'color-background-dark', - // 'color-background-darker', this should only be used for elements not for text - ], - }, - 'blurred background': { - foregroundColors: [ - 'color-main-text', - 'color-text-maxcontrast-blur', - ], - backgroundColors: [ - 'color-main-background-blur', - ], - }, - Primary: { - foregroundColors: [ - 'color-primary-text', - ], - backgroundColors: [ - // 'color-primary-default', this should only be used for elements not for text! - // 'color-primary-hover', this should only be used for elements and not for text! - 'color-primary', - ], - }, - 'Primary light': { - foregroundColors: [ - 'color-primary-light-text', - ], - backgroundColors: [ - 'color-primary-light', - 'color-primary-light-hover', - ], - }, - 'Primary element': { - foregroundColors: [ - 'color-primary-element-text', - 'color-primary-element-text-dark', - ], - backgroundColors: [ - 'color-primary-element', - 'color-primary-element-hover', - ], - }, - 'Primary element light': { - foregroundColors: [ - 'color-primary-element-light-text', - ], - backgroundColors: [ - 'color-primary-element-light', - 'color-primary-element-light-hover', - ], - }, - 'Severity information texts': { - foregroundColors: [ - 'color-error-text', - 'color-warning-text', - 'color-success-text', - 'color-info-text', - ], - backgroundColors: [ - 'color-main-background', - 'color-background-hover', - ], - }, - // only most important severity colors are supported on the blur - 'Severity information on blur': { - foregroundColors: [ - 'color-error-text', - 'color-success-text', - ], - backgroundColors: [ - 'color-main-background-blur', - ], - }, -} - -/** - * Create a wrapper element with color and background set - * - * @param foreground The foreground color (css variable without leading --) - * @param background The background color - */ -function createTestCase(foreground: string, background: string) { - const wrapper = document.createElement('div') - wrapper.style.padding = '14px' - wrapper.style.color = `var(--${foreground})` - wrapper.style.backgroundColor = `var(--${background})` - if (background.includes('blur')) { - wrapper.style.backdropFilter = 'var(--filter-background-blur)' - } - - const testCase = document.createElement('div') - testCase.innerText = `${foreground} ${background}` - testCase.setAttribute('data-cy-testcase', '') - - wrapper.appendChild(testCase) - return wrapper -} - -describe('Accessibility of Nextcloud theming colors', () => { - for (const theme of themesToTest) { - context(`Theme: ${theme}`, () => { - before(() => { - cy.createRandomUser().then(($user) => { - // set user theme - cy.runOccCommand(`user:setting -- '${$user.userId}' theming enabled-themes '[\\"${theme}\\"]'`) - cy.login($user) - cy.visit('/') - cy.injectAxe({ axeCorePath: 'node_modules/axe-core/axe.min.js' }) - }) - }) - - beforeEach(() => { - cy.document().then((doc) => { - // Unset background image and thus use background-color for testing blur background (images do not work with axe-core) - doc.body.style.backgroundImage = 'unset' - - const root = doc.querySelector('#content') - if (root === null) { - throw new Error('No test root found') - } - root.innerHTML = '' - }) - }) - - for (const [name, { backgroundColors, foregroundColors }] of Object.entries(testCases)) { - context(`Accessibility of CSS color variables for ${name}`, () => { - for (const foreground of foregroundColors) { - for (const background of backgroundColors) { - it(`color contrast of ${foreground} on ${background}`, () => { - cy.document().then((doc) => { - const element = createTestCase(foreground, background) - const root = doc.querySelector('#content') - - expect(root).not.to.be.undefined - - root!.appendChild(element) - - cy.checkA11y('[data-cy-testcase]', { - runOnly: ['color-contrast'], - }) - }) - }) - } - } - }) - } - }) - } -}) diff --git a/cypress/e2e/theming/admin-settings_background.cy.ts b/cypress/e2e/theming/admin-settings_background.cy.ts deleted file mode 100644 index 52c27aaed8a76..0000000000000 --- a/cypress/e2e/theming/admin-settings_background.cy.ts +++ /dev/null @@ -1,385 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { NavigationHeader } from '../../pages/NavigationHeader.ts' -import { - defaultBackground, - defaultPrimary, - pickColor, - validateBodyThemingCss, - validateUserThemingDefaultCss, -} from './themingUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Remove the default background and restore it', { testIsolation: false }, function() { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - cy.findByRole('checkbox', { name: /remove background image/i }) - .should('exist') - .should('not.be.checked') - .check({ force: true }) - - cy.wait('@removeBackground') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null)) - cy.waitUntil(() => cy.window().then((win) => { - const backgroundPlain = getComputedStyle(win.document.body).getPropertyValue('--image-background') - return backgroundPlain !== '' - })) - }) - - it('Screenshot the login page and validate login page', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null)) - cy.screenshot() - }) - - it('Undo theming settings and validate login page again', function() { - cy.resetAdminTheming() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss()) - cy.screenshot() - }) -}) - -describe('Remove the default background with a custom background color', function() { - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Change the background color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - pickColor(cy.findByRole('button', { name: /Background color/ })) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - defaultBackground, - selectedColor, - )) - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - - cy.findByRole('checkbox', { name: /remove background image/i }) - .should('exist') - .should('not.be.checked') - .check({ force: true }) - cy.wait('@removeBackground') - }) - - it('Screenshot the login page and validate login page', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null, selectedColor)) - cy.screenshot() - }) - - it('Undo theming settings and validate login page again', function() { - cy.resetAdminTheming() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss()) - cy.screenshot() - }) -}) - -describe('Remove the default background with a bright color', function() { - const navigationHeader = new NavigationHeader() - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.resetUserTheming(admin) - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - cy.findByRole('checkbox', { name: /remove background image/i }) - .check({ force: true }) - cy.wait('@removeBackground') - }) - - it('Change the background color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - pickColor(cy.findByRole('button', { name: /Background color/ }), 4) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null, selectedColor)) - }) - - it('See the header being inverted', function() { - // Probe the Nextcloud logo: it carries the same - // `var(--background-image-invert-if-bright)` filter and is always - // present in the header. The waffle launcher's current-app icon only - // renders when an app is active, which isn't the case on settings, - // and the in-popover tiles use a fixed brightness/invert filter - // regardless of theme so they're not a valid inversion probe. - cy.waitUntil(() => navigationHeader - .logo() - .find('.logo') - .then((el) => { - let ret = true - el.each(function() { - ret = ret && window.getComputedStyle(this).filter === 'invert(1)' - }) - return ret - })) - }) -}) - -describe('Disable user theming and enable it back', function() { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Disable user background theming', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('disableUserTheming') - - cy.findByRole('checkbox', { name: /Disable user theming/ }) - .should('exist') - .and('not.be.checked') - .check({ force: true }) - - cy.wait('@disableUserTheming') - }) - - it('Login as user', function() { - cy.logout() - cy.createRandomUser().then((user) => { - cy.login(user) - }) - }) - - it('User cannot not change background settings', function() { - cy.visit('/settings/user/theming') - cy.contains('Customization has been disabled by your administrator').should('exist') - }) -}) - -describe('The user default background settings reflect the admin theming settings', function() { - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - after(function() { - cy.resetAdminTheming() - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Change the default background', function() { - cy.intercept('*/apps/theming/ajax/uploadImage').as('setBackground') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - cy.fixture('image.jpg', null).as('background') - cy.get('input[type="file"][name="background"]') - .should('exist') - .selectFile('@background', { force: true }) - - cy.wait('@setBackground') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - '/apps/theming/image/background?v=', - null, - )) - }) - - it('Change the background color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - cy.intercept('*/apps/theming/theme/default.css?*').as('cssLoaded') - - pickColor(cy.findByRole('button', { name: /Background color/ })) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.wait('@cssLoaded') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - '/apps/theming/image/background?v=', - selectedColor, - )) - }) - - it('Login page should match admin theming settings', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss( - defaultPrimary, - '/apps/theming/image/background?v=', - selectedColor, - )) - }) - - it('Login as user', function() { - cy.createRandomUser().then((user) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .scrollIntoView() - }) - - it('Default user background settings should match admin theming settings', function() { - cy.findByRole('button', { name: 'Default background' }) - .should('exist') - .and('have.attr', 'aria-pressed', 'true') - - cy.window() - .should(() => validateUserThemingDefaultCss( - selectedColor, - '/apps/theming/image/background?v=', - )) - }) -}) - -describe('The user default background settings reflect the admin theming settings with background removed', function() { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - after(function() { - cy.resetAdminTheming() - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Remove the default background', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('removeBackground') - cy.findByRole('checkbox', { name: /remove background image/i }) - .check({ force: true }) - cy.wait('@removeBackground') - }) - - it('Login page should match admin theming settings', function() { - cy.logout() - cy.visit('/') - - cy.window() - .should(() => validateBodyThemingCss(defaultPrimary, null)) - }) - - it('Login as user', function() { - cy.createRandomUser().then((user) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .scrollIntoView() - }) - - it('Default user background settings should match admin theming settings', function() { - cy.findByRole('button', { name: 'Default background' }) - .should('exist') - .and('have.attr', 'aria-pressed', 'true') - - cy.window() - .should(() => validateUserThemingDefaultCss(defaultPrimary, null)) - }) -}) diff --git a/cypress/e2e/theming/admin-settings_branding.cy.ts b/cypress/e2e/theming/admin-settings_branding.cy.ts deleted file mode 100644 index 1f21045edf174..0000000000000 --- a/cypress/e2e/theming/admin-settings_branding.cy.ts +++ /dev/null @@ -1,219 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' - -const admin = new User('admin', 'admin') - -describe('Admin theming: Setting custom project URLs', function() { - this.beforeEach(() => { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - cy.visit('/settings/admin/theming') - cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming') - }) - - it('Setting the web link', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/path?query#fragment{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/path?query#fragment') - }) - - it('Setting the legal notice link', () => { - cy.findByRole('textbox', { name: /legal notice link/i }) - .should('exist') - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .type('http://example.com/path?query#fragment{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', /legal notice/i) - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/path?query#fragment') - }) - - it('Setting the privacy policy link', () => { - cy.findByRole('textbox', { name: /privacy policy link/i }) - .should('exist') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('have.attr', 'type', 'url') - .type('http://privacy.local/path?query#fragment{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', /privacy policy/i) - .should('be.visible') - .and('have.attr', 'href', 'http://privacy.local/path?query#fragment') - }) -}) - -describe('Admin theming: Web link corner cases', function() { - this.beforeEach(() => { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - cy.visit('/settings/admin/theming') - cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming') - }) - - it('Already URL encoded', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/%22path%20with%20space%22{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/%22path%20with%20space%22') - }) - - it('URL with double quotes', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/"path"{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/%22path%22') - }) - - it('URL with double quotes and already encoded', () => { - cy.findByRole('textbox', { name: /web link/i }) - .and('have.attr', 'type', 'url') - .as('input') - .scrollIntoView() - cy.get('@input') - .should('be.visible') - .type('{selectAll}http://example.com/"the%20path"{enter}') - - cy.wait('@updateTheming') - - cy.logout() - - cy.visit('/') - cy.contains('a', 'Nextcloud') - .should('be.visible') - .and('have.attr', 'href', 'http://example.com/%22the%20path%22') - }) -}) - -describe('Admin theming: Change the login fields then reset them', function() { - const name = 'ABCdef123' - const url = 'https://example.com' - const slogan = 'Testing is fun' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: /^Theming/, level: 2 }) - .should('exist') - .scrollIntoView() - }) - - it('Change the name field', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('updateFields') - - // Name - cy.findByRole('textbox', { name: 'Name' }) - .should('be.visible') - .type(`{selectall}${name}{enter}`) - cy.wait('@updateFields') - - // Url - cy.findByRole('textbox', { name: 'Web link' }) - .should('be.visible') - .type(`{selectall}${url}{enter}`) - cy.wait('@updateFields') - - // Slogan - cy.findByRole('textbox', { name: 'Slogan' }) - .should('be.visible') - .type(`{selectall}${slogan}{enter}`) - cy.wait('@updateFields') - }) - - it('Ensure undo button presence', function() { - cy.findAllByRole('button', { name: /undo changes/i }) - .should('have.length', 3) - }) - - it('Validate login screen changes', function() { - cy.logout() - cy.visit('/') - - cy.get('[data-login-form-headline]').should('contain.text', name) - cy.get('footer p a').should('have.text', name) - cy.get('footer p a').should('have.attr', 'href', url) - cy.get('footer p').should('contain.text', `– ${slogan}`) - }) - - it('Undo theming settings', function() { - cy.login(admin) - cy.visit('/settings/admin/theming') - cy.findAllByRole('button', { name: /undo changes/i }) - .each((button) => { - cy.intercept('*/apps/theming/ajax/undoChanges').as('undoField') - cy.wrap(button).click() - cy.wait('@undoField') - }) - cy.logout() - }) - - it('Validate login screen changes again', function() { - cy.visit('/') - - cy.get('[data-login-form-headline]').should('not.contain.text', name) - cy.get('footer p a').should('not.have.text', name) - cy.get('footer p a').should('not.have.attr', 'href', url) - cy.get('footer p').should('not.contain.text', `– ${slogan}`) - }) -}) diff --git a/cypress/e2e/theming/admin-settings_colors.cy.ts b/cypress/e2e/theming/admin-settings_colors.cy.ts deleted file mode 100644 index 6651c3a4714ca..0000000000000 --- a/cypress/e2e/theming/admin-settings_colors.cy.ts +++ /dev/null @@ -1,67 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { - defaultBackground, - defaultPrimary, - pickColor, - validateBodyThemingCss, -} from './themingUtils.ts' - -const admin = new User('admin', 'admin') - -describe('Change the primary color and reset it', function() { - let selectedColor = '' - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the admin theming section', function() { - cy.visit('/settings/admin/theming') - cy.findByRole('heading', { name: 'Background and color' }) - .should('exist') - .scrollIntoView() - }) - - it('Change the primary color', function() { - cy.intercept('*/apps/theming/ajax/updateStylesheet').as('setColor') - - pickColor(cy.findByRole('button', { name: /Primary color/ })) - .then((color) => { - selectedColor = color - }) - - cy.wait('@setColor') - cy.waitUntil(() => validateBodyThemingCss( - selectedColor, - defaultBackground, - defaultPrimary, - )) - }) - - it('Screenshot the login page and validate login page', function() { - cy.logout() - cy.visit('/') - - cy.waitUntil(() => validateBodyThemingCss( - selectedColor, - defaultBackground, - defaultPrimary, - )) - cy.screenshot() - }) - - it('Undo theming settings and validate login page again', function() { - cy.resetAdminTheming() - cy.visit('/') - - cy.waitUntil(validateBodyThemingCss) - cy.screenshot() - }) -}) diff --git a/cypress/e2e/theming/admin-settings_default-app.cy.ts b/cypress/e2e/theming/admin-settings_default-app.cy.ts deleted file mode 100644 index a11928d21a770..0000000000000 --- a/cypress/e2e/theming/admin-settings_default-app.cy.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { NavigationHeader } from '../../pages/NavigationHeader.ts' - -const admin = new User('admin', 'admin') - -after(() => cy.resetAdminTheming()) - -describe('Admin theming set default apps', () => { - const navigationHeader = new NavigationHeader() - - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the current default app is the dashboard', () => { - // check default route - cy.visit('/') - cy.url().should('match', /apps\/dashboard/) - - // Also check the top logo link - navigationHeader.logo().click() - cy.url().should('match', /apps\/dashboard/) - }) - - it('See the default app settings', () => { - cy.visit('/settings/admin/theming') - - cy.get('.settings-section').contains('Navigation bar settings').should('exist') - getDefaultAppSwitch().should('exist') - getDefaultAppSwitch().scrollIntoView() - }) - - it('Toggle the "use custom default app" switch', () => { - getDefaultAppSwitch().should('not.be.checked') - cy.findByRole('region', { name: 'Global default app' }) - .should('not.exist') - - getDefaultAppSwitch().check({ force: true }) - getDefaultAppSwitch().should('be.checked') - cy.findByRole('region', { name: 'Global default app' }) - .should('exist') - }) - - it('See the default app combobox', () => { - cy.findByRole('region', { name: 'Global default app' }) - .should('exist') - .findByRole('combobox') - .as('defaultAppSelect') - .scrollIntoView() - - cy.get('@defaultAppSelect') - .findByText('Dashboard') - .should('be.visible') - cy.get('@defaultAppSelect') - .findByText('Files') - .should('be.visible') - }) - - it('See the default app order selector', () => { - cy.findByRole('region', { name: 'Global default app' }) - .should('exist') - cy.findByRole('list', { name: 'Navigation bar app order' }) - .should('exist') - .findAllByRole('listitem') - .should('have.length', 2) - .then((elements) => { - const appIDs = elements.map((idx, el) => el.innerText.trim()).get() - expect(appIDs).to.deep.eq(['Dashboard', 'Files']) - }) - }) - - it('Change the default app', () => { - cy.findByRole('list', { name: 'Navigation bar app order' }) - .should('exist') - .as('appOrderSelector') - .scrollIntoView() - - cy.get('@appOrderSelector') - .findAllByRole('listitem') - .filter((_, e) => !!e.innerText.match(/Files/i)) - .findByRole('button', { name: 'Move up' }) - .as('moveFilesUpButton') - - cy.get('@moveFilesUpButton').should('be.visible') - cy.get('@moveFilesUpButton').click() - cy.get('@moveFilesUpButton').should('not.exist') - }) - - it('See the default app is changed', () => { - cy.findByRole('list', { name: 'Navigation bar app order' }) - .findAllByRole('listitem') - .then((elements) => { - const appIDs = elements.map((idx, el) => el.innerText.trim()).get() - expect(appIDs).to.deep.eq(['Files', 'Dashboard']) - }) - - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/files') - }) - }) - - it('Toggle the "use custom default app" switch back to reset the default apps', () => { - cy.visit('/settings/admin/theming') - getDefaultAppSwitch().scrollIntoView() - - getDefaultAppSwitch().should('be.checked') - getDefaultAppSwitch().uncheck({ force: true }) - getDefaultAppSwitch().should('be.not.checked') - - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/dashboard') - }) - }) -}) - -function getDefaultAppSwitch() { - return cy.findByRole('checkbox', { name: 'Use custom default app' }) -} diff --git a/cypress/e2e/theming/user-settings_app-order.cy.ts b/cypress/e2e/theming/user-settings_app-order.cy.ts deleted file mode 100644 index 5098b9af4314e..0000000000000 --- a/cypress/e2e/theming/user-settings_app-order.cy.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { User } from '@nextcloud/e2e-test-server/cypress' - -import { NavigationHeader } from '../../pages/NavigationHeader.ts' -import { SettingsAppOrderList } from '../../pages/SettingsAppOrderList.ts' -import { installTestApp, uninstallTestApp } from '../../support/commonUtils.ts' - -before(() => uninstallTestApp()) - -describe('User theming set app order', () => { - const navigationHeader = new NavigationHeader() - const appOrderList = new SettingsAppOrderList() - let user: User - - before(() => { - cy.resetAdminTheming() - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => cy.deleteUser(user)) - - it('See the app order settings', () => { - visitAppOrderSettings() - }) - - it('See that the dashboard app is the first one', () => { - const appOrder = ['Dashboard', 'Files'] - appOrderList.assertAppOrder(appOrder) - - // Check the top app menu order. The launcher grid appends a synthetic - // "More apps" / "App store" tile to the user's apps, so iterate - // positionally only over the real-app prefix. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) - - it('Change the app order', () => { - appOrderList.interceptAppOrder() - appOrderList.getAppOrderList() - .scrollIntoView() - appOrderList.getUpButtonForApp('Files') - .should('be.visible') - .click() - appOrderList.waitForAppOrderUpdate() - - appOrderList.assertAppOrder(['Files', 'Dashboard']) - }) - - it('See the app menu order is changed', () => { - cy.reload() - const appOrder = ['Files', 'Dashboard'] - appOrderList.getAppOrderList() - .scrollIntoView() - appOrderList.assertAppOrder(appOrder) - - // Check the top app menu order. Idempotent open in the page object - // re-opens the popover after the reload above. The synthetic trailing - // tile is ignored by iterating only over the expected app names. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) -}) - -describe('User theming set app order with default app', () => { - const appOrderList = new SettingsAppOrderList() - const navigationHeader = new NavigationHeader() - let user: User - - before(() => { - cy.resetAdminTheming() - // install a third app - installTestApp() - // set files as default app - cy.runOccCommand('config:system:set --value \'files\' defaultapp') - - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => { - cy.deleteUser(user) - uninstallTestApp() - }) - - it('See files is the default app', () => { - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/files') - }) - }) - - it('See the app order settings: files is the first one', () => { - visitAppOrderSettings() - - const appOrder = ['Files', 'Dashboard', 'Test App 2', 'Test App'] - appOrderList.getAppOrderList() - .scrollIntoView() - appOrderList.assertAppOrder(appOrder) - }) - - it('Can not change the default app', () => { - appOrderList.getUpButtonForApp('Files').should('not.exist') - appOrderList.getDownButtonForApp('Files').should('not.exist') - appOrderList.getUpButtonForApp('Dashboard').should('not.exist') - // but can move down - appOrderList.getDownButtonForApp('Dashboard').should('be.visible') - }) - - it('Can see the correct buttons for other apps', () => { - appOrderList.getUpButtonForApp('Test App 2').should('be.visible') - appOrderList.getDownButtonForApp('Test App 2').should('be.visible') - appOrderList.getUpButtonForApp('Test App').should('be.visible') - appOrderList.getDownButtonForApp('Test App').should('not.exist') - }) - - it('Change the order of the other apps', () => { - appOrderList.interceptAppOrder() - appOrderList.getUpButtonForApp('Test App').click() - appOrderList.waitForAppOrderUpdate() - appOrderList.getUpButtonForApp('Test App').click() - appOrderList.waitForAppOrderUpdate() - - // Can't get up anymore, files is enforced as default app - appOrderList.getUpButtonForApp('Test App').should('not.exist') - - // Check the app order settings UI - appOrderList.assertAppOrder(['Files', 'Test App', 'Dashboard', 'Test App 2']) - }) - - it('See the app menu order is changed', () => { - cy.reload() - - const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2'] - // Check the top app menu order. See note above: the launcher appends - // a synthetic tile that we skip by iterating positionally. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) -}) - -describe('User theming app order list accessibility', () => { - const appOrderList = new SettingsAppOrderList() - let user: User - - before(() => { - cy.resetAdminTheming() - installTestApp() - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => { - uninstallTestApp() - cy.deleteUser(user) - }) - - it('click the first button', () => { - visitAppOrderSettings() - appOrderList.interceptAppOrder() - appOrderList.getDownButtonForApp('Dashboard') - .should('be.visible') - .scrollIntoView() - appOrderList.getDownButtonForApp('Dashboard') - .focus() - appOrderList.getDownButtonForApp('Dashboard') - .click() - appOrderList.waitForAppOrderUpdate() - }) - - it('see the same app kept the focus', () => { - appOrderList.getDownButtonForApp('Dashboard').should('have.focus') - }) - - it('click the last button', () => { - appOrderList.interceptAppOrder() - appOrderList.getUpButtonForApp('Dashboard') - .should('be.visible') - .focus() - appOrderList.getUpButtonForApp('Dashboard').click() - appOrderList.waitForAppOrderUpdate() - }) - - it('see the same app kept the focus', () => { - appOrderList.getUpButtonForApp('Dashboard').should('not.exist') - appOrderList.getDownButtonForApp('Dashboard').should('have.focus') - }) -}) - -describe('User theming reset app order', () => { - const appOrderList = new SettingsAppOrderList() - const navigationHeader = new NavigationHeader() - let user: User - - before(() => { - cy.resetAdminTheming() - // Create random user for this test - cy.createRandomUser().then(($user) => { - user = $user - cy.login($user) - }) - }) - - after(() => cy.deleteUser(user)) - - it('See that the dashboard app is the first one', () => { - visitAppOrderSettings() - - const appOrder = ['Dashboard', 'Files'] - appOrderList.assertAppOrder(appOrder) - - // Check the top app menu order. See note above on the synthetic tile. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) - - it('See the reset button is disabled', () => { - appOrderList.getResetButton() - .scrollIntoView() - appOrderList.getResetButton() - .should('be.disabled') - }) - - it('Change the app order', () => { - appOrderList.interceptAppOrder() - appOrderList.getUpButtonForApp('Files') - .should('be.visible') - .click() - appOrderList.waitForAppOrderUpdate() - - appOrderList.assertAppOrder(['Files', 'Dashboard']) - }) - - it('See the reset button is no longer disabled', () => { - appOrderList.getResetButton() - .scrollIntoView() - appOrderList.getResetButton() - .should('be.visible') - .and('be.enabled') - }) - - it('Reset the app order', () => { - cy.intercept('GET', '/ocs/v2.php/core/navigation/apps').as('loadApps') - appOrderList.interceptAppOrder() - appOrderList.getResetButton().click({ force: true }) - - cy.wait('@updateAppOrder') - .its('request.body') - .should('have.property', 'configValue', '[]') - cy.wait('@loadApps') - }) - - it('See the app order is restored', () => { - const appOrder = ['Dashboard', 'Files'] - appOrderList.assertAppOrder(appOrder) - // Check the top app menu order. See note above on the synthetic tile. - navigationHeader.getNavigationEntries().then(($entries) => { - appOrder.forEach((name, index) => { - expect($entries.eq(index)).to.contain.text(name) - }) - }) - }) - - it('See the reset button is disabled again', () => { - appOrderList.getResetButton() - .should('be.disabled') - }) -}) - -function visitAppOrderSettings() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Navigation bar settings/ }) - .should('exist') - .scrollIntoView() -} diff --git a/cypress/e2e/theming/user-settings_background.cy.ts b/cypress/e2e/theming/user-settings_background.cy.ts deleted file mode 100644 index 2ad0023047253..0000000000000 --- a/cypress/e2e/theming/user-settings_background.cy.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { User } from '@nextcloud/e2e-test-server/cypress' -import { NavigationHeader } from '../../pages/NavigationHeader.ts' -import { defaultPrimary, pickColor, validateBodyThemingCss } from './themingUtils.ts' - -const admin = new User('admin', 'admin') - -describe('User default background settings', function() { - before(function() { - cy.resetAdminTheming() - cy.resetUserTheming(admin) - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Appearance and accessibility settings/ }) - .should('be.visible') - }) - - it('Default is selected on new users', function() { - cy.findByRole('button', { name: 'Default background', pressed: true }) - .should('exist') - .scrollIntoView() - }) -}) - -describe('User select shipped backgrounds and remove background', function() { - before(function() { - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a shipped background', function() { - const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' - const backgroundName = 'Background picture of a red-ish butterfly wing under microscope' - cy.intercept('*/apps/theming/background/shipped').as('setBackground') - - // Select background - cy.findByRole('button', { name: backgroundName, pressed: false }) - .click() - cy.findByRole('button', { name: backgroundName, pressed: true }) - .should('be.visible') - - // Validate changed background and primary - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) - }) - - it('Select a bright shipped background', function() { - const background = 'bernie-cetonia-aurata-take-off-composition.jpg' - const backgroundName = 'Montage of a cetonia aurata bug that takes off with white background' - cy.intercept('*/apps/theming/background/shipped').as('setBackground') - - cy.findByRole('button', { name: backgroundName, pressed: false }) - .click() - cy.findByRole('button', { name: backgroundName, pressed: true }) - .should('be.visible') - - // Validate changed background and primary - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#56633d', background, '#dee0d3')) - }) -}) - -describe('User select a custom color', function() { - before(function() { - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a custom color', function() { - cy.intercept('*/apps/theming/background/color').as('clearBackground') - - // Clear background - pickColor(cy.findByRole('button', { name: 'Plain background' }), 7) - - // Validate clear background - cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#3794ac')) - }) -}) - -describe('User select a bright custom color and remove background', function() { - const navigationHeader = new NavigationHeader() - - before(function() { - cy.createRandomUser().then((user: User) => { - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Remove background', function() { - cy.intercept('*/apps/theming/background/color').as('clearBackground') - - // Clear background - pickColor(cy.findByRole('button', { name: 'Plain background' }), 4) - - // Validate clear background - cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#ddcb55')) - }) - - it('See the header being inverted', function() { - // Probe the Nextcloud logo: it carries the same - // `var(--background-image-invert-if-bright)` filter and is always - // present in the header. The waffle launcher's current-app icon only - // renders when an app is active, which isn't the case on settings, - // and the in-popover tiles use a fixed brightness/invert filter - // regardless of theme so they're not a valid inversion probe. - cy.waitUntil(() => navigationHeader.logo().find('.logo').then((el) => { - let ret = true - el.each(function() { - ret = ret && window.getComputedStyle(this).filter === 'invert(1)' - }) - return ret - })) - }) - - it('Select another but non-bright shipped background', function() { - const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg' - const backgroundName = 'Background picture of a red-ish butterfly wing under microscope' - cy.intercept('*/apps/theming/background/shipped').as('setBackground') - - // Select background - cy.findByRole('button', { name: backgroundName, pressed: false }) - .click() - cy.findByRole('button', { name: backgroundName, pressed: true }) - .should('be.visible') - - // Validate changed background and primary - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss('#a53c17', background, '#652e11')) - }) - - it('See the header NOT being inverted this time', function() { - // Probe the Nextcloud logo: see the inverted-header test above for - // why we don't probe the menu icons. - cy.waitUntil(() => navigationHeader.logo().find('.logo').then((el) => { - let ret = true - el.each(function() { - ret = ret && window.getComputedStyle(this).filter === 'none' - }) - return ret - })) - }) -}) - -describe('User select a custom background', function() { - const image = 'image.jpg' - before(function() { - cy.createRandomUser().then((user: User) => { - cy.uploadFile(user, image, 'image/jpeg') - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a custom background', function() { - cy.intercept('*/apps/theming/background/custom').as('setBackground') - - // Pick background - cy.findByRole('button', { name: 'Custom background' }).click() - cy.findByRole('dialog') - .should('be.visible') - .findAllByRole('row') - .contains(image) - .click() - cy.findByRole('button', { name: 'Select background' }).click() - - // Wait for background to be set - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221')) - }) -}) - -describe('User changes settings and reload the page', function() { - const image = 'image.jpg' - - before(function() { - cy.createRandomUser().then((user: User) => { - cy.uploadFile(user, image, 'image/jpeg') - cy.login(user) - }) - }) - - it('See the user background settings', function() { - cy.visit('/settings/user/theming') - cy.findByRole('heading', { name: /Background and color/ }) - .should('exist') - .scrollIntoView() - }) - - it('Select a custom background', function() { - cy.intercept('*/apps/theming/background/custom').as('setBackground') - - // Pick background - cy.findByRole('button', { name: 'Custom background' }).click() - cy.findByRole('dialog') - .should('be.visible') - .findAllByRole('row') - .contains(image) - .click() - cy.findByRole('button', { name: 'Select background' }).click() - - // Wait for background to be set - cy.wait('@setBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, 'apps/theming/background?v=', '#2f2221')) - }) - - it('Select a custom background color', function() { - cy.intercept('*/apps/theming/background/color').as('clearBackground') - - // Clear background - pickColor(cy.findByRole('button', { name: 'Plain background' }), 5) - - // Validate clear background - cy.wait('@clearBackground') - cy.waitUntil(() => validateBodyThemingCss(defaultPrimary, null, '#a5b872')) - }) - - it('Select a custom primary color', function() { - cy.intercept('/ocs/v2.php/apps/provisioning_api/api/v1/config/users/theming/primary_color').as('setPrimaryColor') - - pickColor(cy.findByRole('button', { name: 'Primary color' }), 2) - - cy.wait('@setPrimaryColor') - cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872')) - }) - - it('Reload the page and validate persistent changes', function() { - cy.reload() - cy.waitUntil(() => validateBodyThemingCss('#c98879', null, '#a5b872')) - }) -}) diff --git a/tests/playwright/e2e/appstore/admin-settings-apps.spec.ts b/tests/playwright/e2e/appstore/admin-settings-apps.spec.ts new file mode 100644 index 0000000000000..d318c3283affc --- /dev/null +++ b/tests/playwright/e2e/appstore/admin-settings-apps.spec.ts @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test } from '../../support/fixtures/admin-appstore-page.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' +import { runOcc } from '@nextcloud/e2e-test-server' + +test.describe('Settings: App management', () => { + test.beforeEach(async ({ page, appstorePage }) => { + // Disable QA testing app if already enabled + expect(await runOcc(['app:disable', 'testing'])) + .toMatch(/(No such app enabled|testing .+ disabled)/) + // Enable update notification app if disabled + expect(await runOcc(['app:enable', 'updatenotification'])) + .toMatch(/(updatenotification already enabled|updatenotification .+ enabled)/) + + // Open the installed apps page + await appstorePage.openInstalledApps() + + // Wait for the apps table to load + await appstorePage.appsTable().waitFor({ state: 'visible', timeout: 10000 }) + }) + + test('Can enable an installed app', async ({ page, appstorePage }) => { + // Intercept the enable app request + const enableRequest = page.waitForResponse( + (response) => response.url().includes('/ocs/v2.php/apps/appstore/api/v1/apps/enable'), + ) + + // Find and click the enable button for the QA testing app + await expect(appstorePage.appsTable()).toBeVisible() + const qaTestingRow = appstorePage.appRow('QA testing') + await expect(qaTestingRow).toBeVisible({ timeout: 10000 }) + + await appstorePage.enableButton('QA testing').click({ force: true }) + + // Handle password confirmation if needed + await handlePasswordConfirmation(page, 'admin') + + // Wait for the API request + await enableRequest + + // Wait until we see the disable button for the app + await expect(appstorePage.appsTable()).toBeVisible() + await expect(appstorePage.appRow('QA testing')).toBeVisible() + await expect(appstorePage.disableButton('QA testing')).toBeVisible() + + // Change to enabled apps view + await appstorePage.openEnabledApps() + + // Verify the app appears in the enabled list + await expect(appstorePage.appRow('QA testing')).toBeVisible() + }) + + test('Can disable an installed app', async ({ page, appstorePage }) => { + // Intercept the disable app request + const disableRequest = page.waitForResponse( + (response) => response.url().includes('/ocs/v2.php/apps/appstore/api/v1/apps/disable'), + ) + + // Find and click the disable button for the Update notification app + await expect(appstorePage.appsTable()).toBeVisible() + const updateRow = appstorePage.appRow('Update notification') + await expect(updateRow).toBeVisible({ timeout: 10000 }) + + await appstorePage.disableButton('Update notification').click({ force: true }) + + // Handle password confirmation if needed + await handlePasswordConfirmation(page, 'admin') + + // Wait for the API request + await disableRequest + + // Wait until we see the enable button for the app + await expect(appstorePage.appsTable()).toBeVisible() + await expect(appstorePage.appRow('Update notification')).toBeVisible() + await expect(appstorePage.enableButton('Update notification')).toBeVisible() + + // Change to disabled apps view + await appstorePage.openDisabledApps() + + // Verify the app appears in the disabled list + await expect(appstorePage.appRow('Update notification')).toBeVisible() + }) + + test('Browse enabled apps', async ({ appstorePage }) => { + // Open the "Active apps" section + await appstorePage.openEnabledApps() + + // Verify the URL is correct + await expect(appstorePage.navigationLink('Active apps')).toHaveAttribute('aria-current', 'page') + + // Verify that there are only enabled apps (all have "Disable" button, no "Enable" button) + await expect(appstorePage.appsTable()).toBeVisible() + + // Get all rows and verify each has a disable button and no enable button + const rows = appstorePage.appsTable().locator('tr') + const rowCount = await rows.count() + + for (let i = 1; i < rowCount; i++) { // Skip header row + const row = rows.nth(i) + const enableButton = row.getByRole('button', { name: 'Enable' }) + + // Enabled apps should not have an "Enable" button + await expect(enableButton).not.toBeVisible() + } + }) + + test('Browse disabled apps', async ({ appstorePage }) => { + // Open the "Disabled apps" section + await appstorePage.openDisabledApps() + + // Verify the current section is "Disabled apps" + await expect(appstorePage.navigationLink('Disabled apps')).toHaveAttribute('aria-current', 'page') + + // Verify that there are only disabled apps (all have "Enable" button, no "Disable" button) + await expect(appstorePage.appsTable()).toBeVisible() + + // Get all rows and verify each has an enable button and no disable button + const rows = appstorePage.appsTable().locator('tr') + const rowCount = await rows.count() + + for (let i = 1; i < rowCount; i++) { // Skip header row + const row = rows.nth(i) + const disableButton = row.getByRole('button', { name: 'Disable' }) + + // Disabled apps should not have a "Disable" button + await expect(disableButton).not.toBeVisible() + } + }) + + test('Browse app bundles', async ({ appstorePage }) => { + // Open the "App bundles" section + await appstorePage.openBundles() + + // Verify the current section is "App bundles" + await expect(appstorePage.navigationLink('App bundles')).toHaveAttribute('aria-current', 'page') + + // Verify we see the app bundles + await expect(appstorePage.enterpriseBundleHeading()).toBeVisible() + await expect(appstorePage.educationBundleHeading()).toBeVisible() + }) + + test('View app details', async ({ appstorePage }) => { + // Click on the "QA testing" app + await appstorePage.appLink('QA testing').click({ force: true }) + + // Verify the app details sidebar is shown + const sidebar = appstorePage.appSidebar() + await expect(sidebar).toBeVisible() + await expect(appstorePage.appSidebarHeader()).toContainText('QA testing') + + // Verify the sidebar contains expected elements + await expect(appstorePage.viewInStoreLink()).toBeVisible() + await expect(appstorePage.appSidebarEnableButton()).toBeVisible() + await expect(appstorePage.removeButton()).toBeVisible() + + // Verify version information is displayed + await expect(appstorePage.versionText()).toBeVisible() + }) + + test('Limit app usage to group', async ({ appstorePage, page }) => { + // Open the "Active apps" section + await appstorePage.openEnabledApps() + + // Select the updatenotification app + await appstorePage.appLink('Update Notification').scrollIntoViewIfNeeded() + await appstorePage.appLink('Update Notification').click() + + // Click the "Limit to groups" button + await appstorePage.limitToGroupsButton().click() + + // The dialog should be visible + const dialog = appstorePage.groupDialog() + await expect(dialog).toBeVisible() + + // Type "admin" in the search field + const searchInput = appstorePage.groupSearchInput() + await expect(searchInput).toBeFocused() + await searchInput.fill('admin') + + // Select the admin option from the dropdown + await appstorePage.groupOption('admin').click() + + // Click the Save button + await appstorePage.dialogSaveButton().click() + + // Handle password confirmation + await handlePasswordConfirmation(page, 'admin') + + // Verify the group is now in the "Limited to groups" list + const limitedList = appstorePage.limitedToGroupsList() + await expect(limitedList).toBeVisible() + await expect(limitedList.getByRole('listitem', { name: /admin/ })).toBeVisible() + + // Now disable the group limitation + await appstorePage.limitToGroupsButton().click() + + // The dialog should be visible again + await expect(dialog).toBeVisible() + + // Click the deselect button for the admin group + await appstorePage.deselectGroupButton('admin').click() + + // Click Save + await appstorePage.dialogSaveButton().click() + + // Handle password confirmation + await handlePasswordConfirmation(page, 'admin') + + // Verify the "Limited to groups" list is no longer visible + await expect(appstorePage.limitedToGroupsList()).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/theming/admin-settings-branding.spec.ts b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts index d78409a26d035..fe6196bc832dc 100644 --- a/tests/playwright/e2e/theming/admin-settings-branding.spec.ts +++ b/tests/playwright/e2e/theming/admin-settings-branding.spec.ts @@ -10,8 +10,6 @@ import { test } from '../../support/fixtures/admin-theming-page.ts' const admin = new User('admin', 'admin') test.describe('Admin theming branding settings', () => { - test.describe.configure({ mode: 'serial' }) - test.beforeEach(async ({ adminThemingPage }) => { await adminThemingPage.reset() await adminThemingPage.open() diff --git a/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts index b82a1fa5316f8..30807476704fb 100644 --- a/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts +++ b/tests/playwright/e2e/theming/admin-settings-default-app.spec.ts @@ -44,8 +44,8 @@ test.describe('Admin theming set default apps', () => { await expect(adminThemingPage.defaultAppSwitch()).toBeChecked() await expect(adminThemingPage.defaultAppRegion()).toBeVisible() - await expect(adminThemingPage.defaultAppSelect().getByText('Dashboard')).toBeVisible() - await expect(adminThemingPage.defaultAppSelect().getByText('Files')).toBeVisible() + await expect(adminThemingPage.defaultAppSelectedValue('Dashboard')).toBeVisible() + await expect(adminThemingPage.defaultAppSelectedValue('Files')).toBeVisible() await expect(adminThemingPage.appOrderEntries()).toHaveCount(2) await expect(adminThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard') diff --git a/tests/playwright/e2e/theming/user-settings-app-order.spec.ts b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts index 305334635d687..8a5ac4a3c850b 100644 --- a/tests/playwright/e2e/theming/user-settings-app-order.spec.ts +++ b/tests/playwright/e2e/theming/user-settings-app-order.spec.ts @@ -18,6 +18,7 @@ test('User can change personal app order', async ({ page }) => { await expect(userThemingPage.appOrderEntries().nth(0)).toContainText('Dashboard') await expect(userThemingPage.appOrderEntries().nth(1)).toContainText('Files') + await navigationHeader.openMenu() await expect(navigationHeader.navigationEntries().nth(0)).toContainText('Dashboard') await expect(navigationHeader.navigationEntries().nth(1)).toContainText('Files') @@ -39,6 +40,7 @@ test('User can change personal app order', async ({ page }) => { const reloadedOrder = (await userThemingPage.appOrderEntries().allInnerTexts()).map((entry) => entry.trim()) expect(reloadedOrder).toContain('Dashboard') expect(reloadedOrder).toContain('Files') + await navigationHeader.openMenu() await expect(navigationHeader.navigationEntries().nth(0)).toContainText(reloadedOrder[0]!) await expect(navigationHeader.navigationEntries().nth(1)).toContainText(reloadedOrder[1]!) }) diff --git a/tests/playwright/support/fixtures/admin-appstore-page.ts b/tests/playwright/support/fixtures/admin-appstore-page.ts new file mode 100644 index 0000000000000..b4fea5fac0c7f --- /dev/null +++ b/tests/playwright/support/fixtures/admin-appstore-page.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as adminSessionTest } from './admin-session.ts' +import { AppstorePage } from '../sections/AppstorePage.ts' + +export const test = adminSessionTest.extend<{ appstorePage: AppstorePage }>({ + appstorePage: async ({ page }, use) => { + const appstorePage = new AppstorePage(page) + await use(appstorePage) + }, +}) diff --git a/tests/playwright/support/sections/AdminThemingPage.ts b/tests/playwright/support/sections/AdminThemingPage.ts index 44520c5a640d7..7e445f3fb7755 100644 --- a/tests/playwright/support/sections/AdminThemingPage.ts +++ b/tests/playwright/support/sections/AdminThemingPage.ts @@ -24,7 +24,7 @@ export class AdminThemingPage { }) const requestToken = (await tokenResponse.json()).token - const response = await this.page.request.post('/apps/theming/ajax/undoAllChanges', { + const response = await this.page.request.post('./apps/theming/ajax/undoAllChanges', { headers: { requesttoken: requestToken, }, @@ -44,7 +44,15 @@ export class AdminThemingPage { } defaultAppSelect(): Locator { - return this.defaultAppRegion().getByRole('combobox') + // NcSelect appends the dropdown listbox to (appendToBody: true), so it cannot + // be reached via a scoped locator. Return the selected-options wrapper instead, which + // stays inline and contains the visible selected-value tag spans. + return this.defaultAppRegion().locator('.vs__selected-options') + } + + defaultAppSelectedValue(name: string): Locator { + // NcSelect renders each selected value as a tag with a "Deselect " button. + return this.defaultAppRegion().getByRole('button', { name: `Deselect ${name}` }) } appOrderList(): Locator { diff --git a/tests/playwright/support/sections/AppstorePage.ts b/tests/playwright/support/sections/AppstorePage.ts new file mode 100644 index 0000000000000..053e7e0c04315 --- /dev/null +++ b/tests/playwright/support/sections/AppstorePage.ts @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +export class AppstorePage { + constructor(private readonly page: Page) {} + + /** + * Opens the main appstore page + */ + async openAppstore() { + await this.page.goto('settings/apps') + await this.appsTable().waitFor({ state: 'visible' }) + } + + /** + * Opens the installed apps page + */ + async openInstalledApps() { + await this.page.goto('settings/apps/installed') + await this.appsTable().waitFor({ state: 'visible' }) + } + + /** + * Opens the enabled apps page + */ + async openEnabledApps() { + await this.navigationLink('Active apps').click() + await this.page.waitForURL(/settings\/apps\/enabled$/) + } + + /** + * Opens the disabled apps page + */ + async openDisabledApps() { + await this.navigationLink('Disabled apps').click() + await this.page.waitForURL(/settings\/apps\/disabled$/) + } + + /** + * Opens the app bundles page + */ + async openBundles() { + await this.navigationLink('App bundles').click() + await this.page.waitForURL(/settings\/apps\/bundles$/) + } + + /** + * Gets the apps table element + */ + appsTable(): Locator { + return this.page.getByRole('table') + } + + /** + * Gets a specific app row by app name + */ + appRow(appName: string): Locator { + return this.appsTable().locator('tr').filter({ hasText: appName }).first() + } + + /** + * Gets the enable button for a specific app + */ + enableButton(appName: string): Locator { + return this.appRow(appName).getByRole('button', { name: 'Enable' }) + } + + /** + * Gets the disable button for a specific app + */ + disableButton(appName: string): Locator { + return this.appRow(appName).getByRole('button', { name: 'Disable' }) + } + + /** + * Gets the app link in the table + */ + appLink(appName: string): Locator { + return this.appsTable().getByRole('link', { name: appName }) + } + + /** + * Gets the navigation link in the appstore sidebar + */ + navigationLink(name: string): Locator { + return this.page.getByRole('navigation', { name: 'Appstore categories' }).getByRole('link', { name }) + } + + /** + * Gets the app sidebar + */ + appSidebar(): Locator { + return this.page.locator('#app-sidebar-vue') + } + + /** + * Gets the app sidebar header + */ + appSidebarHeader(): Locator { + return this.appSidebar().locator('.app-sidebar-header__info') + } + + /** + * Gets the "Enable" button in the app sidebar (not the table row). + * Use this when checking the sidebar after clicking an app link. + */ + appSidebarEnableButton(): Locator { + return this.appSidebar().getByRole('button', { name: 'Enable' }) + } + + /** + * Gets the "View in store" link in the sidebar + */ + viewInStoreLink(): Locator { + return this.appSidebar().getByRole('link', { name: 'View in store' }) + } + + /** + * Gets the "Remove" button in the sidebar + */ + removeButton(): Locator { + return this.appSidebar().getByRole('button', { name: 'Remove' }) + } + + /** + * Gets the "Limit to groups" button + */ + limitToGroupsButton(): Locator { + return this.appSidebar().getByRole('button', { name: 'Limit to groups' }) + } + + /** + * Gets the "Limited to groups" list + */ + limitedToGroupsList(): Locator { + return this.appSidebar().getByRole('list', { name: 'Limited to groups' }) + } + + /** + * Gets the group dialog + */ + groupDialog(): Locator { + return this.page.getByRole('dialog') + } + + /** + * Gets the save button in the dialog + */ + dialogSaveButton(): Locator { + return this.groupDialog().getByRole('button', { name: 'Save' }) + } + + /** + * Gets the deselect button for a group + */ + deselectGroupButton(groupName: string): Locator { + return this.groupDialog().getByRole('button', { name: `Deselect ${groupName}` }) + } + + /** + * Gets the group search input. + * NcSelectUsers uses role="combobox" on the search input, not role="textbox". + */ + groupSearchInput(): Locator { + return this.groupDialog().locator('input').first() + } + + /** + * Gets the enterprise bundle heading + */ + enterpriseBundleHeading(): Locator { + return this.page.getByRole('heading', { name: 'Enterprise bundle' }) + } + + /** + * Gets the education bundle heading + */ + educationBundleHeading(): Locator { + return this.page.getByRole('heading', { name: 'Education bundle' }) + } + + /** + * Gets the version text from sidebar + */ + versionText(): Locator { + return this.appSidebar().getByText(/Version \d+\.\d+\.\d+/) + } + + /** + * Gets a group option from the dropdown + */ + groupOption(groupName: string): Locator { + return this.page.getByRole('option', { name: new RegExp(groupName) }) + } +} diff --git a/tests/playwright/support/sections/NavigationHeaderPage.ts b/tests/playwright/support/sections/NavigationHeaderPage.ts index 543fccd830480..eee46402c3d7c 100644 --- a/tests/playwright/support/sections/NavigationHeaderPage.ts +++ b/tests/playwright/support/sections/NavigationHeaderPage.ts @@ -17,10 +17,34 @@ export class NavigationHeaderPage { } navigation(): Locator { - return this.header.getByRole('navigation', { name: 'Applications menu' }) + return this.header.getByRole('navigation', { name: 'Applications' }) } + private waffleButton(): Locator { + return this.navigation().locator('.app-menu__waffle') + } + + /** + * Open the waffle launcher popover. + * The app entries only exist in the DOM while the popover is open. + */ + async openMenu(): Promise { + const isOpen = await this.waffleButton().getAttribute('aria-expanded') === 'true' + if (!isOpen) { + await this.waffleButton().click() + } + await this.popover().waitFor({ state: 'visible' }) + } + + popover(): Locator { + return this.page.locator('[role="menu"][aria-label="Apps"]') + } + + /** + * Returns navigation entries from the waffle popover. + * Call {@link openMenu} first — entries are only in the DOM while the popover is open. + */ navigationEntries(): Locator { - return this.navigation().getByRole('listitem') + return this.popover().getByRole('menuitem') } } diff --git a/tests/playwright/support/utils/password-confirmation.ts b/tests/playwright/support/utils/password-confirmation.ts new file mode 100644 index 0000000000000..75f26b8536684 --- /dev/null +++ b/tests/playwright/support/utils/password-confirmation.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +/** + * Handle the password confirmation dialog if it appears + * + * @param page - The Playwright page object + * @param password - The password to enter (default: 'admin') + */ +export async function handlePasswordConfirmation(page: Page, password = 'admin') { + const dialog = page.locator('.modal-container:has-text("Authentication required")') + + try { + // Check if the dialog exists within a short timeout + const dialogVisible = await dialog.isVisible({ timeout: 500 }).catch(() => false) + + if (dialogVisible) { + // Fill the password field + await dialog.locator('input[type="password"]').fill(password) + + // Click the confirm button + await dialog.getByRole('button', { name: 'Confirm' }).click() + + // Wait for the dialog to disappear + await dialog.waitFor({ state: 'hidden' }) + } + } catch (error) { + // Dialog didn't appear, which is fine - some operations might not require confirmation + } +} From a21c2fddd6b2595f6d882a135a4491bd2203d9a6 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 8 Jun 2026 17:41:07 +0200 Subject: [PATCH 4/4] test(files): migrate more tests from Cypress to Playwright Signed-off-by: Ferdinand Thiessen --- cypress/e2e/files/files-delete.cy.ts | 70 ----- cypress/e2e/files/files-navigation.cy.ts | 56 ---- cypress/e2e/files/files-renaming.cy.ts | 288 ------------------ cypress/e2e/files/files-sidebar.cy.ts | 137 --------- .../playwright/e2e/files/files-delete.spec.ts | 60 ++++ .../e2e/files/files-navigation.spec.ts | 51 ++++ .../e2e/files/files-renaming.spec.ts | 242 +++++++++++++++ .../e2e/files/files-sidebar.spec.ts | 112 +++++++ .../playwright/support/fixtures/files-page.ts | 42 +++ tests/playwright/support/matchers.ts | 65 ++++ .../support/sections/FilesListPage.ts | 78 +++++ .../support/sections/FilesSidebarPage.ts | 18 ++ tests/playwright/support/utils/dav.ts | 98 ++++++ 13 files changed, 766 insertions(+), 551 deletions(-) delete mode 100644 cypress/e2e/files/files-delete.cy.ts delete mode 100644 cypress/e2e/files/files-navigation.cy.ts delete mode 100644 cypress/e2e/files/files-renaming.cy.ts delete mode 100644 cypress/e2e/files/files-sidebar.cy.ts create mode 100644 tests/playwright/e2e/files/files-delete.spec.ts create mode 100644 tests/playwright/e2e/files/files-navigation.spec.ts create mode 100644 tests/playwright/e2e/files/files-renaming.spec.ts create mode 100644 tests/playwright/e2e/files/files-sidebar.spec.ts create mode 100644 tests/playwright/support/fixtures/files-page.ts create mode 100644 tests/playwright/support/matchers.ts create mode 100644 tests/playwright/support/sections/FilesListPage.ts create mode 100644 tests/playwright/support/sections/FilesSidebarPage.ts create mode 100644 tests/playwright/support/utils/dav.ts diff --git a/cypress/e2e/files/files-delete.cy.ts b/cypress/e2e/files/files-delete.cy.ts deleted file mode 100644 index b1af310d9b6dd..0000000000000 --- a/cypress/e2e/files/files-delete.cy.ts +++ /dev/null @@ -1,70 +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 { getRowForFile, navigateToFolder, selectAllFiles, triggerActionForFile, triggerSelectionAction } from './FilesUtils.ts' - -describe('files: Delete files using file actions', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - }) - }) - - it('can delete file', () => { - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - cy.login(user) - cy.visit('/apps/files') - - // The file must exist and the preview loaded as it locks the file - getRowForFile('file.txt') - .should('be.visible') - .find('.files-list__row-icon-preview--loaded') - .should('exist') - - cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') - - triggerActionForFile('file.txt', 'delete') - cy.wait('@deleteFile').its('response.statusCode').should('eq', 204) - }) - - it('can delete multiple files', () => { - cy.mkdir(user, '/root') - for (let i = 0; i < 5; i++) { - cy.uploadContent(user, new Blob([]), 'text/plain', `/root/file${i}.txt`) - } - cy.login(user) - cy.visit('/apps/files') - navigateToFolder('/root') - - // The file must exist and the preview loaded as it locks the file - cy.get('.files-list__row-icon-preview--loaded') - .should('have.length', 5) - - cy.intercept('DELETE', '**/remote.php/dav/files/**').as('deleteFile') - - // select all - selectAllFiles() - triggerSelectionAction('delete') - - // see dialog for confirmation - cy.findByRole('dialog', { name: 'Confirm deletion' }) - .findByRole('button', { name: 'Delete files' }) - .click() - - cy.wait('@deleteFile') - cy.get('@deleteFile.all') - .should('have.length', 5) - - .should((all: any) => { - for (const call of all) { - expect(call.response.statusCode).to.equal(204) - } - }) - }) -}) diff --git a/cypress/e2e/files/files-navigation.cy.ts b/cypress/e2e/files/files-navigation.cy.ts deleted file mode 100644 index 9fd74097debb4..0000000000000 --- a/cypress/e2e/files/files-navigation.cy.ts +++ /dev/null @@ -1,56 +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 { getRowForFile, navigateToFolder } from './FilesUtils.ts' - -describe('files: Navigate through folders and observe behavior', () => { - let user: User - - before(() => { - cy.createRandomUser().then(($user) => { - user = $user - cy.mkdir(user, '/foo') - cy.mkdir(user, '/foo/bar') - cy.mkdir(user, '/foo/bar/baz') - }) - }) - - it('Shows root folder and we can navigate to the last folder', () => { - cy.login(user) - cy.visit('/apps/files/') - - getRowForFile('foo').should('be.visible') - navigateToFolder('/foo/bar/baz') - - // Last folder is empty - cy.get('[data-cy-files-list-row-fileid]').should('not.exist') - }) - - it('Highlight the previous folder when navigating back', () => { - cy.go('back') - getRowForFile('baz').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - - cy.go('back') - getRowForFile('bar').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - - cy.go('back') - getRowForFile('foo').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - }) - - it('Can navigate forward again', () => { - cy.go('forward') - getRowForFile('bar').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - - cy.go('forward') - getRowForFile('baz').should('be.visible') - .invoke('attr', 'class').should('contain', 'active') - }) -}) diff --git a/cypress/e2e/files/files-renaming.cy.ts b/cypress/e2e/files/files-renaming.cy.ts deleted file mode 100644 index 200bf6779dc7e..0000000000000 --- a/cypress/e2e/files/files-renaming.cy.ts +++ /dev/null @@ -1,288 +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 { calculateViewportHeight, createFolder, getRowForFile, haveValidity, renameFile, triggerActionForFile } from './FilesUtils.ts' - -describe('files: Rename nodes', { testIsolation: true }, () => { - let user: User - - beforeEach(() => { - cy.createRandomUser().then(($user) => { - user = $user - - // remove welcome file - cy.rm(user, '/welcome.txt') - // create a file called "file.txt" - cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt') - - // login and visit files app - cy.login(user) - }) - cy.visit('/apps/files') - }) - - it('can rename a file', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}other.txt') - .should(haveValidity('')) - .type('{enter}') - - // See it is renamed - getRowForFile('other.txt').should('be.visible') - }) - - /** - * If this test gets flaky than we have a problem: - * It means that the selection is not reliable set to the basename - */ - it('only selects basename of file', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .should((el) => { - const input = el.get(0) as HTMLInputElement - expect(input.selectionStart).to.equal(0) - expect(input.selectionEnd).to.equal('file'.length) - }) - }) - - it('show validation error on file rename', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}.htaccess') - // See validity - .should(haveValidity(/reserved name/i)) - }) - - it('shows accessible loading information', () => { - const { resolve, promise } = Promise.withResolvers() - - getRowForFile('file.txt').should('be.visible') - - // intercept the rename (MOVE) - // the callback will wait until the promise resolve (so we have time to check the loading state) - cy.intercept( - 'MOVE', - /\/remote.php\/dav\/files\//, - (request) => { - // we need to wait in the onResponse handler as the intercept handler times out otherwise - request.on('response', async () => { - await promise - }) - }, - ).as('moveFile') - - // Start the renaming - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}new-name.txt{enter}') - - // Loading state is visible - getRowForFile('new-name.txt') - .findByRole('img', { name: 'File is loading' }) - .should('be.visible') - // checkbox is not visible - getRowForFile('new-name.txt') - .findByRole('checkbox', { name: /^Toggle selection/ }) - .should('not.exist') - - cy.log('Resolve promise to preoceed with MOVE request') - .then(() => resolve()) - - // Ensure the request is done (file renamed) - cy.wait('@moveFile') - - // checkbox visible again - getRowForFile('new-name.txt') - .findByRole('checkbox', { name: /^Toggle selection/ }) - .should('exist') - // see the loading state is gone - getRowForFile('new-name.txt') - .findByRole('img', { name: 'File is loading' }) - .should('not.exist') - }) - - it('cancel renaming on esc press', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}other.txt') - .should(haveValidity('')) - .type('{esc}') - - // See it is not renamed - getRowForFile('other.txt').should('not.exist') - getRowForFile('file.txt') - .should('be.visible') - .find('input[type="text"]') - .should('not.exist') - }) - - it('cancel on enter if no new name is entered', () => { - // All are visible by default - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{enter}') - - // See it is not renamed - getRowForFile('file.txt') - .should('be.visible') - .find('input[type="text"]') - .should('not.exist') - }) - - /** - * This is a regression test of: https://github.com/nextcloud/server/issues/47438 - * The issue was that the renaming state was not reset when the new name moved the file out of the view of the current files list - * due to virtual scrolling the renaming state was not changed then by the UI events (as the component was taken out of DOM before any event handling). - */ - it('correctly resets renaming state', () => { - // Create 19 additional files - for (let i = 1; i <= 19; i++) { - cy.uploadContent(user, new Blob([]), 'text/plain', `/file${i}.txt`) - } - - // Calculate and setup a viewport where only the first 4 files are visible, causing 6 rows to be rendered - cy.viewport(768, 500) - cy.login(user) - calculateViewportHeight(4) - .then((height) => cy.viewport(768, height)) - - cy.visit('/apps/files') - - getRowForFile('file.txt') - .should('be.visible') - // Z so it is shown last - renameFile('file.txt', 'zzz.txt') - // not visible any longer - getRowForFile('zzz.txt') - .should('not.exist') - // scroll file list to bottom - cy.get('[data-cy-files-list]') - .scrollTo('bottom') - cy.screenshot() - // The file is no longer in rename state - getRowForFile('zzz.txt') - .should('be.visible') - .findByRole('textbox', { name: 'Filename' }) - .should('not.exist') - }) - - it('shows warning on extension change - select new extension', () => { - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}file.md') - .type('{enter}') - - // See warning dialog - cy.findByRole('dialog', { name: 'Change file extension' }) - .should('be.visible') - .findByRole('button', { name: 'Use .md' }) - .click() - - // See it is renamed - getRowForFile('file.md').should('be.visible') - }) - - it('shows warning on extension change - select old extension', () => { - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}document.md') - .type('{enter}') - - // See warning dialog - cy.findByRole('dialog', { name: 'Change file extension' }) - .should('be.visible') - .findByRole('button', { name: 'Keep .txt' }) - .click() - - // See it is renamed - getRowForFile('document.txt').should('be.visible') - }) - - it('shows warning on extension removal', () => { - getRowForFile('file.txt').should('be.visible') - - triggerActionForFile('file.txt', 'rename') - getRowForFile('file.txt') - .findByRole('textbox', { name: 'Filename' }) - .should('be.visible') - .type('{selectAll}file') - .type('{enter}') - - cy.findByRole('dialog', { name: 'Change file extension' }) - .should('be.visible') - .findByRole('button', { name: 'Keep .txt' }) - .should('be.visible') - cy.findByRole('dialog', { name: 'Change file extension' }) - .findByRole('button', { name: 'Remove extension' }) - .should('be.visible') - .click() - - // See it is renamed - getRowForFile('file').should('be.visible') - getRowForFile('file.txt').should('not.exist') - }) - - it('does not show warning on folder renaming with a dot', () => { - createFolder('folder.2024') - - getRowForFile('folder.2024').should('be.visible') - - triggerActionForFile('folder.2024', 'rename') - getRowForFile('folder.2024') - .findByRole('textbox', { name: 'Folder name' }) - .should('be.visible') - .type('{selectAll}folder.2025') - .should(haveValidity('')) - .type('{enter}') - - // See warning dialog - cy.get('[role=dialog]').should('not.exist') - - // See it is not renamed - getRowForFile('folder.2025').should('be.visible') - }) -}) diff --git a/cypress/e2e/files/files-sidebar.cy.ts b/cypress/e2e/files/files-sidebar.cy.ts deleted file mode 100644 index 69be3473a808d..0000000000000 --- a/cypress/e2e/files/files-sidebar.cy.ts +++ /dev/null @@ -1,137 +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 { assertNotExistOrNotVisible } from '../settings/usersUtils.ts' -import { getRowForFile, navigateToFolder, triggerActionForFile } from './FilesUtils.ts' - -describe('Files: Sidebar', { testIsolation: true }, () => { - let user: User - let fileId: number = 0 - - beforeEach(() => cy.createRandomUser().then(($user) => { - user = $user - - cy.mkdir(user, '/folder') - cy.uploadContent(user, new Blob([]), 'text/plain', '/file').then((response) => { - fileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') - }) - cy.login(user) - })) - - it('opens the sidebar', () => { - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - - triggerActionForFile('file', 'details') - - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'file' }) - .should('be.visible') - }) - - it('changes the current fileid', () => { - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - - triggerActionForFile('file', 'details') - - cy.get('[data-cy-sidebar]').should('be.visible') - cy.url().should('contain', `apps/files/files/${fileId}`) - }) - - it('changes the sidebar content on other file', () => { - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - - triggerActionForFile('file', 'details') - - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'file' }) - .should('be.visible') - - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(600) // wait for a bit to avoid flakiness - - triggerActionForFile('folder', 'details') - cy.get('[data-cy-sidebar]') - .should('be.visible') - .findByRole('heading', { name: 'folder' }) - .should('be.visible') - }) - - it('closes the sidebar on navigation', () => { - cy.visit('/apps/files') - - getRowForFile('file').should('be.visible') - getRowForFile('folder').should('be.visible') - - // open the sidebar - triggerActionForFile('file', 'details') - // validate it is open - cy.get('[data-cy-sidebar]') - .should('be.visible') - - // if we navigate to the folder - navigateToFolder('folder') - // the sidebar should not be visible anymore - cy.get('[data-cy-sidebar]') - .should(assertNotExistOrNotVisible) - }) - - it('closes the sidebar on delete', () => { - cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/file`).as('deleteFile') - // visit the files app - cy.visit('/apps/files') - getRowForFile('file').should('be.visible') - // open the sidebar - triggerActionForFile('file', 'details') - // validate it is open - cy.get('[data-cy-sidebar]') - .should('be.visible') - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(600) // wait for a bit to avoid flakiness - - // delete the file - triggerActionForFile('file', 'delete') - cy.wait('@deleteFile', { timeout: 10000 }) - // see the sidebar is closed - cy.get('[data-cy-sidebar]') - .should(assertNotExistOrNotVisible) - }) - - it('changes the fileid on delete', () => { - cy.intercept('DELETE', `**/remote.php/dav/files/${user.userId}/folder/other`).as('deleteFile') - - cy.uploadContent(user, new Blob([]), 'text/plain', '/folder/other').then((response) => { - const otherFileId = Number.parseInt(response.headers['oc-fileid'] ?? '0') - cy.login(user) - cy.visit('/apps/files') - - getRowForFile('folder').should('be.visible') - navigateToFolder('folder') - getRowForFile('other').should('be.visible') - - // open the sidebar - triggerActionForFile('other', 'details') - // validate it is open - cy.get('[data-cy-sidebar]').should('be.visible') - cy.url().should('contain', `apps/files/files/${otherFileId}`) - - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(600) // wait for a bit to avoid flakiness - - triggerActionForFile('other', 'delete') - cy.wait('@deleteFile') - - cy.get('[data-cy-sidebar]').should('not.be.visible') - // Ensure the URL is changed - cy.url().should('not.contain', `apps/files/files/${otherFileId}`) - }) - }) -}) diff --git a/tests/playwright/e2e/files/files-delete.spec.ts b/tests/playwright/e2e/files/files-delete.spec.ts new file mode 100644 index 0000000000000..f62bff950efda --- /dev/null +++ b/tests/playwright/e2e/files/files-delete.spec.ts @@ -0,0 +1,60 @@ +/* + * 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' + +test.describe('Files: Delete', () => { + test('can delete a file', async ({ page, user, filesListPage }) => { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await filesListPage.open() + + const row = filesListPage.getRowForFile('file.txt') + await expect(row).toBeVisible() + // Preview must finish loading before delete — a loading preview can lock the file + await expect(row.locator('.files-list__row-icon-preview--loaded')).toBeVisible() + + const deleteResponse = page.waitForResponse( + (r) => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'DELETE', + { timeout: 10000 }, + ) + await filesListPage.triggerActionForFile('file.txt', 'delete') + expect((await deleteResponse).status()).toBe(204) + }) + + test('can delete multiple files', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/root') + for (let i = 0; i < 5; i++) { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/root/file${i}.txt`) + } + await filesListPage.open() + await filesListPage.navigateToFolder('root') + + // All 5 preview thumbnails must finish loading before we delete + await expect(page.locator('.files-list__row-icon-preview--loaded')).toHaveCount(5) + + // Set up listeners for all 5 DELETE responses before triggering the action + const deleteResponses = Promise.all( + Array.from({ length: 5 }, () => + page.waitForResponse( + (r) => r.url().includes(`/remote.php/dav/files/${user.userId}/root/`) && r.request().method() === 'DELETE', + { timeout: 15000 }, + ), + ), + ) + + await filesListPage.selectAll() + await filesListPage.triggerSelectionAction('delete') + + await page.getByRole('dialog', { name: 'Confirm deletion' }) + .getByRole('button', { name: 'Delete files' }) + .click() + + const responses = await deleteResponses + for (const response of responses) { + expect(response.status()).toBe(204) + } + }) +}) diff --git a/tests/playwright/e2e/files/files-navigation.spec.ts b/tests/playwright/e2e/files/files-navigation.spec.ts new file mode 100644 index 0000000000000..6a867404951bd --- /dev/null +++ b/tests/playwright/e2e/files/files-navigation.spec.ts @@ -0,0 +1,51 @@ +/* + * 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 } from '../../support/utils/dav.ts' + +test.describe('Files: Navigation', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/foo') + await mkdir(page.request, user, '/foo/bar') + await mkdir(page.request, user, '/foo/bar/baz') + await filesListPage.open() + }) + + test('shows root folder and can navigate to a deeply nested folder', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('foo')).toBeVisible() + await filesListPage.navigateToFolder('foo/bar/baz') + + // deepest folder is empty — no file rows rendered + await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0) + }) + + test('highlights the previous folder when navigating back and forward', async ({ page, filesListPage }) => { + await filesListPage.navigateToFolder('foo/bar/baz') + await expect(page.locator('[data-cy-files-list-row-fileid]')).toHaveCount(0) + + // Navigate back through each level — the folder we came from is highlighted + await page.goBack() + await expect(filesListPage.getRowForFile('baz')).toBeVisible() + await expect(filesListPage.getRowForFile('baz')).toBeActiveRow() + + await page.goBack() + await expect(filesListPage.getRowForFile('bar')).toBeVisible() + await expect(filesListPage.getRowForFile('bar')).toBeActiveRow() + + await page.goBack() + await expect(filesListPage.getRowForFile('foo')).toBeVisible() + await expect(filesListPage.getRowForFile('foo')).toBeActiveRow() + + // Navigate forward — the folder we re-entered is highlighted + await page.goForward() + await expect(filesListPage.getRowForFile('bar')).toBeVisible() + await expect(filesListPage.getRowForFile('bar')).toBeActiveRow() + + await page.goForward() + await expect(filesListPage.getRowForFile('baz')).toBeVisible() + await expect(filesListPage.getRowForFile('baz')).toBeActiveRow() + }) +}) diff --git a/tests/playwright/e2e/files/files-renaming.spec.ts b/tests/playwright/e2e/files/files-renaming.spec.ts new file mode 100644 index 0000000000000..ffe9d1ef82a18 --- /dev/null +++ b/tests/playwright/e2e/files/files-renaming.spec.ts @@ -0,0 +1,242 @@ +/* + * 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, rm, uploadContent } from '../../support/utils/dav.ts' + +test.describe('Files: Rename nodes', () => { + test.beforeEach(async ({ page, user, filesListPage }) => { + // New users get welcome.txt — remove it so the list contains only our test files + await rm(page.request, user, '/welcome.txt') + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt') + await filesListPage.open() + }) + + test('can rename a file', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.fill('other.txt') + await expect(input).toHaveValidationMessage('') + await input.press('Enter') + + await expect(filesListPage.getRowForFile('other.txt')).toBeVisible() + }) + + /** + * If this test gets flaky then the selection is not reliably set to the basename. + * The selection should cover only the name part (without extension) when rename opens. + */ + test('only selects basename of file on rename open', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + + const { selectionStart, selectionEnd } = await input.evaluate( + (el) => ({ selectionStart: (el as HTMLInputElement).selectionStart, selectionEnd: (el as HTMLInputElement).selectionEnd }), + ) + expect(selectionStart).toBe(0) + expect(selectionEnd).toBe('file'.length) + }) + + test('shows validation error on invalid filename', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.fill('.htaccess') + + await expect(input).toHaveValidationMessage(/reserved name/i) + }) + + test('shows accessible loading state while rename MOVE is in-flight', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + // Hold MOVE requests until we explicitly release them + let resolveMove!: () => void + const moveAllowed = new Promise(resolve => { resolveMove = resolve }) + await page.route(/remote\.php\/dav\/files\//, async (route) => { + if (route.request().method() === 'MOVE') { + await moveAllowed + } + await route.continue() + }) + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('new-name.txt') + await input.press('Enter') + + // While MOVE is blocked: row shows loading icon, checkbox is hidden + const loadingRow = filesListPage.getRowForFile('new-name.txt') + await expect(loadingRow.getByRole('img', { name: 'File is loading' })).toBeVisible() + await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).not.toBeVisible() + + // Release the MOVE and wait for it to complete + const moveResponse = page.waitForResponse( + r => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE', + ) + resolveMove() + await moveResponse + await page.unroute(/remote\.php\/dav\/files\//) + + // Loading state clears: checkbox reappears, loading icon gone + await expect(loadingRow.getByRole('checkbox', { name: /Toggle selection/ })).toBeVisible() + await expect(loadingRow.getByRole('img', { name: 'File is loading' })).not.toBeVisible() + }) + + test('cancel renaming on Escape', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.fill('other.txt') + await expect(input).toHaveValidationMessage('') + await input.press('Escape') + + // Original name kept, rename input removed + await expect(filesListPage.getRowForFile('other.txt')).toHaveCount(0) + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible() + }) + + test('cancel renaming on Enter when name is unchanged', async ({ filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + + const input = filesListPage.getRenameInputForFile('file.txt') + await expect(input).toBeVisible() + await input.press('Enter') + + // No rename happened, input is gone + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt').locator('input[type="text"]')).not.toBeVisible() + }) + + /** + * Regression: https://github.com/nextcloud/server/issues/47438 + * Virtual scrolling removed the renaming component from DOM before state reset, + * leaving the row permanently stuck in rename mode. + */ + test('correctly resets renaming state after virtual-scroll re-render', async ({ page, user, filesListPage }) => { + // Create 19 more files so virtual scrolling kicks in with a small viewport + for (let i = 1; i <= 19; i++) { + await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', `/file${i}.txt`) + } + + // Start with a small viewport so only a few rows fit + await page.setViewportSize({ width: 768, height: 500 }) + await filesListPage.open() + + // Measure the DOM to calculate the exact height that shows only 4 rows + const viewportHeight = await page.evaluate(() => { + const filesList = document.querySelector('[data-cy-files-list]') as HTMLElement + const outerHeight = window.innerHeight - filesList.clientHeight + const beforeHeight = (document.querySelector('.files-list__before') as HTMLElement)?.offsetHeight ?? 0 + const filterHeight = (document.querySelector('.files-list__filters') as HTMLElement)?.offsetHeight ?? 0 + const theadHeight = (document.querySelector('[data-cy-files-list-thead]') as HTMLElement)?.offsetHeight ?? 0 + const rowHeight = (document.querySelector('[data-cy-files-list-tbody] tr') as HTMLElement)?.offsetHeight ?? 0 + return outerHeight + beforeHeight + filterHeight + theadHeight + 4 * rowHeight + }) + await page.setViewportSize({ width: 768, height: viewportHeight }) + await filesListPage.open() + + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + // Rename to 'zzz.txt' — sorts last, scrolls out of the visible area + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + const moveResponse = page.waitForResponse( + r => r.url().includes('/remote.php/dav/files/') && r.request().method() === 'MOVE', + ) + await input.fill('zzz.txt') + await input.press('Enter') + await moveResponse + + // After rename zzz.txt is sorted to the end — no longer in the visible viewport + await expect(filesListPage.getRowForFile('zzz.txt')).toHaveCount(0) + + // Scroll to the bottom to bring zzz.txt into view + await page.locator('[data-cy-files-list]').evaluate(el => el.scrollTo(0, el.scrollHeight)) + + // Row must be visible and NOT in rename state + await expect(filesListPage.getRowForFile('zzz.txt')).toBeVisible() + await expect(filesListPage.getRenameInputForFile('zzz.txt')).not.toBeVisible() + }) + + test('shows extension-change warning — keep new extension', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('file.md') + await input.press('Enter') + + await page.getByRole('dialog', { name: 'Change file extension' }) + .getByRole('button', { name: 'Use .md' }) + .click() + + await expect(filesListPage.getRowForFile('file.md')).toBeVisible() + }) + + test('shows extension-change warning — keep old extension', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('document.md') + await input.press('Enter') + + await page.getByRole('dialog', { name: 'Change file extension' }) + .getByRole('button', { name: 'Keep .txt' }) + .click() + + await expect(filesListPage.getRowForFile('document.txt')).toBeVisible() + }) + + test('shows extension-removal warning', async ({ page, filesListPage }) => { + await expect(filesListPage.getRowForFile('file.txt')).toBeVisible() + + await filesListPage.triggerActionForFile('file.txt', 'rename') + const input = filesListPage.getRenameInputForFile('file.txt') + await input.fill('file') + await input.press('Enter') + + const dialog = page.getByRole('dialog', { name: 'Change file extension' }) + await expect(dialog.getByRole('button', { name: 'Keep .txt' })).toBeVisible() + await dialog.getByRole('button', { name: 'Remove extension' }).click() + + await expect(filesListPage.getRowForFile('file')).toBeVisible() + await expect(filesListPage.getRowForFile('file.txt')).toHaveCount(0) + }) + + test('does not show extension warning when renaming a folder with a dot', async ({ page, user, filesListPage }) => { + await mkdir(page.request, user, '/folder.2024') + await filesListPage.open() + + await expect(filesListPage.getRowForFile('folder.2024')).toBeVisible() + + await filesListPage.triggerActionForFile('folder.2024', 'rename') + const input = filesListPage.getRenameInputForFolder('folder.2024') + await expect(input).toBeVisible() + await input.fill('folder.2025') + await expect(input).toHaveValidationMessage('') + await input.press('Enter') + + await expect(page.locator('[role="dialog"]')).toHaveCount(0) + await expect(filesListPage.getRowForFile('folder.2025')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/files/files-sidebar.spec.ts b/tests/playwright/e2e/files/files-sidebar.spec.ts new file mode 100644 index 0000000000000..e94f57c0b8957 --- /dev/null +++ b/tests/playwright/e2e/files/files-sidebar.spec.ts @@ -0,0 +1,112 @@ +/* + * 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' + +test.describe('Files: Sidebar', () => { + let fileId: number + + test.beforeEach(async ({ user, page, filesListPage }) => { + await mkdir(page.request, user, '/folder') + fileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file') + await filesListPage.open() + }) + + test('opens the sidebar', async ({ filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + await filesListPage.triggerActionForFile('file', 'details') + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('file')).toBeVisible() + }) + + test('changes the current fileid', async ({ page, filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + await filesListPage.triggerActionForFile('file', 'details') + + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(page).toHaveURL(new RegExp(`apps/files/files/${fileId}`)) + }) + + test('changes the sidebar content on other file', async ({ filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + await filesListPage.triggerActionForFile('file', 'details') + + await expect(filesSidebar.sidebar()).toBeVisible() + // Wait for the first file's heading to be stable before switching + await expect(filesSidebar.heading('file')).toBeVisible() + + await filesListPage.triggerActionForFile('folder', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(filesSidebar.heading('folder')).toBeVisible() + }) + + test('closes the sidebar on navigation', async ({ filesListPage, filesSidebar }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + + // Open the sidebar + await filesListPage.triggerActionForFile('file', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + + // Navigate into the folder — sidebar should close + await filesListPage.navigateToFolder('folder') + await expect(filesSidebar.sidebar()).not.toBeVisible() + }) + + test('closes the sidebar on delete', async ({ page, filesListPage, filesSidebar, user }) => { + await expect(filesListPage.getRowForFile('file')).toBeVisible() + + // Open the sidebar + await filesListPage.triggerActionForFile('file', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + // Wait for the sidebar to be fully rendered before deleting + await expect(filesSidebar.heading('file')).toBeVisible() + + const deleteResponse = page.waitForResponse( + (response) => + response.url().includes(`/remote.php/dav/files/${user.userId}/file`) + && response.request().method() === 'DELETE', + { timeout: 10000 }, + ) + + await filesListPage.triggerActionForFile('file', 'delete') + await deleteResponse + + await expect(filesSidebar.sidebar()).not.toBeVisible() + }) + + test('changes the fileid on delete', async ({ page, filesListPage, filesSidebar, user }) => { + const otherFileId = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/folder/other') + + await expect(filesListPage.getRowForFile('folder')).toBeVisible() + await filesListPage.navigateToFolder('folder') + await expect(filesListPage.getRowForFile('other')).toBeVisible() + + // Open the sidebar for the inner file + await filesListPage.triggerActionForFile('other', 'details') + await expect(filesSidebar.sidebar()).toBeVisible() + await expect(page).toHaveURL(new RegExp(`apps/files/files/${otherFileId}`)) + // Wait for the sidebar to be fully rendered before deleting + await expect(filesSidebar.heading('other')).toBeVisible() + + const deleteResponse = page.waitForResponse( + (response) => + response.url().includes(`/remote.php/dav/files/${user.userId}/folder/other`) + && response.request().method() === 'DELETE', + { timeout: 10000 }, + ) + + await filesListPage.triggerActionForFile('other', 'delete') + await deleteResponse + + await expect(filesSidebar.sidebar()).not.toBeVisible() + await expect(page).not.toHaveURL(new RegExp(`apps/files/files/${otherFileId}`)) + }) +}) diff --git a/tests/playwright/support/fixtures/files-page.ts b/tests/playwright/support/fixtures/files-page.ts new file mode 100644 index 0000000000000..c1eba8c96a50a --- /dev/null +++ b/tests/playwright/support/fixtures/files-page.ts @@ -0,0 +1,42 @@ +/* + * 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, login } from '@nextcloud/e2e-test-server/playwright' +import { test as baseTest } from '@playwright/test' +import type { User } from '@nextcloud/e2e-test-server' +import { FilesListPage } from '../sections/FilesListPage.ts' +import { FilesSidebarPage } from '../sections/FilesSidebarPage.ts' + +type FilesFixtures = { + user: User + filesListPage: FilesListPage + filesSidebar: FilesSidebarPage +} + +export const test = baseTest.extend({ + user: async ({ context }, use) => { + const user = await createRandomUser() + try { + await login(context.request, user) + } catch { + // Retry once on transient auth failure + await new Promise((resolve) => setTimeout(resolve, 800)) + await login(context.request, user) + } + await use(user) + await runOcc(['user:delete', user.userId]) + }, + + filesListPage: async ({ page }, use) => { + await use(new FilesListPage(page)) + }, + + filesSidebar: async ({ page }, use) => { + await use(new FilesSidebarPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/matchers.ts b/tests/playwright/support/matchers.ts new file mode 100644 index 0000000000000..491cf431ec983 --- /dev/null +++ b/tests/playwright/support/matchers.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect as baseExpect, type Locator } from '@playwright/test' + +export const expect = baseExpect.extend({ + /** + * Asserts that a file-list row has the active highlight class. + * A row becomes active when it was the last folder navigated into + * (e.g. after a browser back/forward traversal). + */ + async toBeActiveRow(received: Locator, options?: { timeout?: number }) { + let pass: boolean + let failMessage: string | undefined + try { + await baseExpect(received).toHaveClass(/files-list__row--active/, options) + pass = true + } catch (e: unknown) { + pass = false + failMessage = (e as Error).message + } + return { + message: () => pass + ? `Expected row not to have class 'files-list__row--active'` + : failMessage ?? `Expected row to have class 'files-list__row--active'`, + pass, + } + }, + /** + * Asserts that an input element has a specific HTML5 validation message. + * An empty string means the input is valid (no validation error). + * Retries until the message matches or the timeout expires. + */ + async toHaveValidationMessage(received: Locator, expected: string | RegExp, options?: { timeout?: number }) { + let pass = false + let actual = '' + const getMsg = async () => received.evaluate((el) => (el as HTMLInputElement).validationMessage) + try { + if (typeof expected === 'string') { + await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toBe(expected) + } else { + await baseExpect.poll(getMsg, { timeout: options?.timeout ?? 5000 }).toMatch(expected) + } + pass = true + } catch { + actual = await getMsg().catch(() => '') + } + return { + message: () => pass + ? `Expected validation message not to equal ${JSON.stringify(expected)}` + : `Expected validation message ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + pass, + } + }, +}) + +declare module '@playwright/test' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Matchers { + toBeActiveRow(options?: { timeout?: number }): R + toHaveValidationMessage(expected: string | RegExp, options?: { timeout?: number }): R + } +} diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts new file mode 100644 index 0000000000000..3f8918e81a8df --- /dev/null +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +export class FilesListPage { + constructor(private readonly page: Page) {} + + async open(): Promise { + await this.page.goto('apps/files') + await this.page.locator('[data-cy-files-list]').waitFor({ state: 'visible' }) + } + + getRowForFile(filename: string): Locator { + return this.page.locator(`[data-cy-files-list-row-name="${filename}"]`) + } + + getRowForFileId(fileid: number): Locator { + return this.page.locator(`[data-cy-files-list-row-fileid="${fileid}"]`) + } + + private getActionsButtonForFile(filename: string): Locator { + return this.getRowForFile(filename) + .getByRole('button', { name: 'Actions' }) + } + + async triggerActionForFile(filename: string, actionId: string): Promise { + const row = this.getRowForFile(filename) + await row.hover() + + const actionsButton = this.getActionsButtonForFile(filename) + await actionsButton.scrollIntoViewIfNeeded() + // force: true to avoid issues with the sticky file list header + await actionsButton.click({ force: true }) + + const menuId = await actionsButton.getAttribute('aria-controls') + // The action button has role="menuitem", so use tag selector not getByRole + const actionEntry = this.page + .locator(`#${menuId} [data-cy-files-list-row-action="${actionId}"] button`) + await actionEntry.waitFor({ state: 'visible' }) + await actionEntry.click() + } + + async selectAll(): Promise { + await this.page.locator('[data-cy-files-list-selection-checkbox]') + .getByRole('checkbox') + .click({ force: true }) + } + + async triggerSelectionAction(actionId: string): Promise { + const actionsButton = this.page.locator('[data-cy-files-list-selection-actions]') + .getByRole('button', { name: 'Actions' }) + await actionsButton.click({ force: true }) + // NcActionButton renders as