A tab names the account it was claimed for on every request, beacon and upload (X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with 401 account_changed and keep the session, so a tab left showing the account signed in before reloads instead of writing to the new one. /auth/login clears a connect marker left by an abandoned connect, and a link completes only on a session that still holds. The prefs merge reads the row under a lock. A tab's sessionStorage records its own owner. A page the account loses is given up, address bar and remembered page included. The panel layouts are preferences; the storage contract also refuses a key handed to the Storage API or to usePref outside its kind. A refused sign-in as someone else and a failed sign-out say so. The chromium-only e2e specs are in the gate register, the member walk waits for its requests, and devices.spec checks the save itself.
117 lines
5.0 KiB
TypeScript
117 lines
5.0 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` 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<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 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()}`);
|
|
} 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();
|
|
}
|
|
});
|