feat(cli): check and snapshot --local-only load nothing but the project and template CDNs

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-14 12:49:03 -07:00
parent c0636d017f
commit 7a00aacaad
8 changed files with 156 additions and 4 deletions
+2 -1
View File
@@ -756,6 +756,7 @@ cover every finding from the full check.
| `--layout` | Layout knobs, currently `proseCoverageFloor=0.05` (0–1, default 0.15) |
| `--browser-gpu` / `--no-browser-gpu` | Hardware GPU capture, or deterministic SwiftShader (default: auto-detect) |
| `--proxy` / `--no-proxy` | Auto-transcode browser-hostile codecs before checking (default: on) |
| `--local-only` | Load only the project's own files, `data:`/`blob:` URLs and the template CDNs (GSAP on jsDelivr, Google Fonts). A request to any other host fails and is reported as a failed load. |
`--caption-zone` and `--frame-check` refuse a ranged check when none of their
configured seek samples falls inside `--range`; a requested gate never silently
@@ -876,7 +877,7 @@ npx hyperframes snapshot my-project --at 1.5,4.3 --against ref.mp4
| `--angle` | Orthogonal 3D camera for depth and occlusion checks: `front`, `iso`, `top`, `side`, or `yaw,pitch` in degrees. Tilts the whole stage, so you get real pixels. |
| `--describe` | Gemini vision analysis of each frame. Runs by default when `GEMINI_API_KEY` is set. Pass a question to override the prompt, or `--describe false` to opt out. |
| `--timeout` | Ms to wait for the runtime (default 5000) |
| `--browser-gpu`, `--proxy` | Same meaning as on [`check`](#check) |
| `--browser-gpu`, `--proxy`, `--local-only` | Same meaning as on [`check`](#check) |
Each snapshot is a 1920×1080 PNG taken by bundling the project, serving it,
launching headless Chrome, and seeking. Useful for visual verification during
@@ -6,6 +6,7 @@ import {
import { spawn } from "node:child_process";
import type { Browser, Page } from "puppeteer-core";
import type { InProcessRequest, InProcessResponse } from "../utils/staticProjectServer.js";
import { allowedLocalOnlyRequest, guardLocalOnlyRequests } from "./localOnlyRequests.js";
import { c } from "../ui/colors.js";
import {
assertWebGpuAdapterAvailable,
@@ -91,6 +92,7 @@ export interface OpenSettledCompositionPageOptions {
// Runs after the page exists but before page.goto, so console/pageerror/
// request listeners can attach without missing load-time events.
beforeNavigate?: (page: Page) => void | Promise<void>;
localOnly?: boolean;
}
export interface FfmpegRunResult {
@@ -262,7 +264,8 @@ export async function openSettledCompositionPage(
const page = await chromeBrowser.newPage();
await installPageFunctionGuard(page);
await page.setViewport(viewport);
if (answer) await answerPageInProcess(page, url, answer);
if (answer) await answerPageInProcess(page, url, answer, options.localOnly === true);
else if (options.localOnly) await guardLocalOnlyRequests(page, url);
await options.beforeNavigate?.(page);
await page.goto(url, {
waitUntil: "domcontentloaded",
@@ -604,18 +607,22 @@ export async function runFfmpegOnce(
});
}
/** Requests to the in-process origin are answered here; any other request goes on as usual. */
/** Requests to the in-process origin are answered here; any other request goes on as usual, or under --local-only only if allowed. */
async function answerPageInProcess(
page: Page,
url: string,
answer: (request: InProcessRequest) => Promise<InProcessResponse>,
localOnly: boolean,
): Promise<void> {
const origin = new URL(url).origin;
await page.setRequestInterception(true);
page.on("request", (request) => {
if (request.isInterceptResolutionHandled()) return;
const target = new URL(request.url());
if (target.origin !== origin) return void request.continue().catch(() => {});
if (target.origin !== origin) {
const blocked = localOnly && !allowedLocalOnlyRequest(request.url(), url);
return void (blocked ? request.abort("blockedbyclient") : request.continue()).catch(() => {});
}
answer({
method: request.method(),
path: target.pathname + target.search,
@@ -0,0 +1,93 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeManagedBrowser } from "../browser/launch.js";
import { openSettledCompositionPage } from "./captureCompositionFrame.js";
import { allowedLocalOnlyRequest } from "./localOnlyRequests.js";
describe("allowedLocalOnlyRequest", () => {
const server = "http://127.0.0.1:4100/index.html";
it.each([
["http://127.0.0.1:4100/assets/a.png", true],
["data:image/png;base64,AAAA", true],
["blob:http://127.0.0.1:4100/1234", true],
["https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js", true],
["https://fonts.googleapis.com/css2?family=Inter", true],
["https://fonts.gstatic.com/s/inter/v13/a.woff2", true],
["http://127.0.0.1:4101/secret", false],
["https://example.com/", false],
["https://cdn.jsdelivr.net/gh/someone/repo/payload.txt", false],
["https://cdn.jsdelivr.net/npm/gsap-extra/index.js", false],
["https://fonts.googleapis.com.example.com/x", false],
])("%s -> %s", (url, allowed) => {
expect(allowedLocalOnlyRequest(url, server)).toBe(allowed);
});
});
async function listen(handler: Parameters<typeof createServer>[1]): Promise<[Server, string]> {
const server = createServer(handler);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
return [server, `http://127.0.0.1:${(server.address() as AddressInfo).port}`];
}
describe("a --local-only page", () => {
let foreign: Server;
let foreignUrl: string;
let foreignHits = 0;
let project: Server;
let projectUrl: string;
let html = "";
beforeAll(async () => {
[foreign, foreignUrl] = await listen((_req, res) => {
foreignHits++;
res.writeHead(200, { "access-control-allow-origin": "*" }).end("FOREIGN TEXT");
});
html = `<!doctype html><html><body>
<div data-composition-id="main" data-width="320" data-height="180" data-duration="1"></div>
<script>window.__timelines={main:{duration:()=>1,seek:()=>{},pause:()=>{}}};
fetch("${foreignUrl}/secret").then((r)=>r.text()).then((t)=>{document.title=t},()=>{document.title="blocked"});</script>
</body></html>`;
[project, projectUrl] = await listen((_req, res) => {
res.writeHead(200, { "content-type": "text/html" }).end(html);
});
});
afterAll(async () => {
await new Promise((resolve) => foreign.close(resolve));
await new Promise((resolve) => project.close(resolve));
});
async function pageTitle(localOnly: boolean): Promise<{ title: string; failed: string[] }> {
const failed: string[] = [];
const { browser, page } = await openSettledCompositionPage(html, `${projectUrl}/index.html`, {
renderReadyTimeoutMs: 2_000,
renderReadyWarningSuffix: "local-only test",
browserGpuMode: "software",
localOnly,
beforeNavigate: (p) => {
p.on("requestfailed", (r) => failed.push(`${r.url()} ${r.failure()?.errorText}`));
},
});
try {
await page.waitForFunction(() => document.title !== "", { timeout: 10_000 });
return { title: await page.title(), failed };
} finally {
await closeManagedBrowser(browser);
}
}
it("cannot read another host", async () => {
foreignHits = 0;
const { title, failed } = await pageTitle(true);
expect(title).toBe("blocked");
expect(foreignHits).toBe(0);
expect(failed).toEqual([`${foreignUrl}/secret net::ERR_BLOCKED_BY_CLIENT.Inspector`]);
}, 60_000);
it("reads it without the flag", async () => {
foreignHits = 0;
expect((await pageTitle(false)).title).toBe("FOREIGN TEXT");
expect(foreignHits).toBe(1);
}, 60_000);
});
@@ -0,0 +1,32 @@
import type { Page } from "puppeteer-core";
// The template CDNs a --local-only page still loads: GSAP and Google Fonts.
const TEMPLATE_CDN_PREFIXES = [
"https://cdn.jsdelivr.net/npm/gsap@",
"https://cdn.jsdelivr.net/npm/gsap/",
"https://fonts.googleapis.com/",
"https://fonts.gstatic.com/",
];
/** True when a --local-only page may load `url`: the CLI's own server, data:/blob:, or a template CDN. */
export function allowedLocalOnlyRequest(url: string, serverUrl: string): boolean {
if (url.startsWith("data:") || url.startsWith("blob:")) return true;
try {
if (new URL(url).origin === new URL(serverUrl).origin) return true;
} catch {
return false;
}
return TEMPLATE_CDN_PREFIXES.some((prefix) => url.startsWith(prefix));
}
/** Every other request, redirect hops included, fails as a load blocked by the client. */
export async function guardLocalOnlyRequests(page: Page, serverUrl: string): Promise<void> {
await page.setRequestInterception(true);
page.on("request", (request) => {
if (request.isInterceptResolutionHandled()) return;
const action = allowedLocalOnlyRequest(request.url(), serverUrl)
? request.continue()
: request.abort("blockedbyclient");
void action.catch(() => {});
});
}
+7
View File
@@ -215,6 +215,12 @@ const CHECK_COMMAND_ARGS = {
"Auto-transcode browser-hostile video codecs (default: hyperframes.json media.autoProxy, which defaults on)",
default: undefined,
},
"local-only": {
type: "boolean",
description:
"Load only the project's own files, data:/blob: URLs and the template CDNs (GSAP, Google Fonts); any other host fails as a failed load",
default: false,
},
"browser-gpu": {
type: "boolean",
description:
@@ -313,6 +319,7 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
layout: parseLayout(args.layout),
autoProxy: args.proxy as boolean | undefined,
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
localOnly: args["local-only"] === true,
};
}
+9
View File
@@ -484,6 +484,7 @@ async function captureSnapshots(
against?: string;
/** Add to outputDir without clearing it; name each still by its time, not its place in the run. */
keep?: boolean;
localOnly?: boolean;
},
): Promise<string[]> {
const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js");
@@ -517,6 +518,7 @@ async function captureSnapshots(
renderReadyTimeoutMs: opts.timeout ?? 5000,
renderReadyWarningSuffix: "snapshots may be inaccurate",
browserGpuMode: opts.browserGpuMode,
localOnly: opts.localOnly,
});
try {
@@ -1059,6 +1061,12 @@ export default defineCommand({
"Use hardware browser GPU capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect, PRODUCER_BROWSER_GPU_MODE overrides)",
default: undefined,
},
"local-only": {
type: "boolean",
description:
"Load only the project's own files, data:/blob: URLs and the template CDNs (GSAP, Google Fonts); any other host fails to load",
default: false,
},
},
async run({ args }) {
if ([args.at, args.range, args.composition].filter((value) => value !== undefined).length > 1)
@@ -1153,6 +1161,7 @@ export default defineCommand({
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
against,
keep: args.keep === true,
localOnly: args["local-only"] === true,
});
reportArtifacts(
"snapshot",
+2
View File
@@ -217,6 +217,7 @@ export async function runBrowserCheck(
const launchSettleStart = Date.now();
const session = await openSettledCompositionPage(html, server.url, {
navigationTimeoutMs: options.timeout,
localOnly: options.localOnly,
renderReadyTimeoutMs: options.timeout,
renderReadyWarningSuffix: "checking the current page state",
browserGpuMode: options.browserGpuMode ?? resolveCliChromeGpuMode(),
@@ -296,6 +297,7 @@ export async function captureFindingCrops(
try {
const session = await openSettledCompositionPage(html, server.url, {
navigationTimeoutMs: options.timeout,
localOnly: options.localOnly,
renderReadyTimeoutMs: options.timeout,
renderReadyWarningSuffix: "capturing finding crops",
signal: deadline?.signal,
+1
View File
@@ -28,6 +28,7 @@ export interface CheckOptions {
/** Explicit --proxy/--no-proxy override; undefined preserves project config. */
autoProxy?: boolean;
browserGpuMode?: BrowserGpuMode;
localOnly?: boolean;
}
export interface CaptionZoneOptions {