core-worker.spec.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. import { describe, expect, it, vi } from 'vitest';
  2. import { parseHTML } from 'linkedom';
  3. import {
  4. DEFAULT_FILE_VIEWER_RENDER_TARGET_CLASS,
  5. FILE_VIEWER_RENDER_SESSION_DISPOSE_ERROR_MESSAGE,
  6. WorkerRefImpl,
  7. applyFileViewerRenderSurfaceState,
  8. clearFileViewerRenderSurface,
  9. createFileRenderHandlerRendererSession,
  10. createFileRenderHandlerRegistry,
  11. createFileRenderHandlerLoader,
  12. createFileViewerRenderSurfaceActionHandlers,
  13. createFileViewerRenderReadinessTarget,
  14. createFileViewerRenderSurfaceState,
  15. createFileViewerRenderSurfaceStateTarget,
  16. createFileViewerRenderTarget,
  17. createFileViewerRendererDispatcher,
  18. disposeActiveFileViewerRendererSession,
  19. disposeFileViewerRendered,
  20. disposeFileViewerRendererSession,
  21. normalizeSource,
  22. removeFileViewerRenderTarget,
  23. resetFileViewerRenderSurface,
  24. renderFileViewerHandler,
  25. runFileViewerRenderSurfaceClear,
  26. runFileViewerRenderSurfaceMount,
  27. resolveFileViewerRenderSessionDisposeErrorMessage,
  28. reportFileViewerRenderSessionDisposeError,
  29. refWorker,
  30. type FileRenderContext,
  31. type FileRenderHandler,
  32. type RendererDefinition,
  33. } from '../packages/core/src';
  34. describe('@file-viewer/core worker and render contracts', () => {
  35. it('creates worker refs lazily and reuses the created worker', () => {
  36. const worker = { terminate: vi.fn() } as unknown as Worker;
  37. const provider = vi.fn(() => worker);
  38. const ref = refWorker('demo.worker.js');
  39. expect(ref.name).toBe('demo.worker.js');
  40. expect(ref.worker).toBeNull();
  41. expect(ref.defaults(provider)).toBe(worker);
  42. expect(ref.defaults(provider)).toBe(worker);
  43. expect(provider).toHaveBeenCalledTimes(1);
  44. });
  45. it('keeps WorkerRefImpl constructible for compatibility wrappers', () => {
  46. const worker = { postMessage: vi.fn() } as unknown as Worker;
  47. const ref = new WorkerRefImpl('compat.worker.js', worker);
  48. expect(ref.name).toBe('compat.worker.js');
  49. expect(ref.defaults(() => {
  50. throw new Error('should not replace existing worker');
  51. })).toBe(worker);
  52. });
  53. it('keeps the legacy one-argument WorkerRefImpl constructor working', () => {
  54. const worker = { postMessage: vi.fn() } as unknown as Worker;
  55. const ref = new WorkerRefImpl(worker);
  56. expect(ref.name).toBe('');
  57. expect(ref.defaults(() => {
  58. throw new Error('should not replace existing worker');
  59. })).toBe(worker);
  60. });
  61. it('exposes renderer context and handler contracts from core', async () => {
  62. const context: FileRenderContext = {
  63. filename: 'demo.pdf',
  64. url: '/demo.pdf',
  65. streamUrl: '/demo.pdf',
  66. onProgressiveRender: vi.fn(),
  67. registerExportAdapter: vi.fn(),
  68. };
  69. const handler: FileRenderHandler<string, HTMLDivElement> = async (_buffer, target, type, nextContext) => {
  70. nextContext?.onProgressiveRender?.();
  71. target.dataset.renderedType = type || '';
  72. return nextContext?.filename || '';
  73. };
  74. const target = { dataset: {} } as HTMLDivElement;
  75. await expect(handler(new ArrayBuffer(1), target, 'pdf', context)).resolves.toBe('demo.pdf');
  76. expect(target.dataset.renderedType).toBe('pdf');
  77. expect(context.onProgressiveRender).toHaveBeenCalledTimes(1);
  78. });
  79. it('invokes render handlers through the core dispatcher contract', async () => {
  80. const context: FileRenderContext = {
  81. filename: 'demo.pdf',
  82. onProgressiveRender: vi.fn(),
  83. };
  84. const handler: FileRenderHandler<string, HTMLDivElement> = async (_buffer, target, type, nextContext) => {
  85. nextContext?.onProgressiveRender?.();
  86. target.dataset.renderedType = type || '';
  87. return nextContext?.filename || '';
  88. };
  89. const dispatcher = createFileViewerRendererDispatcher({
  90. registry: {
  91. list: () => [{
  92. id: 'pdf',
  93. label: 'PDF',
  94. category: 'document',
  95. extensions: ['pdf'],
  96. } as RendererDefinition],
  97. register: () => undefined,
  98. unregister: () => false,
  99. getById: () => undefined,
  100. getByExtension: () => undefined,
  101. hasExtension: () => false,
  102. listExtensions: () => [],
  103. },
  104. handlers: [{ rendererId: 'pdf', handler }],
  105. });
  106. const target = { dataset: {} } as HTMLDivElement;
  107. await expect(renderFileViewerHandler({
  108. dispatcher,
  109. buffer: new ArrayBuffer(1),
  110. target,
  111. type: '.PDF',
  112. context,
  113. })).resolves.toBe('demo.pdf');
  114. expect(target.dataset.renderedType).toBe('pdf');
  115. expect(context.onProgressiveRender).toHaveBeenCalledTimes(1);
  116. });
  117. it('adapts legacy render handlers into renderer loaders', async () => {
  118. const unmount = vi.fn();
  119. const onProgressiveRender = vi.fn();
  120. const registerExportAdapter = vi.fn();
  121. const handler: FileRenderHandler<{ unmount: () => void }, HTMLDivElement> = async (_buffer, target, type, context) => {
  122. context?.onProgressiveRender?.();
  123. target.dataset.renderedType = type || '';
  124. target.dataset.filename = context?.filename || '';
  125. target.dataset.streamUrl = context?.streamUrl || '';
  126. return { unmount };
  127. };
  128. const loader = createFileRenderHandlerLoader({ handler });
  129. const target = { dataset: {} } as HTMLDivElement;
  130. const session = await loader({
  131. source: normalizeSource({
  132. buffer: new ArrayBuffer(1),
  133. filename: 'demo.docx',
  134. }),
  135. surface: { container: target },
  136. options: {},
  137. registerExportAdapter,
  138. renderContext: {
  139. filename: 'legacy.docx',
  140. streamUrl: '/legacy-stream.docx',
  141. onProgressiveRender,
  142. },
  143. });
  144. expect(target.dataset.renderedType).toBe('docx');
  145. expect(target.dataset.filename).toBe('legacy.docx');
  146. expect(target.dataset.streamUrl).toBe('/legacy-stream.docx');
  147. expect(onProgressiveRender).toHaveBeenCalledTimes(1);
  148. await session.destroy?.();
  149. expect(unmount).toHaveBeenCalledTimes(1);
  150. const destroy = vi.fn();
  151. disposeFileViewerRendered({ destroy });
  152. expect(destroy).toHaveBeenCalledTimes(1);
  153. });
  154. it('wraps legacy rendered instances into core renderer sessions', async () => {
  155. const unmount = vi.fn();
  156. const rendered = { unmount };
  157. const session = createFileRenderHandlerRendererSession(rendered);
  158. expect(session.rendered).toBe(rendered);
  159. await session.destroy?.();
  160. expect(unmount).toHaveBeenCalledTimes(1);
  161. });
  162. it('creates and clears framework-neutral render surface targets', () => {
  163. const { document } = parseHTML('<main id="root"><span>old</span></main>');
  164. const root = document.getElementById('root') as HTMLElement;
  165. clearFileViewerRenderSurface(root);
  166. expect(root.childElementCount).toBe(0);
  167. const target = createFileViewerRenderTarget(root);
  168. expect(target.className).toBe(DEFAULT_FILE_VIEWER_RENDER_TARGET_CLASS);
  169. expect(root.firstElementChild).toBe(target);
  170. const customTarget = createFileViewerRenderTarget(root, { className: 'custom-render' });
  171. expect(customTarget.className).toBe('custom-render');
  172. expect(root.childElementCount).toBe(2);
  173. expect(removeFileViewerRenderTarget(root, customTarget)).toBe(true);
  174. expect(removeFileViewerRenderTarget(root, customTarget)).toBe(false);
  175. });
  176. it('applies framework-neutral render surface state and disposes the active session', () => {
  177. const { document } = parseHTML('<main id="root"><section>rendered</section></main>');
  178. const root = document.getElementById('root') as HTMLElement;
  179. const onError = vi.fn();
  180. const destroy = vi.fn();
  181. const session = { destroy };
  182. const adapter = { print: true };
  183. const state = createFileViewerRenderSurfaceState<typeof session>();
  184. const readiness = {
  185. renderedReady: true,
  186. progressiveReady: true,
  187. };
  188. expect(state.session).toBeNull();
  189. expect(state.exportAdapter).toBeNull();
  190. expect(applyFileViewerRenderSurfaceState(state, {
  191. session,
  192. exportAdapter: adapter,
  193. })).toBe(state);
  194. expect(state.session).toBe(session);
  195. expect(state.exportAdapter).toBe(adapter);
  196. expect(disposeActiveFileViewerRendererSession(state, { onError })).toBe(session);
  197. expect(destroy).toHaveBeenCalledTimes(1);
  198. expect(onError).not.toHaveBeenCalled();
  199. expect(state.session).toBeNull();
  200. expect(state.exportAdapter).toBe(adapter);
  201. applyFileViewerRenderSurfaceState(state, { exportAdapter: null });
  202. expect(state.exportAdapter).toBeNull();
  203. applyFileViewerRenderSurfaceState(state, {
  204. session,
  205. exportAdapter: adapter,
  206. });
  207. expect(resetFileViewerRenderSurface({
  208. surfaceState: state,
  209. readinessState: readiness,
  210. container: root,
  211. disposeOptions: { onError },
  212. })).toBe(session);
  213. expect(destroy).toHaveBeenCalledTimes(2);
  214. expect(onError).not.toHaveBeenCalled();
  215. expect(state.session).toBeNull();
  216. expect(state.exportAdapter).toBeNull();
  217. expect(readiness).toEqual({
  218. renderedReady: false,
  219. progressiveReady: false,
  220. });
  221. expect(root.childElementCount).toBe(0);
  222. });
  223. it('creates render surface targets from framework accessors', () => {
  224. const { document } = parseHTML('<main id="root"><section>rendered</section></main>');
  225. const root = document.getElementById('root') as HTMLElement;
  226. const destroy = vi.fn();
  227. const session = { destroy };
  228. const adapter = { exportHtml: true };
  229. const stateBacking: {
  230. session: typeof session | null;
  231. exportAdapter: typeof adapter | null;
  232. } = {
  233. session: null,
  234. exportAdapter: null,
  235. };
  236. const readinessBacking = {
  237. renderedReady: true,
  238. progressiveReady: true,
  239. };
  240. const state = createFileViewerRenderSurfaceStateTarget<typeof session>({
  241. session: {
  242. get: () => stateBacking.session,
  243. set: value => {
  244. stateBacking.session = value;
  245. },
  246. },
  247. exportAdapter: {
  248. get: () => stateBacking.exportAdapter,
  249. set: value => {
  250. stateBacking.exportAdapter = value as typeof adapter | null;
  251. },
  252. },
  253. });
  254. const readiness = createFileViewerRenderReadinessTarget({
  255. renderedReady: {
  256. get: () => readinessBacking.renderedReady,
  257. set: value => {
  258. readinessBacking.renderedReady = value;
  259. },
  260. },
  261. progressiveReady: {
  262. get: () => readinessBacking.progressiveReady,
  263. set: value => {
  264. readinessBacking.progressiveReady = value;
  265. },
  266. },
  267. });
  268. expect(applyFileViewerRenderSurfaceState(state, {
  269. session,
  270. exportAdapter: adapter,
  271. })).toBe(state);
  272. expect(stateBacking).toEqual({
  273. session,
  274. exportAdapter: adapter,
  275. });
  276. expect(resetFileViewerRenderSurface({
  277. surfaceState: state,
  278. readinessState: readiness,
  279. container: root,
  280. })).toBe(session);
  281. expect(destroy).toHaveBeenCalledTimes(1);
  282. expect(stateBacking).toEqual({
  283. session: null,
  284. exportAdapter: null,
  285. });
  286. expect(readinessBacking).toEqual({
  287. renderedReady: false,
  288. progressiveReady: false,
  289. });
  290. expect(root.childElementCount).toBe(0);
  291. });
  292. it('runs framework-neutral render surface clear lifecycle orchestration', () => {
  293. const { document } = parseHTML('<main id="root"><section>rendered</section></main>');
  294. const root = document.getElementById('root') as HTMLElement;
  295. const session = { destroy: vi.fn() };
  296. const state = createFileViewerRenderSurfaceState<typeof session>();
  297. const readiness = {
  298. renderedReady: true,
  299. progressiveReady: true,
  300. };
  301. const events: string[] = [];
  302. const unloadContext = { phase: 'unload-start' as const };
  303. applyFileViewerRenderSurfaceState(state, {
  304. session,
  305. exportAdapter: { exportHtml: true },
  306. });
  307. const result = runFileViewerRenderSurfaceClear({
  308. reason: 'component-unmount',
  309. surfaceState: state,
  310. readinessState: readiness,
  311. container: root,
  312. onUnloadStart: reason => {
  313. events.push(`start:${reason}:${root.childElementCount}`)
  314. return unloadContext;
  315. },
  316. onClearActiveDocumentContext: () => events.push(`clear-active:${root.childElementCount}`),
  317. onClearDocumentState: () => events.push('clear-doc'),
  318. onStopZoomObserver: () => events.push('stop-zoom'),
  319. onClearZoomProvider: () => events.push('clear-zoom'),
  320. onUnloadComplete: (context, reason) => events.push(`complete:${reason}:${context === unloadContext}`),
  321. });
  322. expect(result).toEqual({
  323. reason: 'component-unmount',
  324. unloadContext,
  325. session,
  326. });
  327. expect(session.destroy).toHaveBeenCalledTimes(1);
  328. expect(state.session).toBeNull();
  329. expect(state.exportAdapter).toBeNull();
  330. expect(readiness).toEqual({
  331. renderedReady: false,
  332. progressiveReady: false,
  333. });
  334. expect(root.childElementCount).toBe(0);
  335. expect(events).toEqual([
  336. 'start:component-unmount:1',
  337. 'clear-active:0',
  338. 'clear-doc',
  339. 'stop-zoom',
  340. 'clear-zoom',
  341. 'complete:component-unmount:true',
  342. ]);
  343. });
  344. it('runs framework-neutral render surface mount orchestration', async () => {
  345. const { document } = parseHTML('<main id="root"><span>old</span></main>');
  346. const root = document.getElementById('root') as HTMLElement;
  347. const session = { destroy: vi.fn() };
  348. const adapter = { exportHtml: true };
  349. const state = createFileViewerRenderSurfaceState<typeof session>();
  350. const readiness = {
  351. renderedReady: false,
  352. progressiveReady: false,
  353. };
  354. const clearRenderedContent = vi.fn(() => clearFileViewerRenderSurface(root));
  355. const waitForContainer = vi.fn();
  356. const waitForPaint = vi.fn();
  357. const startZoomObserver = vi.fn();
  358. const refreshDocumentIndex = vi.fn();
  359. const refreshZoomProvider = vi.fn();
  360. const render = vi.fn(async context => {
  361. expect(context.type).toBe('pdf');
  362. expect(context.filename).toBe('demo.pdf');
  363. expect(context.sourceUrl).toBe('/example/demo.pdf');
  364. expect(context.streamUrl).toBe('/stream/demo.pdf');
  365. expect(context.target.className).toBe(DEFAULT_FILE_VIEWER_RENDER_TARGET_CLASS);
  366. context.registerExportAdapter(adapter);
  367. context.onProgressiveRender();
  368. context.target.appendChild(document.createElement('strong'));
  369. return session;
  370. });
  371. const result = await runFileViewerRenderSurfaceMount({
  372. buffer: new ArrayBuffer(4),
  373. file: new File(['demo'], 'demo.pdf'),
  374. version: 1,
  375. sourceUrl: '/example/demo.pdf',
  376. streamUrl: '/stream/demo.pdf',
  377. getContainer: () => root,
  378. surfaceState: state,
  379. readinessState: readiness,
  380. isCurrent: version => version === 1,
  381. clearRenderedContent,
  382. render,
  383. waitForContainer,
  384. waitForPaint,
  385. onStartZoomObserver: startZoomObserver,
  386. onRefreshDocumentIndex: refreshDocumentIndex,
  387. onRefreshZoomProvider: refreshZoomProvider,
  388. });
  389. expect(result).toBe(session);
  390. expect(clearRenderedContent).toHaveBeenCalledWith('replace');
  391. expect(waitForContainer).toHaveBeenCalledTimes(1);
  392. expect(waitForPaint).toHaveBeenCalledTimes(1);
  393. expect(startZoomObserver).toHaveBeenCalledTimes(1);
  394. expect(render).toHaveBeenCalledTimes(1);
  395. expect(root.innerHTML).not.toContain('old');
  396. expect(root.innerHTML).toContain('file-render');
  397. expect(root.innerHTML).toContain('strong');
  398. expect(state.exportAdapter).toBe(adapter);
  399. expect(readiness.progressiveReady).toBe(true);
  400. expect(refreshDocumentIndex).toHaveBeenCalledTimes(1);
  401. expect(refreshZoomProvider).toHaveBeenCalledTimes(1);
  402. });
  403. it('creates reusable render surface action handlers for framework wrappers', async () => {
  404. const { document } = parseHTML('<main id="root"><span>old</span></main>');
  405. const root = document.getElementById('root') as HTMLElement;
  406. const staleSession = { destroy: vi.fn() };
  407. const session = { destroy: vi.fn() };
  408. const adapter = { exportHtml: true };
  409. const state = createFileViewerRenderSurfaceState<typeof session>();
  410. const readiness = {
  411. renderedReady: true,
  412. progressiveReady: true,
  413. };
  414. const waitForContainer = vi.fn();
  415. const waitForPaint = vi.fn();
  416. const startZoomObserver = vi.fn();
  417. const stopZoomObserver = vi.fn();
  418. const clearZoomProvider = vi.fn();
  419. const refreshDocumentIndex = vi.fn();
  420. const refreshZoomProvider = vi.fn();
  421. const events: string[] = [];
  422. const unloadContext = { phase: 'facade-unload' as const };
  423. const render = vi.fn(async context => {
  424. expect(context.type).toBe('pdf');
  425. expect(context.filename).toBe('facade.pdf');
  426. context.registerExportAdapter(adapter);
  427. context.onProgressiveRender();
  428. context.target.appendChild(document.createElement('strong'));
  429. return session;
  430. });
  431. const actions = createFileViewerRenderSurfaceActionHandlers({
  432. getContainer: () => root,
  433. surfaceState: state,
  434. readinessState: readiness,
  435. isCurrent: version => version === 1,
  436. waitForContainer,
  437. waitForPaint,
  438. onUnloadStart: reason => {
  439. events.push(`start:${reason}`);
  440. return unloadContext;
  441. },
  442. onUnloadComplete: (context, reason) => {
  443. events.push(`complete:${reason}:${context === unloadContext}`);
  444. },
  445. onClearActiveDocumentContext: () => events.push('clear-active'),
  446. onClearDocumentState: () => events.push('clear-doc'),
  447. onStartZoomObserver: startZoomObserver,
  448. onStopZoomObserver: stopZoomObserver,
  449. onClearZoomProvider: clearZoomProvider,
  450. onRefreshDocumentIndex: refreshDocumentIndex,
  451. onRefreshZoomProvider: refreshZoomProvider,
  452. render,
  453. });
  454. expect(actions.setActiveRenderSession(staleSession)).toBe(state);
  455. expect(state.session).toBe(staleSession);
  456. const clearResult = actions.clearRenderedContent('replace');
  457. expect(clearResult).toEqual({
  458. reason: 'replace',
  459. unloadContext,
  460. session: staleSession,
  461. });
  462. expect(staleSession.destroy).toHaveBeenCalledTimes(1);
  463. expect(state.session).toBeNull();
  464. expect(state.exportAdapter).toBeNull();
  465. expect(readiness).toEqual({
  466. renderedReady: false,
  467. progressiveReady: false,
  468. });
  469. expect(root.childElementCount).toBe(0);
  470. root.appendChild(document.createElement('em'));
  471. const result = await actions.mountRenderedContent(
  472. new ArrayBuffer(4),
  473. new File(['demo'], 'facade.pdf'),
  474. 1,
  475. '/example/facade.pdf',
  476. '/stream/facade.pdf'
  477. );
  478. expect(result).toBe(session);
  479. expect(render).toHaveBeenCalledTimes(1);
  480. expect(root.innerHTML).not.toContain('em');
  481. expect(root.innerHTML).toContain('file-render');
  482. expect(root.innerHTML).toContain('strong');
  483. expect(state.exportAdapter).toBe(adapter);
  484. expect(readiness.progressiveReady).toBe(true);
  485. expect(waitForContainer).toHaveBeenCalledTimes(1);
  486. expect(waitForPaint).toHaveBeenCalledTimes(1);
  487. expect(startZoomObserver).toHaveBeenCalledTimes(1);
  488. expect(stopZoomObserver).toHaveBeenCalledTimes(2);
  489. expect(clearZoomProvider).toHaveBeenCalledTimes(2);
  490. expect(refreshDocumentIndex).toHaveBeenCalledTimes(1);
  491. expect(refreshZoomProvider).toHaveBeenCalledTimes(1);
  492. expect(events).toEqual([
  493. 'start:replace',
  494. 'clear-active',
  495. 'clear-doc',
  496. 'complete:replace:true',
  497. 'start:replace',
  498. 'clear-active',
  499. 'clear-doc',
  500. 'complete:replace:true',
  501. ]);
  502. const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
  503. const error = new Error('facade dispose failed');
  504. try {
  505. actions.destroyRenderSession({
  506. destroy: () => {
  507. throw error;
  508. },
  509. });
  510. expect(warn).toHaveBeenCalledWith('预览内容卸载失败', error);
  511. } finally {
  512. warn.mockRestore();
  513. }
  514. });
  515. it('removes stale render targets and disposes stale sessions during surface mount', async () => {
  516. const { document } = parseHTML('<main id="root"></main>');
  517. const root = document.getElementById('root') as HTMLElement;
  518. const session = { destroy: vi.fn() };
  519. const state = createFileViewerRenderSurfaceState<typeof session>();
  520. const readiness = {
  521. renderedReady: false,
  522. progressiveReady: false,
  523. };
  524. let currentVersion = 1;
  525. const render = vi.fn(async context => {
  526. context.target.appendChild(document.createElement('strong'));
  527. currentVersion = 2;
  528. return session;
  529. });
  530. await expect(runFileViewerRenderSurfaceMount({
  531. buffer: new ArrayBuffer(4),
  532. file: new File(['demo'], 'demo.pdf'),
  533. version: 1,
  534. getContainer: () => root,
  535. surfaceState: state,
  536. readinessState: readiness,
  537. isCurrent: version => version === currentVersion,
  538. clearRenderedContent: () => clearFileViewerRenderSurface(root),
  539. render,
  540. })).resolves.toBeUndefined();
  541. expect(render).toHaveBeenCalledTimes(1);
  542. expect(session.destroy).toHaveBeenCalledTimes(1);
  543. expect(root.innerHTML).not.toContain('file-render');
  544. });
  545. it('safely disposes renderer sessions and reports teardown errors', async () => {
  546. const onError = vi.fn();
  547. const syncError = new Error('sync teardown failed');
  548. const asyncError = new Error('async teardown failed');
  549. const syncDestroy = vi.fn(() => {
  550. throw syncError;
  551. });
  552. const asyncDestroy = vi.fn(() => Promise.reject(asyncError));
  553. disposeFileViewerRendererSession({ destroy: syncDestroy }, { onError });
  554. disposeFileViewerRendererSession({ destroy: asyncDestroy }, { onError });
  555. disposeFileViewerRendererSession(null, { onError });
  556. await Promise.resolve();
  557. expect(FILE_VIEWER_RENDER_SESSION_DISPOSE_ERROR_MESSAGE).toBe('预览内容卸载失败');
  558. expect(resolveFileViewerRenderSessionDisposeErrorMessage()).toBe('预览内容卸载失败');
  559. expect(resolveFileViewerRenderSessionDisposeErrorMessage({ message: '自定义卸载失败' })).toBe('自定义卸载失败');
  560. const loggedDisposeWarnings: Array<[string, unknown]> = [];
  561. expect(reportFileViewerRenderSessionDisposeError({
  562. error: syncError,
  563. onLogError: (message, error) => {
  564. loggedDisposeWarnings.push([message, error]);
  565. },
  566. })).toBe('预览内容卸载失败');
  567. expect(reportFileViewerRenderSessionDisposeError({
  568. message: '自定义卸载失败',
  569. error: asyncError,
  570. onLogError: (message, error) => {
  571. loggedDisposeWarnings.push([message, error]);
  572. },
  573. })).toBe('自定义卸载失败');
  574. expect(loggedDisposeWarnings).toEqual([
  575. ['预览内容卸载失败', syncError],
  576. ['自定义卸载失败', asyncError],
  577. ]);
  578. expect(syncDestroy).toHaveBeenCalledTimes(1);
  579. expect(asyncDestroy).toHaveBeenCalledTimes(1);
  580. expect(onError).toHaveBeenCalledWith(syncError);
  581. expect(onError).toHaveBeenCalledWith(asyncError);
  582. });
  583. it('builds renderer registries from legacy render handlers', async () => {
  584. const unmount = vi.fn();
  585. const handler: FileRenderHandler<{ unmount: () => void }, HTMLDivElement> = async (_buffer, target, type, context) => {
  586. target.dataset.renderedType = type || '';
  587. target.dataset.filename = context?.filename || '';
  588. return { unmount };
  589. };
  590. const target = { dataset: {} } as HTMLDivElement;
  591. const { registry, missingRendererIds } = createFileRenderHandlerRegistry({
  592. definitions: [{
  593. id: 'text-fixture',
  594. label: 'Text Fixture',
  595. category: 'code',
  596. extensions: ['txt-fixture'],
  597. }],
  598. handlers: [{
  599. rendererId: 'text-fixture',
  600. handler,
  601. }],
  602. });
  603. const renderer = registry.getByExtension('TXT-FIXTURE');
  604. expect(missingRendererIds).toEqual([]);
  605. expect(renderer?.load).toBeTypeOf('function');
  606. const session = await renderer?.load?.({
  607. source: normalizeSource({
  608. buffer: new ArrayBuffer(1),
  609. filename: 'demo.txt-fixture',
  610. }),
  611. surface: { container: target },
  612. options: {},
  613. });
  614. expect(target.dataset.renderedType).toBe('txt-fixture');
  615. expect(target.dataset.filename).toBe('demo.txt-fixture');
  616. await session?.destroy?.();
  617. expect(unmount).toHaveBeenCalledTimes(1);
  618. });
  619. });