Skip to content
Open
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
40 changes: 37 additions & 3 deletions packages/cli/src/commands/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
raceMediaReady,
resolveNavigationTimeoutMs,
shouldIgnoreRequestFailure,
shouldIgnoreHttpError,
} from "./validate.js";
import { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
Expand Down Expand Up @@ -99,6 +100,29 @@ describe("raceMediaReady", () => {
});

describe("shouldIgnoreRequestFailure", () => {
it("ignores the optional caption overrides file when it is absent", () => {
expect(
shouldIgnoreRequestFailure(
"http://127.0.0.1:3000/caption-overrides.json",
"net::ERR_ABORTED",
"fetch",
),
).toBe(true);
expect(shouldIgnoreHttpError("http://127.0.0.1:3000/caption-overrides.json", 404)).toBe(true);
});

it("keeps non-optional JSON and non-404 caption override failures reportable", () => {
expect(
shouldIgnoreRequestFailure(
"http://127.0.0.1:3000/transcript.json",
"net::ERR_ABORTED",
"fetch",
),
).toBe(false);
expect(shouldIgnoreHttpError("http://127.0.0.1:3000/transcript.json", 404)).toBe(false);
expect(shouldIgnoreHttpError("http://127.0.0.1:3000/caption-overrides.json", 500)).toBe(false);
});

it("ignores aborted media preload requests", () => {
expect(
shouldIgnoreRequestFailure("http://127.0.0.1:3000/assets/sfx.wav", "net::ERR_ABORTED"),
Expand Down Expand Up @@ -140,7 +164,9 @@ describe("waitForPreferredSeekTarget", () => {

await waitForPreferredSeekTarget(page, 123);

expect(page.waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 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 () => {
Expand Down Expand Up @@ -173,7 +199,11 @@ describe("extractCompositionErrorsFromLint", () => {
// 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 }>,
findings: Array<{
code: string;
severity: "error" | "warning" | "info";
message: string;
}>,
): Pick<ProjectLintResult, "results"> {
return {
results: [
Expand Down Expand Up @@ -214,7 +244,11 @@ describe("extractCompositionErrorsFromLint", () => {
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" },
{
code: "root_missing_composition_id",
severity: "error",
message: "also unrelated",
},
]);

expect(extractCompositionErrorsFromLint(lintResult)).toEqual([]);
Expand Down
73 changes: 63 additions & 10 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function shouldIgnoreRequestFailure(
errorText: string | undefined,
resourceType?: string,
): boolean {
if (isOptionalCaptionOverridesRequest(url)) return true;
if (errorText !== "net::ERR_ABORTED") return false;
if (resourceType === "media") return true;
try {
Expand All @@ -87,6 +88,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<number> {
return page.evaluate(() => {
if (window.__hf?.duration && window.__hf.duration > 0) return window.__hf.duration;
Expand Down Expand Up @@ -207,7 +220,11 @@ export async function auditClipDurations(
continue;
}
const mediaSeconds = Math.max(0, clip.duration - clip.mediaStart);
const fit = analyzeClipMediaFit({ slotSeconds: clip.slot, mediaSeconds, loop: clip.loop });
const fit = analyzeClipMediaFit({
slotSeconds: clip.slot,
mediaSeconds,
loop: clip.loop,
});
if (!fit) continue;
warnings.push({
level: "warning",
Expand Down Expand Up @@ -276,7 +293,10 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
: [],
)) as ContrastCandidate[];

const screenshot = (await page.screenshot({ encoding: "base64", type: "png" })) as string;
const screenshot = (await page.screenshot({
encoding: "base64",
type: "png",
})) as string;
const entries = await page.evaluate(
(b64: string, time: number, cands: ContrastCandidate[]) =>
typeof (window as unknown as Record<string, unknown>).__contrastAuditFinish === "function"
Expand Down Expand Up @@ -369,7 +389,11 @@ async function localizeRemoteAssets(
async function validateInBrowser(
project: ProjectDir,
opts: { timeout?: number; contrast?: boolean },
): Promise<{ errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] }> {
): Promise<{
errors: ConsoleEntry[];
warnings: ConsoleEntry[];
contrast?: ContrastEntry[];
}> {
const projectDir = project.dir;
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const { ensureBrowser } = await import("../browser/manager.js");
Expand Down Expand Up @@ -431,9 +455,19 @@ async function validateInBrowser(
const text = msg.text();
if (type === "error") {
if (text.startsWith("Failed to load resource")) return;
errors.push({ level: "error", text, url: loc.url, line: loc.lineNumber });
errors.push({
level: "error",
text,
url: loc.url,
line: loc.lineNumber,
});
} else if (type === "warn") {
warnings.push({ level: "warning", text, url: loc.url, line: loc.lineNumber });
warnings.push({
level: "warning",
text,
url: loc.url,
line: loc.lineNumber,
});
}
});

Expand Down Expand Up @@ -463,14 +497,22 @@ 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 });
errors.push({
level: "error",
text: `${res.status()} loading ${path}`,
url,
});
}
});

const navTimeoutMs = resolveNavigationTimeoutMs(opts.timeout);
try {
await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: navTimeoutMs });
await page.goto(server.url, {
waitUntil: "domcontentloaded",
timeout: navTimeoutMs,
});
} catch (err) {
const hinted = navigationTimeoutHint(err, navTimeoutMs);
if (hinted) throw hinted;
Expand Down Expand Up @@ -593,7 +635,11 @@ Examples:
hyperframes validate --timeout 5000`,
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
dir: {
type: "positional",
description: "Project directory",
required: false,
},
json: { type: "boolean", description: "Output as JSON", default: false },
contrast: {
type: "boolean",
Expand All @@ -620,7 +666,10 @@ Examples:
}

try {
const result = await validateInBrowser(project, { timeout, contrast: useContrast });
const result = await validateInBrowser(project, {
timeout,
contrast: useContrast,
});
const exitCode = printValidationResult(result, asJson);
process.exit(exitCode);
} catch (err: unknown) {
Expand All @@ -632,7 +681,11 @@ Examples:
});

function printValidationResult(
result: { errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] },
result: {
errors: ConsoleEntry[];
warnings: ConsoleEntry[];
contrast?: ContrastEntry[];
},
asJson: boolean,
): number {
const { errors, warnings, contrast } = result;
Expand Down
56 changes: 46 additions & 10 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,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";
Expand Down Expand Up @@ -116,7 +120,12 @@ export async function runBrowserCheck(
// errors). The session is already open, so this is one extra evaluate.
const { analyzeClipMediaFit } = await import("@hyperframes/engine");
for (const entry of await auditClipDurations(page, analyzeClipMediaFit, options.timeout)) {
drafts.push({ code: "clip_media_fit", severity: entry.level, message: entry.text, time: 0 });
drafts.push({
code: "clip_media_fit",
severity: entry.level,
message: entry.text,
time: 0,
});
}
const driver = createPageDriver(page, (time) => {
currentTime = time;
Expand Down Expand Up @@ -211,7 +220,12 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: (
if (message.includes("Unexpected token '<'") || message.includes("Unexpected token '&lt;'")) {
return;
}
drafts.push({ code: "page_error", severity: "error", message, time: currentTime() });
drafts.push({
code: "page_error",
severity: "error",
message,
time: currentTime(),
});
});
wireNetworkListeners(page, drafts, currentTime);
}
Expand All @@ -234,6 +248,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",
Expand All @@ -250,7 +265,10 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
getDuration: () => getCompositionDuration(page),
getTransitionBoundaries: () => collectTweenBoundaries(page),
getCanvas: () =>
page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
})),
findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors),
seek: async (time) => {
setTime(time);
Expand All @@ -267,10 +285,16 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
}

async function injectAuditScripts(page: Page, contrast: boolean): Promise<void> {
await page.addScriptTag({ content: loadBrowserScript("layout-audit.browser.js") });
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
await page.addScriptTag({
content: loadBrowserScript("layout-audit.browser.js"),
});
await page.addScriptTag({
content: loadBrowserScript("motion-sample.browser.js"),
});
if (contrast) {
await page.addScriptTag({ content: loadBrowserScript("contrast-audit.browser.js") });
await page.addScriptTag({
content: loadBrowserScript("contrast-audit.browser.js"),
});
}
}

Expand Down Expand Up @@ -484,7 +508,11 @@ async function resolveAnchors(page: Page, requests: AnchorRequest[]): Promise<Ch

async function resolveRootAnchor(page: Page): Promise<CheckAnchor> {
const anchors = await resolveAnchors(page, [
{ selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) },
{
selector: "[data-composition-id]",
time: 0,
bbox: await compositionBbox(page),
},
]);
return anchors[0] ?? fallbackAnchor(undefined);
}
Expand All @@ -510,7 +538,10 @@ async function collectContrast(
// This screenshot is the one contrast math is sampled from below — it must
// stay untouched by the annotation overlay (finishContrast reads real
// painted pixels), so annotation only ever happens on a SECOND shot.
const measurementShot = await page.screenshot({ encoding: "base64", type: "png" });
const measurementShot = await page.screenshot({
encoding: "base64",
type: "png",
});
if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64");
const raw = await finishContrast(
page,
Expand Down Expand Up @@ -778,7 +809,12 @@ function parseGeometryCandidate(value: unknown, time: number): CheckGeometryCand
if (!identity) return [];
const anchor = parseGeometryAnchor(value, rect, time);
if (!anchor) return [];
const candidate: CheckGeometryCandidate = { ...identity, ...anchor, rect, elementRect };
const candidate: CheckGeometryCandidate = {
...identity,
...anchor,
rect,
elementRect,
};
const overflow = parseOverflow(Reflect.get(value, "overflow"));
if (overflow) candidate.overflow = overflow;
return [candidate];
Expand Down
Loading