The suite ran only as an admin, so nothing end to end held what a member sees. A second seeded account, a member with its own session, now walks every rail page (no admin entry, no admin call, no error state) and follows links to the admin pages. It found one: a member's link to an admin page showed the feed with no rail row current, and the address bar kept naming the admin page — only the content fell back. The navigation state now derives the page on screen through each module's gate (reachablePage), so the rail, the content, the tab title and the address bar agree, on a link, a Back, and a role taken away mid-session.
115 lines
4.9 KiB
TypeScript
115 lines
4.9 KiB
TypeScript
import { test as setup, expect, type Page } from "@playwright/test";
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { MEMBER_STORAGE } 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` runs 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<void> {
|
|
// 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 per-account:
|
|
// `siftlode.onboarding.dismissed.<id>`, `siftlode.startOnHome.<id>`.
|
|
await page.goto("/");
|
|
await page.evaluate((id: number) => {
|
|
localStorage.setItem("siftlode.lang", "en");
|
|
localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1");
|
|
localStorage.setItem(`siftlode.startOnHome.${id}`, "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()}`);
|
|
} 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();
|
|
}
|
|
});
|