mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions (#1831)
* fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions The #1 render failure bucket in production telemetry (PostHog project 356858, dashboard 1783183 "HyperFrames — Bottom-Line & Activation"; ~65-69K occurrences / ~27-28K affected users over 30 days, ~80% via AI-agent authoring flows) is a `data-composition-src` reference pointing at a scene file that is empty, malformed, or missing. Root cause, traced end-to-end: - The literal error "Composition HTML is empty or could not be parsed: <path>" is real (not a PostHog paraphrase) — thrown by a since-reverted guard in packages/core/src/compiler/inlineSubCompositions.ts (#1364), then changed to a silent skip in #1678 to avoid aborting renders on partial content during authoring. #1629 added per-assembler guards for 3 skill workflows (product-launch-video, faceless-explainer, pr-to-video), but general-video and hand-authored flows — where the dominant filename `scene-title.html` (40K+/68K of the bucket) originates — have no assembler and thus no guard. #1678 assumed the assembler guards from #1629 covered this pre-render; they only covered 3 of the many authoring flows. - On current `main`, an empty/malformed data-composition-src file no longer crashes or throws during render — it's silently dropped by the tolerant inliner. Reproduced locally: `hyperframes render` on a project with an empty scene-title.html "succeeds" after ~93s (two 45s pollSubCompositionTimelines timeouts) with the scene silently missing from the output video. `hyperframes validate` also reports "No console errors" for the same broken project. - The raw `Cannot destructure property 'firstElementChild' of 'documentElement' as it is null` crash reproduces directly against linkedom (the DOMParser polyfill packages/cli/src/utils/dom.ts installs in the real CLI runtime) for empty and non-HTML input — confirmed with a standalone repro script, not just inferred. jsdom/happy-dom (used in this repo's own test environment) are spec-compliant and never produce a null documentElement, which is why this needed a linkedom-specific test file. Fix: - New shared helper `checkSubCompositionUsability` (packages/core/src/compiler/subCompositionValidity.ts) is the single source of truth for "is this data-composition-src file usable" — mirrors the inliner's own parse/template/body logic so all callers agree. - `inlineSubCompositions.ts` (preview/studio bundling) now uses the shared helper internally but keeps its #1678 tolerant skip-and-continue behavior unchanged — mid-authoring iteration on a partial project must keep working. `onMissingComposition` now also receives a human-readable reason. - New render-only pre-flight (`assertSubCompositionsUsable` in packages/producer/src/services/htmlCompiler.ts) walks every data-composition-src reference (including nested ones, root-relative, matching parseSubCompositions' own resolution) before any compilation work starts, and throws naming every offending file at once. This is unconditional — not gated behind --strict — because a render that silently drops a scene is strictly worse than one that refuses to start. Confirmed locally: render now fails in ~0.4s with an actionable message instead of "succeeding" after 93s with a missing scene. - New `hyperframes lint` rule `missing_or_empty_sub_composition` (packages/cli/src/utils/lintProject.ts) surfaces the same check as a file-scoped, actionable lint error (already unconditional — lint exits 1 on any error). - `hyperframes validate` now also runs this check before launching a browser, so it no longer reports "No console errors" for a project with a broken sub-composition. - `packages/core/src/parsers/htmlParser.ts`: guarded every `documentElement`-may-be-null access (parseHtml, updateElementInHtml, addElementToHtml, removeElementFromHtml, extractCompositionMetadata, validateCompositionHtml) with a new typed `CompositionHtmlParseError` (or, for validateCompositionHtml's collect-and-report contract, a typed validation failure) instead of a raw crash. Tests: empty file, whitespace-only, malformed/non-HTML, missing file, nested sub-compositions (both happy path and broken-grandchild), and the happy path — at the shared-helper, lint, and render pre-flight layers. Not changed: the AI-agent authoring skills (skills/*). general-video and hand-authored flows have no assemble-index.mjs equivalent to guard, so the fix is at the CLI/render layer instead — flow-agnostic, covers every authoring path, and the skills' existing "run lint/validate and stop on failure" guidance now actually catches this class of mistake once run. Not run in this environment: the producer package's full regression-harness test suite (`bun test` in packages/producer) — it performs heavy real rendering (S3 asset downloads, Google Fonts fetches, full video encodes) and did not complete in a reasonable time in this sandbox. Verified instead via the targeted test file for all touched code (76/76 passing), whole-repo typecheck/build/oxlint, `fallow audit` (complexity/duplication/dead-code gate, clean), and manual end-to-end CLI runs (render/lint/validate) against reproduction projects, including a nested sub-composition scenario. CI should run the full producer suite before merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(parsers,lint): port empty-composition pre-flight to extracted packages Rebased onto main, which extracted @hyperframes/lint from core (lint depends only on parsers, not core). Relocate checkSubCompositionUsability from core to @hyperframes/parsers so both core (inliner) and lint can consume it without a core<->lint cycle; core keeps a @deprecated re-export shim. Correctness fixes from code review: - checkSubCompositionUsability now returns "no-composition-root" when the <template>/<body> content has no [data-composition-id] element (previously a marker-free placeholder body passed both guards). - lint's missing/empty sub-composition rule now only checks files reachable via data-composition-src from the root (matching render pre-flight), instead of a raw filesystem walk that false-positived on orphaned files. - drop `as string` cast in inlineSubCompositions in favor of an explicit null guard (per CLAUDE.md). Review-comment items: - move EmptyCompositionError JSDoc above the class (was above the adapter fn). - correct stale circular-ref comment to match actual silent-skip behavior. - rewrite self-contradicting lint message ("silently drop") to describe the new loud render-pre-flight abort. - add the __PLACEHOLDER__ (/^__[A-Z_]+__$/) skip to the render pre-flight so it agrees with lint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9c4d9e50a0
commit
cf573f7f3f
@@ -54,6 +54,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
"linkedom": "^0.18.12",
|
||||
"postcss": "^8.5.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { HyperframeLintFinding } from "./types.js";
|
||||
import { lintProject } from "./project.js";
|
||||
|
||||
function tmpProject(name: string): string {
|
||||
return mkdtempSync(join(tmpdir(), `hf-lint-test-${name}-`));
|
||||
}
|
||||
|
||||
function validHtml(compId = "main"): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="${compId}" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["${compId}"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
let dirs: string[] = [];
|
||||
|
||||
function makeProject(indexHtml: string, subComps?: Record<string, string>): string {
|
||||
const dir = tmpProject("lint");
|
||||
dirs.push(dir);
|
||||
writeFileSync(join(dir, "index.html"), indexHtml);
|
||||
if (subComps) {
|
||||
const compsDir = join(dir, "compositions");
|
||||
mkdirSync(compsDir, { recursive: true });
|
||||
for (const [name, html] of Object.entries(subComps)) {
|
||||
writeFileSync(join(compsDir, name), html);
|
||||
}
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const d of dirs) {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
dirs = [];
|
||||
});
|
||||
|
||||
describe("missing_or_empty_sub_composition", () => {
|
||||
function htmlWithSubComp(srcPath: string): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div data-composition-src="${srcPath}" data-composition-id="scene-title" data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function validSubCompHtml(): string {
|
||||
return `<!doctype html><html><body>
|
||||
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
|
||||
<div class="title">Hello</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// Shared assertion: lint a project referencing "compositions/scene-title.html"
|
||||
// (or a custom srcPath) and return the missing_or_empty_sub_composition
|
||||
// finding, if any, plus the raw lint result for callers that need totalErrors.
|
||||
async function lintSubComp(
|
||||
srcPath: string,
|
||||
subCompFiles?: Record<string, string>,
|
||||
): Promise<{ finding: HyperframeLintFinding | undefined; totalErrors: number }> {
|
||||
const project = makeProject(htmlWithSubComp(srcPath), subCompFiles);
|
||||
const { totalErrors, results } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.find((f) => f.code === "missing_or_empty_sub_composition");
|
||||
return { finding, totalErrors };
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "empty",
|
||||
content: "",
|
||||
expectMessageContains: "empty",
|
||||
},
|
||||
{
|
||||
label: "whitespace-only",
|
||||
content: " \n\t ",
|
||||
expectMessageContains: "empty",
|
||||
},
|
||||
{
|
||||
label: "malformed / non-HTML",
|
||||
content: "just some plain text, no tags at all",
|
||||
expectMessageContains: "could not be parsed",
|
||||
},
|
||||
])(
|
||||
"errors when the referenced sub-composition file is $label",
|
||||
async ({ content, expectMessageContains }) => {
|
||||
const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", {
|
||||
"scene-title.html": content,
|
||||
});
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain(expectMessageContains);
|
||||
},
|
||||
);
|
||||
|
||||
it("errors when the referenced sub-composition file does not exist", async () => {
|
||||
// No subComps passed — compositions/ directory doesn't even exist.
|
||||
const { finding, totalErrors } = await lintSubComp("compositions/does-not-exist.html");
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("compositions/does-not-exist.html");
|
||||
expect(finding?.message).toContain("does not exist");
|
||||
});
|
||||
|
||||
it("errors when the referenced sub-composition file has content but no data-composition-id root", async () => {
|
||||
const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", {
|
||||
"scene-title.html": "<!doctype html><html><body><p>TODO: scene content</p></body></html>",
|
||||
});
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("data-composition-id");
|
||||
});
|
||||
|
||||
it("does not error when the referenced sub-composition file is valid (happy path)", async () => {
|
||||
const { finding } = await lintSubComp("compositions/scene-title.html", {
|
||||
"scene-title.html": validSubCompHtml(),
|
||||
});
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not error on a project with no data-composition-src references", async () => {
|
||||
const project = makeProject(validHtml());
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.find((f) => f.code === "missing_or_empty_sub_composition");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dedupes a single bad reference into one finding even if repeated", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div data-composition-src="compositions/scene-title.html" data-composition-id="a" data-start="0" data-duration="5"></div>
|
||||
<div data-composition-src="compositions/scene-title.html" data-composition-id="b" data-start="5" data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html, { "scene-title.html": "" });
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const findings = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.filter((f) => f.code === "missing_or_empty_sub_composition");
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Regression: lint used to raw-filesystem-walk every .html under
|
||||
// compositions/, regardless of whether the root composition actually
|
||||
// references it. render's pre-flight (assertSubCompositionsUsable) only
|
||||
// follows real data-composition-src references starting from the root, so
|
||||
// an orphaned file with its own dangling reference made `lint`/`validate`
|
||||
// fail even though `render` succeeds fine on the same project.
|
||||
it("does not error on an orphaned, unreferenced file under compositions/ with a dangling reference inside it", async () => {
|
||||
const project = makeProject(validHtml(), {});
|
||||
const archivedDir = join(project, "compositions", "archived");
|
||||
mkdirSync(archivedDir, { recursive: true });
|
||||
// Never referenced from index.html — this file is unreachable.
|
||||
writeFileSync(
|
||||
join(archivedDir, "old-draft.html"),
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="old-draft" data-width="1920" data-height="1080">
|
||||
<div data-composition-src="compositions/does-not-exist.html" data-composition-id="ghost"></div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
const { results, totalErrors } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.find((f) => f.code === "missing_or_empty_sub_composition");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
expect(totalErrors).toBe(0);
|
||||
});
|
||||
|
||||
it("still errors when a broken reference IS reachable from the root (nested, not just top-level)", async () => {
|
||||
const project = makeProject(htmlWithSubComp("compositions/parent.html"));
|
||||
mkdirSync(join(project, "compositions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(project, "compositions", "parent.html"),
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
|
||||
<div data-composition-src="compositions/does-not-exist.html" data-composition-id="child"></div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
const { results, totalErrors } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.find((f) => f.code === "missing_or_empty_sub_composition");
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("compositions/does-not-exist.html");
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,16 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path";
|
||||
import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
|
||||
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
|
||||
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { lintHyperframeHtml } from "./hyperframeLinter.js";
|
||||
import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js";
|
||||
import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity";
|
||||
|
||||
/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */
|
||||
function parseSubCompHtml(html: string): ParsableDocumentLike {
|
||||
return parseHTML(html).document as unknown as ParsableDocumentLike;
|
||||
}
|
||||
|
||||
interface HtmlSource {
|
||||
html: string;
|
||||
@@ -221,6 +229,7 @@ export async function lintProject(projectDir: string): Promise<ProjectLintResult
|
||||
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
|
||||
...lintMultipleRootCompositions(projectDir),
|
||||
...lintDuplicateAudioTracks(allHtmlSources),
|
||||
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
|
||||
];
|
||||
if (projectFindings.length > 0) {
|
||||
for (const finding of projectFindings) {
|
||||
@@ -502,3 +511,102 @@ function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFind
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error if a `data-composition-src` reference points at a file that is
|
||||
* missing, empty, or does not parse to usable HTML. This is the #1 render
|
||||
* failure bucket in production telemetry: a scene-authoring step (an AI
|
||||
* agent, most commonly) writes the reference before — or without ever —
|
||||
* writing valid content into the scene file.
|
||||
*
|
||||
* The render pre-flight check (`assertSubCompositionsUsable` in
|
||||
* `packages/producer/src/services/htmlCompiler.ts`) now aborts the render
|
||||
* loudly and immediately when this happens, rather than silently dropping
|
||||
* the scene — so catching it here, before the render even starts, means the
|
||||
* failure surfaces at lint/validate time with the same message instead of
|
||||
* only at render time.
|
||||
*
|
||||
* Only follows files actually reachable via `data-composition-src` starting
|
||||
* from the root composition — mirroring the reachability semantics of
|
||||
* `assertSubCompositionsUsable`. A raw filesystem walk of every `.html`
|
||||
* under `compositions/` would flag orphaned/unreferenced files that the
|
||||
* renderer never visits, producing false-positive lint/validate failures on
|
||||
* projects that actually render fine. Lint, render, and the inliner must
|
||||
* never disagree about whether a given file would actually render
|
||||
* something.
|
||||
*/
|
||||
function lintMissingOrEmptySubComposition(
|
||||
projectDir: string,
|
||||
rootHtml: string,
|
||||
): HyperframeLintFinding[] {
|
||||
// Dedup by src path — the same reference can appear from nested sub-comps.
|
||||
const checked = new Map<string, { srcPath: string; problem: string }>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const walk = (html: string): void => {
|
||||
const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
const scannable = maskNonScannableRanges(html);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = compositionSrcRe.exec(scannable)) !== null) {
|
||||
const srcPath = (match[1] ?? "").trim();
|
||||
if (!srcPath) continue;
|
||||
if (/^__[A-Z_]+__$/.test(srcPath)) continue; // template placeholder
|
||||
|
||||
// data-composition-src is always written root-relative (even from a
|
||||
// nested sub-composition) — matches the resolution the renderer uses
|
||||
// in packages/producer/src/services/htmlCompiler.ts (parseSubCompositions
|
||||
// / assertSubCompositionsUsable).
|
||||
const filePath = resolve(projectDir, srcPath);
|
||||
|
||||
// Circular reference guard — same as assertSubCompositionsUsable.
|
||||
// Already-visited files were already checked (or are mid-walk); skip
|
||||
// re-checking/re-recursing but still let a later distinct reference to
|
||||
// the same broken file surface (checked is keyed by srcPath, not filePath).
|
||||
if (visited.has(filePath)) continue;
|
||||
visited.add(filePath);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
if (!checked.has(srcPath)) {
|
||||
checked.set(srcPath, { srcPath, problem: "the file does not exist" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileHtml = readFileSync(filePath, "utf-8");
|
||||
const validity = checkSubCompositionUsability(fileHtml, parseSubCompHtml);
|
||||
if (!validity.ok) {
|
||||
if (!checked.has(srcPath)) {
|
||||
checked.set(srcPath, {
|
||||
srcPath,
|
||||
problem: validity.detail ?? "the file is empty or could not be parsed",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Usable — recurse into it so nested references are validated too,
|
||||
// but only because this file is itself reachable from the root.
|
||||
walk(fileHtml);
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootHtml);
|
||||
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const { srcPath, problem } of checked.values()) {
|
||||
findings.push({
|
||||
code: "missing_or_empty_sub_composition",
|
||||
severity: "error",
|
||||
message: `data-composition-src references "${srcPath}", but ${problem}.`,
|
||||
fixHint:
|
||||
`Fix this before rendering — the render pre-flight rejects unusable sub-compositions. ` +
|
||||
`Write valid HTML into "${srcPath}" — it needs a <template> or <body> containing an element with ` +
|
||||
`data-composition-id, data-width, and data-height. Preview/studio still tolerates and skips the ` +
|
||||
"scene while you author it. If a scene-authoring step is still running, wait for it to finish " +
|
||||
"before referencing the file, or re-run the step that generates it.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user