test: initial end to end test with playwright

This commit is contained in:
Edouard Vanbelle
2026-05-04 15:28:44 +02:00
parent 52f4a865a9
commit 3c3d24ac72
28 changed files with 494 additions and 0 deletions
+2
View File
@@ -1,5 +1,7 @@
name: CI name: CI
# IMPORTANT: using Swatinem/rust-cache@v2 : reuse same cache as playwright.yml (minimize non necessary new compilation)
on: on:
push: push:
branches: branches:
+4
View File
@@ -6,6 +6,10 @@ on:
pull_request: pull_request:
branches: [ "main", "dev" ] branches: [ "main", "dev" ]
concurrency:
group: rust-build-${{ github.ref }}
cancel-in-progress: false
jobs: jobs:
build-and-test: build-and-test:
name: Build and Test Docker Image name: Build and Test Docker Image
@@ -0,0 +1,39 @@
name: Playwright — Update snapshots
on:
workflow_dispatch:
jobs:
update-snapshots:
timeout-minutes: 60
runs-on: ubuntu-latest
defaults:
run:
working-directory: tests/e2e
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install Node dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Update snapshots
run: npm test -- --update-snapshots
- name: Commit updated snapshots
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "test(e2e): update playwright linux snapshots"
file_pattern: "tests/e2e/scenarios/**/*-linux.png"
+49
View File
@@ -0,0 +1,49 @@
name: Playwright Test (end-to-end)
# IMPORTANT: using Swatinem/rust-cache@v2 : reuse same cache as ci.yml (minimize non necessary new compilation)
on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main, dev ]
concurrency:
group: rust-build-${{ github.ref }}
cancel-in-progress: false
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
defaults:
run:
working-directory: tests/e2e
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install Node dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests (spawns DB via pretest hook)
run: npm test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: tests/e2e/playwright-report/
retention-days: 30
+8
View File
@@ -87,3 +87,11 @@ resources/gen/
# Helm chart dependencies # Helm chart dependencies
charts/*/charts/* charts/*/charts/*
# Playwright
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
tests/e2e/blob-report/
tests/e2e/playwright/.cache/
tests/e2e/playwright/.auth/
+8
View File
@@ -63,3 +63,11 @@ front-lint:
# check CSS rules # check CSS rules
front-rules: front-rules:
stylelint static/css/ stylelint static/css/
# end-to-end tests
front-test:
cd tests/e2e && npm test
# update images snapshots
front-test-update-snapshot:
cd tests/e2e && npm test -- --update-snapshots
+16
View File
@@ -0,0 +1,16 @@
services:
postgres-test:
image: postgres:18.2-alpine3.23
environment:
POSTGRES_USER: oxicloud_test
POSTGRES_PASSWORD: oxicloud_test
POSTGRES_DB: oxicloud_test
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql # in-memory: always blank on start (pg18+ uses /var/lib/postgresql/18/data)
healthcheck:
test: ["CMD-SHELL", "pg_isready -U oxicloud_test"]
interval: 2s
timeout: 5s
retries: 10
+18
View File
@@ -0,0 +1,18 @@
import { TEST_ADMIN } from './scenarios/helpers';
export default async function globalSetup() {
const res = await fetch('http://localhost:8087/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: TEST_ADMIN.username,
email: TEST_ADMIN.email,
password: TEST_ADMIN.password,
}),
});
// 409 = admin already exists (idempotent across retries)
if (!res.ok && res.status !== 409) {
throw new Error(`Admin setup failed: ${res.status} ${await res.text()}`);
}
}
+97
View File
@@ -0,0 +1,97 @@
{
"name": "e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "e2e",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/node": "^25.6.0"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"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/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "e2e",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"pretest": "node -e \"(async()=>{await require('./spawn-db.js')()})().catch(e=>{console.error(e);process.exit(1)})\"",
"test": "npx playwright test",
"posttest": "node -e \"(async()=>{await require('./stop-db.js')()})().catch(e=>{console.error(e);process.exit(1)})\""
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/node": "^25.6.0"
}
}
+54
View File
@@ -0,0 +1,54 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './scenarios',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [['github'], ['html']] : 'html',
globalSetup: require.resolve('./global-setup'),
use: {
baseURL: 'http://localhost:8087',
trace: 'on-first-retry',
toHaveScreenshot: { maxDiffPixelRatio: 0.02 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
webServer: {
command: 'cargo run',
url: 'http://localhost:8087',
timeout: 600_000,
reuseExistingServer: false,
cwd: '../..',
stdout: 'pipe',
stderr: 'pipe',
env: {
DATABASE_URL: 'postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test',
OXICLOUD_SERVER_PORT: 8087,
OXICLOUD_DB_CONNECTION_STRING: 'postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test',
OXICLOUD_STORAGE_PATH: './tests/e2e/storage',
OXICLOUD_STATIC_PATH: './static',
OXICLOUD_JWT_SECRET: 'test-secret-do-not-use-in-prod-minimum-32-chars',
OXICLOUD_ENABLE_AUTH: 'true',
OXICLOUD_ENABLE_TRASH: 'true',
OXICLOUD_ENABLE_SEARCH: 'true',
OXICLOUD_ENABLE_FILE_SHARING: 'true',
OXICLOUD_WOPI_ENABLED: 'false',
OXICLOUD_OIDC_ENABLED: 'false',
RUST_LOG: 'warn',
},
},
});
+36
View File
@@ -0,0 +1,36 @@
import { Page, expect } from '@playwright/test';
export const TEST_ADMIN = {
username: 'admin',
email: 'testadmin@example.com',
password: 'TestPassword1!',
};
/**
* Log in as the test admin and wait until the main app is ready.
*/
export async function loginAsAdmin(page: Page) {
await goToLoginPage(page);
await page.locator('#login-username').fill(TEST_ADMIN.username);
await page.locator('#login-password').fill(TEST_ADMIN.password);
await page.locator('#login-panel button[type="submit"]').click();
await expect(page.locator('#sidebar')).toBeVisible({ timeout: 15_000 });
}
/**
* Navigate to `/` and land on the login panel, handling the language selector
* if it appears (fresh localStorage). The admin account is guaranteed to exist
* because globalSetup created it before any test ran.
*/
export async function goToLoginPage(page: Page) {
await page.goto('/');
// Both panels start with .hidden — wait for JS to reveal one.
await page.waitForSelector('#language-panel:not(.hidden), #login-panel:not(.hidden)');
if (await page.locator('#language-panel').isVisible()) {
await page.locator('#language-continue').click();
}
await expect(page.locator('#login-panel')).toBeVisible();
}
@@ -0,0 +1,91 @@
import { test, expect } from '@playwright/test';
import { goToLoginPage, loginAsAdmin, TEST_ADMIN } from './helpers';
test('has OxiCloud title', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/OxiCloud/);
});
test('language selector > choose EN > reach login page', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#language-panel')).toBeVisible();
await expect(page.getByText('Select your language to continue')).toBeVisible();
await expect(page.locator('#lang-picker-name')).toHaveText('English');
await expect(page.locator('#language-panel')).toHaveScreenshot('language-selector.png');
await page.locator('#language-continue').click();
await expect(page.locator('#login-panel')).toBeVisible();
await expect(page.locator('#language-panel')).toBeHidden();
await expect(page.locator('#login-panel')).toHaveScreenshot('login-panel.png');
});
test('login with wrong password is rejected', async ({ page }) => {
await goToLoginPage(page);
await page.locator('#login-username').fill(TEST_ADMIN.username);
await page.locator('#login-password').fill('definitely-wrong-password');
await page.locator('#login-panel button[type="submit"]').click();
const loginError = page.locator('#login-error');
await expect(loginError).toBeVisible();
await expect(loginError).toContainText('Authentication error (403): Forbidden');
await expect(page.locator('#login-panel')).toBeVisible();
await expect(page.locator('#login-panel')).toHaveScreenshot('login-panel-error.png');
});
test.describe('authenticated as admin', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('home page shows files list', async ({ page }) => {
await expect(page.locator('.files-container')).toBeVisible();
await expect(page.locator('#user-menu-wrapper')).toBeVisible();
await expect(page).toHaveScreenshot('home-files.png', {
// ignore this div (max value may change)
// FIXME: ensure same max capacity from server during test
animations: 'disabled',
mask: [page.locator('.storage-bar'), page.locator('.storage-info') ]
});
});
test('theme can be changed to dark', async ({ page }) => {
await page.locator('#user-avatar-btn').click();
await expect(page.locator('#user-menu')).toBeVisible();
await page.locator('#theme-toggle-pill').click();
// html element must carry data-theme="dark"
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// localStorage must persist the choice
const theme = await page.evaluate(() => localStorage.getItem('oxicloud_theme'));
expect(theme).toBe('dark');
await expect(page).toHaveScreenshot('home-files-darktheme.png', {
// ignore this div (max value may change)
// FIXME: ensure same max capacity from server during test
animations: 'disabled',
mask: [page.locator('.storage-bar'), page.locator('.storage-info') ]
});
// Toggle back to light
await page.locator('#theme-toggle-pill').click();
await expect(page.locator('html')).not.toHaveAttribute('data-theme', 'dark');
const themeAfter = await page.evaluate(() => localStorage.getItem('oxicloud_theme'));
expect(themeAfter).toBe('light');
await expect(page).toHaveScreenshot('home-files-lightheme.png', {
// ignore this div (max value may change)
// FIXME: ensure same max capacity from server during test
animations: 'disabled',
mask: [page.locator('.storage-bar'), page.locator('.storage-info') ]
});
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+31
View File
@@ -0,0 +1,31 @@
const { execSync, spawnSync } = require('child_process');
const net = require('net');
const path = require('path');
const COMPOSE_FILE = path.join(__dirname, 'docker-compose.test.yml');
const CMD = `docker compose -f ${COMPOSE_FILE}`;
function waitForPort(host, port, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
function attempt() {
const sock = net.connect(port, host);
sock.once('connect', () => { sock.destroy(); resolve(); });
sock.once('error', () => {
sock.destroy();
if (Date.now() >= deadline) return reject(new Error(`Timeout waiting for ${host}:${port}`));
setTimeout(attempt, 500);
});
}
attempt();
});
}
module.exports = async function globalSetup() {
console.log('[setup] Starting test postgres (database is empty) ...');
execSync(`${CMD} down`, { stdio: 'inherit' });
execSync(`${CMD} up -d`, { stdio: 'inherit' });
console.log('[setup] Waiting for postgres on port 5433...');
await waitForPort('127.0.0.1', 5433);
console.log('[setup] Postgres is ready.');
};
+9
View File
@@ -0,0 +1,9 @@
const { execSync } = require('child_process');
const path = require('path');
const COMPOSE_FILE = path.join(__dirname, 'docker-compose.test.yml');
module.exports = async function globalTeardown() {
console.log('[teardown] Stopping test postgres...');
execSync(`docker compose -f ${COMPOSE_FILE} down -v`, { stdio: 'inherit' });
};
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "node16",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"types": ["node"]
},
"include": ["./**/*.ts"],
"exclude": ["node_modules", "dist"]
}