From a9eaa3fd74077ac923e40d1133075d18b27db04b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 12:40:45 +0200 Subject: [PATCH] fix(test): fix playwright to suport OPAQUE+DPOP --- tests/e2e/global-setup.ts | 2 +- tests/e2e/playwright.codegen.config.ts | 17 - tests/e2e/playwright.coverage.config.ts | 4 +- tests/e2e/spa/admin.spec.ts | 2 +- tests/e2e/spa/appshell.spec.ts | 2 +- tests/e2e/spa/batch.spec.ts | 2 +- tests/e2e/spa/command-palette.spec.ts | 2 +- tests/e2e/spa/coverage-helpers.ts | 2 +- tests/e2e/spa/device.spec.ts | 2 +- tests/e2e/spa/dotfile-filter.spec.ts | 2 +- tests/e2e/spa/errors.spec.ts | 2 +- tests/e2e/spa/favorites.spec.ts | 2 +- tests/e2e/spa/files-extra.spec.ts | 2 +- tests/e2e/spa/files.spec.ts | 2 +- tests/e2e/spa/global-setup.ts | 2 +- tests/e2e/spa/groups.spec.ts | 2 +- tests/e2e/spa/helpers.ts | 533 ++++++++++++++++++++++++ tests/e2e/spa/move.spec.ts | 2 +- tests/e2e/spa/music.spec.ts | 2 +- tests/e2e/spa/navigation.spec.ts | 2 +- tests/e2e/spa/photos.spec.ts | 2 +- tests/e2e/spa/profile.spec.ts | 2 +- tests/e2e/spa/recent.spec.ts | 2 +- tests/e2e/spa/search.spec.ts | 2 +- tests/e2e/spa/share-users.spec.ts | 2 +- tests/e2e/spa/shared.spec.ts | 2 +- tests/e2e/spa/sharing.spec.ts | 2 +- tests/e2e/spa/trash.spec.ts | 2 +- 28 files changed, 560 insertions(+), 44 deletions(-) delete mode 100644 tests/e2e/playwright.codegen.config.ts create mode 100644 tests/e2e/spa/helpers.ts diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 6fa3c257..4ef7e10c 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -1,4 +1,4 @@ -import { seedAdmin } from './scenarios/helpers'; +import { seedAdmin } from './spa/helpers'; /** * Legacy webServer flow only: seed the admin against the single server diff --git a/tests/e2e/playwright.codegen.config.ts b/tests/e2e/playwright.codegen.config.ts deleted file mode 100644 index 491572b0..00000000 --- a/tests/e2e/playwright.codegen.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from '@playwright/test'; -import containers from './playwright.containers.config'; - -/** - * Codegen recorder config — reuses the isolated-stack setup from the container - * e2e config (worker-scoped DB + app container, Nix-chromium executablePath via - * PW_CHROMIUM_PATH, etc.), but points at the per-template recorder files in - * scenarios/codegen/ instead of the e2e suite. - * - * Driven by `just front-codegen`, which sets OXICLOUD_E2E_CONTAINERS=1 (so the - * stack boots) and runs one chosen scenarios/codegen/.spec.ts headed. - */ -export default defineConfig({ - ...containers, - testDir: './scenarios/codegen', - testIgnore: [], -}); diff --git a/tests/e2e/playwright.coverage.config.ts b/tests/e2e/playwright.coverage.config.ts index dff74d5c..15477889 100644 --- a/tests/e2e/playwright.coverage.config.ts +++ b/tests/e2e/playwright.coverage.config.ts @@ -55,8 +55,8 @@ export default defineConfig({ timeout: 600_000, reuseExistingServer: false, cwd: '../..', - stdout: 'inherit', - stderr: 'inherit', + stdout: 'pipe', + stderr: 'pipe', env: { ...commonEnv, OXICLOUD_SERVER_PORT: '8088', diff --git a/tests/e2e/spa/admin.spec.ts b/tests/e2e/spa/admin.spec.ts index 3a749771..013232e0 100644 --- a/tests/e2e/spa/admin.spec.ts +++ b/tests/e2e/spa/admin.spec.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import { test, expect } from './coverage-helpers'; -import { apiLogin, apiAdminCreateUser } from '../scenarios/helpers'; +import { apiLogin, apiAdminCreateUser } from './helpers'; const PLUGIN_ZIP = path.join(__dirname, '..', 'fixtures', 'plugin-hello.zip'); diff --git a/tests/e2e/spa/appshell.spec.ts b/tests/e2e/spa/appshell.spec.ts index ae74709d..5bd52c17 100644 --- a/tests/e2e/spa/appshell.spec.ts +++ b/tests/e2e/spa/appshell.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, seedFilesAndFolders } from '../scenarios/helpers'; +import { apiLogin, seedFilesAndFolders } from './helpers'; /** * AppShell chrome — global search box (suggestions + submit), notification diff --git a/tests/e2e/spa/batch.spec.ts b/tests/e2e/spa/batch.spec.ts index f6ccfd7b..193124af 100644 --- a/tests/e2e/spa/batch.spec.ts +++ b/tests/e2e/spa/batch.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder } from './helpers'; /** * Batch selection bar — select-all then batch favorite / batch delete, each in diff --git a/tests/e2e/spa/command-palette.spec.ts b/tests/e2e/spa/command-palette.spec.ts index 60011ec3..c46dd034 100644 --- a/tests/e2e/spa/command-palette.spec.ts +++ b/tests/e2e/spa/command-palette.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin } from '../scenarios/helpers'; +import { apiLogin } from './helpers'; /** * Command palette — opens on Ctrl/Cmd+K, filters on typed input, and closes on diff --git a/tests/e2e/spa/coverage-helpers.ts b/tests/e2e/spa/coverage-helpers.ts index a63f62d6..4d1db81b 100644 --- a/tests/e2e/spa/coverage-helpers.ts +++ b/tests/e2e/spa/coverage-helpers.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Page, expect } from '@playwright/test'; -import { test as base, TEST_ADMIN } from '../scenarios/helpers'; +import { test as base, TEST_ADMIN } from './helpers'; /** * `test` for the SvelteKit SPA coverage suite. Extends the shared `test` diff --git a/tests/e2e/spa/device.spec.ts b/tests/e2e/spa/device.spec.ts index 1c43d416..025e4889 100644 --- a/tests/e2e/spa/device.spec.ts +++ b/tests/e2e/spa/device.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin } from '../scenarios/helpers'; +import { apiLogin } from './helpers'; /** * Device-pairing route — submit a (bogus) device code to exercise the lookup diff --git a/tests/e2e/spa/dotfile-filter.spec.ts b/tests/e2e/spa/dotfile-filter.spec.ts index 436147e6..f881d0fe 100644 --- a/tests/e2e/spa/dotfile-filter.spec.ts +++ b/tests/e2e/spa/dotfile-filter.spec.ts @@ -4,7 +4,7 @@ import { apiLogin, apiSetHideDotfiles, apiTrashFolder, -} from '../scenarios/helpers'; +} from './helpers'; /** * Dotfile-hide filter — end-to-end coverage of the UI-only, per-user diff --git a/tests/e2e/spa/errors.spec.ts b/tests/e2e/spa/errors.spec.ts index 979046bc..fd2ffc0a 100644 --- a/tests/e2e/spa/errors.spec.ts +++ b/tests/e2e/spa/errors.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder } from './helpers'; /** * Error-path coverage — drive the catch/error branches that the happy-path diff --git a/tests/e2e/spa/favorites.spec.ts b/tests/e2e/spa/favorites.spec.ts index 9206b24e..1e5e481d 100644 --- a/tests/e2e/spa/favorites.spec.ts +++ b/tests/e2e/spa/favorites.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder } from './helpers'; /** * Favorites route — favorite a folder from the files context menu, see it in diff --git a/tests/e2e/spa/files-extra.spec.ts b/tests/e2e/spa/files-extra.spec.ts index 776b04da..9a9e1ac7 100644 --- a/tests/e2e/spa/files-extra.spec.ts +++ b/tests/e2e/spa/files-extra.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from './helpers'; /** * Deeper files-page coverage (the largest source file): list/grid view toggle, diff --git a/tests/e2e/spa/files.spec.ts b/tests/e2e/spa/files.spec.ts index 0955cb44..ef7e88f5 100644 --- a/tests/e2e/spa/files.spec.ts +++ b/tests/e2e/spa/files.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from './helpers'; /** * File-browser CRUD against the SvelteKit `/files` route — the single largest diff --git a/tests/e2e/spa/global-setup.ts b/tests/e2e/spa/global-setup.ts index e6f9ad69..ec2dc974 100644 --- a/tests/e2e/spa/global-setup.ts +++ b/tests/e2e/spa/global-setup.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { seedAdmin } from '../scenarios/helpers'; +import { seedAdmin } from './helpers'; /** * Global setup for the SPA coverage suite: diff --git a/tests/e2e/spa/groups.spec.ts b/tests/e2e/spa/groups.spec.ts index 30b1d861..a2589ae8 100644 --- a/tests/e2e/spa/groups.spec.ts +++ b/tests/e2e/spa/groups.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiAdminCreateUser } from '../scenarios/helpers'; +import { apiLogin, apiAdminCreateUser } from './helpers'; /** * Groups route — create a group, expand it, search members, rename, and delete diff --git a/tests/e2e/spa/helpers.ts b/tests/e2e/spa/helpers.ts new file mode 100644 index 00000000..bb8586d7 --- /dev/null +++ b/tests/e2e/spa/helpers.ts @@ -0,0 +1,533 @@ +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 — many specs' beforeEach + test body both call + // apiLogin; the old bare-POST version was a no-op on a live + // session and callers rely on that. Navigate to /login FIRST, + // then decide: if the SPA's layout guard redirected us away + // (URL no longer starts with /login), we're already signed in + // as SOMEONE and can identify who via a browser-context probe + // (the SW signs the DPoP proof for the bound session — a Node- + // side `page.request.get` would 401 under `DPOP_MODE=required` + // and falsely conclude "not signed in", sending us back to + // /login where the layout guard redirects again → the fill() + // below times out with "The folder is empty" showing on the + // failure screenshot). + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + + if (!new URL(page.url()).pathname.startsWith('/login')) { + // SPA redirected away → we're already signed in. Identify who. + const probe = await browserFetch(page, { url: '/api/auth/me', method: 'GET' }); + if (probe.ok) { + const body = JSON.parse(probe.body || '{}') as { username?: string }; + if (body.username === admin.username) return; + } + // Wrong user (or session raced out from under us) — nuke + // cookies + storage and re-goto /login. Clearing cookies alone + // isn't enough: the SW keypair lives in IndexedDB and the + // login-form soft-reload flag lives in sessionStorage. + await page.context().clearCookies(); + await page.evaluate(async () => { + sessionStorage.clear(); + localStorage.clear(); + }); + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + } + + // `networkidle` above waits for the SPA's boot probes + // (`getOidcProviders` + `getAuthStatus` in `login/+page.svelte:: + // onMount`) to settle BEFORE we touch 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. + 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' + }); +} + +/** + * Run a `fetch` in the PAGE context via `page.evaluate` and return + * `{ ok, status, body }`. Threading requests through the browser + * (rather than Playwright's Node-side `page.request.*`) is load- + * bearing under `OXICLOUD_DPOP_MODE=required`: + * + * * page-context `fetch` gets intercepted by the SPA's Service + * Worker (`service-worker.ts`), which mints and attaches a + * valid DPoP proof per request using the browser-held keypair; + * * `page.request.*` runs in Node — no SW, no proof — and would + * 401 on every state-changing call against a bound session. + * + * CSRF is read inline from `document.cookie` (double-submit token + * lives on the non-HttpOnly `oxicloud_csrf` cookie). Body is passed + * as an already-serialized string so this helper works uniformly + * for JSON, form, and multipart-manually-encoded payloads. + */ +async function browserFetch( + page: Page, + init: { + url: string; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + contentType?: string; + body?: string; + }, +): Promise<{ ok: boolean; status: number; body: string }> { + return page.evaluate( + async ({ url, method, contentType, body }) => { + const csrf = document.cookie.match(/(?:^|; )oxicloud_csrf=([^;]+)/)?.[1] ?? ''; + const headers: Record = {}; + if (csrf) headers['x-csrf-token'] = csrf; + if (contentType) headers['content-type'] = contentType; + const res = await fetch(url, { + method, + credentials: 'same-origin', + headers, + body, + }); + return { ok: res.ok, status: res.status, body: await res.text() }; + }, + init, + ); +} + +/** Convenience: `browserFetch` + JSON body encoding. */ +async function browserFetchJson( + page: Page, + method: 'POST' | 'PUT' | 'PATCH', + url: string, + body: unknown, +): Promise<{ ok: boolean; status: number; body: string; json(): T }> { + const res = await browserFetch(page, { + url, + method, + contentType: 'application/json', + body: JSON.stringify(body), + }); + return { ...res, json: () => JSON.parse(res.body) as T }; +} + +/** 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 browserFetchJson( + page, + 'POST', + '/api/folders', + parentId ? { name, parent_id: parentId } : { name }, + ); + if (!res.ok) throw new Error(`apiCreateFolder(${name}) failed: ${res.status} ${res.body}`); + return res.json(); +} + +/** + * 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 browserFetchJson(page, 'POST', '/api/admin/users', { + username, + password: 'TestPassword1!', + email: `${username}@example.test`, + role: 'user', + quota_bytes: 1073741824, + }); + if (!res.ok) + throw new Error(`apiAdminCreateUser(${username}) failed: ${res.status} ${res.body}`); + 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 browserFetchJson(page, 'POST', '/api/groups', { name, description: null }); + if (!res.ok) throw new Error(`apiCreateGroup(${name}) failed: ${res.status} ${res.body}`); + 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 browserFetch(page, { + url: `/api/folders/${folderId}`, + method: 'DELETE', + }); + if (!res.ok) throw new Error(`apiTrashFolder(${folderId}) failed: ${res.status} ${res.body}`); +} + +/** + * 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 browserFetch(page, { + url: `/api/recent/${itemType}/${id}`, + method: 'POST', + }).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 browserFetch(page, { url: '/api/trash/empty', method: 'DELETE' }); + if (!res.ok) throw new Error(`apiEmptyTrash failed: ${res.status} ${res.body}`); +} + +/** + * 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 browserFetchJson(page, 'PATCH', '/api/auth/me/profile', { + ui_preferences: { hide_dotfiles: hide }, + }); + if (!res.ok) throw new Error(`apiSetHideDotfiles(${hide}) failed: ${res.status} ${res.body}`); +} + +/** 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 { + // Multipart upload rides the browser too so the SW signs it. The + // file bytes cross the page.evaluate boundary as a base64 string + // (structured clone doesn't handle Node `Buffer` cleanly), then + // get decoded into a `Blob` on the page side. `FormData` builds + // the multipart body — the browser sets the boundary + content- + // type header itself, so we deliberately don't pass a Content- + // Type in the fetch init (would collide). + const b64 = file.body.toString('base64'); + const res = await page.evaluate( + async ({ b64, name, mimeType, folderId }) => { + const csrf = document.cookie.match(/(?:^|; )oxicloud_csrf=([^;]+)/)?.[1] ?? ''; + const binary = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const blob = new Blob([binary], { type: mimeType }); + const form = new FormData(); + form.set('folder_id', folderId); + form.set('file', blob, name); + const r = await fetch('/api/files/upload', { + method: 'POST', + credentials: 'same-origin', + headers: csrf ? { 'x-csrf-token': csrf } : {}, + body: form, + }); + return { ok: r.ok, status: r.status, body: await r.text() }; + }, + { b64, name: file.name, mimeType: file.mimeType, folderId }, + ); + if (!res.ok) throw new Error(`apiUploadFile(${file.name}) failed: ${res.status} ${res.body}`); +} + +/** + * 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/spa/move.spec.ts b/tests/e2e/spa/move.spec.ts index 847251cd..7f5a64a8 100644 --- a/tests/e2e/spa/move.spec.ts +++ b/tests/e2e/spa/move.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder } from './helpers'; /** * MoveDialog coverage — move a folder into another via the context menu. diff --git a/tests/e2e/spa/music.spec.ts b/tests/e2e/spa/music.spec.ts index 16b380f8..d6c8c220 100644 --- a/tests/e2e/spa/music.spec.ts +++ b/tests/e2e/spa/music.spec.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder, apiUploadFile } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder, apiUploadFile } from './helpers'; /** * Music route — playlist lifecycle, the add-tracks dialog, and the audio player diff --git a/tests/e2e/spa/navigation.spec.ts b/tests/e2e/spa/navigation.spec.ts index a1f4dd6c..b23119e5 100644 --- a/tests/e2e/spa/navigation.spec.ts +++ b/tests/e2e/spa/navigation.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, seedFilesAndFolders } from '../scenarios/helpers'; +import { apiLogin, seedFilesAndFolders } from './helpers'; /** * Loads every primary route while authenticated. Each route is its own test diff --git a/tests/e2e/spa/photos.spec.ts b/tests/e2e/spa/photos.spec.ts index bebb7837..696eb20c 100644 --- a/tests/e2e/spa/photos.spec.ts +++ b/tests/e2e/spa/photos.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder, apiUploadFile, SAMPLE_FILES } from './helpers'; /** * Photos route — populate the grid with uploaded images, open the lightbox and diff --git a/tests/e2e/spa/profile.spec.ts b/tests/e2e/spa/profile.spec.ts index b6d37399..49edc082 100644 --- a/tests/e2e/spa/profile.spec.ts +++ b/tests/e2e/spa/profile.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin } from '../scenarios/helpers'; +import { apiLogin } from './helpers'; /** * Profile route — edit the profile, open the avatar panel, and generate an app diff --git a/tests/e2e/spa/recent.spec.ts b/tests/e2e/spa/recent.spec.ts index ad9b0c0a..7ccefd50 100644 --- a/tests/e2e/spa/recent.spec.ts +++ b/tests/e2e/spa/recent.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder, apiRecordRecent } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder, apiRecordRecent } from './helpers'; /** * Recent route — populate it via the recents API (the SPA doesn't auto-record diff --git a/tests/e2e/spa/search.spec.ts b/tests/e2e/spa/search.spec.ts index 8db10498..1a321c1a 100644 --- a/tests/e2e/spa/search.spec.ts +++ b/tests/e2e/spa/search.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder, apiUploadFile } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder, apiUploadFile } from './helpers'; /** * Search route — seed a uniquely-named file, query for it, then exercise the diff --git a/tests/e2e/spa/share-users.spec.ts b/tests/e2e/spa/share-users.spec.ts index b6f0efda..aba1f2aa 100644 --- a/tests/e2e/spa/share-users.spec.ts +++ b/tests/e2e/spa/share-users.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder, apiAdminCreateUser, apiCreateGroup } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder, apiAdminCreateUser, apiCreateGroup } from './helpers'; /** * User-to-user sharing — create a second user, add them as a member through the diff --git a/tests/e2e/spa/shared.spec.ts b/tests/e2e/spa/shared.spec.ts index d4d7be64..1feb387e 100644 --- a/tests/e2e/spa/shared.spec.ts +++ b/tests/e2e/spa/shared.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder } from './helpers'; /** * Shared route — create a public link via the share dialog, then view it in diff --git a/tests/e2e/spa/sharing.spec.ts b/tests/e2e/spa/sharing.spec.ts index 6551ce9f..408a16cf 100644 --- a/tests/e2e/spa/sharing.spec.ts +++ b/tests/e2e/spa/sharing.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder } from './helpers'; /** * ShareDialog coverage — opened from the files context menu. Exercises the diff --git a/tests/e2e/spa/trash.spec.ts b/tests/e2e/spa/trash.spec.ts index 5e4fc401..4ee0793c 100644 --- a/tests/e2e/spa/trash.spec.ts +++ b/tests/e2e/spa/trash.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './coverage-helpers'; -import { apiLogin, apiCreateFolder, apiTrashFolder } from '../scenarios/helpers'; +import { apiLogin, apiCreateFolder, apiTrashFolder } from './helpers'; /** * Trash flow — restore and permanently delete items from /trash. The folder is