Files
hyperframes/skills/hyperframes-animation/scripts/animation-map.test.mjs
T
WaterrrForever 7d21cc9b8a fix(skills,cli): close four reproduced contract gaps from the CLI feedback digest (#2476)
* fix(cli): invalidate the skills nudge cache after a successful install/update/check

The passive "N skills out of date or missing" nudge reads a 24h config
cache that only the background check (on non-skills commands) ever wrote.
The skills commands themselves are excluded from the nudge pipeline, so a
successful `skills update`/install/check never refreshed or dropped the
cached verdict — the pre-install count kept printing on every other
command for up to 24h.

Reconcile commands now drop the cached verdict (counts + timestamp) so
the next command's background check re-runs for real. The offline
presence-only path deliberately keeps the cache: that run learned nothing
about freshness.

* fix(skills): win32-safe npx spawns in media-use + accurate whisper wording

The Whisper transcribe fallback and the Kokoro local-TTS delegation both
spawned a bare "npx" via execFileSync — on Windows npx is npx.cmd, which
spawn cannot exec, so both paths died with `spawnSync npx ENOENT`. Route
them through the skill's existing resolveSpawnCommand (node + npx-cli.js
on win32, no shell:true), same as the audio engine's TTS spawns.

Also corrects the "bundled with the hyperframes CLI" claim about
whisper.cpp: it is resolved from PATH / installed via Homebrew / built
from source with git+cmake on first use, and models download from
HuggingFace — nothing whisper is shipped in the package.

* feat(skills): canonical fully-silent marker + auth status exit-code docs

product-launch's Step 3.1 gate said "or the project is marked silent"
but nothing defined how to mark one, and audio.mjs unconditionally
retrieved BGM. Define the canonical marker — `music: none` in the
storyboard's top YAML block, plus no SCRIPT.md — and honor it:
audio generate produces nothing (removing stale audio_meta.json, since
absence is what assemble treats as silent), and `music: none` with
narration keeps TTS while turning BGM off.

Also documents the `auth status` exit-code contract (exit 1 while
signed out is the normal offline state, not a failure) in the
product-launch Step 0 note and the CLI skill's cloud reference.

* fix(skills): transient-init retry for standalone animation-map and contrast-report

The standalone helpers called initializeSession exactly once, so a valid
modular project — whose sub-composition timelines register asynchronously
— could hit the readiness deadline and die with the transient
"zero duration / Runtime ready: false" diagnostic the render pipeline
retries (probeStage). Add initializeSessionWithRetry to the shared
package-loader (both byte-identical copies): close the crashed session
and retry once with a fresh browser, gated by the engine's canonical
isTransientBrowserError — now re-exported from @hyperframes/producer,
with a frozen fallback pattern list for older published packages. The
"Runtime ready: true" fast-fail (a genuine authoring bug) still fails
without a retry.

* feat(skills): extend the fully-silent marker to faceless-explainer and pr-to-video

Both workflows reuse product-launch's audio model — their Step 3.1 gates
carried the same undefined "marked silent" phrase, and their (intentionally
identical) audio.mjs copies had the same unconditional BGM retrieve. Port
the `music: none` marker handling into both copies, define the marker in
their SKILL.md Step 3.1 and story-design references, and turn the
copies' "intentionally identical" header claim into a byte-identity pin
test so the next fix can't silently miss one of them.

* test(cli): reset the prune mock explicitly instead of relying on restoreAllMocks

The converge test's toHaveBeenCalledTimes(1) held only because vitest 3's
vi.restoreAllMocks() clears vi.fn() call state; vitest 4 restores spies
only, so the count would accumulate across tests and fail. Reset
pruneOrphanedLockEntries in beforeEach like the other manifest mocks —
passes under both vitest 3.2.4 (pinned) and vitest 4.

* test(skills): close review findings — package-loader pin, whisper win32 parity, quoted-none

Review follow-ups on #2476:

- package-loader.mjs byte-identity pin (the elevated concern): the two
  copies now carry initializeSessionWithRetry + FALLBACK_TRANSIENT_PATTERNS,
  exactly the shared-logic shape a future fix could land in one copy and
  miss in the other — same enforcement as the audio.mjs pin.
- whisper win32 call-site parity: runWhisper's npx resolution lifted into
  lib/npx-sync.mjs (resolveNpxInvocation, injectable params matching the
  localTtsGenerate idiom) with the same three-branch coverage as the
  Kokoro site — plus the hard-fail contract (throws actionably, since the
  whisper fallback has no next provider to fall through to).
- quoted music: "none" pin: the vendored storyboard parser strips matching
  quotes at parse time (stripQuotes), so the silent marker already accepts
  the quoted spelling — pinned so that stays true.
2026-07-15 22:22:16 +08:00

289 lines
12 KiB
JavaScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it } from "node:test";
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const HELPERS = [
join(REPO_ROOT, "skills", "hyperframes-animation", "scripts", "animation-map.mjs"),
join(REPO_ROOT, "skills", "hyperframes-creative", "scripts", "contrast-report.mjs"),
];
describe("HyperFrames skill helpers", () => {
for (const helper of HELPERS)
it(`${helper.split("/").at(-1)} bundles modular input and uses rational fps`, () => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-helper-test-"));
const packageDir = join(root, "node_modules", "@hyperframes", "producer");
const corePackageDir = join(root, "node_modules", "@hyperframes", "core");
const sharpPackageDir = join(root, "node_modules", "sharp");
const compositionDir = join(root, "composition");
mkdirSync(packageDir, { recursive: true });
mkdirSync(corePackageDir, { recursive: true });
mkdirSync(sharpPackageDir, { recursive: true });
mkdirSync(compositionDir, { recursive: true });
writeFileSync(
join(packageDir, "package.json"),
JSON.stringify({ name: "@hyperframes/producer", type: "module", exports: "./index.mjs" }),
);
writeFileSync(
join(packageDir, "index.mjs"),
[
'import { readFileSync } from "node:fs";',
'import { join } from "node:path";',
"export async function createFileServer(options) {",
' const bundled = readFileSync(join(options.compiledDir, "index.html"), "utf8");',
' if (bundled !== "<!doctype html><main>bundled modular composition</main>") {',
" throw new Error(`UNEXPECTED_BUNDLE=${bundled}`);",
" }",
' return { url: "http://test", close() {} };',
"}",
"export async function createCaptureSession(_url, _out, options) {",
" throw new Error(`CAPTURE_OPTIONS=${JSON.stringify(options)}`);",
"}",
"export async function initializeSession() {}",
"export async function closeCaptureSession() {}",
"export async function getCompositionDuration() { return 0; }",
].join("\n"),
);
writeFileSync(
join(corePackageDir, "package.json"),
JSON.stringify({
name: "@hyperframes/core",
type: "module",
exports: { ".": "./index.mjs", "./compiler": "./compiler.mjs" },
}),
);
writeFileSync(
join(corePackageDir, "index.mjs"),
[
"export function parseFps(input) {",
" if (input === '30000/1001') return { ok: true, value: { num: 30000, den: 1001 } };",
" if (input === '29.97') return { ok: false, reason: 'ambiguous-decimal' };",
" return { ok: true, value: { num: Number(input), den: 1 } };",
"}",
].join("\n"),
);
writeFileSync(
join(corePackageDir, "compiler.mjs"),
[
"export async function bundleToSingleHtml() {",
' return "<!doctype html><main>bundled modular composition</main>";',
"}",
].join("\n"),
);
writeFileSync(
join(sharpPackageDir, "package.json"),
JSON.stringify({ name: "sharp", type: "module", exports: "./index.mjs" }),
);
writeFileSync(join(sharpPackageDir, "index.mjs"), "export default function sharp() {}\n");
try {
const result = spawnSync(
process.execPath,
[helper, compositionDir, "--fps", "30000/1001", "--out", join(root, "output")],
{
encoding: "utf8",
env: {
...process.env,
HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules"),
},
},
);
const output = `${result.stdout}\n${result.stderr}`;
assert.notEqual(result.status, 0);
assert.match(output, /CAPTURE_OPTIONS=.*"fps":\{"num":30000,"den":1001\}/);
const invalid = spawnSync(
process.execPath,
[helper, compositionDir, "--fps", "29.97", "--out", join(root, "invalid-output")],
{
encoding: "utf8",
env: {
...process.env,
HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules"),
},
},
);
const invalidOutput = `${invalid.stdout}\n${invalid.stderr}`;
assert.notEqual(invalid.status, 0);
assert.match(invalidOutput, /Invalid --fps "29\.97": ambiguous-decimal/);
assert.doesNotMatch(invalidOutput, /CAPTURE_OPTIONS=/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
// The two package-loader.mjs copies are intentionally byte-identical (each
// skill ships standalone, so neither can import the other's) and now carry
// shared logic (initializeSessionWithRetry + FALLBACK_TRANSIENT_PATTERNS)
// that a future fix could land in one copy and silently miss in the other —
// the exact drift class the audio.mjs identity pin was born to catch.
describe("package-loader parity", () => {
it("package-loader.mjs is byte-identical to hyperframes-creative's copy (the stated contract)", () => {
const here = readFileSync(
join(REPO_ROOT, "skills", "hyperframes-animation", "scripts", "package-loader.mjs"),
"utf8",
);
const sibling = readFileSync(
join(REPO_ROOT, "skills", "hyperframes-creative", "scripts", "package-loader.mjs"),
"utf8",
);
assert.equal(here, sibling);
});
});
// ── Transient-init retry (the zero-duration false-fail fix) ─────────────────
// A valid modular project's sub-composition timelines register asynchronously;
// the first initializeSession can time out with the transient "zero duration /
// Runtime ready: false" diagnostic. The render pipeline closes the crashed
// session and retries once with a fresh browser (probeStage) — the standalone
// helpers must do the same instead of reporting the project as zero-duration.
/** Write a fake node_modules with the given producer index.mjs source. */
function writeFakeEnv(root, producerIndexSource) {
const packageDir = join(root, "node_modules", "@hyperframes", "producer");
const corePackageDir = join(root, "node_modules", "@hyperframes", "core");
const sharpPackageDir = join(root, "node_modules", "sharp");
const compositionDir = join(root, "composition");
mkdirSync(packageDir, { recursive: true });
mkdirSync(corePackageDir, { recursive: true });
mkdirSync(sharpPackageDir, { recursive: true });
mkdirSync(compositionDir, { recursive: true });
writeFileSync(
join(packageDir, "package.json"),
JSON.stringify({ name: "@hyperframes/producer", type: "module", exports: "./index.mjs" }),
);
writeFileSync(join(packageDir, "index.mjs"), producerIndexSource);
writeFileSync(
join(corePackageDir, "package.json"),
JSON.stringify({
name: "@hyperframes/core",
type: "module",
exports: { ".": "./index.mjs", "./compiler": "./compiler.mjs" },
}),
);
writeFileSync(
join(corePackageDir, "index.mjs"),
"export function parseFps(input) { return { ok: true, value: { num: Number(input), den: 1 } }; }",
);
writeFileSync(
join(corePackageDir, "compiler.mjs"),
'export async function bundleToSingleHtml() { return "<!doctype html><main>x</main>"; }',
);
writeFileSync(
join(sharpPackageDir, "package.json"),
JSON.stringify({ name: "sharp", type: "module", exports: "./index.mjs" }),
);
writeFileSync(join(sharpPackageDir, "index.mjs"), "export default function sharp() {}\n");
return compositionDir;
}
function runHelper(helper, root, compositionDir) {
const result = spawnSync(process.execPath, [helper, compositionDir, "--out", join(root, "out")], {
encoding: "utf8",
env: { ...process.env, HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules") },
});
return `${result.stdout}\n${result.stderr}`;
}
const FAKE_PRODUCER_COMMON = [
'export async function createFileServer() { return { url: "http://test", close() {} }; }',
'export async function createCaptureSession() { console.error("SESSION_CREATED"); return {}; }',
'export async function closeCaptureSession() { console.error("SESSION_CLOSED"); }',
"export async function getCompositionDuration() { return 0; }",
].join("\n");
describe("transient-init retry", () => {
for (const helper of HELPERS) {
it(`${helper.split("/").at(-1)} retries a transient zero-duration init once with a fresh session`, () => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-retry-test-"));
try {
const compositionDir = writeFakeEnv(
root,
[
FAKE_PRODUCER_COMMON,
"let initCalls = 0;",
"export async function initializeSession() {",
" initCalls++;",
" if (initCalls === 1) {",
// The transient shape: readiness deadline hit before async
// sub-composition timelines landed (Runtime ready: false).
' throw new Error("Composition has zero duration after initialization.\\nRuntime ready: false");',
" }",
' throw new Error("INIT_ATTEMPT_2_REACHED");',
"}",
].join("\n"),
);
const output = runHelper(helper, root, compositionDir);
// Retried: fresh session created for attempt 2, crashed one closed.
assert.match(output, /retrying with a fresh browser session/);
assert.equal((output.match(/SESSION_CREATED/g) ?? []).length, 2);
assert.equal((output.match(/SESSION_CLOSED/g) ?? []).length, 2);
// ...and the retry genuinely re-ran init (bounded: no third attempt).
assert.match(output, /INIT_ATTEMPT_2_REACHED/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it(`${helper.split("/").at(-1)} does NOT retry a genuine authoring failure (Runtime ready: true)`, () => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-retry-test-"));
try {
const compositionDir = writeFakeEnv(
root,
[
FAKE_PRODUCER_COMMON,
"export async function initializeSession() {",
// The fast-fail shape: runtime IS ready, there is genuinely no
// timeline/duration — an authoring bug retries can't fix.
' throw new Error("Composition has zero duration after initialization.\\nRuntime ready: true");',
"}",
].join("\n"),
);
const output = runHelper(helper, root, compositionDir);
assert.doesNotMatch(output, /retrying with a fresh browser session/);
assert.equal((output.match(/SESSION_CREATED/g) ?? []).length, 1);
assert.match(output, /Composition has zero duration/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
it("prefers the producer's own isTransientBrowserError classifier when exported", () => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-retry-test-"));
try {
const compositionDir = writeFakeEnv(
root,
[
FAKE_PRODUCER_COMMON,
// A message the frozen fallback patterns would NOT match — only the
// producer-provided classifier can mark it transient.
"export function isTransientBrowserError(err) { return String(err && err.message).includes('CUSTOM_TRANSIENT'); }",
"let initCalls = 0;",
"export async function initializeSession() {",
" initCalls++;",
' if (initCalls === 1) throw new Error("CUSTOM_TRANSIENT flake");',
' throw new Error("INIT_ATTEMPT_2_REACHED");',
"}",
].join("\n"),
);
const output = runHelper(HELPERS[0], root, compositionDir);
assert.match(output, /retrying with a fresh browser session/);
assert.match(output, /INIT_ATTEMPT_2_REACHED/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});