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
112 changes: 111 additions & 1 deletion packages/cli/src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "../utils/checkPipeline.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { consumeCommandResult } from "../utils/commandResult.js";
import type { BundleDiagnostic } from "@hyperframes/core/compiler";
import type { ProjectLintResult } from "../utils/lintProject.js";
import type {
LayoutIssue,
Expand Down Expand Up @@ -264,6 +265,7 @@ function dependencies(
runtime?: CheckFinding[];
writeSnapshot?: CheckDependencies["writeSnapshot"];
captureFindingCrops?: CheckDependencies["captureFindingCrops"];
compile?: BundleDiagnostic[];
} = {},
): { deps: CheckDependencies; runBrowserCheck: ReturnType<typeof vi.fn> } {
const runBrowserCheck = vi.fn(
Expand All @@ -273,7 +275,11 @@ function dependencies(
motion: MotionSpecResolution,
): Promise<CheckBrowserResult> => {
const result = await runAuditGrid(driver, checkOptions, motion);
return { ...result, runtimeFindings: options.runtime ?? [] };
return {
...result,
runtimeFindings: options.runtime ?? [],
compileDiagnostics: options.compile ?? [],
};
},
);
const deps: CheckDependencies = {
Expand Down Expand Up @@ -306,6 +312,16 @@ async function runScenario(
return { report, deps, browser: runBrowserCheck };
}

function lutDiagnostic(): BundleDiagnostic {
return {
code: "color_grading_lut_not_inlined",
severity: "warning",
message:
'[HyperFrames] Could not inline color grading LUT "assets/luts/gone.cube". The rendered bundle may not be self-contained.',
source: "assets/luts/gone.cube",
};
}

function runtimeError(): CheckFinding {
return {
code: "console_error",
Expand Down Expand Up @@ -814,6 +830,10 @@ function reportWithFindings(overrides: Partial<CheckReport> = {}): CheckReport {
ok: true,
strict: false,
lint: { ...emptySection(), filesScanned: 0 },
// `reached: true` with no findings is the clean-project shape. The other
// value means bundling never ran, which is "unknown" rather than "none",
// and no test here is about that case.
compile: { ...emptySection(), reached: true },
runtime: emptySection(),
layout: {
...emptySection(),
Expand Down Expand Up @@ -1526,3 +1546,93 @@ describe("dense motion-overlap re-sampling", () => {
expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true);
});
});

describe("compile diagnostics section", () => {
afterEach(() => {
consumeCommandResult();
vi.restoreAllMocks();
});

it("surfaces bundler diagnostics as findings and marks the section reached", async () => {
const { report } = await runScenario(fakeDriver(), {}, { compile: [lutDiagnostic()] });

expect(report.compile.reached).toBe(true);
expect(report.compile.findings).toHaveLength(1);
expect(report.compile.findings[0]).toMatchObject({
code: "color_grading_lut_not_inlined",
severity: "warning",
sourceFile: "assets/luts/gone.cube",
});
expect(report.compile.warningCount).toBe(1);
expect(report.compile.errorCount).toBe(0);
});

it("keeps compile warnings out of the pass/fail gate, even under --strict", async () => {
const { report } = await runScenario(
fakeDriver(),
{ strict: true },
{ compile: [lutDiagnostic()] },
);

expect(report.ok).toBe(true);
expect(checkExitCode(report)).toBe(0);
});

it("reports an empty but reached compile section for a clean project", async () => {
const { report } = await runScenario(fakeDriver());

expect(report.compile.reached).toBe(true);
expect(report.compile.findings).toEqual([]);
expect(report.compile.ok).toBe(true);
});

it("marks compile not-reached when a lint error short-circuits before bundling", async () => {
const { report, browser } = await runScenario(
fakeDriver(),
{},
{ lint: lintWith("error", "missing_composition_id", "boom"), compile: [lutDiagnostic()] },
);

expect(browser).not.toHaveBeenCalled();
expect(report.compile.reached).toBe(false);
expect(report.compile.findings).toEqual([]);
});

it("carries the compile section into the --json envelope", async () => {
const { report } = await runScenario(fakeDriver(), {}, { compile: [lutDiagnostic()] });
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => PROJECT,
runPipeline: vi.fn(async () => report),
withMeta: (value) => ({ ...value, _meta: { version: "test" } }),
});

await runCommand(command, { rawArgs: ["--json"] });

const output = log.mock.calls[0]?.[0];
if (typeof output !== "string") throw new Error("expected JSON output");
const envelope = JSON.parse(output) as CheckReport;
expect(envelope.compile).toMatchObject({
ok: true,
reached: true,
warningCount: 1,
findings: [{ code: "color_grading_lut_not_inlined", severity: "warning" }],
});
});

it("prints the compile section in the human report", async () => {
const { report } = await runScenario(fakeDriver(), {}, { compile: [lutDiagnostic()] });
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => PROJECT,
runPipeline: vi.fn(async () => report),
withMeta: (value) => value,
});

await runCommand(command, { rawArgs: [] });

const printed = log.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(printed).toContain("Compile");
expect(printed).toContain("color_grading_lut_not_inlined");
});
});
19 changes: 19 additions & 0 deletions packages/cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ function nonNegativeNumber(value: unknown, fallback: number): number {

function printHumanReport(report: CheckReport): void {
printSection("Lint", report.lint);
printCompileSection(report);
printSection("Runtime", report.runtime);
printLayoutSection("Layout", report.layout);
printSection("Motion", report.motion);
Expand All @@ -415,6 +416,24 @@ function printHumanReport(report: CheckReport): void {
console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`);
}

/** Compile-time bundler diagnostics. `reached: false` means the pipeline
* short-circuited before bundling (any lint error does this), so "0 findings"
* would be a lie — say "not reached" instead. */
function printCompileSection(report: CheckReport): void {
console.log();
console.log(c.bold("Compile"));
if (!report.compile.reached) {
console.log(` ${c.dim("◇")} not reached (fix lint errors first)`);
return;
}
if (report.compile.findings.length === 0) {
console.log(` ${c.success("◇")} 0 errors, 0 warnings`);
return;
}
for (const finding of report.compile.findings) printFinding(finding);
printCounts(report.compile);
}

function printSection(title: string, section: CheckSection): void {
console.log();
console.log(c.bold(title));
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/utils/bundleWithLocalizedFonts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { BundleDiagnosticSink } from "@hyperframes/core/compiler";
import { normalizeErrorMessage } from "./errorMessage.js";
import { c } from "../ui/colors.js";

Expand All @@ -20,9 +21,12 @@ export async function bundleWithLocalizedFonts(
// Injectable for tests. Production callers omit it and get the producer
// font-localization pass (see localizeWithProducer).
localizeFonts: (html: string) => Promise<string> = localizeWithProducer,
// Optional compile-diagnostic sink, threaded straight through to the bundler.
// Omit it and the bundler keeps console.warn-ing exactly as before.
onDiagnostic?: BundleDiagnosticSink,
): Promise<string> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(projectDir);
const html = await bundleToSingleHtml(projectDir, onDiagnostic ? { onDiagnostic } : undefined);
return localizeFonts(html);
}

Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/utils/checkBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,11 @@ it("carries raw browser geometry through the page driver and pipeline", async ()
runAuditGrid,
);

expect(mocks.bundleWithLocalizedFonts).toHaveBeenCalledWith(PROJECT.dir);
expect(mocks.bundleWithLocalizedFonts).toHaveBeenCalledWith(
PROJECT.dir,
undefined,
expect.any(Function),
);
expect(result.layoutIssues).toEqual([
expect.objectContaining({
code: "frame_out_of_frame",
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type { Page } from "puppeteer-core";
import type { BundleDiagnostic } from "@hyperframes/core/compiler";
import {
AUDIT_SEEK_OPTIONS,
DENSE_GEOMETRY_SEEK_OPTIONS,
Expand Down Expand Up @@ -150,7 +151,13 @@ export async function runBrowserCheck(
runGrid: RunAuditGrid,
): Promise<CheckBrowserResult> {
const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js");
const html = await bundleWithLocalizedFonts(project.dir);
// Compile diagnostics are captured here rather than console.warn-ed, so they
// land in the check report. captureFindingCrops re-bundles the same project
// and deliberately does NOT collect them again (they would be duplicates).
const compileDiagnostics: BundleDiagnostic[] = [];
const html = await bundleWithLocalizedFonts(project.dir, undefined, (diagnostic) =>
compileDiagnostics.push(diagnostic),
);
await preResolveHostileMediaProxies(project.dir, html, options.autoProxy);
const server = await serveStaticProjectHtml(
project.dir,
Expand Down Expand Up @@ -195,6 +202,7 @@ export async function runBrowserCheck(
...result,
timings: { ...result.timings, launchSettleMs },
runtimeFindings: drafts.map((draft) => runtimeFinding(draft, rootAnchor)),
compileDiagnostics,
};
} finally {
await chromeBrowser?.close().catch(() => undefined);
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/src/utils/checkPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type MotionFrame,
} from "./motionAudit.js";
import { findMotionSpec, readMotionSpec } from "./motionSpec.js";
import type { BundleDiagnostic } from "@hyperframes/core/compiler";
import { normalizeErrorMessage } from "./errorMessage.js";
import {
parseColorRGBA,
Expand Down Expand Up @@ -1097,6 +1098,8 @@ export async function runAuditGrid(
contrastPassed: contrast.passed,
screenshots: collected.screenshots,
timings: { launchSettleMs: 0, seekLoopMs, contrastMs: collected.contrastMs },
// The grid never bundles; runBrowserCheck owns bundling and overwrites this.
compileDiagnostics: [],
};
}

Expand Down Expand Up @@ -1132,17 +1135,22 @@ export async function runCheckPipeline(
}

let browser: CheckBrowserResult;
// Bundling happens inside runBrowserCheck, so a throw from there (a Chrome
// launch failure, a bundler error) leaves the compile pass unobserved rather
// than clean. Track that instead of letting an empty section read as a pass.
let bundlingReached = true;
try {
browser = await dependencies.runBrowserCheck(project, options, motion);
} catch (error) {
browser = emptyBrowserResult();
browser.runtimeFindings.push(runtimeFailure(error));
bundlingReached = false;
}

const snapshotFiles = options.snapshots
? await writeContrastSnapshots(dependencies, project.dir, browser)
: [];
const report = buildReport(options, lint, browser, motion, [], snapshotFiles);
const report = buildReport(options, lint, browser, motion, [], snapshotFiles, bundlingReached);
return options.snapshots
? await withFindingCrops(dependencies, project, options, report)
: report;
Expand Down Expand Up @@ -1322,7 +1330,12 @@ function buildReport(
motion: MotionSpecResolution,
extraMotionFindings: CheckFinding[],
snapshotFiles: string[],
bundlingReached = false,
): CheckReport {
const compile = {
...section(browser.compileDiagnostics.map(compileFinding)),
reached: bundlingReached,
};
const layout = shapeLayoutSection(browser.layoutIssues, browser, options);
const shapedMotion = shapeLayoutFindings(browser.motionIssues, options);
const motionFindings: CheckFinding[] = [...shapedMotion.findings, ...extraMotionFindings];
Expand All @@ -1345,6 +1358,7 @@ function buildReport(
ok: errorCount === 0 && (!options.strict || warningCount === 0),
strict: options.strict,
lint,
compile,
runtime,
layout,
motion: {
Expand Down Expand Up @@ -1468,9 +1482,22 @@ function emptyBrowserResult(): CheckBrowserResult {
contrastPassed: 0,
screenshots: [],
timings: { launchSettleMs: 0, seekLoopMs: 0, contrastMs: 0 },
compileDiagnostics: [],
};
}

/** A compile diagnostic as a report finding. The bundler has no source
* positions at these sites, so it anchors at the composition root like the
* lint and motion-spec findings do. */
function compileFinding(diagnostic: BundleDiagnostic): CheckFinding {
return findingAtRoot(
diagnostic.code,
diagnostic.severity,
diagnostic.message,
diagnostic.source ?? "index.html",
);
}

function runtimeFailure(error: unknown, code = "check_runtime_failure"): CheckFinding {
return findingAtRoot(code, "error", normalizeErrorMessage(error), "index.html");
}
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/utils/checkTypes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { BundleDiagnostic } from "@hyperframes/core/compiler";
import type { ProjectLintResult } from "./lintProject.js";
import type { LayoutIssue, LayoutOverflow, LayoutRect } from "./layoutAudit.js";
import type { Canvas, MotionFrame } from "./motionAudit.js";
Expand Down Expand Up @@ -242,6 +243,10 @@ export interface CheckBrowserResult {
contrastPassed: number;
screenshots: CheckScreenshot[];
timings: CheckTimings;
/** Compile-time diagnostics collected while bundling the project for the
* browser pass. Empty whenever bundling never ran (lint-error short-circuit,
* a browser-launch failure) — absence is "not observed", not "clean". */
compileDiagnostics: BundleDiagnostic[];
}

/** The seek-grid audit loop, injected into checkBrowser so it never imports checkPipeline back. */
Expand All @@ -263,6 +268,18 @@ export interface CheckReport {
ok: boolean;
strict: boolean;
lint: CheckSection & { filesScanned: number };
/**
* Compile-time bundler diagnostics, surfaced as findings so agents and
* `--json` consumers see them instead of losing them to stdout.
*
* Deliberately NOT folded into the report's aggregate error/warning counts:
* every code here is a warning today, and rolling them into the aggregate
* would make `--strict` start failing projects it passes now. Visibility
* first; gating is a separate decision. `reached` says whether bundling
* actually ran — `runCheckPipeline` short-circuits before the bundler when
* lint has any error, so `reached: false` means "unknown", not "none".
*/
compile: CheckSection & { reached: boolean };
runtime: CheckSection;
layout: CheckSection<AnchoredLayoutIssue> & {
duration: number;
Expand Down
Loading
Loading