diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 570fc02fe..8170fc75a 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -902,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, ); diff --git a/packages/producer/src/services/hyperframeRuntimeLoader.test.ts b/packages/producer/src/services/hyperframeRuntimeLoader.test.ts index 73530ccd0..a34654ef7 100644 --- a/packages/producer/src/services/hyperframeRuntimeLoader.test.ts +++ b/packages/producer/src/services/hyperframeRuntimeLoader.test.ts @@ -36,40 +36,71 @@ describe("resolveHyperframeManifestPath", () => { expect(SIBLING_PATH).toContain("producer/src/services/hyperframe.manifest.json"); }); - it("includes sibling path as first candidate in resolution order", async () => { - // Import the actual source and verify the sibling path is found when it - // exists. In the monorepo, the monorepo-relative path also exists, so we - // verify the sibling would win by checking its position in candidates. - // - // We can't easily mock existsSync in ESM, but we CAN verify the - // structural invariant: the function checks SIBLING first by reading the - // source and confirming the candidate array order. - const { readFileSync } = await import("node:fs"); - const source = readFileSync(resolve(THIS_DIR, "hyperframeRuntimeLoader.ts"), "utf8"); - - // The candidates array must list SIBLING_MANIFEST_PATH before the others - const candidatesMatch = source.match(/const candidates = \[([\s\S]*?)\];/); - expect(candidatesMatch).not.toBeNull(); - const candidatesBody = candidatesMatch![1]; - - const siblingIdx = candidatesBody.indexOf("SIBLING_MANIFEST_PATH"); - const cwdIdx = candidatesBody.indexOf("CWD_RELATIVE_MANIFEST_PATHS"); - const moduleIdx = candidatesBody.indexOf("MODULE_RELATIVE_MANIFEST_PATH"); - - expect(siblingIdx).toBeGreaterThan(-1); - expect(siblingIdx).toBeLessThan(cwdIdx); - expect(cwdIdx).toBeLessThan(moduleIdx); + it("prefers sibling path when it exists, otherwise picks the first existing candidate", async () => { + // Behaviour-level replacement for the old source-text test that + // asserted on string positions inside `const candidates = [...]`. We + // prove the behavioural invariant instead: the resolver returns the + // first candidate that actually exists on disk. + const { resolveHyperframeManifestPath } = await import("./hyperframeRuntimeLoader.js"); + const resolved = resolveHyperframeManifestPath(); + expect(existsSync(resolved)).toBe(true); + // The sibling would win when present. In dev, the monorepo-relative + // core/dist is the real fallback; either way the path must exist. + if (existsSync(SIBLING_PATH)) { + expect(resolved).toBe(SIBLING_PATH); + } }); - it("finds manifest via monorepo-relative path in dev (integration check)", async () => { - // In the monorepo, the core/dist manifest should exist from the build. - // This acts as a smoke test that the resolution works in the dev env. + it("falls back to MONOREPO_PATH when present in dev (smoke test)", async () => { if (!existsSync(MONOREPO_PATH)) { // Skip if core hasn't been built — this is expected in CI before build return; } const { resolveHyperframeManifestPath } = await import("./hyperframeRuntimeLoader.js"); - const result = resolveHyperframeManifestPath(); - expect(existsSync(result)).toBe(true); + expect(resolveHyperframeManifestPath()).toBe(MONOREPO_PATH); + }); +}); + +describe("hyperframeRuntimeLoader error path (#3370)", () => { + const originalEnv = process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH; + + beforeEach(() => { + delete process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = originalEnv; + } else { + delete process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH; + } + }); + + it("names the env-override path when PRODUCER_HYPERFRAME_MANIFEST_PATH is set and missing", async () => { + // Force the env-override branch with a missing file. The thrown error + // must name the override, not any fallback candidate. + process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = "/nonexistent/override/manifest.json"; + const { resolveVerifiedHyperframeRuntime } = await import("./hyperframeRuntimeLoader.js"); + expect(() => resolveVerifiedHyperframeRuntime()).toThrow( + /nonexistent\/override\/manifest\.json/, + ); + }); + + it("triedManifestPaths returns only the override when PRODUCER_HYPERFRAME_MANIFEST_PATH is set", async () => { + process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = "/another/missing/override.json"; + const { triedManifestPaths } = await import("./hyperframeRuntimeLoader.js"); + expect(triedManifestPaths()).toEqual(["/another/missing/override.json"]); + }); + + it("triedManifestPaths lists every candidate when no override is set", async () => { + delete process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH; + const { triedManifestPaths } = await import("./hyperframeRuntimeLoader.js"); + const tried = triedManifestPaths(); + expect(tried.length).toBeGreaterThanOrEqual(4); + // The first candidate must be the sibling path so the user sees it + // first in the error message (heygen-com/hyperframes#3370). + expect(tried[0]).toBe( + resolve(dirname(fileURLToPath(import.meta.url)), "hyperframe.manifest.json"), + ); }); }); diff --git a/packages/producer/src/services/hyperframeRuntimeLoader.ts b/packages/producer/src/services/hyperframeRuntimeLoader.ts index 64e72f6a6..c45c8e120 100644 --- a/packages/producer/src/services/hyperframeRuntimeLoader.ts +++ b/packages/producer/src/services/hyperframeRuntimeLoader.ts @@ -9,13 +9,17 @@ const MODULE_RELATIVE_MANIFEST_PATH = resolve( PRODUCER_DIR, "../../../core/dist/hyperframe.manifest.json", ); -const CWD_RELATIVE_MANIFEST_PATHS = [ - // When bundled to a single file (dist/public-server.js), the manifest - // is copied as a sibling by build.mjs - resolve(PRODUCER_DIR, "hyperframe.manifest.json"), +// Order matters: a bundled CLI ships the manifest as a sibling of the +// packaged module; dev runs reach it via monorepo-relative paths. Listed +// once here so the resolver and the missing-manifest error share the same +// owner — printing only the fallback candidate misdirects the user +// (heygen-com/hyperframes#3370). +const MANIFEST_CANDIDATES: readonly string[] = [ + SIBLING_MANIFEST_PATH, resolve(process.cwd(), "packages/core/dist/hyperframe.manifest.json"), resolve(process.cwd(), "../core/dist/hyperframe.manifest.json"), resolve(process.cwd(), "core/dist/hyperframe.manifest.json"), + MODULE_RELATIVE_MANIFEST_PATH, ]; type HyperframeRuntimeManifest = { @@ -34,20 +38,21 @@ export type ResolvedHyperframeRuntime = { }; export function resolveHyperframeManifestPath(): string { - if (process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) { - return process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH; + const envOverride = process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH; + if (envOverride) { + return envOverride; } - const candidates = [ - SIBLING_MANIFEST_PATH, - ...CWD_RELATIVE_MANIFEST_PATHS, - MODULE_RELATIVE_MANIFEST_PATH, - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; - } - } - return MODULE_RELATIVE_MANIFEST_PATH; + const found = MANIFEST_CANDIDATES.find((candidate) => existsSync(candidate)); + // Fall back to the last candidate only when nothing exists. The caller will + // read its iife/artifact and throw; returning a stable but unreachable path + // keeps the existing API contract. + return found ?? MODULE_RELATIVE_MANIFEST_PATH; +} + +export function triedManifestPaths(): readonly string[] { + return process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH + ? [process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH] + : MANIFEST_CANDIDATES; } export function getVerifiedHyperframeRuntimeSource(): string { @@ -57,8 +62,9 @@ export function getVerifiedHyperframeRuntimeSource(): string { export function resolveVerifiedHyperframeRuntime(): ResolvedHyperframeRuntime { const manifestPath = resolveHyperframeManifestPath(); if (!existsSync(manifestPath)) { + const tried = triedManifestPaths().join(", "); throw new Error( - `[HyperframeRuntimeLoader] Missing manifest at ${manifestPath}. Build core runtime artifacts before rendering.`, + `[HyperframeRuntimeLoader] Missing manifest. Tried: ${tried}. Searched from cwd=${process.cwd()}. Build core runtime artifacts before rendering.`, ); }