Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/cli/src/commands/validate.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -57,7 +58,24 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom
}

async function seekTo(page: import("puppeteer-core").Page, time: number): Promise<void> {
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;
Expand All @@ -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<unknown>;
}

export async function waitForPreferredSeekTarget(
page: WaitForFunctionPage,
timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
): Promise<void> {
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.
Expand Down
Loading