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;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { bundleToSingleHtml } from "./htmlBundler";
|
||||
import { bundleToSingleHtml, type BundleDiagnostic } from "./htmlBundler";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
|
||||
function makeTempProject(files: Record<string, string>): string {
|
||||
@@ -1388,3 +1388,91 @@ describe("bundleToSingleHtml", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compile diagnostics: the sink that lets `hyperframes check` report what used
|
||||
// to vanish into console.warn.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One project that trips all three diagnostic sites at once:
|
||||
* - no `window.__timelines` registration -> StaticGuard contract breach
|
||||
* - `data-composition-src` at a file that does not exist -> skipped sub-comp
|
||||
* - `data-color-grading` LUT at a file that does not exist -> un-inlinable LUT
|
||||
*/
|
||||
function makeAllDiagnosticsProject(): string {
|
||||
return makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180">
|
||||
<div data-composition-src="scenes/gone.html"></div>
|
||||
<video id="clip" src="clip.mp4" data-color-grading='{"lut":{"src":"assets/luts/gone.cube"}}'></video>
|
||||
</div>
|
||||
</body></html>`,
|
||||
});
|
||||
}
|
||||
|
||||
describe("bundleToSingleHtml compile diagnostics", () => {
|
||||
it("routes every compile-time diagnostic to the sink instead of console.warn", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const dir = makeAllDiagnosticsProject();
|
||||
const diagnostics: BundleDiagnostic[] = [];
|
||||
|
||||
await bundleToSingleHtml(dir, { onDiagnostic: (d) => diagnostics.push(d) });
|
||||
|
||||
expect(diagnostics.map((d) => d.code).sort()).toEqual([
|
||||
"color_grading_lut_not_inlined",
|
||||
"static_guard_contract",
|
||||
"sub_composition_skipped",
|
||||
]);
|
||||
expect(diagnostics.every((d) => d.severity === "warning")).toBe(true);
|
||||
// A sink takes ownership: nothing leaks to the console behind its back.
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the message and the source each diagnostic already knows", async () => {
|
||||
const dir = makeAllDiagnosticsProject();
|
||||
const diagnostics: BundleDiagnostic[] = [];
|
||||
|
||||
await bundleToSingleHtml(dir, { onDiagnostic: (d) => diagnostics.push(d) });
|
||||
|
||||
const byCode = new Map(diagnostics.map((d) => [d.code, d]));
|
||||
expect(byCode.get("sub_composition_skipped")?.source).toBe("scenes/gone.html");
|
||||
expect(byCode.get("sub_composition_skipped")?.message).toContain(
|
||||
'Skipping sub-composition "scenes/gone.html"',
|
||||
);
|
||||
expect(byCode.get("color_grading_lut_not_inlined")?.source).toBe("assets/luts/gone.cube");
|
||||
expect(byCode.get("color_grading_lut_not_inlined")?.message).toContain(
|
||||
'Could not inline color grading LUT "assets/luts/gone.cube"',
|
||||
);
|
||||
expect(byCode.get("static_guard_contract")?.source).toBe("index.html");
|
||||
expect(byCode.get("static_guard_contract")?.message).toContain("[StaticGuard]");
|
||||
});
|
||||
|
||||
it("still console.warns, with byte-identical output, when no sink is supplied", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const dir = makeAllDiagnosticsProject();
|
||||
const diagnostics: BundleDiagnostic[] = [];
|
||||
|
||||
const withSink = await bundleToSingleHtml(dir, {
|
||||
onDiagnostic: (d) => diagnostics.push(d),
|
||||
});
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
|
||||
const withoutSink = await bundleToSingleHtml(dir);
|
||||
|
||||
// The sink changes reporting, never the bundle.
|
||||
expect(withoutSink).toBe(withSink);
|
||||
// Every diagnostic reaches console.warn with exactly its message, one
|
||||
// argument, same as before the sink existed.
|
||||
expect(warnSpy.mock.calls.map((call) => call[0])).toEqual(diagnostics.map((d) => d.message));
|
||||
expect(warnSpy.mock.calls.every((call) => call.length === 1)).toBe(true);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -320,16 +320,23 @@ function isExternalSvgFragmentUse(el: Element, attr: string, urlValue: string):
|
||||
return pathBeforeFragment.toLowerCase().endsWith(".svg");
|
||||
}
|
||||
|
||||
function warnColorGradingLutNotInlined(lutSrc: string): void {
|
||||
function warnColorGradingLutNotInlined(lutSrc: string, emit: BundleDiagnosticSink): void {
|
||||
const trimmed = lutSrc.trim();
|
||||
if (!isRelativeUrl(trimmed)) return;
|
||||
console.warn(
|
||||
`[HyperFrames] Could not inline color grading LUT "${trimmed}". The rendered bundle may not be self-contained.`,
|
||||
);
|
||||
emit({
|
||||
code: "color_grading_lut_not_inlined",
|
||||
severity: "warning",
|
||||
message: `[HyperFrames] Could not inline color grading LUT "${trimmed}". The rendered bundle may not be self-contained.`,
|
||||
source: trimmed,
|
||||
});
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: string): string {
|
||||
function rewriteColorGradingLutWithInlinedAssets(
|
||||
value: string,
|
||||
projectDir: string,
|
||||
emit: BundleDiagnosticSink,
|
||||
): string {
|
||||
if (!value.trim().startsWith("{")) return value;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -343,7 +350,7 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: stri
|
||||
if (typeof lut === "string") {
|
||||
const inlined = maybeInlineRelativeAssetUrl(lut, projectDir);
|
||||
if (!inlined) {
|
||||
warnColorGradingLutNotInlined(lut);
|
||||
warnColorGradingLutNotInlined(lut, emit);
|
||||
return value;
|
||||
}
|
||||
Reflect.set(parsed, "lut", inlined);
|
||||
@@ -354,7 +361,7 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: stri
|
||||
if (typeof lutSrc !== "string") return value;
|
||||
const inlined = maybeInlineRelativeAssetUrl(lutSrc, projectDir);
|
||||
if (!inlined) {
|
||||
warnColorGradingLutNotInlined(lutSrc);
|
||||
warnColorGradingLutNotInlined(lutSrc, emit);
|
||||
return value;
|
||||
}
|
||||
Reflect.set(lut, "src", inlined);
|
||||
@@ -673,9 +680,46 @@ function stripJsCommentsParserSafe(source: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compile-time diagnostic raised while bundling — the class of problem that
|
||||
* used to reach only `console.warn` and was therefore invisible to any
|
||||
* programmatic consumer (`hyperframes check --json`, studio, CI logs).
|
||||
*
|
||||
* Severity is `"warning"` for every code emitted today: the bundler is
|
||||
* deliberately tolerant here (it skips the broken piece and keeps going) and
|
||||
* nothing about this envelope changes that. Escalating any of these to an
|
||||
* error is a separate, gating decision.
|
||||
*/
|
||||
export interface BundleDiagnostic {
|
||||
/** Stable machine-readable identifier — safe to match on in tooling. */
|
||||
code: "static_guard_contract" | "color_grading_lut_not_inlined" | "sub_composition_skipped";
|
||||
severity: "warning";
|
||||
/**
|
||||
* Human-readable text. Byte-identical to the `console.warn` line this
|
||||
* replaces, so terminal output is unchanged when no sink is supplied.
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* What the diagnostic is about, when the emitter knows: the entry file, the
|
||||
* sub-composition `src`, or the LUT URL. Not a line/column — none of these
|
||||
* sites carry source positions, and inventing one would be a lie.
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type BundleDiagnosticSink = (diagnostic: BundleDiagnostic) => void;
|
||||
|
||||
export interface BundleOptions {
|
||||
/** Project-relative HTML entry to bundle. Defaults to `index.html`. */
|
||||
entryFile?: string;
|
||||
/**
|
||||
* Collect compile-time diagnostics instead of logging them. Follows the same
|
||||
* shape as `inlineSubCompositions`'s `onMissingComposition`: optional, and
|
||||
* when omitted every diagnostic still goes to `console.warn` with exactly the
|
||||
* text it has today. Producer, studio, vite preview and render all rely on
|
||||
* that default.
|
||||
*/
|
||||
onDiagnostic?: BundleDiagnosticSink;
|
||||
/** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */
|
||||
probeMediaDuration?: MediaDurationProber;
|
||||
/**
|
||||
@@ -798,14 +842,20 @@ export async function bundleToSingleHtml(
|
||||
return isSafePath(projectDir, resolved) ? resolved : null;
|
||||
};
|
||||
|
||||
const emit: BundleDiagnosticSink =
|
||||
options?.onDiagnostic ?? ((diagnostic) => console.warn(diagnostic.message));
|
||||
|
||||
const rawHtml = readFileSync(indexPath, "utf-8");
|
||||
const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration);
|
||||
|
||||
const staticGuard = await validateHyperframeHtmlContract(compiled);
|
||||
if (!staticGuard.isValid) {
|
||||
console.warn(
|
||||
`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`,
|
||||
);
|
||||
emit({
|
||||
code: "static_guard_contract",
|
||||
severity: "warning",
|
||||
message: `[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`,
|
||||
source: entryFile,
|
||||
});
|
||||
}
|
||||
|
||||
const withInterceptor = injectInterceptor(compiled, options?.runtime ?? "inline");
|
||||
@@ -911,9 +961,12 @@ export async function bundleToSingleHtml(
|
||||
buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId),
|
||||
scriptErrorLabel: "[HyperFrames] composition script error:",
|
||||
onMissingComposition: (srcPath: string, reason?: string) => {
|
||||
console.warn(
|
||||
`[Bundler] Skipping sub-composition "${srcPath}": ${reason ?? "the file could not be found"}.`,
|
||||
);
|
||||
emit({
|
||||
code: "sub_composition_skipped",
|
||||
severity: "warning",
|
||||
message: `[Bundler] Skipping sub-composition "${srcPath}": ${reason ?? "the file could not be found"}.`,
|
||||
source: srcPath,
|
||||
});
|
||||
},
|
||||
});
|
||||
const compStyleChunks: string[] = [...subCompResult.styles];
|
||||
@@ -1119,7 +1172,7 @@ export async function bundleToSingleHtml(
|
||||
if (value) {
|
||||
el.setAttribute(
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
rewriteColorGradingLutWithInlinedAssets(value, projectDir),
|
||||
rewriteColorGradingLutWithInlinedAssets(value, projectDir, emit),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export {
|
||||
assignBundledRuntimeCompositionIds,
|
||||
type BundledHostCompositionIdentity,
|
||||
bundleToSingleHtml,
|
||||
type BundleDiagnostic,
|
||||
type BundleDiagnosticSink,
|
||||
type BundleOptions,
|
||||
prepareFlattenedInnerRoot,
|
||||
FLATTENED_INNER_ROOT_STRIP_ATTRS,
|
||||
|
||||
Reference in New Issue
Block a user