Files
hermes-agent/apps/desktop/src/lib/image-resize.test.ts
T
2026-08-15 01:03:57 -07:00

200 lines
6.8 KiB
TypeScript
Raw 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 { afterEach, describe, expect, it, vi } from 'vitest'
import { downscaleDataUrlForPreview } from './image-resize'
// A minimal valid 1x1 red PNG (67 bytes) for testing
const TINY_PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII='
const TINY_PNG_DATA_URL = `data:image/png;base64,${TINY_PNG_B64}`
// A 1×1 transparent PNG used as the fallback placeholder
const FALLBACK_PLACEHOLDER =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII='
describe('downscaleDataUrlForPreview', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('without createImageBitmap (jsdom default)', () => {
it('fails closed when createImageBitmap is unavailable', async () => {
await expect(downscaleDataUrlForPreview('data:image/png;base64,ZmFrZQ==')).resolves.toBe(FALLBACK_PLACEHOLDER)
})
it('preserves an external source when resize APIs are unavailable', async () => {
await expect(downscaleDataUrlForPreview('not-a-data-url')).resolves.toBe('not-a-data-url')
})
it('preserves a non-image data URL when resize APIs are unavailable', async () => {
await expect(downscaleDataUrlForPreview('data:text/plain')).resolves.toBe('data:text/plain')
})
})
describe('with mocked createImageBitmap + OffscreenCanvas', () => {
/**
* Set up the full mock chain: fetch → createImageBitmap → OffscreenCanvas → FileReader.
* Mocks a bitmap of the given dimensions so the downscaling logic is exercised.
*/
function setupMocks(bitmapWidth: number, bitmapHeight: number) {
let activeBitmaps = 0
let maxActiveBitmaps = 0
const close = vi.fn(() => {
activeBitmaps -= 1
})
const drawImage = vi.fn()
const convertToBlob = vi.fn(async () => new Blob(['x'], { type: 'image/png' }))
const canvasSizes: [number, number][] = []
const bitmap = { width: bitmapWidth, height: bitmapHeight, close }
const ctx = { drawImage }
class MockOffscreenCanvas {
getContext = vi.fn(() => ctx)
convertToBlob = convertToBlob
constructor(width: number, height: number) {
canvasSizes.push([width, height])
}
}
// Mock fetch to return a Blob from the data URL
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
}))
)
vi.stubGlobal(
'createImageBitmap',
vi.fn(async () => {
activeBitmaps += 1
maxActiveBitmaps = Math.max(maxActiveBitmaps, activeBitmaps)
return bitmap
})
)
vi.stubGlobal('OffscreenCanvas', MockOffscreenCanvas)
return { bitmap, close, drawImage, convertToBlob, ctx, canvasSizes, maxActiveBitmaps: () => maxActiveBitmaps }
}
it('returns the original when image is smaller than maxLongEdge', async () => {
setupMocks(500, 300)
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
expect(result).toBe(TINY_PNG_DATA_URL)
})
it('downscales when image exceeds the default preview edge', async () => {
const { drawImage, close } = setupMocks(4000, 3000)
// In jsdom, FileReader.readAsDataURL won't actually produce a data URL,
// so the function falls through to the fallback placeholder. But the
// important thing is that drawImage was called with scaled dimensions
// and the bitmap was closed.
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL)
// scale = 512 / 4000 = 0.128 → width=512, height=384
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 512, 384)
expect(close).toHaveBeenCalled()
// Result should be the downscaled data URL (from our mocked blob),
// NOT the original tiny PNG — the function attempted downscaling.
expect(result).not.toBe(TINY_PNG_DATA_URL)
// The drawImage was called with correct dimensions and bitmap was cleaned up
expect(drawImage).toHaveBeenCalled()
expect(close).toHaveBeenCalled()
})
it('keeps every thumbnail bounded for a 72-image composer prompt', async () => {
const { canvasSizes, drawImage, maxActiveBitmaps } = setupMocks(6000, 6000)
await Promise.all(Array.from({ length: 72 }, () => downscaleDataUrlForPreview(TINY_PNG_DATA_URL)))
expect(canvasSizes).toHaveLength(72)
expect(canvasSizes.every(([width, height]) => width === 512 && height === 512)).toBe(true)
expect(drawImage).toHaveBeenCalledTimes(72)
expect(maxActiveBitmaps()).toBe(1)
})
it('returns placeholder when createImageBitmap throws', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
}))
)
vi.stubGlobal(
'createImageBitmap',
vi.fn(async () => {
throw new Error('decode failed')
})
)
vi.stubGlobal(
'OffscreenCanvas',
vi.fn(() => ({}))
)
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
expect(result).toBe(FALLBACK_PLACEHOLDER)
})
it('closes bitmap even when canvas getContext returns null', async () => {
const close = vi.fn()
const bitmap = { width: 4000, height: 3000, close }
class NullCanvas {
getContext = () => null
constructor(_w: number, _h: number) {}
}
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
}))
)
vi.stubGlobal(
'createImageBitmap',
vi.fn(async () => bitmap)
)
vi.stubGlobal('OffscreenCanvas', NullCanvas)
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
expect(result).toBe(FALLBACK_PLACEHOLDER)
expect(close).toHaveBeenCalled()
})
it('uses placeholder instead of original on convertToBlob failure', async () => {
const close = vi.fn()
const bitmap = { width: 4000, height: 3000, close }
class BrokenCanvas {
getContext = () => ({ drawImage: vi.fn() })
convertToBlob = vi.fn(async () => {
throw new Error('blob failed')
})
constructor(_w: number, _h: number) {}
}
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
}))
)
vi.stubGlobal(
'createImageBitmap',
vi.fn(async () => bitmap)
)
vi.stubGlobal('OffscreenCanvas', BrokenCanvas)
const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
expect(result).toBe(FALLBACK_PLACEHOLDER)
expect(close).toHaveBeenCalled()
})
})
})