fix(cli): reject blank default composition entries (#3392)

* fix(cli): reject blank default composition entry

* fix(cli): complete blank entry safeguards
This commit is contained in:
Miguel Ángel
2026-08-21 11:00:43 -04:00
committed by GitHub
parent 1b86b56127
commit a9ea07edde
11 changed files with 412 additions and 26 deletions
+106
View File
@@ -69,6 +69,112 @@ describe("external symlink assets", () => {
});
});
describe("blank_root_with_standalone_composition", () => {
it("errors when the default entry is blank but an authored standalone composition lives under compositions", async () => {
const project = makeProject(validHtml(), {
"index.html": `<!doctype html><html><body>
<div data-composition-id="bona-brand-card" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div id="main-clip" class="clip" data-start="0" data-duration="5" data-track-index="0">BONA</div>
</div>
<script>window.__timelines = { "bona-brand-card": gsap.timeline({ paused: true }) };</script>
</body></html>`,
});
const { results, totalErrors } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(totalErrors).toBeGreaterThan(0);
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("compositions/index.html");
expect(finding?.message).toContain("index.html");
expect(finding?.fixHint).toContain("data-composition-src");
});
it("treats non-rendering script, style, link, meta, and template children as blank", async () => {
const shellOnlyRoot = validHtml().replace(
"</div>",
`<script type="application/json">{}</script>
<style>.unused { color: white; }</style>
<link rel="stylesheet" href="data:text/css,.unused%7Bcolor:white%7D">
<meta name="description" content="shell">
<template id="row-template"><div>row</div></template>
</div>`,
);
const project = makeProject(shellOnlyRoot, {
"authored.html": `<!doctype html><html><body>
<div data-composition-id="authored" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="clip" data-start="0" data-duration="5">Visible</div>
</div>
</body></html>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeDefined();
});
it("does not fire when index.html already contains authored clip content", async () => {
const authoredRoot = validHtml().replace(
"</div>",
'<div class="clip" data-start="0" data-duration="10">Master content</div></div>',
);
const project = makeProject(authoredRoot, {
"alternate.html": `<!doctype html><html><body>
<div data-composition-id="alternate" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="clip" data-start="0" data-duration="5">Alternate</div>
</div>
</body></html>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeUndefined();
});
it("does not treat a template-wrapped sub-composition as a misplaced standalone entry", async () => {
const project = makeProject(validHtml(), {
"scene.html": `<template>
<div data-composition-id="scene" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="5">Scene</div>
</div>
</template>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeUndefined();
});
it("still catches a standalone composition that contains an unrelated nested template", async () => {
const project = makeProject(validHtml(), {
"card.html": `<!doctype html><html><body>
<div data-composition-id="card" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="clip" data-start="0" data-duration="5">Card</div>
<template id="repeated-row"><div class="row">Row</div></template>
</div>
</body></html>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeDefined();
});
});
describe("missing_or_empty_sub_composition", () => {
function htmlWithSubComp(srcPath: string): string {
return `<html><body>
+41
View File
@@ -230,6 +230,7 @@ export async function lintProject(
...lintMissingLocalAsset(projectDir, allHtmlSources),
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
...(!entryFile ? lintMultipleRootCompositions(projectDir) : []),
...(!entryFile ? lintBlankRootWithStandaloneComposition(rootHtml, allHtmlSources) : []),
...lintDuplicateAudioTracks(allHtmlSources),
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
...(await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))),
@@ -254,6 +255,46 @@ export async function lintProject(
return { results, totalErrors, totalWarnings, totalInfos };
}
function lintBlankRootWithStandaloneComposition(
rootHtml: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const { document: rootDocument } = parseHTML(rootHtml);
const root = rootDocument.querySelector("body [data-composition-id]");
// A no-media scaffold has no rendered descendants and can silently mask an authored file below.
// A scaffold that retained its A-roll <video>/<audio> is visibly non-blank, so this rule leaves it
// alone even when another composition is unmounted.
if (!root || root.querySelector("*:not(script):not(style):not(link):not(meta):not(template)")) {
return [];
}
const standaloneCandidates: string[] = [];
for (const source of htmlSources) {
if (!source.compSrcPath) continue;
const { document } = parseHTML(source.html);
const composition = document.querySelector("body [data-composition-id]");
if (!composition) continue;
const authoredTimedContent = Array.from(
composition.querySelectorAll(
".clip, [data-start], [data-end], video, audio, img, svg, canvas",
),
).some((element) => !element.hasAttribute("data-composition-src"));
if (authoredTimedContent) standaloneCandidates.push(source.compSrcPath);
}
if (standaloneCandidates.length === 0) return [];
return [
{
code: "blank_root_with_standalone_composition",
severity: "error",
message: `The default index.html composition has no renderable content, but ${standaloneCandidates.join(", ")} contains a standalone timed composition. Default check, snapshot, preview, and render commands open index.html, so they will capture only its background.`,
fixHint:
`Move the authored composition into index.html, or mount it from index.html with data-composition-src and the sub-composition <template> contract. ` +
`If the separate file is intentional, render it explicitly with --composition ${standaloneCandidates[0]}.`,
},
];
}
function lintProjectAudioFiles(
projectDir: string,
htmlSources: HtmlSource[],