mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge origin/main into the audio stack
Twelve of the stack's feature commits landed on main as squashes (#3274 through #3292, plus #3401's canary removal); the 96 review-and-fix commits that followed them here did not, and main moved 64 commits on in the meantime. This reconciles the two. 58 files conflicted. 44 were audio-only — main's side there is the squashed form of commits this branch already carries and has since superseded, so the branch side stands. The rest needed real work, in both directions: **Taken from main, absent here.** - `ensureAudioGroupInertStyle` (#3278's review). An `<hf-audio-group>` is an unknown custom element, so it still takes a flex/grid slot and can open a line box — adding a group shifted authored layout. The helper and its `init.ts` call never came back to the branch, and this branch is what emits the element. - `#3383`'s ended-audio replay: `canSeekEndedMediaBackward` and its five siblings in `media.ts`, with all six tests. Not present here in any form. - `#3380`'s `asetpts=N/SR/TB` between `apad` and `atrim`. Also applied to `mixGroupMembers`, the group submix, which is new on this branch and so had the same bug in a path main's fix could not reach: delayed members padded then amix'd, where a group of four or more silently loses one. - `#3401`'s `displayNumber` thread. The header derives its row from the group-aware order and the undo label from ascending element keys, so once a group exists the same click said "Hide track 2" and recorded "Hide track 1". - `#3413`/`#3421`'s viewport handling — the popover's height cap and `inset()`, and `resolveFloatingPanelPosition` for the grouping dialog, which lives in a track header at the bottom of the window. - Two extractions this branch had inline and at exactly the 600-line cap: `useTimelineDeleteOps` and `editingModeSlice`. Bodies were identical. **Kept from the branch, against main.** Mute and solo are gone by deliberate breaking change (`remove mute and solo from tracks and groups`, `remove the group volume slider and level meter`), so eight files main still carries are deleted again, `PlayerControls` keeps no `previewIframeRef` (it existed only to feed `SoloBanner`), the group-levels branch comes out of main's new `previewMessageRouter`, and `STRIP_H` goes with the bus strip it sized. Main's `TimelineTrackPlainHeader.test.tsx` is rewritten against the control that actually exists — the visibility eye, withheld from an audible audio row and offered back once hidden, which is the only way out of `data-hidden`. **Unioned.** `TimelineFxPopover` — main's positioning, this branch's audition telemetry (`auditionPresetChain`, `storedChain`, `onAuditionTracked`); `SKILL.md` — main's #3416 "keep the carve group a voice group" beside this branch's bus section, with the canary paragraph dropped since the canaries no longer exist. Every port is mutation-checked. core 2508, studio 4460, lint 528, engine 1630, cli 2813, sdk 549, producer green; tsc, oxlint, oxfmt, fallow and the 600-line cap clean.
This commit is contained in:
@@ -20,7 +20,22 @@ async function loadPuppeteerBrowsers(): Promise<PuppeteerBrowsers> {
|
||||
}
|
||||
}
|
||||
|
||||
const CHROME_VERSION = "152.0.7928.2";
|
||||
// Bumped from 152.0.7928.2 on 2026-08-10 to pick up crbug 522872457's fix
|
||||
// (CL 8032671, "Force-merge PendingLayer for canvas child descendant", merged
|
||||
// 2026-07-07 — after the 152.0.7935.0 canary cut, so the old pin predated it).
|
||||
//
|
||||
// Measured on the shipping headless-shell binary, drawElementImage vs a CDP
|
||||
// screenshot of the identical state:
|
||||
// sibling content dropped from 3D captures — FIXED
|
||||
// background lost from 3D captures — FIXED
|
||||
// backface-visibility:hidden ignored — STILL BROKEN (1.4 dB -> 14.8 dB;
|
||||
// better, still far under the
|
||||
// 32 dB floor)
|
||||
// So the 3D compile gate must stay. See PRINFRA-486.
|
||||
//
|
||||
// Deliberately Beta, not Canary. 153.0.8000.0 measured identical to this build
|
||||
// on every probe variant, so crossing a major buys nothing measurable.
|
||||
const CHROME_VERSION = "152.0.7977.30";
|
||||
const CACHE_ROOT_DIR = join(homedir(), ".cache", "hyperframes");
|
||||
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
|
||||
// Puppeteer's managed cache — where `@puppeteer/browsers install
|
||||
@@ -332,7 +347,7 @@ async function findFromCache(): Promise<CacheLookupResult> {
|
||||
// this is the non-`preferManagedChrome` path, which exists so a user who
|
||||
// installed chrome-headless-shell separately (via `@puppeteer/browsers
|
||||
// install`) keeps using that binary instead of being silently switched to
|
||||
// the HF-pinned one. Note `CHROME_VERSION` (above) is a Dev-channel pin
|
||||
// the HF-pinned one. Note `CHROME_VERSION` (above) is a pre-Stable pin
|
||||
// that may be NEWER than a user's puppeteer-cache Stable build — this is
|
||||
// about respecting an explicit prior choice, not "newest wins".
|
||||
const fromPuppeteer = findFromPuppeteerCache();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isWindowsChromeCrashError, windowsChromeCrashRemediation } from "./windowsCrash.js";
|
||||
|
||||
describe("isWindowsChromeCrashError", () => {
|
||||
it("matches Puppeteer launch-failure wrapper + decimal exit code", () => {
|
||||
expect(
|
||||
isWindowsChromeCrashError(
|
||||
"Failed to launch the browser process! Code: 3221225595, with no stderr.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Puppeteer launch-failure wrapper + hex exit code", () => {
|
||||
expect(
|
||||
isWindowsChromeCrashError("Failed to launch the browser process (exited with 0xC0000409)"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Puppeteer launch-failure wrapper + named Windows status symbol", () => {
|
||||
expect(
|
||||
isWindowsChromeCrashError(
|
||||
"Failed to launch the browser process — STATUS_STACK_BUFFER_OVERRUN",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a launch failure without the crash-code signal", () => {
|
||||
// Linux shared-library launch failures land in the sibling
|
||||
// chromeLaunchRemediation path, not this one.
|
||||
expect(
|
||||
isWindowsChromeCrashError(
|
||||
"Failed to launch the browser process (libnss3.so: cannot open shared object file)",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match the crash code alone without the launch-failure wrapper", () => {
|
||||
expect(isWindowsChromeCrashError("some other Windows process exited with 3221225595")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not match unrelated errors", () => {
|
||||
expect(isWindowsChromeCrashError("Composition HTML is empty")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("windowsChromeCrashRemediation", () => {
|
||||
it("returns undefined off Windows even for a matching error", () => {
|
||||
if (process.platform === "win32") return;
|
||||
expect(
|
||||
windowsChromeCrashRemediation("Failed to launch the browser process! Code: 3221225595"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for non-launch errors on any platform", () => {
|
||||
expect(windowsChromeCrashRemediation("Composition HTML is empty")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a launch failure without the crash code on any platform", () => {
|
||||
expect(
|
||||
windowsChromeCrashRemediation(
|
||||
"Failed to launch the browser process (libnss3.so cannot open)",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Detection + remediation for Windows chrome-headless-shell launch crashes.
|
||||
*
|
||||
* Field feedback (#hyperframes-cli-feedback ts=1784116246, win32/x64,
|
||||
* HyperFrames CLI 0.7.58) hit the exact error
|
||||
* `Failed to launch the browser process ... Code: 3221225595` with no stderr.
|
||||
* Exit code 3221225595 == 0xC0000409 == STATUS_STACK_BUFFER_OVERRUN — a Windows
|
||||
* stack-corruption fatal reported against the pinned chrome-headless-shell
|
||||
* binary on some Win10/Win11 hosts (typically pre-24H2 or particular AV/EDR
|
||||
* combinations). The reporter recovered by pointing `HYPERFRAMES_BROWSER_PATH`
|
||||
* at their system Chrome; the render then used the screenshot fallback and
|
||||
* produced a complete MP4.
|
||||
*
|
||||
* The generic "Try --docker" hint the CLI already emits doesn't name that env
|
||||
* var, so the workaround is undiscoverable unaided. Sibling failure mode to
|
||||
* the download-time hint added in #2443 and the closed-with-invite #2078
|
||||
* (SIGTRAP at launch on macOS arm64); same `HYPERFRAMES_BROWSER_PATH`
|
||||
* remediation, different trigger + platform.
|
||||
*
|
||||
* The match is gated on both the Puppeteer launch-failure wrapper text AND the
|
||||
* specific crash-code signal (decimal, hex, or symbol name) so unrelated
|
||||
* Windows launch failures — which need different remediation — don't
|
||||
* mis-fire this hint.
|
||||
*/
|
||||
|
||||
const STATUS_STACK_BUFFER_OVERRUN_DEC = "3221225595";
|
||||
const STATUS_STACK_BUFFER_OVERRUN_HEX = /0x[cC]0000409/;
|
||||
const STATUS_STACK_BUFFER_OVERRUN_NAME = /STATUS_STACK_BUFFER_OVERRUN/i;
|
||||
|
||||
export function isWindowsChromeCrashError(errorMessage: string): boolean {
|
||||
if (!/Failed to launch the browser process/i.test(errorMessage)) return false;
|
||||
return (
|
||||
errorMessage.includes(STATUS_STACK_BUFFER_OVERRUN_DEC) ||
|
||||
STATUS_STACK_BUFFER_OVERRUN_HEX.test(errorMessage) ||
|
||||
STATUS_STACK_BUFFER_OVERRUN_NAME.test(errorMessage)
|
||||
);
|
||||
}
|
||||
|
||||
export function windowsChromeCrashRemediation(errorMessage: string): string | undefined {
|
||||
if (process.platform !== "win32") return undefined;
|
||||
if (!isWindowsChromeCrashError(errorMessage)) return undefined;
|
||||
return [
|
||||
"chrome-headless-shell crashed at launch (Windows STATUS_STACK_BUFFER_OVERRUN, exit 0xC0000409 / 3221225595).",
|
||||
"The pinned Chromium build is not stable on this Windows host; point hyperframes at your installed Chrome instead:",
|
||||
"",
|
||||
' set HYPERFRAMES_BROWSER_PATH="C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
|
||||
"",
|
||||
"Then re-run your command. Any Chrome build works for the screenshot capture path; install a real chrome-headless-shell later if you need the perf-optimized BeginFrame path.",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -165,6 +165,57 @@ describe("CLI lifecycle", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let a post-validation throw doom a render whose artifact is on disk", async () => {
|
||||
// The gap the field reports land in: a teardown step throws AFTER the
|
||||
// artifact validated, the command wrapper catches it, and the result
|
||||
// carries exitCode 1. The uncaughtException / unhandledRejection handlers
|
||||
// both consult isRenderSucceeded(), but a caught throw never reaches them,
|
||||
// so nothing sanitized the code and a valid MP4 was reported as a failure.
|
||||
const trackCommandResult = vi.fn();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
try {
|
||||
const successState = await import("./utils/render-success-state.js");
|
||||
mockInitCommand(() => {
|
||||
successState.markRenderSucceeded();
|
||||
throw new Error("post-render teardown blew up");
|
||||
});
|
||||
mockTelemetry({ trackCommandResult });
|
||||
|
||||
process.argv = ["node", "cli.ts", "init", "--json"];
|
||||
await import("./cli.js");
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(trackCommandResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ success: true, exitCode: 0 }),
|
||||
);
|
||||
successState._resetRenderSuccessForTests();
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("still exits non-zero when a command throws and no render ever validated", async () => {
|
||||
// The guard on the sanitizer above: it must key off a validated artifact,
|
||||
// not merely off the command having finished. Without this, every caught
|
||||
// throw would silently become exit 0.
|
||||
const trackCommandResult = vi.fn();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
try {
|
||||
mockInitCommand(() => {
|
||||
throw new Error("genuine failure");
|
||||
});
|
||||
mockTelemetry({ trackCommandResult });
|
||||
|
||||
process.argv = ["node", "cli.ts", "init", "--json"];
|
||||
await import("./cli.js");
|
||||
|
||||
expect(process.exitCode).not.toBe(0);
|
||||
expect(trackCommandResult).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("still scores an EPIPE before the artifact is validated as a failure", async () => {
|
||||
const trackCommandResult = vi.fn();
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
|
||||
+13
-4
@@ -283,12 +283,21 @@ let finalized = false;
|
||||
async function finalizeCli(result: CommandResult): Promise<void> {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
commandFailed ||= result.exitCode !== 0;
|
||||
// Once the artifact has validated and been committed to disk, the run
|
||||
// delivered — anything recorded as a failure after that is teardown noise.
|
||||
// The uncaughtException / unhandledRejection handlers already consult
|
||||
// isRenderSucceeded(), but a post-render throw that the command wrapper
|
||||
// CATCHES never reaches them: it becomes an ordinary non-zero
|
||||
// CommandResult, and a valid render is reported as a failure. Sanitizing
|
||||
// here, once, is what those handlers cannot cover, and it keeps the exit
|
||||
// code and the telemetry record from disagreeing about the same run.
|
||||
const exitCode = isRenderSucceeded() ? 0 : result.exitCode;
|
||||
commandFailed ||= exitCode !== 0;
|
||||
await telemetryReady.catch(() => {});
|
||||
_trackCommandResult?.({
|
||||
command,
|
||||
success: result.exitCode === 0 && commandSucceededForTelemetry(),
|
||||
exitCode: result.exitCode,
|
||||
success: exitCode === 0 && commandSucceededForTelemetry(),
|
||||
exitCode,
|
||||
durationMs: Date.now() - commandStart,
|
||||
runId,
|
||||
});
|
||||
@@ -298,7 +307,7 @@ async function finalizeCli(result: CommandResult): Promise<void> {
|
||||
_printStalePinNotice?.();
|
||||
_printSkillsUpdateNotice?.();
|
||||
}
|
||||
process.exitCode = result.exitCode;
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
registerRootExitRequester((exitCode) => {
|
||||
|
||||
@@ -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 { dirname, 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,58 @@ describe("parseUpdateTarget", () => {
|
||||
expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publish default-entry preflight", () => {
|
||||
async function runEntryMismatch(candidate: string): Promise<string> {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-publish-entry-mismatch-"));
|
||||
const candidatePath = join(project, candidate);
|
||||
mkdirSync(dirname(candidatePath), { recursive: true });
|
||||
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(
|
||||
candidatePath,
|
||||
`<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: "",
|
||||
});
|
||||
const lines: string[] = [];
|
||||
const log = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => {
|
||||
lines.push(parts.map(String).join(" "));
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
publishCommand.run?.({ args: { dir: project, yes: true, proxy: false } } as never),
|
||||
).rejects.toMatchObject({ name: "CliRuntimeError" });
|
||||
expect(publishState.publish).not.toHaveBeenCalled();
|
||||
return lines.join("\n");
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("suggests a nested index.html directory with the re-rooting caveat", async () => {
|
||||
const output = await runEntryMismatch("compositions/brand/index.html");
|
||||
|
||||
expect(output).toContain("hyperframes publish <project>/compositions/brand");
|
||||
expect(output).toContain("assets are self-contained under that directory");
|
||||
});
|
||||
|
||||
it("does not suggest a directory for a standalone file that is not index.html", async () => {
|
||||
const output = await runEntryMismatch("compositions/card.html");
|
||||
|
||||
expect(output).toContain("compositions/card.html");
|
||||
expect(output).not.toContain("hyperframes publish <project>/compositions");
|
||||
expect(output).toContain("publish accepts project directories, not individual HTML files");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { join, posix, relative, resolve } from "node:path";
|
||||
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 {
|
||||
definitiveEntryMismatchComposition,
|
||||
hasDefinitiveEntryMismatch,
|
||||
lintProject,
|
||||
} from "../utils/lintProject.js";
|
||||
import { formatLintFindings } from "../utils/lintFormat.js";
|
||||
import {
|
||||
buildPublishFileMap,
|
||||
@@ -92,6 +96,27 @@ export default defineCommand({
|
||||
for (const line of formatLintFindings(lintResult)) console.log(line);
|
||||
console.log();
|
||||
}
|
||||
if (hasDefinitiveEntryMismatch(lintResult)) {
|
||||
const candidate = definitiveEntryMismatchComposition(lintResult);
|
||||
console.log(c.error(" Aborting publish because the default index.html entry is blank."));
|
||||
if (candidate && posix.basename(candidate) === "index.html") {
|
||||
const candidateDir = posix.dirname(candidate);
|
||||
const target = `<project>/${candidateDir}`;
|
||||
console.log(
|
||||
c.dim(
|
||||
` Move or mount the authored file, or publish its directory directly: hyperframes publish ${target}. Only use the directory form when its assets are self-contained under that directory; otherwise mount it from the project root.`,
|
||||
),
|
||||
);
|
||||
} else if (candidate) {
|
||||
console.log(
|
||||
c.dim(
|
||||
` Move or mount ${candidate} as a project index.html before publishing; publish accepts project directories, not individual HTML files.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
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"],
|
||||
@@ -79,6 +79,7 @@ import { runEnvironmentChecks } from "../browser/preflight.js";
|
||||
import { detectH264EncoderMode } from "../browser/ffmpeg.js";
|
||||
import { chromeLaunchRemediation } from "../browser/linuxDeps.js";
|
||||
import { macosOldChromeCrashRemediation } from "../browser/macosOldChromeCrash.js";
|
||||
import { windowsChromeCrashRemediation } from "../browser/windowsCrash.js";
|
||||
import { killOrphanedProcesses } from "../utils/orphanCleanup.js";
|
||||
import {
|
||||
markRenderSucceeded,
|
||||
@@ -901,12 +902,15 @@ export async function renderLocal(
|
||||
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
|
||||
} catch (error: unknown) {
|
||||
maybeConsumeDeParallelRouterTrial(deParallelRouterActive, job, options.quiet);
|
||||
// The render container sets `ENV CONTAINER=true`; suggesting `--docker`
|
||||
// from inside it is a misdirection (heygen-com/hyperframes#3370).
|
||||
const inContainer = process.env.CONTAINER === "true";
|
||||
handleRenderError(
|
||||
error,
|
||||
options,
|
||||
startTime,
|
||||
false,
|
||||
"Try --docker for containerized rendering",
|
||||
inContainer ? "" : "Try --docker for containerized rendering",
|
||||
job.failedStage,
|
||||
job,
|
||||
);
|
||||
@@ -1433,6 +1437,20 @@ function handleRenderError(
|
||||
errorBox("Render failed — Chrome could not launch", message, macosRemediation);
|
||||
failCommand();
|
||||
}
|
||||
// Windows chrome-headless-shell can crash at launch with
|
||||
// STATUS_STACK_BUFFER_OVERRUN (exit 0xC0000409 / 3221225595). Same
|
||||
// HYPERFRAMES_BROWSER_PATH remediation as the download-time hint (#2443)
|
||||
// and the closed-with-invite arm64 macOS sibling (#2078). Field feedback
|
||||
// ts=1784116246.
|
||||
const windowsRemediation = windowsChromeCrashRemediation(message);
|
||||
if (windowsRemediation) {
|
||||
errorBox(
|
||||
"Render failed — chrome-headless-shell crashed at launch",
|
||||
message,
|
||||
windowsRemediation,
|
||||
);
|
||||
failCommand();
|
||||
}
|
||||
errorBox("Render failed", message, hint);
|
||||
failCommand();
|
||||
}
|
||||
|
||||
@@ -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 { dirname, 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,55 @@ describe("transparent snapshot capture", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshot lint preflight", () => {
|
||||
async function runEntryMismatch(candidate: string): Promise<string> {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-snapshot-entry-mismatch-"));
|
||||
const candidatePath = join(project, candidate);
|
||||
mkdirSync(dirname(candidatePath), { recursive: true });
|
||||
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(
|
||||
candidatePath,
|
||||
`<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();
|
||||
return lines.join("\n");
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("does not suggest a directory for a standalone file that is not index.html", async () => {
|
||||
const output = await runEntryMismatch("compositions/card.html");
|
||||
|
||||
expect(output).toContain("compositions/card.html");
|
||||
expect(output).not.toContain("hyperframes snapshot <project>/compositions");
|
||||
expect(output).toContain("snapshot accepts project directories, not individual HTML files");
|
||||
});
|
||||
|
||||
it("suggests the reported index.html directory with the re-rooting caveat", async () => {
|
||||
const output = await runEntryMismatch("compositions/index.html");
|
||||
|
||||
expect(output).toContain("hyperframes snapshot <project>/compositions");
|
||||
expect(output).toContain("assets are self-contained under that directory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSnapshotVideoFrameTime", () => {
|
||||
it("keeps media active at the inclusive clip end and samples its last decodable frame", () => {
|
||||
expect(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { failCommand } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join, relative, isAbsolute, basename } from "node:path";
|
||||
import { resolve, join, relative, isAbsolute, basename, posix } from "node:path";
|
||||
import {
|
||||
DEFAULT_ZOOM_SCALE,
|
||||
captureRegionCrop,
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
type ZoomTarget,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import {
|
||||
definitiveEntryMismatchComposition,
|
||||
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 +656,33 @@ export default defineCommand({
|
||||
},
|
||||
async run({ args }) {
|
||||
const project = resolveProject(args.dir);
|
||||
const lintResult = await lintProject(project.dir);
|
||||
if (hasDefinitiveEntryMismatch(lintResult)) {
|
||||
const candidate = definitiveEntryMismatchComposition(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."));
|
||||
if (candidate && posix.basename(candidate) === "index.html") {
|
||||
const candidateDir = posix.dirname(candidate);
|
||||
const target = `<project>/${candidateDir}`;
|
||||
console.log(
|
||||
c.dim(
|
||||
` Move or mount the authored file, or snapshot its directory directly: hyperframes snapshot ${target}. Only use the directory form when its assets are self-contained under that directory; otherwise mount it from the project root.`,
|
||||
),
|
||||
);
|
||||
} else if (candidate) {
|
||||
console.log(
|
||||
c.dim(
|
||||
` Move or mount ${candidate} as a project index.html before snapshotting; snapshot accepts project directories, not individual HTML files.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
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
|
||||
|
||||
@@ -8,7 +8,15 @@ import { describe, expect, it } from "vitest";
|
||||
const blocksDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../registry/blocks");
|
||||
|
||||
interface RegistryManifest {
|
||||
files: Array<{ path: string; type: string }>;
|
||||
name: string;
|
||||
tags?: string[];
|
||||
files: Array<{ path: string; target: string; type: string }>;
|
||||
}
|
||||
|
||||
const promotedTemplateTag = "ad-template";
|
||||
|
||||
function loadRegistryManifest(itemDir: string): RegistryManifest {
|
||||
return JSON.parse(readFileSync(join(itemDir, "registry-item.json"), "utf8")) as RegistryManifest;
|
||||
}
|
||||
|
||||
function findMissingLocalScripts(itemDir: string, manifest: RegistryManifest): string[] {
|
||||
@@ -32,6 +40,37 @@ function findMissingLocalScripts(itemDir: string, manifest: RegistryManifest): s
|
||||
}
|
||||
|
||||
describe("registry blocks", () => {
|
||||
it("ships an editing contract and declared variables for every promoted template", () => {
|
||||
const promotedManifests = readdirSync(blocksDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
itemDir: join(blocksDir, entry.name),
|
||||
manifest: loadRegistryManifest(join(blocksDir, entry.name)),
|
||||
}))
|
||||
.filter(({ manifest }) => manifest.tags?.includes(promotedTemplateTag));
|
||||
|
||||
expect(promotedManifests.length).toBeGreaterThan(0);
|
||||
for (const { itemDir, manifest } of promotedManifests) {
|
||||
const templateId = manifest.name;
|
||||
const contractFiles = manifest.files.filter(
|
||||
(file) =>
|
||||
file.path === "TEMPLATE.md" &&
|
||||
file.target === "TEMPLATE.md" &&
|
||||
file.type === "hyperframes:asset",
|
||||
);
|
||||
const composition = manifest.files.find((file) => file.type === "hyperframes:composition");
|
||||
|
||||
expect(contractFiles, templateId).toHaveLength(1);
|
||||
expect(composition, templateId).toBeDefined();
|
||||
const html = readFileSync(join(itemDir, composition?.path ?? ""), "utf8");
|
||||
const { document } = parseHTML(html);
|
||||
const declarations = JSON.parse(
|
||||
document.documentElement.getAttribute("data-composition-variables") ?? "[]",
|
||||
) as unknown[];
|
||||
expect(declarations.length, templateId).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("installs every local script referenced by a block composition", () => {
|
||||
const missing: string[] = [];
|
||||
|
||||
@@ -39,9 +78,7 @@ describe("registry blocks", () => {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const itemDir = join(blocksDir, entry.name);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(itemDir, "registry-item.json"), "utf8"),
|
||||
) as RegistryManifest;
|
||||
const manifest = loadRegistryManifest(itemDir);
|
||||
|
||||
for (const src of findMissingLocalScripts(itemDir, manifest)) {
|
||||
missing.push(`${entry.name}: ${src}`);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { affectsProjectSignature } from "@hyperframes/studio-server";
|
||||
|
||||
export type FileChangeListener = (relativePath: string) => void;
|
||||
|
||||
@@ -42,7 +44,17 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
|
||||
watcher = watch(projectDir, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const relativePath = filename.toString();
|
||||
if (!shouldWatchProjectFile(relativePath)) return;
|
||||
// The reload filter excludes all of `.hyperframes/`, but two files in
|
||||
// there feed the preview signature and Studio writes one of them at
|
||||
// runtime — dropping those at ingest left the CLI server's ETag stale
|
||||
// until restart. Admit them here and let the reload listener re-apply
|
||||
// its own filter, so what triggers a browser reload is unchanged.
|
||||
if (
|
||||
!shouldWatchProjectFile(relativePath) &&
|
||||
!affectsProjectSignature(projectDir, join(projectDir, relativePath))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPaths.add(relativePath);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
|
||||
@@ -84,6 +84,36 @@ describe("createStudioServer autoProxy plumbing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Studio project lint endpoint", () => {
|
||||
it("surfaces findings that require the complete project graph", async () => {
|
||||
const projectDir = tmpProject();
|
||||
mkdirSync(join(projectDir, "compositions"));
|
||||
mkdirSync(join(projectDir, "scenes"));
|
||||
writeFileSync(
|
||||
join(projectDir, "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(projectDir, "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>`,
|
||||
);
|
||||
writeFileSync(join(projectDir, "scenes", "intro.html"), "<html><body>Intro</body></html>");
|
||||
server = createStudioServer({ projectDir, projectName: "demo" });
|
||||
|
||||
const response = await server.app.request("http://localhost/api/projects/demo/lint");
|
||||
const payload = (await response.json()) as {
|
||||
findings?: Array<{ code?: string; file?: string }>;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.findings).toContainEqual(
|
||||
expect.objectContaining({ code: "blank_root_with_standalone_composition" }),
|
||||
);
|
||||
expect(payload.findings).toContainEqual(expect.objectContaining({ file: "scenes/intro.html" }));
|
||||
expect(payload.findings?.every((finding) => !finding.file?.startsWith(projectDir))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host guarding on identity-bearing responses", () => {
|
||||
// NOTE: the SPA-injection branch itself is covered in telemetryIdentity.test.ts
|
||||
// via buildStudioHeadScriptsForHost. It cannot be asserted here: this route
|
||||
|
||||
@@ -9,7 +9,11 @@ import { Hono, type Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from "node:fs";
|
||||
import { resolve, join, basename } from "node:path";
|
||||
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
|
||||
import {
|
||||
createProjectWatcher,
|
||||
shouldWatchProjectFile,
|
||||
type ProjectWatcher,
|
||||
} from "./fileWatcher.js";
|
||||
import {
|
||||
hashSignatureParts,
|
||||
loadRuntimeSource,
|
||||
@@ -32,6 +36,7 @@ import {
|
||||
consumeFileWriteReceipt,
|
||||
fileContentVersion,
|
||||
getMimeType,
|
||||
affectsProjectSignature,
|
||||
type PreviewApiAdapter,
|
||||
thumbnailDeviceScaleFactor,
|
||||
type ResolvedProject,
|
||||
@@ -370,8 +375,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
|
||||
const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
|
||||
let cachedProjectSignature: string | null = null;
|
||||
watcher.addListener(() => {
|
||||
cachedProjectSignature = null;
|
||||
watcher.addListener((changedPath) => {
|
||||
if (affectsProjectSignature(projectDir, join(projectDir, changedPath))) {
|
||||
cachedProjectSignature = null;
|
||||
}
|
||||
});
|
||||
|
||||
const adapter: PreviewApiAdapter = {
|
||||
@@ -442,6 +449,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
return await lintHyperframeHtml(html, opts);
|
||||
},
|
||||
|
||||
async lintProject(dir: string) {
|
||||
const { lintProject } = await import("@hyperframes/lint");
|
||||
return await lintProject(dir);
|
||||
},
|
||||
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
|
||||
rendersDir: () => join(projectDir, "renders"),
|
||||
@@ -776,7 +788,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
|
||||
.catch(() => {});
|
||||
};
|
||||
watcher.addListener(listener);
|
||||
// Re-applied here because the watcher now also emits the signature
|
||||
// manifest files, which must not trigger a browser reload.
|
||||
watcher.addListener((changedPath) => {
|
||||
if (shouldWatchProjectFile(changedPath)) listener(changedPath);
|
||||
});
|
||||
while (true) {
|
||||
await stream.sleep(30000);
|
||||
}
|
||||
|
||||
@@ -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,23 @@
|
||||
// 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",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function definitiveEntryMismatchComposition(result: ProjectLintResult): string | undefined {
|
||||
for (const entry of result.results) {
|
||||
const finding = entry.result.findings.find(
|
||||
(candidate) => candidate.code === "blank_root_with_standalone_composition",
|
||||
);
|
||||
if (finding) return finding.suggestedComposition;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user