- Uploads: one at a time per user and ten an hour, each refused only after its body arrived; the history read an entry at a time (32 MiB cap), a Watch later list capped at YouTube's 5000, a user's kept videos at 200k; any unreadable shape is a 422, not a 500. - Quota: Watch later lookups stop at the backfill reserve and at a per-user daily share; what is not asked about waits, known videos still go in, and the shorts job retries the rest. - Staging and settling share one per-user advisory lock (no deadlock); a run settles only the rows it read, so a row staged meanwhile is never called gone. - A failed run's notice names a translated failure, never the upstream's text. - The status says when an import is queued; the page watches it to its end even when left, then reads the feed, its counts and the playlists again. - Settings: one live region, the zone kept (unavailable) while a run goes, focus handed back, a missed drop kept from the browser, an oversize file refused before it is sent. - Scheduler and run lists show Takeout runs by the importing user.
119 lines
5.2 KiB
TypeScript
119 lines
5.2 KiB
TypeScript
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<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()}`);
|
|
// 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();
|
|
}
|
|
});
|