feat(tooling): audit_metrics.py — size, complexity, gate weight and a speed baseline
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { test, type Page } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which
|
||||
* runs this as the `speed` project (never part of the e2e suite: its file name is not a `.spec`).
|
||||
* Each reading is the median of RUNS cold page loads (a fresh browser context, so no HTTP cache),
|
||||
* against the localdev stack and the seeded E2E account. It is a baseline for before/after within
|
||||
* one machine, not an absolute figure: the same measurement on a phone over the internet is slower.
|
||||
*
|
||||
* Not measured here: the in-app players (the E2E account owns no download, Plex item or watch
|
||||
* history to open), and any iOS reading (Playwright's WebKit decodes media through the host OS —
|
||||
* see playwright.config.ts) — the owner's phone gives that one by hand.
|
||||
*/
|
||||
|
||||
const RUNS = 5;
|
||||
const OUT = "e2e/.speed/latest.json";
|
||||
const CONTAINER = process.env.E2E_DB_CONTAINER || "siftlode-db-1";
|
||||
|
||||
const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? NaN;
|
||||
|
||||
/** JavaScript the page pulled in, uncompressed: the same on every stack (localdev serves it raw,
|
||||
* prod gzips it at the proxy), and what the browser has to parse. */
|
||||
const scriptBytes = (page: Page) =>
|
||||
page.evaluate(() =>
|
||||
(performance.getEntriesByType("resource") as PerformanceResourceTiming[])
|
||||
.filter((r) => r.initiatorType === "script" || r.name.endsWith(".js"))
|
||||
.reduce((sum, r) => sum + r.decodedBodySize, 0),
|
||||
);
|
||||
|
||||
/** Public share links on the local stack that could play: no password, not expired, asset ready.
|
||||
* The page itself is the judge of the last step (its file must be on THIS disk — a localdev DB
|
||||
* seeded from prod lists assets whose files never came along), so the caller probes them. */
|
||||
function shareTokens(): string[] {
|
||||
const sql =
|
||||
"select l.token from download_links l join download_jobs j on j.id = l.job_id" +
|
||||
" join media_assets a on a.id = j.asset_id" +
|
||||
" where l.password_hash is null and (l.expires_at is null or l.expires_at > now())" +
|
||||
" and a.status = 'ready' order by l.id";
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
["exec", CONTAINER, "psql", "-U", "siftlode", "-d", "siftlode", "-tAc", sql],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return out
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
test("speed baseline", async ({ browser, page: page0 }) => {
|
||||
test.setTimeout(180_000);
|
||||
const result: Record<string, number | string | null> = {
|
||||
measured_at: new Date().toISOString(),
|
||||
runs: RUNS,
|
||||
};
|
||||
|
||||
// Feed: navigation start → the first video card on screen.
|
||||
const feedMs: number[] = [];
|
||||
let feedJs = 0;
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto("/");
|
||||
// Timed inside the page, every frame: an `expect` would read the clock on its own retry
|
||||
// schedule (100 ms … 1 s steps), not when the card appeared.
|
||||
const at = await page.waitForFunction(
|
||||
() => document.querySelector('[data-testid="video-card"]') && performance.now(),
|
||||
null,
|
||||
{ polling: "raf", timeout: 30_000 },
|
||||
);
|
||||
feedMs.push((await at.jsonValue()) as number);
|
||||
feedJs = await scriptBytes(page);
|
||||
await ctx.close();
|
||||
}
|
||||
result.feed_first_card_ms = Math.round(median(feedMs));
|
||||
result.feed_js_raw_bytes = feedJs;
|
||||
|
||||
// /watch: navigation start → the shared video playing (started muted by script, standing in for
|
||||
// the visitor's tap — the page has no autoplay).
|
||||
let token: string | null = null;
|
||||
for (const t of shareTokens()) {
|
||||
if ((await page0.request.get(`/api/public/watch/${t}`)).ok()) {
|
||||
token = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
const watchMs: number[] = [];
|
||||
let watchJs = 0;
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`/watch/${token}`);
|
||||
await page.waitForFunction(() => document.querySelector("video, audio"), null, {
|
||||
polling: "raf",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const video = page.locator("video, audio").first();
|
||||
watchMs.push(
|
||||
await video.evaluate(
|
||||
(el: HTMLMediaElement) =>
|
||||
new Promise<number>((resolve, reject) => {
|
||||
el.muted = true;
|
||||
el.addEventListener("playing", () => resolve(performance.now()), { once: true });
|
||||
el.play().catch(reject);
|
||||
}),
|
||||
),
|
||||
);
|
||||
watchJs = await scriptBytes(page);
|
||||
await ctx.close();
|
||||
}
|
||||
result.watch_time_to_play_ms = Math.round(median(watchMs));
|
||||
result.watch_js_raw_bytes = watchJs;
|
||||
} else {
|
||||
result.watch_time_to_play_ms = null;
|
||||
result.watch_js_raw_bytes = null;
|
||||
result.watch_note = "no playable share link on the local stack";
|
||||
}
|
||||
|
||||
mkdirSync("e2e/.speed", { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(result, null, 2) + "\n");
|
||||
});
|
||||
Reference in New Issue
Block a user