mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
fix(cli): report compile diagnostics in the check report
The bundler emits three warnings -- an un-inlinable color-grading LUT, a StaticGuard contract breach, and a skipped sub-composition -- as bare console.warn calls. They never reached the structured check report, so --json consumers could not see them at all and a terminal reader had to catch them scrolling past above the report they were reading. bundleToSingleHtml now takes an optional diagnostics sink, following the onMissingComposition hook inlineSubCompositions already uses. Without a sink the calls console.warn exactly as before, with byte-identical text and a byte-identical bundle; a test pins both. check passes one and renders a Compile section carrying the same CheckFinding shape lint and runtime already use. Two deliberate limits. Compile counts stay out of the aggregate totals, so --strict fails nothing it passes today: this changes what you can see, not what passes. And the section carries a reached flag, because an empty findings list is otherwise ambiguous -- runCheckPipeline short-circuits before bundling whenever lint has errors, and clean and never-ran must not print the same. It reports not reached (fix lint errors first) instead, and is also false when the browser check throws after bundling. Verified on a lint-clean probe: the LUT warning appears in the Compile section and in the --json envelope. Removing data-width from the same probe makes lint fail and the section correctly reads not reached. Left as follow-ups: sub_composition_skipped is arguably an error for a render, since you ship a bundle silently missing a scene; and static_guard_contract fires only when lint has errors, so it is structurally unreachable through check and surfaces for studio, preview and render callers only.
This commit is contained in:
@@ -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,
|
||||
@@ -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(
|
||||
@@ -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 = {
|
||||
@@ -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",
|
||||
@@ -1526,3 +1542,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BundleDiagnosticSink } from "@hyperframes/core/compiler";
|
||||
import { normalizeErrorMessage } from "./errorMessage.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
@@ -1345,6 +1358,7 @@ function buildReport(
|
||||
ok: errorCount === 0 && (!options.strict || warningCount === 0),
|
||||
strict: options.strict,
|
||||
lint,
|
||||
compile,
|
||||
runtime,
|
||||
layout,
|
||||
motion: {
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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. */
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user