/** * 把 Node 的 `unhandledRejection` 桥接成 jsdom `window` 的 `unhandledrejection` 事件。 * * 生产 WebView 里,`void` 掉的 async handler(`onSubmit` / `onClick`)抛出的拒绝会派发 * `unhandledrejection`,被 `AuthenticatedClient` 的全局 handler 交给错误池。jsdom 既不派发 * 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node * 监听器,再合成一个与 WebView `PromiseRejectionEvent` 同形(`reason` + `promise`)的 window 事件。 * * 只抑制**被 window 侧消费**的拒绝:合成事件的 `reason` / `promise` 是访问器,任何监听方读过 * 任一个就算有人接手;没人读说明这条拒绝是意外的,交回安装前的监听器(含 vitest 的失败上报), * 不会被静默吞掉。 * * 安装前用 `rawListeners` 取原始监听器:`process.once` 注册的包装(`.listener`)在拆卸时 * 按 `once` 装回去,不会退化成每次拒绝都触发的普通监听器;安装期间新增的监听器仍排在 * 恢复出来的监听器之前。 * * 用法:用例内 `const uninstall = installUnhandledRejectionBridge()`,在 `finally` 或 * `afterEach` 里 `uninstall()`。 */ type RejectionListener = (reason: unknown, promise: Promise) => void; type RawRejectionListener = RejectionListener & { listener?: RejectionListener; }; function invokeRejectionListener( listener: RawRejectionListener, reason: unknown, promise: Promise, ) { // once 包装必须调它包裹的原函数;Node 的 EventEmitter 把 this 绑成 process, // 这里用 Reflect.apply 对齐,别让依赖 this 的原监听器拿到 undefined。 Reflect.apply(listener.listener ?? listener, process, [reason, promise]); } /** * 合成一个与 WebView `unhandledrejection` 同形的 window 事件。 * * 浏览器里本来就是 `PromiseRejectionEvent`(带 `reason` / `promise`),有构造器就直接用它; * jsdom 不实现这个类时退回普通 `Event`。两种来源都再把 `reason` / `promise` 覆盖成实例访问器: * 既保证 jsdom 分支下 `promise` 同样可读,也让「读过任一个字段」都能记成有人接手。 */ function createRejectionEvent( reason: unknown, promise: Promise, markConsumed: () => void, ): Event { const event = typeof PromiseRejectionEvent === 'function' ? new PromiseRejectionEvent('unhandledrejection', { promise, reason }) : new Event('unhandledrejection'); Object.defineProperties(event, { reason: { get() { markConsumed(); return reason; }, configurable: true, }, promise: { get() { markConsumed(); return promise; }, configurable: true, }, }); return event; } export function installUnhandledRejectionBridge() { const originalListeners = process.rawListeners( 'unhandledRejection', ) as RawRejectionListener[]; process.removeAllListeners('unhandledRejection'); const handleRejection = (reason: unknown, promise: Promise) => { let consumed = false; const event = createRejectionEvent(reason, promise, () => { consumed = true; }); window.dispatchEvent(event); if (!consumed) { for (const listener of originalListeners) { invokeRejectionListener(listener, reason, promise); } } }; process.on('unhandledRejection', handleRejection); return () => { process.removeListener('unhandledRejection', handleRejection); for (const listener of originalListeners) { if (listener.listener) { process.once('unhandledRejection', listener.listener); } else { process.on('unhandledRejection', listener); } } }; }