pdf-runtime.client.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. // @vitest-environment jsdom
  2. /** Browser Worker ownership, handshake, and PDF.js teardown under controlled completions. */
  3. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  4. import type { PdfDocument, PdfSession } from '../src/client/pdf/document.ts'
  5. const api = vi.hoisted(() => ({ getDocument: vi.fn(), createWorker: vi.fn(), destroyBridge: vi.fn() }))
  6. vi.mock('pdfjs-dist', () => ({ getDocument: api.getDocument, PDFWorker: { create: api.createWorker } }))
  7. vi.mock('../src/client/pdf/assets.ts', () => ({
  8. workerSource: 'export const WorkerMessageHandler = {}',
  9. createPdfBinaryDataFactory: () => class { fetch() { return Promise.resolve(new Uint8Array()) } },
  10. }))
  11. import { openPdf } from '../src/client/pdf/runtime.ts'
  12. import { PdfWorkerFailure } from '../src/client/pdf/errors.ts'
  13. const NativeURL = URL
  14. const workers: ControlledWorker[] = []
  15. const sessions: PdfSession[] = []
  16. const releases: Array<() => void> = []
  17. const createURL = vi.fn(() => 'blob:pdf-worker')
  18. const revokeURL = vi.fn()
  19. class ControlledWorker extends EventTarget {
  20. readonly terminate = vi.fn()
  21. constructor(readonly url: string, readonly options: WorkerOptions) {
  22. super()
  23. workers.push(this)
  24. }
  25. ready(): void { this.dispatchEvent(new MessageEvent('message', { data: { type: 'dsh-pdf-worker-ready' } })) }
  26. }
  27. beforeEach(() => {
  28. workers.length = 0
  29. sessions.length = 0
  30. releases.length = 0
  31. vi.clearAllMocks()
  32. api.createWorker.mockReturnValue({ destroy: api.destroyBridge })
  33. vi.stubGlobal('Worker', ControlledWorker)
  34. vi.stubGlobal('URL', class extends NativeURL {
  35. static override createObjectURL = createURL
  36. static override revokeObjectURL = revokeURL
  37. })
  38. })
  39. afterEach(async () => {
  40. try {
  41. for (const release of releases) release()
  42. await Promise.allSettled(sessions.map(session => session.dispose()))
  43. } finally {
  44. vi.unstubAllGlobals()
  45. vi.restoreAllMocks()
  46. }
  47. })
  48. function setup(controller = new AbortController(), failed = vi.fn()) {
  49. const loading = Promise.withResolvers<PdfDocument>()
  50. const entered = Promise.withResolvers<unknown>()
  51. const destroy = vi.fn(async () => {})
  52. api.getDocument.mockImplementation((options: unknown) => {
  53. entered.resolve(options)
  54. return { promise: loading.promise, destroy }
  55. })
  56. const data = new TextEncoder().encode('%PDF-test')
  57. const session = openPdf(data, controller.signal, failed)
  58. sessions.push(session)
  59. void session.document.catch(() => {})
  60. return { loading, entered, destroy, controller, failed, session, data }
  61. }
  62. describe('PDF Worker lifecycle', () => {
  63. it('starts a real module-worker port before calling getDocument with complete bytes and local assets', async () => {
  64. const h = setup()
  65. expect(workers[0]!.options).toEqual({ type: 'module', name: 'dsh-pdf' })
  66. expect(api.getDocument).not.toHaveBeenCalled()
  67. workers[0]!.ready()
  68. const options = await h.entered.promise as { data: Uint8Array<ArrayBuffer>; BinaryDataFactory: unknown }
  69. expect(api.createWorker).toHaveBeenCalledWith({ port: workers[0] })
  70. expect(options).toMatchObject({
  71. useWorkerFetch: false, enableXfa: false, cMapPacked: true, isEvalSupported: false,
  72. })
  73. expect(typeof options.BinaryDataFactory).toBe('function')
  74. expect(Array.from(options.data)).toEqual([37, 80, 68, 70, 45, 116, 101, 115, 116])
  75. expect(options.data).not.toBe(h.data)
  76. expect(options.data.buffer).not.toBe(h.data.buffer)
  77. const transferred = structuredClone(options.data, { transfer: [options.data.buffer] })
  78. expect(options.data.byteLength).toBe(0)
  79. expect(Array.from(h.data)).toEqual(Array.from(transferred))
  80. expect(new TextDecoder().decode(h.data)).toBe('%PDF-test')
  81. const document = { numPages: 2, getPage: vi.fn() }
  82. h.loading.resolve(document)
  83. await expect(h.session.document).resolves.toBe(document)
  84. await h.session.dispose()
  85. await h.session.dispose()
  86. expect(h.destroy).toHaveBeenCalledOnce()
  87. expect(api.destroyBridge).toHaveBeenCalledOnce()
  88. expect(workers[0]!.terminate).toHaveBeenCalledOnce()
  89. expect(revokeURL).toHaveBeenCalledExactlyOnceWith('blob:pdf-worker')
  90. })
  91. it('cancels startup before the worker handshake without invoking PDF.js', async () => {
  92. const h = setup()
  93. h.controller.abort()
  94. await expect(h.session.document).rejects.toMatchObject({ name: 'AbortError' })
  95. await h.session.dispose()
  96. workers[0]!.ready()
  97. expect(api.getDocument).not.toHaveBeenCalled()
  98. expect(workers[0]!.terminate).toHaveBeenCalledOnce()
  99. expect(revokeURL).toHaveBeenCalledOnce()
  100. })
  101. it('awaits document destruction before terminating a healthy worker and rejects a late load', async () => {
  102. const h = setup()
  103. workers[0]!.ready()
  104. await h.entered.promise
  105. const destruction = Promise.withResolvers<undefined>()
  106. const destroying = Promise.withResolvers<undefined>()
  107. releases.push(() => { destruction.resolve(undefined) })
  108. h.destroy.mockImplementation(async () => { destroying.resolve(undefined); await destruction.promise })
  109. h.controller.abort()
  110. await destroying.promise
  111. expect(workers[0]!.terminate).not.toHaveBeenCalled()
  112. h.loading.resolve({ numPages: 99, getPage: vi.fn() })
  113. destruction.resolve(undefined)
  114. await expect(h.session.document).rejects.toMatchObject({ name: 'AbortError' })
  115. await h.session.dispose()
  116. expect(workers[0]!.terminate).toHaveBeenCalledOnce()
  117. })
  118. it('reports worker startup failure and releases its Blob without a fake-worker fallback', async () => {
  119. const h = setup()
  120. const error = new ErrorEvent('error', { message: 'worker blocked' })
  121. workers[0]!.dispatchEvent(error)
  122. await expect(h.session.document).rejects.toMatchObject({ kind: 'worker', cause: error })
  123. expect(h.failed).toHaveBeenCalledOnce()
  124. expect(api.getDocument).not.toHaveBeenCalled()
  125. expect(workers[0]!.terminate).toHaveBeenCalledOnce()
  126. expect(revokeURL).toHaveBeenCalledOnce()
  127. })
  128. it('reports a worker crash after loading and releases the document and worker', async () => {
  129. const h = setup()
  130. workers[0]!.ready()
  131. await h.entered.promise
  132. h.loading.resolve({ numPages: 1, getPage: vi.fn() })
  133. await h.session.document
  134. const error = new ErrorEvent('error', { message: 'worker crashed' })
  135. workers[0]!.dispatchEvent(error)
  136. await h.session.dispose()
  137. expect(h.failed).toHaveBeenCalledWith(expect.objectContaining({ kind: 'worker', cause: error }))
  138. expect(h.destroy).toHaveBeenCalledOnce()
  139. expect(api.destroyBridge).toHaveBeenCalledOnce()
  140. expect(workers[0]!.terminate).toHaveBeenCalledOnce()
  141. })
  142. it('allocates nothing for an already-ended document lifetime', async () => {
  143. const controller = new AbortController()
  144. controller.abort()
  145. const h = setup(controller)
  146. await expect(h.session.document).rejects.toMatchObject({ name: 'AbortError' })
  147. await h.session.dispose()
  148. expect(workers).toEqual([])
  149. expect(createURL).not.toHaveBeenCalled()
  150. expect(api.getDocument).not.toHaveBeenCalled()
  151. })
  152. it('ignores unrelated Worker wire messages until its own startup acknowledgement', async () => {
  153. const h = setup()
  154. for (const data of [null, 'noise', { action: 'ready', sourceName: 'worker' }, { type: 'other-worker' }]) {
  155. workers[0]!.dispatchEvent(new MessageEvent('message', { data }))
  156. }
  157. expect(api.getDocument).not.toHaveBeenCalled()
  158. workers[0]!.ready()
  159. await h.entered.promise
  160. h.loading.resolve({ numPages: 1, getPage: vi.fn() })
  161. await h.session.document
  162. })
  163. it('terminates after messageerror even when the failure callback throws', async () => {
  164. const callbackError = new Error('listener failed')
  165. const failed = vi.fn(() => { throw callbackError })
  166. const log = vi.spyOn(console, 'error').mockImplementation(() => {})
  167. const h = setup(new AbortController(), failed)
  168. workers[0]!.dispatchEvent(new MessageEvent('messageerror'))
  169. await expect(h.session.document).rejects.toBeInstanceOf(PdfWorkerFailure)
  170. await h.session.dispose()
  171. expect(log).toHaveBeenCalledExactlyOnceWith('[pdf] failure callback threw', callbackError)
  172. expect(workers[0]!.terminate).toHaveBeenCalledOnce()
  173. expect(revokeURL).toHaveBeenCalledOnce()
  174. })
  175. it('suppresses late failure reports and finishes abort cleanup when the worker crashes', async () => {
  176. const h = setup()
  177. workers[0]!.ready()
  178. await h.entered.promise
  179. h.loading.resolve({ numPages: 1, getPage: vi.fn() })
  180. await h.session.document
  181. const destruction = Promise.withResolvers<undefined>()
  182. const destroying = Promise.withResolvers<undefined>()
  183. releases.push(() => { destruction.resolve(undefined) })
  184. h.destroy.mockImplementation(async () => { destroying.resolve(undefined); await destruction.promise })
  185. h.controller.abort()
  186. await destroying.promise
  187. workers[0]!.dispatchEvent(new MessageEvent('messageerror'))
  188. await h.session.dispose()
  189. expect(h.failed).not.toHaveBeenCalled()
  190. expect(workers[0]!.terminate).toHaveBeenCalledOnce()
  191. destruction.resolve(undefined)
  192. })
  193. it('logs a library teardown rejection after releasing the bridge, Worker, and Blob', async () => {
  194. const log = vi.spyOn(console, 'error').mockImplementation(() => {})
  195. const h = setup()
  196. workers[0]!.ready()
  197. await h.entered.promise
  198. h.loading.resolve({ numPages: 1, getPage: vi.fn() })
  199. await h.session.document
  200. const failure = new Error('terminate RPC rejected')
  201. h.destroy.mockRejectedValueOnce(failure)
  202. await expect(h.session.dispose()).resolves.toBeUndefined()
  203. expect(log).toHaveBeenCalledExactlyOnceWith('[pdf] cleanup failed', failure)
  204. expect(api.destroyBridge).toHaveBeenCalledOnce()
  205. expect(workers[0]!.terminate).toHaveBeenCalledOnce()
  206. expect(revokeURL).toHaveBeenCalledOnce()
  207. })
  208. it('revokes its source URL when browser policy refuses Worker construction', async () => {
  209. vi.stubGlobal('Worker', vi.fn(function blockedWorker() {
  210. throw new DOMException('Worker blocked by policy', 'SecurityError')
  211. }))
  212. const h = setup()
  213. await expect(h.session.document).rejects.toMatchObject({ name: 'SecurityError' })
  214. expect(revokeURL).toHaveBeenCalledOnce()
  215. expect(api.getDocument).not.toHaveBeenCalled()
  216. })
  217. })