140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
// tests/unit/renderer/lib/bootstrap-single-flight.test.ts
|
|
//
|
|
// 守卫(r206,2026-10-20):bootstrap 的**单飞**(single-flight)契约。
|
|
//
|
|
// 起因(r85 recon):main.tsx 后台 IIFE 第一行发 bootstrap,App.tsx passive
|
|
// effect 又「幂等兜底」地看 usePathsStore.loaded 决定要不要再发 —— 但
|
|
// React passive effect flush(<16ms)永远早于 bootstrap IPC 往返(50-200ms),
|
|
// loaded 此刻必然还是 false,所以**每次冷启都并发两次 bootstrap IPC**
|
|
// (dev StrictMode 三次)。main.tsx 里那句「loaded 守卫保证不会跑 legacy
|
|
// fallback」是**不生效的假守卫**。
|
|
//
|
|
// 契约(本文件逐条钉):
|
|
// - 并发调用命中同一 promise ⇒ 只发**一次** IPC;
|
|
// - 成功结果(**含 ok:false 的合法回复**)被缓存 ⇒ 不重发 —— 与
|
|
// main.tsx「失败后置 loaded=true」的「本轮启动尝试过」语义一致;
|
|
// - rejection(桥未就绪 / IPC 抛)清缓存 ⇒ 瞬时 race 之后重调可再试
|
|
// (这才是原注释「幂等兜底」想表达却没做到的行为);
|
|
// - 桥缺失(api.bootstrap 不是函数)转成 **rejection** 而不是同步 throw
|
|
// —— 符合 r83 契约(调用点带同线 typeof guard,Proxy 属性读安全)。
|
|
//
|
|
// 隔离手段:bootstrap.ts 的 inflight 是**模块级**变量 ⇒ 每例
|
|
// vi.resetModules() + 动态 import 拿全新模块实例。
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type { BootstrapPayload, IpcResult } from '@shared/types';
|
|
|
|
const OK_PAYLOAD = {
|
|
settings: {} as BootstrapPayload['settings'],
|
|
paths: {} as BootstrapPayload['paths'],
|
|
} as BootstrapPayload;
|
|
|
|
async function freshBootstrap() {
|
|
vi.resetModules();
|
|
return await import('@renderer/lib/bootstrap');
|
|
}
|
|
|
|
function setBridge(value: unknown): void {
|
|
(window as unknown as { api?: unknown }).api = value;
|
|
}
|
|
|
|
afterEach(() => {
|
|
setBridge(undefined);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('bootstrap 单飞(r206)', () => {
|
|
it('并发调用只发一次 IPC', async () => {
|
|
const mod = await freshBootstrap();
|
|
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
|
setBridge({ bootstrap });
|
|
|
|
const [a, b] = await Promise.all([mod.bootstrapOnce(), mod.bootstrapOnce()]);
|
|
|
|
expect(bootstrap).toHaveBeenCalledTimes(1);
|
|
expect(a).toBe(b); // 同一个 promise ⇒ 同一个结果对象
|
|
});
|
|
|
|
it('三个并发调用仍只发一次', async () => {
|
|
const mod = await freshBootstrap();
|
|
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
|
setBridge({ bootstrap });
|
|
|
|
await Promise.all([mod.bootstrapOnce(), mod.bootstrapOnce(), mod.bootstrapOnce()]);
|
|
|
|
expect(bootstrap).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('成功结果被缓存:后续调用不再发 IPC', async () => {
|
|
const mod = await freshBootstrap();
|
|
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
|
setBridge({ bootstrap });
|
|
|
|
await mod.bootstrapOnce();
|
|
await mod.bootstrapOnce();
|
|
|
|
expect(bootstrap).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('ok:false 的合法回复也缓存(不重发)', async () => {
|
|
const mod = await freshBootstrap();
|
|
const bootstrap = vi
|
|
.fn()
|
|
.mockResolvedValue({ ok: false, code: 'INTERNAL', message: 'x' } as IpcResult<BootstrapPayload>);
|
|
setBridge({ bootstrap });
|
|
|
|
const first = await mod.bootstrapOnce();
|
|
const second = await mod.bootstrapOnce();
|
|
|
|
expect(bootstrap).toHaveBeenCalledTimes(1);
|
|
expect(first).toBe(second);
|
|
expect(first.ok).toBe(false);
|
|
});
|
|
|
|
it('rejection 清缓存:之后重调能再试(幂等兜底真的兜住了)', async () => {
|
|
const mod = await freshBootstrap();
|
|
const bootstrap = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('bridge race'))
|
|
.mockResolvedValueOnce({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
|
setBridge({ bootstrap });
|
|
|
|
await expect(mod.bootstrapOnce()).rejects.toThrow('bridge race');
|
|
const retry = await mod.bootstrapOnce();
|
|
|
|
expect(bootstrap).toHaveBeenCalledTimes(2);
|
|
expect(retry.ok).toBe(true);
|
|
});
|
|
|
|
it('桥缺失 ⇒ 返回 rejected promise(不是同步 throw)', async () => {
|
|
const mod = await freshBootstrap();
|
|
setBridge(undefined);
|
|
|
|
// 同步 throw 会让调用点的 try/catch 之外代码炸掉;契约要求是 rejection。
|
|
let promise: Promise<IpcResult<BootstrapPayload>> | null = null;
|
|
expect(() => {
|
|
promise = mod.bootstrapOnce();
|
|
}).not.toThrow();
|
|
await expect(promise!).rejects.toThrow(/bridge missing/);
|
|
});
|
|
|
|
it('桥在但方法不是函数 ⇒ 同样转成 rejection', async () => {
|
|
const mod = await freshBootstrap();
|
|
setBridge({ bootstrap: 'not-a-function' });
|
|
|
|
await expect(mod.bootstrapOnce()).rejects.toThrow(/bridge missing/);
|
|
});
|
|
|
|
it('单飞是**跨调用**的:第二个 awaiter 拿到的还是第一次的结果', async () => {
|
|
const mod = await freshBootstrap();
|
|
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
|
|
setBridge({ bootstrap });
|
|
|
|
const p1 = mod.bootstrapOnce();
|
|
const p2 = mod.bootstrapOnce();
|
|
expect(p1).toBe(p2); // 连 promise 引用都相同
|
|
await p1;
|
|
await p2;
|
|
expect(bootstrap).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|