@modusensus/dsh-mneme 0.8.0 → 0.8.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.
@@ -4,9 +4,13 @@ import { createStore } from "../src/store.js";
4
4
  import { createService } from "../src/service.js";
5
5
  import { createTools } from "../src/tools.js";
6
6
 
7
- // v0.8.0 A3(issue #17):strictScope 硬过滤 + agent 不对称可见性。
8
- // 四象限公式:记忆可见 ⇔ (agent 维未标注或命中) AND (workspace 维未标注或命中);
9
- // 当前会话维度解析不到时该维度带标注的记忆不可见(fail-closed)。
7
+ // v0.8.0 A3(issue #17)→ v0.8.1 第 3 步(issue #170 4.3,作者已确认):
8
+ // strictScope 硬过滤只认显式声明。可见性公式收窄为:记忆可见 ⇔ 对每一维
9
+ // (该维标注存在且来源=explicit 时:命中当前会话)AND。
10
+ // - explicit 行为硬墙:他者维度不可见(含 memory_get 无存在性泄漏);
11
+ // - auto / 存量 NULL 来源(v0.8.0 自动标注)不进硬过滤,只吃 A2 软加权
12
+ // (foreign ×0.5 保留可见);
13
+ // - 当前会话维度解析不到 → 该维 explicit 行一律不可见(fail-closed 收窄)。
10
14
 
11
15
  function setup(config) {
12
16
  const store = createStore(":memory:");
@@ -14,46 +18,69 @@ function setup(config) {
14
18
  return { store, service };
15
19
  }
16
20
 
17
- test("visibility predicate: four-quadrant semantics", () => {
21
+ test("visibility predicate: explicit rows keep four-quadrant semantics", () => {
18
22
  const { service } = setup({ scopeEnabled: true, strictScope: true });
19
23
  const vis = service.isVisibleInScope;
20
24
  const me = { agent_scope: "coder", workspace_scope: "D:\\p" };
25
+ const E = "explicit";
21
26
 
22
27
  // 全局(未标注)对谁都可见。
23
28
  assert.equal(vis({}, me), true);
24
29
  // agent 专属:同 agent 任意 workspace 可见,他人不可见。
25
- assert.equal(vis({ agent_scope: "coder" }, me), true);
26
- assert.equal(vis({ agent_scope: "novelist" }, me), false);
30
+ assert.equal(vis({ agent_scope: "coder", agent_scope_source: E }, me), true);
31
+ assert.equal(vis({ agent_scope: "novelist", agent_scope_source: E }, me), false);
27
32
  // workspace 专属:同 workspace 任意 agent 可见,别的 workspace 不可见。
28
- assert.equal(vis({ workspace_scope: "D:\\p" }, me), true);
29
- assert.equal(vis({ workspace_scope: "D:\\q" }, me), false);
33
+ assert.equal(vis({ workspace_scope: "D:\\p", workspace_scope_source: E }, me), true);
34
+ assert.equal(vis({ workspace_scope: "D:\\q", workspace_scope_source: E }, me), false);
30
35
  // 双标注:精确命中才可见。
31
- assert.equal(vis({ agent_scope: "coder", workspace_scope: "D:\\p" }, me), true);
32
- assert.equal(vis({ agent_scope: "coder", workspace_scope: "D:\\q" }, me), false);
33
- assert.equal(vis({ agent_scope: "novelist", workspace_scope: "D:\\p" }, me), false);
36
+ assert.equal(vis({ agent_scope: "coder", agent_scope_source: E, workspace_scope: "D:\\p", workspace_scope_source: E }, me), true);
37
+ assert.equal(vis({ agent_scope: "coder", agent_scope_source: E, workspace_scope: "D:\\q", workspace_scope_source: E }, me), false);
38
+ assert.equal(vis({ agent_scope: "novelist", agent_scope_source: E, workspace_scope: "D:\\p", workspace_scope_source: E }, me), false);
34
39
  });
35
40
 
36
- test("visibility predicate: anonymous session sees only unscoped rows (fail-closed)", () => {
41
+ test("visibility predicate: auto and legacy rows are NOT hard walls (soft only)", () => {
42
+ const { service } = setup({ scopeEnabled: true, strictScope: true });
43
+ const vis = service.isVisibleInScope;
44
+ const me = { agent_scope: "coder", workspace_scope: "D:\\p" };
45
+
46
+ // auto(v0.8.1 载体自动标注)与存量 NULL 来源(v0.8.0 自动标注)他者维度
47
+ // 也可见——硬过滤只认 explicit。
48
+ assert.equal(vis({ agent_scope: "novelist" }, me), true, "legacy NULL source");
49
+ assert.equal(vis({ agent_scope: "novelist", agent_scope_source: "auto" }, me), true);
50
+ assert.equal(vis({ workspace_scope: "D:\\q", workspace_scope_source: "auto" }, me), true);
51
+ // 混合:agent 维 auto + workspace 维 explicit → 只 workspace 维构成硬墙。
52
+ assert.equal(vis({ agent_scope: "novelist", agent_scope_source: "auto", workspace_scope: "D:\\q", workspace_scope_source: "explicit" }, me), false);
53
+ assert.equal(vis({ agent_scope: "novelist", agent_scope_source: "explicit", workspace_scope: "D:\\q", workspace_scope_source: "auto" }, me), false);
54
+ });
55
+
56
+ test("visibility predicate: anonymous session fail-closes only explicit rows", () => {
37
57
  const { service } = setup({ scopeEnabled: true, strictScope: true });
38
58
  const vis = service.isVisibleInScope;
39
59
  const anon = { agent_scope: null, workspace_scope: null };
40
60
  assert.equal(vis({}, anon), true);
41
- assert.equal(vis({ agent_scope: "coder" }, anon), false);
42
- assert.equal(vis({ workspace_scope: "D:\\p" }, anon), false);
61
+ assert.equal(vis({ agent_scope: "coder", agent_scope_source: "explicit" }, anon), false);
62
+ assert.equal(vis({ workspace_scope: "D:\\p", workspace_scope_source: "explicit" }, anon), false);
63
+ // auto/存量行照常可见(不冒认,但也不因身份缺失误杀软标注)。
64
+ assert.equal(vis({ agent_scope: "coder" }, anon), true);
65
+ assert.equal(vis({ agent_scope: "coder", agent_scope_source: "auto" }, anon), true);
43
66
  });
44
67
 
45
- test("searchMemories hard-filters foreign scopes when strictScope is on", async () => {
68
+ test("searchMemories: explicit foreign filtered, auto/legacy foreign stay visible under strictScope", async () => {
46
69
  const { store, service } = setup({ scopeEnabled: true, strictScope: true });
47
70
  store.save({ type: "project", title: "global needle", content: "x" });
48
- store.save({ type: "project", title: "mine needle", content: "x", agent_scope: "me" });
49
- store.save({ type: "project", title: "foreign needle", content: "x", agent_scope: "other" });
71
+ store.save({ type: "project", title: "mine needle", content: "x", agent_scope: "me", agent_scope_source: "explicit" });
72
+ store.save({ type: "project", title: "explicit foreign needle", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
73
+ store.save({ type: "project", title: "auto foreign needle", content: "x", agent_scope: "other", agent_scope_source: "auto" });
74
+ store.save({ type: "project", title: "legacy foreign needle", content: "x", agent_scope: "other" });
50
75
 
51
76
  const rows = await service.searchMemories("needle", {
52
77
  mode: "keyword",
53
78
  scope: { agent_scope: "me", workspace_scope: null }
54
79
  });
55
- // 他 scope 直接出局(区别于 A2 的降权保留可见);未标注与命中行保留。
56
- assert.deepEqual(rows.map((m) => m.title).sort(), ["global needle", "mine needle"]);
80
+ // explicit 他者出局;auto/存量他者保留(A2 降权仍可见);未标注与命中行保留。
81
+ assert.deepEqual(rows.map((m) => m.title).sort(), [
82
+ "auto foreign needle", "global needle", "legacy foreign needle", "mine needle"
83
+ ]);
57
84
  });
58
85
 
59
86
  test("strictScope off keeps A2 behavior: foreign rows demoted but visible", async () => {
@@ -66,38 +93,73 @@ test("strictScope off keeps A2 behavior: foreign rows demoted but visible", asyn
66
93
  assert.deepEqual(rows.map((m) => m.title), ["foreign needle"]);
67
94
  });
68
95
 
69
- test("store.list/count visibility option filters in SQL with consistent totals", () => {
96
+ test("store.list/count visibility: explicit hard wall + auto/legacy pass, consistent totals", () => {
70
97
  const { store } = setup({ scopeEnabled: true, strictScope: true });
71
98
  store.save({ type: "decision", title: "g", content: "x" });
72
- store.save({ type: "decision", title: "mine", content: "x", agent_scope: "me", workspace_scope: "D:\\p" });
73
- store.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other" });
74
- store.save({ type: "decision", title: "other-ws", content: "x", workspace_scope: "D:\\q" });
99
+ store.save({ type: "decision", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit", workspace_scope: "D:\\p", workspace_scope_source: "explicit" });
100
+ store.save({ type: "decision", title: "theirs-explicit", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
101
+ store.save({ type: "decision", title: "theirs-auto", content: "x", agent_scope: "other", agent_scope_source: "auto" });
102
+ store.save({ type: "decision", title: "theirs-legacy", content: "x", agent_scope: "other" });
103
+ store.save({ type: "decision", title: "other-ws-explicit", content: "x", workspace_scope: "D:\\q", workspace_scope_source: "explicit" });
75
104
 
76
105
  const vis = { agentScope: "me", workspaceScope: "D:\\p" };
77
106
  const titles = store.list({ visibility: vis }).map((m) => m.title).sort();
78
- assert.deepEqual(titles, ["g", "mine"]);
79
- assert.equal(store.count("decision", { visibility: vis }), 2);
107
+ assert.deepEqual(titles, ["g", "mine", "theirs-auto", "theirs-legacy"]);
108
+ assert.equal(store.count("decision", { visibility: vis }), 4);
80
109
 
81
- // 匿名会话(维度解析不到):只放行未标注行。
82
- const anonTitles = store.list({ visibility: { agentScope: null, workspaceScope: null } }).map((m) => m.title);
83
- assert.deepEqual(anonTitles, ["g"]);
110
+ // 匿名会话:explicit 行全出局,auto/存量/未标注放行。
111
+ const anonTitles = store.list({ visibility: { agentScope: null, workspaceScope: null } }).map((m) => m.title).sort();
112
+ assert.deepEqual(anonTitles, ["g", "theirs-auto", "theirs-legacy"]);
113
+ assert.equal(store.count("decision", { visibility: { agentScope: null, workspaceScope: null } }), 3);
84
114
  });
85
115
 
86
- test("injectCandidates hard-filters scoped rows when strictScope is on", () => {
116
+ test("injectCandidates hard-filters explicit foreign rows when strictScope is on", () => {
87
117
  const { store, service } = setup({ scopeEnabled: true, strictScope: true });
88
118
  store.save({ type: "decision", title: "global", content: "x", importance: 4 });
89
- store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other" });
119
+ store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
120
+ store.save({ type: "decision", title: "auto-foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "auto" });
90
121
 
91
122
  const injected = service.injectCandidates({
92
123
  maxItems: 5,
93
124
  threshold: 3,
94
125
  scope: { agent_scope: "me", workspace_scope: null }
95
126
  });
96
- assert.deepEqual(injected.map((m) => m.title), ["global"]);
127
+ assert.deepEqual(injected.map((m) => m.title).sort(), ["auto-foreign", "global"]);
97
128
 
98
129
  // flag 下(scope 不传)→ 他 scope 照常注入(A2 前行为)。
99
130
  const unscoped = service.injectCandidates({ maxItems: 5, threshold: 3 });
100
- assert.deepEqual(unscoped.map((m) => m.title).sort(), ["foreign", "global"]);
131
+ assert.deepEqual(unscoped.map((m) => m.title).sort(), ["auto-foreign", "foreign", "global"]);
132
+ });
133
+
134
+ test("memory_update/memory_delete hide explicit foreign-scope rows under strictScope (review item 4, aligned with memory_get)", async () => {
135
+ const { store, pick } = setupTools({ scopeEnabled: true, strictScope: true });
136
+ const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other", agent_scope_source: "explicit" });
137
+ const autoTheirs = store.save({ type: "pitfall", title: "auto-theirs", content: "c", agent_scope: "other", agent_scope_source: "auto" });
138
+ const update = pick("memory_update");
139
+ const del = pick("memory_delete");
140
+
141
+ // explicit 他 scope:update 按不存在拒绝、delete 视作不存在——不能凭 id 直改直删。
142
+ await assert.rejects(() => runHandler(update, { id: theirs.id, content: "tampered" }, STRICT_EXEC), /memory not found/);
143
+ assert.equal(store.getById(theirs.id).content, "c", "row untouched by the rejected update");
144
+ const delOut = await runHandler(del, { id: theirs.id }, STRICT_EXEC);
145
+ assert.equal(delOut.deleted, false, "delete treats invisible rows as absent (no existence leak)");
146
+ assert.ok(store.getById(theirs.id), "row NOT deleted");
147
+
148
+ // auto 他 scope:不构成硬墙,照常可改(与 get 的可见性口径一致)。
149
+ const updAuto = await runHandler(update, { id: autoTheirs.id, content: "updated" }, STRICT_EXEC);
150
+ assert.equal(updAuto.memory.id, autoTheirs.id);
151
+
152
+ // 行主人自己照常可改。
153
+ const ownerOut = await runHandler(update, { id: theirs.id, content: "owner edit" }, OTHER_EXEC);
154
+ assert.equal(ownerOut.memory.id, theirs.id);
155
+
156
+ // strictScope 关:update/delete 不做可见性校验(管理语义回归)。
157
+ const { store: store2, pick: pick2 } = setupTools({ scopeEnabled: true });
158
+ const t2 = store2.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other", agent_scope_source: "explicit" });
159
+ await runHandler(pick2("memory_update"), { id: t2.id, content: "edited" }, STRICT_EXEC);
160
+ assert.equal(store2.getById(t2.id).content, "edited");
161
+ const del2 = await runHandler(pick2("memory_delete"), { id: t2.id }, STRICT_EXEC);
162
+ assert.equal(del2.deleted, true);
101
163
  });
102
164
 
103
165
  function setupTools(config) {
@@ -121,17 +183,21 @@ async function runHandler(def, args, exec) {
121
183
  const STRICT_EXEC = { agent: { session: { id: "s1", header: { agentPreset: "me", cwd: "D:\\p" } } } };
122
184
  const OTHER_EXEC = { agent: { session: { id: "s2", header: { agentPreset: "other", cwd: "D:\\q" } } } };
123
185
 
124
- test("memory_get hides foreign-scope rows under strictScope, resolvable for the owner", async () => {
186
+ test("memory_get hides explicit foreign-scope rows under strictScope, resolvable for the owner", async () => {
125
187
  const { store, pick } = setupTools({ scopeEnabled: true, strictScope: true });
126
- const mine = store.save({ type: "pitfall", title: "mine", content: "c", agent_scope: "me" });
127
- const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other" });
188
+ const mine = store.save({ type: "pitfall", title: "mine", content: "c", agent_scope: "me", agent_scope_source: "explicit" });
189
+ const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other", agent_scope_source: "explicit" });
190
+ const autoTheirs = store.save({ type: "pitfall", title: "auto-theirs", content: "c", agent_scope: "other", agent_scope_source: "auto" });
128
191
  const get = pick("memory_get");
129
192
 
130
193
  const out = await runHandler(get, { id: mine.id }, STRICT_EXEC);
131
194
  assert.equal(out.memory.title, "mine");
132
195
 
133
- // 他 scope → 按不存在处理(无存在性泄漏)。
196
+ // explicit 他 scope → 按不存在处理(无存在性泄漏)。
134
197
  await assert.rejects(() => runHandler(get, { id: theirs.id }, STRICT_EXEC), /memory not found/);
198
+ // auto 他 scope → 可见(软加权不进硬过滤)。
199
+ const autoOut = await runHandler(get, { id: autoTheirs.id }, STRICT_EXEC);
200
+ assert.equal(autoOut.memory.title, "auto-theirs");
135
201
  // 行主人自己照常可取。
136
202
  const ownerOut = await runHandler(get, { id: theirs.id }, OTHER_EXEC);
137
203
  assert.equal(ownerOut.memory.title, "theirs");
@@ -139,17 +205,17 @@ test("memory_get hides foreign-scope rows under strictScope, resolvable for the
139
205
 
140
206
  test("memory_get without strictScope returns any row regardless of scope annotation", async () => {
141
207
  const { store, pick } = setupTools({ scopeEnabled: true });
142
- const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other" });
208
+ const theirs = store.save({ type: "pitfall", title: "theirs", content: "c", agent_scope: "other", agent_scope_source: "explicit" });
143
209
  const get = pick("memory_get");
144
210
  const out = await runHandler(get, { id: theirs.id }, STRICT_EXEC);
145
211
  assert.equal(out.memory.title, "theirs");
146
212
  });
147
213
 
148
- test("memory_list filters rows and keeps total consistent under strictScope", async () => {
214
+ test("memory_list filters explicit rows and keeps total consistent under strictScope", async () => {
149
215
  const { store, pick } = setupTools({ scopeEnabled: true, strictScope: true });
150
216
  store.save({ type: "decision", title: "g", content: "x" });
151
- store.save({ type: "decision", title: "mine", content: "x", agent_scope: "me" });
152
- store.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other" });
217
+ store.save({ type: "decision", title: "mine", content: "x", agent_scope: "me", agent_scope_source: "explicit" });
218
+ store.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
153
219
  const list = pick("memory_list");
154
220
 
155
221
  const out = await runHandler(list, { type: "decision" }, STRICT_EXEC);
@@ -158,7 +224,7 @@ test("memory_list filters rows and keeps total consistent under strictScope", as
158
224
 
159
225
  // flag 关(A2 默认):他 scope 的行照常出现在浏览视图。
160
226
  const { store: store2, pick: pick2 } = setupTools({ scopeEnabled: true });
161
- store2.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other" });
227
+ store2.save({ type: "decision", title: "theirs", content: "x", agent_scope: "other", agent_scope_source: "explicit" });
162
228
  const out2 = await runHandler(pick2("memory_list"), { type: "decision" }, STRICT_EXEC);
163
229
  assert.deepEqual(out2.items.map((m) => m.title), ["theirs"]);
164
230
  assert.equal(out2.total, 1);
@@ -0,0 +1,152 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ validateRegistryUrl,
6
+ compareVersions,
7
+ classify,
8
+ fetchLatestVersion,
9
+ resetCacheForTest,
10
+ REGISTRY_URL,
11
+ PACKAGE_VERSION,
12
+ } from '../src/version-check.js';
13
+
14
+ describe('version-check.js', () => {
15
+ // ---- validateRegistryUrl:https + 精确 host 白名单(构造性排除本机/内网)----
16
+ describe('validateRegistryUrl', () => {
17
+ test('白名单内 https 地址通过', () => {
18
+ assert.equal(validateRegistryUrl('https://registry.npmjs.org/@modusensus%2Fdsh-mneme/latest'), true);
19
+ assert.equal(validateRegistryUrl('https://registry.npmjs.org/anything'), true);
20
+ });
21
+
22
+ test('非 https 一律拒绝', () => {
23
+ assert.equal(validateRegistryUrl('http://registry.npmjs.org/latest'), false);
24
+ assert.equal(validateRegistryUrl('ftp://registry.npmjs.org/latest'), false);
25
+ assert.equal(validateRegistryUrl('file:///etc/passwd'), false);
26
+ });
27
+
28
+ test('host 不在白名单一律拒绝(含本机/内网/相似域名)', () => {
29
+ assert.equal(validateRegistryUrl('https://localhost/latest'), false);
30
+ assert.equal(validateRegistryUrl('https://127.0.0.1/latest'), false);
31
+ assert.equal(validateRegistryUrl('https://[::1]/latest'), false);
32
+ assert.equal(validateRegistryUrl('https://10.0.0.5/latest'), false);
33
+ assert.equal(validateRegistryUrl('https://192.168.1.10/latest'), false);
34
+ assert.equal(validateRegistryUrl('https://evil-registry.npmjs.org.example.com/latest'), false);
35
+ assert.equal(validateRegistryUrl('https://registry.npmjs.org.evil.com/latest'), false);
36
+ assert.equal(validateRegistryUrl('https://npmjs.org/latest'), false);
37
+ });
38
+
39
+ test('非法 URL 拒绝而不抛出', () => {
40
+ assert.equal(validateRegistryUrl('not a url'), false);
41
+ assert.equal(validateRegistryUrl(''), false);
42
+ });
43
+ });
44
+
45
+ // ---- compareVersions / classify:纯函数比对 ----
46
+ describe('compareVersions', () => {
47
+ test('三元组数值比较', () => {
48
+ assert.equal(compareVersions('0.8.0', '0.8.1'), -1);
49
+ assert.equal(compareVersions('0.8.1', '0.8.0'), 1);
50
+ assert.equal(compareVersions('0.8.0', '0.8.0'), 0);
51
+ assert.equal(compareVersions('0.10.0', '0.9.0'), 1);
52
+ assert.equal(compareVersions('1.0.0', '0.9.9'), 1);
53
+ });
54
+
55
+ test('同号带 prerelease 的一方更旧;两侧都带视为同版', () => {
56
+ assert.equal(compareVersions('0.9.0-rc.1', '0.9.0'), -1);
57
+ assert.equal(compareVersions('0.9.0', '0.9.0-rc.1'), 1);
58
+ assert.equal(compareVersions('0.9.0-rc.1', '0.9.0-rc.1'), 0);
59
+ // 数字三元组相等、两侧都带后缀 → 同版(横幅只关心是否落后 latest,
60
+ // 不追求完整 semver prerelease 序)
61
+ assert.equal(compareVersions('0.9.0-rc.2', '0.9.0-rc.1'), 0);
62
+ });
63
+
64
+ test('解析失败返回 null', () => {
65
+ assert.equal(compareVersions('unknown', '0.8.0'), null);
66
+ assert.equal(compareVersions('0.8.0', ''), null);
67
+ assert.equal(compareVersions(null, null), null);
68
+ });
69
+ });
70
+
71
+ describe('classify', () => {
72
+ test('四种形态', () => {
73
+ assert.equal(classify('0.8.0', '0.8.1'), 'outdated');
74
+ assert.equal(classify('0.8.0', '0.8.0'), 'up-to-date');
75
+ // workspace/link 直载开发态:运行版本高于已发布 → ahead(前端不打扰)
76
+ assert.equal(classify('0.9.0', '0.8.1'), 'ahead');
77
+ // 查询失败/离线 → unknown(前端完全静默)
78
+ assert.equal(classify('0.8.0', null), 'unknown');
79
+ assert.equal(classify('unknown', '0.8.0'), 'unknown');
80
+ });
81
+ });
82
+
83
+ // ---- fetchLatestVersion:TTL 缓存 + 失败静默 ----
84
+ describe('fetchLatestVersion', () => {
85
+ test('正常响应取 latest;TTL 内复用缓存不重复请求', async () => {
86
+ resetCacheForTest();
87
+ let calls = 0;
88
+ let clock = 1000;
89
+ const fetchImpl = async () => { calls += 1; return { ok: true, json: async () => ({ version: '0.8.1' }) }; };
90
+ const now = () => clock;
91
+
92
+ assert.equal(await fetchLatestVersion({ fetchImpl, now }), '0.8.1');
93
+ assert.equal(await fetchLatestVersion({ fetchImpl, now }), '0.8.1');
94
+ assert.equal(calls, 1);
95
+
96
+ clock += 60 * 60 * 1000 - 1;
97
+ assert.equal(await fetchLatestVersion({ fetchImpl, now }), '0.8.1');
98
+ assert.equal(calls, 1);
99
+
100
+ // 过 TTL 后重新请求
101
+ clock += 1;
102
+ assert.equal(await fetchLatestVersion({ fetchImpl, now }), '0.8.1');
103
+ assert.equal(calls, 2);
104
+ resetCacheForTest();
105
+ });
106
+
107
+ test('失败静默返回 null:网络异常 / 非 2xx / 形状不对', async () => {
108
+ resetCacheForTest();
109
+ assert.equal(await fetchLatestVersion({ fetchImpl: async () => { throw new Error('offline'); }, now: () => 1000 }), null);
110
+ assert.equal(await fetchLatestVersion({ fetchImpl: async () => ({ ok: false, status: 503 }), now: () => 1000 }), null);
111
+ assert.equal(await fetchLatestVersion({ fetchImpl: async () => ({ ok: true, json: async () => ({ nope: true }) }), now: () => 1000 }), null);
112
+ resetCacheForTest();
113
+ });
114
+
115
+ test('失败不写缓存:下次调用立刻重试', async () => {
116
+ resetCacheForTest();
117
+ let calls = 0;
118
+ let fail = true;
119
+ const fetchImpl = async () => {
120
+ calls += 1;
121
+ if (fail) throw new Error('boom');
122
+ return { ok: true, json: async () => ({ version: '0.8.2' }) };
123
+ };
124
+ assert.equal(await fetchLatestVersion({ fetchImpl, now: () => 1000 }), null);
125
+ assert.equal(await fetchLatestVersion({ fetchImpl, now: () => 1000 }), null);
126
+ assert.equal(calls, 2);
127
+ fail = false;
128
+ assert.equal(await fetchLatestVersion({ fetchImpl, now: () => 1000 }), '0.8.2');
129
+ resetCacheForTest();
130
+ });
131
+
132
+ test('运行环境无全局 fetch 时走默认参兜底:resolve null 而非同步抛', async () => {
133
+ resetCacheForTest();
134
+ const original = globalThis.fetch;
135
+ globalThis.fetch = undefined;
136
+ try {
137
+ // 修复前:默认参 fetchImpl = fetch 在参数求值期同步抛 ReferenceError,
138
+ // 路由的 .catch 接不住;修复后应归入常规失败路径静默返 null。
139
+ assert.equal(await fetchLatestVersion({ now: () => 1000 }), null);
140
+ } finally {
141
+ globalThis.fetch = original;
142
+ }
143
+ resetCacheForTest();
144
+ });
145
+ });
146
+
147
+ // ---- 模块常量:URL 必须过白名单,运行版本可读 ----
148
+ test('REGISTRY_URL 自身通过白名单校验;PACKAGE_VERSION 可读', () => {
149
+ assert.equal(validateRegistryUrl(REGISTRY_URL), true);
150
+ assert.match(PACKAGE_VERSION, /^\d+\.\d+\.\d+/);
151
+ });
152
+ });