// @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('真实 WebView 内不经过会读注册表的库内注销脚本', async () => { fake = createTauriEventFake(); fake.install(); const unsubscribe = await subscribeTauriEvent( 'planning-session-v2-stream', vi.fn(), ); unsubscribe(); // 库内 listen 一次都没用到:AGC 自己走 event 插件 invoke。 expect(fake.bridgeListenCalls).toBe(0); expect(fake.racyUnregisterCalls).toBe(0); expect(fake.racyUnregisterViolations).toEqual([]); }); it('没有原生 internals 时回落到注入的事件桥接(jsdom 替身 / 浏览器预览)', async () => { fake = createTauriEventFake(); fake.install(); // 只留全局桥,模拟各套件里 `event: { listen }` 的替身环境。 delete (window as unknown as Record).__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).__TAURI__; delete (window as unknown as Record).__TAURI_INTERNALS__; expect(canSubscribeTauriEvents()).toBe(false); const unsubscribe = await subscribeTauriEvent( 'error-report-updated', vi.fn(), ); expect(typeof unsubscribe).toBe('function'); expect(() => unsubscribe()).not.toThrow(); }); });