test: create basic Playwright test infrastructure

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-03-17 02:31:09 +01:00
parent 6c9c739d54
commit c45a5d4809
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
11 changed files with 385 additions and 2 deletions

View file

@ -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:

130
.github/workflows/playwright.yml vendored Normal file
View file

@ -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

2
.gitignore vendored
View file

@ -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*

View file

@ -74,6 +74,7 @@ $expectedFiles = [
'openapi.json',
'package-lock.json',
'package.json',
'playwright.config.ts',
'psalm-ncu.xml',
'psalm-ocp.xml',
'psalm-strict.xml',

64
package-lock.json generated
View file

@ -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",

View file

@ -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",

55
playwright.config.ts Normal file
View file

@ -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/,
},
},
})

View file

@ -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' }]],
}

View file

@ -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))
}

View file

@ -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)
},
})

View file

@ -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])
},
})