chore(e2e): remove old playwright test (wired to vanilla JS)

This commit is contained in:
Edouard Vanbelle
2026-08-09 12:26:13 +02:00
parent 69c57e1e24
commit 11c7f9440e
55 changed files with 5 additions and 1487 deletions
@@ -1,45 +0,0 @@
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: Build OxiCloud (release)
working-directory: .
run: cargo build --release
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Update snapshots
run: npm test -- --update-snapshots=all
env:
BUILD_TARGET: release
- 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"
+5 -1
View File
@@ -5,7 +5,11 @@
DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
OXICLOUD_STATIC_PATH=./static
# `OXICLOUD_STATIC_PATH` intentionally NOT set — every remaining
# test suite either serves the built SvelteKit SPA from
# `static-dist/` (coverage suite via `start-server-spa.sh`) or
# doesn't need static assets at all (Hurl API / WebDAV / CalDAV
# suites). The legacy `./static` vanilla frontend was retired.
OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars
OXICLOUD_ENABLE_AUTH=true
OXICLOUD_ENABLE_TRASH=true
-4
View File
@@ -4,10 +4,6 @@
"description": "",
"main": "index.js",
"scripts": {
"pretest": "bash ../common/spawn-db.sh",
"test": "FORCE_COLOR=true npx playwright test scenarios/01-home-and-login.spec.ts scenarios/02-folder-management.spec.ts",
"posttest": "bash ../common/stop-db.sh",
"test:containers": "OXICLOUD_E2E_CONTAINERS=1 FORCE_COLOR=true npx playwright test -c playwright.containers.config.ts",
"build:spa": "cd ../../frontend && COVERAGE=1 VITE_E2E=1 npm run build",
"test:coverage": "FORCE_COLOR=true npx playwright test -c playwright.coverage.config.ts",
"coverage:report": "node coverage-report.cjs e2e",
-84
View File
@@ -1,84 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import * as path from 'path';
import { loadEnv } from './load-env';
const startScript = path.join(__dirname, 'start-server.sh');
const commonEnv = loadEnv(path.join(__dirname, '../common/server.env'));
console.log(`starting playwright with env BUILD_TARGET=${process.env.BUILD_TARGET ?? "debug"}`);
const workspace=process.env.GITHUB_WORKSPACE ?? path.join(__dirname, '../..');
export default defineConfig({
testDir: './scenarios',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: process.env.CI ? [['line'], ['github'], ['html']] : [ ['list'], ['html']],
globalSetup: require.resolve('./global-setup'),
globalTeardown: require.resolve('./global-teardown'),
use: {
// 127.0.0.1, not `localhost`: the server binds IPv4 (127.0.0.1:8087) and
// on CI runners `localhost` resolves to ::1 (IPv6) first, so requests get
// ECONNREFUSED and the webServer readiness check below times out.
baseURL: 'http://127.0.0.1:8087',
trace: 'on-first-retry',
headless: true,
// take a screenshot on failure
screenshot: 'only-on-failure',
// Tile/file rows expose a `data-testid` of the item name (kept only in e2e
// builds via VITE_E2E); makes getByTestId + codegen prefer stable selectors.
testIdAttribute: 'data-testid',
},
expect: {
toHaveScreenshot: { maxDiffPixelRatio: 0.01 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: process.env.BUILD_TARGET
? `bash "${startScript}" "${workspace}/target/${process.env.BUILD_TARGET}/oxicloud"`
: `bash "${startScript}" cargo run`,
// Poll the dedicated readiness probe, not `/`: `/ready` returns 200 as soon
// as the DB pool is live, whereas `/` depends on the SPA build being present
// and can 404 (Playwright only treats 2xx/3xx/400-403 as "ready").
url: 'http://127.0.0.1:8087/ready',
timeout: 600_000,
reuseExistingServer: false,
cwd: '../..',
stdout: 'inherit',
stderr: 'inherit',
env: {
...commonEnv,
OXICLOUD_SERVER_PORT: '8087',
OXICLOUD_STORAGE_PATH: './tests/e2e/storage',
// Verbose startup so a CI webServer-readiness timeout shows where the
// server stalls (DB connect, migrations, bind) instead of nothing.
RUST_LOG: 'info,oxicloud=debug,sqlx=warn,tower_http=info',
// OPAQUE + DPoP inherited from `../common/server.env`:
// OXICLOUD_AUTH_OPAQUE_MODE=migrate (Phase 2 silent-migration
// on first legacy login, Phase 4 refusal thereafter)
// OXICLOUD_DPOP_MODE=required (verify every proof;
// unbound sessions still exempt per Gate 5 design)
//
// Known failure surfaces under `DPOP=required`:
// * Node-side `page.request.*` helpers can't sign proofs
// → 401 on state-changing calls. Task #47 rewrites those
// through `page.evaluate` so signing happens in-browser.
// * Browser-direct content GETs (img src, a href, video src)
// also can't sign — Gate C content-serve allowlist in
// `middleware/dpop.rs` exempts the known paths.
},
},
});
-58
View File
@@ -1,58 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import * as os from 'os';
/**
* Testcontainers e2e config (Option D): every worker boots its own isolated
* DB + OxiCloud app container (Svelte SPA baked in) via the worker-scoped
* `stack` fixture in `scenarios/helpers.ts`.
*
* Differences from `playwright.config.ts` (legacy single-server flow):
* - No `webServer` — the app is a container started per worker.
* - No `globalSetup` — each worker seeds its own admin in the fixture.
* - No `use.baseURL` — the fixture supplies a per-worker random port.
* - `fullyParallel` + N workers — stacks are fully isolated, so parallel.
*
* Run with: OXICLOUD_E2E_CONTAINERS=1 playwright test -c playwright.containers.config.ts
* Set $OXICLOUD_IMAGE to a prebuilt tag to skip the per-run Dockerfile build.
*/
export default defineConfig({
testDir: './scenarios',
// The recorder harnesses in scenarios/codegen/ call page.pause() and are run
// only via `just front-codegen` (playwright.codegen.config.ts) — never as
// part of the e2e suite.
testIgnore: ['**/codegen/**'],
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
// One DB + one app container per worker — cap so we don't exhaust Docker.
workers: process.env.CI ? 2 : Math.max(1, Math.floor(os.cpus().length / 4)),
// Real container app is slower than a local cargo process; give actions room.
timeout: 60_000,
reporter: process.env.CI ? [['line'], ['github'], ['html']] : [['list'], ['html']],
use: {
trace: 'on-first-retry',
headless: true,
screenshot: 'only-on-failure',
// Tile/file rows expose a `data-testid` of the item name (kept only in e2e
// builds via VITE_E2E); makes getByTestId + codegen prefer stable selectors.
testIdAttribute: 'data-testid',
// On NixOS (and other distros where Playwright's downloaded chromium can't
// run due to the generic dynamic linker), point at a system/Nix chromium
// via PW_CHROMIUM_PATH. Unset → use Playwright's bundled browser (CI).
launchOptions: process.env.PW_CHROMIUM_PATH
? { executablePath: process.env.PW_CHROMIUM_PATH }
: {},
},
expect: {
toHaveScreenshot: { maxDiffPixelRatio: 0.01 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
@@ -1,96 +0,0 @@
import { expect } from '@playwright/test';
import { test, 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-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 empty 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') ]
});
await expect(page.locator('#files-container-error')).toBeVisible();
await expect(page.locator('#files-container-error')).toContainText("No files in this folder");
});
test('theme can be changed to dark', async ({ page }) => {
await page.locator('#user-avatar-btn').click();
await expect(page.locator('#user-menu')).toBeVisible();
// The appearance row is now a 3-option segmented control
// (Light / Like OS / Dark). Each option carries a `data-mode` attribute.
await page.locator('.theme-segmented__opt[data-mode="dark"]').click();
// html element must carry data-color-scheme="dark" (new attribute).
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', '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') ]
});
// Switch back to light via the Light option.
await page.locator('.theme-segmented__opt[data-mode="light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', 'light');
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.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

@@ -1,231 +0,0 @@
import * as path from 'path';
import * as fs from 'fs/promises';
import { expect, Page } from '@playwright/test';
import { test, loginAsAdmin } from './helpers';
const FIXTURES = path.join(__dirname, '../../fixtures');
/**
* Opens the new-folder modal, fills the name, and clicks Confirm.
* Does NOT wait for the modal to close — the caller decides what to assert next.
*/
async function submitNewFolder(page: Page, name: string) {
await page.locator('#new-folder-btn').click();
await expect(page.locator('#input-modal')).toBeVisible();
await page.locator('#modal-input').fill(name);
await page.locator('#modal-confirm-btn').click();
}
function mimeFromPath(fp: string): string {
const map: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.mp4': 'video/mp4',
'.pdf': 'application/pdf',
};
return map[path.extname(fp).toLowerCase()] ?? 'application/octet-stream';
}
/**
* Simulates a file drag-and-drop onto the OxiCloud dropzone overlay.
*
* How it works:
* 1. File buffers are read in Node and transferred into the page as File
* objects inside a DataTransfer — no real OS drag needed.
* 2. A document-level 'dragover' is dispatched so the app reveals the
* dropzone (same code path as a real drag from the OS).
* 3. A 'drop' is dispatched on #dropzone. The handler falls back to
* dataTransfer.files when webkitGetAsEntry() returns null (which it
* does for programmatic File objects), hitting the same upload path.
*/
async function dragFilesToDropzone(page: Page, filePaths: string[]) {
const fileData = await Promise.all(
filePaths.map(async (fp) => ({
name: path.basename(fp),
buffer: Array.from(await fs.readFile(fp)),
type: mimeFromPath(fp),
}))
);
const dataTransfer = await page.evaluateHandle((files) => {
const dt = new DataTransfer();
for (const { name, buffer, type } of files) {
dt.items.add(new File([new Uint8Array(buffer)], name, { type }));
}
return dt;
}, fileData);
// Reveal the dropzone overlay (same logic as a real OS drag).
await dataTransfer.evaluate((dt) => {
document.dispatchEvent(new DragEvent('dragover', {
bubbles: true, cancelable: true, dataTransfer: dt,
}));
});
await expect(page.locator('#dropzone')).toBeVisible();
await page.locator('#dropzone').dispatchEvent('drop', { dataTransfer });
}
test.describe('Folder management', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('folder creation', async ({ page }) => {
const name = `Test folder creation`;
await submitNewFolder(page, name);
await expect(page.locator('#input-modal')).toBeHidden();
await expect(page.locator(`.file-item[data-folder-name="${name}"]`)).toBeVisible();
// Screenshot: mask dynamic text so layout is what's tested, not the name.
await expect(page).toHaveScreenshot('folder-created.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'),
],
});
});
test('folder reject if already exists', async ({ page }) => {
const name = `Test existing folder`;
// Prerequisite: create the folder once successfully.
await submitNewFolder(page, name);
await expect(page.locator('#input-modal')).toBeHidden();
// Second creation with the same name must keep the modal open with an error.
await submitNewFolder(page, name);
await expect(page.locator('#modal-error')).toBeVisible();
await expect(page.locator('#modal-error')).toContainText('already exists');
// Cancel leaves the list unchanged.
await page.locator('#modal-cancel-btn').click();
await expect(page.locator('#input-modal')).toBeHidden();
});
test('folder creation rejected if bad name', async ({ page }) => {
await page.locator('#new-folder-btn').click();
await expect(page.locator('#input-modal')).toBeVisible();
await page.locator('#modal-input').fill('/');
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-error')).toBeVisible();
await expect(page.locator('#modal-error')).toContainText('Invalid folder name');
await page.locator('#modal-cancel-btn').click();
await expect(page.locator('#input-modal')).toBeHidden();
});
test('folder rename', async ({ page }) => {
const original = `Test folder`;
const renamed = `Renamed folders`;
// Prerequisite: create the folder to rename.
await submitNewFolder(page, original);
await expect(page.locator('#input-modal')).toBeHidden();
await expect(page.locator(`.file-item[data-folder-name="${original}"]`)).toBeVisible();
// Open context menu and trigger rename.
await page.locator(`.file-item[data-folder-name="${original}"] .file-actions`).click();
await expect(page.locator('#folder-context-menu')).toBeVisible();
await page.locator('#rename-folder-option').click();
await expect(page.locator('#input-modal')).toBeVisible();
await page.locator('#modal-input').fill(renamed);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#input-modal')).toBeHidden();
await expect(page.locator(`.file-item[data-folder-name="${renamed}"]`)).toBeVisible();
await expect(page.locator(`.file-item[data-folder-name="${original}"]`)).not.toBeVisible();
await expect(page).toHaveScreenshot('folder-renamed.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'),
],
});
});
// ── File upload ────────────────────────────────────────────────────────────
// These two tests are intentionally sequential: test 1 uploads the files,
// test 2 reads the state left by test 1. Both run inside the same describe
// block so Playwright executes them in order within a single worker.
test('upload of multiple files', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('#upload-btn').click();
await expect(page.locator('#upload-dropdown-menu')).toBeVisible();
await page.locator('#upload-files-btn').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([
path.join(FIXTURES, 'oxicloud-logo.jpg'),
path.join(FIXTURES, 'free_video_over_1MB.mp4'),
]);
// Each file appears in the list as soon as its individual upload completes.
await expect(page.locator('.file-item[data-file-name="oxicloud-logo.jpg"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.file-item[data-file-name="free_video_over_1MB.mp4"]')).toBeVisible({ timeout: 15_000 });
});
test('uploaded files rendered with thumbnails', async ({ page }) => {
// The beforeEach login re-loads the home folder; files uploaded in the
// previous test are already in the DB and visible immediately.
await expect(page.locator('.file-item[data-file-name="oxicloud-logo.jpg"]')).toBeVisible();
await expect(page.locator('.file-item[data-file-name="free_video_over_1MB.mp4"]')).toBeVisible();
// Image thumbnail is generated server-side: wait for the <img> to be
// visible (the error handler hides it while the server is still processing,
// then un-hides it once the src resolves successfully).
await expect(
page.locator('.file-item[data-file-name="oxicloud-logo.jpg"] .file-thumb')
).toBeVisible({ timeout: 10_000 });
// Video thumbnail is generated client-side from a canvas frame extraction.
// Give the browser 1 s to finish before taking the screenshot.
await page.waitForTimeout(1_000);
await expect(page).toHaveScreenshot('files-with-thumbnails.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'), // upload timestamps differ every run
],
});
});
test('drag and drop images onto dropzone', async ({ page }) => {
await dragFilesToDropzone(page, [
path.join(FIXTURES, 'blue-image.png'),
path.join(FIXTURES, 'green-image.png'),
path.join(FIXTURES, 'red-image.png'),
]);
// Each card appears as its upload completes.
await expect(page.locator('.file-item[data-file-name="blue-image.png"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.file-item[data-file-name="green-image.png"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.file-item[data-file-name="red-image.png"]')).toBeVisible({ timeout: 15_000 });
// PNGs get server-side thumbnails — wait for at least the first to resolve.
await expect(
page.locator('.file-item[data-file-name="blue-image.png"] .file-thumb')
).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveScreenshot('dropped-files.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'),
],
});
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

@@ -1,17 +0,0 @@
import { test, apiLogin, seedFilesAndFolders } from './helpers';
import { expect } from '@playwright/test';
test('TextFilesShowContents', async ({ page }) => {
await apiLogin(page);
await seedFilesAndFolders(page);
await page.goto('/');
// recorded steps
await page.getByTestId('Documents').click();
await page.getByTestId('notes.txt').click();
await expect(page.locator('pre')).toContainText('Hello from the codegen seed. Line two.');
await page.getByTestId('file-viewer-close-btn').click();
await page.getByTestId('README.md').click();
await expect(page.locator('pre')).toContainText('# Seeded A **markdown** file for the file browser.');
await page.getByTestId('file-viewer-close-btn').click();
});
-70
View File
@@ -1,70 +0,0 @@
# Codegen recorder templates
Each file here is a **starting point** for `just front-codegen`. It boots an
isolated container stack, runs your setup, then opens the Playwright Inspector
so you record from a known state. The file name is what shows up in the menu —
drop a new `*.spec.ts` here and it appears automatically.
## Anatomy
```ts
import { test, apiLogin } from '../helpers';
test('codegen: <name>', async ({ page }) => {
test.setTimeout(0); // no timeout — recorder stays open until you close it
await apiLogin(page); // setup: get to the state you want to record from
await page.goto('/files');
await page.pause(); // MUST be last — opens the Inspector
});
```
Rules:
- Import `test` (and any helpers) from `../helpers` — **not** `@playwright/test`.
That wires in the container-stack fixture and the JS-error guard.
- Set `test.setTimeout(0)` so the recorder doesn't time out while you work.
- End with `await page.pause()`. Everything **before** it runs first, so put as
much setup as you like there (log in, open a folder, start an upload, …) and
you'll record the continuation from that state.
- Use the helpers for setup: `apiLogin(page)` signs in via the API (no UI
clicks, selector-independent). Skip it for an anonymous/login flow.
## What carries into the saved spec
When you save a recording, the setup lines (everything before `page.pause()`,
minus `test.setTimeout`) are copied into the generated `scenarios/<name>.spec.ts`,
then your recorded steps are spliced in. So a recorder's setup == the saved
test's setup — keep it to the state you want every recording from this template
to start in.
## Seeding content before you record
To record flows that need existing files/folders (move, copy, delete,
multi-select, previews, sorting by type), seed them via the API in the setup —
no UI clicks, just like `apiLogin`. The `authed-files` template does this:
```ts
import { test, apiLogin, seedFilesAndFolders } from '../helpers';
test('codegen: authed-files', async ({ page }) => {
test.setTimeout(0);
await apiLogin(page);
await seedFilesAndFolders(page); // Documents/, Documents/Reports/, Images/ + files of each type
await page.goto('/');
await page.pause();
});
```
`helpers.ts` exposes the building blocks: `apiCreateFolder`, `apiUploadFile`,
`SAMPLE_FILES` (text / markdown / JSON / CSV / PNG / PDF), and the
`seedFilesAndFolders` convenience that lays down a small mixed-type tree.
**Remember:** the saved spec inherits this setup, so the recorded selectors only
have content to act on if `seedFilesAndFolders(page)` runs there too — it does,
because the template's setup is copied into the saved test.
## Add one
```sh
cp authed.spec.ts my-start.spec.ts # edit the setup, keep page.pause() last
just front-codegen # "my-start" is now in the menu
```
-15
View File
@@ -1,15 +0,0 @@
import { test } from '../helpers';
/**
* Codegen recorder — START: anonymous at the app root.
*
* Run via `just front-codegen` → pick "anon". No login; navigates to `/` and
* lets the app route an unauthenticated visitor (currently it redirects to
* /login?redirect=/). Use this to record the logged-out redirect behaviour or
* a future public landing page. Click the Record ⏺ button to start generating.
*/
test('codegen: anon', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await page.goto('/');
await page.pause();
});
@@ -1,30 +0,0 @@
import { test, apiLogin, seedFilesAndFolders } from '../helpers';
/**
* Codegen recorder — START: signed in, on a file browser already populated
* with folders and files of different types.
*
* Builds on the "authed" template: same `apiLogin`, but also seeds a small
* tree via the API before pausing, so you record flows that need existing
* content (move/copy/delete, drag-drop, previews, multi-select, sorting by
* type, …) without first creating it by hand.
*
* Seeded in the home folder (see `seedFilesAndFolders`):
* config.json, pixel.png (root)
* Documents/ → README.md, notes.txt
* Documents/Reports/ → data.csv, sample.pdf
* Images/ → pixel.png
*
* Run via `just front-codegen` → pick "authed-files". Click the Record ⏺
* button in the Inspector to start generating; copy the code into a real
* *.spec.ts (see scenarios/example.template.ts). Note the saved test must run
* `seedFilesAndFolders(page)` in its setup too, or the recorded selectors
* won't have anything to act on.
*/
test('codegen: authed-files', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await apiLogin(page);
await seedFilesAndFolders(page);
await page.goto('/');
await page.pause();
});
@@ -1,16 +0,0 @@
import { test, apiLogin } from '../helpers';
/**
* Codegen recorder — START: signed in, on the trash view.
*
* Run via `just front-codegen` → pick "authed-trash". An example of a
* deep-linked start point — copy this file (apiLogin + goto a route) to add
* your own recorders; `just front-codegen` discovers every *.spec.ts here
* automatically. Click the Record ⏺ button in the Inspector to start generating.
*/
test('codegen: authed-trash', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await apiLogin(page);
await page.goto('/trash');
await page.pause();
});
@@ -1,20 +0,0 @@
import { test, apiLogin } from '../helpers';
/**
* Codegen recorder — START: signed in, on the file browser.
*
* Run via `just front-codegen` → pick "authed". Boots an isolated container
* stack, runs the setup below, then page.pause() opens the Inspector. Click
* the Record ⏺ button to start generating; copy the code into a real
* *.spec.ts (see scenarios/example.template.ts).
*
* Extend the setup to record from a deeper state (e.g. open a folder, start an
* upload) — everything before page.pause() runs first, so you record the
* continuation.
*/
test('codegen: authed', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await apiLogin(page);
await page.goto('/');
await page.pause();
});
-13
View File
@@ -1,13 +0,0 @@
import { test } from '../helpers';
/**
* Codegen recorder — START: the SPA sign-in screen (not authenticated).
*
* Run via `just front-codegen` → pick "login". Use this to record the login
* flow itself. Click the Record ⏺ button in the Inspector to start generating.
*/
test('codegen: login', async ({ page }) => {
test.setTimeout(0); // recorder stays open until you close the Inspector
await page.goto('/login');
await page.pause();
});
-29
View File
@@ -1,29 +0,0 @@
/**
* Scaffold for a recorded spec — NOT collected by the runner (no `.spec`/`.test`
* in the name). Turn a codegen recording into a real test:
*
* 1. cp scenarios/example.template.ts scenarios/<name>.spec.ts
* 2. Match the setup to the codegen TEMPLATE you recorded from (below).
* 3. Paste the recorded steps from the Playwright Inspector where marked.
* 4. Add assertions.
*
* Run it with: npm run test:containers
*/
import { test, apiLogin } from './helpers';
// import { expect } from '@playwright/test'; // ← uncomment when you add assertions
test('describe the behaviour under test', async ({ page }) => {
// ── Setup: mirror your codegen TEMPLATE ──────────────────────────────────
// template "authed" / "authed-*":
await apiLogin(page);
await page.goto('/');
// template "login": (remove the two lines above) await page.goto('/login');
// template "anon": (remove the two lines above) await page.goto('/');
// ── ▼ Paste recorded steps from the Inspector below ▼ ────────────────────
// ── ▲ End recorded steps ▲ ───────────────────────────────────────────────
// Assertions, e.g.:
// await expect(page.getByRole('heading', { name: 'Files' })).toBeVisible();
});
-468
View File
@@ -1,468 +0,0 @@
import { test as base, Page, expect } from '@playwright/test';
import { startStack, Stack } from '../fixtures/oxicloud-stack';
/**
* When `OXICLOUD_E2E_CONTAINERS=1`, each Playwright worker boots its own
* isolated DB + app stack via Testcontainers (see `playwright.containers.
* config.ts`). Otherwise the legacy single-server webServer flow is used
* and this fixture is an inert pass-through.
*/
const USE_CONTAINERS = process.env.OXICLOUD_E2E_CONTAINERS === '1';
type WorkerFixtures = {
/** The per-worker isolated stack, or `null` in the legacy webServer flow. */
stack: Stack | null;
};
/**
* Extended `test` fixture with two responsibilities:
*
* 1. `stack` (worker-scoped) — in container mode, starts a dedicated
* DB + app stack per worker, seeds its admin, and tears it down at
* worker exit. The app instance is reused across every test the worker
* runs, so container startup is paid once per worker, not per test.
* 2. `page` — fails any test that produces an unhandled browser-side JS
* error (SyntaxError, ReferenceError, uncaught rejection, etc.).
*
* Import `test` from this module instead of `@playwright/test` so every spec
* gets both behaviours automatically without per-file boilerplate.
*/
export const test = base.extend<object, WorkerFixtures>({
stack: [
async ({}, use) => {
if (!USE_CONTAINERS) {
await use(null);
return;
}
const stack = await startStack();
try {
await seedAdmin(stack.baseURL);
await use(stack);
} finally {
await stack.stop();
}
},
// Worker-scoped: booting Postgres + the app container (image build on
// first run, migrations on boot) can take well over the default 30s
// fixture timeout. Match the 180s container startup budget in startStack.
{ scope: 'worker', timeout: 200_000 },
],
// Point relative `page.goto('/')` at the per-worker stack when present;
// otherwise fall back to the baseURL configured in the project (the
// legacy webServer at :8087).
baseURL: async ({ stack }, use, testInfo) => {
await use(stack ? stack.baseURL : testInfo.project.use.baseURL);
},
page: async ({ page }, use) => {
const jsErrors: Error[] = [];
page.on('pageerror', (err) => jsErrors.push(err));
await use(page);
if (jsErrors.length > 0) {
throw new Error(
`${jsErrors.length} unhandled JS error(s) on page:\n` +
jsErrors.map((e) => ` • ${e.message}`).join('\n')
);
}
},
});
export const TEST_ADMIN = {
username: 'admin',
email: 'testadmin@example.com',
password: 'TestPassword1!',
};
/**
* Create the first-admin account via the public `POST /api/setup` route.
* Idempotent: a 409 (admin already exists) is treated as success so the
* call is safe to retry and to run once per worker.
*
* Shared by `global-setup.ts` (legacy flow, single server) and the
* worker-scoped `stack` fixture (container flow, one server per worker).
*/
export async function seedAdmin(baseURL: string, admin = TEST_ADMIN): Promise<void> {
const res = await fetch(`${baseURL}/api/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: admin.username,
email: admin.email,
password: admin.password,
}),
});
if (!res.ok && res.status !== 409) {
throw new Error(`Admin setup failed: ${res.status} ${await res.text()}`);
}
}
/**
* Authenticate the page's browser context, ready for subsequent
* `page.goto()` calls to load already-signed-in.
*
* Uses the SPA's real login flow (`page.goto('/login')` → fill form
* → submit) rather than a bare `POST /api/auth/login`, so this works
* correctly under both auth modes the test env supports:
*
* * `OXICLOUD_AUTH_OPAQUE_MODE=off` — SPA does legacy login,
* server accepts.
* * `OXICLOUD_AUTH_OPAQUE_MODE=migrate` — first login legacy-
* succeeds + silently mints an OPAQUE envelope (Phase 2 hook);
* every subsequent login the SPA detects the envelope via
* `/api/auth/opaque/login/lookup` and does the full KE1/KE3
* OPAQUE handshake. Legacy `POST /api/auth/login` would 403
* with `opaque_migrated_use_opaque` (Phase 4) from the second
* login on — that's what the old bare-POST apiLogin used to
* hit as soon as OPAQUE went from `off` to `migrate`.
* * `OXICLOUD_DPOP_MODE=required` — the SPA computes and sends
* `dpop_jkt` in the login body; the session is created bound.
* A bare-POST wouldn't include it, so subsequent requests
* wouldn't get DPoP-signed. Going through the SPA keeps the
* end-to-end flow honest.
*
* Overhead vs the old direct POST: ~200-500 ms per test to load
* `/login`, submit, and wait for the post-login redirect. Runs
* once per test (from `beforeEach`), so the total suite tax is
* modest and the coverage payoff is real.
*/
export async function apiLogin(page: Page, admin = TEST_ADMIN): Promise<void> {
// Idempotence check — many specs' beforeEach + test body both call
// apiLogin; the old bare-POST version was a no-op on a live
// session, and callers depend on that. Under UI-driven login,
// navigating to /login when already authenticated triggers the
// SPA's layout guard to redirect away → the login form never
// renders → the fill() below times out. Probe /api/auth/me FIRST:
// 2xx means we're already signed in as SOMEONE. If that's the
// right admin, no-op; otherwise fall through to a fresh login.
const probe = await page.request.get('/api/auth/me').catch(() => null);
if (probe?.ok()) {
const body = (await probe.json().catch(() => ({}))) as { username?: string };
if (body.username === admin.username) return;
}
await page.goto('/login');
// Wait for the SPA's boot probes (`getOidcProviders` +
// `getAuthStatus` in `login/+page.svelte::onMount`) to complete
// BEFORE touching the form. Otherwise the boot `$effect` fires
// MID-FILL — when `booting` flips from true to false, the
// auto-focus effect steals focus back to the identifier input,
// and any remaining characters of the password-fill land in
// the username field. Symptom: username="adminTestPassword1!",
// password="", submit-button shows "Send sign-in link" → SPA
// fires magic-link/send with the concatenated identifier and
// login never completes.
//
// `networkidle` waits for the network to have no more than 0
// requests in flight for 500 ms. By that point providers
// + status have landed and `booting = false` has already
// stabilised → the auto-focus effect fired ONCE (harmlessly,
// before we touch the form), never again during our fills.
await page.waitForLoadState('networkidle');
await page.getByTestId('login-username-input').fill(admin.username);
await page.getByTestId('login-password-input').fill(admin.password);
await page.getByTestId('login-submit-btn').click();
// Post-login the SPA's `goto(redirectTarget)` sends the user
// to `/files` (default) or a `?redirect=` target — OR to
// `/profile?forcePasswordChange=1` when the backend has stamped
// `force_password_change_at_next_login=true` on this account
// (usually because a prior admin-reset test flipped it). Match
// any post-login destination that ISN'T `/login` itself. The
// 15s ceiling covers the OPAQUE-post-migration path: WASM load
// + KE1 + KE3 + Argon2id.
await page.waitForURL((url) => !url.pathname.startsWith('/login'), {
timeout: 15_000,
waitUntil: 'commit'
});
}
/**
* Build the `x-csrf-token` header for a cookie-authenticated, state-changing
* request. The server uses a double-submit cookie: login sets a non-HttpOnly
* `oxicloud_csrf` cookie, and POST/PUT/DELETE must echo its value in the
* header (see `csrf_middleware`). Returns `{}` if no cookie is present (e.g.
* not logged in), letting the caller's request fail with the real 4xx.
*/
async function csrfHeaders(page: Page): Promise<Record<string, string>> {
const cookies = await page.context().cookies();
const token = cookies.find((c) => c.name === 'oxicloud_csrf')?.value;
return token ? { 'x-csrf-token': token } : {};
}
/** A folder as returned by the API (subset we use). */
export type ApiFolder = { id: string; parent_id: string | null };
/**
* Create a folder via the API and return it. `parentId` omitted ⇒ the folder
* is created in the caller's home (root) folder, and the returned `parent_id`
* is that home folder's id (handy as the target for "root" file uploads, which
* require an explicit folder id). Requires the page to already be
* authenticated (see `apiLogin`).
*/
export async function apiCreateFolder(
page: Page,
name: string,
parentId?: string,
): Promise<ApiFolder> {
const res = await page.request.post('/api/folders', {
headers: await csrfHeaders(page),
data: parentId ? { name, parent_id: parentId } : { name },
});
if (!res.ok()) {
throw new Error(`apiCreateFolder(${name}) failed: ${res.status()} ${await res.text()}`);
}
return (await res.json()) as ApiFolder;
}
/**
* Create a regular user via the admin API. Requires the page to be authenticated
* as an admin (see `apiLogin`). Returns the created username. Handy for tests
* that need a second account (sharing, group membership, recipient search).
*/
export async function apiAdminCreateUser(page: Page, username: string): Promise<string> {
const res = await page.request.post('/api/admin/users', {
headers: await csrfHeaders(page),
data: {
username,
password: 'TestPassword1!',
email: `${username}@example.test`,
role: 'user',
quota_bytes: 1073741824,
},
});
if (!res.ok()) {
throw new Error(`apiAdminCreateUser(${username}) failed: ${res.status()} ${await res.text()}`);
}
return username;
}
/**
* Create a group via the API (requires an authenticated admin/manager). Returns
* the group name. Useful for sharing-with-group and group-membership tests.
*/
export async function apiCreateGroup(page: Page, name: string): Promise<string> {
const res = await page.request.post('/api/groups', {
headers: await csrfHeaders(page),
data: { name, description: null },
});
if (!res.ok()) {
throw new Error(`apiCreateGroup(${name}) failed: ${res.status()} ${await res.text()}`);
}
return name;
}
/** Move a folder to trash via the API (DELETE /api/folders/{id}). */
export async function apiTrashFolder(page: Page, folderId: string): Promise<void> {
const res = await page.request.delete(`/api/folders/${folderId}`, {
headers: await csrfHeaders(page),
});
if (!res.ok()) {
throw new Error(`apiTrashFolder(${folderId}) failed: ${res.status()} ${await res.text()}`);
}
}
/**
* Record an access in the user's "recent" list (POST /api/recent/{type}/{id})
* so the /recent route has deterministic content. Best-effort: a non-2xx is
* tolerated so callers don't fail on a recents quirk.
*/
export async function apiRecordRecent(
page: Page,
itemType: 'file' | 'folder',
id: string,
): Promise<void> {
await page.request
.post(`/api/recent/${itemType}/${id}`, { headers: await csrfHeaders(page) })
.catch(() => {});
}
/** Empty the trash via the API (DELETE /api/trash/empty) for a clean slate. */
export async function apiEmptyTrash(page: Page): Promise<void> {
const res = await page.request.delete('/api/trash/empty', { headers: await csrfHeaders(page) });
if (!res.ok()) {
throw new Error(`apiEmptyTrash failed: ${res.status()} ${await res.text()}`);
}
}
/**
* Flip the caller's `ui_preferences.hide_dotfiles` server-side. Used by the
* dotfile-filter e2e spec to establish a known state at test start and to
* clean up at teardown so sibling tests aren't polluted by a leftover
* "hidden" mode (the preference is persistent across sessions because it's
* stored on `auth.users.ui_preferences`, not in localStorage).
*
* PATCHes only `hide_dotfiles`; siblings in the bag (view_mode, future
* keys) survive the shallow-merge on the server side.
*/
export async function apiSetHideDotfiles(page: Page, hide: boolean): Promise<void> {
const res = await page.request.patch('/api/auth/me/profile', {
headers: await csrfHeaders(page),
data: { ui_preferences: { hide_dotfiles: hide } },
});
if (!res.ok()) {
throw new Error(`apiSetHideDotfiles(${hide}) failed: ${res.status()} ${await res.text()}`);
}
}
/** A file to seed: its name, MIME type, and raw bytes. */
export type SeedFile = { name: string; mimeType: string; body: Buffer };
/**
* Upload one file via the API into `folderId`. The target folder is
* **required**: the server resolves the file's owner from its parent folder
* and rejects an upload with no `folder_id` ("folder_id is required to
* determine file owner"). For a "root" file, pass the home folder's id — the
* `parent_id` returned by `apiCreateFolder(name)`.
*
* The `folder_id` field is sent before `file` because the upload handler
* parses the multipart stream in order and permission-checks the target folder
* before spooling the body.
*/
export async function apiUploadFile(
page: Page,
file: SeedFile,
folderId: string,
): Promise<void> {
const filePart = { name: file.name, mimeType: file.mimeType, buffer: file.body };
const res = await page.request.post('/api/files/upload', {
headers: await csrfHeaders(page),
multipart: { folder_id: folderId, file: filePart },
});
if (!res.ok()) {
throw new Error(`apiUploadFile(${file.name}) failed: ${res.status()} ${await res.text()}`);
}
}
/**
* A small library of files spanning common types (text, markdown, JSON, CSV,
* PNG image, PDF), so a recording starts from a browser that exercises the
* different icons / previews / row renderers. Bytes are tiny but valid.
*/
export const SAMPLE_FILES = {
text: (): SeedFile => ({
name: 'notes.txt',
mimeType: 'text/plain',
body: Buffer.from('Hello from the codegen seed.\nLine two.\n'),
}),
markdown: (): SeedFile => ({
name: 'README.md',
mimeType: 'text/markdown',
body: Buffer.from('# Seeded\n\nA **markdown** file for the file browser.\n'),
}),
json: (): SeedFile => ({
name: 'config.json',
mimeType: 'application/json',
body: Buffer.from(JSON.stringify({ seeded: true, items: [1, 2, 3] }, null, 2)),
}),
csv: (): SeedFile => ({
name: 'data.csv',
mimeType: 'text/csv',
body: Buffer.from('id,name,size\n1,alpha,10\n2,beta,20\n'),
}),
png: (): SeedFile => ({
name: 'pixel.png',
mimeType: 'image/png',
// 1×1 transparent PNG.
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
),
}),
pdf: (): SeedFile => ({
name: 'sample.pdf',
mimeType: 'application/pdf',
body: Buffer.from(
'%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]>>endobj\n' +
'trailer<</Root 1 0 R>>\n%%EOF\n',
),
}),
};
/**
* Seed a representative tree of folders and files of different types for the
* authenticated user, so a codegen recording (or a test) starts from a
* populated file browser. Idempotent enough for one run per worker; re-running
* creates duplicate names (the backend allows them). Requires `apiLogin` first.
*
* Layout created in the user's home folder:
*
* config.json (home/root)
* pixel.png (home/root)
* Documents/ README.md, notes.txt
* Documents/Reports/ data.csv, sample.pdf
* Images/ pixel.png
*
* Returns the created folder ids (plus the resolved `home` id) so callers can
* deep-link or assert.
*/
export async function seedFilesAndFolders(
page: Page,
): Promise<{ home: string; documents: string; reports: string; images: string }> {
const documents = await apiCreateFolder(page, 'Documents');
const reports = await apiCreateFolder(page, 'Reports', documents.id);
const images = await apiCreateFolder(page, 'Images');
// Created-at-root folders carry the home folder id as their parent — use it
// as the target for the "root" files (uploads require an explicit folder).
const home = documents.parent_id;
if (!home) {
throw new Error('seedFilesAndFolders: could not resolve home folder id from a root folder');
}
await apiUploadFile(page, SAMPLE_FILES.json(), home);
await apiUploadFile(page, SAMPLE_FILES.png(), home);
await apiUploadFile(page, SAMPLE_FILES.markdown(), documents.id);
await apiUploadFile(page, SAMPLE_FILES.text(), documents.id);
await apiUploadFile(page, SAMPLE_FILES.csv(), reports.id);
await apiUploadFile(page, SAMPLE_FILES.pdf(), reports.id);
await apiUploadFile(page, SAMPLE_FILES.png(), images.id);
return { home, documents: documents.id, reports: reports.id, images: images.id };
}
/**
* Log in as the test admin and wait until the main app is fully initialized.
*
* We wait for two things after the login redirect:
* 1. `#sidebar` — confirms the main HTML has loaded.
* 2. `#user-avatar-btn .user-vignette` — confirms that `setupUserMenu()` has
* run and mounted the avatar vignette. This is the earliest reliable
* signal that the click-handler on the avatar button is attached, so any
* subsequent test that opens the user menu will not race against JS startup.
*
* Without (2), CI (Ubuntu + Xvfb) occasionally clicks the button before the
* event listener is registered because the JS runtime is slower than on macOS.
*/
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-submit').click();
await expect(page.locator('#sidebar')).toBeVisible({ timeout: 15_000 });
// Wait for the JS app to initialise: avatar vignette present ⟹ click handler attached.
await expect(page.locator('#user-avatar-btn .user-vignette')).toBeAttached({ timeout: 10_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.
// Use expect() (5 s default) rather than waitForSelector() (30 s) so a JS
// crash fails fast instead of hanging for the full test timeout.
await expect(
page.locator('#language-panel:not(.hidden), #login-panel:not(.hidden)').first()
).toBeAttached();
if (await page.locator('#language-panel').isVisible()) {
await page.locator('#language-continue').click();
}
await expect(page.locator('#login-panel')).toBeVisible();
}
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bash
# Interactive Playwright codegen driver (invoked by `just front-codegen`).
#
# Pick a starting-point recorder from scenarios/codegen/, record against a
# throwaway container stack, then turn the recording into a real spec:
# record → name (re-prompts until free) → $EDITOR paste → assemble → run →
# reopen in --debug on failure.
set -euo pipefail
# Run from tests/e2e regardless of how we were invoked.
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# ── Pick a recorder (menu of scenarios/codegen/*.spec.ts) ─────────────────────
shopt -s nullglob
files=(scenarios/codegen/*.spec.ts)
[[ ${#files[@]} -gt 0 ]] || { echo "no recorder files in scenarios/codegen/" >&2; exit 1; }
names=()
for f in "${files[@]}"; do names+=("$(basename "$f" .spec.ts)"); done
echo "Pick a codegen starting point:"
PS3="› "
select n in "${names[@]}"; do [[ -n "${n:-}" ]] && break; echo " invalid choice — enter a number"; done
echo "→ recorder: $n"
# ── Environment ───────────────────────────────────────────────────────────────
# NixOS (and distros where Playwright's bundled chromium can't run) need a
# system chromium. Respect an explicit PW_CHROMIUM_PATH, else auto-detect.
chromium="${PW_CHROMIUM_PATH:-$(command -v chromium 2>/dev/null || true)}"
if [[ -n "$chromium" ]]; then export PW_CHROMIUM_PATH="$chromium"; echo "→ chromium: $chromium"; fi
# Build the app image from the CURRENT source for codegen, so recordings reflect
# the latest frontend (and its data-testid hooks) instead of a stale image.
#
# We build it HERE with the buildx CLI rather than letting the Testcontainers
# fixture build it. The fixture's JS-driven BuildKit build does NOT reuse the
# local cargo cache mounts, so it cold-compiles the whole binary every run
# (>5 min) and blows past the 200 s stack-setup timeout. The CLI build uses the
# `builder-cache` stage and the shared cache mounts, so it recompiles only what
# changed (seconds on a warm cache); we then hand the tag to the fixture via
# $OXICLOUD_IMAGE, which makes it skip its own build entirely.
#
# Escape hatch: export OXICLOUD_IMAGE yourself to force-reuse a prebuilt image
# (it must be built with --build-arg VITE_E2E=1, and may be stale vs local edits).
if [[ -n "${OXICLOUD_IMAGE:-}" ]]; then
echo "→ OXICLOUD_IMAGE=$OXICLOUD_IMAGE (env override) — reusing it; needs VITE_E2E=1 and may be stale."
else
REPO_ROOT="$(cd ../.. && pwd)"
OXICLOUD_IMAGE="oxicloud-e2e:latest"
echo "→ building $OXICLOUD_IMAGE from current source (buildx, incremental — only changed crates/assets recompile)…"
DOCKER_BUILDKIT=1 docker build \
--build-arg BUILDER=builder-cache \
--build-arg BIN_DIR=/app/bin \
--build-arg VITE_E2E=1 \
--tag "$OXICLOUD_IMAGE" \
"$REPO_ROOT"
export OXICLOUD_IMAGE
echo "→ OXICLOUD_IMAGE=$OXICLOUD_IMAGE (prebuilt; fixture will reuse it)"
fi
export OXICLOUD_E2E_CONTAINERS=1
# ── Record ────────────────────────────────────────────────────────────────────
npx playwright test -c playwright.codegen.config.ts "scenarios/codegen/$n.spec.ts" --headed --workers=1
# ── Save the recording as a real spec ─────────────────────────────────────────
# Keeps the template's setup (apiLogin/goto), drops page.pause(), and splices in
# the steps you paste (handles a full codegen file or bare action lines).
echo
# Prompt for a spec name until it's valid and not already taken (blank = skip).
spec=""
while :; do
read -rp "Save recording to a spec? Enter a name (blank to skip): " out || true
[[ -z "${out:-}" ]] && break
spec="$(node scripts/finish-codegen.mjs --resolve "$out")" && break || true
done
[[ -n "$spec" ]] || exit 0
# Collect the recording in $VISUAL/$EDITOR (paste, then save & close). For GUI
# editors set a blocking flag, e.g. EDITOR="code --wait". Falls back to nano/vi,
# then to a Ctrl-D paste if no editor is available.
editor="${VISUAL:-${EDITOR:-}}"
[[ -z "$editor" ]] && editor="$(command -v nano 2>/dev/null || command -v vi 2>/dev/null || true)"
tmp="$(mktemp --suffix=.recording.ts)"
trap 'rm -f "$tmp"' EXIT
if [[ -n "$editor" ]]; then
echo "Opening $editor — paste the recorded output, then save & close."
$editor "$tmp"
recording="$(cat "$tmp")"
else
echo "No \$VISUAL/\$EDITOR set. Paste the recorded output, then press Ctrl-D:"
recording="$(cat)"
fi
if [[ -z "${recording//[[:space:]]/}" ]]; then
echo "(empty — nothing saved)"
exit 0
fi
printf '%s' "$recording" | node scripts/finish-codegen.mjs "scenarios/codegen/$n.spec.ts" "$out" >/dev/null
# ── Run the new spec; reopen in --debug on failure ────────────────────────────
echo "→ running $spec"
if npx playwright test -c playwright.containers.config.ts "$spec" --workers=1; then
echo "✓ $spec passed"
else
echo "✗ $spec failed — reopening in debug mode…"
npx playwright test -c playwright.containers.config.ts "$spec" --workers=1 --debug
fi
-120
View File
@@ -1,120 +0,0 @@
// Assemble a real spec from a codegen recording.
//
// Usage: node scripts/finish-codegen.mjs <templatePath> <outName> (recording on stdin)
//
// Takes the recorder template the user started from (scenarios/codegen/<x>.spec.ts),
// keeps its SETUP (everything before page.pause(), minus test.setTimeout), and
// splices in the recorded steps read from stdin — writing scenarios/<outName>.spec.ts.
//
// The pasted recording may be either bare action lines, e.g.
// await page.getByText('…').click();
// or a whole codegen file, e.g.
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => { await page.…; });
// In the latter case the import + test(...) wrapper are stripped, leaving the body.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
const argv = process.argv.slice(2);
/** Sanitize a name to scenarios/<base>.spec.ts; exit non-zero if invalid/taken. */
function resolveOutPath(rawName) {
const base = (rawName ?? '')
.trim()
.replace(/\.spec\.ts$/, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
if (!base) {
console.error('finish-codegen: empty/invalid output name');
process.exit(1);
}
const outPath = `scenarios/${base}.spec.ts`;
if (existsSync(outPath)) {
console.error(`finish-codegen: ${outPath} already exists — pick another name`);
process.exit(1);
}
return { base, outPath };
}
// `--resolve <name>`: validate a name + print its spec path (no stdin, no write).
// The just recipe loops this to re-prompt until a free, valid name is given.
if (argv[0] === '--resolve') {
console.log(resolveOutPath(argv[1]).outPath);
process.exit(0);
}
const [templatePath, rawOutName] = argv;
if (!templatePath || !rawOutName) {
console.error('usage: finish-codegen.mjs <templatePath> <outName> (recording on stdin)');
console.error(' or: finish-codegen.mjs --resolve <outName>');
process.exit(1);
}
const recording = readFileSync(0, 'utf-8'); // stdin
const template = readFileSync(templatePath, 'utf-8');
const { base, outPath } = resolveOutPath(rawOutName);
/** Body between the first `=> {` and the final `});` of a test file/snippet. */
function callbackBody(src) {
const m = src.match(/=>\s*\{([\s\S]*)\}\s*\)\s*;?\s*$/);
return m ? m[1] : null;
}
/** Re-indent non-empty lines to a common 2-space base. */
function reindent(lines, spaces = 2) {
const body = lines.filter((l) => l.trim());
const min = body.length ? Math.min(...body.map((l) => l.match(/^\s*/)[0].length)) : 0;
const pad = ' '.repeat(spaces);
return lines.map((l) => (l.trim() ? pad + l.slice(min) : ''));
}
// ── 1. helpers import from the template (fix ../helpers → ./helpers) ──────────
const helperImport =
(template.match(/^import .*from ['"]\.\.\/helpers['"];?$/m) || [])[0]?.replace(
/\.\.\/helpers/,
'./helpers',
) ?? "import { test } from './helpers';";
// ── 2. setup lines = template body before page.pause(), minus setTimeout ──────
const tBody = callbackBody(template);
const setupLines = reindent(
(tBody ?? '')
.split('\n')
.filter((l) => l.trim() && !/page\.pause\(/.test(l) && !/test\.setTimeout\(/.test(l)),
);
// ── 3. recorded steps from stdin (strip wrapper if a whole file was pasted) ───
let recBody = recording;
if (/^\s*(import\b|test\s*\()/.test(recording)) {
recBody = callbackBody(recording) ?? recording;
}
let actionLines = recBody.split('\n').filter((l) => !/^\s*import\s/.test(l));
while (actionLines.length && !actionLines[0].trim()) actionLines.shift();
while (actionLines.length && !actionLines[actionLines.length - 1].trim()) actionLines.pop();
actionLines = reindent(actionLines);
if (!actionLines.length) {
console.error('finish-codegen: no recorded steps found in the pasted input');
process.exit(1);
}
// ── 4. compose ───────────────────────────────────────────────────────────────
const imports = [helperImport];
if (/\bexpect\(/.test(actionLines.join('\n'))) {
imports.push("import { expect } from '@playwright/test';");
}
const out = [
...imports,
'',
`test('${base.replace(/'/g, "\\'")}', async ({ page }) => {`,
...setupLines,
'',
' // recorded steps',
...actionLines,
'});',
'',
].join('\n');
writeFileSync(outPath, out);
console.error(`✓ wrote ${outPath}`);
console.log(outPath); // stdout = machine-readable path; the recipe runs it
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env bash
# Boot a clean test DB then start OxiCloud (passed as arguments).
# Used by playwright.config.ts as the webServer command so that both
# `npm test` and `npx playwright test` always start from an empty database.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/e2e/storage"
# Mirror everything (these markers + the server's own stdout/stderr) to a log
# file as well as the console. Playwright captures the webServer's stdout, but
# in CI that capture isn't always surfaced in the job log — the file is, via an
# `if: always()` "print server startup log" step in ci.yml. The final
# `exec "$@"` below inherits these redirected fds, so the server's output is
# tee'd too, while the process still replaces this shell (Playwright tracks the
# PID for teardown).
SERVER_LOG="$REPO_ROOT/tests/e2e/server-startup.log"
exec > >(tee "$SERVER_LOG") 2>&1
mark() { echo "[start-server $(date -u +%H:%M:%S)] $*"; }
mark "repo_root=$REPO_ROOT"
mark "server binary args: $*"
if [[ -n "${1:-}" && "$1" != "cargo" ]]; then
ls -la "$1" 2>&1 || mark "WARNING: server binary '$1' not found"
fi
mark "DATABASE_URL=${DATABASE_URL:-<unset>} OXICLOUD_SERVER_PORT=${OXICLOUD_SERVER_PORT:-<unset>}"
# ensure storage is empty before starting
mark "wiping $OXICLOUD_STORAGE_PATH to ensure clean startup"
rm -rf "$OXICLOUD_STORAGE_PATH"
mkdir -p "$OXICLOUD_STORAGE_PATH"
# Spawn database
mark "spawning test database…"
bash "$REPO_ROOT/tests/common/spawn-db.sh"
mark "database ready; starting server…"
# Point `--config` at an INTENTIONALLY EMPTY file. Two reasons:
#
# 1. Blocks the fallback `dotenvy::dotenv()` probe of `$CWD/.env`
# in main.rs — otherwise a developer's local `.env`
# (typical: `OXICLOUD_METRICS_LISTEN=127.0.0.1:9090`) leaks
# into the test server via CWD auto-load and clashes with
# anything the dev is already running.
#
# 2. An empty file has nothing to override — so every var
# Playwright loaded into `webServer.env` from server.env AND
# every per-suite override on top (SERVER_PORT, STORAGE_PATH,
# RUST_LOG, OPAQUE_MODE, DPOP_MODE, …) reaches the server
# intact. We can't use `--config server.env` here because
# `dotenvy::from_filename_override` would clobber Playwright's
# per-suite overrides for the keys it shares with the file.
#
# See tests/common/empty.env for the header explaining this.
CONFIG_PATH="$REPO_ROOT/tests/common/empty.env"
# `cargo run` needs `--` to separate cargo's own flags from the
# binary's — a direct-binary invocation takes them raw. First arg
# tells us which we're in.
if [[ "${1:-}" == "cargo" ]]; then
exec "$@" -- --config "$CONFIG_PATH"
else
exec "$@" --config "$CONFIG_PATH"
fi