mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
feat(cli): check and snapshot --local-only load nothing but the project and template CDNs
This commit is contained in:
@@ -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(() => {});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user