import { PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS, preparePerformanceSurveyImagesInBrowser, } from '@/lib/performance-survey-image-browser' class FakeWorker { static instances: FakeWorker[] = [] static postMessageError: Error | null = null onmessage: ((event: MessageEvent) => void) | null = null onerror: ((event: ErrorEvent) => void) | null = null onmessageerror: ((event: MessageEvent) => void) | null = null postMessage = jest.fn((_message: unknown) => { if (FakeWorker.postMessageError) throw FakeWorker.postMessageError }) terminate = jest.fn() constructor() { FakeWorker.instances.push(this) } } describe('performance-survey-image-browser', () => { const originalWorker = Object.getOwnPropertyDescriptor(globalThis, 'Worker') const originalOffscreenCanvas = Object.getOwnPropertyDescriptor(globalThis, 'OffscreenCanvas') const originalCreateImageBitmap = Object.getOwnPropertyDescriptor(globalThis, 'createImageBitmap') beforeEach(() => { jest.useFakeTimers() FakeWorker.instances = [] FakeWorker.postMessageError = null Object.defineProperty(globalThis, 'Worker', { configurable: true, writable: true, value: FakeWorker, }) Object.defineProperty(globalThis, 'OffscreenCanvas', { configurable: true, writable: true, value: class FakeOffscreenCanvas {}, }) Object.defineProperty(globalThis, 'createImageBitmap', { configurable: true, writable: true, value: jest.fn(), }) }) afterEach(() => { jest.useRealTimers() if (originalWorker) Object.defineProperty(globalThis, 'Worker', originalWorker) else delete (globalThis as { Worker?: unknown }).Worker if (originalOffscreenCanvas) Object.defineProperty(globalThis, 'OffscreenCanvas', originalOffscreenCanvas) else delete (globalThis as { OffscreenCanvas?: unknown }).OffscreenCanvas if (originalCreateImageBitmap) Object.defineProperty(globalThis, 'createImageBitmap', originalCreateImageBitmap) else delete (globalThis as { createImageBitmap?: unknown }).createImageBitmap }) test('用户取消会立即终止 Worker,且不会被识别为服务器兜底错误', async () => { const controller = new AbortController() const removeListenerSpy = jest.spyOn(controller.signal, 'removeEventListener') const promise = preparePerformanceSurveyImagesInBrowser( [new File(['image'], 'photo.jpg', { type: 'image/jpeg' })], undefined, { profile: 'task_detail_preview', signal: controller.signal, timeoutMs: 60_000 }, ) const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' }) const worker = FakeWorker.instances[0] expect(worker).toBeDefined() controller.abort() await rejection expect(worker.terminate).toHaveBeenCalledTimes(1) expect(removeListenerSpy).toHaveBeenCalled() expect(jest.getTimerCount()).toBe(0) }) test('watchdog 超时会终止 Worker 并返回独立 WORKER_TIMEOUT', async () => { expect(PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS).toBe(10 * 60 * 1000) const promise = preparePerformanceSurveyImagesInBrowser( [new File(['image'], 'slow.jpg', { type: 'image/jpeg' })], undefined, { profile: 'task_detail_preview', timeoutMs: 1_000 }, ) const rejection = expect(promise).rejects.toMatchObject({ code: 'WORKER_TIMEOUT', message: expect.stringContaining('将改由服务器安全处理'), }) await jest.advanceTimersByTimeAsync(1_000) await rejection const worker = FakeWorker.instances[0] expect(worker.terminate).toHaveBeenCalledTimes(1) expect(worker.onmessage).toBeNull() expect(worker.onerror).toBeNull() expect(worker.onmessageerror).toBeNull() expect(jest.getTimerCount()).toBe(0) }) test('成功返回时也清理 watchdog、Abort listener 和 Worker', async () => { const controller = new AbortController() const removeListenerSpy = jest.spyOn(controller.signal, 'removeEventListener') const promise = preparePerformanceSurveyImagesInBrowser( [new File(['image'], 'success.jpg', { type: 'image/jpeg' })], undefined, { profile: 'task_detail_preview', signal: controller.signal, timeoutMs: 60_000 }, ) const worker = FakeWorker.instances[0] const request = worker.postMessage.mock.calls[0][0] as { id: string } worker.onmessage?.({ data: { id: request.id, ok: true, images: [{ blob: new Blob(['preview'], { type: 'image/webp' }), fileName: 'success-preview.webp', mimeType: 'image/webp', width: 800, height: 600, kind: 'preview', sourceName: 'success.jpg', sourceIndex: 0, sliceIndex: 0, sliceCount: 1, }], }, } as MessageEvent) await expect(promise).resolves.toEqual([ expect.objectContaining({ file: expect.any(File), sourceName: 'success.jpg', }), ]) expect(worker.terminate).toHaveBeenCalledTimes(1) expect(removeListenerSpy).toHaveBeenCalled() expect(jest.getTimerCount()).toBe(0) }) test('postMessage 失败时清理 watchdog、Abort listener 和 Worker', async () => { FakeWorker.postMessageError = new Error('structured clone failed') const controller = new AbortController() const removeListenerSpy = jest.spyOn(controller.signal, 'removeEventListener') const promise = preparePerformanceSurveyImagesInBrowser( [new File(['image'], 'clone-error.jpg', { type: 'image/jpeg' })], undefined, { profile: 'task_detail_preview', signal: controller.signal, timeoutMs: 60_000 }, ) await expect(promise).rejects.toMatchObject({ code: 'WORKER_UNAVAILABLE' }) const worker = FakeWorker.instances[0] expect(worker.terminate).toHaveBeenCalledTimes(1) expect(removeListenerSpy).toHaveBeenCalled() expect(jest.getTimerCount()).toBe(0) }) test('Worker 运行错误时只结算一次并清理资源', async () => { const promise = preparePerformanceSurveyImagesInBrowser( [new File(['image'], 'worker-error.jpg', { type: 'image/jpeg' })], undefined, { profile: 'task_detail_preview', timeoutMs: 60_000 }, ) const worker = FakeWorker.instances[0] const handleError = worker.onerror handleError?.({} as ErrorEvent) handleError?.({} as ErrorEvent) await expect(promise).rejects.toMatchObject({ code: 'WORKER_UNAVAILABLE' }) expect(worker.terminate).toHaveBeenCalledTimes(1) expect(jest.getTimerCount()).toBe(0) }) })