From 55536f2a8e359885dbfa55898787181825b1b3ad Mon Sep 17 00:00:00 2001 From: 1bcMax <195689928+1bcMax@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:30:34 -0400 Subject: [PATCH 1/2] feat(imagegen): support reference image for image-to-image (gpt-image-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes BlockRunAI/Franklin#12 (initial scope — OpenAI gpt-image-1/2). Adds image_url to the ImageGen tool schema + flow: - Accepts http(s) URL, data URI, or local file path (auto-resolved, base64-encoded, capped at 4 MB). - When image_url is set, routes the call to /v1/images/image2image instead of /v1/images/generations and forces an edit-capable model. Default for ref-image mode is openai/gpt-image-2. - Skips the AskUser proposal flow in ref-image mode (the media router doesn't yet know which models do img-to-img). - Decodes data: URIs locally instead of fetching, since gpt-image-2's edit endpoint can return base64. google/nano-banana-pro and xai/grok-imagine-image-pro intentionally NOT in the supported set yet — they need gateway-side editImage() branches that are out of scope for this PR. Tests: +4 cases for resolveReferenceImage (URL/data/file pass-throughs, relative path, oversize cap, bad ext) and the EDIT_SUPPORTED_MODELS gate. Total 166/166 pass; build clean. --- src/tools/imagegen.ts | 157 ++++++++++++++++++++++++++++++++++++------ test/local.mjs | 73 +++++++++++++++++++- 2 files changed, 207 insertions(+), 23 deletions(-) diff --git a/src/tools/imagegen.ts b/src/tools/imagegen.ts index 99efb08..2b02316 100644 --- a/src/tools/imagegen.ts +++ b/src/tools/imagegen.ts @@ -27,6 +27,17 @@ interface ImageGenInput { output_path?: string; size?: string; model?: string; + /** + * Optional reference image for image-to-image generation (style transfer, + * character consistency, edits). When set, the call is routed to + * /v1/images/image2image instead of /v1/images/generations and only models + * that support reference images may be used (gpt-image-1/2, + * nano-banana-pro, grok-imagine-image-pro). Accepts: + * - http(s) URL — fetched server-side + * - data URI (data:image/...;base64,...) + * - local file path — read, base64-encoded, capped at ~4 MB + */ + image_url?: string; /** * Optional Content id to attach this generation to. When provided: * (1) Budget is checked BEFORE the paid generation — refusing up-front @@ -37,6 +48,51 @@ interface ImageGenInput { contentId?: string; } +/** + * Models that accept a reference image via /v1/images/image2image. Currently + * limited to OpenAI's edit endpoint — Gemini Nano Banana Pro and Grok Imagine + * Image Pro need gateway-side support before they can be wired in here. + */ +export const EDIT_SUPPORTED_MODELS = new Set([ + 'openai/gpt-image-1', + 'openai/gpt-image-2', +]); + +export const REFERENCE_IMAGE_MAX_BYTES = 4_000_000; + +/** + * Normalize a reference image into a data URI suitable for the gateway. + * Returns the input unchanged for http(s) URLs (gateway fetches them) or + * already-formed data URIs. Local file paths are read and base64-encoded. + */ +export function resolveReferenceImage(input: string, workingDir: string): string { + if (/^https?:\/\//i.test(input)) return input; + if (input.startsWith('data:image/')) return input; + + // Treat as local file path. + const resolved = path.isAbsolute(input) ? input : path.resolve(workingDir, input); + const stat = fs.statSync(resolved); + if (stat.size > REFERENCE_IMAGE_MAX_BYTES) { + throw new Error( + `Reference image too large: ${(stat.size / 1_000_000).toFixed(1)}MB > ${(REFERENCE_IMAGE_MAX_BYTES / 1_000_000).toFixed(1)}MB cap. Resize or crop first.`, + ); + } + const ext = path.extname(resolved).toLowerCase(); + const mimeMap: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + }; + const mime = mimeMap[ext]; + if (!mime) { + throw new Error(`Unsupported reference image extension ${ext || '(none)'}. Use .png/.jpg/.jpeg/.gif/.webp.`); + } + const bytes = fs.readFileSync(resolved); + return `data:${mime};base64,${bytes.toString('base64')}`; +} + export interface ImageGenDeps { /** Optional Content library for auto-recording generations into a piece. */ library?: ContentLibrary; @@ -50,12 +106,24 @@ function buildExecute(deps: ImageGenDeps) { ctx: ExecutionScope, ): Promise { const rawInput = input as unknown as ImageGenInput; - const { output_path, size, model, contentId } = rawInput; + const { output_path, size, model, contentId, image_url } = rawInput; if (!rawInput.prompt) { return { output: 'Error: prompt is required', isError: true }; } + // Resolve the reference image (if any) before any paid call so we fail + // cheaply on bad paths / oversize attachments. Holds the resolved data URI + // / http URL that gets posted to /v1/images/image2image. + let referenceImage: string | undefined; + if (image_url) { + try { + referenceImage = resolveReferenceImage(image_url, ctx.workingDir); + } catch (err) { + return { output: `Error: ${(err as Error).message}`, isError: true }; + } + } + // One-shot refinement opt-out: leading `///` tells Franklin "don't // refine this prompt, I wrote it the way I want it." Strip the prefix // and pass skipRefine through to the router. @@ -72,12 +140,29 @@ function buildExecute(deps: ImageGenDeps) { // step and use the old default. Otherwise: classifier picks a fitting // model + rewrites the prompt, the preview goes to AskUser, user // chooses or cancels. - let imageModel = model || 'openai/gpt-image-1'; + // Reference-image mode forces an edit-capable model. If the caller named + // an unsupported one, fail loudly so we don't silently downgrade their + // request to text-only generation. + if (referenceImage && model && !EDIT_SUPPORTED_MODELS.has(model)) { + return { + output: + `Error: model ${model} does not support reference images. ` + + `Use one of: ${[...EDIT_SUPPORTED_MODELS].join(', ')}.`, + isError: true, + }; + } + + let imageModel = model || (referenceImage ? 'openai/gpt-image-2' : 'openai/gpt-image-1'); const imageSize = size || '1024x1024'; let chosenPrompt = prompt; + // Skip the proposal flow when a reference image is set: the media router + // doesn't know which models support image-to-image, so its suggestions + // would frequently be unusable (text-only models). Default to gpt-image-1 + // for now; a future router upgrade can pick between the four edit-capable + // models based on the prompt. const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1'; - if (!model && !autoApprove && ctx.onAskUser) { + if (!model && !autoApprove && ctx.onAskUser && !referenceImage) { try { const chain = loadChain(); const client = new ModelClient({ apiUrl: API_URLS[chain], chain }); @@ -137,20 +222,34 @@ function buildExecute(deps: ImageGenDeps) { const chain = loadChain(); const apiUrl = API_URLS[chain]; - const endpoint = `${apiUrl}/v1/images/generations`; + // Reference-image mode hits the dedicated /v1/images/image2image endpoint; + // otherwise stay on text-to-image generations. + const endpoint = referenceImage + ? `${apiUrl}/v1/images/image2image` + : `${apiUrl}/v1/images/generations`; // Default output path const outPath = output_path ? (path.isAbsolute(output_path) ? output_path : path.resolve(ctx.workingDir, output_path)) : path.resolve(ctx.workingDir, `generated-${Date.now()}.png`); - const body = JSON.stringify({ - model: imageModel, - prompt: chosenPrompt, - n: 1, - size: imageSize, - response_format: 'b64_json', - }); + const body = JSON.stringify( + referenceImage + ? { + model: imageModel, + prompt: chosenPrompt, + image: referenceImage, + size: imageSize, + n: 1, + } + : { + model: imageModel, + prompt: chosenPrompt, + n: 1, + size: imageSize, + response_format: 'b64_json', + }, + ); const headers: Record = { 'Content-Type': 'application/json', @@ -173,7 +272,7 @@ function buildExecute(deps: ImageGenDeps) { if (response.status === 402) { const paymentHeaders = await signPayment(response, chain, endpoint); if (!paymentHeaders) { - return { output: 'Payment failed. Check wallet balance with: runcode balance', isError: true }; + return { output: 'Payment failed. Check wallet balance with: franklin balance', isError: true }; } response = await fetch(endpoint, { @@ -198,11 +297,21 @@ function buildExecute(deps: ImageGenDeps) { return { output: 'No image data returned from API', isError: true }; } - // Save image + // Save image. The /v1/images/image2image endpoint returns Gemini results + // as a data URI in `url`, so decode those locally instead of going through + // fetch — saves a network round-trip and avoids data:-URI fetch quirks. if (imageData.b64_json) { const buffer = Buffer.from(imageData.b64_json, 'base64'); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, buffer); + } else if (imageData.url && imageData.url.startsWith('data:')) { + const match = imageData.url.match(/^data:[^;]+;base64,(.+)$/); + if (!match) { + return { output: 'Malformed data URI in response', isError: true }; + } + const buffer = Buffer.from(match[1], 'base64'); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, buffer); } else if (imageData.url) { // Download from URL (with 30s timeout) const dlCtrl = new AbortController(); @@ -290,7 +399,7 @@ async function signPayment( feePayer as string, { resourceUrl: details.resource?.url || endpoint, - resourceDescription: details.resource?.description || 'RunCode image generation', + resourceDescription: details.resource?.description || 'Franklin image generation', maxTimeoutSeconds: details.maxTimeoutSeconds || 300, extra: details.extra as Record | undefined, } @@ -309,7 +418,7 @@ async function signPayment( details.network || 'eip155:8453', { resourceUrl: details.resource?.url || endpoint, - resourceDescription: details.resource?.description || 'RunCode image generation', + resourceDescription: details.resource?.description || 'Franklin image generation', maxTimeoutSeconds: details.maxTimeoutSeconds || 300, extra: details.extra as Record | undefined, } @@ -347,13 +456,16 @@ export function createImageGenCapability(deps: ImageGenDeps = {}): CapabilityHan spec: { name: 'ImageGen', description: - "Generate an image from a text prompt. Costs USDC from the user's wallet " + - "— confirm before generating. Saves to a local file. Default size: " + - "1024x1024. Do NOT call repeatedly to iterate on style — ask the user " + - "first. Pass contentId to attach the result to an existing Content " + - "piece: the content's budget is checked BEFORE paying, and on success " + - "the image is recorded as an asset with its estimated cost. Skipping " + - "contentId generates a one-off image with no budget tracking.", + "Generate an image from a text prompt — optionally with a reference " + + "image for style transfer / character consistency / edits. Costs USDC " + + "from the user's wallet — confirm before generating. Saves to a local " + + "file. Default size: 1024x1024. Do NOT call repeatedly to iterate on " + + "style — ask the user first. Pass contentId to attach the result to " + + "an existing Content piece: the content's budget is checked BEFORE " + + "paying, and on success the image is recorded as an asset with its " + + "estimated cost. Skipping contentId generates a one-off image with no " + + "budget tracking. When image_url is set, only edit-capable models " + + "(openai/gpt-image-1, openai/gpt-image-2) are accepted.", input_schema: { type: 'object', properties: { @@ -361,6 +473,7 @@ export function createImageGenCapability(deps: ImageGenDeps = {}): CapabilityHan output_path: { type: 'string', description: 'Where to save the image. Default: generated-.png in working directory' }, size: { type: 'string', description: 'Image size: 1024x1024, 1792x1024, or 1024x1792. Default: 1024x1024' }, model: { type: 'string', description: 'Image model to use. Default: openai/gpt-image-1' }, + image_url: { type: 'string', description: 'Optional reference image (image-to-image / style transfer). Accepts an http(s) URL, a data URI, or a local file path. Only works with edit-capable models.' }, contentId: { type: 'string', description: 'Optional Content id to attach this generation to. Pre-flight budget check + auto-record on success.' }, }, required: ['prompt'], diff --git a/test/local.mjs b/test/local.mjs index 8240eaf..388c77f 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -272,7 +272,7 @@ test('write capability allows files under system temp directory', async () => { test('session storage falls back to temp dir when HOME is not writable', async () => { const originalHome = process.env.HOME; const fakeHome = mkdtempSync(join(tmpdir(), 'rc-home-ro-')); - const fallbackDir = join(tmpdir(), 'runcode', 'sessions'); + const fallbackDir = join(tmpdir(), 'franklin', 'sessions'); try { mkdirSync(fakeHome, { recursive: true }); @@ -3944,3 +3944,74 @@ test('version-check: getAvailableUpdate reflects cache vs installed version', as else if (fs.existsSync(cacheFile)) fs.unlinkSync(cacheFile); } }); + +test('imagegen: resolveReferenceImage passes URLs and data URIs through unchanged', async () => { + const { resolveReferenceImage } = await import('../dist/tools/imagegen.js'); + + // http(s) URLs flow straight to the gateway — gateway fetches them. + assert.equal( + resolveReferenceImage('https://example.com/cat.png', '/tmp'), + 'https://example.com/cat.png', + ); + assert.equal( + resolveReferenceImage('http://example.com/cat.png', '/tmp'), + 'http://example.com/cat.png', + ); + + // Pre-formed data URIs are already in the correct shape. + const dataUri = 'data:image/png;base64,iVBORw0KGgo='; + assert.equal(resolveReferenceImage(dataUri, '/tmp'), dataUri); +}); + +test('imagegen: resolveReferenceImage reads and base64-encodes a local image', async () => { + const { resolveReferenceImage } = await import('../dist/tools/imagegen.js'); + const tmp = mkdtempSync(join(tmpdir(), 'imagegen-ref-')); + const imgPath = join(tmp, 'pixel.png'); + // 1x1 transparent PNG. + const pngBytes = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); + writeFileSync(imgPath, pngBytes); + + try { + const out = resolveReferenceImage(imgPath, '/tmp'); + assert.match(out, /^data:image\/png;base64,/); + const decoded = Buffer.from(out.split(',')[1], 'base64'); + assert.ok(decoded.equals(pngBytes), 'round-trip should preserve bytes'); + + // Relative paths resolve against workingDir. + const relOut = resolveReferenceImage('pixel.png', tmp); + assert.equal(relOut, out); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('imagegen: resolveReferenceImage rejects unsupported extensions and oversized files', async () => { + const { resolveReferenceImage, REFERENCE_IMAGE_MAX_BYTES } = await import('../dist/tools/imagegen.js'); + const tmp = mkdtempSync(join(tmpdir(), 'imagegen-ref-')); + + try { + // Unsupported extension. + const txt = join(tmp, 'note.txt'); + writeFileSync(txt, 'hello'); + assert.throws(() => resolveReferenceImage(txt, '/tmp'), /Unsupported reference image extension/); + + // Oversized PNG. + const big = join(tmp, 'huge.png'); + writeFileSync(big, Buffer.alloc(REFERENCE_IMAGE_MAX_BYTES + 1, 0)); + assert.throws(() => resolveReferenceImage(big, '/tmp'), /Reference image too large/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('imagegen: EDIT_SUPPORTED_MODELS lists OpenAI image-edit models', async () => { + const { EDIT_SUPPORTED_MODELS } = await import('../dist/tools/imagegen.js'); + assert.ok(EDIT_SUPPORTED_MODELS.has('openai/gpt-image-1')); + assert.ok(EDIT_SUPPORTED_MODELS.has('openai/gpt-image-2')); + // Other providers can be added once the gateway wires them up. + assert.ok(!EDIT_SUPPORTED_MODELS.has('google/nano-banana-pro')); + assert.ok(!EDIT_SUPPORTED_MODELS.has('xai/grok-imagine-image-pro')); +}); From 19f68227e40152776b61730b863ca8e7ed61c5cb Mon Sep 17 00:00:00 2001 From: 1bcMax <195689928+1bcMax@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:33:37 -0400 Subject: [PATCH 2/2] fix(imagegen): inline http(s) URLs as data URIs before posting Gateway's /v1/images/image2image schema validates `image` against /^data:image\//, so passing a raw URL through (the original draft) hits a 400 instead of reaching the upstream. Make resolveReferenceImage async and fetch URLs into a base64 data URI client-side, with: - 30s fetch timeout - Content-Type must start with image/ - Same 4 MB cap as local files - Clean error surfaces for non-2xx responses Also flips the existing tests to await the now-async helper and adds a URL-fetch test against an in-process http server (positive case + non-image content-type rejection + 404 propagation). 167/167 tests pass. --- src/tools/imagegen.ts | 36 +++++++++++++++++++---- test/local.mjs | 67 ++++++++++++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 22 deletions(-) diff --git a/src/tools/imagegen.ts b/src/tools/imagegen.ts index 2b02316..760f63b 100644 --- a/src/tools/imagegen.ts +++ b/src/tools/imagegen.ts @@ -61,14 +61,38 @@ export const EDIT_SUPPORTED_MODELS = new Set([ export const REFERENCE_IMAGE_MAX_BYTES = 4_000_000; /** - * Normalize a reference image into a data URI suitable for the gateway. - * Returns the input unchanged for http(s) URLs (gateway fetches them) or - * already-formed data URIs. Local file paths are read and base64-encoded. + * Normalize a reference image into a base64 data URI for the gateway. The + * /v1/images/image2image endpoint validates `image` against /^data:image\//, + * so http(s) URLs and local paths both have to be inlined client-side before + * posting. Already-formed data URIs pass through. */ -export function resolveReferenceImage(input: string, workingDir: string): string { - if (/^https?:\/\//i.test(input)) return input; +export async function resolveReferenceImage(input: string, workingDir: string): Promise { if (input.startsWith('data:image/')) return input; + if (/^https?:\/\//i.test(input)) { + const ctrl = new AbortController(); + const timeout = setTimeout(() => ctrl.abort(), 30_000); + try { + const resp = await fetch(input, { signal: ctrl.signal }); + if (!resp.ok) { + throw new Error(`Reference image fetch failed: ${resp.status} ${resp.statusText}`); + } + const contentType = (resp.headers.get('content-type') || '').toLowerCase().split(';')[0].trim(); + if (!contentType.startsWith('image/')) { + throw new Error(`Reference image URL returned non-image content-type: ${contentType || '(none)'}`); + } + const buf = Buffer.from(await resp.arrayBuffer()); + if (buf.byteLength > REFERENCE_IMAGE_MAX_BYTES) { + throw new Error( + `Reference image too large: ${(buf.byteLength / 1_000_000).toFixed(1)}MB > ${(REFERENCE_IMAGE_MAX_BYTES / 1_000_000).toFixed(1)}MB cap.`, + ); + } + return `data:${contentType};base64,${buf.toString('base64')}`; + } finally { + clearTimeout(timeout); + } + } + // Treat as local file path. const resolved = path.isAbsolute(input) ? input : path.resolve(workingDir, input); const stat = fs.statSync(resolved); @@ -118,7 +142,7 @@ function buildExecute(deps: ImageGenDeps) { let referenceImage: string | undefined; if (image_url) { try { - referenceImage = resolveReferenceImage(image_url, ctx.workingDir); + referenceImage = await resolveReferenceImage(image_url, ctx.workingDir); } catch (err) { return { output: `Error: ${(err as Error).message}`, isError: true }; } diff --git a/test/local.mjs b/test/local.mjs index 388c77f..745dbd6 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -3945,22 +3945,57 @@ test('version-check: getAvailableUpdate reflects cache vs installed version', as } }); -test('imagegen: resolveReferenceImage passes URLs and data URIs through unchanged', async () => { +test('imagegen: resolveReferenceImage passes data URIs through unchanged', async () => { const { resolveReferenceImage } = await import('../dist/tools/imagegen.js'); - // http(s) URLs flow straight to the gateway — gateway fetches them. - assert.equal( - resolveReferenceImage('https://example.com/cat.png', '/tmp'), - 'https://example.com/cat.png', - ); - assert.equal( - resolveReferenceImage('http://example.com/cat.png', '/tmp'), - 'http://example.com/cat.png', + // Pre-formed data URIs are already in the gateway-required shape. + const dataUri = 'data:image/png;base64,iVBORw0KGgo='; + assert.equal(await resolveReferenceImage(dataUri, '/tmp'), dataUri); +}); + +test('imagegen: resolveReferenceImage fetches http(s) URLs and inlines them as data URIs', async () => { + const { resolveReferenceImage } = await import('../dist/tools/imagegen.js'); + const pngBytes = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', ); - // Pre-formed data URIs are already in the correct shape. - const dataUri = 'data:image/png;base64,iVBORw0KGgo='; - assert.equal(resolveReferenceImage(dataUri, '/tmp'), dataUri); + const server = createServer((req, res) => { + if (req.url === '/img.png') { + res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': pngBytes.length }); + res.end(pngBytes); + } else if (req.url === '/text.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(''); + } else if (req.url === '/missing.png') { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found'); + } else { + res.writeHead(500); res.end(); + } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + try { + const out = await resolveReferenceImage(`http://127.0.0.1:${port}/img.png`, '/tmp'); + assert.match(out, /^data:image\/png;base64,/, 'url should round-trip into a data URI'); + const decoded = Buffer.from(out.split(',')[1], 'base64'); + assert.ok(decoded.equals(pngBytes), 'fetched bytes must match original'); + + // Non-image content-type → reject before we waste a paid call. + await assert.rejects( + () => resolveReferenceImage(`http://127.0.0.1:${port}/text.html`, '/tmp'), + /non-image content-type/, + ); + + // Upstream errors surface clearly. + await assert.rejects( + () => resolveReferenceImage(`http://127.0.0.1:${port}/missing.png`, '/tmp'), + /Reference image fetch failed: 404/, + ); + } finally { + await new Promise(resolve => server.close(resolve)); + } }); test('imagegen: resolveReferenceImage reads and base64-encodes a local image', async () => { @@ -3975,13 +4010,13 @@ test('imagegen: resolveReferenceImage reads and base64-encodes a local image', a writeFileSync(imgPath, pngBytes); try { - const out = resolveReferenceImage(imgPath, '/tmp'); + const out = await resolveReferenceImage(imgPath, '/tmp'); assert.match(out, /^data:image\/png;base64,/); const decoded = Buffer.from(out.split(',')[1], 'base64'); assert.ok(decoded.equals(pngBytes), 'round-trip should preserve bytes'); // Relative paths resolve against workingDir. - const relOut = resolveReferenceImage('pixel.png', tmp); + const relOut = await resolveReferenceImage('pixel.png', tmp); assert.equal(relOut, out); } finally { rmSync(tmp, { recursive: true, force: true }); @@ -3996,12 +4031,12 @@ test('imagegen: resolveReferenceImage rejects unsupported extensions and oversiz // Unsupported extension. const txt = join(tmp, 'note.txt'); writeFileSync(txt, 'hello'); - assert.throws(() => resolveReferenceImage(txt, '/tmp'), /Unsupported reference image extension/); + await assert.rejects(() => resolveReferenceImage(txt, '/tmp'), /Unsupported reference image extension/); // Oversized PNG. const big = join(tmp, 'huge.png'); writeFileSync(big, Buffer.alloc(REFERENCE_IMAGE_MAX_BYTES + 1, 0)); - assert.throws(() => resolveReferenceImage(big, '/tmp'), /Reference image too large/); + await assert.rejects(() => resolveReferenceImage(big, '/tmp'), /Reference image too large/); } finally { rmSync(tmp, { recursive: true, force: true }); }