import { test as setup, expect, type Page } from "@playwright/test"; import { execFileSync } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { MEMBER_STORAGE, SEED_FILE } from "./helpers"; import { retryConnect } from "./retryConnect"; /** * One-time setup for the characterization net (the `setup` project the tests depend on): * 1. preflight — the localdev :8080 stack must be up (owned by `siftlode dev`); * 2. seed — reset the dedicated E2E accounts to a known baseline, in-container (idempotent); * 3. authenticate each ONCE via password-login and persist its session + `en` locale to its own * storageState, so the real specs never re-auth (auth endpoints are rate-limited). The suite * runs as the admin; `member.spec.ts` and `devices.spec.ts` run as the plain member, from * MEMBER_STORAGE. */ const STORAGE = "e2e/.auth/state.json"; const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local"; const MEMBER_EMAIL = process.env.E2E_MEMBER_EMAIL || "e2e-member@siftlode.local"; // No default: a password committed to a public repo is not a credential. The ops wrapper // (`siftlode e2e` / `check-full`) exports it from the secret store. const PASSWORD = process.env.E2E_PASSWORD ?? ""; const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1"; const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080"; const HERE = dirname(fileURLToPath(import.meta.url)); const SEED_SCRIPT = resolve(HERE, "../../scripts/e2e_seed.py"); // The in-container seed only makes sense when the target is the local stack. const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL); /** Sign `email` in on `page`'s context and save that context as `path`. */ async function signIn(page: Page, email: string, path: string): Promise { // These requests run in Node and share the page's cookie jar — so the `session` cookie the login // receives is what storageState saves below — and a localhost connect the machine dropped is // tried again (retryConnect). const api = retryConnect(page.request); const login = await api.post("/auth/password-login", { data: { email, password: PASSWORD } }); expect( login.ok(), `password-login failed for ${email} — is the E2E account seeded?`, ).toBeTruthy(); const me = await (await api.get("/api/me")).json(); expect(me.authenticated, `session not established after login (${email})`).toBeTruthy(); // Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay that // would intercept every click — the test accounts have no YouTube connection, so it auto-opens), // and opt the account out of starting on Home. A bare URL lands on Home, and the feed, channel // and player flows this net locks all open "/" expecting the feed; the opt-out is the one // Settings → Appearance → Startup writes. Both flags are the account's local state, held for the // account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData). await page.goto("/"); await page.evaluate((id: number) => { localStorage.setItem("siftlode.owner", String(id)); localStorage.setItem("siftlode.lang", "en"); localStorage.setItem("siftlode.onboarding.dismissed", "1"); localStorage.setItem("siftlode.startOnHome", "0"); }, me.id); mkdirSync(dirname(path), { recursive: true }); await page.context().storageState({ path }); } setup("seed + authenticate", async ({ page, browser }) => { expect( PASSWORD, "E2E_PASSWORD is not set — run the suite through `siftlode e2e` (it reads the secret store)", ).not.toBe(""); const api = retryConnect(page.request); // 1. Preflight: fail fast (with a useful message) if the stack isn't running. const health = await api.get("/healthz"); expect( health.ok(), `localdev stack not reachable at ${BASE_URL} — start it with \`siftlode dev\``, ).toBeTruthy(); // 2. Idempotent seed inside the api container (skippable / local-only). if (!process.env.E2E_SKIP_SEED && isLocalTarget) { try { const out = execFileSync( "docker", [ "exec", "-i", "-e", `E2E_EMAIL=${EMAIL}`, "-e", `E2E_MEMBER_EMAIL=${MEMBER_EMAIL}`, "-e", `E2E_PASSWORD=${PASSWORD}`, CONTAINER, "python", "-", ], { input: readFileSync(SEED_SCRIPT) }, ); console.log(`[e2e seed] ${out.toString().trim()}`); // What the seed chose, for the specs that need one of its videos (helpers.seeded). writeFileSync(SEED_FILE, out); } catch (err: unknown) { const e = err as { stderr?: Buffer; message?: string }; throw new Error(`E2E seed failed: ${e.stderr?.toString() || e.message}`); } } // 3. Authenticate each account in its own context: one cookie jar holds one session. await signIn(page, EMAIL, STORAGE); const memberContext = await browser.newContext({ storageState: { cookies: [], origins: [] } }); try { await signIn(await memberContext.newPage(), MEMBER_EMAIL, MEMBER_STORAGE); } finally { await memberContext.close(); } });