Files
Genarrative/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts
T
k88936 27f7375b51 拒绝桥合成事件带上promise
- unhandledRejectionBridge:合成事件优先用 PromiseRejectionEvent(jsdom 没这个构造器时退回普通 Event),再把 reason / promise 都挂成实例访问器,jsdom 分支下 promise 也可读
- 判定调整为读 reason 或 promise 任一个都算 window 侧接手(原来只认 reason)
- 补一条用例:事件带 promise 且读 promise 也算被消费
2026-10-02 13:48:26 +08:00

99 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 把 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<unknown>) => void;
type RawRejectionListener = RejectionListener & {
listener?: RejectionListener;
};
function invokeRejectionListener(
listener: RawRejectionListener,
reason: unknown,
promise: Promise<unknown>,
) {
// 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<unknown>,
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<unknown>) => {
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);
}
}
};
}