dsh-dbhub-live 5.2.0-dev.3 → 5.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.
- package/AGENTS.md +116 -116
- package/README.en.md +22 -22
- package/README.md +23 -23
- package/doc/ARCHITECTURE.md +108 -108
- package/doc/CLIENT.md +124 -124
- package/doc/DESENSITIZE.md +56 -56
- package/doc/DEVELOPMENT.md +139 -139
- package/doc/IMPACT-MATRIX.md +36 -36
- package/doc/RELEASE.md +93 -93
- package/lib/meta.mjs +267 -267
- package/lib/policy.mjs +92 -92
- package/lib/update.mjs +244 -244
- package/package.json +1 -1
- package/scripts/desensitize-check.mjs +331 -331
- package/scripts/docs-check.mjs +214 -214
- package/scripts/release-preflight.mjs +180 -180
- package/test/meta.test.mjs +234 -234
- package/test/policy.test.mjs +132 -132
- package/test/update.test.mjs +270 -270
package/test/update.test.mjs
CHANGED
|
@@ -1,270 +1,270 @@
|
|
|
1
|
-
// Unit tests for the plugin self-update check (lib/update.mjs): npm-first with a
|
|
2
|
-
// GitHub fallback, prerelease-aware hasUpdate, error sanitization, the 30 min
|
|
3
|
-
// throttle, `force`, concurrent-call dedupe and the per-request timeout.
|
|
4
|
-
//
|
|
5
|
-
// Everything is driven through an injected fetch stub and an injected clock, so
|
|
6
|
-
// these tests never touch the network and never wait on a real timer.
|
|
7
|
-
|
|
8
|
-
import test from 'node:test'
|
|
9
|
-
import assert from 'node:assert/strict'
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
DEFAULT_THROTTLE_MS,
|
|
13
|
-
GITHUB_LATEST_URL,
|
|
14
|
-
NPM_LATEST_URL,
|
|
15
|
-
createUpdateChecker,
|
|
16
|
-
versionFromGithubBody,
|
|
17
|
-
versionFromNpmBody,
|
|
18
|
-
} from '../lib/update.mjs'
|
|
19
|
-
|
|
20
|
-
// ── helpers ───────────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
/** A fetch stub that answers from a per-URL table: value = body | {status}. */
|
|
23
|
-
function stubFetch(routes) {
|
|
24
|
-
const calls = []
|
|
25
|
-
const impl = async (url) => {
|
|
26
|
-
calls.push(url)
|
|
27
|
-
const route = routes[url]
|
|
28
|
-
if (!route) throw new Error('unexpected url ' + url)
|
|
29
|
-
if (route instanceof Error) throw route
|
|
30
|
-
if (typeof route === 'function') return route(url)
|
|
31
|
-
const status = typeof route.status === 'number' ? route.status : 200
|
|
32
|
-
return {
|
|
33
|
-
ok: status >= 200 && status < 300,
|
|
34
|
-
status,
|
|
35
|
-
json: async () => route.body,
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
impl.calls = calls
|
|
39
|
-
impl.byUrl = (needle) => calls.filter((url) => url.includes(needle)).length
|
|
40
|
-
return impl
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const ok = (body) => ({ status: 200, body })
|
|
44
|
-
const fail = (status) => ({ status })
|
|
45
|
-
|
|
46
|
-
function checker(overrides = {}) {
|
|
47
|
-
return createUpdateChecker({
|
|
48
|
-
currentVersion: '5.1.1-dev.1',
|
|
49
|
-
now: () => 1000,
|
|
50
|
-
...overrides,
|
|
51
|
-
})
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const NPM_OK = { [NPM_LATEST_URL]: ok({ version: '5.2.0' }) }
|
|
55
|
-
const GH_OK = { [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }) }
|
|
56
|
-
|
|
57
|
-
// ── pure body extractors ──────────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
test('versionFromNpmBody accepts objects and raw JSON strings, rejects junk', () => {
|
|
60
|
-
assert.equal(versionFromNpmBody({ version: '5.2.0' }), '5.2.0')
|
|
61
|
-
assert.equal(versionFromNpmBody({ version: ' 5.2.0-dev.1 ' }), '5.2.0-dev.1')
|
|
62
|
-
assert.equal(versionFromNpmBody('{"version":"5.2.0"}'), '5.2.0')
|
|
63
|
-
assert.equal(versionFromNpmBody({ version: 'latest' }), '')
|
|
64
|
-
assert.equal(versionFromNpmBody({ version: '' }), '')
|
|
65
|
-
assert.equal(versionFromNpmBody({}), '')
|
|
66
|
-
assert.equal(versionFromNpmBody(null), '')
|
|
67
|
-
assert.equal(versionFromNpmBody('not json'), '')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('versionFromGithubBody strips the release tag v prefix', () => {
|
|
71
|
-
assert.equal(versionFromGithubBody({ tag_name: 'v5.2.0' }), '5.2.0')
|
|
72
|
-
assert.equal(versionFromGithubBody({ tag_name: '5.2.0' }), '5.2.0')
|
|
73
|
-
assert.equal(versionFromGithubBody({ tag_name: 'V5.2.0-dev.3' }), '5.2.0-dev.3')
|
|
74
|
-
assert.equal(versionFromGithubBody('{"tag_name":"v5.2.0"}'), '5.2.0')
|
|
75
|
-
assert.equal(versionFromGithubBody({ tag_name: 'vnext' }), '')
|
|
76
|
-
assert.equal(versionFromGithubBody({ name: 'v5.2.0' }), '')
|
|
77
|
-
assert.equal(versionFromGithubBody(null), '')
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
// ── happy path + fallback ─────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
test('npm success produces the full snapshot', async () => {
|
|
83
|
-
const fetchImpl = stubFetch(NPM_OK)
|
|
84
|
-
const update = checker({ fetchImpl, now: () => 4242 })
|
|
85
|
-
assert.deepEqual(update.snapshot(), {
|
|
86
|
-
latest: '',
|
|
87
|
-
latestSpec: '',
|
|
88
|
-
hasUpdate: false,
|
|
89
|
-
checkedAt: 0,
|
|
90
|
-
source: '',
|
|
91
|
-
error: '',
|
|
92
|
-
}, 'nothing is claimed before the first check')
|
|
93
|
-
const snap = await update.check()
|
|
94
|
-
assert.deepEqual(snap, {
|
|
95
|
-
latest: '5.2.0',
|
|
96
|
-
latestSpec: 'dsh-dbhub-live@5.2.0',
|
|
97
|
-
hasUpdate: true,
|
|
98
|
-
checkedAt: 4242,
|
|
99
|
-
source: 'npm',
|
|
100
|
-
error: '',
|
|
101
|
-
})
|
|
102
|
-
assert.deepEqual(fetchImpl.calls, [NPM_LATEST_URL], 'github is not asked when npm answered')
|
|
103
|
-
// No rejected promise, no throw: check() resolves on every path.
|
|
104
|
-
assert.deepEqual(update.snapshot(), snap)
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('npm failure falls back to the GitHub release tag', async () => {
|
|
108
|
-
const fetchImpl = stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }) })
|
|
109
|
-
const snap = await checker({ fetchImpl }).check()
|
|
110
|
-
assert.equal(snap.source, 'github')
|
|
111
|
-
assert.equal(snap.latest, '5.2.0')
|
|
112
|
-
assert.equal(snap.latestSpec, 'dsh-dbhub-live@5.2.0')
|
|
113
|
-
assert.equal(snap.hasUpdate, true)
|
|
114
|
-
assert.equal(snap.error, '', 'a successful fallback clears the npm error')
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('an invalid npm payload also falls back instead of claiming a version', async () => {
|
|
118
|
-
const fetchImpl = stubFetch({
|
|
119
|
-
[NPM_LATEST_URL]: ok({ version: 'latest' }),
|
|
120
|
-
[GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }),
|
|
121
|
-
})
|
|
122
|
-
const snap = await checker({ fetchImpl }).check()
|
|
123
|
-
assert.equal(snap.latest, '5.2.0')
|
|
124
|
-
assert.equal(snap.source, 'github')
|
|
125
|
-
assert.equal(snap.error, '')
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
test('both sources failing yields a short error and KEEPS the previous result', async () => {
|
|
129
|
-
let clock = 1000
|
|
130
|
-
let current = stubFetch(NPM_OK)
|
|
131
|
-
// One checker; the stub it calls is swapped between runs (like a network
|
|
132
|
-
// that worked once and then went down).
|
|
133
|
-
const flaky = createUpdateChecker({
|
|
134
|
-
currentVersion: '5.1.1-dev.1',
|
|
135
|
-
now: () => clock,
|
|
136
|
-
throttleMs: 0,
|
|
137
|
-
fetchImpl: (url, init) => current(url, init),
|
|
138
|
-
})
|
|
139
|
-
const good = await flaky.check()
|
|
140
|
-
assert.equal(good.latest, '5.2.0')
|
|
141
|
-
assert.equal(good.error, '')
|
|
142
|
-
|
|
143
|
-
current = stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: fail(500) })
|
|
144
|
-
clock = 60_000
|
|
145
|
-
const bad = await flaky.check(true)
|
|
146
|
-
assert.equal(bad.latest, '5.2.0', 'stale latest preserved')
|
|
147
|
-
assert.equal(bad.latestSpec, 'dsh-dbhub-live@5.2.0')
|
|
148
|
-
assert.equal(bad.source, 'npm')
|
|
149
|
-
assert.equal(bad.hasUpdate, true)
|
|
150
|
-
assert.equal(bad.checkedAt, 60_000, 'checkedAt advances even on failure')
|
|
151
|
-
assert.equal(bad.error, 'npm: HTTP 503; github: HTTP 500')
|
|
152
|
-
|
|
153
|
-
// A brand-new checker has nothing to preserve: a failure leaves it empty.
|
|
154
|
-
const fresh = createUpdateChecker({
|
|
155
|
-
currentVersion: '5.1.1-dev.1',
|
|
156
|
-
now: () => clock,
|
|
157
|
-
fetchImpl: stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: fail(500) }),
|
|
158
|
-
})
|
|
159
|
-
const empty = await fresh.check(true)
|
|
160
|
-
assert.equal(empty.latest, '')
|
|
161
|
-
assert.equal(empty.latestSpec, '')
|
|
162
|
-
assert.equal(empty.hasUpdate, false)
|
|
163
|
-
assert.equal(empty.source, '')
|
|
164
|
-
assert.equal(empty.error, 'npm: HTTP 503; github: HTTP 500')
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
test('hasUpdate is strictly-newer and prerelease-aware', async () => {
|
|
168
|
-
const cases = [
|
|
169
|
-
// current, latest, expected hasUpdate
|
|
170
|
-
['5.1.1-dev.1', '5.2.0', true, 'a newer release'],
|
|
171
|
-
['5.2.0-dev.1', '5.2.0', true, 'the release supersedes its own dev build'],
|
|
172
|
-
['5.2.0', '5.2.0-dev.2', false, 'a dev build is older than the release'],
|
|
173
|
-
['5.2.0', '5.2.0', false, 'equal versions'],
|
|
174
|
-
['5.2.0', '5.1.0', false, 'an older release'],
|
|
175
|
-
['5.1.0', '5.2.0-dev.1', true, 'a newer dev build'],
|
|
176
|
-
['', '5.2.0', false, 'unknown current version never claims an update'],
|
|
177
|
-
]
|
|
178
|
-
for (const [currentVersion, latest, expected, why] of cases) {
|
|
179
|
-
const fetchImpl = stubFetch({ [NPM_LATEST_URL]: ok({ version: latest }) })
|
|
180
|
-
const snap = await createUpdateChecker({ currentVersion, fetchImpl, now: () => 1 }).check(true)
|
|
181
|
-
assert.equal(snap.hasUpdate, expected, currentVersion + ' -> ' + latest + ' (' + why + ')')
|
|
182
|
-
assert.equal(snap.latest, latest)
|
|
183
|
-
}
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
// ── throttle / force / dedupe ─────────────────────────────────────────────
|
|
187
|
-
|
|
188
|
-
test('a second check inside the throttle window makes no request', async () => {
|
|
189
|
-
let clock = 10_000
|
|
190
|
-
const fetchImpl = stubFetch(NPM_OK)
|
|
191
|
-
const update = createUpdateChecker({ fetchImpl, now: () => clock })
|
|
192
|
-
const first = await update.check()
|
|
193
|
-
assert.equal(fetchImpl.calls.length, 1)
|
|
194
|
-
|
|
195
|
-
clock = 10_000 + DEFAULT_THROTTLE_MS - 1
|
|
196
|
-
const reused = await update.check()
|
|
197
|
-
assert.deepEqual(reused, first, 'the snapshot is reused verbatim')
|
|
198
|
-
assert.equal(fetchImpl.calls.length, 1, 'no new request inside the throttle window')
|
|
199
|
-
|
|
200
|
-
// `force` always refetches, even immediately.
|
|
201
|
-
const forced = await update.check(true)
|
|
202
|
-
assert.equal(fetchImpl.calls.length, 2)
|
|
203
|
-
assert.equal(forced.checkedAt, clock)
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
test('concurrent checks share one in-flight request', async () => {
|
|
207
|
-
let release
|
|
208
|
-
const gate = new Promise((resolve) => { release = resolve })
|
|
209
|
-
const fetchImpl = stubFetch({
|
|
210
|
-
[NPM_LATEST_URL]: async () => {
|
|
211
|
-
await gate
|
|
212
|
-
return { ok: true, status: 200, json: async () => ({ version: '5.2.0' }) }
|
|
213
|
-
},
|
|
214
|
-
})
|
|
215
|
-
const update = createUpdateChecker({ fetchImpl, now: () => 7 })
|
|
216
|
-
const first = update.check(true)
|
|
217
|
-
const second = update.check(true)
|
|
218
|
-
assert.equal(first, second, 'the same promise is handed to every concurrent caller')
|
|
219
|
-
release()
|
|
220
|
-
const [a, b] = await Promise.all([first, second])
|
|
221
|
-
assert.deepEqual(a, b)
|
|
222
|
-
assert.equal(fetchImpl.calls.length, 1, 'one request burst, not one per caller')
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
test('a failing check never rejects and error text is sanitized', async () => {
|
|
226
|
-
const fetchImpl = stubFetch({
|
|
227
|
-
[NPM_LATEST_URL]: new Error('boom https://registry.npmjs.org/dsh-dbhub-live/latest?token=abc123'),
|
|
228
|
-
[GITHUB_LATEST_URL]: new Error('fetch failed'),
|
|
229
|
-
})
|
|
230
|
-
const snap = await createUpdateChecker({ fetchImpl, now: () => 5 }).check(true)
|
|
231
|
-
assert.equal(snap.latest, '')
|
|
232
|
-
assert.equal(snap.source, '')
|
|
233
|
-
assert.match(snap.error, /^npm: /)
|
|
234
|
-
assert.match(snap.error, /github: /)
|
|
235
|
-
assert.ok(!snap.error.includes('http'), 'no URL survives into the view: ' + snap.error)
|
|
236
|
-
assert.ok(!snap.error.includes('token='), 'no query token survives into the view: ' + snap.error)
|
|
237
|
-
assert.ok(snap.error.length <= 200, 'the message stays short')
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
test('a request that outlives the budget is aborted and reported as timeout', async () => {
|
|
241
|
-
const fetchImpl = (url, init) => new Promise((resolve, reject) => {
|
|
242
|
-
const signal = init && init.signal
|
|
243
|
-
if (!signal) {
|
|
244
|
-
reject(new Error('no abort signal was passed'))
|
|
245
|
-
return
|
|
246
|
-
}
|
|
247
|
-
const abort = () => {
|
|
248
|
-
const error = new Error('The operation was aborted')
|
|
249
|
-
error.name = 'AbortError'
|
|
250
|
-
reject(error)
|
|
251
|
-
}
|
|
252
|
-
if (signal.aborted) abort()
|
|
253
|
-
else signal.addEventListener('abort', abort)
|
|
254
|
-
})
|
|
255
|
-
const snap = await createUpdateChecker({ fetchImpl, now: () => 1, timeoutMs: 20 }).check(true)
|
|
256
|
-
assert.equal(snap.latest, '')
|
|
257
|
-
assert.equal(snap.source, '')
|
|
258
|
-
assert.equal(snap.error, 'npm: timeout; github: timeout')
|
|
259
|
-
assert.equal(snap.checkedAt, 1)
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
test('snapshot() returns a detached copy', async () => {
|
|
263
|
-
const update = checker({ fetchImpl: stubFetch(NPM_OK) })
|
|
264
|
-
await update.check(true)
|
|
265
|
-
const snap = update.snapshot()
|
|
266
|
-
snap.latest = 'tampered'
|
|
267
|
-
snap.hasUpdate = false
|
|
268
|
-
assert.equal(update.snapshot().latest, '5.2.0')
|
|
269
|
-
assert.equal(update.snapshot().hasUpdate, true)
|
|
270
|
-
})
|
|
1
|
+
// Unit tests for the plugin self-update check (lib/update.mjs): npm-first with a
|
|
2
|
+
// GitHub fallback, prerelease-aware hasUpdate, error sanitization, the 30 min
|
|
3
|
+
// throttle, `force`, concurrent-call dedupe and the per-request timeout.
|
|
4
|
+
//
|
|
5
|
+
// Everything is driven through an injected fetch stub and an injected clock, so
|
|
6
|
+
// these tests never touch the network and never wait on a real timer.
|
|
7
|
+
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_THROTTLE_MS,
|
|
13
|
+
GITHUB_LATEST_URL,
|
|
14
|
+
NPM_LATEST_URL,
|
|
15
|
+
createUpdateChecker,
|
|
16
|
+
versionFromGithubBody,
|
|
17
|
+
versionFromNpmBody,
|
|
18
|
+
} from '../lib/update.mjs'
|
|
19
|
+
|
|
20
|
+
// ── helpers ───────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** A fetch stub that answers from a per-URL table: value = body | {status}. */
|
|
23
|
+
function stubFetch(routes) {
|
|
24
|
+
const calls = []
|
|
25
|
+
const impl = async (url) => {
|
|
26
|
+
calls.push(url)
|
|
27
|
+
const route = routes[url]
|
|
28
|
+
if (!route) throw new Error('unexpected url ' + url)
|
|
29
|
+
if (route instanceof Error) throw route
|
|
30
|
+
if (typeof route === 'function') return route(url)
|
|
31
|
+
const status = typeof route.status === 'number' ? route.status : 200
|
|
32
|
+
return {
|
|
33
|
+
ok: status >= 200 && status < 300,
|
|
34
|
+
status,
|
|
35
|
+
json: async () => route.body,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
impl.calls = calls
|
|
39
|
+
impl.byUrl = (needle) => calls.filter((url) => url.includes(needle)).length
|
|
40
|
+
return impl
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ok = (body) => ({ status: 200, body })
|
|
44
|
+
const fail = (status) => ({ status })
|
|
45
|
+
|
|
46
|
+
function checker(overrides = {}) {
|
|
47
|
+
return createUpdateChecker({
|
|
48
|
+
currentVersion: '5.1.1-dev.1',
|
|
49
|
+
now: () => 1000,
|
|
50
|
+
...overrides,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const NPM_OK = { [NPM_LATEST_URL]: ok({ version: '5.2.0' }) }
|
|
55
|
+
const GH_OK = { [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }) }
|
|
56
|
+
|
|
57
|
+
// ── pure body extractors ──────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
test('versionFromNpmBody accepts objects and raw JSON strings, rejects junk', () => {
|
|
60
|
+
assert.equal(versionFromNpmBody({ version: '5.2.0' }), '5.2.0')
|
|
61
|
+
assert.equal(versionFromNpmBody({ version: ' 5.2.0-dev.1 ' }), '5.2.0-dev.1')
|
|
62
|
+
assert.equal(versionFromNpmBody('{"version":"5.2.0"}'), '5.2.0')
|
|
63
|
+
assert.equal(versionFromNpmBody({ version: 'latest' }), '')
|
|
64
|
+
assert.equal(versionFromNpmBody({ version: '' }), '')
|
|
65
|
+
assert.equal(versionFromNpmBody({}), '')
|
|
66
|
+
assert.equal(versionFromNpmBody(null), '')
|
|
67
|
+
assert.equal(versionFromNpmBody('not json'), '')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('versionFromGithubBody strips the release tag v prefix', () => {
|
|
71
|
+
assert.equal(versionFromGithubBody({ tag_name: 'v5.2.0' }), '5.2.0')
|
|
72
|
+
assert.equal(versionFromGithubBody({ tag_name: '5.2.0' }), '5.2.0')
|
|
73
|
+
assert.equal(versionFromGithubBody({ tag_name: 'V5.2.0-dev.3' }), '5.2.0-dev.3')
|
|
74
|
+
assert.equal(versionFromGithubBody('{"tag_name":"v5.2.0"}'), '5.2.0')
|
|
75
|
+
assert.equal(versionFromGithubBody({ tag_name: 'vnext' }), '')
|
|
76
|
+
assert.equal(versionFromGithubBody({ name: 'v5.2.0' }), '')
|
|
77
|
+
assert.equal(versionFromGithubBody(null), '')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ── happy path + fallback ─────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
test('npm success produces the full snapshot', async () => {
|
|
83
|
+
const fetchImpl = stubFetch(NPM_OK)
|
|
84
|
+
const update = checker({ fetchImpl, now: () => 4242 })
|
|
85
|
+
assert.deepEqual(update.snapshot(), {
|
|
86
|
+
latest: '',
|
|
87
|
+
latestSpec: '',
|
|
88
|
+
hasUpdate: false,
|
|
89
|
+
checkedAt: 0,
|
|
90
|
+
source: '',
|
|
91
|
+
error: '',
|
|
92
|
+
}, 'nothing is claimed before the first check')
|
|
93
|
+
const snap = await update.check()
|
|
94
|
+
assert.deepEqual(snap, {
|
|
95
|
+
latest: '5.2.0',
|
|
96
|
+
latestSpec: 'dsh-dbhub-live@5.2.0',
|
|
97
|
+
hasUpdate: true,
|
|
98
|
+
checkedAt: 4242,
|
|
99
|
+
source: 'npm',
|
|
100
|
+
error: '',
|
|
101
|
+
})
|
|
102
|
+
assert.deepEqual(fetchImpl.calls, [NPM_LATEST_URL], 'github is not asked when npm answered')
|
|
103
|
+
// No rejected promise, no throw: check() resolves on every path.
|
|
104
|
+
assert.deepEqual(update.snapshot(), snap)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('npm failure falls back to the GitHub release tag', async () => {
|
|
108
|
+
const fetchImpl = stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }) })
|
|
109
|
+
const snap = await checker({ fetchImpl }).check()
|
|
110
|
+
assert.equal(snap.source, 'github')
|
|
111
|
+
assert.equal(snap.latest, '5.2.0')
|
|
112
|
+
assert.equal(snap.latestSpec, 'dsh-dbhub-live@5.2.0')
|
|
113
|
+
assert.equal(snap.hasUpdate, true)
|
|
114
|
+
assert.equal(snap.error, '', 'a successful fallback clears the npm error')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('an invalid npm payload also falls back instead of claiming a version', async () => {
|
|
118
|
+
const fetchImpl = stubFetch({
|
|
119
|
+
[NPM_LATEST_URL]: ok({ version: 'latest' }),
|
|
120
|
+
[GITHUB_LATEST_URL]: ok({ tag_name: 'v5.2.0' }),
|
|
121
|
+
})
|
|
122
|
+
const snap = await checker({ fetchImpl }).check()
|
|
123
|
+
assert.equal(snap.latest, '5.2.0')
|
|
124
|
+
assert.equal(snap.source, 'github')
|
|
125
|
+
assert.equal(snap.error, '')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('both sources failing yields a short error and KEEPS the previous result', async () => {
|
|
129
|
+
let clock = 1000
|
|
130
|
+
let current = stubFetch(NPM_OK)
|
|
131
|
+
// One checker; the stub it calls is swapped between runs (like a network
|
|
132
|
+
// that worked once and then went down).
|
|
133
|
+
const flaky = createUpdateChecker({
|
|
134
|
+
currentVersion: '5.1.1-dev.1',
|
|
135
|
+
now: () => clock,
|
|
136
|
+
throttleMs: 0,
|
|
137
|
+
fetchImpl: (url, init) => current(url, init),
|
|
138
|
+
})
|
|
139
|
+
const good = await flaky.check()
|
|
140
|
+
assert.equal(good.latest, '5.2.0')
|
|
141
|
+
assert.equal(good.error, '')
|
|
142
|
+
|
|
143
|
+
current = stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: fail(500) })
|
|
144
|
+
clock = 60_000
|
|
145
|
+
const bad = await flaky.check(true)
|
|
146
|
+
assert.equal(bad.latest, '5.2.0', 'stale latest preserved')
|
|
147
|
+
assert.equal(bad.latestSpec, 'dsh-dbhub-live@5.2.0')
|
|
148
|
+
assert.equal(bad.source, 'npm')
|
|
149
|
+
assert.equal(bad.hasUpdate, true)
|
|
150
|
+
assert.equal(bad.checkedAt, 60_000, 'checkedAt advances even on failure')
|
|
151
|
+
assert.equal(bad.error, 'npm: HTTP 503; github: HTTP 500')
|
|
152
|
+
|
|
153
|
+
// A brand-new checker has nothing to preserve: a failure leaves it empty.
|
|
154
|
+
const fresh = createUpdateChecker({
|
|
155
|
+
currentVersion: '5.1.1-dev.1',
|
|
156
|
+
now: () => clock,
|
|
157
|
+
fetchImpl: stubFetch({ [NPM_LATEST_URL]: fail(503), [GITHUB_LATEST_URL]: fail(500) }),
|
|
158
|
+
})
|
|
159
|
+
const empty = await fresh.check(true)
|
|
160
|
+
assert.equal(empty.latest, '')
|
|
161
|
+
assert.equal(empty.latestSpec, '')
|
|
162
|
+
assert.equal(empty.hasUpdate, false)
|
|
163
|
+
assert.equal(empty.source, '')
|
|
164
|
+
assert.equal(empty.error, 'npm: HTTP 503; github: HTTP 500')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('hasUpdate is strictly-newer and prerelease-aware', async () => {
|
|
168
|
+
const cases = [
|
|
169
|
+
// current, latest, expected hasUpdate
|
|
170
|
+
['5.1.1-dev.1', '5.2.0', true, 'a newer release'],
|
|
171
|
+
['5.2.0-dev.1', '5.2.0', true, 'the release supersedes its own dev build'],
|
|
172
|
+
['5.2.0', '5.2.0-dev.2', false, 'a dev build is older than the release'],
|
|
173
|
+
['5.2.0', '5.2.0', false, 'equal versions'],
|
|
174
|
+
['5.2.0', '5.1.0', false, 'an older release'],
|
|
175
|
+
['5.1.0', '5.2.0-dev.1', true, 'a newer dev build'],
|
|
176
|
+
['', '5.2.0', false, 'unknown current version never claims an update'],
|
|
177
|
+
]
|
|
178
|
+
for (const [currentVersion, latest, expected, why] of cases) {
|
|
179
|
+
const fetchImpl = stubFetch({ [NPM_LATEST_URL]: ok({ version: latest }) })
|
|
180
|
+
const snap = await createUpdateChecker({ currentVersion, fetchImpl, now: () => 1 }).check(true)
|
|
181
|
+
assert.equal(snap.hasUpdate, expected, currentVersion + ' -> ' + latest + ' (' + why + ')')
|
|
182
|
+
assert.equal(snap.latest, latest)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// ── throttle / force / dedupe ─────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
test('a second check inside the throttle window makes no request', async () => {
|
|
189
|
+
let clock = 10_000
|
|
190
|
+
const fetchImpl = stubFetch(NPM_OK)
|
|
191
|
+
const update = createUpdateChecker({ fetchImpl, now: () => clock })
|
|
192
|
+
const first = await update.check()
|
|
193
|
+
assert.equal(fetchImpl.calls.length, 1)
|
|
194
|
+
|
|
195
|
+
clock = 10_000 + DEFAULT_THROTTLE_MS - 1
|
|
196
|
+
const reused = await update.check()
|
|
197
|
+
assert.deepEqual(reused, first, 'the snapshot is reused verbatim')
|
|
198
|
+
assert.equal(fetchImpl.calls.length, 1, 'no new request inside the throttle window')
|
|
199
|
+
|
|
200
|
+
// `force` always refetches, even immediately.
|
|
201
|
+
const forced = await update.check(true)
|
|
202
|
+
assert.equal(fetchImpl.calls.length, 2)
|
|
203
|
+
assert.equal(forced.checkedAt, clock)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('concurrent checks share one in-flight request', async () => {
|
|
207
|
+
let release
|
|
208
|
+
const gate = new Promise((resolve) => { release = resolve })
|
|
209
|
+
const fetchImpl = stubFetch({
|
|
210
|
+
[NPM_LATEST_URL]: async () => {
|
|
211
|
+
await gate
|
|
212
|
+
return { ok: true, status: 200, json: async () => ({ version: '5.2.0' }) }
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
const update = createUpdateChecker({ fetchImpl, now: () => 7 })
|
|
216
|
+
const first = update.check(true)
|
|
217
|
+
const second = update.check(true)
|
|
218
|
+
assert.equal(first, second, 'the same promise is handed to every concurrent caller')
|
|
219
|
+
release()
|
|
220
|
+
const [a, b] = await Promise.all([first, second])
|
|
221
|
+
assert.deepEqual(a, b)
|
|
222
|
+
assert.equal(fetchImpl.calls.length, 1, 'one request burst, not one per caller')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('a failing check never rejects and error text is sanitized', async () => {
|
|
226
|
+
const fetchImpl = stubFetch({
|
|
227
|
+
[NPM_LATEST_URL]: new Error('boom https://registry.npmjs.org/dsh-dbhub-live/latest?token=abc123'),
|
|
228
|
+
[GITHUB_LATEST_URL]: new Error('fetch failed'),
|
|
229
|
+
})
|
|
230
|
+
const snap = await createUpdateChecker({ fetchImpl, now: () => 5 }).check(true)
|
|
231
|
+
assert.equal(snap.latest, '')
|
|
232
|
+
assert.equal(snap.source, '')
|
|
233
|
+
assert.match(snap.error, /^npm: /)
|
|
234
|
+
assert.match(snap.error, /github: /)
|
|
235
|
+
assert.ok(!snap.error.includes('http'), 'no URL survives into the view: ' + snap.error)
|
|
236
|
+
assert.ok(!snap.error.includes('token='), 'no query token survives into the view: ' + snap.error)
|
|
237
|
+
assert.ok(snap.error.length <= 200, 'the message stays short')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('a request that outlives the budget is aborted and reported as timeout', async () => {
|
|
241
|
+
const fetchImpl = (url, init) => new Promise((resolve, reject) => {
|
|
242
|
+
const signal = init && init.signal
|
|
243
|
+
if (!signal) {
|
|
244
|
+
reject(new Error('no abort signal was passed'))
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const abort = () => {
|
|
248
|
+
const error = new Error('The operation was aborted')
|
|
249
|
+
error.name = 'AbortError'
|
|
250
|
+
reject(error)
|
|
251
|
+
}
|
|
252
|
+
if (signal.aborted) abort()
|
|
253
|
+
else signal.addEventListener('abort', abort)
|
|
254
|
+
})
|
|
255
|
+
const snap = await createUpdateChecker({ fetchImpl, now: () => 1, timeoutMs: 20 }).check(true)
|
|
256
|
+
assert.equal(snap.latest, '')
|
|
257
|
+
assert.equal(snap.source, '')
|
|
258
|
+
assert.equal(snap.error, 'npm: timeout; github: timeout')
|
|
259
|
+
assert.equal(snap.checkedAt, 1)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('snapshot() returns a detached copy', async () => {
|
|
263
|
+
const update = checker({ fetchImpl: stubFetch(NPM_OK) })
|
|
264
|
+
await update.check(true)
|
|
265
|
+
const snap = update.snapshot()
|
|
266
|
+
snap.latest = 'tampered'
|
|
267
|
+
snap.hasUpdate = false
|
|
268
|
+
assert.equal(update.snapshot().latest, '5.2.0')
|
|
269
|
+
assert.equal(update.snapshot().hasUpdate, true)
|
|
270
|
+
})
|