From 43d282a73f83c7adae7786cfa2580f9f45daab32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 11 Jul 2026 22:42:22 +0000 Subject: [PATCH] fix(check): allow missing caption overrides --- packages/cli/src/commands/validate.test.ts | 40 +++++++++++- packages/cli/src/commands/validate.ts | 73 +++++++++++++++++++--- packages/cli/src/utils/checkBrowser.ts | 56 ++++++++++++++--- 3 files changed, 146 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 3fe07fbfb4..78a01686c6 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -16,6 +16,7 @@ import { raceMediaReady, resolveNavigationTimeoutMs, shouldIgnoreRequestFailure, + shouldIgnoreHttpError, } from "./validate.js"; import { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; @@ -99,6 +100,29 @@ describe("raceMediaReady", () => { }); describe("shouldIgnoreRequestFailure", () => { + it("ignores the optional caption overrides file when it is absent", () => { + expect( + shouldIgnoreRequestFailure( + "http://127.0.0.1:3000/caption-overrides.json", + "net::ERR_ABORTED", + "fetch", + ), + ).toBe(true); + expect(shouldIgnoreHttpError("http://127.0.0.1:3000/caption-overrides.json", 404)).toBe(true); + }); + + it("keeps non-optional JSON and non-404 caption override failures reportable", () => { + expect( + shouldIgnoreRequestFailure( + "http://127.0.0.1:3000/transcript.json", + "net::ERR_ABORTED", + "fetch", + ), + ).toBe(false); + expect(shouldIgnoreHttpError("http://127.0.0.1:3000/transcript.json", 404)).toBe(false); + expect(shouldIgnoreHttpError("http://127.0.0.1:3000/caption-overrides.json", 500)).toBe(false); + }); + it("ignores aborted media preload requests", () => { expect( shouldIgnoreRequestFailure("http://127.0.0.1:3000/assets/sfx.wav", "net::ERR_ABORTED"), @@ -140,7 +164,9 @@ describe("waitForPreferredSeekTarget", () => { await waitForPreferredSeekTarget(page, 123); - expect(page.waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 123 }); + expect(page.waitForFunction).toHaveBeenCalledWith(expect.any(Function), { + timeout: 123, + }); }); it("does not fail validation when only the legacy raw timeline fallback is available", async () => { @@ -173,7 +199,11 @@ describe("extractCompositionErrorsFromLint", () => { // lintProject finding into validate's error list so this is a real // validate failure instead. function makeLintResult( - findings: Array<{ code: string; severity: "error" | "warning" | "info"; message: string }>, + findings: Array<{ + code: string; + severity: "error" | "warning" | "info"; + message: string; + }>, ): Pick { return { results: [ @@ -214,7 +244,11 @@ describe("extractCompositionErrorsFromLint", () => { it("ignores unrelated lint finding codes", () => { const lintResult = makeLintResult([ { code: "audio_src_not_found", severity: "error", message: "unrelated" }, - { code: "root_missing_composition_id", severity: "error", message: "also unrelated" }, + { + code: "root_missing_composition_id", + severity: "error", + message: "also unrelated", + }, ]); expect(extractCompositionErrorsFromLint(lintResult)).toEqual([]); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 9624615717..af6c9898ec 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -78,6 +78,7 @@ export function shouldIgnoreRequestFailure( errorText: string | undefined, resourceType?: string, ): boolean { + if (isOptionalCaptionOverridesRequest(url)) return true; if (errorText !== "net::ERR_ABORTED") return false; if (resourceType === "media") return true; try { @@ -87,6 +88,18 @@ export function shouldIgnoreRequestFailure( } } +export function shouldIgnoreHttpError(url: string, status: number): boolean { + return status === 404 && isOptionalCaptionOverridesRequest(url); +} + +function isOptionalCaptionOverridesRequest(url: string): boolean { + try { + return new URL(url).pathname === "/caption-overrides.json"; + } catch { + return false; + } +} + async function getCompositionDuration(page: import("puppeteer-core").Page): Promise { return page.evaluate(() => { if (window.__hf?.duration && window.__hf.duration > 0) return window.__hf.duration; @@ -207,7 +220,11 @@ export async function auditClipDurations( continue; } const mediaSeconds = Math.max(0, clip.duration - clip.mediaStart); - const fit = analyzeClipMediaFit({ slotSeconds: clip.slot, mediaSeconds, loop: clip.loop }); + const fit = analyzeClipMediaFit({ + slotSeconds: clip.slot, + mediaSeconds, + loop: clip.loop, + }); if (!fit) continue; warnings.push({ level: "warning", @@ -276,7 +293,10 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise typeof (window as unknown as Record).__contrastAuditFinish === "function" @@ -369,7 +389,11 @@ async function localizeRemoteAssets( async function validateInBrowser( project: ProjectDir, opts: { timeout?: number; contrast?: boolean }, -): Promise<{ errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] }> { +): Promise<{ + errors: ConsoleEntry[]; + warnings: ConsoleEntry[]; + contrast?: ContrastEntry[]; +}> { const projectDir = project.dir; const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const { ensureBrowser } = await import("../browser/manager.js"); @@ -431,9 +455,19 @@ async function validateInBrowser( const text = msg.text(); if (type === "error") { if (text.startsWith("Failed to load resource")) return; - errors.push({ level: "error", text, url: loc.url, line: loc.lineNumber }); + errors.push({ + level: "error", + text, + url: loc.url, + line: loc.lineNumber, + }); } else if (type === "warn") { - warnings.push({ level: "warning", text, url: loc.url, line: loc.lineNumber }); + warnings.push({ + level: "warning", + text, + url: loc.url, + line: loc.lineNumber, + }); } }); @@ -463,14 +497,22 @@ async function validateInBrowser( if (res.status() >= 400) { const url = res.url(); if (url.includes("favicon")) return; + if (shouldIgnoreHttpError(url, res.status())) return; const path = decodeURIComponent(new URL(url).pathname).replace(/^\//, ""); - errors.push({ level: "error", text: `${res.status()} loading ${path}`, url }); + errors.push({ + level: "error", + text: `${res.status()} loading ${path}`, + url, + }); } }); const navTimeoutMs = resolveNavigationTimeoutMs(opts.timeout); try { - await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: navTimeoutMs }); + await page.goto(server.url, { + waitUntil: "domcontentloaded", + timeout: navTimeoutMs, + }); } catch (err) { const hinted = navigationTimeoutHint(err, navTimeoutMs); if (hinted) throw hinted; @@ -593,7 +635,11 @@ Examples: hyperframes validate --timeout 5000`, }, args: { - dir: { type: "positional", description: "Project directory", required: false }, + dir: { + type: "positional", + description: "Project directory", + required: false, + }, json: { type: "boolean", description: "Output as JSON", default: false }, contrast: { type: "boolean", @@ -620,7 +666,10 @@ Examples: } try { - const result = await validateInBrowser(project, { timeout, contrast: useContrast }); + const result = await validateInBrowser(project, { + timeout, + contrast: useContrast, + }); const exitCode = printValidationResult(result, asJson); process.exit(exitCode); } catch (err: unknown) { @@ -632,7 +681,11 @@ Examples: }); function printValidationResult( - result: { errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] }, + result: { + errors: ConsoleEntry[]; + warnings: ConsoleEntry[]; + contrast?: ContrastEntry[]; + }, asJson: boolean, ): number { const { errors, warnings, contrast } = result; diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index d889e41b73..4b017e4842 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -12,7 +12,11 @@ import { seekCompositionTimeline, waitForPreferredSeekTarget, } from "../capture/captureCompositionFrame.js"; -import { auditClipDurations, shouldIgnoreRequestFailure } from "../commands/validate.js"; +import { + auditClipDurations, + shouldIgnoreHttpError, + shouldIgnoreRequestFailure, +} from "../commands/validate.js"; import { loadBrowserScript } from "../commands/layout.js"; import { normalizeErrorMessage } from "./errorMessage.js"; import { ambiguousIssue, type MotionFrame } from "./motionAudit.js"; @@ -116,7 +120,12 @@ export async function runBrowserCheck( // errors). The session is already open, so this is one extra evaluate. const { analyzeClipMediaFit } = await import("@hyperframes/engine"); for (const entry of await auditClipDurations(page, analyzeClipMediaFit, options.timeout)) { - drafts.push({ code: "clip_media_fit", severity: entry.level, message: entry.text, time: 0 }); + drafts.push({ + code: "clip_media_fit", + severity: entry.level, + message: entry.text, + time: 0, + }); } const driver = createPageDriver(page, (time) => { currentTime = time; @@ -211,7 +220,12 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: ( if (message.includes("Unexpected token '<'") || message.includes("Unexpected token '<'")) { return; } - drafts.push({ code: "page_error", severity: "error", message, time: currentTime() }); + drafts.push({ + code: "page_error", + severity: "error", + message, + time: currentTime(), + }); }); wireNetworkListeners(page, drafts, currentTime); } @@ -234,6 +248,7 @@ function wireNetworkListeners(page: Page, drafts: RuntimeDraft[], currentTime: ( if (response.status() < 400) return; const url = response.url(); if (url.includes("favicon")) return; + if (shouldIgnoreHttpError(url, response.status())) return; drafts.push({ code: "http_error", severity: "error", @@ -250,7 +265,10 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud getDuration: () => getCompositionDuration(page), getTransitionBoundaries: () => collectTweenBoundaries(page), getCanvas: () => - page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), + page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })), findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors), seek: async (time) => { setTime(time); @@ -267,10 +285,16 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud } async function injectAuditScripts(page: Page, contrast: boolean): Promise { - await page.addScriptTag({ content: loadBrowserScript("layout-audit.browser.js") }); - await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") }); + await page.addScriptTag({ + content: loadBrowserScript("layout-audit.browser.js"), + }); + await page.addScriptTag({ + content: loadBrowserScript("motion-sample.browser.js"), + }); if (contrast) { - await page.addScriptTag({ content: loadBrowserScript("contrast-audit.browser.js") }); + await page.addScriptTag({ + content: loadBrowserScript("contrast-audit.browser.js"), + }); } } @@ -484,7 +508,11 @@ async function resolveAnchors(page: Page, requests: AnchorRequest[]): Promise { const anchors = await resolveAnchors(page, [ - { selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) }, + { + selector: "[data-composition-id]", + time: 0, + bbox: await compositionBbox(page), + }, ]); return anchors[0] ?? fallbackAnchor(undefined); } @@ -510,7 +538,10 @@ async function collectContrast( // This screenshot is the one contrast math is sampled from below — it must // stay untouched by the annotation overlay (finishContrast reads real // painted pixels), so annotation only ever happens on a SECOND shot. - const measurementShot = await page.screenshot({ encoding: "base64", type: "png" }); + const measurementShot = await page.screenshot({ + encoding: "base64", + type: "png", + }); if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64"); const raw = await finishContrast( page, @@ -778,7 +809,12 @@ function parseGeometryCandidate(value: unknown, time: number): CheckGeometryCand if (!identity) return []; const anchor = parseGeometryAnchor(value, rect, time); if (!anchor) return []; - const candidate: CheckGeometryCandidate = { ...identity, ...anchor, rect, elementRect }; + const candidate: CheckGeometryCandidate = { + ...identity, + ...anchor, + rect, + elementRect, + }; const overflow = parseOverflow(Reflect.get(value, "overflow")); if (overflow) candidate.overflow = overflow; return [candidate];