From 06a4f9346f9edc1f383dd4601f5c37f1108e6f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 21:15:03 +0800 Subject: [PATCH] =?UTF-8?q?unhandledrejection=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=A1=A5=E5=8F=AA=E5=90=9E=E8=A2=AB=20window=20=E4=BE=A7?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E7=9A=84=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合成事件的 reason 改为访问器:有监听方读过就算有人接手;没人读则交回安装前的监听器(含 vitest 失败上报) - 新增 unhandledRejectionBridge.test.ts 钉住两条路径:无人接手会交回、window 读过则不再交回 - 避免桥把无关的未处理拒绝静默吞掉 --- .../tests/unhandledRejectionBridge.test.ts | 73 +++++++++++++++++++ .../tests/unhandledRejectionBridge.ts | 19 ++++- 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts new file mode 100644 index 000000000..3022d8f4e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +/** + * jsdom `unhandledrejection` 桥的「只吞被消费的拒绝」语义。 + * + * 桥接管 Node 监听器是为了让组件能收到 window 事件;但没人消费的拒绝必须交回原监听器, + * 否则用例会静默吞掉真实的未处理拒绝。 + */ +import { afterEach, expect, test } from 'vitest'; + +import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; + +const installed: Array<() => void> = []; +const processListeners: Array<(reason: unknown, promise: unknown) => void> = []; +const windowListeners: Array<(event: Event) => void> = []; +let parkedListeners: Array<(reason: unknown, promise: unknown) => void> = []; + +afterEach(() => { + while (installed.length > 0) installed.pop()?.(); + while (processListeners.length > 0) { + process.removeListener('unhandledRejection', processListeners.pop()!); + } + while (windowListeners.length > 0) { + window.removeEventListener('unhandledrejection', windowListeners.pop()!); + } + for (const listener of parkedListeners) { + process.on('unhandledRejection', listener); + } + parkedListeners = []; +}); + +/** 停放 vitest 自己的监听器,好观察「交回原监听器」而不触发用例失败上报。 */ +function parkProcessListeners() { + parkedListeners = process.listeners('unhandledRejection'); + process.removeAllListeners('unhandledRejection'); +} + +function trackProcessListener() { + const forwarded: unknown[] = []; + const listener = (reason: unknown) => { + forwarded.push(reason); + }; + processListeners.push(listener); + process.on('unhandledRejection', listener); + return forwarded; +} + +test('没人读 reason 的拒绝会交回原监听器', () => { + parkProcessListeners(); + const forwarded = trackProcessListener(); + installed.push(installUnhandledRejectionBridge()); + + const reason = new Error('意外的拒绝'); + process.emit('unhandledRejection', reason, Promise.resolve()); + + expect(forwarded).toEqual([reason]); +}); + +test('window 侧读过 reason 的拒绝不再交回原监听器', () => { + const forwarded = trackProcessListener(); + const consumed: unknown[] = []; + const listener = (event: Event) => { + consumed.push((event as Event & { reason?: unknown }).reason); + }; + windowListeners.push(listener); + window.addEventListener('unhandledrejection', listener); + installed.push(installUnhandledRejectionBridge()); + + const reason = new Error('已接手的拒绝'); + process.emit('unhandledRejection', reason, Promise.resolve()); + + expect(consumed).toEqual([reason]); + expect(forwarded).toEqual([]); +}); diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts index 01f440388..85dc4c24e 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts @@ -6,16 +6,31 @@ * 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node * 监听器,再合成一个带 `reason` 的 window 事件。 * + * 只抑制**被 window 侧消费**的拒绝:合成事件的 `reason` 是访问器,任何监听方读过它就算 + * 有人接手;没人读说明这条拒绝是意外的,交回安装前的监听器(含 vitest 的失败上报), + * 不会被静默吞掉。 + * * 用法:用例内 `const uninstall = installUnhandledRejectionBridge()`,在 `finally` 或 * `afterEach` 里 `uninstall()`。 */ export function installUnhandledRejectionBridge() { const originalListeners = process.listeners('unhandledRejection'); process.removeAllListeners('unhandledRejection'); - const handleRejection = (reason: unknown) => { + const handleRejection = (reason: unknown, promise: Promise) => { + let consumed = false; const event = new Event('unhandledrejection'); - Object.defineProperty(event, 'reason', { value: reason }); + Object.defineProperty(event, 'reason', { + get() { + consumed = true; + return reason; + }, + }); window.dispatchEvent(event); + if (!consumed) { + for (const listener of originalListeners) { + listener(reason, promise); + } + } }; process.on('unhandledRejection', handleRejection); return () => {