open-design/apps/daemon/tests/media-nanobanana.test.ts
zztdan f3024fdc22
feat(media): add Nano Banana image provider (#631)
* feat(media): add Nano Banana image provider

* fix(media): support Gemini API key headers for Nano Banana

* refactor(media): move Nano Banana model override flag into provider metadata
2026-05-06 20:26:31 +08:00

185 lines
5.9 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { generateMedia } from '../src/media.js';
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2uoAAAAASUVORK5CYII=';
const TEST_NANOBANANA_BASE_URL = 'https://nano-banana-gateway.example.test';
describe('nano-banana media generation', () => {
let root: string;
let projectRoot: string;
let projectsRoot: string;
const realFetch = globalThis.fetch;
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
const originalDataDir = process.env.OD_DATA_DIR;
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'od-nanobanana-'));
projectRoot = path.join(root, 'project-root');
projectsRoot = path.join(projectRoot, '.od', 'projects');
await mkdir(projectsRoot, { recursive: true });
delete process.env.OD_MEDIA_CONFIG_DIR;
delete process.env.OD_DATA_DIR;
process.env.OD_NANOBANANA_API_KEY = 'nano-test-key';
});
afterEach(async () => {
globalThis.fetch = realFetch;
delete process.env.OD_NANOBANANA_API_KEY;
if (originalMediaConfigDir == null) {
delete process.env.OD_MEDIA_CONFIG_DIR;
} else {
process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
}
if (originalDataDir == null) {
delete process.env.OD_DATA_DIR;
} else {
process.env.OD_DATA_DIR = originalDataDir;
}
await rm(root, { recursive: true, force: true });
});
async function writeConfig(data: unknown) {
const file = path.join(projectRoot, '.od', 'media-config.json');
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify(data), 'utf8');
}
it('renders Nano Banana images through generateContent', async () => {
await writeConfig({
providers: {
nanobanana: {
baseUrl: TEST_NANOBANANA_BASE_URL,
model: 'custom-nano-model',
},
},
});
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
expect(String(input)).toBe(`${TEST_NANOBANANA_BASE_URL}/v1beta/models/custom-nano-model:generateContent`);
expect(init?.method).toBe('POST');
expect(init?.headers).toMatchObject({
authorization: 'Bearer nano-test-key',
'content-type': 'application/json',
});
expect(init?.headers).not.toHaveProperty('x-goog-api-key');
expect(JSON.parse(String(init?.body))).toEqual({
contents: [{ parts: [{ text: 'A watercolor shiba inu under cherry blossoms' }] }],
generationConfig: {
responseModalities: ['IMAGE'],
imageConfig: {
aspectRatio: '16:9',
imageSize: '1K',
},
},
});
return new Response(JSON.stringify({
candidates: [{
content: {
parts: [{
inlineData: {
mimeType: 'image/png',
data: PNG_BASE64,
},
}],
},
}],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
const result = await generateMedia({
projectRoot,
projectsRoot,
projectId: 'project-1',
surface: 'image',
model: 'gemini-3.1-flash-image-preview',
prompt: 'A watercolor shiba inu under cherry blossoms',
aspect: '16:9',
output: 'nano.png',
});
expect(result.name).toBe('nano.png');
expect(result.providerId).toBe('nanobanana');
expect(result.providerNote).toContain('nano-banana/custom-nano-model');
expect(result.providerNote).toContain('16:9');
expect(result.providerNote).toContain('1K');
const bytes = await readFile(path.join(projectsRoot, 'project-1', 'nano.png'));
expect(bytes.length).toBeGreaterThan(0);
});
it('uses x-goog-api-key for the official Gemini endpoint', async () => {
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
expect(String(input)).toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent');
expect(init?.headers).toMatchObject({
'content-type': 'application/json',
'x-goog-api-key': 'nano-test-key',
});
expect(init?.headers).not.toHaveProperty('authorization');
return new Response(JSON.stringify({
candidates: [{
content: {
parts: [{
inlineData: {
mimeType: 'image/png',
data: PNG_BASE64,
},
}],
},
}],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
const result = await generateMedia({
projectRoot,
projectsRoot,
projectId: 'project-1',
surface: 'image',
model: 'gemini-3.1-flash-image-preview',
prompt: 'A studio photo of a yellow banana on white seamless paper',
aspect: '1:1',
output: 'official.png',
});
expect(result.providerId).toBe('nanobanana');
expect(result.name).toBe('official.png');
});
it('surfaces upstream Nano Banana errors', async () => {
await writeConfig({
providers: {
nanobanana: {
baseUrl: TEST_NANOBANANA_BASE_URL,
},
},
});
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
error: { message: 'quota exceeded' },
}), {
status: 429,
headers: { 'content-type': 'application/json' },
})));
await expect(generateMedia({
projectRoot,
projectsRoot,
projectId: 'project-1',
surface: 'image',
model: 'gemini-3.1-flash-image-preview',
prompt: 'A neon city skyline',
aspect: '1:1',
})).rejects.toThrow(/nano-banana image 429/);
});
});