dsh-plugin-rack 0.1.2 → 0.1.3
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/README.md +33 -9
- package/client.js +191 -84
- package/index.js +61 -51
- package/lib/spec.js +285 -0
- package/package.json +14 -6
- package/test/behaviour.mjs +0 -549
- package/test/download.mjs +0 -221
- package/test/job-background.mjs +0 -205
- package/test/pnpm-command.mjs +0 -49
- package/test/self-check.mjs +0 -124
package/test/behaviour.mjs
DELETED
|
@@ -1,549 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Behavioural checks for the pieces that are easy to break silently.
|
|
3
|
-
//
|
|
4
|
-
// Run it before publishing: node test/behaviour.mjs
|
|
5
|
-
//
|
|
6
|
-
// These are not integration tests — they exercise the pure decision functions
|
|
7
|
-
// that the bug-prone paths depend on (package naming, cancellation truth, idle
|
|
8
|
-
// gate reporting). A regression in any of them turns into a wrong UI state
|
|
9
|
-
// rather than a crash, so nothing else would catch it.
|
|
10
|
-
|
|
11
|
-
import { readFileSync } from 'node:fs'
|
|
12
|
-
import { fileURLToPath } from 'node:url'
|
|
13
|
-
import { dirname, join } from 'node:path'
|
|
14
|
-
|
|
15
|
-
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
-
const root = join(here, '..')
|
|
17
|
-
const source = readFileSync(join(root, 'index.js'), 'utf8')
|
|
18
|
-
|
|
19
|
-
const failures = []
|
|
20
|
-
const check = (label, ok, detail) => {
|
|
21
|
-
if (ok) {
|
|
22
|
-
console.log(' ok ' + label)
|
|
23
|
-
} else {
|
|
24
|
-
console.log(' FAIL ' + label + (detail === undefined ? '' : ' — ' + detail))
|
|
25
|
-
failures.push(label)
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Pull a top-level `function name(...) { ... }` out of the source and eval it. */
|
|
30
|
-
function extract(name) {
|
|
31
|
-
const start = source.indexOf('function ' + name + '(')
|
|
32
|
-
if (start === -1) throw new Error('function ' + name + ' not found in index.js')
|
|
33
|
-
let depth = 0
|
|
34
|
-
let index = source.indexOf('{', start)
|
|
35
|
-
const bodyStart = index
|
|
36
|
-
for (; index < source.length; index += 1) {
|
|
37
|
-
const ch = source[index]
|
|
38
|
-
if (ch === '{') depth += 1
|
|
39
|
-
else if (ch === '}') {
|
|
40
|
-
depth -= 1
|
|
41
|
-
if (depth === 0) break
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return source.slice(start, index + 1).replace(/^function\s+\w+/, 'function')
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function load(name) {
|
|
48
|
-
// eslint-disable-next-line no-new-func
|
|
49
|
-
return new Function('return (' + extract(name) + ')')()
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
console.log('displayName')
|
|
53
|
-
{
|
|
54
|
-
const displayName = load('displayName')
|
|
55
|
-
check('scoped package keeps both segments', displayName('@scope/pkg') === '@scope/pkg')
|
|
56
|
-
check('bare name passes through', displayName('dsh-whale-widget') === 'dsh-whale-widget')
|
|
57
|
-
check(
|
|
58
|
-
'file:// resolves the package after node_modules',
|
|
59
|
-
displayName('file:///C:/x/node_modules/cc-safety-net/dist/deepseek-harness/index.js') === 'cc-safety-net',
|
|
60
|
-
)
|
|
61
|
-
check(
|
|
62
|
-
'file:// resolves a scoped package',
|
|
63
|
-
displayName('file:///C:/x/node_modules/@scope/pkg/dist/index.js') === '@scope/pkg',
|
|
64
|
-
)
|
|
65
|
-
check(
|
|
66
|
-
'nested node_modules resolves to the nearest package',
|
|
67
|
-
displayName('file:///C:/x/node_modules/a/node_modules/b/index.js') === 'b',
|
|
68
|
-
)
|
|
69
|
-
check(
|
|
70
|
-
'a plain path falls back to the file name',
|
|
71
|
-
displayName('file:///C:/x/packages/my-plugin/index.js') === 'index.js',
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
console.log('cancellation truth')
|
|
76
|
-
{
|
|
77
|
-
// The rule: a cancelled job stays cancelled even after the global abort
|
|
78
|
-
// controller has been cleared, because endJob() nulls it and the running
|
|
79
|
-
// applyUpdates would otherwise sail past every check.
|
|
80
|
-
const jobCancelled = load('jobCancelled')
|
|
81
|
-
check('a cancelled job reports cancelled', jobCancelled({ cancelled: true }) === true)
|
|
82
|
-
check('a live job reports not cancelled', jobCancelled({ cancelled: false }) === false)
|
|
83
|
-
check('a missing job reports not cancelled', jobCancelled(null) === false)
|
|
84
|
-
check('an undefined job reports not cancelled', jobCancelled(undefined) === false)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
console.log('idle gate reporting')
|
|
88
|
-
{
|
|
89
|
-
// holdAgents returns a release function carrying `.gated`. The whole point is
|
|
90
|
-
// that "no agents service" must not be mistaken for "waited and everyone was
|
|
91
|
-
// idle", so the flag has to be set on every return path.
|
|
92
|
-
const body = extract('holdAgents')
|
|
93
|
-
check('ungated path sets gated = false', body.includes('releaseUngated.gated = false'))
|
|
94
|
-
check('gated path sets gated = true', body.includes('releaseAll.gated = true'))
|
|
95
|
-
check('ungated path returns early', /if \(!available\)[^]*?return releaseUngated/.test(body))
|
|
96
|
-
// Every entry point that takes the maintenance gate must report the flag, not
|
|
97
|
-
// just the batch updater -- otherwise the same machine says "I waited" for a
|
|
98
|
-
// bulk update and says nothing for a single install.
|
|
99
|
-
check('applyUpdates reports unguarded', source.includes('unguarded: true'))
|
|
100
|
-
const guardedPnpm = extract('guardedPnpm')
|
|
101
|
-
check('guardedPnpm computes the flag', guardedPnpm.includes('release.gated === false'))
|
|
102
|
-
check('guardedPnpm marks its result', guardedPnpm.includes('markUnguarded('))
|
|
103
|
-
const fileUpgrade = extract('applyFileUpgrade')
|
|
104
|
-
check('applyFileUpgrade computes the flag', fileUpgrade.includes('release.gated === false'))
|
|
105
|
-
check('applyFileUpgrade marks its result', fileUpgrade.includes('markUnguarded('))
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
console.log('connection reset is not reported as failure')
|
|
109
|
-
{
|
|
110
|
-
const client = readFileSync(join(root, 'client.js'), 'utf8')
|
|
111
|
-
|
|
112
|
-
// 真跑这两个纯函数,不再 grep 源码。上一轮这 5 项全是 includes(),
|
|
113
|
-
// 所以「load() 返回 undefined → sameEnabledMap 里读 undefined.rows 炸掉」
|
|
114
|
-
// 这个 P0 在 33 项全绿的情况下活了下来。
|
|
115
|
-
function loadClientFn(name) {
|
|
116
|
-
const start = client.indexOf('function ' + name + '(')
|
|
117
|
-
if (start === -1) throw new Error('function ' + name + ' not found in client.js')
|
|
118
|
-
let depth = 0
|
|
119
|
-
let index = client.indexOf('{', start)
|
|
120
|
-
for (; index < client.length; index += 1) {
|
|
121
|
-
const ch = client[index]
|
|
122
|
-
if (ch === '{') depth += 1
|
|
123
|
-
else if (ch === '}') {
|
|
124
|
-
depth -= 1
|
|
125
|
-
if (depth === 0) break
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return client.slice(start, index + 1).replace(/^function\s+\w+/, 'function')
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const sameEnabledMap = new Function(
|
|
132
|
-
'return (' + loadClientFn('sameEnabledMap') + ')',
|
|
133
|
-
)()
|
|
134
|
-
const failureOutcome = new Function(
|
|
135
|
-
'sameEnabledMap',
|
|
136
|
-
'return (' + loadClientFn('failureOutcome') + ')',
|
|
137
|
-
)(sameEnabledMap)
|
|
138
|
-
|
|
139
|
-
const snap = (pairs) => ({ rows: pairs.map(([entryId, enabled]) => ({ entryId, enabled })) })
|
|
140
|
-
|
|
141
|
-
check('identical snapshots agree', sameEnabledMap(snap([['a', true]]), snap([['a', true]])) === true)
|
|
142
|
-
check('a changed row is detected', sameEnabledMap(snap([['a', true]]), snap([['a', false]])) === false)
|
|
143
|
-
check('rows added by the reload are ignored', sameEnabledMap(snap([['a', true]]), snap([['a', true], ['b', false]])) === true)
|
|
144
|
-
check('a vanished row is ignored', sameEnabledMap(snap([['a', true], ['b', false]]), snap([['a', true]])) === true)
|
|
145
|
-
|
|
146
|
-
// 这正是 P0:load() 曾返回 undefined,而判空只写了 !== null。
|
|
147
|
-
check('undefined healed snapshot does not throw', (() => {
|
|
148
|
-
try {
|
|
149
|
-
return sameEnabledMap(snap([['a', true]]), undefined) === false
|
|
150
|
-
} catch {
|
|
151
|
-
return false
|
|
152
|
-
}
|
|
153
|
-
})())
|
|
154
|
-
check('null healed snapshot does not throw', (() => {
|
|
155
|
-
try {
|
|
156
|
-
return sameEnabledMap(snap([['a', true]]), null) === false
|
|
157
|
-
} catch {
|
|
158
|
-
return false
|
|
159
|
-
}
|
|
160
|
-
})())
|
|
161
|
-
|
|
162
|
-
// failureOutcome 是 catch 里那段分支的纯形式。
|
|
163
|
-
check('settled change stays silent', failureOutcome('unreachable', snap([['a', true]]), snap([['a', true]])).kind === null)
|
|
164
|
-
check('lost connection downgrades to a warning', failureOutcome('unreachable', snap([['a', true]]), snap([['a', false]])).kind === 'warn')
|
|
165
|
-
check('the warning names connLost', failureOutcome('timeout', null, null).reason === 'connLost')
|
|
166
|
-
check('a real error stays an error', failureOutcome('boom', null, null).kind === 'err')
|
|
167
|
-
check('no expectation means no silence', failureOutcome('unreachable', null, snap([['a', true]])).kind === 'warn')
|
|
168
|
-
// 旧 bug 的完整形状:healed 是 undefined 时代码不该崩,也不该误判成"已生效"。
|
|
169
|
-
check('an undefined healed snapshot never claims success', (() => {
|
|
170
|
-
try {
|
|
171
|
-
return failureOutcome('unreachable', snap([['a', true]]), undefined).kind === 'warn'
|
|
172
|
-
} catch {
|
|
173
|
-
return false
|
|
174
|
-
}
|
|
175
|
-
})())
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
console.log('one update button covers both channels')
|
|
179
|
-
{
|
|
180
|
-
const client = readFileSync(join(root, 'client.js'), 'utf8')
|
|
181
|
-
|
|
182
|
-
// 真跑这两个纯函数。上一版这 10 项全是 client.includes(...),其中
|
|
183
|
-
// 「a partial failure is surfaced」只检查有没有 updatePartial 这个词——
|
|
184
|
-
// 而那个分支当时永远走不到,断言照样绿。
|
|
185
|
-
function loadClientFn(name) {
|
|
186
|
-
const start = client.indexOf(' function ' + name + '(')
|
|
187
|
-
if (start === -1) throw new Error('function ' + name + ' not found in client.js')
|
|
188
|
-
let depth = 0
|
|
189
|
-
let index = client.indexOf('{', start)
|
|
190
|
-
for (; index < client.length; index += 1) {
|
|
191
|
-
const ch = client[index]
|
|
192
|
-
if (ch === '{') depth += 1
|
|
193
|
-
else if (ch === '}') {
|
|
194
|
-
depth -= 1
|
|
195
|
-
if (depth === 0) break
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return client.slice(start, index + 1).replace(/^function\s+\w+/, 'function')
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const partitionTargets = new Function('return (' + loadClientFn('partitionTargets') + ')')()
|
|
202
|
-
// mergeUpdateReport 依赖 collectFailures(失败详情归一化),注入进去。
|
|
203
|
-
const collectFailures = new Function('return (' + loadClientFn('collectFailures') + ')')()
|
|
204
|
-
const mergeUpdateReport = new Function(
|
|
205
|
-
'collectFailures',
|
|
206
|
-
'return (' + loadClientFn('mergeUpdateReport') + ')',
|
|
207
|
-
)(collectFailures)
|
|
208
|
-
const messageFor = (n, total) => n + ' of ' + total + ' failed'
|
|
209
|
-
|
|
210
|
-
// ---- 分区 ----
|
|
211
|
-
const extrasRows = [
|
|
212
|
-
{ name: 'gh-pkg', kind: 'github', hasUpdate: true },
|
|
213
|
-
{ name: 'file-pkg', kind: 'file', hasUpdate: true },
|
|
214
|
-
{ name: 'tar-pkg', kind: 'tarball', hasUpdate: false },
|
|
215
|
-
{ name: 'link-pkg', kind: 'link' },
|
|
216
|
-
]
|
|
217
|
-
const split = partitionTargets(['npm-a', 'npm-b', 'gh-pkg', 'file-pkg', 'tar-pkg', 'link-pkg'], extrasRows)
|
|
218
|
-
check('npm names go to the batch endpoint', JSON.stringify(split.npm) === JSON.stringify(['npm-a', 'npm-b']))
|
|
219
|
-
check(
|
|
220
|
-
'file packages reach file-upgrade',
|
|
221
|
-
split.extras.some((one) => one.name === 'file-pkg' && one.action === 'file-upgrade'),
|
|
222
|
-
)
|
|
223
|
-
check(
|
|
224
|
-
'github packages reach repull',
|
|
225
|
-
split.extras.some((one) => one.name === 'gh-pkg' && one.action === 'repull'),
|
|
226
|
-
)
|
|
227
|
-
check(
|
|
228
|
-
'tarball packages reach pin',
|
|
229
|
-
split.extras.some((one) => one.name === 'tar-pkg' && one.action === 'pin'),
|
|
230
|
-
)
|
|
231
|
-
check('link packages are skipped entirely', !split.extras.some((one) => one.name === 'link-pkg'))
|
|
232
|
-
check('an unknown name defaults to the npm channel', partitionTargets(['mystery'], []).npm.length === 1)
|
|
233
|
-
// kind: 'other' 的行读不出来源,不该被猜成 pin。
|
|
234
|
-
const other = partitionTargets(['other-pkg'], [{ name: 'other-pkg', kind: 'other' }])
|
|
235
|
-
check('an unreadable kind is not guessed into a pin', other.extras.length === 0 && other.npm.length === 0)
|
|
236
|
-
|
|
237
|
-
// ---- 合并:一个渠道失败不能吞掉其它渠道 ----
|
|
238
|
-
const mixed = mergeUpdateReport(
|
|
239
|
-
[
|
|
240
|
-
{ ok: true, updated: [{ name: 'npm-a', from: '1.0.0', to: '1.0.1' }] },
|
|
241
|
-
{ ok: false, name: 'file-pkg', message: 'checksum mismatch' },
|
|
242
|
-
],
|
|
243
|
-
messageFor,
|
|
244
|
-
)
|
|
245
|
-
check('a mixed run is not reported as success', mixed.ok === false)
|
|
246
|
-
check('the failure is listed with its reason', mixed.failed.length === 1 && mixed.failed[0].name === 'file-pkg')
|
|
247
|
-
check('the partial message is actually produced', mixed.message === '1 of 2 failed')
|
|
248
|
-
check('the successful channel is still reported', mixed.updated.length === 1)
|
|
249
|
-
check('a reason string survives into failed[].output', mixed.failed[0].output === 'checksum mismatch')
|
|
250
|
-
|
|
251
|
-
// 单个渠道时原样透传,面板要靠 log / hotSwap / restart 显示详情。
|
|
252
|
-
const single = mergeUpdateReport([{ ok: true, log: '/tmp/x.log', hotSwap: { ok: true }, restart: false }], messageFor)
|
|
253
|
-
check('a single channel passes through unchanged', single.log === '/tmp/x.log' && single.restart === false)
|
|
254
|
-
|
|
255
|
-
// 全成功时不能编出失败信息。
|
|
256
|
-
const allGood = mergeUpdateReport(
|
|
257
|
-
[
|
|
258
|
-
{ ok: true, updated: [{ name: 'a', from: '1', to: '2' }] },
|
|
259
|
-
{ ok: true, name: 'b', to: '3.0.0' },
|
|
260
|
-
],
|
|
261
|
-
messageFor,
|
|
262
|
-
)
|
|
263
|
-
check('an all-success run is ok', allGood.ok === true)
|
|
264
|
-
check('an all-success run has no message', allGood.message === undefined)
|
|
265
|
-
check('both channels contribute to updated', allGood.updated.length === 2)
|
|
266
|
-
|
|
267
|
-
// restart 必须从任何一个渠道冒上来,否则混合更新的用户不知道要重启。
|
|
268
|
-
const needsRestart = mergeUpdateReport(
|
|
269
|
-
[
|
|
270
|
-
{ ok: true, restart: false },
|
|
271
|
-
{ ok: true, name: 'x', to: '2.0.0', restart: true },
|
|
272
|
-
],
|
|
273
|
-
messageFor,
|
|
274
|
-
)
|
|
275
|
-
check('restart propagates from any channel', needsRestart.restart === true)
|
|
276
|
-
|
|
277
|
-
// 空输入不能炸:没有渠道要跑时调用方不该收到任何报告对象。
|
|
278
|
-
const empty = mergeUpdateReport([], messageFor)
|
|
279
|
-
check('an empty result returns nothing rather than throwing', empty === undefined)
|
|
280
|
-
|
|
281
|
-
// 单渠道失败必须也能渲染出「哪个渠道挂了」。之前单通道原样返回
|
|
282
|
-
// {ok:false,name,message},没有 failed 字段,面板只剩一句裸错误。
|
|
283
|
-
const singleFail = mergeUpdateReport([{ ok: false, name: 'pkg-a', message: 'unreachable' }], messageFor)
|
|
284
|
-
check('a single-channel failure still gets a failed entry', Array.isArray(singleFail.failed) && singleFail.failed.length === 1)
|
|
285
|
-
check('the single-channel failure keeps its name', singleFail.failed[0].name === 'pkg-a')
|
|
286
|
-
check('the single-channel failure keeps its reason', singleFail.failed[0].output === 'unreachable')
|
|
287
|
-
|
|
288
|
-
// 单渠道成功仍然原样返回,面板要靠 log / hotSwap 显示详情。
|
|
289
|
-
const singleOk = mergeUpdateReport([{ ok: true, log: '/tmp/x', hotSwap: { ok: true } }], messageFor)
|
|
290
|
-
check('a single success passes through untouched', singleOk.log === '/tmp/x' && singleOk.hotSwap.ok === true)
|
|
291
|
-
|
|
292
|
-
// 混合更新里一个渠道换了、另一个没换:不能报"已热替换"。
|
|
293
|
-
// 各渠道管不同的包,npm 换进去了不代表 extras 那个也生效。
|
|
294
|
-
// 界面只看 hotSwap.ok,所以它必须是合取,而不是挑成功的那条。
|
|
295
|
-
const mixedSwap = mergeUpdateReport(
|
|
296
|
-
[
|
|
297
|
-
{ ok: true, hotSwap: { ok: true }, restart: false },
|
|
298
|
-
{ ok: true, name: 'x', to: '2.0.0', hotSwap: { ok: false, reason: 'busy' }, restart: true },
|
|
299
|
-
],
|
|
300
|
-
messageFor,
|
|
301
|
-
)
|
|
302
|
-
check('a partial swap does not claim success', mixedSwap.hotSwap.ok !== true)
|
|
303
|
-
check('a partial swap is flagged', mixedSwap.partialSwap === true)
|
|
304
|
-
check('a partial swap asks for a restart', mixedSwap.restart === true)
|
|
305
|
-
check(
|
|
306
|
-
'the reported swap reason is the failed one',
|
|
307
|
-
mixedSwap.hotSwap.reason === 'busy',
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
const allSwapped = mergeUpdateReport(
|
|
311
|
-
[
|
|
312
|
-
{ ok: true, hotSwap: { ok: true }, restart: false },
|
|
313
|
-
{ ok: true, name: 'x', to: '2.0.0', hotSwap: { ok: true }, restart: false },
|
|
314
|
-
],
|
|
315
|
-
messageFor,
|
|
316
|
-
)
|
|
317
|
-
check('every channel swapping means no restart', allSwapped.restart === false)
|
|
318
|
-
check('every channel swapping is not partial', allSwapped.partialSwap === false)
|
|
319
|
-
|
|
320
|
-
check(
|
|
321
|
-
'no channel swapping keeps the restart advice',
|
|
322
|
-
mergeUpdateReport(
|
|
323
|
-
[
|
|
324
|
-
{ ok: true, hotSwap: { ok: false, reason: 'busy' }, restart: true },
|
|
325
|
-
{ ok: true, name: 'x', to: '2.0.0', restart: true },
|
|
326
|
-
],
|
|
327
|
-
messageFor,
|
|
328
|
-
).restart === true,
|
|
329
|
-
)
|
|
330
|
-
|
|
331
|
-
// 单渠道失败时,服务端带回来的 failed 对象(含 pnpm 输出)不能丢。
|
|
332
|
-
const withDetail = mergeUpdateReport(
|
|
333
|
-
[{ ok: false, failed: { name: '3 packages', output: 'PNPM_STDOUT_DETAIL' }, message: 'rolled back' }],
|
|
334
|
-
messageFor,
|
|
335
|
-
)
|
|
336
|
-
check('a single-channel failed object is not dropped', Array.isArray(withDetail.failed))
|
|
337
|
-
check(
|
|
338
|
-
'the server detail survives the single-channel merge',
|
|
339
|
-
withDetail.failed.some((one) => String(one.output || '').includes('PNPM_STDOUT_DETAIL')),
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
// 多通道也必须带上同一份详情。上一版的 bug 就在这里:多通道分支只看顶层
|
|
343
|
-
// message,把 one.failed.output 吞了 —— 而「混了 github / file / tarball 的
|
|
344
|
-
// 全选」正是统一更新按钮的主场景。
|
|
345
|
-
const multiDetail = mergeUpdateReport(
|
|
346
|
-
[
|
|
347
|
-
{ ok: false, failed: { name: '3 packages', output: 'PNPM_STDOUT_DETAIL' }, message: 'rolled back' },
|
|
348
|
-
{ ok: true, name: 'file-pkg', to: '2.0.0' },
|
|
349
|
-
],
|
|
350
|
-
messageFor,
|
|
351
|
-
)
|
|
352
|
-
check(
|
|
353
|
-
'the server detail survives the multi-channel merge',
|
|
354
|
-
multiDetail.failed.some((one) => String(one.output || '').includes('PNPM_STDOUT_DETAIL')),
|
|
355
|
-
)
|
|
356
|
-
check('both shapes report the same detail rows', multiDetail.failed.length === withDetail.failed.length)
|
|
357
|
-
|
|
358
|
-
// collectFailures 本身:两种形态、去重、空值不产假行。
|
|
359
|
-
check('an empty failure produces no rows', collectFailures({}).length === 0)
|
|
360
|
-
check('a null failure produces no rows', collectFailures(null).length === 0)
|
|
361
|
-
check(
|
|
362
|
-
'a failed array is flattened',
|
|
363
|
-
collectFailures({ failed: [{ name: 'a', output: 'x' }, { name: 'b', output: 'y' }] }).length === 2,
|
|
364
|
-
)
|
|
365
|
-
check(
|
|
366
|
-
'a failed object is accepted too',
|
|
367
|
-
collectFailures({ failed: { name: 'a', output: 'x' } }).length === 1,
|
|
368
|
-
)
|
|
369
|
-
check(
|
|
370
|
-
'the same message is not repeated twice',
|
|
371
|
-
collectFailures({ failed: { name: 'a', output: 'same' }, message: 'same' }).length === 1,
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
// ---- 结构约束 ----
|
|
375
|
-
check(
|
|
376
|
-
'every extras channel is wrapped in its own try/catch',
|
|
377
|
-
/for \(const item of plan\.extras\)[\s\S]{0,400}?catch \(error\)/.test(client),
|
|
378
|
-
)
|
|
379
|
-
check(
|
|
380
|
-
'the npm channel is wrapped too',
|
|
381
|
-
/try \{\s*\n\s*\/\/ A registry download[\s\S]{0,300}?catch \(error\)/.test(client),
|
|
382
|
-
)
|
|
383
|
-
check('a thrown channel is collected, not rethrown', client.includes('results.push(failureEntry(error'))
|
|
384
|
-
check('the panel keeps the report when a channel failed', client.includes('if (merged.ok === true) {'))
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
console.log('the panel renders the report it was given')
|
|
388
|
-
{
|
|
389
|
-
const client = readFileSync(join(root, 'client.js'), 'utf8')
|
|
390
|
-
|
|
391
|
-
function loadClientFn(name) {
|
|
392
|
-
const start = client.indexOf(' function ' + name + '(')
|
|
393
|
-
if (start === -1) throw new Error('function ' + name + ' not found in client.js')
|
|
394
|
-
let depth = 0
|
|
395
|
-
let index = client.indexOf('{', start)
|
|
396
|
-
for (; index < client.length; index += 1) {
|
|
397
|
-
const ch = client[index]
|
|
398
|
-
if (ch === '{') depth += 1
|
|
399
|
-
else if (ch === '}') {
|
|
400
|
-
depth -= 1
|
|
401
|
-
if (depth === 0) break
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
return client.slice(start, index + 1).replace(/^function\s+\w+/, 'function')
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const renderUpdateReport = new Function('return (' + loadClientFn('renderUpdateReport') + ')')()
|
|
408
|
-
const opts = { unknown: 'unknown' }
|
|
409
|
-
|
|
410
|
-
// 上一版的 bug:failed 在单通道时是对象、合并后是数组,渲染端一律按对象读,
|
|
411
|
-
// 多通道失败时详情行变成 "undefined: ",把「哪个渠道挂了」整个丢掉。
|
|
412
|
-
const merged = {
|
|
413
|
-
ok: false,
|
|
414
|
-
updated: [{ name: 'npm-a', from: '1.0.0', to: '1.0.1' }],
|
|
415
|
-
failed: [{ name: 'file-pkg', output: 'checksum mismatch' }],
|
|
416
|
-
message: '1 of 2 failed',
|
|
417
|
-
}
|
|
418
|
-
const out = renderUpdateReport(merged, opts)
|
|
419
|
-
check('an array of failures renders every entry', out.failed.length === 1)
|
|
420
|
-
check('the failure line names the channel', out.failed[0] === 'file-pkg: checksum mismatch')
|
|
421
|
-
check('no literal "undefined" leaks into the failure line', !out.failed[0].includes('undefined'))
|
|
422
|
-
check('the success line keeps both versions', out.updated[0] === 'npm-a 1.0.0 → 1.0.1')
|
|
423
|
-
|
|
424
|
-
// /updates 原样返回时 failed 是单个对象,两种形状都要吃。
|
|
425
|
-
const single = { ok: false, failed: { name: 'x', output: 'boom' } }
|
|
426
|
-
check('a single failed object renders too', renderUpdateReport(single, opts).failed[0] === 'x: boom')
|
|
427
|
-
|
|
428
|
-
// extras 的结果只有 name + to,没有 from。写死 item.from 会渲染成 "b undefined → 3.0.0"。
|
|
429
|
-
const noFrom = { ok: true, updated: [{ name: 'b', to: '3.0.0' }] }
|
|
430
|
-
const noFromLine = renderUpdateReport(noFrom, opts).updated[0]
|
|
431
|
-
check('a missing from does not print undefined', !noFromLine.includes('undefined'))
|
|
432
|
-
check('a missing from omits the arrow', noFromLine === 'b → 3.0.0')
|
|
433
|
-
|
|
434
|
-
// 名都没有的条目也不能变成 "undefined: "。
|
|
435
|
-
check(
|
|
436
|
-
'a nameless failure still renders readably',
|
|
437
|
-
renderUpdateReport({ ok: false, failed: [{ output: 'x' }] }, opts).failed[0] === 'unknown: x',
|
|
438
|
-
)
|
|
439
|
-
check('an empty report renders nothing', renderUpdateReport(null, opts).failed.length === 0)
|
|
440
|
-
check('garbage entries are skipped', renderUpdateReport({ updated: [null, 5] }, opts).updated.length === 0)
|
|
441
|
-
|
|
442
|
-
// 渲染端必须真的用这个函数,而不是又退回手写模板。
|
|
443
|
-
check('the panel renders through renderUpdateReport', /renderUpdateReport\(updateLog/.test(client))
|
|
444
|
-
check('the old single-object assumption is gone', !client.includes('String(updateLog.failed.name)'))
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
console.log('github pin resolution')
|
|
448
|
-
{
|
|
449
|
-
// installedCommit 读不出 commit 时,github 行只能保守地判「无更新」——
|
|
450
|
-
// 于是那一行永远不进更新列表,repull 一次都跑不到。pnpm 写 lockfile 的
|
|
451
|
-
// 形状不止一种,解析器必须都认。这里用临时目录跑真实文件。
|
|
452
|
-
//
|
|
453
|
-
// 注意:不能复用上面那个花括号配平的 extract()——这个函数体内有正则字面量,
|
|
454
|
-
// 里面的 {7,40} 会把配平算错,切出一个 66KB 的怪物。
|
|
455
|
-
const { mkdtempSync, writeFileSync, rmSync, readFileSync: readFile } = await import('node:fs')
|
|
456
|
-
const { tmpdir } = await import('node:os')
|
|
457
|
-
const dir = mkdtempSync(join(tmpdir(), 'rack-lock-'))
|
|
458
|
-
const indexSource = readFile(join(root, 'index.js'), 'utf8').replace(/\r\n/g, '\n')
|
|
459
|
-
const begin = indexSource.indexOf('function installedCommit(')
|
|
460
|
-
const finish = indexSource.indexOf('\n}\n', indexSource.indexOf('const patterns = [', begin))
|
|
461
|
-
const installedCommit = new Function(
|
|
462
|
-
'readFileSync',
|
|
463
|
-
'join',
|
|
464
|
-
indexSource.slice(begin, finish + 2) + '\nreturn installedCommit',
|
|
465
|
-
)((p, enc) => readFile(p, enc), join)
|
|
466
|
-
const SHA = '0123456789abcdef0123456789abcdef01234567'
|
|
467
|
-
|
|
468
|
-
const cases = [
|
|
469
|
-
[
|
|
470
|
-
'codeload tarball form',
|
|
471
|
-
'dsh-our-free-model',
|
|
472
|
-
` dsh-our-free-model@https://codeload.github.com/Ebony-Vinyl/dsh-our-free-model/tar.gz/${SHA}:\n resolution: {tarball: https://codeload.github.com/x/y/tar.gz/${SHA}}\n`,
|
|
473
|
-
],
|
|
474
|
-
[
|
|
475
|
-
'github: spec with #',
|
|
476
|
-
'pkg-a',
|
|
477
|
-
` 'pkg-a@github:owner/repo#${SHA}':\n resolution: {tarball: https://codeload.github.com/owner/repo/tar.gz/${SHA}}\n`,
|
|
478
|
-
],
|
|
479
|
-
[
|
|
480
|
-
'resolved block with commit',
|
|
481
|
-
'pkg-a',
|
|
482
|
-
` pkg-a@github:owner/repo:\n resolution: {commit: ${SHA}, repo: owner/repo, type: git}\n`,
|
|
483
|
-
],
|
|
484
|
-
]
|
|
485
|
-
|
|
486
|
-
for (const [label, pkg, text] of cases) {
|
|
487
|
-
writeFileSync(join(dir, 'pnpm-lock.yaml'), text, 'utf8')
|
|
488
|
-
check('reads the pin from ' + label, installedCommit(dir, pkg) === SHA, String(installedCommit(dir, pkg)))
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// 不能把别的包的 commit 当成这个包的。
|
|
492
|
-
writeFileSync(
|
|
493
|
-
join(dir, 'pnpm-lock.yaml'),
|
|
494
|
-
` other-pkg@github:someone/else:\n resolution: {commit: ${SHA}, type: git}\n`,
|
|
495
|
-
'utf8',
|
|
496
|
-
)
|
|
497
|
-
check(
|
|
498
|
-
'does not borrow another package commit',
|
|
499
|
-
installedCommit(dir, 'pkg-a') === null,
|
|
500
|
-
String(installedCommit(dir, 'pkg-a')),
|
|
501
|
-
)
|
|
502
|
-
|
|
503
|
-
// 没有 lockfile 时返回 null,不抛。
|
|
504
|
-
rmSync(join(dir, 'pnpm-lock.yaml'))
|
|
505
|
-
check('a missing lockfile returns null', installedCommit(dir, 'pkg-a') === null)
|
|
506
|
-
rmSync(dir, { recursive: true, force: true })
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
console.log('write gate')
|
|
510
|
-
{
|
|
511
|
-
// A POST that carries no browser provenance must be refused unless the caller
|
|
512
|
-
// opts in explicitly. This is the only thing standing between "any local
|
|
513
|
-
// script" and "uninstall every plugin".
|
|
514
|
-
check(
|
|
515
|
-
'explicit opt-in header is honoured',
|
|
516
|
-
source.includes("String(req.headers['x-dsh-plugin-rack'] ?? '') === '1'"),
|
|
517
|
-
)
|
|
518
|
-
check('same-site fetch is honoured', source.includes("secFetchSite === 'same-origin'"))
|
|
519
|
-
check('a bare curl without provenance is refused', source.includes('write requests need an Origin header'))
|
|
520
|
-
const client = readFileSync(join(root, 'client.js'), 'utf8')
|
|
521
|
-
check('the panel sends the opt-in header itself', client.includes("'x-dsh-plugin-rack': '1'"))
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
console.log('profile migration')
|
|
525
|
-
{
|
|
526
|
-
const body = extract('adoptProfile')
|
|
527
|
-
for (const file of ['state.json', 'job.json', 'queue.json', 'config.json']) {
|
|
528
|
-
check('carries ' + file, source.includes("'" + file + "'"))
|
|
529
|
-
}
|
|
530
|
-
check('carries both update logs', source.includes("'last-update.log'") && source.includes("'update-log.jsonl'"))
|
|
531
|
-
check('never overwrites an existing target', body.includes('if (existsSync(target)) continue'))
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
console.log('rollback is not cancellable')
|
|
535
|
-
{
|
|
536
|
-
const body = extract('cancelJob')
|
|
537
|
-
check('cancelJob refuses during rollback', body.includes("job.stage === 'rollback'"))
|
|
538
|
-
check('jobStatus publishes cancelable', source.includes('cancelable = running && job.stage'))
|
|
539
|
-
const client = readFileSync(join(root, 'client.js'), 'utf8')
|
|
540
|
-
check('the client honours cancelable rather than guessing', client.includes('job.cancelable === false'))
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if (failures.length > 0) {
|
|
544
|
-
console.log('')
|
|
545
|
-
console.log('BEHAVIOUR_CHECK_FAILED: ' + failures.length + ' problem(s)')
|
|
546
|
-
process.exit(1)
|
|
547
|
-
}
|
|
548
|
-
console.log('')
|
|
549
|
-
console.log('BEHAVIOUR_CHECK_OK')
|