830 lines
25 KiB
TypeScript
830 lines
25 KiB
TypeScript
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();
|
||
}
|
||
});
|
||
});
|