diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index f3e85bdf15f..c730c897eb1 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 00000000000..9804e242330 --- /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 87035b7f675..a53c847f4a1 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 8b44761bcfe..05cdc28f3a6 100644 --- a/build/files-checker.php +++ b/build/files-checker.php @@ -74,6 +74,7 @@ $expectedFiles = [ '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 57e1817ea15..763ca560d06 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 5650126554b..87d75fd531b 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 00000000000..bf88178fb95 --- /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 00000000000..6d99e6887f1 --- /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 00000000000..aa7212f6b5c --- /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 00000000000..f552f13ce93 --- /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 00000000000..8b3951a17aa --- /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]) + }, +})