mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
* fix(media-use): codex gate misfires as 'not logged in' when piped codexUnavailableReason() gated generation on parsing `codex login status` stdout, but that command prints 'Logged in using ChatGPT' to stderr and exits 0 — so the piped stdout media-use captures (execFileSync returns stdout only on success) was empty, and the gate falsely reported 'not logged in'. Every headless / CI / agent run was blocked from codex image gen even when fully authed. Gate on the durable credentials file ($CODEX_HOME/auth.json) instead of the TTY/stderr-only human text. Token validity is still proven by the exec, which fails cleanly on a stale login. The stdout `features list` capability check is unchanged. Verified: reproduced the false 'not logged in' block, then after the fix generated end-to-end via `resolve -t image --provider codex` (valid 1254x1254 PNG, source=generated, provider=codex.image_gen). * fix(media-use): bug-bash fixes — id race, provider/reuse/adopt guards From the bug-bash against main: - MU-23 (HIGH): concurrent resolves raced on nextId (read-max-then-append, non-atomic), so parallel agents got duplicate ids and clobbered each other's files. Add allocateId(): a coarse per-project lock (.media/.lock, 15s stale-steal) around id allocation that scans the manifest AND the type dir for reserved ids, then O_EXCL-creates a placeholder file so the slow download between allocate and append can't collide. 5 parallel resolves now yield 5 distinct ids + files. - X4: --reuse imported across a type mismatch (bgm asset under images/). Apply typesMatch on the --reuse path; reject mismatches (icon<->image still interchangeable). - X5: --provider silently overrode --local-only and made a network call. --local-only is now a hard guard: network providers are skipped even under a forced provider; the miss message explains the conflict. - BUG-2: --provider ignored the exact-cache floor and could hand back an asset from a different provider. A forced --provider now bypasses all reuse rungs (regenerate with THIS provider); the unforced floor is intact. - MU-26/X6: 0-byte assets accepted. --adopt skips 0-byte files (loud); ingest refuses a 0-byte local file (freezeUrl already rejects empty responses). - BUG-4: unknown/unavailable --provider now errors with the available list instead of a generic 'no provider could resolve' (typo != catalog miss). - BUG-5: --reuse "" gave the wrong 'type and intent required' error; it now routes to a clear empty-sha message. - BUG-3: voice duration leaked an unrounded float into index.md; round all durations to 0.1s centrally at record build (matches probe). - Nits: whitespace-only --intent is rejected; nudge grammar (exists/exist). Tests: allocateId reservation + registry local-only-wins added; full media-use suite green. All fixes verified e2e. * fix(cli): reject unknown flags instead of silently ignoring them citty is permissive: an unrecognized flag was dropped, not rejected — so `render . --out x` (the flag is --output/-o) silently ignored --out and rendered to the default renders/<name>.mp4 path. A mistyped flag read as a render/catalog miss. Add assertKnownFlags(): validate every dash-prefixed token against the command's declared args + aliases + the global set (help/version/json) before the command runs, in the shared trackCommandFailures run-wrapper so every leaf command is covered. Handles --flag=value, --no-<bool> negation, camelCase<->kebab arg names, and combined shorts; stops at --; positionals and flag values pass through. Verified: `render . --out x` -> 'Error: Unknown flag: --out'; --output/-o/ --json/--help still accepted. Unit tests added. * docs(skills): install with --full-depth so agents get current main The documented `npx skills add heygen-com/hyperframes` fetched the skills.sh registry blob, which lags GitHub main by hours — so users following the docs got a stale skill (e.g. media-use v1: no --candidates, voice stubbed). The CLI's own `hyperframes skills` command already forces a full clone via --full-depth to bypass this; the docs didn't pass it. Add --full-depth to every documented install command (README, CLAUDE.md, docs/guides/skills.mdx) with a one-line note on the lag. Addresses the user-facing half of the publish/registry lag (#2034). * chore(media-use): collapse resolve.mjs import to satisfy oxfmt --check * fix(cli): extract longFlagName to keep flag validator under complexity gate Also regenerate skills-manifest.json (resolve.mjs formatting change re-hashed the media-use skill). Fixes the Fallow audit + skills-manifest-in-sync CI gates.
336 lines
10 KiB
JavaScript
336 lines
10 KiB
JavaScript
import { strict as assert } from "node:assert";
|
||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { tmpdir } from "node:os";
|
||
import {
|
||
readManifest,
|
||
appendRecord,
|
||
findByPrompt,
|
||
findByEntity,
|
||
nextId,
|
||
allocateId,
|
||
normalizePrompt,
|
||
manifestPath,
|
||
mediaDir,
|
||
typeDirPath,
|
||
} from "./manifest.mjs";
|
||
import { regenerateIndex, generateIndexContent } from "./index-gen.mjs";
|
||
import {
|
||
contentHash,
|
||
cachePut,
|
||
cacheGet,
|
||
cacheGetByEntity,
|
||
importFromCache,
|
||
promote,
|
||
} from "./cache.mjs";
|
||
|
||
let tmp;
|
||
|
||
function setup() {
|
||
tmp = mkdtempSync(join(tmpdir(), "mu-test-"));
|
||
}
|
||
|
||
function cleanup() {
|
||
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
|
||
function makeRecord(overrides = {}) {
|
||
return {
|
||
id: "bgm_001",
|
||
type: "bgm",
|
||
path: ".media/audio/bgm/bgm_001.wav",
|
||
source: "search",
|
||
description: "soft minimal ambient",
|
||
duration: 11,
|
||
provenance: { provider: "heygen.audio.sounds", prompt: "subtle tech" },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function runTests() {
|
||
const tests = [];
|
||
|
||
function test(name, fn) {
|
||
tests.push({ name, fn });
|
||
}
|
||
|
||
// --- manifest.mjs ---
|
||
|
||
test("readManifest returns empty array when no manifest exists", () => {
|
||
setup();
|
||
const result = readManifest(tmp);
|
||
assert.deepStrictEqual(result, []);
|
||
cleanup();
|
||
});
|
||
|
||
test("appendRecord writes valid JSONL and readManifest parses it back", () => {
|
||
setup();
|
||
const record = makeRecord();
|
||
appendRecord(tmp, record);
|
||
const records = readManifest(tmp);
|
||
assert.equal(records.length, 1);
|
||
assert.deepStrictEqual(records[0], record);
|
||
cleanup();
|
||
});
|
||
|
||
test("appendRecord creates .media/ and type subdirs on first write", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord());
|
||
assert.ok(existsSync(mediaDir(tmp)));
|
||
assert.ok(existsSync(typeDirPath(tmp, "bgm")));
|
||
cleanup();
|
||
});
|
||
|
||
test("appendRecord appends multiple records", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
|
||
appendRecord(tmp, makeRecord({ id: "bgm_002", provenance: { prompt: "energetic" } }));
|
||
const records = readManifest(tmp);
|
||
assert.equal(records.length, 2);
|
||
assert.equal(records[0].id, "bgm_001");
|
||
assert.equal(records[1].id, "bgm_002");
|
||
cleanup();
|
||
});
|
||
|
||
test("findByPrompt returns exact-match record", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord());
|
||
const found = findByPrompt(tmp, "subtle tech", "bgm");
|
||
assert.ok(found);
|
||
assert.equal(found.id, "bgm_001");
|
||
cleanup();
|
||
});
|
||
|
||
test("findByPrompt returns null on miss", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord());
|
||
assert.equal(findByPrompt(tmp, "nonexistent", "bgm"), null);
|
||
cleanup();
|
||
});
|
||
|
||
test("findByPrompt filters by type", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord({ type: "sfx" }));
|
||
assert.equal(findByPrompt(tmp, "subtle tech", "bgm"), null);
|
||
assert.ok(findByPrompt(tmp, "subtle tech", "sfx"));
|
||
cleanup();
|
||
});
|
||
|
||
test("findByPrompt matches across case and whitespace variants", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord({ provenance: { provider: "x", prompt: "calm ambient piano" } }));
|
||
assert.ok(findByPrompt(tmp, "Calm Ambient Piano", "bgm"), "case-insensitive");
|
||
assert.ok(findByPrompt(tmp, " calm ambient piano ", "bgm"), "whitespace-insensitive");
|
||
assert.equal(findByPrompt(tmp, "calm ambient guitar", "bgm"), null, "still a real miss");
|
||
cleanup();
|
||
});
|
||
|
||
test("normalizePrompt trims, lowercases, collapses whitespace", () => {
|
||
assert.equal(normalizePrompt(" Upbeat Tech Launch "), "upbeat tech launch");
|
||
assert.equal(normalizePrompt(null), "");
|
||
});
|
||
|
||
test("allocateId reserves the id on disk so a pre-append caller can't reuse it (MU-23)", () => {
|
||
setup();
|
||
const a = allocateId(tmp, "bgm", ".wav");
|
||
assert.equal(a.id, "bgm_001");
|
||
assert.ok(existsSync(join(tmp, a.localPath)), "placeholder reserved on disk");
|
||
// Second allocation BEFORE any manifest append (the download window) must not
|
||
// hand back bgm_001 again, even with a different extension.
|
||
const b = allocateId(tmp, "bgm", ".mp3");
|
||
assert.equal(b.id, "bgm_002");
|
||
assert.notEqual(a.localPath, b.localPath);
|
||
// Lock file is released (not left behind).
|
||
assert.ok(!existsSync(join(tmp, ".media", ".lock")), "lock released");
|
||
cleanup();
|
||
});
|
||
|
||
test("allocateId continues past the highest manifest id", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord({ id: "bgm_005" }));
|
||
assert.equal(allocateId(tmp, "bgm", ".wav").id, "bgm_006");
|
||
cleanup();
|
||
});
|
||
|
||
test("findByEntity matches case-insensitively", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));
|
||
assert.ok(findByEntity(tmp, "github"));
|
||
assert.ok(findByEntity(tmp, "GITHUB"));
|
||
assert.equal(findByEntity(tmp, "gitlab"), null);
|
||
cleanup();
|
||
});
|
||
|
||
test("nextId generates sequential ids", () => {
|
||
setup();
|
||
assert.equal(nextId(tmp, "bgm"), "bgm_001");
|
||
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
|
||
assert.equal(nextId(tmp, "bgm"), "bgm_002");
|
||
appendRecord(tmp, makeRecord({ id: "bgm_002" }));
|
||
assert.equal(nextId(tmp, "bgm"), "bgm_003");
|
||
cleanup();
|
||
});
|
||
|
||
// --- index-gen.mjs ---
|
||
|
||
test("regenerateIndex produces plain-column table", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord());
|
||
regenerateIndex(tmp);
|
||
const content = readFileSync(join(tmp, ".media", "index.md"), "utf8");
|
||
assert.ok(content.includes("# .media · 1 asset"));
|
||
assert.ok(content.includes("bgm_001"));
|
||
assert.ok(content.includes("soft minimal ambient"));
|
||
assert.ok(content.includes("11s"));
|
||
cleanup();
|
||
});
|
||
|
||
test("regenerateIndex handles empty manifest", () => {
|
||
setup();
|
||
mkdirSync(join(tmp, ".media"), { recursive: true });
|
||
writeFileSync(manifestPath(tmp), "");
|
||
regenerateIndex(tmp);
|
||
const content = readFileSync(join(tmp, ".media", "index.md"), "utf8");
|
||
assert.ok(content.includes("# .media · 0 assets"));
|
||
cleanup();
|
||
});
|
||
|
||
test("generateIndexContent includes dims for images", () => {
|
||
const records = [
|
||
makeRecord({ id: "img_001", type: "image", width: 1920, height: 1080, duration: null }),
|
||
];
|
||
const content = generateIndexContent(records);
|
||
assert.ok(content.includes("1920×1080"));
|
||
assert.ok(content.includes("img_001"));
|
||
});
|
||
|
||
test("regenerateIndex matches manifest content after multiple writes", () => {
|
||
setup();
|
||
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
|
||
appendRecord(
|
||
tmp,
|
||
makeRecord({ id: "sfx_001", type: "sfx", description: "whoosh", duration: 3 }),
|
||
);
|
||
regenerateIndex(tmp);
|
||
const content = readFileSync(join(tmp, ".media", "index.md"), "utf8");
|
||
assert.ok(content.includes("# .media · 2 assets"));
|
||
assert.ok(content.includes("bgm_001"));
|
||
assert.ok(content.includes("sfx_001"));
|
||
assert.ok(content.includes("whoosh"));
|
||
cleanup();
|
||
});
|
||
|
||
// --- cache.mjs ---
|
||
|
||
test("cacheGet returns null when cache is empty", () => {
|
||
const result = cacheGet("nonexistent prompt", "bgm");
|
||
assert.equal(result, null);
|
||
});
|
||
|
||
test("cachePut + cacheGet round-trip", () => {
|
||
setup();
|
||
const filePath = join(tmp, "test.wav");
|
||
writeFileSync(filePath, "fake audio bytes for testing");
|
||
const record = makeRecord({ provenance: { prompt: "cache test" } });
|
||
|
||
const { sha } = cachePut(filePath, record);
|
||
assert.ok(sha);
|
||
assert.equal(sha.length, 64);
|
||
|
||
const found = cacheGet("cache test", "bgm");
|
||
assert.ok(found);
|
||
assert.equal(found.reusable, true);
|
||
assert.equal(found.sha, sha);
|
||
|
||
// cross-project reuse must survive trivial prompt variation, not just
|
||
// byte-identical intents (the whole point of normalizePrompt).
|
||
assert.ok(cacheGet(" Cache Test ", "bgm"), "cacheGet is case/whitespace-insensitive");
|
||
cleanup();
|
||
});
|
||
|
||
test("cacheGetByEntity finds cached asset", () => {
|
||
setup();
|
||
const filePath = join(tmp, "logo.png");
|
||
writeFileSync(filePath, "fake png bytes");
|
||
const record = makeRecord({
|
||
type: "icon",
|
||
entity: "TestCorp",
|
||
provenance: { prompt: "TestCorp logo" },
|
||
});
|
||
|
||
cachePut(filePath, record);
|
||
const found = cacheGetByEntity("testcorp");
|
||
assert.ok(found);
|
||
assert.equal(found.entity, "TestCorp");
|
||
cleanup();
|
||
});
|
||
|
||
test("contentHash is deterministic", () => {
|
||
setup();
|
||
const filePath = join(tmp, "det.bin");
|
||
writeFileSync(filePath, "deterministic content");
|
||
const h1 = contentHash(filePath);
|
||
const h2 = contentHash(filePath);
|
||
assert.equal(h1, h2);
|
||
cleanup();
|
||
});
|
||
|
||
test("promote copies project asset to global cache", () => {
|
||
setup();
|
||
const record = makeRecord();
|
||
appendRecord(tmp, record);
|
||
const filePath = join(tmp, record.path);
|
||
mkdirSync(join(filePath, ".."), { recursive: true });
|
||
writeFileSync(filePath, "promotable audio data");
|
||
|
||
const { sha } = promote(tmp, "bgm_001");
|
||
assert.ok(sha);
|
||
|
||
const cached = cacheGet("subtle tech", "bgm");
|
||
assert.ok(cached);
|
||
assert.equal(cached.sha, sha);
|
||
cleanup();
|
||
});
|
||
|
||
test("importFromCache copies cached file into project", () => {
|
||
setup();
|
||
const filePath = join(tmp, "source.wav");
|
||
writeFileSync(filePath, "importable audio");
|
||
const record = makeRecord({ provenance: { prompt: "import test" } });
|
||
const { sha } = cachePut(filePath, record);
|
||
|
||
const cached = cacheGet("import test", "bgm");
|
||
const projectDir = mkdtempSync(join(tmpdir(), "mu-import-"));
|
||
const imported = importFromCache(cached, projectDir, "bgm_001", ".media/audio/bgm/bgm_001.wav");
|
||
|
||
assert.ok(imported);
|
||
assert.equal(imported.id, "bgm_001");
|
||
assert.equal(imported.provenance.imported_from, sha);
|
||
assert.ok(existsSync(join(projectDir, ".media/audio/bgm/bgm_001.wav")));
|
||
|
||
rmSync(projectDir, { recursive: true, force: true });
|
||
cleanup();
|
||
});
|
||
|
||
// --- run ---
|
||
|
||
let passed = 0;
|
||
let failed = 0;
|
||
for (const { name, fn } of tests) {
|
||
try {
|
||
fn();
|
||
passed++;
|
||
console.log(` \x1b[32m✓\x1b[0m ${name}`);
|
||
} catch (err) {
|
||
failed++;
|
||
console.log(` \x1b[31m✗\x1b[0m ${name}`);
|
||
console.log(` ${err.message}`);
|
||
}
|
||
}
|
||
console.log(`\n${passed} passed, ${failed} failed`);
|
||
if (failed > 0) process.exit(1);
|
||
}
|
||
|
||
console.log("media-use · manifest/index/cache tests\n");
|
||
runTests();
|