test/ocr-job.test.ts (10575 bytes)
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 import type { OcrToken } from '../src/align'; 3 import { 4 createOcrJob, 5 OcrStartError, 6 OcrTimeoutError, 7 pageTimeLimit, 8 START_LIMIT_MS, 9 stopOcr, 10 type OcrWorker, 11 } from '../src/ocr-job'; 12 13 /** The reasons of the unhandled rejections while `run` runs, and 20 ms after it. */ 14 async function unhandledDuring(run: () => void): Promise<unknown[]> { 15 const reasons: unknown[] = []; 16 const record = (reason: unknown): void => { 17 reasons.push(reason); 18 }; 19 process.on('unhandledRejection', record); 20 try { 21 run(); 22 await new Promise((resolve) => setTimeout(resolve, 20)); 23 } finally { 24 process.off('unhandledRejection', record); 25 } 26 return reasons; 27 } 28 29 describe('stopOcr', () => { 30 afterEach(() => vi.restoreAllMocks()); 31 32 it('stops the OCR that started', async () => { 33 const terminate = vi.fn(() => Promise.resolve()); 34 stopOcr(Promise.resolve({ terminate })); 35 await new Promise((resolve) => setTimeout(resolve, 0)); 36 expect(terminate).toHaveBeenCalledTimes(1); 37 }); 38 39 it('does nothing when no OCR started', () => { 40 expect(() => stopOcr(undefined)).not.toThrow(); 41 }); 42 43 it('gives no unhandled rejection when the OCR could not start', async () => { 44 // The web finding: the reading ended with an unhandled rejection, and the 45 // progress stayed in the strip. 46 const failed = Promise.reject(new OcrStartError('TypeError: Failed to fetch')); 47 expect(await unhandledDuring(() => void stopOcr(failed))).toEqual([]); 48 }); 49 50 it('gives no unhandled rejection when the stop fails', async () => { 51 const terminate = vi.fn(() => Promise.reject(new Error('The worker is gone.'))); 52 expect(await unhandledDuring(() => void stopOcr(Promise.resolve({ terminate })))).toEqual([]); 53 }); 54 }); 55 56 describe('pageTimeLimit', () => { 57 it('gives a page of the app 120 s', () => { 58 expect(pageTimeLimit(1600 * 2263)).toBe(120_000); 59 expect(pageTimeLimit(1)).toBe(120_000); 60 }); 61 62 it('gives a larger page more time, in proportion to its area', () => { 63 expect(pageTimeLimit(8_000_000)).toBe(240_000); 64 expect(pageTimeLimit(1600 * 10_000)).toBe(480_000); 65 }); 66 }); 67 68 /** A token of page `page`, so that a result shows its page. */ 69 function token(page: number): OcrToken { 70 return { text: 'a', page, line: 0, word: 0, bbox: { x0: 0, y0: 0, x1: 1, y1: 1 } }; 71 } 72 73 /** A fake OCR worker. `read` gives the result of each page. */ 74 function fakeWorker(read: (page: number) => Promise<OcrToken[]>) { 75 return { 76 recognize: vi.fn((_image: string, page: number) => read(page)), 77 terminate: vi.fn(() => Promise.resolve()), 78 } satisfies OcrWorker<string>; 79 } 80 81 /** A page that never ends: tesseract.js after a crash of its worker. */ 82 const never = (): Promise<OcrToken[]> => new Promise(() => undefined); 83 84 const readPage = (page: number): Promise<OcrToken[]> => Promise.resolve([token(page)]); 85 86 /** A fake worker whose page rejects when the worker stops, as in src/ocr.ts. */ 87 function stoppableWorker() { 88 let stop: (reason: Error) => void = () => undefined; 89 const stopped = new Promise<never>((_resolve, reject) => { 90 stop = reject; 91 }); 92 stopped.catch(() => undefined); 93 const worker = fakeWorker(() => stopped); 94 worker.terminate.mockImplementation(() => { 95 stop(new Error('OCR stopped.')); 96 return Promise.resolve(); 97 }); 98 return worker; 99 } 100 101 describe('createOcrJob', () => { 102 beforeEach(() => vi.useFakeTimers()); 103 afterEach(() => vi.useRealTimers()); 104 105 it('stops a worker whose page runs too long, and reads the next page with a new worker', async () => { 106 // The finding: a crashed tesseract worker does not settle its page, and 107 // the reading waited for ever. 108 const hung = fakeWorker(never); 109 const next = fakeWorker(readPage); 110 const start = vi 111 .fn<() => Promise<OcrWorker<string>>>() 112 .mockResolvedValueOnce(hung) 113 .mockResolvedValueOnce(next); 114 const job = createOcrJob(start); 115 let settled = false; 116 const page = job 117 .recognize('page 1', 0, 1000) 118 .catch((e: unknown) => e) 119 .finally(() => { 120 settled = true; 121 }); 122 await vi.advanceTimersByTimeAsync(999); 123 expect(settled).toBe(false); 124 expect(hung.terminate).not.toHaveBeenCalled(); 125 await vi.advanceTimersByTimeAsync(1); 126 const error = await page; 127 expect(error).toBeInstanceOf(OcrTimeoutError); 128 expect((error as Error).message).toBe('The OCR of page 1 took more than 1 s.'); 129 expect(hung.terminate).toHaveBeenCalledTimes(1); 130 await expect(job.recognize('page 2', 1, 1000)).resolves.toEqual([token(1)]); 131 expect(start).toHaveBeenCalledTimes(2); 132 expect(vi.getTimerCount()).toBe(0); 133 }); 134 135 it('stops a worker whose page fails, and reads the next page with a new worker', async () => { 136 // An error event of the worker, or an abort of its WebAssembly, rejects the page. 137 const failed = fakeWorker(() => Promise.reject(new Error('The OCR worker failed.'))); 138 const next = fakeWorker(readPage); 139 const start = vi 140 .fn<() => Promise<OcrWorker<string>>>() 141 .mockResolvedValueOnce(failed) 142 .mockResolvedValueOnce(next); 143 const job = createOcrJob(start); 144 await expect(job.recognize('page 1', 0, 1000)).rejects.toThrow('The OCR worker failed.'); 145 expect(failed.terminate).toHaveBeenCalledTimes(1); 146 await expect(job.recognize('page 2', 1, 1000)).resolves.toEqual([token(1)]); 147 expect(start).toHaveBeenCalledTimes(2); 148 expect(vi.getTimerCount()).toBe(0); 149 }); 150 151 it('keeps the worker for the next page when a page is read in time', async () => { 152 const worker = fakeWorker(readPage); 153 const start = vi.fn(() => Promise.resolve(worker)); 154 const job = createOcrJob(start); 155 await job.start(); 156 await expect(job.recognize('page 1', 0, 1000)).resolves.toEqual([token(0)]); 157 await expect(job.recognize('page 2', 1, 1000)).resolves.toEqual([token(1)]); 158 expect(start).toHaveBeenCalledTimes(1); 159 expect(worker.terminate).not.toHaveBeenCalled(); 160 expect(vi.getTimerCount()).toBe(0); 161 }); 162 163 it('gives each page the start error, and does not start again', async () => { 164 const error = new OcrStartError('TypeError: Failed to fetch'); 165 const start = vi.fn(() => Promise.reject(error)); 166 const job = createOcrJob(start); 167 await expect(job.start()).rejects.toBe(error); 168 await expect(job.recognize('page 1', 0, 1000)).rejects.toBe(error); 169 expect(start).toHaveBeenCalledTimes(1); 170 }); 171 172 /** A start that never ends, as a stalled download. It keeps the signal of each start. */ 173 function stalledStart() { 174 const signals: AbortSignal[] = []; 175 const start = vi.fn((signal: AbortSignal) => { 176 signals.push(signal); 177 return new Promise<OcrWorker<string>>(() => undefined); 178 }); 179 return { start, signals }; 180 } 181 182 it('gives the start error when the start takes longer than its limit, and stops the start', async () => { 183 // The finding: a download of the language data that stalls (a captive 184 // portal) held the start, and so the reading, for ever, with no Retry. 185 const { start, signals } = stalledStart(); 186 const job = createOcrJob(start, 1000); 187 let settled = false; 188 const started = job 189 .start() 190 .catch((e: unknown) => e) 191 .finally(() => { 192 settled = true; 193 }); 194 await vi.advanceTimersByTimeAsync(999); 195 expect(settled).toBe(false); 196 await vi.advanceTimersByTimeAsync(1); 197 expect(settled).toBe(true); 198 const error = await started; 199 expect(error).toBeInstanceOf(OcrStartError); 200 expect((error as Error).message).toBe( 201 'OCR could not start: Error: The start took more than 1 s.', 202 ); 203 // The start of the worker gets the stop, so that it can stop its worker. 204 expect(signals[0]?.aborted).toBe(true); 205 expect(signals[0]?.reason).toBe(error); 206 // Each page gets the same start error. No new start runs. 207 await expect(job.recognize('page 1', 0, 1000)).rejects.toBe(error); 208 expect(start).toHaveBeenCalledTimes(1); 209 expect(vi.getTimerCount()).toBe(0); 210 }); 211 212 it('gives the start error also where abort() keeps no reason (WebView before 98)', async () => { 213 // abort(reason) and AbortSignal.reason came in Chrome 98. The SIMD core of 214 // the app runs from WebView 91. 215 const abort = AbortController.prototype.abort; 216 const noReason = vi.spyOn(AbortController.prototype, 'abort').mockImplementation(function ( 217 this: AbortController, 218 ) { 219 abort.call(this); 220 }); 221 try { 222 const { start } = stalledStart(); 223 const job = createOcrJob(start, 1000); 224 const started = job.start().catch((e: unknown) => e); 225 await vi.advanceTimersByTimeAsync(1000); 226 expect(await started).toBeInstanceOf(OcrStartError); 227 } finally { 228 noReason.mockRestore(); 229 } 230 }); 231 232 it('stops a worker that starts after the start limit', async () => { 233 const worker = fakeWorker(readPage); 234 const start = vi.fn( 235 () => new Promise<OcrWorker<string>>((resolve) => setTimeout(() => resolve(worker), 2000)), 236 ); 237 const job = createOcrJob(start, 1000); 238 const started = job.start().catch((e: unknown) => e); 239 await vi.advanceTimersByTimeAsync(1000); 240 expect(await started).toBeInstanceOf(OcrStartError); 241 expect(worker.terminate).not.toHaveBeenCalled(); 242 await vi.advanceTimersByTimeAsync(1000); 243 expect(worker.terminate).toHaveBeenCalledTimes(1); 244 }); 245 246 it('stops a start that runs when the job stops', async () => { 247 const { start, signals } = stalledStart(); 248 const job = createOcrJob(start, 1000); 249 const started = job.start().catch((e: unknown) => e); 250 job.stop(); 251 expect(signals[0]?.aborted).toBe(true); 252 expect(await started).toEqual(new Error('OCR stopped.')); 253 expect(vi.getTimerCount()).toBe(0); 254 }); 255 256 it('gives the start the limit of a page by default', () => { 257 expect(START_LIMIT_MS).toBe(pageTimeLimit(0)); 258 }); 259 260 it('rejects the page that runs on stop, stops the worker once, and starts no new worker', async () => { 261 const worker = stoppableWorker(); 262 const start = vi.fn(() => Promise.resolve(worker)); 263 const job = createOcrJob(start); 264 const page = job.recognize('page 1', 0, 1000).catch((e: unknown) => e); 265 await vi.advanceTimersByTimeAsync(0); 266 job.stop(); 267 expect(await page).toEqual(new Error('OCR stopped.')); 268 expect(worker.terminate).toHaveBeenCalledTimes(1); 269 await expect(job.recognize('page 2', 1, 1000)).rejects.toThrow('OCR stopped.'); 270 expect(start).toHaveBeenCalledTimes(1); 271 expect(vi.getTimerCount()).toBe(0); 272 }); 273 });