Validation server: HTTP Range support, bounded settle, crash-proof reporting
- serveStatic answers single-range requests with proper 206/Content-Range (416 for unsatisfiable), so Chromium streams large media in bounded chunks instead of holding one throttled progressive request open past the navigation budget - goto policy: waitUntil "load" + up to 10s of sustained-quiet polling replaces the hard networkidle timeout - long autoplaying media can no longer make validation unreachable (applied to renderApp, probe widths, and the interaction gate) - validateRun catches render crashes and records them as runtime errors, so a failed render writes a report with a failed gate 0 instead of crashing reportless 292 tests pass (14 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b445b62a69
commit
23505ee229
@@ -4,6 +4,7 @@ import type { InteractionCapture } from "../capture/interactions.js";
|
|||||||
import { buildRuntimeSpecs, specKey } from "../generate/interactive.js";
|
import { buildRuntimeSpecs, specKey } from "../generate/interactive.js";
|
||||||
import { buildMenuSpecs } from "../generate/menu.js";
|
import { buildMenuSpecs } from "../generate/menu.js";
|
||||||
import type { GateResult } from "./gates.js";
|
import type { GateResult } from "./gates.js";
|
||||||
|
import { gotoAndSettle } from "./render.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage 4 interaction gate. Drives the SAME interactions in the built clone that
|
* Stage 4 interaction gate. Drives the SAME interactions in the built clone that
|
||||||
@@ -61,7 +62,10 @@ export async function driveInteractionGate(opts: {
|
|||||||
const ctx = await browser.newContext({ viewport: { width: vp, height: vh }, deviceScaleFactor: 1 });
|
const ctx = await browser.newContext({ viewport: { width: vp, height: vh }, deviceScaleFactor: 1 });
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
await page.addInitScript("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
await page.addInitScript("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||||
await page.goto(url, { waitUntil: "networkidle", timeout: 45000 });
|
// `load` + bounded network-quiet wait (not strict `networkidle`) so an autoplaying
|
||||||
|
// long hero video — which keeps issuing bounded range fetches — can't make navigation
|
||||||
|
// time out and fail the interaction gate before any interaction is even driven.
|
||||||
|
await gotoAndSettle(page, url);
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
const styleOf = (cid: string, props: string[]): Promise<Record<string, string> | null> =>
|
const styleOf = (cid: string, props: string[]): Promise<Record<string, string> | null> =>
|
||||||
|
|||||||
@@ -146,6 +146,50 @@ async function readLimited(p: string): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of interpreting a `Range` header against a known resource size.
|
||||||
|
* - `kind: "full"` → no (usable) Range header; answer with a normal 200 full body.
|
||||||
|
* - `kind: "range"` → a single satisfiable `bytes=start-end`; answer 206 with [start,end].
|
||||||
|
* - `kind: "unsatisfiable"` → a syntactically valid range wholly past EOF; answer 416. */
|
||||||
|
export type RangeResolution =
|
||||||
|
| { kind: "full" }
|
||||||
|
| { kind: "range"; start: number; end: number }
|
||||||
|
| { kind: "unsatisfiable" };
|
||||||
|
|
||||||
|
/** Parse a single-range HTTP `Range` header ("bytes=start-end", "bytes=start-",
|
||||||
|
* "bytes=-suffix") against a resource of `size` bytes. Multi-range ("a-b,c-d") is
|
||||||
|
* intentionally collapsed to a full 200 (Chromium's media element only ever asks for a
|
||||||
|
* single range, and RFC 7233 lets a server ignore Range and reply 200). Anything the
|
||||||
|
* spec calls malformed (missing "bytes=", non-numeric, inverted, both bounds empty) is
|
||||||
|
* also treated as "full" — the safe fallback. A well-formed range that starts at or past
|
||||||
|
* EOF is "unsatisfiable" → 416. `end` is inclusive and clamped to size-1. */
|
||||||
|
export function parseRangeHeader(header: string | undefined, size: number): RangeResolution {
|
||||||
|
if (!header) return { kind: "full" };
|
||||||
|
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||||
|
if (!m) return { kind: "full" }; // absent/malformed/multi-range → serve full body
|
||||||
|
const startStr = m[1]!;
|
||||||
|
const endStr = m[2]!;
|
||||||
|
if (startStr === "" && endStr === "") return { kind: "full" }; // "bytes=-" is malformed
|
||||||
|
if (size <= 0) return { kind: "unsatisfiable" };
|
||||||
|
let start: number;
|
||||||
|
let end: number;
|
||||||
|
if (startStr === "") {
|
||||||
|
// Suffix range: "bytes=-N" → last N bytes.
|
||||||
|
const suffix = Number(endStr);
|
||||||
|
if (!Number.isFinite(suffix) || suffix <= 0) return { kind: "full" };
|
||||||
|
start = Math.max(0, size - suffix);
|
||||||
|
end = size - 1;
|
||||||
|
} else {
|
||||||
|
start = Number(startStr);
|
||||||
|
if (!Number.isFinite(start)) return { kind: "full" };
|
||||||
|
if (start >= size) return { kind: "unsatisfiable" }; // start past EOF
|
||||||
|
end = endStr === "" ? size - 1 : Number(endStr);
|
||||||
|
if (!Number.isFinite(end)) return { kind: "full" };
|
||||||
|
if (end < start) return { kind: "full" }; // inverted → ignore Range
|
||||||
|
end = Math.min(end, size - 1);
|
||||||
|
}
|
||||||
|
return { kind: "range", start, end };
|
||||||
|
}
|
||||||
|
|
||||||
export function serveStatic(rootDir: string): Promise<{ url: string; close: () => Promise<void> }> {
|
export function serveStatic(rootDir: string): Promise<{ url: string; close: () => Promise<void> }> {
|
||||||
const server: Server = createServer(async (req, res) => {
|
const server: Server = createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -168,8 +212,32 @@ export function serveStatic(rootDir: string): Promise<{ url: string; close: () =
|
|||||||
if (existsSync(fallback)) { res.writeHead(404, { "content-type": "text/html" }); res.end(await readLimited(fallback)); return; }
|
if (existsSync(fallback)) { res.writeHead(404, { "content-type": "text/html" }); res.end(await readLimited(fallback)); return; }
|
||||||
res.writeHead(404); res.end("not found"); return;
|
res.writeHead(404); res.end("not found"); return;
|
||||||
}
|
}
|
||||||
|
const contentType = CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
||||||
|
const size = statSync(filePath).size;
|
||||||
|
// HTTP Range support (RFC 7233). Chromium requests <video>/<audio> with `Range: bytes=0-`
|
||||||
|
// and, absent a 206, holds the single progressive stream open throttled to playback — a
|
||||||
|
// long autoplay hero (76s) then means `waitUntil:"networkidle"` can mathematically never
|
||||||
|
// fire. Answering 206 with bounded chunks lets Chromium fetch in pieces so the network
|
||||||
|
// goes idle between reads. A HEAD-style range far past EOF gets a 416.
|
||||||
|
const range = parseRangeHeader(req.headers.range as string | undefined, size);
|
||||||
|
if (range.kind === "unsatisfiable") {
|
||||||
|
res.writeHead(416, { "content-type": contentType, "content-range": `bytes */${size}`, "accept-ranges": "bytes" });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await readLimited(filePath);
|
const data = await readLimited(filePath);
|
||||||
res.writeHead(200, { "content-type": CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream" });
|
if (range.kind === "range") {
|
||||||
|
const chunk = data.subarray(range.start, range.end + 1);
|
||||||
|
res.writeHead(206, {
|
||||||
|
"content-type": contentType,
|
||||||
|
"content-range": `bytes ${range.start}-${range.end}/${size}`,
|
||||||
|
"accept-ranges": "bytes",
|
||||||
|
"content-length": String(chunk.length),
|
||||||
|
});
|
||||||
|
res.end(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": contentType, "accept-ranges": "bytes", "content-length": String(data.length) });
|
||||||
res.end(data);
|
res.end(data);
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(500); res.end("error");
|
res.writeHead(500); res.end("error");
|
||||||
@@ -194,6 +262,54 @@ export type RenderResult = {
|
|||||||
failedResources: string[];
|
failedResources: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Navigate with `waitUntil:"load"` then wait for a bounded window of network quiet, so
|
||||||
|
* validation NEVER hard-fails on media that trickles requests. `waitUntil:"networkidle"`
|
||||||
|
* is a hard timeout: an autoplaying long video (even chunked via 206) keeps issuing bounded
|
||||||
|
* range fetches, so a strict networkidle can miss its 500ms-idle window and throw at 45s,
|
||||||
|
* leaving validation reportless. Instead we (1) `goto load` (fires on DOM+subresources, not
|
||||||
|
* on ongoing media), then (2) poll for `maxQuietMs` of 500ms network silence up to a
|
||||||
|
* `settleCapMs` ceiling, then proceed regardless. Normal pages reach quiet in well under a
|
||||||
|
* second, so total time stays comparable; only trickle-media pages spend the extra budget and
|
||||||
|
* then continue (the video is frame-0-normalized before the screenshot, so determinism holds).
|
||||||
|
* Returns the goto response (for httpStatus). */
|
||||||
|
export async function gotoAndSettle(
|
||||||
|
page: import("playwright").Page,
|
||||||
|
url: string,
|
||||||
|
opts?: { gotoTimeout?: number; settleCapMs?: number; quietMs?: number; pollMs?: number },
|
||||||
|
): Promise<import("playwright").Response | null> {
|
||||||
|
const gotoTimeout = opts?.gotoTimeout ?? 45000;
|
||||||
|
const settleCapMs = opts?.settleCapMs ?? 10000;
|
||||||
|
const quietMs = opts?.quietMs ?? 500;
|
||||||
|
const pollMs = opts?.pollMs ?? 500;
|
||||||
|
const resp = await page.goto(url, { waitUntil: "load", timeout: gotoTimeout });
|
||||||
|
// Count in-flight requests so we can detect network quiet without relying on the
|
||||||
|
// strict networkidle heuristic (which throws on trickle-media).
|
||||||
|
let inflight = 0;
|
||||||
|
const onReq = (): void => { inflight++; };
|
||||||
|
const onDone = (): void => { inflight = Math.max(0, inflight - 1); };
|
||||||
|
page.on("request", onReq);
|
||||||
|
page.on("requestfinished", onDone);
|
||||||
|
page.on("requestfailed", onDone);
|
||||||
|
try {
|
||||||
|
const deadline = Date.now() + settleCapMs;
|
||||||
|
let quietSince = inflight === 0 ? Date.now() : 0;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (inflight === 0) {
|
||||||
|
if (quietSince === 0) quietSince = Date.now();
|
||||||
|
if (Date.now() - quietSince >= quietMs) break; // sustained quiet reached
|
||||||
|
} else {
|
||||||
|
quietSince = 0;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, pollMs));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
page.off("request", onReq);
|
||||||
|
page.off("requestfinished", onDone);
|
||||||
|
page.off("requestfailed", onDone);
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
|
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
|
||||||
const out: R[] = new Array(items.length);
|
const out: R[] = new Array(items.length);
|
||||||
let next = 0;
|
let next = 0;
|
||||||
@@ -227,7 +343,7 @@ export async function renderApp(opts: {
|
|||||||
page.on("pageerror", (e) => runtimeErrors.push(String(e)));
|
page.on("pageerror", (e) => runtimeErrors.push(String(e)));
|
||||||
page.on("response", (r) => { if (r.status() >= 400) failedResources.add(`${r.status()} ${r.url()}`); });
|
page.on("response", (r) => { if (r.status() >= 400) failedResources.add(`${r.status()} ${r.url()}`); });
|
||||||
page.on("requestfailed", (r) => failedResources.add(`failed ${r.url()}`));
|
page.on("requestfailed", (r) => failedResources.add(`failed ${r.url()}`));
|
||||||
const resp = await page.goto(opts.url, { waitUntil: "networkidle", timeout: 45000 });
|
const resp = await gotoAndSettle(page, opts.url);
|
||||||
if (resp) httpStatus = resp.status();
|
if (resp) httpStatus = resp.status();
|
||||||
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ }
|
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ }
|
||||||
// Scroll to settle any lazy effects, then back to top.
|
// Scroll to settle any lazy effects, then back to top.
|
||||||
@@ -308,7 +424,7 @@ export async function measureProbeWidths(opts: { url: string; widths: number[] }
|
|||||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: w, height: viewportHeight(w) }, deviceScaleFactor: 1 });
|
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: w, height: viewportHeight(w) }, deviceScaleFactor: 1 });
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
await page.addInitScript(ESBUILD_SHIM);
|
await page.addInitScript(ESBUILD_SHIM);
|
||||||
await page.goto(opts.url, { waitUntil: "networkidle", timeout: 45000 });
|
await gotoAndSettle(page, opts.url);
|
||||||
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ }
|
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ }
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const win = window as unknown as { __dittoMotionStopped?: boolean; __dittoMotionStop?: () => void };
|
const win = window as unknown as { __dittoMotionStopped?: boolean; __dittoMotionStop?: () => void };
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ export async function validateRun(runDir: string, opts?: { harnessDir?: string;
|
|||||||
motionGate = await driveMotionGate({ url: server.url + "/", viewports, ir, motion: capture.motion });
|
motionGate = await driveMotionGate({ url: server.url + "/", viewports, ir, motion: capture.motion });
|
||||||
log({ event: "motion_gate", pass: motionGate.pass, metrics: motionGate.metrics });
|
log({ event: "motion_gate", pass: motionGate.pass, metrics: motionGate.metrics });
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// A render crash (e.g. a page.goto timeout on trickling media, or a Playwright fault)
|
||||||
|
// must NOT escape reportless — otherwise validation/ stays empty and the run looks
|
||||||
|
// un-validated. Record the error as a runtime error so httpStatus stays 0, gate 0 fails
|
||||||
|
// with a visible issue, and the normal downstream path writes report.json as usual.
|
||||||
|
runtimeErrors.push(`render error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
log({ event: "render_error", error: e instanceof Error ? e.message : String(e) });
|
||||||
} finally {
|
} finally {
|
||||||
await server.close();
|
await server.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { serveStatic, parseRangeHeader } from "../src/validate/render.js";
|
||||||
|
|
||||||
|
// ---- Pure helper: parseRangeHeader ----
|
||||||
|
describe("parseRangeHeader", () => {
|
||||||
|
const SIZE = 1000;
|
||||||
|
|
||||||
|
it("no header → full 200", () => {
|
||||||
|
assert.deepEqual(parseRangeHeader(undefined, SIZE), { kind: "full" });
|
||||||
|
assert.deepEqual(parseRangeHeader("", SIZE), { kind: "full" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bytes=0- (Chromium's open-ended media probe) → whole file as a range", () => {
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=0-", SIZE), { kind: "range", start: 0, end: 999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounded range bytes=100-199 → [100,199] inclusive", () => {
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=100-199", SIZE), { kind: "range", start: 100, end: 199 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("end past EOF is clamped to size-1", () => {
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=900-5000", SIZE), { kind: "range", start: 900, end: 999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suffix range bytes=-100 → last 100 bytes", () => {
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=-100", SIZE), { kind: "range", start: 900, end: 999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suffix larger than file → whole file", () => {
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=-5000", SIZE), { kind: "range", start: 0, end: 999 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("start at or past EOF → unsatisfiable (416)", () => {
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=1000-", SIZE), { kind: "unsatisfiable" });
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=2000-3000", SIZE), { kind: "unsatisfiable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zero-length resource → unsatisfiable for any range", () => {
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=0-", 0), { kind: "unsatisfiable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("malformed / multi-range / inverted → full (safe fallback, never throws)", () => {
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=abc-def", SIZE), { kind: "full" });
|
||||||
|
assert.deepEqual(parseRangeHeader("items=0-10", SIZE), { kind: "full" });
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=0-10,20-30", SIZE), { kind: "full" }); // multi-range collapsed
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=-", SIZE), { kind: "full" });
|
||||||
|
assert.deepEqual(parseRangeHeader("bytes=500-100", SIZE), { kind: "full" }); // inverted
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Integration: serveStatic answers real Range requests with 206/416 ----
|
||||||
|
describe("serveStatic Range support (integration)", () => {
|
||||||
|
let rootDir = "";
|
||||||
|
let base = "";
|
||||||
|
let close: (() => Promise<void>) | null = null;
|
||||||
|
const BODY = Buffer.alloc(5000, 0x41); // 5000 'A' bytes stands in for a media file
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
rootDir = mkdtempSync(join(tmpdir(), "ditto-serve-"));
|
||||||
|
mkdirSync(join(rootDir, "assets"), { recursive: true });
|
||||||
|
writeFileSync(join(rootDir, "assets", "hero.webm"), BODY);
|
||||||
|
writeFileSync(join(rootDir, "index.html"), "<!doctype html><html><body>ok</body></html>");
|
||||||
|
const s = await serveStatic(rootDir);
|
||||||
|
base = s.url;
|
||||||
|
close = s.close;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await close?.();
|
||||||
|
rmSync(rootDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("open-ended Range bytes=0- → 206 with a bounded body + Content-Range/Accept-Ranges", async () => {
|
||||||
|
const res = await fetch(base + "/assets/hero.webm", { headers: { Range: "bytes=0-" } });
|
||||||
|
assert.equal(res.status, 206);
|
||||||
|
assert.equal(res.headers.get("accept-ranges"), "bytes");
|
||||||
|
assert.equal(res.headers.get("content-range"), `bytes 0-4999/5000`);
|
||||||
|
assert.equal(res.headers.get("content-type"), "video/webm");
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
assert.equal(buf.length, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounded Range bytes=100-199 → 206 returning exactly those 100 bytes", async () => {
|
||||||
|
const res = await fetch(base + "/assets/hero.webm", { headers: { Range: "bytes=100-199" } });
|
||||||
|
assert.equal(res.status, 206);
|
||||||
|
assert.equal(res.headers.get("content-range"), `bytes 100-199/5000`);
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
assert.equal(buf.length, 100);
|
||||||
|
assert.ok(buf.equals(BODY.subarray(100, 200)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Range past EOF → 416 with Content-Range: bytes */size", async () => {
|
||||||
|
const res = await fetch(base + "/assets/hero.webm", { headers: { Range: "bytes=9000-" } });
|
||||||
|
assert.equal(res.status, 416);
|
||||||
|
assert.equal(res.headers.get("content-range"), `bytes */5000`);
|
||||||
|
// Drain the (empty) body so the socket is released.
|
||||||
|
await res.arrayBuffer();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no Range header → full 200 with Accept-Ranges advertised", async () => {
|
||||||
|
const res = await fetch(base + "/assets/hero.webm");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("accept-ranges"), "bytes");
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
assert.equal(buf.length, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HTML routes still serve a normal 200 (Range logic doesn't disturb pages)", async () => {
|
||||||
|
const res = await fetch(base + "/");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const text = await res.text();
|
||||||
|
assert.match(text, /<body>ok<\/body>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user