27f7375b51
- unhandledRejectionBridge:合成事件优先用 PromiseRejectionEvent(jsdom 没这个构造器时退回普通 Event),再把 reason / promise 都挂成实例访问器,jsdom 分支下 promise 也可读 - 判定调整为读 reason 或 promise 任一个都算 window 侧接手(原来只认 reason) - 补一条用例:事件带 promise 且读 promise 也算被消费
99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
/**
|
||
* 把 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);
|
||
}
|
||
}
|
||
};
|
||
}
|