178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
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)
|
||
})
|
||
})
|