Files
baoyu-skills/scripts/codex-imagegen/cache.test.ts
T
Yelban e98fa33bc2 feat: add codex-imagegen backend for non-Codex runtimes
Implements the preferred_image_backend: codex-imagegen config key
already referenced in several SKILL.md files but with no corresponding
backend. Bridges non-Codex runtimes (Claude Code, etc.) to Codex CLI's
built-in image_gen tool via 'codex exec'. Uses the user's Codex
subscription — no OPENAI_API_KEY required.

Reliability:
- Retry with exponential backoff (default 2 retries, configurable)
- 9 classified error kinds (retryable vs non-retryable)
- Node child_process timeout with SIGTERM→SIGKILL ladder

Correctness:
- Verifies image_gen was actually invoked (not bypassed) by checking
  $CODEX_HOME/generated_images/{thread_id}/ for a PNG
- PNG magic byte validation
- Output file size sanity check

Observability:
- Structured JSONL logging with --log-file
- Verbose stderr mode with --verbose
- Returns thread_id, tool_calls, token usage in result JSON
- Raw codex event stream preserved per attempt for debugging

Efficiency:
- Idempotency cache via --cache-dir (sha256 of prompt+aspect+refs)
- Cache hit returns in <0.3s vs 50-90s cold

Features:
- --ref reference images (repeatable, passed through to codex exec --image)
- --retries, --retry-delay, --timeout all configurable
- File lock serializes concurrent invocations

Testing:
- 16 Bun unit tests across parser, cache, validator
- Path-filtered CI workflow at .github/workflows/codex-imagegen-tests.yml
- Composes with existing test.yml without conflicts

Architecture:
- scripts/codex-imagegen.sh: thin bash entrypoint (bun → npx -y bun fallback)
- scripts/codex-imagegen/: TypeScript module
  - main.ts (orchestrator), types.ts (CliOptions/GenerateResult/GenError),
    spawn.ts (child_process), parser.ts (JSONL events), validator.ts
    (image_gen check + PNG magic), cache.ts (sha256 cache + FileLock),
    logger.ts (JSONL structured logger)

Trade-offs documented in docs/codex-imagegen-backend.md:
- 5-10x slower than direct OpenAI API (or near-free on cache hit)
- ToS gray area: image_gen is documented for interactive use; non-
  interactive invocation via codex exec is not explicitly addressed.
  Suitable for personal low-volume use; users are responsible for
  ensuring their usage complies with applicable terms of service.
2026-05-20 00:50:24 +08:00

64 lines
2.2 KiB
TypeScript

import { test, expect } from "bun:test";
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
test("cacheKey is deterministic and order-independent for refs", () => {
const k1 = cacheKey("hello", "16:9", ["a.png", "b.png"]);
const k2 = cacheKey("hello", "16:9", ["b.png", "a.png"]);
expect(k1).toBe(k2);
const k3 = cacheKey("hello", "16:9", []);
expect(k3).not.toBe(k1);
const k4 = cacheKey("hello", "1:1", []);
expect(k4).not.toBe(k3);
});
test("lookupCache returns null on miss, path on hit", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "cig-test-"));
try {
expect(await lookupCache(dir, "abc")).toBeNull();
const fake = path.join(dir, "abc.png");
await writeFile(fake, Buffer.alloc(2000));
expect(await lookupCache(dir, "abc")).toBe(fake);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("storeCache copies source into cache", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "cig-test-"));
const src = path.join(dir, "src.png");
try {
await writeFile(src, Buffer.from("xxxx".repeat(1000)));
await storeCache(dir, "key1", src);
const cached = await lookupCache(dir, "key1");
expect(cached).not.toBeNull();
const a = await readFile(src);
const b = await readFile(cached!);
expect(a.equals(b)).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("FileLock prevents concurrent acquisition", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "cig-lock-"));
try {
const lockPath = path.join(dir, "x.lock");
const lock1 = new FileLock(lockPath);
const lock2 = new FileLock(lockPath);
await lock1.acquire(1000);
let lock2Acquired = false;
const p = lock2.acquire(500).then(() => (lock2Acquired = true)).catch(() => {});
await new Promise((r) => setTimeout(r, 300));
expect(lock2Acquired).toBe(false);
await lock1.release();
await p;
expect(lock2Acquired).toBe(true);
await lock2.release();
} finally {
await rm(dir, { recursive: true, force: true });
}
});