@ilaomianyang/dsh-memory-graph 0.11.0 → 0.12.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,401 @@
1
+ /**
2
+ * Self-update: noticing a newer version, and the one decision that installs it.
3
+ *
4
+ * The two halves are tested apart on purpose. The *check* is a comparison and a
5
+ * cache — no network is allowed in this suite, so the registry is a function
6
+ * (`fetchFn`) and the clock is a number. The *install* is a call into the host's
7
+ * plugin manager, so the manager is an object that records what it was asked for
8
+ * and can be told to refuse, hang, or come back needing a restart.
9
+ *
10
+ * The rule worth stating out loud: a development `link:` checkout must never
11
+ * update itself into a registry tarball, and that refusal must be a readable
12
+ * sentence rather than an exception.
13
+ *
14
+ * node test/update.mjs
15
+ */
16
+ import assert from 'node:assert/strict'
17
+ import fsp from 'node:fs/promises'
18
+ import os from 'node:os'
19
+ import path from 'node:path'
20
+
21
+ import { resolveConfig } from '../src/config.js'
22
+ import {
23
+ PACKAGE_NAME,
24
+ compareVersions,
25
+ createUpdateChecker,
26
+ createUpdateHandler,
27
+ installKindOf,
28
+ readOwnVersion,
29
+ } from '../src/update.js'
30
+
31
+ const results = []
32
+ function check(name, fn) {
33
+ return Promise.resolve()
34
+ .then(fn)
35
+ .then(
36
+ () => results.push(` ok ${name}`),
37
+ (error) => {
38
+ results.push(` FAIL ${name}: ${error.message}`)
39
+ process.exitCode = 1
40
+ },
41
+ )
42
+ }
43
+
44
+ const MINUTE = 60 * 1000
45
+ const HOUR = 60 * MINUTE
46
+
47
+ const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'memory-graph-update-'))
48
+ let serial = 0
49
+ const freshDir = async () => {
50
+ serial += 1
51
+ const dir = path.join(tmpRoot, `v${serial}`)
52
+ await fsp.mkdir(dir, { recursive: true })
53
+ return dir
54
+ }
55
+
56
+ /** A checker over one temp vault, with the registry and the clock under test control. */
57
+ async function makeChecker({
58
+ version = '0.11.0',
59
+ latest = '0.12.0',
60
+ update = {},
61
+ bundles = [{ name: PACKAGE_NAME, availability: 'profile', installTarget: { spec: '0.11.0', version: '0.11.0' } }],
62
+ installResult = { changed: true, application: 'restart-required', stage: 'install', target: PACKAGE_NAME, version: '0.12.0' },
63
+ fetchFails = false,
64
+ registryFails = false,
65
+ } = {}) {
66
+ const dir = await freshDir()
67
+ const cfg = resolveConfig({ update: { enabled: true, ...update } })
68
+ cfg.vaultDir = dir
69
+ const clock = { at: Date.UTC(2026, 10, 3, 8, 0, 0) }
70
+ const fetches = []
71
+ const installs = []
72
+ const logs = []
73
+ const manager = {
74
+ async registries() {
75
+ if (registryFails) throw new Error('no registries')
76
+ return { registry: 'https://registry.npmjs.org', fallbackRegistries: [], resolved: null }
77
+ },
78
+ async listBundles() {
79
+ return bundles
80
+ },
81
+ async installBundle(spec, options) {
82
+ installs.push({ spec, options })
83
+ if (typeof installResult === 'function') return installResult(spec, options)
84
+ return installResult
85
+ },
86
+ }
87
+ const checker = createUpdateChecker({
88
+ cfg,
89
+ log: (level, message) => logs.push(`${level}: ${message}`),
90
+ getPluginManager: () => manager,
91
+ readVersion: async () => version,
92
+ now: () => clock.at,
93
+ fetchFn: async (url) => {
94
+ fetches.push(url)
95
+ if (fetchFails) throw new Error('getaddrinfo ENOTFOUND registry.npmjs.org')
96
+ return { ok: true, status: 200, async json() { return { name: PACKAGE_NAME, version: latest } } }
97
+ },
98
+ })
99
+ return { dir, cfg, checker, clock, fetches, installs, logs, manager }
100
+ }
101
+
102
+ const readCache = async (dir) => JSON.parse(await fsp.readFile(path.join(dir, '.update-state.json'), 'utf8'))
103
+
104
+ /* --------------------------------------------------------------- versions */
105
+
106
+ await check('a newer version compares higher, an equal one compares equal', () => {
107
+ assert.equal(compareVersions('0.12.0', '0.11.0'), 1)
108
+ assert.equal(compareVersions('0.11.0', '0.11.0'), 0)
109
+ assert.equal(compareVersions('0.11.0', '0.12.0'), -1)
110
+ })
111
+
112
+ await check('a prerelease sits below its release, and a v prefix is ignored', () => {
113
+ assert.equal(compareVersions('1.0.0-beta.1', '1.0.0'), -1)
114
+ assert.equal(compareVersions('v0.11.0', '0.11.0'), 0)
115
+ assert.equal(compareVersions('0.9.9', '0.10.0'), -1)
116
+ })
117
+
118
+ await check('the version comes from this package manifest, not a constant', async () => {
119
+ const version = await readOwnVersion()
120
+ assert.match(String(version), /^\d+\.\d+\.\d+/u)
121
+ })
122
+
123
+ /* ----------------------------------------------------- install kinds */
124
+
125
+ await check('a link: or file: install is a working copy, not an updatable package', () => {
126
+ assert.equal(installKindOf([{ name: PACKAGE_NAME, availability: 'profile', installTarget: { spec: 'link:G:/repo' } }]), 'link')
127
+ assert.equal(installKindOf([{ name: PACKAGE_NAME, availability: 'profile', installTarget: { spec: 'file:../repo' } }]), 'link')
128
+ })
129
+
130
+ await check('a registry install is updatable, and an unknown shape is not', () => {
131
+ assert.equal(installKindOf([{ name: PACKAGE_NAME, availability: 'profile', installTarget: { spec: '0.11.0' } }]), 'registry')
132
+ assert.equal(installKindOf([]), 'unknown')
133
+ assert.equal(installKindOf(undefined), 'unknown')
134
+ assert.equal(installKindOf([{ name: 'something-else', availability: 'profile', installTarget: { spec: '1.0.0' } }]), 'unknown')
135
+ })
136
+
137
+ /* -------------------------------------------------------------- checking */
138
+
139
+ await check('a newer registry version reads as behind, in Chinese, with the offset in the stamp', async () => {
140
+ const { checker, dir } = await makeChecker()
141
+ const state = await checker.status()
142
+ assert.equal(state.ok, true)
143
+ assert.equal(state.current, '0.11.0')
144
+ assert.equal(state.latest, '0.12.0')
145
+ assert.equal(state.behind, true)
146
+ assert.match(state.message, /有新版本 0\.12\.0/u)
147
+ assert.match(String(state.checkedAt), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/u)
148
+ const cache = await readCache(dir)
149
+ assert.equal(cache.latest, '0.12.0')
150
+ })
151
+
152
+ await check('the same version reads as up to date', async () => {
153
+ const { checker } = await makeChecker({ latest: '0.11.0' })
154
+ const state = await checker.status()
155
+ assert.equal(state.behind, false)
156
+ assert.match(state.message, /已是最新版本 0\.11\.0/u)
157
+ })
158
+
159
+ await check('a second read inside the window asks the registry once', async () => {
160
+ const { checker, fetches, clock } = await makeChecker()
161
+ await checker.status()
162
+ clock.at += 5 * MINUTE
163
+ await checker.status()
164
+ assert.equal(fetches.length, 1)
165
+ })
166
+
167
+ await check('force does not re-ask within a minute, and does after it', async () => {
168
+ const { checker, fetches, clock } = await makeChecker()
169
+ await checker.status()
170
+ clock.at += 30 * 1000
171
+ await checker.status({ force: true })
172
+ assert.equal(fetches.length, 1, 'a double click must not become two requests')
173
+ clock.at += 40 * 1000
174
+ await checker.status({ force: true })
175
+ assert.equal(fetches.length, 2)
176
+ })
177
+
178
+ await check('the cache expires on its own after checkEveryHours', async () => {
179
+ const { checker, fetches, clock } = await makeChecker({ update: { checkEveryHours: 12 } })
180
+ await checker.status()
181
+ clock.at += 11 * HOUR
182
+ await checker.status()
183
+ assert.equal(fetches.length, 1)
184
+ clock.at += 2 * HOUR
185
+ await checker.status()
186
+ assert.equal(fetches.length, 2)
187
+ })
188
+
189
+ await check('a failed check keeps the last answer and reports the error', async () => {
190
+ const { checker, fetches, clock } = await makeChecker({ fetchFails: true })
191
+ const first = await checker.status()
192
+ assert.equal(first.latest, null)
193
+ assert.match(first.message, /查不到新版本/u)
194
+ assert.match(String(first.error), /ENOTFOUND/u)
195
+ // The cache is not a lie in the other direction either: still no version known.
196
+ clock.at += 20 * HOUR
197
+ const second = await checker.status()
198
+ assert.equal(second.behind, false)
199
+ assert.equal(fetches.length, 2)
200
+ })
201
+
202
+ await check('a registry outage after a good check does not forget the version', async () => {
203
+ const { checker, cfg, clock } = await makeChecker()
204
+ await checker.status()
205
+ cfg.update.checkEveryHours = 1
206
+ clock.at += 2 * HOUR
207
+ const offline = (url) => {
208
+ throw new Error('offline')
209
+ }
210
+ const broken = createUpdateChecker({ cfg, log: () => {}, getPluginManager: () => null, readVersion: async () => '0.11.0', now: () => clock.at, fetchFn: offline })
211
+ const state = await broken.status()
212
+ assert.equal(state.latest, '0.12.0', 'the previous answer survives an outage')
213
+ assert.match(String(state.error), /offline/u)
214
+ })
215
+
216
+ await check('turning checks off reports the version without asking anyone', async () => {
217
+ const { checker, fetches } = await makeChecker({ update: { enabled: false } })
218
+ const state = await checker.status()
219
+ assert.equal(state.current, '0.11.0')
220
+ assert.equal(state.canCheck, false)
221
+ assert.equal(state.behind, false)
222
+ assert.match(state.message, /关掉/u)
223
+ assert.equal(fetches.length, 0)
224
+ })
225
+
226
+ await check('a configured registry wins over the plugin manager one', async () => {
227
+ const { checker } = await makeChecker({ update: { registry: 'https://mirror.example.com/' } })
228
+ const state = await checker.status()
229
+ assert.equal(state.registry, 'https://mirror.example.com')
230
+ })
231
+
232
+ await check('an unusable plugin manager does not stop the check', async () => {
233
+ const { checker } = await makeChecker({ registryFails: true })
234
+ const state = await checker.status()
235
+ assert.equal(state.registry, 'https://registry.npmjs.org')
236
+ assert.equal(state.behind, true)
237
+ })
238
+
239
+ /* ------------------------------------------------------------- installing */
240
+
241
+ await check('a link: install refuses to update itself and says why', async () => {
242
+ const { checker, installs } = await makeChecker({
243
+ bundles: [{ name: PACKAGE_NAME, availability: 'profile', installTarget: { spec: 'link:G:/TempProject/memory-graph-plugin' } }],
244
+ })
245
+ const state = await checker.status()
246
+ assert.equal(state.behind, true)
247
+ assert.equal(state.canInstall, false)
248
+ assert.match(state.message, /link:/u)
249
+ const result = await checker.install()
250
+ assert.equal(result.ok, false)
251
+ assert.equal(result.applied, false)
252
+ assert.match(result.message, /link:/u)
253
+ assert.equal(installs.length, 0, 'nothing may be installed over a working copy')
254
+ })
255
+
256
+ await check('a registry install asks the host for the exact newer version', async () => {
257
+ const { checker, installs } = await makeChecker()
258
+ const result = await checker.install()
259
+ assert.equal(result.ok, true)
260
+ assert.equal(result.applied, true)
261
+ assert.equal(result.application, 'restart-required')
262
+ assert.equal(result.version, '0.12.0')
263
+ assert.match(result.message, /重启 Harness/u)
264
+ assert.equal(installs.length, 1)
265
+ assert.equal(installs[0].spec, `${PACKAGE_NAME}@0.12.0`)
266
+ assert.equal(installs[0].options.enabled, true)
267
+ })
268
+
269
+ await check('an applied update is reported as applied, not as a restart', async () => {
270
+ const { checker } = await makeChecker({ installResult: { changed: true, application: 'applied', stage: 'install', target: PACKAGE_NAME, version: '0.12.0' } })
271
+ const result = await checker.install()
272
+ assert.equal(result.applied, true)
273
+ assert.match(result.message, /已更新到 0\.12\.0/u)
274
+ assert.doesNotMatch(result.message, /重启/u)
275
+ })
276
+
277
+ await check('nothing to update is a refusal, not an install', async () => {
278
+ const { checker, installs } = await makeChecker({ latest: '0.11.0' })
279
+ const result = await checker.install()
280
+ assert.equal(result.ok, false)
281
+ assert.match(result.message, /已经是最新版本/u)
282
+ assert.equal(installs.length, 0)
283
+ })
284
+
285
+ await check('a failed install is reported with the host diagnostic', async () => {
286
+ const { checker } = await makeChecker({
287
+ installResult: { changed: false, application: 'failed', stage: 'install', target: PACKAGE_NAME, error: { code: 'operation-error', diagnostic: 'ERR_PNPM_FETCH_404' } },
288
+ })
289
+ const result = await checker.install()
290
+ assert.equal(result.ok, false)
291
+ assert.equal(result.applied, false)
292
+ assert.match(result.message, /ERR_PNPM_FETCH_404/u)
293
+ })
294
+
295
+ await check('a second install while one is running is refused, not queued', async () => {
296
+ let release
297
+ const gate = new Promise((resolve) => {
298
+ release = resolve
299
+ })
300
+ const { checker, installs } = await makeChecker({
301
+ installResult: async () => {
302
+ await gate
303
+ return { changed: true, application: 'applied', stage: 'install', target: PACKAGE_NAME, version: '0.12.0' }
304
+ },
305
+ })
306
+ const first = checker.install()
307
+ await new Promise((resolve) => setTimeout(resolve, 20))
308
+ const second = await checker.install()
309
+ assert.equal(second.ok, false)
310
+ assert.match(second.message, /已经有一次更新在跑/u)
311
+ release()
312
+ assert.equal((await first).ok, true)
313
+ assert.equal(installs.length, 1)
314
+ })
315
+
316
+ await check('busy shows up in the status the page reads', async () => {
317
+ let release
318
+ const gate = new Promise((resolve) => {
319
+ release = resolve
320
+ })
321
+ const { checker } = await makeChecker({
322
+ installResult: async () => {
323
+ await gate
324
+ return { changed: true, application: 'applied', stage: 'install', target: PACKAGE_NAME, version: '0.12.0' }
325
+ },
326
+ })
327
+ const running = checker.install()
328
+ await new Promise((resolve) => setTimeout(resolve, 20))
329
+ const during = await checker.status()
330
+ assert.equal(during.busy, true)
331
+ assert.equal(during.canInstall, false)
332
+ release()
333
+ await running
334
+ })
335
+
336
+ await check('autoInstall at startup installs when allowed, and only then', async () => {
337
+ const off = await makeChecker({ update: { autoInstall: false } })
338
+ assert.equal((await off.checker.onStart()).behind, true)
339
+ assert.equal(off.installs.length, 0)
340
+
341
+ const on = await makeChecker({ update: { autoInstall: true } })
342
+ const result = await on.checker.onStart()
343
+ assert.equal(result.applied, true)
344
+ assert.equal(on.installs.length, 1)
345
+ })
346
+
347
+ await check('autoInstall never touches a link: install', async () => {
348
+ const { checker, installs } = await makeChecker({
349
+ update: { autoInstall: true },
350
+ bundles: [{ name: PACKAGE_NAME, availability: 'profile', installTarget: { spec: 'link:G:/repo' } }],
351
+ })
352
+ const state = await checker.onStart()
353
+ assert.equal(state.behind, true)
354
+ assert.equal(state.installKind, 'link')
355
+ assert.equal(installs.length, 0)
356
+ })
357
+
358
+ /* ----------------------------------------------------------------- route */
359
+
360
+ const callHandler = async (checker, { method = 'GET', url = 'http://localhost/api/memory-graph.update' } = {}) => {
361
+ const handler = createUpdateHandler({ getChecker: () => checker })
362
+ const response = await handler(new Request(url, { method }))
363
+ return { status: response.status, headers: response.headers, body: await response.json() }
364
+ }
365
+
366
+ await check('GET answers with the version state and never caches it', async () => {
367
+ const { checker } = await makeChecker()
368
+ const { status, headers, body } = await callHandler(checker)
369
+ assert.equal(status, 200)
370
+ assert.equal(headers.get('cache-control'), 'no-store')
371
+ assert.equal(body.current, '0.11.0')
372
+ assert.equal(body.latest, '0.12.0')
373
+ assert.equal(body.behind, true)
374
+ })
375
+
376
+ await check('POST installs, and a refused POST is a 409 with a sentence in it', async () => {
377
+ const ok = await makeChecker()
378
+ const good = await callHandler(ok.checker, { method: 'POST' })
379
+ assert.equal(good.status, 200)
380
+ assert.equal(good.body.applied, true)
381
+
382
+ const link = await makeChecker({
383
+ bundles: [{ name: PACKAGE_NAME, availability: 'profile', installTarget: { spec: 'link:G:/repo' } }],
384
+ })
385
+ const refused = await callHandler(link.checker, { method: 'POST' })
386
+ assert.equal(refused.status, 409)
387
+ assert.equal(refused.body.applied, false)
388
+ assert.match(refused.body.message, /link:/u)
389
+ })
390
+
391
+ await check('a route with no checker explains itself instead of throwing', async () => {
392
+ const { status, body } = await callHandler(null)
393
+ assert.equal(status, 503)
394
+ assert.equal(body.ok, false)
395
+ assert.match(body.error, /更新检查/u)
396
+ })
397
+
398
+ await fsp.rm(tmpRoot, { recursive: true, force: true })
399
+
400
+ console.log(results.join('\n'))
401
+ console.log(process.exitCode === 1 ? `\n${results.filter((line) => line.includes('FAIL')).length} check(s) failed` : `\nall ${results.length} checks passed`)