diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts index 9b796227d..94287a7cb 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts @@ -86,3 +86,20 @@ test('交回原监听器时 this 绑成 process,和 Node EventEmitter 一致', expect(receivers).toEqual([process]); }); + +test('合成事件同时带 reason 与 promise,读任一个都算被消费', () => { + const forwarded = trackProcessListener(); + let received: unknown = null; + const listener = (event: Event) => { + received = (event as Event & { promise?: unknown }).promise; + }; + windowListeners.push(listener); + window.addEventListener('unhandledrejection', listener); + installed.push(installUnhandledRejectionBridge()); + + const promise = Promise.resolve(); + process.emit('unhandledRejection', new Error('带 promise 的拒绝'), promise); + + expect(received).toBe(promise); + expect(forwarded).toEqual([]); +}); diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts index 7e5a5aac6..41f5a4046 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts @@ -4,10 +4,10 @@ * 生产 WebView 里,`void` 掉的 async handler(`onSubmit` / `onClick`)抛出的拒绝会派发 * `unhandledrejection`,被 `AuthenticatedClient` 的全局 handler 交给错误池。jsdom 既不派发 * 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node - * 监听器,再合成一个带 `reason` 的 window 事件。 + * 监听器,再合成一个与 WebView `PromiseRejectionEvent` 同形(`reason` + `promise`)的 window 事件。 * - * 只抑制**被 window 侧消费**的拒绝:合成事件的 `reason` 是访问器,任何监听方读过它就算 - * 有人接手;没人读说明这条拒绝是意外的,交回安装前的监听器(含 vitest 的失败上报), + * 只抑制**被 window 侧消费**的拒绝:合成事件的 `reason` / `promise` 是访问器,任何监听方读过 + * 任一个就算有人接手;没人读说明这条拒绝是意外的,交回安装前的监听器(含 vitest 的失败上报), * 不会被静默吞掉。 * * 安装前用 `rawListeners` 取原始监听器:`process.once` 注册的包装(`.listener`)在拆卸时 @@ -32,6 +32,41 @@ function invokeRejectionListener( 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', @@ -39,12 +74,8 @@ export function installUnhandledRejectionBridge() { process.removeAllListeners('unhandledRejection'); const handleRejection = (reason: unknown, promise: Promise) => { let consumed = false; - const event = new Event('unhandledrejection'); - Object.defineProperty(event, 'reason', { - get() { - consumed = true; - return reason; - }, + const event = createRejectionEvent(reason, promise, () => { + consumed = true; }); window.dispatchEvent(event); if (!consumed) {