fix(render): allow explicit entry without index (#2307)

* fix(render): allow explicit entry without index

* fix(render): unify explicit composition handling

* test(cli): allow explicit render integration under CI load
This commit is contained in:
Miguel Ángel
2026-07-12 23:01:27 -04:00
committed by GitHub
parent 7c0dcb0b14
commit 796d5df156
6 changed files with 97 additions and 12 deletions
+39
View File
@@ -1,5 +1,8 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const producerState = vi.hoisted(() => ({
createdJobs: [] as Array<Record<string, unknown>>,
@@ -1065,4 +1068,40 @@ describe("render fps arg definition", () => {
});
});
describe("render command explicit composition", () => {
it("renders an explicit composition from a project with no index.html", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-explicit-"));
const outputPath = join(projectDir, "out.mp4");
writeFileSync(
join(projectDir, "standalone.html"),
`<html><body>
<div data-composition-id="standalone" data-width="1920" data-height="1080" data-duration="1"></div>
<script>window.__timelines = { standalone: gsap.timeline({ paused: true }) };</script>
</body></html>`,
);
vi.useFakeTimers();
try {
const command = (await import("./render.js")).default;
await command.run?.({
args: {
dir: projectDir,
composition: "standalone.html",
output: outputPath,
quiet: true,
quality: "standard",
format: "mp4",
},
} as never);
expect(producerState.createdJobs.at(-1)).toMatchObject({
entryFile: "standalone.html",
});
} finally {
vi.clearAllTimers();
rmSync(projectDir, { recursive: true, force: true });
}
}, 60_000);
});
// Variables-helper tests live in `../utils/variables.test.ts`.
+9 -6
View File
@@ -8,6 +8,7 @@ import {
} from "../utils/variables.js";
import {
parseGifLoopArg,
hasExplicitCompositionArg,
resolveBrowserTimeoutMsArg,
resolveCompositionEntryArg,
resolveDefaultFpsArg,
@@ -388,12 +389,14 @@ export default defineCommand({
// fallow-ignore-next-line complexity
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
const project = resolveProject(args.dir);
const hasExplicitComposition = hasExplicitCompositionArg(args.composition);
const project = resolveProject(args.dir, { requireIndex: !hasExplicitComposition });
// ── Resolve composition entry file ─────────────────────────────────────
// Needed early: fps default below must read the actual render target, not
// always index.html.
const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync);
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
// ── Validate fps ───────────────────────────────────────────────────────
// Accept either integer (`30`) or ffmpeg-style rational (`30000/1001`).
@@ -691,7 +694,7 @@ export default defineCommand({
preparedBatch = batchModule.prepareBatchRender({
batchPath,
outputTemplate: batchOutputTemplate,
indexPath: project.indexPath,
indexPath: renderTarget,
strictVariables: args["strict-variables"] ?? false,
quiet: quiet || batchJson,
json: batchJson,
@@ -708,7 +711,6 @@ export default defineCommand({
// deck-native path. Best-effort — never block a render on this probe.
if (!quiet) {
try {
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
const { slideshowIslandRegex } = await import("@hyperframes/core/slideshow");
if (slideshowIslandRegex("i").test(readFileSync(renderTarget, "utf8"))) {
console.log(
@@ -807,7 +809,9 @@ export default defineCommand({
// ── Pre-render lint ──────────────────────────────────────────────────
{
const explicitEntry = args.composition && args.composition !== "." ? entryFile : undefined;
// lintProject's explicit-entry contract is an absolute source path;
// entryFile is project-relative for the producer.
const explicitEntry = entryFile ? renderTarget : undefined;
const lintResult = await lintProject(project.dir, explicitEntry);
if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) {
console.log("");
@@ -842,7 +846,6 @@ export default defineCommand({
if (outputResolution) {
let resolutionIssue: { message: string; kind: OutputResolutionIssueKind } | undefined;
try {
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
resolutionIssue = await checkRenderResolutionPreflight(
readFileSync(renderTarget, "utf8"),
outputResolution,
@@ -935,7 +938,7 @@ export default defineCommand({
// ── Validate --variables against data-composition-variables ─────────
const strictVariables = args["strict-variables"] ?? false;
if (variables && Object.keys(variables).length > 0) {
const issues = validateVariablesAgainstProject(project.indexPath, variables);
const issues = validateVariablesAgainstProject(renderTarget, variables);
reportVariableIssues(issues, { strict: strictVariables, quiet });
}
+12
View File
@@ -32,6 +32,18 @@ describe("resolveProjectOrThrow", () => {
}
});
it("accepts a directory without index.html when an explicit entry will be resolved", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-explicit-entry-project-"));
try {
const project = resolveProjectOrThrow(dir, { requireIndex: false });
expect(project.dir).toBe(dir);
expect(project.indexPath).toBe(join(dir, "index.html"));
expect(project.name).toBe(basename(dir));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("accepts a directory with index.html", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-valid-project-"));
try {
+14 -4
View File
@@ -9,6 +9,10 @@ export interface ProjectDir {
indexPath: string;
}
export interface ResolveProjectOptions {
requireIndex?: boolean;
}
export class InvalidProjectError extends Error {
readonly title: string;
readonly hint?: string;
@@ -23,7 +27,10 @@ export class InvalidProjectError extends Error {
}
}
export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir {
export function resolveProjectOrThrow(
dirArg: string | undefined,
options: ResolveProjectOptions = {},
): ProjectDir {
const trimmed = dirArg?.trim();
if (trimmed === "#") {
throw new InvalidProjectError(
@@ -40,7 +47,7 @@ export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir {
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
throw new InvalidProjectError("Not a directory: " + dir);
}
if (!existsSync(indexPath)) {
if (options.requireIndex !== false && !existsSync(indexPath)) {
throw new InvalidProjectError(
"No composition found in " + dir,
"No index.html file found.",
@@ -51,9 +58,12 @@ export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir {
return { dir, name, indexPath };
}
export function resolveProject(dirArg: string | undefined): ProjectDir {
export function resolveProject(
dirArg: string | undefined,
options: ResolveProjectOptions = {},
): ProjectDir {
try {
return resolveProjectOrThrow(dirArg);
return resolveProjectOrThrow(dirArg, options);
} catch (err) {
if (err instanceof InvalidProjectError) {
// Self-exit (not a throw) so the cli.ts wrapper never sees it — report
+12
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import {
MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS,
hasExplicitCompositionArg,
parseBrowserTimeoutMsArg,
parseCompositionEntryArg,
parseGifLoopArg,
@@ -104,6 +105,17 @@ describe("parseBrowserTimeoutMsArg", () => {
});
describe("parseCompositionEntryArg", () => {
it("uses one sentinel classifier for default and explicit composition values", () => {
expect([undefined, "", " ", ".", "./"].map(hasExplicitCompositionArg)).toEqual([
false,
false,
false,
false,
false,
]);
expect(hasExplicitCompositionArg("./compositions/intro.html")).toBe(true);
});
const PROJECT = "/proj";
const stat = makeStat({
[resolve(PROJECT, "index.html")]: "file",
+11 -2
View File
@@ -128,6 +128,15 @@ export type CompositionEntryParseResult =
| { ok: true; value: string | undefined }
| { ok: false; error: CompositionEntryParseError };
function normalizeCompositionEntryArg(raw: string | undefined): string | undefined {
const trimmed = raw?.trim().replace(/^\.\//, "") || undefined;
return !trimmed || trimmed === "." ? undefined : trimmed;
}
export function hasExplicitCompositionArg(raw: string | undefined): boolean {
return normalizeCompositionEntryArg(raw) !== undefined;
}
/**
* Parse and validate `--composition <path>` into a project-relative
* entry file (or `undefined` for the index.html default).
@@ -146,11 +155,11 @@ export function parseCompositionEntryArg(
projectDir: string,
stat: (path: string) => Stats,
): CompositionEntryParseResult {
const trimmed = raw?.trim().replace(/^\.\//, "") || undefined;
const trimmed = normalizeCompositionEntryArg(raw);
// Normalize the project-root shorthands to "no entry override" so the
// producer falls back to index.html instead of statSync-ing the dir
// and later blowing up with EISDIR inside readFileSync().
if (!trimmed || trimmed === ".") return { ok: true, value: undefined };
if (!trimmed) return { ok: true, value: undefined };
const absProjectDir = resolve(projectDir);
const entryPath = resolve(absProjectDir, trimmed);