@zhushanwen/pi-network-proxy 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * 分流分发器行为 + 代理 URL 构造(dispatch 拦截点 / noProxy 优先 / 计数时点 / 委托语义)。
3
+ * 两协议构造形态冒烟在 dispatcher-construction.test.ts(vi.mock undici 隔离)。
4
+ */
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { Dispatcher } from "undici";
7
+ import type { Dispatcher as IDispatcher } from "undici";
8
+
9
+ import { buildProxyUrl, RuleDispatchDispatcher } from "../dispatcher.ts";
10
+ import type { NetworkProxyServer } from "../config.ts";
11
+
12
+ class RecordingDispatcher extends Dispatcher {
13
+ calls: Array<{ origin: string | URL | undefined }> = [];
14
+ dispatch(opts: IDispatcher.DispatchOptions, _handler: IDispatcher.DispatchHandler): boolean {
15
+ this.calls.push({ origin: opts.origin });
16
+ return true;
17
+ }
18
+ }
19
+
20
+ function configLists(noProxy: string[], rules: string[]) {
21
+ return { noProxy, rules };
22
+ }
23
+
24
+ function dispatchVia(
25
+ dispatcher: RuleDispatchDispatcher,
26
+ origin: string | URL | undefined,
27
+ ): { toProxy: boolean; toFallback: boolean } {
28
+ proxyDispatcher.calls = [];
29
+ fallbackDispatcher.calls = [];
30
+ dispatcher.dispatch({ origin, path: "/", method: "GET" }, {});
31
+ return {
32
+ toProxy: proxyDispatcher.calls.length > 0,
33
+ toFallback: fallbackDispatcher.calls.length > 0,
34
+ };
35
+ }
36
+
37
+ let fallbackDispatcher: RecordingDispatcher;
38
+ let proxyDispatcher: RecordingDispatcher;
39
+ let counted: Array<{ host: string; proxied: boolean }>;
40
+
41
+ function createDispatcher(noProxy: string[], rules: string[]): RuleDispatchDispatcher {
42
+ fallbackDispatcher = new RecordingDispatcher();
43
+ proxyDispatcher = new RecordingDispatcher();
44
+ counted = [];
45
+ return new RuleDispatchDispatcher({
46
+ fallback: fallbackDispatcher,
47
+ proxy: proxyDispatcher,
48
+ ...configLists(noProxy, rules),
49
+ onCount: (host, proxied) => counted.push({ host, proxied }),
50
+ });
51
+ }
52
+
53
+ describe("RuleDispatchDispatcher 分流行为", () => {
54
+ it("规则命中 → 代理 dispatch + proxied 计数(计数时点 = 发起时,同步完成)", () => {
55
+ const dispatcher = createDispatcher(["localhost"], ["api.anthropic.com"]);
56
+ const result = dispatchVia(dispatcher, "https://api.anthropic.com/v1/messages");
57
+ expect(result).toEqual({ toProxy: true, toFallback: false });
58
+ expect(counted).toEqual([{ host: "api.anthropic.com", proxied: true }]);
59
+ });
60
+
61
+ it("规则未命中 → 委托原分发器(fallback)+ direct 计数", () => {
62
+ const dispatcher = createDispatcher(["localhost"], ["api.anthropic.com"]);
63
+ const result = dispatchVia(dispatcher, "https://open.bigmodel.cn/api");
64
+ expect(result).toEqual({ toProxy: false, toFallback: true });
65
+ expect(counted).toEqual([{ host: "open.bigmodel.cn", proxied: false }]);
66
+ });
67
+
68
+ it("noProxy 优先于 rules:同主机名两侧都出现时直连(决策 7)", () => {
69
+ const dispatcher = createDispatcher(["api.anthropic.com"], ["api.anthropic.com"]);
70
+ const result = dispatchVia(dispatcher, "https://api.anthropic.com/v1/messages");
71
+ expect(result).toEqual({ toProxy: false, toFallback: true });
72
+ expect(counted).toEqual([{ host: "api.anthropic.com", proxied: false }]);
73
+ });
74
+
75
+ it("子域命中 + 点边界:子域走代理,前缀拼接不命中走 fallback", () => {
76
+ const dispatcher = createDispatcher([], ["api.anthropic.com"]);
77
+ expect(dispatchVia(dispatcher, "https://eu.api.anthropic.com/v1").toProxy).toBe(true);
78
+ expect(dispatchVia(dispatcher, "https://xapi.anthropic.com/v1").toFallback).toBe(true);
79
+ });
80
+
81
+ it("归一化参与分流:大写 / 末尾点 origin 命中规则", () => {
82
+ const dispatcher = createDispatcher([], ["api.anthropic.com"]);
83
+ expect(dispatchVia(dispatcher, "https://API.Anthropic.COM./v1").toProxy).toBe(true);
84
+ });
85
+
86
+ it("URL 对象形态的 origin 同样处理", () => {
87
+ const dispatcher = createDispatcher([], ["api.anthropic.com"]);
88
+ expect(dispatchVia(dispatcher, new URL("https://api.anthropic.com/v1")).toProxy).toBe(true);
89
+ });
90
+
91
+ it("origin 缺失(无已发生证据的异常路径)→ 委托 fallback + direct 计数,不抛", () => {
92
+ const dispatcher = createDispatcher([], ["api.anthropic.com"]);
93
+ expect(() => dispatchVia(dispatcher, undefined)).not.toThrow();
94
+ expect(counted).toEqual([{ host: "", proxied: false }]);
95
+ });
96
+
97
+ it("计数按主机名聚合可累计(多次请求同主机名同键累加)", () => {
98
+ const dispatcher = createDispatcher([], ["api.anthropic.com"]);
99
+ dispatchVia(dispatcher, "https://api.anthropic.com/a");
100
+ dispatchVia(dispatcher, "https://eu.api.anthropic.com/b");
101
+ expect(counted).toEqual([
102
+ { host: "api.anthropic.com", proxied: true },
103
+ { host: "eu.api.anthropic.com", proxied: true },
104
+ ]);
105
+ });
106
+
107
+ it("handler 原样透传给目标分发器(未命中委托语义 = pi 原生行为不变)", () => {
108
+ const dispatcher = createDispatcher([], []);
109
+ const handler = { onHeaders: vi.fn() } as unknown as IDispatcher.DispatchHandler;
110
+ const opts = { origin: "https://any.test", path: "/", method: "GET" } as IDispatcher.DispatchOptions;
111
+ const seen: Array<unknown> = [];
112
+ fallbackDispatcher = new RecordingDispatcher();
113
+ vi.spyOn(fallbackDispatcher, "dispatch").mockImplementation((o, h) => {
114
+ seen.push({ o, h });
115
+ return true;
116
+ });
117
+ const relay = new RuleDispatchDispatcher({
118
+ fallback: fallbackDispatcher,
119
+ proxy: new RecordingDispatcher(),
120
+ noProxy: [],
121
+ rules: [],
122
+ onCount: () => {},
123
+ });
124
+ relay.dispatch(opts, handler);
125
+ expect(seen).toHaveLength(1);
126
+ expect((seen[0] as { h: unknown }).h).toBe(handler);
127
+ });
128
+ });
129
+
130
+ describe("buildProxyUrl(凭据 percent-encode 拼入)", () => {
131
+ const base: NetworkProxyServer = {
132
+ protocol: "http",
133
+ host: "127.0.0.1",
134
+ port: 7890,
135
+ username: "",
136
+ password: "",
137
+ };
138
+
139
+ it("无凭据形态", () => {
140
+ expect(buildProxyUrl(base)).toBe("http://127.0.0.1:7890");
141
+ });
142
+
143
+ it("凭据 percent-encode 拼入(特殊字符被编码,undici 构造内部 decodeURIComponent 还原)", () => {
144
+ expect(buildProxyUrl({ ...base, username: "user@example.com", password: "p@ss:word" })).toBe(
145
+ "http://user%40example.com:p%40ss%3Aword@127.0.0.1:7890",
146
+ );
147
+ });
148
+
149
+ it("socks5 协议形态(Socks5ProxyAgent 校验 socks5: 协议头)", () => {
150
+ expect(buildProxyUrl({ ...base, protocol: "socks5", port: 1080 })).toBe("socks5://127.0.0.1:1080");
151
+ });
152
+
153
+ it("https 协议形态", () => {
154
+ expect(buildProxyUrl({ ...base, protocol: "https", port: 443 })).toBe("https://127.0.0.1:443");
155
+ });
156
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * extension 入口行为单测(真实组件 + tmp agentDir,不 mock undici 全局槽与配置读取链):
3
+ * 工厂加载序列(校验过 → 安装分流器 / 非法 → error + 全直连)、turn 粒度热生效
4
+ * (turn_start 重读配置、turn_end flush 计数)。vi.resetModules 隔离模块级 installed 状态。
5
+ * fs-guard 白名单:写入目标 = PI_CODING_AGENT_DIR 注入的 mkdtemp tmp 目录。
6
+ */
7
+ import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { getGlobalDispatcher, setGlobalDispatcher, type Dispatcher } from "undici";
12
+
13
+ const { loggerMock } = vi.hoisted(() => ({
14
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
15
+ }));
16
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
17
+ getLogger: () => loggerMock,
18
+ createLogger: () => loggerMock,
19
+ setPiHandle: vi.fn(),
20
+ }));
21
+
22
+ import { isRuleDispatchDispatcher, RuleDispatchDispatcher } from "../dispatcher.ts";
23
+
24
+ /** fake pi ExtensionAPI:收集 on 注册的 handler,emit 触发。 */
25
+ function createFakePi() {
26
+ const handlers = new Map<string, Array<() => void>>();
27
+ const pi = {
28
+ on: (event: string, handler: () => void) => {
29
+ const list = handlers.get(event) ?? [];
30
+ list.push(handler);
31
+ handlers.set(event, list);
32
+ },
33
+ } as unknown as import("@earendil-works/pi-coding-agent").ExtensionAPI;
34
+ return {
35
+ pi,
36
+ emit: (event: "turn_start" | "turn_end") => {
37
+ for (const h of handlers.get(event) ?? []) h();
38
+ },
39
+ };
40
+ }
41
+
42
+ const ENABLED_CONFIG = {
43
+ enabled: true,
44
+ proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
45
+ noProxy: ["localhost"],
46
+ rules: ["api.anthropic.com"],
47
+ };
48
+
49
+ let agentDir: string;
50
+ let savedDispatcher: Dispatcher;
51
+
52
+ function writeConfigFile(content: string): void {
53
+ mkdirSync(join(agentDir, "config"), { recursive: true });
54
+ writeFileSync(join(agentDir, "config", "network-proxy-ext-config.json"), content);
55
+ }
56
+
57
+ beforeEach(() => {
58
+ agentDir = mkdtempSync(join(tmpdir(), "network-proxy-index-"));
59
+ vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
60
+ // 保存 undici 全局槽,afterEach 恢复(工厂安装/摘除都改写这个槽)
61
+ savedDispatcher = getGlobalDispatcher();
62
+ loggerMock.error.mockClear();
63
+ });
64
+
65
+ afterEach(() => {
66
+ setGlobalDispatcher(savedDispatcher);
67
+ vi.unstubAllEnvs();
68
+ vi.resetModules();
69
+ });
70
+
71
+ describe("networkProxyExtension 工厂加载序列", () => {
72
+ it("enabled 且校验过:安装分流器到全局槽(识别走 RuleDispatchDispatcher)", async () => {
73
+ writeConfigFile(JSON.stringify(ENABLED_CONFIG));
74
+ const { default: networkProxyExtension } = await import("../index.ts");
75
+ networkProxyExtension(createFakePi().pi);
76
+ expect(isRuleDispatchDispatcher(getGlobalDispatcher())).toBe(true);
77
+ expect(getGlobalDispatcher()).toBeInstanceOf(RuleDispatchDispatcher);
78
+ expect(loggerMock.error).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("enabled=false:不安装(槽保持 pi 原生分发器),零 error", async () => {
82
+ writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, enabled: false }));
83
+ const { default: networkProxyExtension } = await import("../index.ts");
84
+ networkProxyExtension(createFakePi().pi);
85
+ expect(getGlobalDispatcher()).toBe(savedDispatcher);
86
+ expect(loggerMock.error).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it("非法配置(坏 JSON):不抛、不安装(全部直连),error 含恢复动作(配置文件路径)", async () => {
90
+ writeConfigFile("{ not valid json");
91
+ const { default: networkProxyExtension } = await import("../index.ts");
92
+ expect(() => networkProxyExtension(createFakePi().pi)).not.toThrow();
93
+ expect(getGlobalDispatcher()).toBe(savedDispatcher);
94
+ const messages = loggerMock.error.mock.calls.map((c) => String(c[0]));
95
+ expect(messages.some((m) => m.includes("network-proxy-ext-config.json"))).toBe(true);
96
+ });
97
+
98
+ it("非法字段(端口越界):loadConfig 回落 defaults(enabled=false → 不安装)+ error 可归因", async () => {
99
+ writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, proxy: { ...ENABLED_CONFIG.proxy, port: 99999 } }));
100
+ const { default: networkProxyExtension } = await import("../index.ts");
101
+ expect(() => networkProxyExtension(createFakePi().pi)).not.toThrow();
102
+ expect(getGlobalDispatcher()).toBe(savedDispatcher);
103
+ expect(loggerMock.error.mock.calls.length).toBeGreaterThan(0);
104
+ });
105
+ });
106
+
107
+ describe("turn 粒度热生效", () => {
108
+ it("turn_start 重读配置:指纹变化重建(槽上换新分流器),未变零动作", async () => {
109
+ writeConfigFile(JSON.stringify(ENABLED_CONFIG));
110
+ const { default: networkProxyExtension } = await import("../index.ts");
111
+ const { pi, emit } = createFakePi();
112
+ networkProxyExtension(pi);
113
+ const first = getGlobalDispatcher();
114
+
115
+ // 未变配置:同分发器(不重建)
116
+ emit("turn_start");
117
+ expect(getGlobalDispatcher()).toBe(first);
118
+
119
+ // 改写配置(size 变化 → mtime+size 缓存失效):指纹变化重建
120
+ writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, rules: ["api.openai.com"] }));
121
+ emit("turn_start");
122
+ const second = getGlobalDispatcher();
123
+ expect(second).not.toBe(first);
124
+ expect(isRuleDispatchDispatcher(second)).toBe(true);
125
+ });
126
+
127
+ it("turn_end flush 计数增量到 stats JSONL(recordDispatch → turn_end → 落盘行)", async () => {
128
+ writeConfigFile(JSON.stringify(ENABLED_CONFIG));
129
+ const { default: networkProxyExtension } = await import("../index.ts");
130
+ // resetModules 后 index.ts 与此处 import 的 stats.ts 是同一模块图刷新的同一实例
131
+ const { recordDispatch } = await import("../stats.ts");
132
+ const { statsFilePath } = await import("../stats.ts");
133
+ const { pi, emit } = createFakePi();
134
+ networkProxyExtension(pi);
135
+
136
+ recordDispatch("api.anthropic.com", true);
137
+ recordDispatch("open.bigmodel.cn", false);
138
+ emit("turn_end");
139
+
140
+ const lines = readFileSync(statsFilePath(), "utf-8").split("\n").filter((l) => l !== "");
141
+ expect(lines).toHaveLength(2);
142
+ const hosts = lines.map((l) => (JSON.parse(l) as { host: string }).host).sort();
143
+ expect(hosts).toEqual(["api.anthropic.com", "open.bigmodel.cn"]);
144
+ });
145
+
146
+ it("enabled true→false:turn_start 后恢复 pi 原生分发器(禁用生效)", async () => {
147
+ writeConfigFile(JSON.stringify(ENABLED_CONFIG));
148
+ const { default: networkProxyExtension } = await import("../index.ts");
149
+ const { pi, emit } = createFakePi();
150
+ networkProxyExtension(pi);
151
+ expect(isRuleDispatchDispatcher(getGlobalDispatcher())).toBe(true);
152
+
153
+ writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, enabled: false }));
154
+ emit("turn_start");
155
+ expect(getGlobalDispatcher()).toBe(savedDispatcher);
156
+ });
157
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * 安装状态机单测(fake 依赖注入,不 mock undici 全局槽):
3
+ * 指纹比对重建 / 禁用恢复原分发器 / 「构造失败 → 不安装 → 全直连」mock 注入(任务书验收②)。
4
+ * 转移表见 installer.ts 文件头。
5
+ */
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { Dispatcher } from "undici";
8
+
9
+ import type { NetworkProxyConfig } from "../config.ts";
10
+ import { RuleDispatchDispatcher } from "../dispatcher.ts";
11
+ import {
12
+ applyNetworkProxyConfig,
13
+ detachResidualDispatcher,
14
+ type InstalledState,
15
+ type InstallerDeps,
16
+ } from "../installer.ts";
17
+
18
+ class FakeDispatcher extends Dispatcher {
19
+ readonly label: string;
20
+ constructor(label: string) {
21
+ super();
22
+ this.label = label;
23
+ }
24
+ }
25
+
26
+ function config(overrides?: Partial<NetworkProxyConfig>): NetworkProxyConfig {
27
+ return {
28
+ enabled: true,
29
+ proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
30
+ noProxy: ["localhost"],
31
+ rules: ["api.anthropic.com"],
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ /** proxy 字段深覆盖辅助(overrides 是浅合并——protocol/port 必须写进 proxy 对象才进指纹)。 */
37
+ function withProxy(patch: Partial<NetworkProxyConfig["proxy"]>): Partial<NetworkProxyConfig> {
38
+ return { proxy: { ...config().proxy, ...patch } };
39
+ }
40
+
41
+ /** fake 依赖装配:original 捕获、set 调用记录、可注入的构造器(默认成功)。
42
+ * original 可注入共享(真实场景进程内只有一个原分发器——跨 deps 实例传状态时恢复的是
43
+ * 首装 deps 捕获的同一实例)。 */
44
+ function createDeps(
45
+ buildProxy: (c: NetworkProxyConfig) => FakeDispatcher = () => new FakeDispatcher("proxy"),
46
+ sharedOriginal = new FakeDispatcher("original"),
47
+ ) {
48
+ const original = sharedOriginal;
49
+ // get/set 连通同一槽变量(与真实全局槽语义一致:set 改写 get 的返回)——
50
+ // 槽位归属核查(installer 同指纹分支)依赖该语义判「槽上是谁」。
51
+ let slot: FakeDispatcher = original;
52
+ const setCalls: FakeDispatcher[] = [];
53
+ const errors: string[] = [];
54
+ const deps: InstallerDeps & { original: FakeDispatcher; setCalls: FakeDispatcher[]; errors: string[] } = {
55
+ getGlobalDispatcher: () => slot,
56
+ setGlobalDispatcher: (d) => {
57
+ slot = d as FakeDispatcher;
58
+ setCalls.push(d as FakeDispatcher);
59
+ },
60
+ buildProxy,
61
+ onError: (message) => errors.push(message),
62
+ original,
63
+ setCalls,
64
+ errors,
65
+ };
66
+ return deps;
67
+ }
68
+
69
+ describe("applyNetworkProxyConfig 状态机", () => {
70
+ it("首装 enabled:捕获 original → 构造 → 安装,状态装载", () => {
71
+ const deps = createDeps();
72
+ const state = applyNetworkProxyConfig(config(), null, deps);
73
+ expect(state).not.toBeNull();
74
+ expect(state?.current).toBeInstanceOf(RuleDispatchDispatcher);
75
+ expect(state?.originalDispatcher).toBe(deps.original);
76
+ expect(deps.setCalls).toHaveLength(1);
77
+ expect(deps.errors).toHaveLength(0);
78
+ });
79
+
80
+ it("指纹未变:不重建不重复安装(setGlobalDispatcher 零调用)", () => {
81
+ const deps = createDeps();
82
+ const state = applyNetworkProxyConfig(config(), null, deps);
83
+ expect(deps.setCalls).toHaveLength(1);
84
+ const again = applyNetworkProxyConfig(config(), state, deps);
85
+ expect(again).toBe(state);
86
+ expect(deps.setCalls).toHaveLength(1);
87
+ });
88
+
89
+ it("指纹变化:撤旧装新,original 引用跨重建保留", () => {
90
+ const deps = createDeps();
91
+ const first = applyNetworkProxyConfig(config(), null, deps);
92
+ const changed = applyNetworkProxyConfig(
93
+ config({ rules: ["api.openai.com"] }),
94
+ first,
95
+ deps,
96
+ );
97
+ expect(changed?.current).not.toBe(first?.current);
98
+ expect(changed?.originalDispatcher).toBe(deps.original);
99
+ expect(deps.setCalls).toHaveLength(2);
100
+ });
101
+
102
+ it("enabled true→false:恢复原分发器(槽上回到 original)+ 状态清空", () => {
103
+ const deps = createDeps();
104
+ const installed = applyNetworkProxyConfig(config(), null, deps);
105
+ const after = applyNetworkProxyConfig(config({ enabled: false }), installed, deps);
106
+ expect(after).toBeNull();
107
+ expect(deps.setCalls).toHaveLength(2);
108
+ expect(deps.setCalls[1]).toBe(deps.original);
109
+ });
110
+
111
+ it("首装即禁用(enabled=false + 无状态):零动作", () => {
112
+ const deps = createDeps();
113
+ expect(applyNetworkProxyConfig(config({ enabled: false }), null, deps)).toBeNull();
114
+ expect(deps.setCalls).toHaveLength(0);
115
+ expect(deps.errors).toHaveLength(0);
116
+ });
117
+
118
+ it("构造失败(mock 注入抛错):onError 上报 + 不安装(setGlobalDispatcher 零调用)——全直连", () => {
119
+ const deps = createDeps(() => {
120
+ throw new TypeError("this[kConnector] is not a function");
121
+ });
122
+ const state = applyNetworkProxyConfig(config(), null, deps);
123
+ expect(state?.current).toBeNull();
124
+ expect(deps.setCalls).toHaveLength(0); // 槽上仍是 pi 原生分发器 = 全部直连
125
+ expect(deps.errors).toHaveLength(1);
126
+ expect(deps.errors[0]).toContain("构造失败");
127
+ expect(deps.errors[0]).toContain("直连");
128
+ });
129
+
130
+ it("锁定态同指纹不重试构造;指纹变化后重试(用户修正配置自然恢复)", () => {
131
+ let shouldThrow = true;
132
+ const deps = createDeps(() => {
133
+ if (shouldThrow) throw new Error("boom");
134
+ return new FakeDispatcher("proxy");
135
+ });
136
+ const locked = applyNetworkProxyConfig(config(), null, deps);
137
+ expect(locked?.current).toBeNull();
138
+
139
+ // 同指纹:不重试(fail-fast 首败即停)
140
+ const stillLocked = applyNetworkProxyConfig(config(), locked, deps);
141
+ expect(stillLocked?.current).toBeNull();
142
+ expect(deps.errors).toHaveLength(1);
143
+
144
+ // 用户修正(端口变化 → 指纹变化):重试成功并安装
145
+ shouldThrow = false;
146
+ const recovered = applyNetworkProxyConfig(config(withProxy({ port: 7891 })), stillLocked, deps);
147
+ expect(recovered?.current).toBeInstanceOf(RuleDispatchDispatcher);
148
+ expect(deps.setCalls).toHaveLength(1);
149
+ });
150
+
151
+ it("运行中重建遇构造失败:撤下旧分流器恢复原分发器(全部直连),记指纹锁定", () => {
152
+ const sharedOriginal = new FakeDispatcher("original");
153
+ const deps = createDeps(() => new FakeDispatcher("proxy"), sharedOriginal);
154
+ const installed = applyNetworkProxyConfig(config(), null, deps);
155
+ expect(deps.setCalls).toHaveLength(1);
156
+
157
+ const failing = createDeps(() => {
158
+ throw new Error("socks5 experimental API changed");
159
+ }, sharedOriginal);
160
+ const locked = applyNetworkProxyConfig(config(withProxy({ protocol: "socks5" })), installed, failing);
161
+ expect(locked?.current).toBeNull();
162
+ // 撤旧:set 收到 original(回 pi 原生行为 = 全部直连/env 代理路由)
163
+ expect(failing.setCalls).toHaveLength(1);
164
+ expect(failing.setCalls[0]).toBe(sharedOriginal);
165
+ expect(failing.errors[0]).toContain("socks5");
166
+ });
167
+
168
+ it("锁定态再遇禁用:无槽操作、状态清空(构造失败后关开关 = 干净退出)", () => {
169
+ const deps = createDeps(() => {
170
+ throw new Error("boom");
171
+ });
172
+ const locked = applyNetworkProxyConfig(config(), null, deps);
173
+ const after = applyNetworkProxyConfig(config({ enabled: false }), locked, deps);
174
+ expect(after).toBeNull();
175
+ expect(deps.setCalls).toHaveLength(0);
176
+ });
177
+
178
+ it("安装状态的 current 即 setGlobalDispatcher 收到的实例(槽与状态一致)", () => {
179
+ const deps = createDeps();
180
+ const state: InstalledState | null = applyNetworkProxyConfig(config(), null, deps);
181
+ expect(deps.setCalls[0]).toBe(state?.current);
182
+ expect(vi.isMockFunction(deps.onError)).toBe(false);
183
+ });
184
+
185
+ // ── 槽位归属核查(pi 启动序列在 extension 安装后重跑 configureHttpDispatcher 整槽覆盖,
186
+ // dist main.js:707 实证——指纹未变也必须重装,否则会话整段生命周期不走代理)──
187
+
188
+ /** 可变槽 deps:getGlobalDispatcher 返回当前槽变量(覆盖测试用,createDeps 槽固定)。 */
189
+ function createMutableSlotDeps(initialSlot: FakeDispatcher) {
190
+ let slot: FakeDispatcher = initialSlot;
191
+ const setCalls: FakeDispatcher[] = [];
192
+ const errors: string[] = [];
193
+ const deps: InstallerDeps & { setCalls: FakeDispatcher[]; errors: string[]; setSlot: (d: FakeDispatcher) => void } = {
194
+ getGlobalDispatcher: () => slot,
195
+ setGlobalDispatcher: (d) => {
196
+ slot = d as FakeDispatcher;
197
+ setCalls.push(d as FakeDispatcher);
198
+ },
199
+ buildProxy: () => new FakeDispatcher("proxy"),
200
+ onError: (message) => errors.push(message),
201
+ setCalls,
202
+ errors,
203
+ setSlot: (d) => {
204
+ slot = d;
205
+ },
206
+ };
207
+ return deps;
208
+ }
209
+
210
+ it("同指纹但槽位被外部覆盖(pi 启动重装形态):撤旧装新,fallback = 覆盖者", () => {
211
+ const piNative = new FakeDispatcher("pi-native");
212
+ const deps = createMutableSlotDeps(piNative);
213
+ const first = applyNetworkProxyConfig(config(), null, deps);
214
+ expect(first?.current).not.toBeNull();
215
+ // pi 启动序列整槽覆盖:槽上换成 pi 原生分发器,本扩展状态未感知
216
+ const overwritten = new FakeDispatcher("pi-native-after-reconfigure");
217
+ deps.setSlot(overwritten);
218
+ // 同一配置(指纹未变)下一 turn 推进:必须重装
219
+ const again = applyNetworkProxyConfig(config(), first, deps);
220
+ expect(again?.current).not.toBeNull();
221
+ expect(again?.current).not.toBe(first?.current);
222
+ expect(again?.fingerprint).toBe(first?.fingerprint);
223
+ // 新分流器 fallback = 槽上覆盖者(未命中委托 = 覆盖者的行为)
224
+ expect((again?.current as RuleDispatchDispatcher).fallback).toBe(overwritten);
225
+ // 槽上回到本扩展分发器
226
+ expect(deps.getGlobalDispatcher()).toBe(again?.current);
227
+ });
228
+
229
+ it("同指纹且槽位仍归本扩展:零动作(覆盖核查不误伤既有无动作分支)", () => {
230
+ const deps = createMutableSlotDeps(new FakeDispatcher("pi-native"));
231
+ const first = applyNetworkProxyConfig(config(), null, deps);
232
+ const setCallsAfterInstall = deps.setCalls.length;
233
+ const again = applyNetworkProxyConfig(config(), first, deps);
234
+ expect(again).toBe(first);
235
+ expect(deps.setCalls).toHaveLength(setCallsAfterInstall);
236
+ });
237
+
238
+ it("禁用时槽位已被外部覆盖:不回写(槽保持覆盖者)、状态清空", () => {
239
+ const deps = createMutableSlotDeps(new FakeDispatcher("pi-native"));
240
+ const installed = applyNetworkProxyConfig(config(), null, deps);
241
+ const overwritten = new FakeDispatcher("pi-native-after-reconfigure");
242
+ deps.setSlot(overwritten);
243
+ const setCallsAfterInstall = deps.setCalls.length;
244
+ const after = applyNetworkProxyConfig(config({ enabled: false }), installed, deps);
245
+ expect(after).toBeNull();
246
+ expect(deps.setCalls).toHaveLength(setCallsAfterInstall);
247
+ expect(deps.getGlobalDispatcher()).toBe(overwritten);
248
+ });
249
+ });
250
+
251
+ describe("detachResidualDispatcher(reload 残留摘除)", () => {
252
+ /** 模拟旧模块环境残留在全局槽上的分流器(跨环境识别走 Symbol.for 标记,不走 instanceof)。 */
253
+ function residualDispatcher(original: FakeDispatcher): RuleDispatchDispatcher {
254
+ return new RuleDispatchDispatcher({
255
+ fallback: original,
256
+ proxy: new FakeDispatcher("proxy"),
257
+ noProxy: [],
258
+ rules: [],
259
+ onCount: () => {},
260
+ });
261
+ }
262
+
263
+ it("installed=null 且槽上是残留分流器:恢复其 fallback(旧环境安装时捕获的原分发器)", () => {
264
+ const sharedOriginal = new FakeDispatcher("original");
265
+ const deps = createDeps(() => new FakeDispatcher("proxy"), sharedOriginal);
266
+ const residual = residualDispatcher(sharedOriginal);
267
+ detachResidualDispatcher(null, { ...deps, getGlobalDispatcher: () => residual });
268
+ expect(deps.setCalls).toEqual([sharedOriginal]);
269
+ });
270
+
271
+ it("installed=null 且槽上是 pi 原生分发器(非本扩展):零动作", () => {
272
+ const deps = createDeps();
273
+ detachResidualDispatcher(null, deps);
274
+ expect(deps.setCalls).toHaveLength(0);
275
+ });
276
+
277
+ it("installed 非 null(同环境 factory 重跑):早退零动作——槽上 current 不被误摘", () => {
278
+ const deps = createDeps();
279
+ const state = applyNetworkProxyConfig(config(), null, deps);
280
+ expect(state).not.toBeNull();
281
+ detachResidualDispatcher(state, deps);
282
+ expect(deps.setCalls).toHaveLength(1); // 仅首装那一次,摘除未触发
283
+ });
284
+
285
+ it("reload 后推进状态机:enabled=false 恢复直连(摘除 + 零动作,无旧分流器残留)", () => {
286
+ const sharedOriginal = new FakeDispatcher("original");
287
+ const deps = createDeps(() => new FakeDispatcher("proxy"), sharedOriginal);
288
+ const residual = residualDispatcher(sharedOriginal);
289
+ // reload 形态:新环境 installed=null、槽上是旧环境残留
290
+ detachResidualDispatcher(null, { ...deps, getGlobalDispatcher: () => residual });
291
+ const after = applyNetworkProxyConfig(config({ enabled: false }), null, {
292
+ ...deps,
293
+ getGlobalDispatcher: () => sharedOriginal,
294
+ });
295
+ expect(after).toBeNull();
296
+ expect(deps.setCalls).toEqual([sharedOriginal]); // 摘除那一次;禁用分支零动作
297
+ });
298
+
299
+ it("reload 后推进状态机:enabled=true 捕获的 original 是原分发器(无嵌套双计)", () => {
300
+ const sharedOriginal = new FakeDispatcher("original");
301
+ const deps = createDeps(() => new FakeDispatcher("proxy"), sharedOriginal);
302
+ const residual = residualDispatcher(sharedOriginal);
303
+ detachResidualDispatcher(null, { ...deps, getGlobalDispatcher: () => residual });
304
+ const state = applyNetworkProxyConfig(config(), null, {
305
+ ...deps,
306
+ getGlobalDispatcher: () => sharedOriginal,
307
+ });
308
+ expect(state?.originalDispatcher).toBe(sharedOriginal);
309
+ expect(state?.current).toBeInstanceOf(RuleDispatchDispatcher);
310
+ // 新分流器的 fallback 是原分发器而非旧残留——同一请求只经一层计数
311
+ expect((state?.current as RuleDispatchDispatcher).fallback).toBe(sharedOriginal);
312
+ });
313
+ });