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

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,324 @@
1
+ /**
2
+ * fetch 包装状态机单测(fake 依赖注入,不触真实网络):
3
+ * 命中/未命中分流、计数、noProxy 优先、禁用恢复、外部覆盖重装、构造失败锁定、
4
+ * reload 残留摘除、入参形态覆盖。转移表见 fetch-wrapper.ts 文件头。
5
+ */
6
+ import { beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { Dispatcher } from "undici";
8
+
9
+ import type { NetworkProxyConfig } from "../config.ts";
10
+ import {
11
+ applyNetworkProxyConfig,
12
+ detachResidualFetchWrapper,
13
+ type FetchLike,
14
+ type FetchWrapperDeps,
15
+ type ProxyFetchInit,
16
+ } from "../fetch-wrapper.ts";
17
+ import { resetCounters, snapshotCounters } from "../stats.ts";
18
+
19
+ class FakeDispatcher extends Dispatcher {}
20
+
21
+ interface FetchCall { // oe-exempt:20261020:test:测试观测记录形状别名,仅本测试文件消费
22
+ input: string | URL | Request;
23
+ init?: RequestInit;
24
+ }
25
+
26
+ interface ProxyCall { // oe-exempt:20261020:test:测试观测记录形状别名,仅本测试文件消费
27
+ input: string | URL | Request;
28
+ init: ProxyFetchInit;
29
+ }
30
+
31
+ function config(overrides?: Partial<NetworkProxyConfig>): NetworkProxyConfig {
32
+ return {
33
+ enabled: true,
34
+ proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
35
+ noProxy: ["localhost"],
36
+ rules: ["api.anthropic.com"],
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ function withProxy(patch: Partial<NetworkProxyConfig["proxy"]>): Partial<NetworkProxyConfig> {
42
+ return { proxy: { ...config().proxy, ...patch } };
43
+ }
44
+
45
+ /** fake 依赖装配:original fetch / 代理 fetch / 构造器全部可观测、可注入失败。 */
46
+ function createHarness() {
47
+ const originalCalls: FetchCall[] = [];
48
+ const originalFetch: FetchLike = async (input, init) => {
49
+ originalCalls.push({ input, init });
50
+ return new Response("original");
51
+ };
52
+ let currentFetch: FetchLike = originalFetch;
53
+
54
+ const proxyCalls: ProxyCall[] = [];
55
+ const proxy = new FakeDispatcher();
56
+ const proxyFetch = async (input: string | URL | Request, init: ProxyFetchInit): Promise<Response> => {
57
+ proxyCalls.push({ input, init });
58
+ return new Response("proxy");
59
+ };
60
+
61
+ let buildProxy = (_config: NetworkProxyConfig): Dispatcher => proxy;
62
+ const buildCalls: NetworkProxyConfig[] = [];
63
+ const errors: string[] = [];
64
+
65
+ const deps: FetchWrapperDeps = {
66
+ getFetch: () => currentFetch,
67
+ setFetch: (fetch) => {
68
+ currentFetch = fetch;
69
+ },
70
+ buildProxy: (next) => {
71
+ buildCalls.push(next);
72
+ return buildProxy(next);
73
+ },
74
+ proxyFetch,
75
+ onError: (message) => errors.push(message),
76
+ };
77
+
78
+ return {
79
+ deps,
80
+ originalFetch,
81
+ originalCalls,
82
+ proxyCalls,
83
+ proxy,
84
+ buildCalls,
85
+ errors,
86
+ getCurrent: () => currentFetch,
87
+ setCurrent: (fetch: FetchLike) => {
88
+ currentFetch = fetch;
89
+ },
90
+ setBuildProxy: (next: (config: NetworkProxyConfig) => Dispatcher) => {
91
+ buildProxy = next;
92
+ },
93
+ };
94
+ }
95
+
96
+ beforeEach(() => {
97
+ resetCounters();
98
+ });
99
+
100
+ describe("applyNetworkProxyConfig 包装状态机", () => {
101
+ it("首装 enabled:包装 globalThis.fetch 槽位(fake 槽),返回包装状态", () => {
102
+ const h = createHarness();
103
+ const state = applyNetworkProxyConfig(config(), null, h.deps);
104
+ expect(state).not.toBeNull();
105
+ expect(state?.wrapper).not.toBeNull();
106
+ expect(h.getCurrent()).toBe(state?.wrapper);
107
+ expect(h.errors).toHaveLength(0);
108
+ });
109
+
110
+ it("规则命中:走代理 fetch + proxied 计数,init 保留并注入 dispatcher", async () => {
111
+ const h = createHarness();
112
+ applyNetworkProxyConfig(config(), null, h.deps);
113
+ const response = await h.getCurrent()("https://api.anthropic.com/v1/messages", {
114
+ method: "POST",
115
+ headers: { "x-test": "1" },
116
+ body: "{}",
117
+ });
118
+ expect(await response.text()).toBe("proxy");
119
+ expect(h.proxyCalls).toHaveLength(1);
120
+ expect(h.originalCalls).toHaveLength(0);
121
+ expect(h.proxyCalls[0]?.init.dispatcher).toBe(h.proxy);
122
+ expect(h.proxyCalls[0]?.init.method).toBe("POST");
123
+ expect(h.proxyCalls[0]?.init.headers).toEqual({ "x-test": "1" });
124
+ expect(h.proxyCalls[0]?.init.body).toBe("{}");
125
+ expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 1, direct: 0 });
126
+ });
127
+
128
+ it("规则未命中:原样委托原 fetch + direct 计数", async () => {
129
+ const h = createHarness();
130
+ applyNetworkProxyConfig(config(), null, h.deps);
131
+ const init: RequestInit = { method: "GET" };
132
+ const response = await h.getCurrent()("https://open.bigmodel.cn/api", init);
133
+ expect(await response.text()).toBe("original");
134
+ expect(h.originalCalls).toHaveLength(1);
135
+ expect(h.originalCalls[0]?.init).toBe(init);
136
+ expect(h.proxyCalls).toHaveLength(0);
137
+ expect(snapshotCounters().get("open.bigmodel.cn")).toEqual({ proxied: 0, direct: 1 });
138
+ });
139
+
140
+ it("noProxy 优先于 rules:同主机名两侧都出现时直连", async () => {
141
+ const h = createHarness();
142
+ applyNetworkProxyConfig(
143
+ config({ noProxy: ["api.anthropic.com"], rules: ["api.anthropic.com"] }),
144
+ null,
145
+ h.deps,
146
+ );
147
+ await h.getCurrent()("https://api.anthropic.com/v1/messages");
148
+ expect(h.proxyCalls).toHaveLength(0);
149
+ expect(h.originalCalls).toHaveLength(1);
150
+ expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 0, direct: 1 });
151
+ });
152
+
153
+ it("子域命中 + 点边界:子域走代理,前缀拼接不命中", async () => {
154
+ const h = createHarness();
155
+ applyNetworkProxyConfig(config({ noProxy: [], rules: ["api.anthropic.com"] }), null, h.deps);
156
+ await h.getCurrent()("https://eu.api.anthropic.com/v1");
157
+ await h.getCurrent()("https://xapi.anthropic.com/v1");
158
+ expect(h.proxyCalls).toHaveLength(1);
159
+ expect(h.originalCalls).toHaveLength(1);
160
+ });
161
+
162
+ it("入参形态:string / URL / Request 都能识别主机名", async () => {
163
+ const h = createHarness();
164
+ applyNetworkProxyConfig(config({ noProxy: [], rules: ["api.anthropic.com"] }), null, h.deps);
165
+ await h.getCurrent()("https://api.anthropic.com/a");
166
+ await h.getCurrent()(new URL("https://api.anthropic.com/b"));
167
+ await h.getCurrent()(new Request("https://api.anthropic.com/c"));
168
+ expect(h.proxyCalls).toHaveLength(3);
169
+ expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 3, direct: 0 });
170
+ });
171
+
172
+ it("无法解析目标(相对 URL / 空 host):原样放行且不计数", async () => {
173
+ const h = createHarness();
174
+ applyNetworkProxyConfig(config(), null, h.deps);
175
+ await h.getCurrent()("/relative/path");
176
+ await h.getCurrent()(new URL("file:///tmp/a"));
177
+ expect(h.originalCalls).toHaveLength(2);
178
+ expect(h.proxyCalls).toHaveLength(0);
179
+ expect(snapshotCounters().size).toBe(0);
180
+ });
181
+
182
+ it("指纹未变且包装仍在槽位:零动作(状态对象复用,不重建)", () => {
183
+ const h = createHarness();
184
+ const state = applyNetworkProxyConfig(config(), null, h.deps);
185
+ const again = applyNetworkProxyConfig(config(), state, h.deps);
186
+ expect(again).toBe(state);
187
+ expect(h.buildCalls).toHaveLength(1);
188
+ });
189
+
190
+ it("指纹变化:重建代理分发器并换新包装", async () => {
191
+ const h = createHarness();
192
+ const first = applyNetworkProxyConfig(config(), null, h.deps);
193
+ const second = applyNetworkProxyConfig(
194
+ config({ rules: ["api.openai.com"] }),
195
+ first,
196
+ h.deps,
197
+ );
198
+ expect(second?.wrapper).not.toBe(first?.wrapper);
199
+ expect(h.buildCalls).toHaveLength(2);
200
+ await h.getCurrent()("https://api.openai.com/v1");
201
+ expect(h.proxyCalls).toHaveLength(1);
202
+ });
203
+
204
+ it("槽位被外部覆盖(pi 重跑 configureHttpDispatcher → undici.install):同指纹也以覆盖者为 original 重新包装", async () => {
205
+ const h = createHarness();
206
+ const first = applyNetworkProxyConfig(config(), null, h.deps);
207
+ // 模拟 pi 设置变更:globalThis.fetch 被 pi 原生实现整体替换,本扩展状态未感知
208
+ const piReinstalled: FetchLike = async () => new Response("pi-native");
209
+ h.setCurrent(piReinstalled);
210
+
211
+ const again = applyNetworkProxyConfig(config(), first, h.deps);
212
+ expect(again).not.toBe(first);
213
+ expect(again?.wrapper).not.toBeNull();
214
+ expect(h.getCurrent()).toBe(again?.wrapper);
215
+ // 未命中流量走覆盖者(pi 当前实现),不是旧 original
216
+ await h.getCurrent()("https://open.bigmodel.cn/api");
217
+ expect(h.originalCalls).toHaveLength(0);
218
+ // 命中流量仍走代理
219
+ await h.getCurrent()("https://api.anthropic.com/v1");
220
+ expect(h.proxyCalls).toHaveLength(1);
221
+ });
222
+
223
+ it("enabled true→false:恢复原 fetch + 状态清空", () => {
224
+ const h = createHarness();
225
+ const state = applyNetworkProxyConfig(config(), null, h.deps);
226
+ const after = applyNetworkProxyConfig(config({ enabled: false }), state, h.deps);
227
+ expect(after).toBeNull();
228
+ expect(h.getCurrent()).toBe(h.originalFetch);
229
+ });
230
+
231
+ it("首装即禁用:零动作", () => {
232
+ const h = createHarness();
233
+ expect(applyNetworkProxyConfig(config({ enabled: false }), null, h.deps)).toBeNull();
234
+ expect(h.buildCalls).toHaveLength(0);
235
+ expect(h.errors).toHaveLength(0);
236
+ });
237
+
238
+ it("构造失败:onError + 不包装(全直连),同指纹锁定不重试;指纹变化后重试", () => {
239
+ const h = createHarness();
240
+ h.setBuildProxy(() => {
241
+ throw new TypeError("this[kConnector] is not a function");
242
+ });
243
+ const locked = applyNetworkProxyConfig(config(), null, h.deps);
244
+ expect(locked?.wrapper).toBeNull();
245
+ expect(h.getCurrent()).toBe(h.originalFetch);
246
+ expect(h.errors).toHaveLength(1);
247
+ expect(h.errors[0]).toContain("构造失败");
248
+ expect(h.errors[0]).toContain("直连");
249
+
250
+ const stillLocked = applyNetworkProxyConfig(config(), locked, h.deps);
251
+ expect(stillLocked?.wrapper).toBeNull();
252
+ expect(h.buildCalls).toHaveLength(1); // 同指纹不重试
253
+
254
+ h.setBuildProxy(() => new FakeDispatcher());
255
+ const recovered = applyNetworkProxyConfig(config(withProxy({ port: 7891 })), stillLocked, h.deps);
256
+ expect(recovered?.wrapper).not.toBeNull();
257
+ expect(h.getCurrent()).toBe(recovered?.wrapper);
258
+ });
259
+
260
+ it("运行中重建遇构造失败:撤下旧包装恢复原 fetch,记指纹锁定", () => {
261
+ const h = createHarness();
262
+ const installed = applyNetworkProxyConfig(config(), null, h.deps);
263
+ h.setBuildProxy(() => {
264
+ throw new Error("socks5 experimental API changed");
265
+ });
266
+ const locked = applyNetworkProxyConfig(
267
+ config(withProxy({ protocol: "socks5" })),
268
+ installed,
269
+ h.deps,
270
+ );
271
+ expect(locked?.wrapper).toBeNull();
272
+ expect(h.getCurrent()).toBe(h.originalFetch);
273
+ expect(h.errors[0]).toContain("socks5");
274
+ });
275
+
276
+ it("锁定态再遇禁用:状态清空、零槽位操作", () => {
277
+ const h = createHarness();
278
+ h.setBuildProxy(() => {
279
+ throw new Error("boom");
280
+ });
281
+ const locked = applyNetworkProxyConfig(config(), null, h.deps);
282
+ const after = applyNetworkProxyConfig(config({ enabled: false }), locked, h.deps);
283
+ expect(after).toBeNull();
284
+ expect(h.getCurrent()).toBe(h.originalFetch);
285
+ });
286
+ });
287
+
288
+ describe("detachResidualFetchWrapper(reload 残留摘除)", () => {
289
+ it("installed=null 且槽上是本扩展旧包装:恢复其 original fetch", () => {
290
+ const h = createHarness();
291
+ applyNetworkProxyConfig(config(), null, h.deps);
292
+ expect(h.getCurrent()).not.toBe(h.originalFetch);
293
+
294
+ // 模拟 reload 后新模块环境:installed=null,槽上仍是旧环境包装
295
+ detachResidualFetchWrapper(null, h.deps);
296
+ expect(h.getCurrent()).toBe(h.originalFetch);
297
+ });
298
+
299
+ it("installed=null 且槽上是非本扩展函数:零动作", () => {
300
+ const h = createHarness();
301
+ const foreignFetch: FetchLike = async () => new Response("foreign");
302
+ h.setCurrent(foreignFetch);
303
+ detachResidualFetchWrapper(null, h.deps);
304
+ expect(h.getCurrent()).toBe(foreignFetch);
305
+ });
306
+
307
+ it("installed 非 null(同环境 factory 重跑):早退,不误摘当前包装", () => {
308
+ const h = createHarness();
309
+ const state = applyNetworkProxyConfig(config(), null, h.deps);
310
+ detachResidualFetchWrapper(state, h.deps);
311
+ expect(h.getCurrent()).toBe(state?.wrapper);
312
+ });
313
+
314
+ it("reload 后重新推进:摘除旧包装后再包新层(不嵌套双计)", async () => {
315
+ const h = createHarness();
316
+ applyNetworkProxyConfig(config(), null, h.deps);
317
+ detachResidualFetchWrapper(null, h.deps);
318
+ const state = applyNetworkProxyConfig(config(), null, h.deps);
319
+ expect(state?.originalFetch).toBe(h.originalFetch);
320
+ await h.getCurrent()("https://api.anthropic.com/v1");
321
+ expect(h.proxyCalls).toHaveLength(1);
322
+ expect(snapshotCounters().get("api.anthropic.com")).toEqual({ proxied: 1, direct: 0 });
323
+ });
324
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * extension 入口行为单测(真实组件 + tmp agentDir,不 mock 配置读取链):
3
+ * 工厂加载序列(校验过 → 包装 globalThis.fetch / 非法 → 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
+
12
+ const { loggerMock } = vi.hoisted(() => ({
13
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
14
+ }));
15
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
16
+ getLogger: () => loggerMock,
17
+ createLogger: () => loggerMock,
18
+ setPiHandle: vi.fn(),
19
+ }));
20
+
21
+ /** fake pi ExtensionAPI:收集 on 注册的 handler,emit 触发。 */
22
+ function createFakePi() {
23
+ const handlers = new Map<string, Array<() => void>>();
24
+ const pi = {
25
+ on: (event: string, handler: () => void) => {
26
+ const list = handlers.get(event) ?? [];
27
+ list.push(handler);
28
+ handlers.set(event, list);
29
+ },
30
+ } as unknown as import("@earendil-works/pi-coding-agent").ExtensionAPI;
31
+ return {
32
+ pi,
33
+ emit: (event: "turn_start" | "turn_end") => {
34
+ for (const h of handlers.get(event) ?? []) h();
35
+ },
36
+ };
37
+ }
38
+
39
+ const ENABLED_CONFIG = {
40
+ enabled: true,
41
+ proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
42
+ noProxy: ["localhost"],
43
+ rules: ["api.anthropic.com"],
44
+ };
45
+
46
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
47
+
48
+ let agentDir: string;
49
+ let savedFetch: FetchLike;
50
+ let fakeFetch: FetchLike;
51
+ let fakeCalls: number;
52
+
53
+ function writeConfigFile(content: string): void {
54
+ mkdirSync(join(agentDir, "config"), { recursive: true });
55
+ writeFileSync(join(agentDir, "config", "network-proxy-ext-config.json"), content);
56
+ }
57
+
58
+ beforeEach(() => {
59
+ agentDir = mkdtempSync(join(tmpdir(), "network-proxy-index-"));
60
+ vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
61
+ savedFetch = globalThis.fetch;
62
+ fakeCalls = 0;
63
+ fakeFetch = async () => {
64
+ fakeCalls += 1;
65
+ return new Response("fake-original");
66
+ };
67
+ globalThis.fetch = fakeFetch;
68
+ loggerMock.error.mockClear();
69
+ });
70
+
71
+ afterEach(() => {
72
+ globalThis.fetch = savedFetch;
73
+ vi.unstubAllEnvs();
74
+ vi.resetModules();
75
+ });
76
+
77
+ describe("networkProxyExtension 工厂加载序列", () => {
78
+ it("enabled 且校验过:包装 globalThis.fetch(未命中规则时委托 fake 原 fetch)", async () => {
79
+ writeConfigFile(JSON.stringify(ENABLED_CONFIG));
80
+ const { default: networkProxyExtension } = await import("../index.ts");
81
+ networkProxyExtension(createFakePi().pi);
82
+ expect(globalThis.fetch).not.toBe(fakeFetch);
83
+ const response = await globalThis.fetch("https://open.bigmodel.cn/api");
84
+ expect(await response.text()).toBe("fake-original");
85
+ expect(fakeCalls).toBe(1);
86
+ expect(loggerMock.error).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it("enabled=false:不包装(globalThis.fetch 保持原实现),零 error", async () => {
90
+ writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, enabled: false }));
91
+ const { default: networkProxyExtension } = await import("../index.ts");
92
+ networkProxyExtension(createFakePi().pi);
93
+ expect(globalThis.fetch).toBe(fakeFetch);
94
+ expect(loggerMock.error).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("非法配置(坏 JSON):不抛、不包装(全部直连),error 含恢复动作(配置文件路径)", async () => {
98
+ writeConfigFile("{ not valid json");
99
+ const { default: networkProxyExtension } = await import("../index.ts");
100
+ expect(() => networkProxyExtension(createFakePi().pi)).not.toThrow();
101
+ expect(globalThis.fetch).toBe(fakeFetch);
102
+ const messages = loggerMock.error.mock.calls.map((c) => String(c[0]));
103
+ expect(messages.some((m) => m.includes("network-proxy-ext-config.json"))).toBe(true);
104
+ });
105
+
106
+ it("非法字段(端口越界):loadConfig 回落 defaults(enabled=false → 不包装)+ error 可归因", async () => {
107
+ writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, proxy: { ...ENABLED_CONFIG.proxy, port: 99999 } }));
108
+ const { default: networkProxyExtension } = await import("../index.ts");
109
+ expect(() => networkProxyExtension(createFakePi().pi)).not.toThrow();
110
+ expect(globalThis.fetch).toBe(fakeFetch);
111
+ expect(loggerMock.error.mock.calls.length).toBeGreaterThan(0);
112
+ });
113
+ });
114
+
115
+ describe("turn 粒度热生效", () => {
116
+ it("turn_start 重读配置:指纹变化换新包装,未变零动作", async () => {
117
+ writeConfigFile(JSON.stringify(ENABLED_CONFIG));
118
+ const { default: networkProxyExtension } = await import("../index.ts");
119
+ const { pi, emit } = createFakePi();
120
+ networkProxyExtension(pi);
121
+ const first = globalThis.fetch;
122
+
123
+ // 未变配置:同包装函数(不重建)
124
+ emit("turn_start");
125
+ expect(globalThis.fetch).toBe(first);
126
+
127
+ // 改写配置(size 变化 → mtime+size 缓存失效):指纹变化换新包装
128
+ writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, rules: ["api.openai.com"] }));
129
+ emit("turn_start");
130
+ expect(globalThis.fetch).not.toBe(first);
131
+ });
132
+
133
+ it("pi 重装 globalThis.fetch(configureHttpDispatcher)后 turn_start 重新包装覆盖者", async () => {
134
+ writeConfigFile(JSON.stringify(ENABLED_CONFIG));
135
+ const { default: networkProxyExtension } = await import("../index.ts");
136
+ const { pi, emit } = createFakePi();
137
+ networkProxyExtension(pi);
138
+
139
+ let piReinstalledCalls = 0;
140
+ const piReinstalled: FetchLike = async () => {
141
+ piReinstalledCalls += 1;
142
+ return new Response("pi-native");
143
+ };
144
+ globalThis.fetch = piReinstalled;
145
+ emit("turn_start");
146
+
147
+ expect(globalThis.fetch).not.toBe(piReinstalled);
148
+ const response = await globalThis.fetch("https://open.bigmodel.cn/api");
149
+ expect(await response.text()).toBe("pi-native");
150
+ expect(piReinstalledCalls).toBe(1);
151
+ });
152
+
153
+ it("turn_end flush 计数增量到 stats JSONL(recordDispatch → turn_end → 落盘行)", async () => {
154
+ writeConfigFile(JSON.stringify(ENABLED_CONFIG));
155
+ const { default: networkProxyExtension } = await import("../index.ts");
156
+ // resetModules 后 index.ts 与此处 import 的 stats.ts 是同一模块图刷新的同一实例
157
+ const { recordDispatch, statsFilePath } = await import("../stats.ts");
158
+ const { pi, emit } = createFakePi();
159
+ networkProxyExtension(pi);
160
+
161
+ recordDispatch("api.anthropic.com", true);
162
+ recordDispatch("open.bigmodel.cn", false);
163
+ emit("turn_end");
164
+
165
+ const lines = readFileSync(statsFilePath(), "utf-8").split("\n").filter((l) => l !== "");
166
+ expect(lines).toHaveLength(2);
167
+ const hosts = lines.map((l) => (JSON.parse(l) as { host: string }).host).sort();
168
+ expect(hosts).toEqual(["api.anthropic.com", "open.bigmodel.cn"]);
169
+ });
170
+
171
+ it("enabled true→false:turn_start 后恢复原 fetch(禁用生效)", async () => {
172
+ writeConfigFile(JSON.stringify(ENABLED_CONFIG));
173
+ const { default: networkProxyExtension } = await import("../index.ts");
174
+ const { pi, emit } = createFakePi();
175
+ networkProxyExtension(pi);
176
+ expect(globalThis.fetch).not.toBe(fakeFetch);
177
+
178
+ writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, enabled: false }));
179
+ emit("turn_start");
180
+ expect(globalThis.fetch).toBe(fakeFetch);
181
+ });
182
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 匹配语义真值表(任务书验收②:suffix 点边界 / noProxy 条目语义 / 归一化)。
3
+ *
4
+ * 本包 match.ts 是 packages/shared/src/network-proxy.ts 匹配函数的复制体(bundle 自包含
5
+ * 不 import shared 运行时代码)——真值表即两侧行为一致性的镜像锁定,漂移时以 shared 为准。
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import { matchesHostPattern, normalizeHostname } from "../match.ts";
10
+
11
+ describe("normalizeHostname(归一化)", () => {
12
+ it("转小写", () => {
13
+ expect(normalizeHostname("API.Anthropic.COM")).toBe("api.anthropic.com");
14
+ });
15
+
16
+ it("去末尾点(FQDN 根点与裸主机名同义)", () => {
17
+ expect(normalizeHostname("api.anthropic.com.")).toBe("api.anthropic.com");
18
+ expect(normalizeHostname("api.anthropic.com..")).toBe("api.anthropic.com");
19
+ });
20
+
21
+ it("组合形态:大写 + 末尾点", () => {
22
+ expect(normalizeHostname("API.Anthropic.COM.")).toBe("api.anthropic.com");
23
+ });
24
+
25
+ it("无点主机名原样(小写化)", () => {
26
+ expect(normalizeHostname("localhost")).toBe("localhost");
27
+ expect(normalizeHostname("LocalHost")).toBe("localhost");
28
+ });
29
+
30
+ it("不 trim 首尾空白(带空白条目由校验层显式拒绝,匹配层保持严格)", () => {
31
+ expect(normalizeHostname(" api.anthropic.com")).toBe(" api.anthropic.com");
32
+ expect(normalizeHostname("api.anthropic.com ")).toBe("api.anthropic.com ");
33
+ });
34
+ });
35
+
36
+ describe("matchesHostPattern 真值表(suffix 点边界)", () => {
37
+ const pattern = "api.anthropic.com";
38
+
39
+ it("等于命中", () => {
40
+ expect(matchesHostPattern("api.anthropic.com", pattern)).toBe(true);
41
+ });
42
+
43
+ it("子域命中(以 .pattern 结尾)", () => {
44
+ expect(matchesHostPattern("eu.api.anthropic.com", pattern)).toBe(true);
45
+ expect(matchesHostPattern("a.b.api.anthropic.com", pattern)).toBe(true);
46
+ });
47
+
48
+ it("前缀拼接不命中(点边界必须对齐)", () => {
49
+ expect(matchesHostPattern("xapi.anthropic.com", pattern)).toBe(false);
50
+ expect(matchesHostPattern("papi.anthropic.com", pattern)).toBe(false);
51
+ });
52
+
53
+ it("恶意后缀不命中(pattern 出现在中间也不命中)", () => {
54
+ expect(matchesHostPattern("api-anthropic.com.evil.com", pattern)).toBe(false);
55
+ expect(matchesHostPattern("api.anthropic.com.evil.com", pattern)).toBe(false); // evil.com 的子域,非 pattern 子域
56
+ });
57
+
58
+ it("完全不同的主机名不命中", () => {
59
+ expect(matchesHostPattern("openai.com", pattern)).toBe(false);
60
+ expect(matchesHostPattern("anthropic.com", pattern)).toBe(false); // pattern 本身不是其子域
61
+ });
62
+
63
+ it("归一化参与匹配:大小写与末尾点在 host/pattern 任一侧生效", () => {
64
+ expect(matchesHostPattern("API.Anthropic.COM.", "api.anthropic.com")).toBe(true);
65
+ expect(matchesHostPattern("api.anthropic.com", "API.ANTHROPIC.COM.")).toBe(true);
66
+ expect(matchesHostPattern("EU.API.Anthropic.Com", pattern)).toBe(true);
67
+ });
68
+
69
+ it("空主机名 / 空 pattern 不命中任何条目", () => {
70
+ expect(matchesHostPattern("", pattern)).toBe(false);
71
+ expect(matchesHostPattern("api.anthropic.com", "")).toBe(false);
72
+ expect(matchesHostPattern("", "")).toBe(false);
73
+ });
74
+
75
+ it("首尾空白不参与匹配(host/pattern 任一侧带空白恒不命中——空白条目由校验层显式拒绝,匹配层不静默 trim 容忍)", () => {
76
+ expect(matchesHostPattern("api.anthropic.com", " api.anthropic.com")).toBe(false);
77
+ expect(matchesHostPattern("api.anthropic.com", "api.anthropic.com ")).toBe(false);
78
+ expect(matchesHostPattern(" api.anthropic.com", pattern)).toBe(false);
79
+ });
80
+
81
+ it("noProxy 条目语义:localhost 同时覆盖 localhost 与 *.localhost", () => {
82
+ expect(matchesHostPattern("localhost", "localhost")).toBe(true);
83
+ expect(matchesHostPattern("foo.localhost", "localhost")).toBe(true);
84
+ expect(matchesHostPattern("127.0.0.1", "127.0.0.1")).toBe(true);
85
+ });
86
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * startupConfig 声明守护测试:断言 package.json `taiji.startupConfig` 声明的 content
3
+ * 与代码 DEFAULT_NETWORK_PROXY_CONFIG 常量深相等,防止两侧任一改动未同步(漂移)。
4
+ * 机制:runtime 启动序列统一 ensure(extension-startup-config.ts),声明即首建内容。
5
+ * 范式:smart-context 同名测试。
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ import { DEFAULT_NETWORK_PROXY_CONFIG } from "../config.ts";
14
+
15
+ /** package.json `taiji.startupConfig` 声明条目的最小形状(守护断言用)。 */
16
+ interface StartupConfigEntry { // oe-exempt:20261019:framework:声明守护测试的最小形状锚(测试常驻资产,非临时期限类目可承载)
17
+
18
+ path: string;
19
+ content: unknown;
20
+ }
21
+
22
+ const pkg = JSON.parse(
23
+ readFileSync(join(fileURLToPath(import.meta.url), "../../../package.json"), "utf-8"),
24
+ ) as { "taiji"?: { startupConfig?: StartupConfigEntry[] } };
25
+
26
+ describe("startupConfig 声明守护", () => {
27
+ it("声明 content 与代码 DEFAULT_NETWORK_PROXY_CONFIG 深相等", () => {
28
+ const entry = pkg["taiji"]?.startupConfig?.find(
29
+ (e: StartupConfigEntry) => e.path === "config/network-proxy-ext-config.json",
30
+ );
31
+ expect(entry).toBeDefined();
32
+ expect(entry?.content).toEqual(DEFAULT_NETWORK_PROXY_CONFIG);
33
+ });
34
+
35
+ it("默认声明为禁用态(P2 降级契约:默认无行为变化)", () => {
36
+ expect(DEFAULT_NETWORK_PROXY_CONFIG.enabled).toBe(false);
37
+ });
38
+ });