mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { parseUpdateTarget } from "./publish.js";
|
||||
const publishState = vi.hoisted(() => ({ publish: vi.fn() }));
|
||||
|
||||
vi.mock("../utils/publishProject.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../utils/publishProject.js")>()),
|
||||
publishProjectArchive: publishState.publish,
|
||||
}));
|
||||
|
||||
import publishCommand, { parseUpdateTarget } from "./publish.js";
|
||||
|
||||
describe("parseUpdateTarget", () => {
|
||||
it("extracts the id from a full published URL", () => {
|
||||
@@ -25,3 +35,37 @@ describe("parseUpdateTarget", () => {
|
||||
expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publish default-entry preflight", () => {
|
||||
it("rejects the real fixture before creating or uploading an archive", async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-publish-entry-mismatch-"));
|
||||
const compositions = join(project, "compositions");
|
||||
mkdirSync(compositions);
|
||||
writeFileSync(
|
||||
join(project, "index.html"),
|
||||
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div></body></html>`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(compositions, "index.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>`,
|
||||
);
|
||||
publishState.publish.mockReset();
|
||||
publishState.publish.mockResolvedValue({
|
||||
title: "test",
|
||||
fileCount: 2,
|
||||
claimed: true,
|
||||
projectId: "project-id",
|
||||
url: "https://hyperframes.dev/p/project-id",
|
||||
claimToken: "",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
publishCommand.run?.({ args: { dir: project, yes: true, proxy: false } } as never),
|
||||
).rejects.toMatchObject({ name: "CliRuntimeError" });
|
||||
expect(publishState.publish).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { defineCommand } from "citty";
|
||||
import * as clack from "@clack/prompts";
|
||||
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { lintProject } from "../utils/lintProject.js";
|
||||
import { hasDefinitiveEntryMismatch, lintProject } from "../utils/lintProject.js";
|
||||
import { formatLintFindings } from "../utils/lintFormat.js";
|
||||
import {
|
||||
buildPublishFileMap,
|
||||
@@ -92,6 +92,11 @@ export default defineCommand({
|
||||
for (const line of formatLintFindings(lintResult)) console.log(line);
|
||||
console.log();
|
||||
}
|
||||
if (hasDefinitiveEntryMismatch(lintResult)) {
|
||||
console.log(c.error(" Aborting publish because the default index.html entry is blank."));
|
||||
console.log();
|
||||
failCommand();
|
||||
}
|
||||
}
|
||||
|
||||
if (args.yes !== true) {
|
||||
|
||||
@@ -222,6 +222,7 @@ describe("renderLocal browser GPU config", () => {
|
||||
renderLocal,
|
||||
resolveBrowserGpuForCli,
|
||||
renderLintContinuationHint,
|
||||
runRenderLint,
|
||||
__resetDeParallelRouterTrialStateForTests: resetTrialState,
|
||||
} = renderModule;
|
||||
|
||||
@@ -234,6 +235,47 @@ describe("renderLocal browser GPU config", () => {
|
||||
expect(renderLintContinuationHint(false)).toContain("Use --strict to block errors");
|
||||
});
|
||||
|
||||
it("aborts the real render lint preflight on a default-entry mismatch without --strict", async () => {
|
||||
const lintResult = {
|
||||
results: [
|
||||
{
|
||||
file: "index.html",
|
||||
contentHash: "abc",
|
||||
result: {
|
||||
ok: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
infoCount: 0,
|
||||
findings: [
|
||||
{
|
||||
code: "blank_root_with_standalone_composition",
|
||||
severity: "error" as const,
|
||||
message: "wrong entry",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
totalErrors: 1,
|
||||
totalWarnings: 0,
|
||||
totalInfos: 0,
|
||||
};
|
||||
|
||||
await expect(
|
||||
runRenderLint(
|
||||
{
|
||||
project: { dir: "/tmp/project" },
|
||||
entryFile: undefined,
|
||||
renderTarget: "/tmp/project/index.html",
|
||||
strictErrors: false,
|
||||
strictAll: false,
|
||||
effectiveQuiet: true,
|
||||
} as never,
|
||||
async () => lintResult,
|
||||
),
|
||||
).rejects.toMatchObject({ name: "CliRuntimeError" });
|
||||
});
|
||||
|
||||
function setEnv(key: string, value: string) {
|
||||
if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
|
||||
process.env[key] = value;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync
|
||||
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
|
||||
import { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
|
||||
import { presentRenderPlan } from "./render/present.js";
|
||||
import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js";
|
||||
import { executeRenderPlan, renderLintContinuationHint, runRenderLint } from "./render/execute.js";
|
||||
// Test-only seams retained at the command boundary for render behavior tests.
|
||||
export { resolveBrowserGpuForCli, renderLintContinuationHint };
|
||||
export { resolveBrowserGpuForCli, renderLintContinuationHint, runRenderLint };
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Render to MP4", "hyperframes render --output output.mp4"],
|
||||
|
||||
@@ -3,7 +3,12 @@ import type { CanvasResolution, OutputResolutionIssueKind } from "@hyperframes/c
|
||||
import { c } from "../../ui/colors.js";
|
||||
import { errorBox, formatBytes } from "../../ui/format.js";
|
||||
import { formatLintFindings } from "../../utils/lintFormat.js";
|
||||
import { lintProject, shouldBlockRender } from "../../utils/lintProject.js";
|
||||
import {
|
||||
hasDefinitiveEntryMismatch,
|
||||
lintProject,
|
||||
shouldBlockRender,
|
||||
type ProjectLintResult,
|
||||
} from "../../utils/lintProject.js";
|
||||
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
|
||||
import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
|
||||
import {
|
||||
@@ -40,6 +45,17 @@ export function renderLintContinuationHint(strictErrors: boolean): string {
|
||||
: " Continuing render despite lint issues. Use --strict to block errors.";
|
||||
}
|
||||
|
||||
function renderLintShouldAbort(
|
||||
strictErrors: boolean,
|
||||
strictAll: boolean,
|
||||
lintResult: ProjectLintResult,
|
||||
): boolean {
|
||||
return (
|
||||
hasDefinitiveEntryMismatch(lintResult) ||
|
||||
shouldBlockRender(strictErrors, strictAll, lintResult.totalErrors, lintResult.totalWarnings)
|
||||
);
|
||||
}
|
||||
|
||||
/** Execute a validated plan. Output and process lifecycle stay outside parsing. */
|
||||
export async function executeRenderPlan(
|
||||
plan: RenderPlan,
|
||||
@@ -158,22 +174,19 @@ async function ensureRenderBrowser(plan: RenderPlan): Promise<string> {
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function runRenderLint(plan: RenderPlan): Promise<void> {
|
||||
export async function runRenderLint(
|
||||
plan: RenderPlan,
|
||||
runLint: (projectDir: string, entryFile?: string) => Promise<ProjectLintResult> = lintProject,
|
||||
): Promise<void> {
|
||||
// lintProject's explicit-entry contract is an absolute source path;
|
||||
// entryFile remains project-relative for the producer.
|
||||
const explicitEntry = plan.entryFile ? plan.renderTarget : undefined;
|
||||
const lintResult = await lintProject(plan.project.dir, explicitEntry);
|
||||
const lintResult = await runLint(plan.project.dir, explicitEntry);
|
||||
if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) return;
|
||||
presentRenderLintFindings(lintResult, plan.effectiveQuiet);
|
||||
if (
|
||||
shouldBlockRender(
|
||||
plan.strictErrors,
|
||||
plan.strictAll,
|
||||
lintResult.totalErrors,
|
||||
lintResult.totalWarnings,
|
||||
)
|
||||
) {
|
||||
presentRenderLintAbort(plan);
|
||||
const definitiveEntryMismatch = hasDefinitiveEntryMismatch(lintResult);
|
||||
if (renderLintShouldAbort(plan.strictErrors, plan.strictAll, lintResult)) {
|
||||
presentRenderLintAbort(plan, definitiveEntryMismatch);
|
||||
failCommand();
|
||||
}
|
||||
presentRenderLintContinuation(plan);
|
||||
@@ -188,11 +201,16 @@ function presentRenderLintFindings(
|
||||
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line);
|
||||
}
|
||||
|
||||
function presentRenderLintAbort(plan: RenderPlan): void {
|
||||
function presentRenderLintAbort(plan: RenderPlan, definitiveEntryMismatch: boolean): void {
|
||||
if (plan.effectiveQuiet) return;
|
||||
const mode = plan.strictAll ? "--strict-all" : "--strict";
|
||||
console.log("");
|
||||
console.log(c.error(` Aborting render due to lint issues (${mode} mode).`));
|
||||
console.log(
|
||||
c.error(
|
||||
definitiveEntryMismatch
|
||||
? " Aborting render because the default index.html entry is blank."
|
||||
: ` Aborting render due to lint issues (${plan.strictAll ? "--strict-all" : "--strict"} mode).`,
|
||||
),
|
||||
);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const snapshotState = vi.hoisted(() => ({
|
||||
openSettledPage: vi.fn(async () => {
|
||||
throw new Error("browser capture reached");
|
||||
}),
|
||||
closeServer: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../capture/captureCompositionFrame.js")>()),
|
||||
openSettledCompositionPage: snapshotState.openSettledPage,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/staticProjectServer.js", () => ({
|
||||
serveStaticProjectHtml: vi.fn(async () => ({
|
||||
url: "http://127.0.0.1:1",
|
||||
close: snapshotState.closeServer,
|
||||
})),
|
||||
}));
|
||||
|
||||
import snapshotCommand, {
|
||||
computeSnapshotTimes,
|
||||
formatSnapshotTimestamp,
|
||||
parseZoomScale,
|
||||
@@ -62,6 +84,41 @@ describe("transparent snapshot capture", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshot lint preflight", () => {
|
||||
it("rejects the real fixture before invoking browser capture", async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-snapshot-entry-mismatch-"));
|
||||
const compositions = join(project, "compositions");
|
||||
mkdirSync(compositions);
|
||||
writeFileSync(
|
||||
join(project, "index.html"),
|
||||
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div></body></html>`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(compositions, "index.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>`,
|
||||
);
|
||||
snapshotState.openSettledPage.mockClear();
|
||||
const lines: string[] = [];
|
||||
const log = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => {
|
||||
lines.push(parts.map(String).join(" "));
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
snapshotCommand.run?.({ args: { dir: project } } as never),
|
||||
).rejects.toMatchObject({
|
||||
name: "CliRuntimeError",
|
||||
});
|
||||
expect(snapshotState.openSettledPage).not.toHaveBeenCalled();
|
||||
expect(lines.join("\n")).toContain("hyperframes snapshot");
|
||||
expect(lines.join("\n")).toContain("compositions");
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSnapshotVideoFrameTime", () => {
|
||||
it("keeps media active at the inclusive clip end and samples its last decodable frame", () => {
|
||||
expect(
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type ZoomTarget,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { hasDefinitiveEntryMismatch, lintProject } from "../utils/lintProject.js";
|
||||
import { formatLintFindings } from "../utils/lintFormat.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
@@ -650,6 +652,22 @@ export default defineCommand({
|
||||
},
|
||||
async run({ args }) {
|
||||
const project = resolveProject(args.dir);
|
||||
const lintResult = await lintProject(project.dir);
|
||||
if (hasDefinitiveEntryMismatch(lintResult)) {
|
||||
console.log("");
|
||||
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) {
|
||||
console.log(line);
|
||||
}
|
||||
console.log("");
|
||||
console.log(c.error(" Aborting snapshot because the default index.html entry is blank."));
|
||||
console.log(
|
||||
c.dim(
|
||||
" Move or mount the authored file, or snapshot its directory directly: hyperframes snapshot <project>/compositions",
|
||||
),
|
||||
);
|
||||
console.log("");
|
||||
failCommand();
|
||||
}
|
||||
const frames = parseInt(args.frames as string, 10) || 5;
|
||||
const timeout = parseInt(args.timeout as string, 10) || 5000;
|
||||
const atTimestamps = args.at
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
|
||||
import { lintProject, shouldBlockRender } from "./lintProject.js";
|
||||
import { hasDefinitiveEntryMismatch, lintProject, shouldBlockRender } from "./lintProject.js";
|
||||
|
||||
function tmpProject(name: string): string {
|
||||
return mkdtempSync(join(tmpdir(), `hf-test-${name}-`));
|
||||
@@ -249,6 +249,51 @@ describe("lintProject", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDefinitiveEntryMismatch", () => {
|
||||
it("distinguishes the blank-default-entry failure from ordinary lint errors", () => {
|
||||
const result = {
|
||||
results: [
|
||||
{
|
||||
file: "index.html",
|
||||
contentHash: "abc",
|
||||
result: {
|
||||
ok: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
infoCount: 0,
|
||||
findings: [
|
||||
{
|
||||
code: "blank_root_with_standalone_composition",
|
||||
severity: "error" as const,
|
||||
message: "wrong entry",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
totalErrors: 1,
|
||||
totalWarnings: 0,
|
||||
totalInfos: 0,
|
||||
};
|
||||
|
||||
expect(hasDefinitiveEntryMismatch(result)).toBe(true);
|
||||
expect(
|
||||
hasDefinitiveEntryMismatch({
|
||||
...result,
|
||||
results: [
|
||||
{
|
||||
...result.results[0]!,
|
||||
result: {
|
||||
...result.results[0]!.result,
|
||||
findings: [{ code: "media_missing_id", severity: "error", message: "missing" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function validHtmlWithAudio(compId = "main"): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="${compId}" data-width="1920" data-height="1080">
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
// ponytail: thin re-export — lintProject lives in @hyperframes/lint so it's usable without the CLI
|
||||
// CLI facade: the linter stays reusable without the CLI, while command-specific gates live here.
|
||||
export { lintProject, shouldBlockRender } from "@hyperframes/lint";
|
||||
export type { ProjectLintResult } from "@hyperframes/lint";
|
||||
|
||||
import type { ProjectLintResult } from "@hyperframes/lint";
|
||||
|
||||
export function hasDefinitiveEntryMismatch(result: ProjectLintResult): boolean {
|
||||
return result.results.some((entry) =>
|
||||
entry.result.findings.some(
|
||||
(finding) => finding.code === "blank_root_with_standalone_composition",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user