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:
Samraaj Bath
2026-07-04 01:26:22 -07:00
co-authored by Claude Fable 5
parent b445b62a69
commit 23505ee229
4 changed files with 249 additions and 4 deletions
+5 -1
View File
@@ -4,6 +4,7 @@ import type { InteractionCapture } from "../capture/interactions.js";
import { buildRuntimeSpecs, specKey } from "../generate/interactive.js";
import { buildMenuSpecs } from "../generate/menu.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
@@ -61,7 +62,10 @@ export async function driveInteractionGate(opts: {
const ctx = await browser.newContext({ viewport: { width: vp, height: vh }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
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);
const styleOf = (cid: string, props: string[]): Promise<Record<string, string> | null> =>
+119 -3
View File
@@ -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> }> {
const server: Server = createServer(async (req, res) => {
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; }
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);
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);
} catch {
res.writeHead(500); res.end("error");
@@ -194,6 +262,54 @@ export type RenderResult = {
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[]> {
const out: R[] = new Array(items.length);
let next = 0;
@@ -227,7 +343,7 @@ export async function renderApp(opts: {
page.on("pageerror", (e) => runtimeErrors.push(String(e)));
page.on("response", (r) => { if (r.status() >= 400) failedResources.add(`${r.status()} ${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();
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ }
// 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 page = await ctx.newPage();
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 */ }
await page.evaluate(() => {
const win = window as unknown as { __dittoMotionStopped?: boolean; __dittoMotionStop?: () => void };
+7
View File
@@ -122,6 +122,13 @@ export async function validateRun(runDir: string, opts?: { harnessDir?: string;
motionGate = await driveMotionGate({ url: server.url + "/", viewports, ir, motion: capture.motion });
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 {
await server.close();
}
+118
View File
@@ -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>/);
});
});