Files
makelore/tests/unit/image-attachment-compression.test.ts
2026-07-29 17:22:35 +08:00

830 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi } from 'vitest';
import {
calculateImageResizeTarget,
estimateSelectedImageTokens,
getEffectiveImageMimeType,
getImageEncodeCandidates,
getImageFileNameForMime,
IMAGE_MAX_AUTO_PROCESS_SOURCE_BYTES,
processImageAttachment,
shouldSkipAutomaticImageProcessing,
type BrowserImageCodec,
type DecodedImage,
type RenderedImage,
} from '@/lib/image-attachment-compression';
import { browserImageCodec } from '@/lib/browser-image-codec';
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, reject, resolve };
}
describe('calculateImageResizeTarget', () => {
it('keeps a small image unchanged', () => {
expect(calculateImageResizeTarget(1280, 720)).toEqual({
width: 1280,
height: 720,
scale: 1,
resized: false,
});
});
it('caps a 4K image by the pixel limit', () => {
const target = calculateImageResizeTarget(3840, 2160);
expect(target).toEqual({
width: 1930,
height: 1086,
scale: expect.closeTo(Math.sqrt(2_097_152 / (3840 * 2160)), 8),
resized: true,
});
expect(target.width * target.height).toBeLessThanOrEqual(2_097_152);
});
it('caps a very wide image by the long edge and never returns zero', () => {
expect(calculateImageResizeTarget(4096, 256)).toMatchObject({
width: 2048,
height: 128,
resized: true,
});
expect(calculateImageResizeTarget(100_000, 1)).toMatchObject({
width: 2048,
height: 1,
resized: true,
});
});
it.each([
[Number.NaN, 10],
[Number.POSITIVE_INFINITY, 10],
[10, Number.NEGATIVE_INFINITY],
[0, 10],
[-1, 10],
[10, 0],
])('rejects invalid dimensions %s × %s', (width, height) => {
expect(() => calculateImageResizeTarget(width, height))
.toThrowError(new RangeError('Image dimensions must be finite positive numbers'));
});
});
describe('image MIME and encoding rules', () => {
it('infers a missing MIME from the filename', () => {
expect(getEffectiveImageMimeType(new File([], 'animation.GIF'))).toBe('image/gif');
expect(getEffectiveImageMimeType(new File([], 'vector.svg'))).toBe('image/svg+xml');
expect(getEffectiveImageMimeType(new File([], 'unknown.bin'))).toBe('application/octet-stream');
});
it.each(['image/gif', 'IMAGE/GIF', 'image/svg+xml'])('skips %s immediately', (mimeType) => {
expect(shouldSkipAutomaticImageProcessing(mimeType)).toBe(true);
});
it('selects lossless output for PNG and BMP', () => {
expect(getImageEncodeCandidates('image/png', true)).toEqual([
{ mimeType: 'image/png' },
]);
expect(getImageEncodeCandidates('image/bmp', false)).toEqual([
{ mimeType: 'image/png' },
]);
});
it('selects JPEG quality and WebP fallbacks deterministically', () => {
expect(getImageEncodeCandidates('image/jpeg', false)).toEqual([
{ mimeType: 'image/jpeg', quality: 0.85 },
]);
expect(getImageEncodeCandidates('image/webp', true)).toEqual([
{ mimeType: 'image/webp', quality: 0.85 },
{ mimeType: 'image/png' },
]);
expect(getImageEncodeCandidates('image/avif', false)).toEqual([
{ mimeType: 'image/webp', quality: 0.85 },
{ mimeType: 'image/jpeg', quality: 0.85 },
]);
});
it('makes transport filename extensions match the selected MIME', () => {
expect(getImageFileNameForMime('capture.bmp', 'image/png')).toBe('capture.png');
expect(getImageFileNameForMime('photo.avif', 'image/webp')).toBe('photo.webp');
expect(getImageFileNameForMime('photo.avif', 'image/jpeg')).toBe('photo.jpg');
expect(getImageFileNameForMime('photo.jpeg', 'image/jpg')).toBe('photo.jpg');
expect(getImageFileNameForMime('.hidden', 'image/png')).toBe('.hidden.png');
expect(getImageFileNameForMime('notes.txt', 'text/plain')).toBe('notes.txt');
});
});
describe('estimateSelectedImageTokens', () => {
it('delegates only an imported Qwen Plus model to the shared profile', () => {
expect(estimateSelectedImageTokens(
'niancode-user-models/qwen3.7-plus',
3840,
2160,
)).toBe(8102);
expect(estimateSelectedImageTokens(
'niancode-user-models/qwen3.7-plus',
1930,
1086,
)).toBe(2049);
});
it('returns null for unverified models and a same-named model on another provider', () => {
expect(estimateSelectedImageTokens(
'niancode-user-models/qwen-vl-max',
1930,
1086,
)).toBeNull();
expect(estimateSelectedImageTokens(
'openai/qwen3.7-plus',
1930,
1086,
)).toBeNull();
expect(estimateSelectedImageTokens('qwen3.7-plus', 1930, 1086)).toBeNull();
expect(estimateSelectedImageTokens(
'niancode-user-models/qwen3.7-plus/extra',
1930,
1086,
)).toBeNull();
});
});
function createFakeCodec(options: {
frameCount?: number | null;
width?: number;
height?: number;
hasAlpha?: boolean;
encoded?: Array<Blob | null>;
}) {
const decoded: DecodedImage = {
source: {} as CanvasImageSource,
width: options.width ?? 3840,
height: options.height ?? 2160,
dispose: vi.fn(),
};
const rendered: RenderedImage = {
canvas: {} as HTMLCanvasElement,
hasAlpha: options.hasAlpha ?? false,
dispose: vi.fn(),
};
const encoded = [...(options.encoded ?? [])];
const codec = {
inspectFrameCount: vi.fn(async () => (
Object.prototype.hasOwnProperty.call(options, 'frameCount')
? options.frameCount ?? null
: 1
)),
decode: vi.fn(async () => decoded),
render: vi.fn(() => rendered),
encode: vi.fn(async () => encoded.shift() ?? null),
} satisfies BrowserImageCodec;
return { codec, decoded, rendered };
}
describe('processImageAttachment', () => {
it('keeps an over-limit encoded source sendable without invoking the codec', async () => {
const { codec } = createFakeCodec({});
const file = new File([], 'huge.png', { type: 'image/png' });
Object.defineProperty(file, 'size', {
configurable: true,
value: IMAGE_MAX_AUTO_PROCESS_SOURCE_BYTES + 1,
});
await expect(processImageAttachment(file, codec)).resolves.toMatchObject({
status: 'skipped',
original: {
blob: file,
mimeType: 'image/png',
bytes: IMAGE_MAX_AUTO_PROCESS_SOURCE_BYTES + 1,
},
defaultVariant: 'original',
skipReason: 'source-too-large',
warning: expect.any(String),
});
expect(codec.inspectFrameCount).not.toHaveBeenCalled();
expect(codec.decode).not.toHaveBeenCalled();
expect(codec.render).not.toHaveBeenCalled();
expect(codec.encode).not.toHaveBeenCalled();
});
it('inspects one frame, honors EXIF orientation, resizes, and uses WebP first', async () => {
const compressed = new Blob(['compressed'], { type: 'image/webp' });
const { codec, decoded, rendered } = createFakeCodec({
frameCount: 1,
encoded: [compressed],
});
const file = new File(['original'], 'photo.avif', { type: 'image/avif' });
const result = await processImageAttachment(file, codec);
expect(codec.inspectFrameCount).toHaveBeenCalledWith(
file,
'image/avif',
undefined,
);
expect(codec.decode).toHaveBeenCalledWith(file, {
imageOrientation: 'from-image',
}, undefined);
expect(codec.render).toHaveBeenCalledWith(decoded, {
width: 1930,
height: 1086,
}, undefined);
expect(codec.encode).toHaveBeenCalledWith(rendered, {
mimeType: 'image/webp',
quality: 0.85,
}, undefined);
expect(result).toMatchObject({
status: 'compressed',
defaultVariant: 'compressed',
compressed: {
blob: compressed,
mimeType: 'image/webp',
width: 1930,
height: 1086,
},
});
expect(decoded.dispose).toHaveBeenCalledTimes(1);
expect(rendered.dispose).toHaveBeenCalledTimes(1);
});
it('falls back from unsupported WebP to PNG for an alpha image', async () => {
const png = new Blob(['png'], { type: 'image/png' });
const { codec } = createFakeCodec({
frameCount: 1,
hasAlpha: true,
encoded: [null, png],
});
const result = await processImageAttachment(
new File(['original'], 'alpha.avif', { type: 'image/avif' }),
codec,
);
expect(codec.encode).toHaveBeenNthCalledWith(1, expect.anything(), {
mimeType: 'image/webp',
quality: 0.85,
}, undefined);
expect(codec.encode).toHaveBeenNthCalledWith(2, expect.anything(), {
mimeType: 'image/png',
}, undefined);
expect(result).toMatchObject({
status: 'compressed',
compressed: { mimeType: 'image/png' },
});
});
it.each([
['image/png', 'animated.png'],
['image/webp', 'animated.webp'],
['image/avif', 'animated.avif'],
])('skips multi-frame %s without decoding its first frame', async (mimeType, name) => {
const { codec } = createFakeCodec({ frameCount: 3 });
const result = await processImageAttachment(
new File(['animated'], name, { type: mimeType }),
codec,
);
expect(result).toMatchObject({
status: 'skipped',
skipReason: 'animated',
defaultVariant: 'original',
warning: '检测到动画图片,已保留原图。',
});
expect(codec.decode).not.toHaveBeenCalled();
expect(codec.render).not.toHaveBeenCalled();
expect(codec.encode).not.toHaveBeenCalled();
});
it('skips an animation-capable raster when single-frame status is unknown', async () => {
const { codec } = createFakeCodec({ frameCount: null });
const result = await processImageAttachment(
new File(['unknown'], 'unknown.webp', { type: 'image/webp' }),
codec,
);
expect(result).toMatchObject({
status: 'skipped',
skipReason: 'static-unverified',
warning: '无法确认图片为单帧,已保留原图。',
});
expect(codec.decode).not.toHaveBeenCalled();
});
it.each([
['image/gif', 'animation.gif', 'animated'],
['image/svg+xml', 'vector.svg', 'vector'],
] as const)('skips %s before frame inspection', async (mimeType, name, skipReason) => {
const { codec } = createFakeCodec({});
const result = await processImageAttachment(
new File(['original'], name, { type: mimeType }),
codec,
);
expect(result).toMatchObject({
status: 'skipped',
skipReason,
defaultVariant: 'original',
});
expect(codec.inspectFrameCount).not.toHaveBeenCalled();
expect(codec.decode).not.toHaveBeenCalled();
});
it('does not render or encode a small one-frame image', async () => {
const { codec } = createFakeCodec({
frameCount: 1,
width: 1280,
height: 720,
});
const result = await processImageAttachment(
new File(['small'], 'small.jpg', { type: 'image/jpeg' }),
codec,
);
expect(result).toMatchObject({
status: 'unchanged',
defaultVariant: 'original',
original: { width: 1280, height: 720 },
});
expect(codec.render).not.toHaveBeenCalled();
expect(codec.encode).not.toHaveBeenCalled();
});
it('fails open when decode throws', async () => {
const { codec } = createFakeCodec({ frameCount: 1 });
codec.decode.mockRejectedValueOnce(new Error('decode failed'));
const file = new File(['original'], 'broken.png', { type: 'image/png' });
await expect(processImageAttachment(file, codec)).resolves.toEqual({
status: 'failed',
original: {
blob: file,
mimeType: 'image/png',
bytes: file.size,
},
defaultVariant: 'original',
warning: '图片压缩失败,发送时将使用原图。',
});
});
it('fails open when every MIME encoder is unsupported', async () => {
const { codec } = createFakeCodec({
frameCount: 1,
hasAlpha: false,
encoded: [null, null],
});
const result = await processImageAttachment(
new File(['original'], 'photo.avif', { type: 'image/avif' }),
codec,
);
expect(result).toMatchObject({
status: 'failed',
defaultVariant: 'original',
warning: '图片压缩失败,发送时将使用原图。',
});
});
it('preserves decoded original dimensions when rendering later fails', async () => {
const { codec } = createFakeCodec({
frameCount: 1,
width: 3000,
height: 2000,
});
codec.render.mockImplementationOnce(() => {
throw new Error('render failed');
});
const file = new File(['original'], 'photo.png', { type: 'image/png' });
await expect(processImageAttachment(file, codec)).resolves.toMatchObject({
status: 'failed',
original: {
blob: file,
width: 3000,
height: 2000,
},
defaultVariant: 'original',
});
});
it('releases decoded and rendered resources when aborting after render', async () => {
const controller = new AbortController();
const { codec, decoded, rendered } = createFakeCodec({
frameCount: 1,
width: 3000,
height: 2000,
});
codec.render.mockImplementationOnce(() => {
controller.abort();
return rendered;
});
await expect(processImageAttachment(
new File(['original'], 'photo.png', { type: 'image/png' }),
codec,
{ signal: controller.signal },
)).rejects.toMatchObject({ name: 'AbortError' });
expect(decoded.dispose).toHaveBeenCalledTimes(1);
expect(rendered.dispose).toHaveBeenCalledTimes(1);
expect(codec.encode).not.toHaveBeenCalled();
});
it('releases resources when an encode completes after cancellation', async () => {
const controller = new AbortController();
const { codec, decoded, rendered } = createFakeCodec({
frameCount: 1,
width: 3000,
height: 2000,
});
codec.encode.mockImplementationOnce(async () => {
controller.abort();
return new Blob(['compressed'], { type: 'image/png' });
});
await expect(processImageAttachment(
new File(['original'], 'photo.png', { type: 'image/png' }),
codec,
{ signal: controller.signal },
)).rejects.toMatchObject({ name: 'AbortError' });
expect(decoded.dispose).toHaveBeenCalledTimes(1);
expect(rendered.dispose).toHaveBeenCalledTimes(1);
});
it('preserves a compressed result when rendered cleanup throws', async () => {
const compressed = new Blob(['compressed'], { type: 'image/webp' });
const { codec, decoded, rendered } = createFakeCodec({
frameCount: 1,
encoded: [compressed],
});
rendered.dispose = vi.fn(() => {
throw new Error('rendered cleanup failed');
});
decoded.dispose = vi.fn(() => {
throw new Error('decoded cleanup failed');
});
await expect(processImageAttachment(
new File(['original'], 'photo.avif', { type: 'image/avif' }),
codec,
)).resolves.toMatchObject({
status: 'compressed',
defaultVariant: 'compressed',
compressed: { blob: compressed },
});
expect(rendered.dispose).toHaveBeenCalledTimes(1);
expect(decoded.dispose).toHaveBeenCalledTimes(1);
});
it('preserves an unchanged result when decoded cleanup throws', async () => {
const { codec, decoded } = createFakeCodec({
frameCount: 1,
width: 1280,
height: 720,
});
decoded.dispose = vi.fn(() => {
throw new Error('decoded cleanup failed');
});
await expect(processImageAttachment(
new File(['original'], 'small.jpg', { type: 'image/jpeg' }),
codec,
)).resolves.toMatchObject({
status: 'unchanged',
defaultVariant: 'original',
original: { width: 1280, height: 720 },
});
expect(decoded.dispose).toHaveBeenCalledTimes(1);
});
});
describe('browserImageCodec', () => {
it('passes a Blob stream to ImageDecoder without buffering the whole source', async () => {
const stream = {} as ReadableStream<Uint8Array>;
const blob = new Blob(['png'], { type: 'image/png' });
const arrayBuffer = vi.spyOn(blob, 'arrayBuffer');
Object.defineProperty(blob, 'stream', {
configurable: true,
value: vi.fn(() => stream),
});
let decoderData: unknown;
class FakeImageDecoder {
static isTypeSupported = vi.fn(async () => true);
tracks = {
ready: Promise.resolve(),
selectedTrack: { frameCount: 1 },
};
close = vi.fn();
constructor(init: { data: unknown }) {
decoderData = init.data;
}
}
vi.stubGlobal('ImageDecoder', FakeImageDecoder);
try {
await expect(browserImageCodec.inspectFrameCount(
blob,
'image/png',
)).resolves.toBe(1);
expect(decoderData).toBe(stream);
expect(arrayBuffer).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it('does not create a fallback ImageDecoder after buffering is aborted', async () => {
const buffered = createDeferred<ArrayBuffer>();
const blob = new Blob(['png'], { type: 'image/png' });
const arrayBuffer = vi.spyOn(blob, 'arrayBuffer')
.mockImplementation(() => buffered.promise);
Object.defineProperty(blob, 'stream', {
configurable: true,
value: vi.fn(() => {
throw new TypeError('ReadableStream input is unsupported');
}),
});
const construct = vi.fn();
class FakeImageDecoder {
static isTypeSupported = vi.fn(async () => true);
tracks = {
ready: Promise.resolve(),
selectedTrack: { frameCount: 1 },
};
close = vi.fn();
constructor() {
construct();
}
}
vi.stubGlobal('ImageDecoder', FakeImageDecoder);
const controller = new AbortController();
try {
const inspected = browserImageCodec.inspectFrameCount(
blob,
'image/png',
controller.signal,
);
await vi.waitFor(() => expect(arrayBuffer).toHaveBeenCalledTimes(1));
controller.abort();
buffered.resolve(new ArrayBuffer(3));
await expect(inspected).rejects.toMatchObject({ name: 'AbortError' });
expect(construct).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it('closes ImageDecoder and rejects when frame inspection is aborted', async () => {
const ready = createDeferred<void>();
const close = vi.fn();
const construct = vi.fn();
class FakeImageDecoder {
static isTypeSupported = vi.fn(async () => true);
tracks = {
ready: ready.promise,
selectedTrack: { frameCount: 1 },
};
close = close;
constructor() {
construct();
}
}
vi.stubGlobal('ImageDecoder', FakeImageDecoder);
const controller = new AbortController();
try {
const inspected = browserImageCodec.inspectFrameCount(
new Blob(['png'], { type: 'image/png' }),
'image/png',
controller.signal,
);
await vi.waitFor(() => expect(construct).toHaveBeenCalledTimes(1));
controller.abort();
ready.resolve();
await expect(inspected).rejects.toMatchObject({ name: 'AbortError' });
expect(close).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllGlobals();
}
});
it('closes a late ImageBitmap when decode is aborted', async () => {
const bitmap = {
width: 640,
height: 480,
close: vi.fn(),
};
const pendingBitmap = createDeferred<typeof bitmap>();
vi.stubGlobal('createImageBitmap', vi.fn(() => pendingBitmap.promise));
const controller = new AbortController();
try {
const decoded = browserImageCodec.decode(
new Blob(['image'], { type: 'image/png' }),
{ imageOrientation: 'from-image' },
controller.signal,
);
controller.abort();
pendingBitmap.resolve(bitmap);
await expect(decoded).rejects.toMatchObject({ name: 'AbortError' });
expect(bitmap.close).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllGlobals();
}
});
it('revokes the fallback image URL when decode is aborted', async () => {
const createObjectURL = vi.fn(() => 'blob:pending-image');
const revokeObjectURL = vi.fn();
class PendingImage {
decoding = '';
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
src = '';
}
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
vi.stubGlobal('Image', PendingImage);
vi.stubGlobal('createImageBitmap', undefined);
const controller = new AbortController();
try {
const decoded = browserImageCodec.decode(
new Blob(['image'], { type: 'image/png' }),
{ imageOrientation: 'from-image' },
controller.signal,
);
controller.abort();
await expect(decoded).rejects.toMatchObject({ name: 'AbortError' });
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:pending-image');
} finally {
vi.unstubAllGlobals();
}
});
it('rejects a pending canvas encoder when aborted', async () => {
let finishEncoding!: BlobCallback;
const rendered: RenderedImage = {
canvas: {
toBlob: vi.fn((callback: BlobCallback) => {
finishEncoding = callback;
}),
} as unknown as HTMLCanvasElement,
hasAlpha: false,
dispose: vi.fn(),
};
const controller = new AbortController();
const encoded = browserImageCodec.encode(
rendered,
{ mimeType: 'image/png' },
controller.signal,
);
controller.abort();
finishEncoding(new Blob(['late'], { type: 'image/png' }));
await expect(encoded).rejects.toMatchObject({ name: 'AbortError' });
});
it.each(['image/jpeg', 'image/bmp'] as const)(
'marks animation-capable bytes mislabeled as %s as unverified',
async (mimeType) => {
const staticHeader = mimeType === 'image/jpeg'
? new Uint8Array([0xff, 0xd8, 0xff])
: new Uint8Array([0x42, 0x4d]);
const webpHeader = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0,
0x57, 0x45, 0x42, 0x50,
]);
await expect(browserImageCodec.inspectFrameCount(
new Blob([staticHeader], { type: mimeType }),
mimeType,
)).resolves.toBe(1);
await expect(browserImageCodec.inspectFrameCount(
new Blob([webpHeader], { type: mimeType }),
mimeType,
)).resolves.toBeNull();
},
);
it('closes ImageDecoder after reading a verified animated-capable raster', async () => {
const close = vi.fn();
class FakeImageDecoder {
static isTypeSupported = vi.fn(async () => true);
tracks = {
ready: Promise.resolve(),
selectedTrack: { frameCount: 2 },
};
close = close;
}
vi.stubGlobal('ImageDecoder', FakeImageDecoder);
try {
await expect(browserImageCodec.inspectFrameCount(
new Blob(['png'], { type: 'image/png' }),
'image/png',
)).resolves.toBe(2);
expect(FakeImageDecoder.isTypeSupported).toHaveBeenCalledWith('image/png');
expect(close).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllGlobals();
}
});
it('detects alpha from canvas pixels and releases the canvas allocation', () => {
const context = {
clearRect: vi.fn(),
drawImage: vi.fn(),
getImageData: vi.fn(() => ({
data: new Uint8ClampedArray([0, 0, 0, 254]),
})),
} as unknown as CanvasRenderingContext2D;
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => context),
} as unknown as HTMLCanvasElement;
const createElement = vi.spyOn(document, 'createElement').mockReturnValue(canvas);
try {
const rendered = browserImageCodec.render({
source: {} as CanvasImageSource,
width: 2,
height: 1,
dispose: vi.fn(),
}, { width: 2, height: 1 });
expect(createElement).toHaveBeenCalledWith('canvas');
expect(rendered.hasAlpha).toBe(true);
expect(canvas.width).toBe(2);
rendered.dispose();
expect(canvas.width).toBe(1);
expect(canvas.height).toBe(1);
} finally {
createElement.mockRestore();
}
});
it('rejects an encoder result with a MIME that differs from the candidate', async () => {
const toBlob = vi.fn((callback: BlobCallback) => {
callback(new Blob(['png'], { type: 'image/png' }));
});
const rendered: RenderedImage = {
canvas: { toBlob } as unknown as HTMLCanvasElement,
hasAlpha: false,
dispose: vi.fn(),
};
await expect(browserImageCodec.encode(rendered, {
mimeType: 'image/jpeg',
quality: 0.85,
})).resolves.toBeNull();
expect(toBlob).toHaveBeenCalledWith(
expect.any(Function),
'image/jpeg',
0.85,
);
});
it('revokes the fallback image URL exactly once when Image setup throws', async () => {
const createObjectURL = vi.fn(() => 'blob:test-image');
const revokeObjectURL = vi.fn();
class ThrowingImage {
constructor() {
throw new Error('Image setup failed');
}
}
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
vi.stubGlobal('Image', ThrowingImage);
vi.stubGlobal('createImageBitmap', undefined);
try {
await expect(browserImageCodec.decode(
new Blob(['image'], { type: 'image/png' }),
{ imageOrientation: 'from-image' },
)).rejects.toThrow('Image setup failed');
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-image');
} finally {
vi.unstubAllGlobals();
}
});
});