Skip to content

Commit a108ffa

Browse files
xuanruliclaude
andcommitted
feat(validate): extract @hyperframes/validate importable runtime validate
The browser-based `validate` (console/network/HTTP + contrast/clip audits, remote-asset localization, viewport-from-comp) only existed inline in the `validate` CLI command, so external consumers couldn't reuse it — they reimplement a parallel headless harness that drifts from render (missing localize → false CORS; hardcoded viewport → false out-of-frame; hand-maintained runtime parity). Lint doesn't have this problem: it's an importable `@hyperframes/lint` package. This lifts the browser core into a new `@hyperframes/validate` package, exporting `validateHtmlInBrowser(html, opts)`. The CLI `validate` command is behavior-unchanged: it lints + bundles, resolves Chrome via its own `ensureBrowser()`, and delegates. The package takes `opts.browserExecutablePath` so the caller owns browser discovery — the package stays launch-agnostic (no browser/manager), like lint owns rules but not I/O. New `opts.onPage(page)` runs extra checks on the SAME loaded page before teardown (findings merged), so a consumer can layer its own gates (e.g. caption-zone / out-of-frame) without a second browser load. - contrast-audit.browser.js moves to the package (its only consumer); the CLI build copies it into dist and the bundled runtime loads it via the same page.addScriptTag path. - The small low-level helpers validate needs (static file server, comp-viewport parse, linkedom shim, error normalizer) are vendored into the package so it doesn't import from @hyperframes/cli (which depends on it); folding both onto a shared low-level package is a follow-up. Marked in .fallowrc duplicates.ignore, matching the repo's moved-code convention. - Pure-function + contrast-script tests move to the package with the code they cover; the CLI keeps only extractCompositionErrorsFromLint (a CLI-side lint→error shaping concern). Verified: package build + typecheck clean, package tests 13/13, CLI tests 1270/1270 (103 files), fallow audit clean on 20 changed files, and an e2e `hyperframes validate` on a real composition through the package (Chrome launch + contrast audit) returns ok:true. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ac0153c commit a108ffa

21 files changed

Lines changed: 1122 additions & 707 deletions

.fallowrc.jsonc

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
// page.addScriptTag (see layout.ts / validate.ts) — referenced by file
2727
// path, never imported, so they have no import-graph referrer.
2828
"packages/cli/src/commands/layout-audit.browser.js",
29-
"packages/cli/src/commands/contrast-audit.browser.js",
3029
"packages/cli/src/commands/motion-sample.browser.js",
30+
// @hyperframes/validate contrast audit injects it via page.addScriptTag (raw string, no import referrer).
31+
"packages/validate/src/contrast-audit.browser.js",
3132
// Worker entry points loaded dynamically by their *Pool.ts companions.
3233
"packages/producer/src/services/pngDecodeBlitWorker.ts",
3334
"packages/producer/src/services/shaderTransitionWorker.ts",
@@ -305,6 +306,13 @@
305306
// new package makes fallow see it as a fresh finding; the underlying clone
306307
// predates this refactor.
307308
"packages/parsers/src/hfIds.ts",
309+
// @hyperframes/validate vendors these from cli/src/utils/* so it needn't import @hyperframes/cli (which depends on it); a shared low-level package is a follow-up.
310+
"packages/validate/src/staticProjectServer.ts",
311+
"packages/validate/src/compositionViewport.ts",
312+
"packages/validate/src/dom.ts",
313+
"packages/validate/src/errorMessage.ts",
314+
// browserValidate.ts: raceMediaReady vs auditClipDurations' in-page closure share the race wiring; intentional — the closure is Puppeteer-serialized into a separate realm (see raceMediaReady doc).
315+
"packages/validate/src/browserValidate.ts",
308316
// Parser test files: parallel arrange/act/assert test cases — pre-existing
309317
// duplication moved from packages/core/src/parsers/.
310318
"packages/parsers/src/gsapParser.test.ts",
@@ -573,6 +581,8 @@
573581
"packages/studio/src/player/components/TimelineCanvas.tsx",
574582
"packages/studio/src/player/components/TimelineClip.tsx",
575583
"packages/studio/src/player/components/TimelineClipDiamonds.tsx",
584+
// WCAG contrast sampler ported wholesale into @hyperframes/validate; high cyclomatic is inherent to the pixel scan (no new logic).
585+
"packages/validate/src/contrast-audit.browser.js",
576586
],
577587
},
578588
}

bun.lock

Lines changed: 41 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"type": "module",
1212
"scripts": {
1313
"dev": "bun run studio",
14-
"build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build",
14+
"build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/validate build && bun run --filter @hyperframes/cli build",
1515
"build:producer": "bun run --filter @hyperframes/producer build",
1616
"studio": "bun run --filter @hyperframes/studio dev",
1717
"build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime",

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@hyperframes/producer": "workspace:*",
5555
"@hyperframes/studio": "workspace:*",
5656
"@hyperframes/studio-server": "workspace:*",
57+
"@hyperframes/validate": "workspace:*",
5758
"@types/adm-zip": "^0.5.7",
5859
"@types/fontkit": "^2.0.9",
5960
"@types/mime-types": "^3.0.1",

packages/cli/scripts/build-copy.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ async function main() {
9999
cpSync(layoutAuditScript, join(DIST, "commands", "layout-audit.browser.js"));
100100
}
101101

102-
const contrastAuditScript = join(CLI_ROOT, "src", "commands", "contrast-audit.browser.js");
102+
// Owned by @hyperframes/validate; bundled CLI loads it from dist/commands via page.addScriptTag.
103+
const contrastAuditScript = join(CLI_ROOT, "..", "validate", "src", "contrast-audit.browser.js");
103104
if (existsSync(contrastAuditScript)) {
104105
cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js"));
105106
}

packages/cli/src/commands/layout-audit.browser.test.ts

Lines changed: 0 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
66

77
const __dirname = dirname(fileURLToPath(import.meta.url));
88
const script = readFileSync(join(__dirname, "layout-audit.browser.js"), "utf-8");
9-
const contrastScript = readFileSync(join(__dirname, "contrast-audit.browser.js"), "utf-8");
109

1110
interface RectInput {
1211
left: number;
@@ -196,47 +195,6 @@ describe("layout-audit.browser content overlap", () => {
196195
});
197196
});
198197

199-
describe("contrast-audit.browser clip-path visibility", () => {
200-
afterEach(() => {
201-
vi.restoreAllMocks();
202-
vi.unstubAllGlobals();
203-
document.body.innerHTML = "";
204-
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
205-
delete (window as unknown as { __contrastAudit?: unknown }).__contrastAudit;
206-
});
207-
208-
it("excludes text clipped to nothing by clip-path from contrast reports", async () => {
209-
document.body.innerHTML = `
210-
<div id="root" data-composition-id="main" data-width="640" data-height="360">
211-
<div id="headline">Hidden text</div>
212-
</div>
213-
`;
214-
215-
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
216-
const id = (element as Element).id;
217-
return {
218-
display: "block",
219-
visibility: "visible",
220-
opacity: "1",
221-
color: "rgb(0, 0, 0)",
222-
fontSize: "32px",
223-
fontWeight: "400",
224-
clipPath: id === "headline" ? "inset(0px 100% 0px 0px)" : "none",
225-
} as unknown as CSSStyleDeclaration;
226-
});
227-
228-
vi.spyOn(document.getElementById("headline")!, "getBoundingClientRect").mockReturnValue(
229-
rect({ left: 100, top: 100, width: 400, height: 80 }),
230-
);
231-
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
232-
null;
233-
234-
installContrastScript();
235-
236-
expect(await runContrastAudit()).toEqual([]);
237-
});
238-
});
239-
240198
// Both blocks overlap heavily; only the exemption on block A should suppress
241199
// the finding, so a missing exemption would surface as a failure here.
242200
function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string }): void {
@@ -446,39 +404,6 @@ function installAuditScript(): void {
446404
window.eval(script);
447405
}
448406

449-
function installContrastScript(): void {
450-
class MockImage {
451-
onload: (() => void) | null = null;
452-
onerror: (() => void) | null = null;
453-
naturalWidth = 640;
454-
naturalHeight = 360;
455-
456-
set src(_value: string) {
457-
this.onload?.();
458-
}
459-
}
460-
461-
vi.stubGlobal("Image", MockImage);
462-
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
463-
mockReturnValue(value: CanvasRenderingContext2D): void;
464-
};
465-
getContextSpy.mockReturnValue({
466-
drawImage() {},
467-
getImageData() {
468-
return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) };
469-
},
470-
} as unknown as CanvasRenderingContext2D);
471-
window.eval(contrastScript);
472-
}
473-
474-
async function runContrastAudit(): Promise<Array<Record<string, unknown>>> {
475-
return (
476-
window as unknown as {
477-
__contrastAudit: (imgBase64: string, time: number) => Promise<Array<Record<string, unknown>>>;
478-
}
479-
).__contrastAudit("stub", 0);
480-
}
481-
482407
function runAudit(): Array<{
483408
code: string;
484409
selector: string;

0 commit comments

Comments
 (0)