594063a1e0
- 按 vitest 0.34 的参数元组口径改写 mock 泛型 - 修正 noUncheckedIndexedAccess 下的下标访问 - 补齐与当前类型脱节的测试桩字段 - 按 scripts/*.mjs 的运行时契约补局部类型声明
240 lines
8.6 KiB
TypeScript
240 lines
8.6 KiB
TypeScript
/**
|
||
* 与 tauri 2.11.x 等价的 Tauri 事件替身,用于回归「注销竞态」这一类 bug。
|
||
*
|
||
* 现有各套件的替身(`window.__TAURI__ = { core: { invoke }, event: { listen } }`)
|
||
* 里的 `listen` 返回的注销函数不读任何注册表,所以**结构上抓不到**
|
||
* `undefined is not an object (evaluating 'listeners[eventId].handlerId')`
|
||
* (tauri-apps/tauri#15799)。这里按 tauri 2.11.3 的真实实现建三层:
|
||
*
|
||
* - `window.__TAURI_INTERNALS__`:core 注入的底层桥(`invoke` / `transformCallback`
|
||
* / `unregisterCallback`),AGC 的 `subscribeTauriEvent` 走这一层。
|
||
* - `window.__TAURI__`:`withGlobalTauri` 的全局桥,`event.listen` 按库内实现返回
|
||
* 「先读注册表条目再摘回调」的注销函数(就是会抛的那条路径)。
|
||
* - `window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener`:库内注销脚本本体,
|
||
* 条目缺失时抛 `TypeError`,并在 `racyUnregisterViolations` 里留痕,便于断言。
|
||
*
|
||
* 注册表条目由 `plugin:event|listen` 之后**另一次** eval 写入:替身把它排进
|
||
* `pendingRegistrationEvals`,只有在 `flushRegistrationEvals()` 之后才可见。
|
||
*/
|
||
|
||
export type TauriEventFakeEvent = {
|
||
event: string;
|
||
id: number;
|
||
payload: unknown;
|
||
};
|
||
|
||
type FakeListener = {
|
||
event: string;
|
||
eventId: number;
|
||
handlerId: number;
|
||
target: unknown;
|
||
};
|
||
|
||
export type TauriEventFake = {
|
||
/** 已登记在「后端」的事件订阅(模拟 `Listeners::js_event_listeners`)。 */
|
||
listeners: FakeListener[];
|
||
/** 库内注销脚本读不到注册表条目时的记录(每次都是一次竞态命中)。 */
|
||
racyUnregisterViolations: string[];
|
||
/** 库内注销脚本被调用的次数。 */
|
||
racyUnregisterCalls: number;
|
||
/** 全局桥 `event.listen` 被调用的次数。 */
|
||
bridgeListenCalls: number;
|
||
/** 每个事件的 `plugin:event|unlisten` 调用次数。 */
|
||
unlistenInvokeCounts: Map<string, number>;
|
||
/** 每个事件的 `plugin:event|listen` 调用次数。 */
|
||
listenInvokeCounts: Map<string, number>;
|
||
/** 还没落地的注册 eval 数量(模拟 listen 之后那次独立 eval)。 */
|
||
pendingRegistrationEvals(): number;
|
||
/** 让注册 eval 落地:之后 `emit` 才能命中新登记的事件 ID。 */
|
||
flushRegistrationEvals(): void;
|
||
/** 仍在 `__TAURI_INTERNALS__.callbacks` 里的 handler 数量。 */
|
||
jsCallbackCount(): number;
|
||
/** 直接投递事件,语义与 `event_initialization_script` 一致(条目缺失则跳过)。 */
|
||
emit(event: string, payload: unknown): void;
|
||
install(): void;
|
||
restore(): void;
|
||
};
|
||
|
||
type FakeOptions = {
|
||
/** 非 event 插件的命令交给它(通常是套件自己的 invoke 替身)。 */
|
||
invoke?: (
|
||
command: string,
|
||
args?: Record<string, unknown>,
|
||
) => Promise<unknown>;
|
||
};
|
||
|
||
const INTERNALS_KEY = '__TAURI_INTERNALS__';
|
||
const EVENT_INTERNALS_KEY = '__TAURI_EVENT_PLUGIN_INTERNALS__';
|
||
const GLOBAL_KEY = '__TAURI__';
|
||
|
||
type MutableWindow = Window & Record<string, unknown>;
|
||
|
||
export function createTauriEventFake(
|
||
options: FakeOptions = {},
|
||
): TauriEventFake {
|
||
const listeners: FakeListener[] = [];
|
||
const racyUnregisterViolations: string[] = [];
|
||
const unlistenInvokeCounts = new Map<string, number>();
|
||
const listenInvokeCounts = new Map<string, number>();
|
||
const jsCallbacks = new Map<number, (eventData: unknown) => void>();
|
||
const registrations: FakeListener[] = [];
|
||
let nextEventId = 1;
|
||
let nextHandlerId = 1;
|
||
let racyUnregisterCalls = 0;
|
||
let bridgeListenCalls = 0;
|
||
let installedGlobals: Array<{ key: string; had: boolean; value: unknown }> =
|
||
[];
|
||
|
||
const registry: Record<string, Record<number, { handlerId: number }>> = {};
|
||
|
||
const fake: TauriEventFake = {
|
||
listeners,
|
||
racyUnregisterViolations,
|
||
get racyUnregisterCalls() {
|
||
return racyUnregisterCalls;
|
||
},
|
||
get bridgeListenCalls() {
|
||
return bridgeListenCalls;
|
||
},
|
||
unlistenInvokeCounts,
|
||
listenInvokeCounts,
|
||
pendingRegistrationEvals: () => registrations.length,
|
||
flushRegistrationEvals() {
|
||
const pending = registrations.splice(0, registrations.length);
|
||
for (const listener of pending) {
|
||
const eventRegistry = (registry[listener.event] ??= {});
|
||
eventRegistry[listener.eventId] = {
|
||
handlerId: listener.handlerId,
|
||
};
|
||
}
|
||
},
|
||
jsCallbackCount: () => jsCallbacks.size,
|
||
emit(event, payload) {
|
||
for (const listener of listeners) {
|
||
if (listener.event !== event) continue;
|
||
const entry = registry[event]?.[listener.eventId];
|
||
if (!entry) continue;
|
||
jsCallbacks.get(entry.handlerId)?.({
|
||
event,
|
||
id: listener.eventId,
|
||
payload,
|
||
});
|
||
}
|
||
},
|
||
install() {
|
||
const target = window as unknown as MutableWindow;
|
||
const internals = {
|
||
invoke: async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'plugin:event|listen') {
|
||
const event = String(args?.event ?? '');
|
||
const eventId = nextEventId++;
|
||
listenInvokeCounts.set(
|
||
event,
|
||
(listenInvokeCounts.get(event) ?? 0) + 1,
|
||
);
|
||
const listener: FakeListener = {
|
||
event,
|
||
eventId,
|
||
handlerId: Number(args?.handler ?? 0),
|
||
target: args?.target,
|
||
};
|
||
listeners.push(listener);
|
||
// 注册表条目由 listen 之后一次独立 eval 写入,这里只排队。
|
||
registrations.push(listener);
|
||
return eventId;
|
||
}
|
||
if (command === 'plugin:event|unlisten') {
|
||
const event = String(args?.event ?? '');
|
||
const eventId = Number(args?.eventId ?? 0);
|
||
unlistenInvokeCounts.set(
|
||
event,
|
||
(unlistenInvokeCounts.get(event) ?? 0) + 1,
|
||
);
|
||
const index = listeners.findIndex(
|
||
(item) => item.event === event && item.eventId === eventId,
|
||
);
|
||
if (index >= 0) listeners.splice(index, 1);
|
||
return null;
|
||
}
|
||
if (options.invoke) return options.invoke(command, args);
|
||
return null;
|
||
},
|
||
transformCallback: (callback: (eventData: unknown) => void) => {
|
||
const handlerId = nextHandlerId++;
|
||
jsCallbacks.set(handlerId, callback);
|
||
return handlerId;
|
||
},
|
||
unregisterCallback: (handlerId: number) => {
|
||
jsCallbacks.delete(handlerId);
|
||
},
|
||
};
|
||
|
||
const racyUnregisterListener = (event: string, eventId: number) => {
|
||
racyUnregisterCalls += 1;
|
||
const eventListeners = registry[event];
|
||
if (!eventListeners) return;
|
||
const entry = eventListeners[eventId];
|
||
if (!entry) {
|
||
racyUnregisterViolations.push(`${event}#${eventId}`);
|
||
// tauri 2.11 unlisten_js_script 的真实抛错点。
|
||
throw new TypeError(
|
||
"undefined is not an object (evaluating 'listeners[eventId].handlerId')",
|
||
);
|
||
}
|
||
jsCallbacks.delete(entry.handlerId);
|
||
};
|
||
|
||
// 全局桥(withGlobalTauri):按库内实现返回会读注册表的注销函数。
|
||
const bridgeListen = async (
|
||
event: string,
|
||
handler: (eventData: unknown) => void,
|
||
) => {
|
||
bridgeListenCalls += 1;
|
||
const handlerId = internals.transformCallback(handler);
|
||
const eventId = Number(
|
||
(await internals.invoke('plugin:event|listen', {
|
||
event,
|
||
target: { kind: 'Any' },
|
||
handler: handlerId,
|
||
})) as number,
|
||
);
|
||
return () => {
|
||
racyUnregisterListener(event, eventId);
|
||
void internals.invoke('plugin:event|unlisten', { event, eventId });
|
||
};
|
||
};
|
||
|
||
const globals: Array<[string, unknown]> = [
|
||
[INTERNALS_KEY, internals],
|
||
[EVENT_INTERNALS_KEY, { unregisterListener: racyUnregisterListener }],
|
||
[
|
||
GLOBAL_KEY,
|
||
{
|
||
core: { invoke: internals.invoke },
|
||
event: {
|
||
listen: (event: string, handler: (eventData: unknown) => void) =>
|
||
bridgeListen(event, handler),
|
||
},
|
||
},
|
||
],
|
||
];
|
||
installedGlobals = globals.map(([key]) => ({
|
||
key,
|
||
had: key in target,
|
||
value: target[key],
|
||
}));
|
||
for (const [key, value] of globals) target[key] = value;
|
||
},
|
||
restore() {
|
||
const target = window as unknown as MutableWindow;
|
||
for (const entry of installedGlobals) {
|
||
if (entry.had) target[entry.key] = entry.value;
|
||
else delete target[entry.key];
|
||
}
|
||
installedGlobals = [];
|
||
},
|
||
};
|
||
|
||
return fake;
|
||
}
|