mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
// Imported before "./validate.js" below: validate.js's own static import of
|
|
// ../utils/project.js triggers that mocked module's factory as soon as
|
|
// validate.js loads, so resolveProjectMock/lintProjectFailureMock must
|
|
// already be bound by then (see the vi.mock calls a few lines down).
|
|
import {
|
|
lintProjectFailureMock,
|
|
metaDescription,
|
|
resolveProjectMock,
|
|
runAndCaptureStdio,
|
|
runAndParseJsonEnvelope,
|
|
} from "./deprecationTestHarness.js";
|
|
import {
|
|
auditClipDurations,
|
|
extractCompositionErrorsFromLint,
|
|
navigationTimeoutHint,
|
|
raceMediaReady,
|
|
resolveNavigationTimeoutMs,
|
|
shouldIgnoreRequestFailure,
|
|
} from "./validate.js";
|
|
import { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js";
|
|
import type { ProjectLintResult } from "../utils/lintProject.js";
|
|
|
|
// validateInBrowser lazy-loads the producer localize helpers via loadProducer;
|
|
// mock it so these unit tests never resolve @hyperframes/producer's built dist.
|
|
vi.mock("../utils/producer.js", () => ({
|
|
loadProducer: vi.fn(async () => ({
|
|
localizeRemoteMediaSources: vi.fn(async (html: string) => ({
|
|
html,
|
|
remoteMediaAssets: new Map(),
|
|
})),
|
|
localizeRemoteImageSources: vi.fn(async (html: string) => ({
|
|
html,
|
|
remoteMediaAssets: new Map(),
|
|
})),
|
|
localizeRemoteFontFaces: vi.fn(async (html: string) => ({
|
|
html,
|
|
remoteMediaAssets: new Map(),
|
|
})),
|
|
})),
|
|
}));
|
|
|
|
// U5 deprecation tests: resolveProject and lintProject are both reached via a
|
|
// dynamic `await import(...)` inside validate.ts's run() / validateInBrowser(),
|
|
// so vi.mock intercepts them the same way it would a static import. Mocking
|
|
// resolveProject skips real filesystem project resolution; mocking lintProject
|
|
// (the first await inside validateInBrowser) gives a fast, deterministic
|
|
// failure well before any real browser or network work — exercising run()'s
|
|
// outer catch (the JSON failure envelope) without needing headless Chrome.
|
|
vi.mock("../utils/project.js", () => resolveProjectMock());
|
|
vi.mock("../utils/lintProject.js", () => lintProjectFailureMock());
|
|
|
|
describe("auditClipDurations", () => {
|
|
it("audits audio only because explicit video slots hold their final frame", async () => {
|
|
let selector = "";
|
|
const originalDocument = globalThis.document;
|
|
const audio = {
|
|
duration: 1,
|
|
id: "voice",
|
|
loop: false,
|
|
tagName: "AUDIO",
|
|
getAttribute: (name: string) =>
|
|
name === "data-duration" ? "5" : name === "data-media-start" ? "0" : null,
|
|
};
|
|
const page = {
|
|
evaluate: async (fn: (waitMs: number) => unknown, waitMs: number) => {
|
|
Object.defineProperty(globalThis, "document", {
|
|
configurable: true,
|
|
value: {
|
|
querySelectorAll: (query: string) => {
|
|
selector = query;
|
|
return [audio];
|
|
},
|
|
},
|
|
});
|
|
return fn(waitMs);
|
|
},
|
|
};
|
|
|
|
try {
|
|
const warnings = await auditClipDurations(
|
|
page as never,
|
|
({ slotSeconds, mediaSeconds }) => ({
|
|
shortfallSeconds: slotSeconds - mediaSeconds,
|
|
toleranceSeconds: 0.05,
|
|
}),
|
|
10,
|
|
);
|
|
expect(selector).toBe("audio[data-duration]");
|
|
expect(warnings).toHaveLength(1);
|
|
expect(warnings[0]?.text).toContain('Audio "voice"');
|
|
} finally {
|
|
if (originalDocument === undefined) {
|
|
Reflect.deleteProperty(globalThis, "document");
|
|
} else {
|
|
Object.defineProperty(globalThis, "document", {
|
|
configurable: true,
|
|
value: originalDocument,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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(
|
|
shouldIgnoreRequestFailure("http://127.0.0.1:3000/assets/sfx.wav", "net::ERR_ABORTED"),
|
|
).toBe(true);
|
|
expect(shouldIgnoreRequestFailure("http://127.0.0.1:3000/video.mp4", "net::ERR_ABORTED")).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
shouldIgnoreRequestFailure(
|
|
"https://www.heygenverse.com/s/50f13ccf-9002-4d80-b567-9d4c0eac30d8/raw",
|
|
"net::ERR_ABORTED",
|
|
"media",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps non-media and non-aborted failures reportable", () => {
|
|
expect(
|
|
shouldIgnoreRequestFailure("http://127.0.0.1:3000/assets/map.png", "net::ERR_ABORTED"),
|
|
).toBe(false);
|
|
expect(
|
|
shouldIgnoreRequestFailure(
|
|
"https://www.heygenverse.com/s/50f13ccf-9002-4d80-b567-9d4c0eac30d8/raw",
|
|
"net::ERR_ABORTED",
|
|
"xhr",
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
shouldIgnoreRequestFailure("http://127.0.0.1:3000/assets/sfx.wav", "net::ERR_FAILED"),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it("does not fail validation when the page stub throws synchronously", async () => {
|
|
const page = {
|
|
waitForFunction: vi.fn(() => {
|
|
throw new Error("waiting failed synchronously");
|
|
}),
|
|
};
|
|
|
|
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
|
|
// files — it skips the scene and keeps going, silently, so `validate`
|
|
// would otherwise report "No console errors" for a project that renders a
|
|
// materially broken video. extractCompositionErrorsFromLint pulls the
|
|
// 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 }>,
|
|
): Pick<ProjectLintResult, "results"> {
|
|
return {
|
|
results: [
|
|
{
|
|
file: "index.html",
|
|
result: {
|
|
ok: findings.length === 0,
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
infoCount: 0,
|
|
findings,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
it("surfaces missing_or_empty_sub_composition errors as ConsoleEntry errors", () => {
|
|
const lintResult = makeLintResult([
|
|
{
|
|
code: "missing_or_empty_sub_composition",
|
|
severity: "error",
|
|
message:
|
|
'data-composition-src references "compositions/scene-title.html", but the file is empty.',
|
|
},
|
|
]);
|
|
|
|
const errors = extractCompositionErrorsFromLint(lintResult);
|
|
|
|
expect(errors).toEqual([
|
|
{
|
|
level: "error",
|
|
text: 'data-composition-src references "compositions/scene-title.html", but the file is empty.',
|
|
},
|
|
]);
|
|
});
|
|
|
|
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" },
|
|
]);
|
|
|
|
expect(extractCompositionErrorsFromLint(lintResult)).toEqual([]);
|
|
});
|
|
|
|
it("returns an empty array for a clean project", () => {
|
|
expect(extractCompositionErrorsFromLint(makeLintResult([]))).toEqual([]);
|
|
});
|
|
|
|
it("collects findings across multiple result files", () => {
|
|
const lintResult: Pick<ProjectLintResult, "results"> = {
|
|
results: [
|
|
{
|
|
file: "index.html",
|
|
result: {
|
|
ok: false,
|
|
errorCount: 1,
|
|
warningCount: 0,
|
|
infoCount: 0,
|
|
findings: [
|
|
{
|
|
code: "missing_or_empty_sub_composition",
|
|
severity: "error",
|
|
message: "scene-a is empty",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
file: "compositions/nested.html",
|
|
result: {
|
|
ok: false,
|
|
errorCount: 1,
|
|
warningCount: 0,
|
|
infoCount: 0,
|
|
findings: [
|
|
{
|
|
code: "missing_or_empty_sub_composition",
|
|
severity: "error",
|
|
message: "scene-b is empty",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const errors = extractCompositionErrorsFromLint(lintResult);
|
|
expect(errors).toHaveLength(2);
|
|
expect(errors.map((e) => e.text)).toEqual(["scene-a is empty", "scene-b is empty"]);
|
|
});
|
|
});
|
|
|
|
// Regression: `validate` used a hardcoded 10s page-navigation timeout that
|
|
// ignored --timeout, so a composition loading GSAP from a CDN <script> (which
|
|
// blocks domcontentloaded) failed with an opaque "Navigation timeout of 10000ms"
|
|
// even though the full render's larger budget rode it out — with no knob to
|
|
// extend it. resolveNavigationTimeoutMs makes --timeout raise the nav budget
|
|
// (never below the 10s floor); navigationTimeoutHint replaces the opaque error.
|
|
describe("resolveNavigationTimeoutMs", () => {
|
|
it("keeps the 10s floor when --timeout is unset or smaller", () => {
|
|
expect(resolveNavigationTimeoutMs(undefined)).toBe(10000);
|
|
expect(resolveNavigationTimeoutMs(3000)).toBe(10000); // the default --timeout
|
|
expect(resolveNavigationTimeoutMs(0)).toBe(10000);
|
|
});
|
|
|
|
it("raises the navigation budget to --timeout when it exceeds the floor", () => {
|
|
expect(resolveNavigationTimeoutMs(30000)).toBe(30000);
|
|
});
|
|
});
|
|
|
|
describe("navigationTimeoutHint", () => {
|
|
it("replaces a Puppeteer navigation-timeout error with an actionable CDN/--timeout hint", () => {
|
|
const hinted = navigationTimeoutHint(
|
|
new Error("Navigation timeout of 10000 ms exceeded"),
|
|
10000,
|
|
);
|
|
expect(hinted).toBeInstanceOf(Error);
|
|
expect(hinted?.message).toContain("10000ms");
|
|
expect(hinted?.message).toContain("CDN");
|
|
expect(hinted?.message).toContain("--timeout");
|
|
});
|
|
|
|
it("returns null for any non-navigation-timeout error so the caller rethrows it as-is", () => {
|
|
expect(navigationTimeoutHint(new Error("net::ERR_CONNECTION_REFUSED"), 10000)).toBeNull();
|
|
expect(navigationTimeoutHint("some string failure", 10000)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("validate command deprecation (U5)", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("marks the command description as deprecated", async () => {
|
|
const { default: validateCommand } = await import("./validate.js");
|
|
expect(metaDescription(validateCommand)).toContain("(deprecated, use check)");
|
|
});
|
|
|
|
it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
|
|
const { default: validateCommand } = await import("./validate.js");
|
|
const { stderrText, stdoutText } = await runAndCaptureStdio(validateCommand);
|
|
expect(stderrText).toContain("hyperframes validate");
|
|
expect(stderrText).toContain("hyperframes check");
|
|
expect(stdoutText).toBe("");
|
|
});
|
|
|
|
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
|
|
const { default: validateCommand } = await import("./validate.js");
|
|
const { parsed } = await runAndParseJsonEnvelope(validateCommand);
|
|
expect(parsed.ok).toBe(false);
|
|
expect(parsed._meta.deprecated).toBe(true);
|
|
});
|
|
});
|