diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 2d78f6b392..422250a499 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -17,6 +17,7 @@ import { raceMediaReady, resolveNavigationTimeoutMs, shouldIgnoreRequestFailure, + shouldIgnoreHttpError, } from "./validate.js"; import { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; @@ -152,6 +153,18 @@ describe("raceMediaReady", () => { }); describe("shouldIgnoreRequestFailure", () => { + it("ignores only the optional root caption overrides 404/abort", () => { + const url = "http://127.0.0.1:3000/caption-overrides.json"; + expect(shouldIgnoreHttpError(url, 404)).toBe(true); + expect(shouldIgnoreRequestFailure(url, "net::ERR_ABORTED", "fetch")).toBe(true); + expect(shouldIgnoreHttpError(url, 500)).toBe(false); + expect(shouldIgnoreRequestFailure(url, "net::ERR_FAILED", "fetch")).toBe(false); + expect(shouldIgnoreHttpError("http://127.0.0.1:3000/transcript.json", 404)).toBe(false); + expect(shouldIgnoreHttpError("http://127.0.0.1:3000/assets/caption-overrides.json", 404)).toBe( + false, + ); + }); + it("ignores aborted media preload requests", () => { expect( shouldIgnoreRequestFailure("http://127.0.0.1:3000/assets/sfx.wav", "net::ERR_ABORTED"), diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index e25989d7c0..14b5a33595 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -79,6 +79,7 @@ export function shouldIgnoreRequestFailure( errorText: string | undefined, resourceType?: string, ): boolean { + if (errorText === "net::ERR_ABORTED" && isOptionalCaptionOverridesRequest(url)) return true; if (errorText !== "net::ERR_ABORTED") return false; if (resourceType === "media") return true; try { @@ -88,6 +89,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; @@ -477,6 +490,7 @@ 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 }); } diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 21a12db9dc..ef40f832c7 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -13,7 +13,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"; @@ -381,6 +385,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", diff --git a/packages/core/src/runtime/captionOverrides.test.ts b/packages/core/src/runtime/captionOverrides.test.ts index e62cf5f5a2..d0a941e5d2 100644 --- a/packages/core/src/runtime/captionOverrides.test.ts +++ b/packages/core/src/runtime/captionOverrides.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { applyCaptionOverrides } from "./captionOverrides"; -function installCaptionOverrideFetch(overrides: unknown[]) { +function installCaptionOverrideFetch(overrides: unknown) { vi.stubGlobal("fetch", async () => ({ ok: true, async json() { @@ -60,6 +60,49 @@ afterEach(() => { }); describe("applyCaptionOverrides", () => { + it("treats a missing optional sidecar as a silent no-op", async () => { + installGsapMock(); + const json = vi.fn(); + vi.stubGlobal("fetch", async () => ({ ok: false, status: 404, json })); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + document.body.innerHTML = `
Hi
`; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + + expect(json).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it.each([ + ["malformed JSON", () => Promise.reject(new SyntaxError("Unexpected token"))], + ["a non-array root", () => Promise.resolve({ wordIndex: 0 })], + ["a non-object entry", () => Promise.resolve([null])], + ])("reports a present sidecar containing %s", async (_shape, json) => { + installGsapMock(); + vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json })); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + document.body.innerHTML = `
Hi
`; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + + expect(error).toHaveBeenCalledOnce(); + expect(error.mock.calls[0]?.[0]).toContain("caption-overrides.json"); + }); + + it("accepts an empty array as the explicit no-op payload", async () => { + installGsapMock(); + installCaptionOverrideFetch([]); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + document.body.innerHTML = `
Hi
`; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + + expect(error).not.toHaveBeenCalled(); + }); + it("reuses existing caption wrappers when overrides are applied more than once", async () => { const { setCalls } = installGsapMock(); installCaptionOverrideFetch([{ wordIndex: 0, x: 12, y: -4, scale: 1.2 }]); diff --git a/packages/core/src/runtime/captionOverrides.ts b/packages/core/src/runtime/captionOverrides.ts index 1cd713b379..9b8c5113a1 100644 --- a/packages/core/src/runtime/captionOverrides.ts +++ b/packages/core/src/runtime/captionOverrides.ts @@ -27,6 +27,20 @@ interface CaptionOverride { fontFamily?: string; } +function isCaptionOverride(value: unknown): value is CaptionOverride { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseCaptionOverridePayload(value: unknown): CaptionOverride[] { + if (!Array.isArray(value)) { + throw new Error("expected a JSON array"); + } + if (!value.every(isCaptionOverride)) { + throw new Error("every array entry must be an object"); + } + return value; +} + interface GsapTween { vars: Record; startTime(): number; @@ -109,13 +123,15 @@ export function applyCaptionOverrides(): void { if (!r.ok) return null; return r.json(); }) - .then((data: CaptionOverride[] | null) => { - if (!data || !Array.isArray(data) || data.length === 0) return; + .then((data: unknown) => { + if (data === null) return; + const overrides = parseCaptionOverridePayload(data); + if (overrides.length === 0) return; // Build word element index for wordIndex fallback const wordEls = getCaptionWordElements(); - for (const override of data) { + for (const override of overrides) { let el: HTMLElement | null = null; if (override.wordId) { el = resolveCaptionWordElement(document.getElementById(override.wordId)); @@ -191,5 +207,8 @@ export function applyCaptionOverrides(): void { } } }) - .catch(() => {}); + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[HyperFrames] Invalid caption-overrides.json: ${message}`); + }); }