diff --git a/.github/workflows/playwright-update-snapshots.yml b/.github/workflows/playwright-update-snapshots.yml deleted file mode 100644 index cadc24a5..00000000 --- a/.github/workflows/playwright-update-snapshots.yml +++ /dev/null @@ -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" diff --git a/tests/common/server.env b/tests/common/server.env index 2a0c4118..e11410b5 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -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 diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 67701d96..fb376e8a 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -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", diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts deleted file mode 100644 index 59fa353c..00000000 --- a/tests/e2e/playwright.config.ts +++ /dev/null @@ -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. - }, - }, -}); diff --git a/tests/e2e/playwright.containers.config.ts b/tests/e2e/playwright.containers.config.ts deleted file mode 100644 index 3d79b26d..00000000 --- a/tests/e2e/playwright.containers.config.ts +++ /dev/null @@ -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'] }, - }, - ], -}); diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts b/tests/e2e/scenarios/01-home-and-login.spec.ts deleted file mode 100644 index 62705c12..00000000 --- a/tests/e2e/scenarios/01-home-and-login.spec.ts +++ /dev/null @@ -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') ] - }); - - }); -}); diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-chromium-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-chromium-darwin.png deleted file mode 100644 index 40e83907..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-chromium-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-chromium-linux.png deleted file mode 100644 index 257e698c..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-chromium-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-chromium-darwin.png deleted file mode 100644 index 3f6b81f1..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-chromium-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-chromium-linux.png deleted file mode 100644 index 17afdfa8..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-firefox-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-firefox-darwin.png deleted file mode 100644 index 3346dcb7..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-firefox-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-firefox-linux.png deleted file mode 100644 index af14b836..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-darktheme-firefox-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-firefox-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-firefox-darwin.png deleted file mode 100644 index 06a6d202..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-firefox-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-firefox-linux.png deleted file mode 100644 index 368300ea..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-firefox-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-chromium-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-chromium-darwin.png deleted file mode 100644 index 15c8ae50..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-chromium-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-chromium-linux.png deleted file mode 100644 index 473340fa..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-firefox-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-firefox-darwin.png deleted file mode 100644 index ee62824f..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-firefox-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-firefox-linux.png deleted file mode 100644 index 5a60a282..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/home-files-lightheme-firefox-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-chromium-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-chromium-darwin.png deleted file mode 100644 index 83419b31..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-chromium-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-chromium-linux.png deleted file mode 100644 index d4d207a0..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-firefox-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-firefox-darwin.png deleted file mode 100644 index f1c1ad88..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-firefox-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-firefox-linux.png deleted file mode 100644 index d7155de8..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/language-selector-firefox-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-chromium-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-chromium-darwin.png deleted file mode 100644 index 2fa95385..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-chromium-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-chromium-linux.png deleted file mode 100644 index e9d7a7bc..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-chromium-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-chromium-darwin.png deleted file mode 100644 index 47b58b25..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-chromium-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-chromium-linux.png deleted file mode 100644 index 26726c83..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-firefox-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-firefox-darwin.png deleted file mode 100644 index f1eac9a3..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-firefox-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-firefox-linux.png deleted file mode 100644 index 9550180d..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-error-firefox-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-firefox-darwin.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-firefox-darwin.png deleted file mode 100644 index 2dfc1458..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-firefox-linux.png b/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-firefox-linux.png deleted file mode 100644 index 4983bd7c..00000000 Binary files a/tests/e2e/scenarios/01-home-and-login.spec.ts-snapshots/login-panel-firefox-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts b/tests/e2e/scenarios/02-folder-management.spec.ts deleted file mode 100644 index d78d2211..00000000 --- a/tests/e2e/scenarios/02-folder-management.spec.ts +++ /dev/null @@ -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 = { - '.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 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'), - ], - }); - }); -}); diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/dropped-files-chromium-darwin.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/dropped-files-chromium-darwin.png deleted file mode 100644 index bb79fe50..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/dropped-files-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/dropped-files-chromium-linux.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/dropped-files-chromium-linux.png deleted file mode 100644 index 450d8764..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/dropped-files-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/dropped-files-firefox-darwin.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/dropped-files-firefox-darwin.png deleted file mode 100644 index 1b33894b..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/dropped-files-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/files-with-thumbnails-chromium-darwin.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/files-with-thumbnails-chromium-darwin.png deleted file mode 100644 index 581d9567..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/files-with-thumbnails-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/files-with-thumbnails-chromium-linux.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/files-with-thumbnails-chromium-linux.png deleted file mode 100644 index 32c51bb4..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/files-with-thumbnails-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/files-with-thumbnails-firefox-darwin.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/files-with-thumbnails-firefox-darwin.png deleted file mode 100644 index bd430dc9..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/files-with-thumbnails-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-created-chromium-darwin.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-created-chromium-darwin.png deleted file mode 100644 index 70ced195..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-created-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-created-chromium-linux.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-created-chromium-linux.png deleted file mode 100644 index 3f750b38..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-created-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-created-firefox-darwin.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-created-firefox-darwin.png deleted file mode 100644 index 867512b5..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-created-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-renamed-chromium-darwin.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-renamed-chromium-darwin.png deleted file mode 100644 index e842d334..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-renamed-chromium-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-renamed-chromium-linux.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-renamed-chromium-linux.png deleted file mode 100644 index 2529bb3f..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-renamed-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-renamed-firefox-darwin.png b/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-renamed-firefox-darwin.png deleted file mode 100644 index 31cec32f..00000000 Binary files a/tests/e2e/scenarios/02-folder-management.spec.ts-snapshots/folder-renamed-firefox-darwin.png and /dev/null differ diff --git a/tests/e2e/scenarios/TextFilesShowContents.spec.ts b/tests/e2e/scenarios/TextFilesShowContents.spec.ts deleted file mode 100644 index 5593eaeb..00000000 --- a/tests/e2e/scenarios/TextFilesShowContents.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/tests/e2e/scenarios/codegen/README.md b/tests/e2e/scenarios/codegen/README.md deleted file mode 100644 index 2797ca58..00000000 --- a/tests/e2e/scenarios/codegen/README.md +++ /dev/null @@ -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: ', 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/.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 -``` diff --git a/tests/e2e/scenarios/codegen/anon.spec.ts b/tests/e2e/scenarios/codegen/anon.spec.ts deleted file mode 100644 index 54139b36..00000000 --- a/tests/e2e/scenarios/codegen/anon.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/tests/e2e/scenarios/codegen/authed-files.spec.ts b/tests/e2e/scenarios/codegen/authed-files.spec.ts deleted file mode 100644 index 80a948ca..00000000 --- a/tests/e2e/scenarios/codegen/authed-files.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/tests/e2e/scenarios/codegen/authed-trash.spec.ts b/tests/e2e/scenarios/codegen/authed-trash.spec.ts deleted file mode 100644 index 1a1f578f..00000000 --- a/tests/e2e/scenarios/codegen/authed-trash.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/tests/e2e/scenarios/codegen/authed.spec.ts b/tests/e2e/scenarios/codegen/authed.spec.ts deleted file mode 100644 index 66f9fda4..00000000 --- a/tests/e2e/scenarios/codegen/authed.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/tests/e2e/scenarios/codegen/login.spec.ts b/tests/e2e/scenarios/codegen/login.spec.ts deleted file mode 100644 index e596dcd9..00000000 --- a/tests/e2e/scenarios/codegen/login.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/tests/e2e/scenarios/example.template.ts b/tests/e2e/scenarios/example.template.ts deleted file mode 100644 index 6cca2aa9..00000000 --- a/tests/e2e/scenarios/example.template.ts +++ /dev/null @@ -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/.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(); -}); diff --git a/tests/e2e/scenarios/helpers.ts b/tests/e2e/scenarios/helpers.ts deleted file mode 100644 index ec1721f1..00000000 --- a/tests/e2e/scenarios/helpers.ts +++ /dev/null @@ -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({ - 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 { - 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 { - // 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> { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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<>endobj\n' + - '2 0 obj<>endobj\n' + - '3 0 obj<>endobj\n' + - 'trailer<>\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(); -} diff --git a/tests/e2e/scripts/codegen.sh b/tests/e2e/scripts/codegen.sh deleted file mode 100755 index 5c0a0495..00000000 --- a/tests/e2e/scripts/codegen.sh +++ /dev/null @@ -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 diff --git a/tests/e2e/scripts/finish-codegen.mjs b/tests/e2e/scripts/finish-codegen.mjs deleted file mode 100644 index a724505b..00000000 --- a/tests/e2e/scripts/finish-codegen.mjs +++ /dev/null @@ -1,120 +0,0 @@ -// Assemble a real spec from a codegen recording. -// -// Usage: node scripts/finish-codegen.mjs (recording on stdin) -// -// Takes the recorder template the user started from (scenarios/codegen/.spec.ts), -// keeps its SETUP (everything before page.pause(), minus test.setTimeout), and -// splices in the recorded steps read from stdin — writing scenarios/.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/.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 `: 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 (recording on stdin)'); - console.error(' or: finish-codegen.mjs --resolve '); - 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 diff --git a/tests/e2e/start-server.sh b/tests/e2e/start-server.sh deleted file mode 100755 index 19904ed4..00000000 --- a/tests/e2e/start-server.sh +++ /dev/null @@ -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:-} OXICLOUD_SERVER_PORT=${OXICLOUD_SERVER_PORT:-}" - -# 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