diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 22f628856b..1030612c5d 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -1,7 +1,60 @@ import { describe, expect, it } from "vitest"; -import { extractCompositionErrorsFromLint, shouldIgnoreRequestFailure } from "./validate.js"; +import { + extractCompositionErrorsFromLint, + raceMediaReady, + shouldIgnoreRequestFailure, +} from "./validate.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; +// Regression for the validate audio-duration-probe timeout: a slow-loading +// media element's duration was snapshotted once, at a fixed point in time, +// and any element still mid-load was permanently misreported as unreadable. +// raceMediaReady is the extracted wiring auditClipDurations now uses to wait +// for `loadedmetadata` up to a deadline instead. Node's built-in EventTarget +// satisfies the same duck-typed shape as a real HTMLMediaElement here, so +// this is a real test of the race/cleanup logic, not a browser mock. +describe("raceMediaReady", () => { + class FakeMediaElement extends EventTarget { + duration = NaN; + } + + it("resolves immediately when duration is already available", async () => { + const el = new FakeMediaElement(); + el.duration = 12.5; + const start = Date.now(); + await raceMediaReady(el, Date.now() + 5000); + expect(Date.now() - start).toBeLessThan(50); + }); + + it("resolves as soon as loadedmetadata fires, before the deadline", async () => { + const el = new FakeMediaElement(); + const promise = raceMediaReady(el, Date.now() + 5000); + setTimeout(() => { + el.duration = 8; + el.dispatchEvent(new Event("loadedmetadata")); + }, 20); + const start = Date.now(); + await promise; + expect(Date.now() - start).toBeLessThan(200); + }); + + it("resolves on error without hanging until the deadline", async () => { + const el = new FakeMediaElement(); + const promise = raceMediaReady(el, Date.now() + 5000); + setTimeout(() => el.dispatchEvent(new Event("error")), 20); + const start = Date.now(); + await promise; + expect(Date.now() - start).toBeLessThan(200); + }); + + it("falls back to the deadline when no event ever fires", async () => { + const el = new FakeMediaElement(); + const start = Date.now(); + await raceMediaReady(el, Date.now() + 50); + expect(Date.now() - start).toBeGreaterThanOrEqual(40); + }); +}); + describe("shouldIgnoreRequestFailure", () => { it("ignores aborted media preload requests", () => { expect( diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 824f5378c0..a298860d35 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -74,6 +74,37 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis await new Promise((r) => setTimeout(r, SEEK_SETTLE_MS)); } +/** + * Race a media element's `loadedmetadata`/`error` event against a deadline, + * whichever comes first. Already-ready elements resolve immediately. + * + * This is the same wiring as the inline copy inside `auditClipDurations`'s + * `page.evaluate()` below — duplicated, not imported, because Puppeteer + * serializes that closure's source and re-runs it in an isolated browser + * realm with no access to this module's scope. Kept here (duck-typed on + * `EventTarget`-shaped objects, not `HTMLMediaElement`) so the actual + * race/cleanup logic has a real, deterministic unit test — Node's built-in + * `EventTarget` satisfies the same shape without a browser or DOM library. + * If you change one copy, change both. + */ +export function raceMediaReady( + el: EventTarget & { duration: number }, + deadlineMs: number, +): Promise { + if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve(); + return new Promise((resolve) => { + const onReady = () => { + el.removeEventListener("loadedmetadata", onReady); + el.removeEventListener("error", onReady); + clearTimeout(timer); + resolve(); + }; + el.addEventListener("loadedmetadata", onReady, { once: true }); + el.addEventListener("error", onReady, { once: true }); + const timer = setTimeout(onReady, Math.max(0, deadlineMs - Date.now())); + }); +} + /** * Flag `