Makelore 2.0 initial clean snapshot
This commit is contained in:
829
tests/unit/image-attachment-compression.test.ts
Normal file
829
tests/unit/image-attachment-compression.test.ts
Normal file
@@ -0,0 +1,829 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user