70271b408e
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m35s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m39s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m13s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Backend tests (push) Successful in 9m16s
Project CI / Repository checks (push) Successful in 8m11s
Project CI / Native shell tests (push) Successful in 11m56s
Project CI / Frontend tests (push) Successful in 11m49s
Project CI / AI game creator shell web tests (push) Successful in 6m37s
Reviewed-on: #355 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
163 lines
5.3 KiB
TypeScript
163 lines
5.3 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
/**
|
||
* 「注销竞态」回归用例(tauri-apps/tauri#15799):
|
||
* 运行模块切换版本时会重渲染并成对触发 AGC 的事件订阅/注销,`React.StrictMode`
|
||
* 更是挂载即注销。tauri 2.11 的库内注销脚本会先读监听注册表条目,而条目由注册
|
||
* eval 异步写入,于是「订阅后立刻注销」会抛
|
||
* `undefined is not an object (evaluating 'listeners[eventId].handlerId')`,
|
||
* 且抛在 invoke 之前 → 后端订阅泄漏。这里用与 tauri 2.11 等价的替身锁死行为。
|
||
*/
|
||
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import {
|
||
canSubscribeTauriEvents,
|
||
subscribeTauriEvent,
|
||
} from '../src/services/tauriEventSubscription';
|
||
import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
|
||
|
||
let fake: TauriEventFake | null = null;
|
||
|
||
afterEach(() => {
|
||
fake?.restore();
|
||
fake = null;
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('subscribeTauriEvent', () => {
|
||
it('注册 eval 还没落地就注销:不抛错、不读注册表条目、不漏后端订阅', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
|
||
const handler = vi.fn();
|
||
const unsubscribe = await subscribeTauriEvent(
|
||
'game-creator-agent-progress',
|
||
handler,
|
||
);
|
||
expect(fake.pendingRegistrationEvals()).toBe(1);
|
||
|
||
// 注册 eval 还没落地(tauri 2.11 的竞态窗口):注销必须安全。
|
||
unsubscribe();
|
||
|
||
expect(fake.racyUnregisterViolations).toEqual([]);
|
||
expect(fake.racyUnregisterCalls).toBe(0);
|
||
expect(fake.listeners).toEqual([]);
|
||
expect(fake.jsCallbackCount()).toBe(0);
|
||
expect(fake.unlistenInvokeCounts.get('game-creator-agent-progress')).toBe(
|
||
1,
|
||
);
|
||
});
|
||
|
||
it('同一事件已有注册条目落地时,注销还没落地的订阅也不该抛', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
|
||
// 先让第一个订阅的注册 eval 落地:该事件的监听对象因此存在。
|
||
const firstUnsubscribe = await subscribeTauriEvent(
|
||
'game-creator-agent-runtime-update',
|
||
vi.fn(),
|
||
);
|
||
fake.flushRegistrationEvals();
|
||
|
||
// 重订阅(StrictMode 二次挂载 / 依赖变化都会重订阅),它的注册 eval 还没落地。
|
||
// 这就是报错现场:事件对象存在、新 eventId 的条目不存在。
|
||
const secondUnsubscribe = await subscribeTauriEvent(
|
||
'game-creator-agent-runtime-update',
|
||
vi.fn(),
|
||
);
|
||
secondUnsubscribe();
|
||
|
||
expect(fake.racyUnregisterViolations).toEqual([]);
|
||
expect(
|
||
fake.unlistenInvokeCounts.get('game-creator-agent-runtime-update'),
|
||
).toBe(1);
|
||
expect(fake.listeners).toHaveLength(1);
|
||
expect(fake.jsCallbackCount()).toBe(1);
|
||
|
||
firstUnsubscribe();
|
||
expect(fake.listeners).toEqual([]);
|
||
expect(fake.jsCallbackCount()).toBe(0);
|
||
});
|
||
|
||
it('重复注销只摘一次后端订阅', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
|
||
const unsubscribe = await subscribeTauriEvent(
|
||
'game-creator-manifest-invalidated',
|
||
vi.fn(),
|
||
);
|
||
unsubscribe();
|
||
unsubscribe();
|
||
unsubscribe();
|
||
|
||
expect(
|
||
fake.unlistenInvokeCounts.get('game-creator-manifest-invalidated'),
|
||
).toBe(1);
|
||
expect(fake.racyUnregisterViolations).toEqual([]);
|
||
});
|
||
|
||
it('注册 eval 落地后事件能投递,注销后不再投递', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
|
||
const handler = vi.fn();
|
||
const unsubscribe = await subscribeTauriEvent<{ message: string }>(
|
||
'game-creator-agent-progress',
|
||
handler,
|
||
);
|
||
fake.flushRegistrationEvals();
|
||
|
||
fake.emit('game-creator-agent-progress', { message: '正在生成' });
|
||
expect(handler).toHaveBeenCalledTimes(1);
|
||
expect(handler.mock.calls[0][0]).toMatchObject({
|
||
event: 'game-creator-agent-progress',
|
||
payload: { message: '正在生成' },
|
||
});
|
||
|
||
unsubscribe();
|
||
fake.emit('game-creator-agent-progress', { message: '不该再收到' });
|
||
expect(handler).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('没有原生 internals 时回落到注入的事件桥接(jsdom 替身 / 浏览器预览)', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
// 只留全局桥,模拟各套件里 `event: { listen }` 的替身环境。
|
||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||
|
||
expect(canSubscribeTauriEvents()).toBe(true);
|
||
const handler = vi.fn();
|
||
const unsubscribe = await subscribeTauriEvent(
|
||
'game-creator-agent-progress',
|
||
handler,
|
||
);
|
||
expect(fake.bridgeListenCalls).toBe(1);
|
||
|
||
fake.emit('game-creator-agent-progress', { message: 'hi' });
|
||
fake.flushRegistrationEvals();
|
||
fake.emit('game-creator-agent-progress', { message: 'hi' });
|
||
expect(handler).toHaveBeenCalledTimes(1);
|
||
|
||
unsubscribe();
|
||
unsubscribe();
|
||
expect(fake.unlistenInvokeCounts.get('game-creator-agent-progress')).toBe(
|
||
1,
|
||
);
|
||
});
|
||
|
||
it('没有任何桥接时返回空操作', async () => {
|
||
delete (window as unknown as Record<string, unknown>).__TAURI__;
|
||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||
|
||
expect(canSubscribeTauriEvents()).toBe(false);
|
||
const unsubscribe = await subscribeTauriEvent(
|
||
'error-report-updated',
|
||
vi.fn(),
|
||
);
|
||
expect(typeof unsubscribe).toBe('function');
|
||
expect(() => unsubscribe()).not.toThrow();
|
||
});
|
||
});
|