diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..929d279 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Test + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + node-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run tests + run: npm test diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..e2fbd1f --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,73 @@ +# Testing Strategy + +This repository has many scripts, but they do not share a single runtime or dependency graph. The lowest-risk testing strategy is to start from stable Node-based library code, then expand outward to CLI and skill-specific smoke tests. + +## Current Baseline + +- Root test runner: `node:test` +- Entry point: `npm test` +- Coverage command: `npm run test:coverage` +- CI trigger: GitHub Actions on `push`, `pull_request`, and manual dispatch + +This avoids introducing Jest/Vitest across a repo that already mixes plain Node scripts, Bun-based skill packages, vendored code, and browser automation. + +## Rollout Plan + +### Phase 1: Stable library coverage + +Focus on pure functions under `scripts/lib/` first. + +- `scripts/lib/release-files.mjs` +- `scripts/lib/shared-skill-packages.mjs` + +Goals: + +- Validate file filtering and release packaging rules +- Catch regressions in package vendoring and dependency rewriting +- Keep tests deterministic and free of network, Bun, or browser requirements + +### Phase 2: Root CLI integration tests + +Add temp-directory integration tests for root CLIs that already support dry-run or local-only flows. + +- `scripts/sync-shared-skill-packages.mjs` +- `scripts/publish-skill.mjs --dry-run` +- `scripts/sync-clawhub.mjs` argument handling and local skill discovery + +Goals: + +- Assert exit codes and stdout for common flows +- Cover CLI argument parsing without hitting external services + +### Phase 3: Skill script smoke tests + +Add opt-in smoke tests for selected `skills/*/scripts/` packages, starting with those that: + +- accept local input files +- have deterministic output +- do not require authenticated browser sessions + +Examples: + +- markdown transforms +- file conversion helpers +- local content analyzers + +Keep browser automation, login flows, and live API publishing scripts outside the default CI path unless they are explicitly mocked. + +### Phase 4: Coverage gates + +After the stable Node path has enough breadth, add coverage thresholds in CI for the tested root modules. + +Recommended order: + +1. Start with reporting only +2. Add line/function thresholds for `scripts/lib/**` +3. Expand include patterns once skill-level smoke tests are reliable + +## Conventions For New Tests + +- Prefer temp directories over committed fixtures unless the fixture is reused heavily +- Test exported functions before testing CLI wrappers +- Avoid network, browser, and credential dependencies in default CI +- Keep tests isolated so they can run with plain `node --test` diff --git a/package.json b/package.json new file mode 100644 index 0000000..d569b19 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "baoyu-skills", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "test:coverage": "node --experimental-test-coverage --test" + } +} diff --git a/tests/release-files.test.mjs b/tests/release-files.test.mjs new file mode 100644 index 0000000..3418246 --- /dev/null +++ b/tests/release-files.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + listReleaseFiles, + validateSelfContainedRelease, +} from "../scripts/lib/release-files.mjs"; + +async function makeTempDir(prefix) { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function writeFile(filePath, contents = "") { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents); +} + +async function writeJson(filePath, value) { + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +test("listReleaseFiles skips generated paths and returns sorted relative paths", async (t) => { + const root = await makeTempDir("baoyu-release-files-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + await writeFile(path.join(root, "b.txt"), "b"); + await writeFile(path.join(root, "a.txt"), "a"); + await writeFile(path.join(root, "nested", "keep.txt"), "keep"); + await writeFile(path.join(root, "node_modules", "skip.js"), "skip"); + await writeFile(path.join(root, ".git", "config"), "skip"); + await writeFile(path.join(root, "dist", "artifact.txt"), "skip"); + await writeFile(path.join(root, "out", "artifact.txt"), "skip"); + await writeFile(path.join(root, "build", "artifact.txt"), "skip"); + await writeFile(path.join(root, ".DS_Store"), "skip"); + await writeFile(path.join(root, "bun.lockb"), "skip"); + + const files = await listReleaseFiles(root); + + assert.deepEqual( + files.map((file) => file.relPath), + ["a.txt", "b.txt", "nested/keep.txt"], + ); +}); + +test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => { + const root = await makeTempDir("baoyu-release-ok-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + await writeJson(path.join(root, "shared", "package.json"), { + name: "shared-package", + version: "1.0.0", + }); + await writeFile(path.join(root, "shared", "index.js"), "export const shared = true;\n"); + await writeJson(path.join(root, "skill", "package.json"), { + name: "test-skill", + version: "1.0.0", + dependencies: { + "shared-package": "file:../shared", + }, + }); + + await assert.doesNotReject(() => validateSelfContainedRelease(root)); +}); + +test("validateSelfContainedRelease rejects missing local file dependencies", async (t) => { + const root = await makeTempDir("baoyu-release-missing-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + await writeJson(path.join(root, "skill", "package.json"), { + name: "test-skill", + version: "1.0.0", + dependencies: { + "shared-package": "file:../shared", + }, + }); + + await assert.rejects( + () => validateSelfContainedRelease(root), + /Missing local dependency for release/, + ); +}); + +test("validateSelfContainedRelease rejects file dependencies outside the release root", async (t) => { + const root = await makeTempDir("baoyu-release-root-"); + const outside = await makeTempDir("baoyu-release-outside-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + t.after(() => fs.rm(outside, { recursive: true, force: true })); + + const skillDir = path.join(root, "skill"); + const externalSpec = path + .relative(skillDir, outside) + .split(path.sep) + .join("/"); + + await writeJson(path.join(skillDir, "package.json"), { + name: "test-skill", + version: "1.0.0", + dependencies: { + "outside-package": `file:${externalSpec}`, + }, + }); + + await assert.rejects( + () => validateSelfContainedRelease(root), + /Release target is not self-contained/, + ); +}); diff --git a/tests/shared-skill-packages.test.mjs b/tests/shared-skill-packages.test.mjs new file mode 100644 index 0000000..d5acd83 --- /dev/null +++ b/tests/shared-skill-packages.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { syncSharedSkillPackages } from "../scripts/lib/shared-skill-packages.mjs"; + +async function makeTempDir(prefix) { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function writeFile(filePath, contents = "") { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents); +} + +async function writeJson(filePath, value) { + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +test("syncSharedSkillPackages vendors workspace packages into skill scripts", async (t) => { + const root = await makeTempDir("baoyu-sync-shared-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + await writeJson(path.join(root, "packages", "baoyu-md", "package.json"), { + name: "baoyu-md", + version: "1.0.0", + }); + await writeFile( + path.join(root, "packages", "baoyu-md", "src", "index.ts"), + "export const markdown = true;\n", + ); + + const consumerDir = path.join(root, "skills", "demo-skill", "scripts"); + await writeJson(path.join(consumerDir, "package.json"), { + name: "demo-skill-scripts", + version: "1.0.0", + dependencies: { + "baoyu-md": "^1.0.0", + kleur: "^4.1.5", + }, + }); + + const result = await syncSharedSkillPackages(root, { install: false }); + + assert.deepEqual(result.packageDirs, [consumerDir]); + assert.deepEqual(result.managedPaths, [ + "skills/demo-skill/scripts/bun.lock", + "skills/demo-skill/scripts/package.json", + "skills/demo-skill/scripts/vendor", + ]); + + const updatedPackageJson = JSON.parse( + await fs.readFile(path.join(consumerDir, "package.json"), "utf8"), + ); + assert.equal(updatedPackageJson.dependencies["baoyu-md"], "file:./vendor/baoyu-md"); + assert.equal(updatedPackageJson.dependencies.kleur, "^4.1.5"); + + const vendoredPackageJson = JSON.parse( + await fs.readFile(path.join(consumerDir, "vendor", "baoyu-md", "package.json"), "utf8"), + ); + assert.equal(vendoredPackageJson.name, "baoyu-md"); + + const vendoredFile = await fs.readFile( + path.join(consumerDir, "vendor", "baoyu-md", "src", "index.ts"), + "utf8", + ); + assert.match(vendoredFile, /markdown = true/); +}); diff --git a/tests/skills/baoyu-image-gen/main.test.mjs b/tests/skills/baoyu-image-gen/main.test.mjs new file mode 100644 index 0000000..0bff707 --- /dev/null +++ b/tests/skills/baoyu-image-gen/main.test.mjs @@ -0,0 +1,301 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createTaskArgs, + detectProvider, + getConfiguredMaxWorkers, + getConfiguredProviderRateLimits, + getWorkerCount, + isRetryableGenerationError, + loadBatchTasks, + mergeConfig, + normalizeOutputImagePath, + parseArgs, + parseSimpleYaml, +} from "../../../skills/baoyu-image-gen/scripts/main.ts"; + +function makeArgs(overrides = {}) { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +function useEnv(t, values) { + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, process.env[key]); + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + t.after(() => { + for (const [key, value] of previous.entries()) { + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); +} + +async function makeTempDir(prefix) { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +test("parseArgs parses the main image-gen CLI flags", () => { + const args = parseArgs([ + "--promptfiles", + "prompts/system.md", + "prompts/content.md", + "--image", + "out/hero", + "--provider", + "openai", + "--quality", + "2k", + "--imageSize", + "4k", + "--ref", + "ref/one.png", + "ref/two.jpg", + "--n", + "3", + "--jobs", + "5", + "--json", + ]); + + assert.deepEqual(args.promptFiles, ["prompts/system.md", "prompts/content.md"]); + assert.equal(args.imagePath, "out/hero"); + assert.equal(args.provider, "openai"); + assert.equal(args.quality, "2k"); + assert.equal(args.imageSize, "4K"); + assert.deepEqual(args.referenceImages, ["ref/one.png", "ref/two.jpg"]); + assert.equal(args.n, 3); + assert.equal(args.jobs, 5); + assert.equal(args.json, true); +}); + +test("parseArgs falls back to positional prompt and rejects invalid provider", () => { + const positional = parseArgs(["draw", "a", "cat"]); + assert.equal(positional.prompt, "draw a cat"); + + assert.throws( + () => parseArgs(["--provider", "stability"]), + /Invalid provider/, + ); +}); + +test("parseSimpleYaml parses nested defaults and provider limits", () => { + const yaml = ` +version: 2 +default_provider: openrouter +default_quality: normal +default_aspect_ratio: '16:9' +default_image_size: 2K +default_model: + google: gemini-3-pro-image-preview + openai: gpt-image-1.5 +batch: + max_workers: 8 + provider_limits: + google: + concurrency: 2 + start_interval_ms: 900 + openai: + concurrency: 4 +`; + + const config = parseSimpleYaml(yaml); + + assert.equal(config.version, 2); + assert.equal(config.default_provider, "openrouter"); + assert.equal(config.default_quality, "normal"); + assert.equal(config.default_aspect_ratio, "16:9"); + assert.equal(config.default_image_size, "2K"); + assert.equal(config.default_model?.google, "gemini-3-pro-image-preview"); + assert.equal(config.default_model?.openai, "gpt-image-1.5"); + assert.equal(config.batch?.max_workers, 8); + assert.deepEqual(config.batch?.provider_limits?.google, { + concurrency: 2, + start_interval_ms: 900, + }); + assert.deepEqual(config.batch?.provider_limits?.openai, { + concurrency: 4, + }); +}); + +test("mergeConfig only fills values missing from CLI args", () => { + const merged = mergeConfig( + makeArgs({ + provider: "openai", + quality: null, + aspectRatio: null, + imageSize: "4K", + }), + { + default_provider: "google", + default_quality: "2k", + default_aspect_ratio: "3:2", + default_image_size: "2K", + }, + ); + + assert.equal(merged.provider, "openai"); + assert.equal(merged.quality, "2k"); + assert.equal(merged.aspectRatio, "3:2"); + assert.equal(merged.imageSize, "4K"); +}); + +test("detectProvider rejects non-ref-capable providers and prefers Google first when multiple keys exist", (t) => { + assert.throws( + () => + detectProvider( + makeArgs({ + provider: "dashscope", + referenceImages: ["ref.png"], + }), + ), + /Reference images require a ref-capable provider/, + ); + + useEnv(t, { + GOOGLE_API_KEY: "google-key", + OPENAI_API_KEY: "openai-key", + OPENROUTER_API_KEY: null, + DASHSCOPE_API_KEY: null, + REPLICATE_API_TOKEN: null, + JIMENG_ACCESS_KEY_ID: null, + JIMENG_SECRET_ACCESS_KEY: null, + ARK_API_KEY: null, + }); + assert.equal(detectProvider(makeArgs()), "google"); +}); + +test("detectProvider selects an available ref-capable provider for reference-image tasks", (t) => { + useEnv(t, { + GOOGLE_API_KEY: null, + OPENAI_API_KEY: "openai-key", + OPENROUTER_API_KEY: null, + DASHSCOPE_API_KEY: null, + REPLICATE_API_TOKEN: null, + JIMENG_ACCESS_KEY_ID: null, + JIMENG_SECRET_ACCESS_KEY: null, + ARK_API_KEY: null, + }); + assert.equal( + detectProvider(makeArgs({ referenceImages: ["ref.png"] })), + "openai", + ); +}); + +test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => { + useEnv(t, { + BAOYU_IMAGE_GEN_MAX_WORKERS: "12", + BAOYU_IMAGE_GEN_GOOGLE_CONCURRENCY: "5", + BAOYU_IMAGE_GEN_GOOGLE_START_INTERVAL_MS: "450", + }); + + const extendConfig = { + batch: { + max_workers: 7, + provider_limits: { + google: { + concurrency: 2, + start_interval_ms: 900, + }, + }, + }, + }; + + assert.equal(getConfiguredMaxWorkers(extendConfig), 12); + assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).google, { + concurrency: 5, + startIntervalMs: 450, + }); +}); + +test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => { + const root = await makeTempDir("baoyu-image-gen-batch-"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + + const batchFile = path.join(root, "jobs", "batch.json"); + await fs.mkdir(path.dirname(batchFile), { recursive: true }); + await fs.writeFile( + batchFile, + JSON.stringify({ + jobs: 2, + tasks: [ + { + id: "hero", + promptFiles: ["prompts/hero.md"], + image: "out/hero", + ref: ["refs/hero.png"], + ar: "16:9", + }, + ], + }), + ); + + const loaded = await loadBatchTasks(batchFile); + assert.equal(loaded.jobs, 2); + assert.equal(loaded.batchDir, path.dirname(batchFile)); + assert.equal(loaded.tasks[0].id, "hero"); + + const taskArgs = createTaskArgs( + makeArgs({ + provider: "replicate", + quality: "2k", + json: true, + }), + loaded.tasks[0], + loaded.batchDir, + ); + + assert.deepEqual(taskArgs.promptFiles, [ + path.join(loaded.batchDir, "prompts/hero.md"), + ]); + assert.equal(taskArgs.imagePath, path.join(loaded.batchDir, "out/hero")); + assert.deepEqual(taskArgs.referenceImages, [ + path.join(loaded.batchDir, "refs/hero.png"), + ]); + assert.equal(taskArgs.provider, "replicate"); + assert.equal(taskArgs.aspectRatio, "16:9"); + assert.equal(taskArgs.quality, "2k"); + assert.equal(taskArgs.json, true); +}); + +test("path normalization, worker count, and retry classification follow expected rules", () => { + assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/); + assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/); + + assert.equal(getWorkerCount(8, null, 3), 3); + assert.equal(getWorkerCount(2, 6, 5), 2); + assert.equal(getWorkerCount(5, 0, 4), 1); + + assert.equal(isRetryableGenerationError(new Error("API error (401): denied")), false); + assert.equal(isRetryableGenerationError(new Error("socket hang up")), true); +}); diff --git a/tests/skills/baoyu-image-gen/providers-dashscope.test.mjs b/tests/skills/baoyu-image-gen/providers-dashscope.test.mjs new file mode 100644 index 0000000..10d8fcf --- /dev/null +++ b/tests/skills/baoyu-image-gen/providers-dashscope.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getSizeFromAspectRatio, + normalizeSize, + parseAspectRatio, +} from "../../../skills/baoyu-image-gen/scripts/providers/dashscope.ts"; + +test("DashScope aspect-ratio parsing accepts numeric ratios only", () => { + assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 }); + assert.equal(parseAspectRatio("square"), null); + assert.equal(parseAspectRatio("-1:2"), null); +}); + +test("DashScope size selection picks the closest supported size per quality preset", () => { + assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024"); + assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720"); + assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152"); + assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536"); +}); + +test("DashScope size normalization converts WxH into provider format", () => { + assert.equal(normalizeSize("1024x1024"), "1024*1024"); + assert.equal(normalizeSize("2048*1152"), "2048*1152"); +}); diff --git a/tests/skills/baoyu-image-gen/providers-google.test.mjs b/tests/skills/baoyu-image-gen/providers-google.test.mjs new file mode 100644 index 0000000..95a50ab --- /dev/null +++ b/tests/skills/baoyu-image-gen/providers-google.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + addAspectRatioToPrompt, + buildGoogleUrl, + buildPromptWithAspect, + extractInlineImageData, + extractPredictedImageData, + getGoogleImageSize, + isGoogleImagen, + isGoogleMultimodal, + normalizeGoogleModelId, +} from "../../../skills/baoyu-image-gen/scripts/providers/google.ts"; + +function useEnv(t, values) { + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, process.env[key]); + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + t.after(() => { + for (const [key, value] of previous.entries()) { + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); +} + +test("Google provider helpers normalize model IDs and select image size defaults", () => { + assert.equal( + normalizeGoogleModelId("models/gemini-3.1-flash-image-preview"), + "gemini-3.1-flash-image-preview", + ); + assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true); + assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true); + assert.equal( + getGoogleImageSize({ imageSize: null, quality: "2k" }), + "2K", + ); + assert.equal( + getGoogleImageSize({ imageSize: "4K", quality: "normal" }), + "4K", + ); +}); + +test("Google URL builder appends v1beta when the base URL does not already include it", (t) => { + useEnv(t, { GOOGLE_BASE_URL: "https://generativelanguage.googleapis.com" }); + assert.equal( + buildGoogleUrl("models/demo:generateContent"), + "https://generativelanguage.googleapis.com/v1beta/models/demo:generateContent", + ); +}); + +test("Google URL and prompt helpers preserve existing v1beta paths and aspect hints", (t) => { + useEnv(t, { GOOGLE_BASE_URL: "https://example.com/custom/v1beta/" }); + assert.equal( + buildGoogleUrl("/models/demo:predict"), + "https://example.com/custom/v1beta/models/demo:predict", + ); + + assert.equal( + addAspectRatioToPrompt("A city skyline", "16:9"), + "A city skyline Aspect ratio: 16:9.", + ); + assert.equal( + buildPromptWithAspect("A city skyline", "16:9", "2k"), + "A city skyline Aspect ratio: 16:9. High resolution 2048px.", + ); +}); + +test("Google response extractors find inline and predicted image payloads", () => { + assert.equal( + extractInlineImageData({ + candidates: [ + { + content: { + parts: [{ inlineData: { data: "inline-base64" } }], + }, + }, + ], + }), + "inline-base64", + ); + + assert.equal( + extractPredictedImageData({ + predictions: [{ image: { imageBytes: "predicted-base64" } }], + }), + "predicted-base64", + ); + + assert.equal( + extractPredictedImageData({ + generatedImages: [{ bytesBase64Encoded: "generated-base64" }], + }), + "generated-base64", + ); +}); diff --git a/tests/skills/baoyu-image-gen/providers-openai.test.mjs b/tests/skills/baoyu-image-gen/providers-openai.test.mjs new file mode 100644 index 0000000..f8a8a81 --- /dev/null +++ b/tests/skills/baoyu-image-gen/providers-openai.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + extractImageFromResponse, + getMimeType, + getOpenAISize, + parseAspectRatio, +} from "../../../skills/baoyu-image-gen/scripts/providers/openai.ts"; + +test("OpenAI aspect-ratio parsing and size selection match model families", () => { + assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 }); + assert.equal(parseAspectRatio("wide"), null); + assert.equal(parseAspectRatio("0:1"), null); + + assert.equal(getOpenAISize("dall-e-3", "16:9", "2k"), "1792x1024"); + assert.equal(getOpenAISize("dall-e-3", "9:16", "normal"), "1024x1792"); + assert.equal(getOpenAISize("dall-e-2", "16:9", "2k"), "1024x1024"); + assert.equal(getOpenAISize("gpt-image-1.5", "16:9", "2k"), "1536x1024"); + assert.equal(getOpenAISize("gpt-image-1.5", "4:3", "2k"), "1024x1024"); +}); + +test("OpenAI mime-type detection covers supported reference image extensions", () => { + assert.equal(getMimeType("frame.png"), "image/png"); + assert.equal(getMimeType("frame.jpg"), "image/jpeg"); + assert.equal(getMimeType("frame.webp"), "image/webp"); + assert.equal(getMimeType("frame.gif"), "image/gif"); +}); + +test("OpenAI response extraction supports base64 and URL download flows", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const fromBase64 = await extractImageFromResponse({ + data: [{ b64_json: Buffer.from("hello").toString("base64") }], + }); + assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello"); + + globalThis.fetch = async () => + new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { "Content-Type": "application/octet-stream" }, + }); + + const fromUrl = await extractImageFromResponse({ + data: [{ url: "https://example.com/image.png" }], + }); + assert.deepEqual([...fromUrl], [1, 2, 3]); + + await assert.rejects( + () => extractImageFromResponse({ data: [{}] }), + /No image in response/, + ); +}); diff --git a/tests/skills/baoyu-image-gen/providers-replicate.test.mjs b/tests/skills/baoyu-image-gen/providers-replicate.test.mjs new file mode 100644 index 0000000..19e3899 --- /dev/null +++ b/tests/skills/baoyu-image-gen/providers-replicate.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildInput, + extractOutputUrl, + parseModelId, +} from "../../../skills/baoyu-image-gen/scripts/providers/replicate.ts"; + +function makeArgs(overrides = {}) { + return { + aspectRatio: null, + quality: null, + n: 1, + ...overrides, + }; +} + +test("Replicate model parsing accepts official formats and rejects malformed ones", () => { + assert.deepEqual(parseModelId("google/nano-banana-pro"), { + owner: "google", + name: "nano-banana-pro", + version: null, + }); + assert.deepEqual(parseModelId("owner/model:abc123"), { + owner: "owner", + name: "model", + version: "abc123", + }); + + assert.throws( + () => parseModelId("just-a-model-name"), + /Invalid Replicate model format/, + ); +}); + +test("Replicate input builder maps aspect ratio, image count, quality, and refs", () => { + assert.deepEqual( + buildInput( + "A robot painter", + makeArgs({ + aspectRatio: "16:9", + quality: "2k", + n: 3, + }), + ["data:image/png;base64,AAAA"], + ), + { + prompt: "A robot painter", + aspect_ratio: "16:9", + number_of_images: 3, + resolution: "2K", + output_format: "png", + image_input: ["data:image/png;base64,AAAA"], + }, + ); + + assert.deepEqual( + buildInput("A robot painter", makeArgs({ quality: "normal" }), ["ref"]), + { + prompt: "A robot painter", + aspect_ratio: "match_input_image", + resolution: "1K", + output_format: "png", + image_input: ["ref"], + }, + ); +}); + +test("Replicate output extraction supports string, array, and object URLs", () => { + assert.equal( + extractOutputUrl({ output: "https://example.com/a.png" }), + "https://example.com/a.png", + ); + assert.equal( + extractOutputUrl({ output: ["https://example.com/b.png"] }), + "https://example.com/b.png", + ); + assert.equal( + extractOutputUrl({ output: { url: "https://example.com/c.png" } }), + "https://example.com/c.png", + ); + + assert.throws( + () => extractOutputUrl({ output: { invalid: true } }), + /Unexpected Replicate output format/, + ); +});