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 () => {