diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 1030612c5..75b0022c4 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { extractCompositionErrorsFromLint, raceMediaReady, shouldIgnoreRequestFailure, + waitForPreferredSeekTarget, } from "./validate.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; @@ -89,6 +90,28 @@ describe("shouldIgnoreRequestFailure", () => { }); }); +describe("waitForPreferredSeekTarget", () => { + it("waits for the runtime player/bridge target before falling back to raw timelines", async () => { + const page = { + waitForFunction: vi.fn(async () => undefined), + }; + + await waitForPreferredSeekTarget(page, 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 () => { + const page = { + waitForFunction: vi.fn(async () => { + throw new Error("waiting failed: timeout"); + }), + }; + + await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined(); + }); +}); + describe("extractCompositionErrorsFromLint", () => { // `bundleToSingleHtml` (the inliner validate.ts bundles through) is // intentionally tolerant of missing/empty/unparsable data-composition-src diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index a298860d3..5de45c09d 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -32,6 +32,7 @@ interface ContrastEntry { const CONTRAST_SAMPLES = 5; const SEEK_SETTLE_MS = 150; +const PREFERRED_SEEK_TARGET_WAIT_MS = 500; const MEDIA_EXTENSIONS = /\.(aac|flac|m4a|mov|mp3|mp4|oga|ogg|wav|webm)$/i; export function shouldIgnoreRequestFailure( @@ -57,7 +58,24 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom } async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { + await waitForPreferredSeekTarget(page); await page.evaluate((t: number) => { + // window.__player.renderSeek is exposed directly by the composition + // runtime (packages/core/src/runtime/init.ts) on every page load, and + // — unlike raw timeline.seek() — it also runs the runtime's own + // [data-start]/[data-duration] visibility sync, hiding clips outside + // their timeline window. window.__hf.seek only exists when the + // producer's render-pipeline bridge script has been injected, which + // validate's static preview server never does, so it was always + // falling through to the raw __timelines seek below and skipping that + // sync — leaving off-window elements looking fully visible to any + // check (e.g. the contrast audit) that reads computed style afterward. + const player = (window as unknown as { __player?: { renderSeek?: (t: number) => void } }) + .__player; + if (player && typeof player.renderSeek === "function") { + player.renderSeek(t); + return; + } if (window.__hf && typeof window.__hf.seek === "function") { window.__hf.seek(t); return; @@ -74,6 +92,32 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis await new Promise((r) => setTimeout(r, SEEK_SETTLE_MS)); } +interface WaitForFunctionPage { + waitForFunction: (pageFunction: () => boolean, options: { timeout: number }) => Promise; +} + +export async function waitForPreferredSeekTarget( + page: WaitForFunctionPage, + timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS, +): Promise { + try { + await page.waitForFunction( + () => { + const w = window as unknown as { + __hf?: { seek?: unknown }; + __player?: { renderSeek?: unknown }; + }; + return typeof w.__player?.renderSeek === "function" || typeof w.__hf?.seek === "function"; + }, + { timeout: timeoutMs }, + ); + } catch { + // Older/static pages may only expose raw window.__timelines. Keep the + // legacy fallback path rather than turning a missing player API into a + // validate failure. + } +} + /** * Race a media element's `loadedmetadata`/`error` event against a deadline, * whichever comes first. Already-ready elements resolve immediately.