test: initial end to end test with playwright
@@ -1,5 +1,7 @@
|
||||
name: CI
|
||||
|
||||
# IMPORTANT: using Swatinem/rust-cache@v2 : reuse same cache as playwright.yml (minimize non necessary new compilation)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
||||
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
branches: [ "main", "dev" ]
|
||||
|
||||
concurrency:
|
||||
group: rust-build-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
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"
|
||||
@@ -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
|
||||
@@ -87,3 +87,11 @@ resources/gen/
|
||||
|
||||
# Helm chart dependencies
|
||||
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/
|
||||
|
||||
@@ -63,3 +63,11 @@ front-lint:
|
||||
# check CSS rules
|
||||
front-rules:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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()}`);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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') ]
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -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.');
|
||||
};
|
||||
@@ -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' });
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||