dsh-plugin-cicd 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +389 -2
- package/RELEASING.md +63 -0
- package/client.js +3786 -0
- package/cordis.patch.yml +63 -0
- package/icon.svg +8 -0
- package/index.js +4662 -0
- package/lib/bilibili.mjs +822 -0
- package/lib/config-store.mjs +159 -0
- package/package.json +62 -3
- package/scripts/configure.mjs +213 -0
- package/scripts/push-via-api.ps1 +111 -0
- package/scripts/verify-bundle.mjs +320 -0
- package/tests/bilibili-check.mjs +398 -0
- package/tests/bump-e2e.mjs +352 -0
- package/tests/client-render.mjs +1267 -0
- package/tests/host-checks.mjs +521 -0
- package/tests/mount-check.mjs +297 -0
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Render the panel's client half in a stub React and assert what it says.
|
|
4
|
+
*
|
|
5
|
+
* The Host half has `mount-check` to prove it activates; the client half had only
|
|
6
|
+
* `node --check`, which proves a file parses and nothing else. That gap matters for
|
|
7
|
+
* exactly the change this file was written for: whether a row offers 发布 or 【升版本
|
|
8
|
+
* 并发布】 depends on a render branch, and a branch nothing exercises is the kind of
|
|
9
|
+
* code that breaks only in front of the user.
|
|
10
|
+
*
|
|
11
|
+
* So this is a small but real renderer. It is not React and does not try to be: it
|
|
12
|
+
* walks the element tree, calls function components, keeps `useState` per component
|
|
13
|
+
* instance, memoises `useCallback`/`useMemo`, and runs `useEffect` with dependency
|
|
14
|
+
* comparison until nothing is dirty. That is enough to run the panel, let its
|
|
15
|
+
* effects fetch, and read the tree it produced.
|
|
16
|
+
*
|
|
17
|
+
* node tests/client-render.mjs
|
|
18
|
+
* node /tmp/asset/package/tests/client-render.mjs /tmp/asset/package
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
import vm from 'node:vm'
|
|
25
|
+
|
|
26
|
+
const here = fileURLToPath(new URL('.', import.meta.url))
|
|
27
|
+
const packageRoot = resolve(process.argv[2] ?? join(here, '..'))
|
|
28
|
+
const source = readFileSync(join(packageRoot, 'client.js'), 'utf8')
|
|
29
|
+
|
|
30
|
+
const results = []
|
|
31
|
+
let failed = 0
|
|
32
|
+
function check(label, condition, detail = '') {
|
|
33
|
+
const pass = condition === true
|
|
34
|
+
if (!pass) failed += 1
|
|
35
|
+
results.push({ label, pass })
|
|
36
|
+
console.log(`${pass ? 'ok ' : 'FAIL'} ${label}${detail === '' ? '' : ` — ${detail}`}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* ---- the smallest React that can render this panel ------------------------ */
|
|
40
|
+
|
|
41
|
+
const values = new Map()
|
|
42
|
+
const effectSlots = new Map()
|
|
43
|
+
const ownerPath = []
|
|
44
|
+
let cursor = 0
|
|
45
|
+
let dirty = false
|
|
46
|
+
let pendingEffects = []
|
|
47
|
+
|
|
48
|
+
/** A hook slot is keyed by the component instance, not by call order alone. */
|
|
49
|
+
function hookKey() {
|
|
50
|
+
return `${ownerPath.join('/')}#${String(cursor++)}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const React = {
|
|
54
|
+
Fragment: Symbol('Fragment'),
|
|
55
|
+
/* `children` belongs in props as well as on the element: a function component
|
|
56
|
+
reads `props.children`, which is how every Chip and Btn in this panel is
|
|
57
|
+
written. Passing it only on the element renders rows with empty buttons. */
|
|
58
|
+
createElement: (type, props, ...children) => {
|
|
59
|
+
const flat = children.flat(Infinity)
|
|
60
|
+
return {
|
|
61
|
+
type,
|
|
62
|
+
props: { ...(props ?? {}), children: flat.length === 0 ? undefined : (flat.length === 1 ? flat[0] : flat) },
|
|
63
|
+
children: flat,
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
useState: (initial) => {
|
|
67
|
+
const key = hookKey()
|
|
68
|
+
if (!values.has(key)) values.set(key, typeof initial === 'function' ? initial() : initial)
|
|
69
|
+
const set = (next) => {
|
|
70
|
+
const value = typeof next === 'function' ? next(values.get(key)) : next
|
|
71
|
+
if (value !== values.get(key)) {
|
|
72
|
+
values.set(key, value)
|
|
73
|
+
dirty = true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [values.get(key), set]
|
|
77
|
+
},
|
|
78
|
+
useRef: (initial) => {
|
|
79
|
+
const key = hookKey()
|
|
80
|
+
if (!values.has(key)) values.set(key, { current: initial })
|
|
81
|
+
return values.get(key)
|
|
82
|
+
},
|
|
83
|
+
useEffect: (fn, deps) => {
|
|
84
|
+
const key = hookKey()
|
|
85
|
+
pendingEffects.push({ key, fn, deps })
|
|
86
|
+
},
|
|
87
|
+
/* Memoised like React with `[]` deps: the panel's callbacks are what its effects
|
|
88
|
+
depend on, and a new identity every pass would re-run every effect forever. */
|
|
89
|
+
useCallback: (fn) => {
|
|
90
|
+
const key = hookKey()
|
|
91
|
+
if (!values.has(key)) values.set(key, fn)
|
|
92
|
+
return values.get(key)
|
|
93
|
+
},
|
|
94
|
+
useMemo: (fn) => {
|
|
95
|
+
const key = hookKey()
|
|
96
|
+
if (!values.has(key)) values.set(key, fn())
|
|
97
|
+
return values.get(key)
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Render function components down to host elements, as React would. */
|
|
102
|
+
function walk(node, path) {
|
|
103
|
+
if (node === null || node === undefined || typeof node === 'boolean') return null
|
|
104
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
|
105
|
+
if (Array.isArray(node)) return node.map((child, at) => walk(child, `${path}.${String(at)}`))
|
|
106
|
+
if (typeof node.type === 'function' || typeof node.type === 'object') {
|
|
107
|
+
const name = node.type?.name ?? node.type?.displayName ?? 'Component'
|
|
108
|
+
const child = `${path}<${name}>`
|
|
109
|
+
const saved = cursor
|
|
110
|
+
ownerPath.push(child)
|
|
111
|
+
cursor = 0
|
|
112
|
+
let output
|
|
113
|
+
try {
|
|
114
|
+
output = node.type(node.props)
|
|
115
|
+
} finally {
|
|
116
|
+
cursor = saved
|
|
117
|
+
ownerPath.pop()
|
|
118
|
+
}
|
|
119
|
+
return { type: child, props: {}, children: [walk(output, child)] }
|
|
120
|
+
}
|
|
121
|
+
return { type: node.type, props: node.props, children: (node.children ?? []).map((child, at) => walk(child, `${path}.${String(at)}`)) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Run the effects whose dependencies changed, then let their promises resolve. */
|
|
125
|
+
async function runEffects() {
|
|
126
|
+
for (const effect of pendingEffects) {
|
|
127
|
+
const slot = effectSlots.get(effect.key)
|
|
128
|
+
const deps = effect.deps
|
|
129
|
+
const changed = slot === undefined
|
|
130
|
+
|| deps === undefined
|
|
131
|
+
|| slot.deps === undefined
|
|
132
|
+
|| deps.length !== slot.deps.length
|
|
133
|
+
|| deps.some((value, at) => value !== slot.deps[at])
|
|
134
|
+
if (!changed) continue
|
|
135
|
+
if (typeof slot?.cleanup === 'function') slot.cleanup()
|
|
136
|
+
effectSlots.set(effect.key, { deps, cleanup: effect.fn() })
|
|
137
|
+
}
|
|
138
|
+
await new Promise((settle) => { setTimeout(settle, 0) })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Render one root component until nothing is dirty, and return its last tree. */
|
|
142
|
+
async function render(component, props, rounds = 10) {
|
|
143
|
+
let tree = null
|
|
144
|
+
for (let round = 0; round < rounds; round += 1) {
|
|
145
|
+
dirty = false
|
|
146
|
+
pendingEffects = []
|
|
147
|
+
cursor = 0
|
|
148
|
+
ownerPath.length = 0
|
|
149
|
+
ownerPath.push('root')
|
|
150
|
+
const output = component(props)
|
|
151
|
+
tree = walk(output, 'root')
|
|
152
|
+
await runEffects()
|
|
153
|
+
if (!dirty) break
|
|
154
|
+
}
|
|
155
|
+
return tree
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Every string in the tree, in order. */
|
|
159
|
+
function textOf(node) {
|
|
160
|
+
if (node === null || node === undefined || typeof node === 'boolean') return ''
|
|
161
|
+
if (typeof node === 'string') return node
|
|
162
|
+
return (node.children ?? []).map(textOf).join(' ')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Whether a rendered tree contains a button carrying this label. */
|
|
166
|
+
function hasButton(node, label) {
|
|
167
|
+
if (node === null || typeof node !== 'object') return false
|
|
168
|
+
if (Array.isArray(node)) return node.some((child) => hasButton(child, label))
|
|
169
|
+
const own = node.type === 'button' && textOf(node).includes(label)
|
|
170
|
+
return own || hasButton(node.children, label)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Press the first button whose text contains this label, as a user would.
|
|
175
|
+
*
|
|
176
|
+
* The row's explanation and its confirmation both live behind the expansion, so a
|
|
177
|
+
* test that never expands only ever sees the summary line — which is how the most
|
|
178
|
+
* important sentence on screen could be missing while every check still passed.
|
|
179
|
+
*
|
|
180
|
+
* @returns {boolean} whether a button was found.
|
|
181
|
+
*/
|
|
182
|
+
function clickButton(node, label) {
|
|
183
|
+
if (node === null || typeof node !== 'object') return false
|
|
184
|
+
if (Array.isArray(node)) return node.some((child) => clickButton(child, label))
|
|
185
|
+
if (node.type === 'button' && textOf(node).includes(label) && typeof node.props?.onClick === 'function') {
|
|
186
|
+
node.props.onClick()
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
return clickButton(node.children, label)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Type into the first input whose placeholder matches, as a user would.
|
|
194
|
+
*
|
|
195
|
+
* The token field is the one control in this panel that is not a button, and a test
|
|
196
|
+
* that only clicks would never prove the sign-in path can be reached without a
|
|
197
|
+
* terminal — which is the whole point of that field existing.
|
|
198
|
+
*
|
|
199
|
+
* @returns {boolean} whether an input was found.
|
|
200
|
+
*/
|
|
201
|
+
function typeInto(node, placeholderPart, value) {
|
|
202
|
+
if (node === null || typeof node !== 'object') return false
|
|
203
|
+
if (Array.isArray(node)) return node.some((child) => typeInto(child, placeholderPart, value))
|
|
204
|
+
if (node.type === 'input' && String(node.props?.placeholder ?? '').includes(placeholderPart) && typeof node.props?.onChange === 'function') {
|
|
205
|
+
node.props.onChange({ target: { value } })
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
return typeInto(node.children, placeholderPart, value)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ---- a document, a fetch that answers from a fixture, and the loader ---- */
|
|
212
|
+
|
|
213
|
+
const styleElements = []
|
|
214
|
+
const document = {
|
|
215
|
+
visibilityState: 'visible',
|
|
216
|
+
getElementById: () => null,
|
|
217
|
+
createElement: () => ({ setAttribute: () => {}, append: () => {}, textContent: '' }),
|
|
218
|
+
head: { append: (element) => styleElements.push(element) },
|
|
219
|
+
addEventListener: () => {},
|
|
220
|
+
removeEventListener: () => {},
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let fixture = {}
|
|
224
|
+
const calls = []
|
|
225
|
+
async function fetchStub(url) {
|
|
226
|
+
const path = String(url).slice(String(url).indexOf('/api/dsh-cicd') + '/api/dsh-cicd'.length)
|
|
227
|
+
calls.push(path)
|
|
228
|
+
const body = fixture[path] ?? { ok: false, code: 'not-found', message: `no fixture for ${path}` }
|
|
229
|
+
return {
|
|
230
|
+
ok: body.__status === undefined || (body.__status >= 200 && body.__status < 300),
|
|
231
|
+
status: body.__status ?? 200,
|
|
232
|
+
json: async () => body,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const sandbox = {
|
|
237
|
+
React,
|
|
238
|
+
console,
|
|
239
|
+
setTimeout,
|
|
240
|
+
clearTimeout,
|
|
241
|
+
/* The panel polls with `setInterval`; here the tick never fires, because the test
|
|
242
|
+
wants the render that follows the first load, not a stream of refreshes. */
|
|
243
|
+
setInterval: () => 0,
|
|
244
|
+
clearInterval: () => {},
|
|
245
|
+
fetch: fetchStub,
|
|
246
|
+
AbortController,
|
|
247
|
+
URL,
|
|
248
|
+
JSON,
|
|
249
|
+
Date,
|
|
250
|
+
Math,
|
|
251
|
+
Number,
|
|
252
|
+
String,
|
|
253
|
+
Boolean,
|
|
254
|
+
Array,
|
|
255
|
+
Object,
|
|
256
|
+
Error,
|
|
257
|
+
Promise,
|
|
258
|
+
RegExp,
|
|
259
|
+
Symbol,
|
|
260
|
+
document,
|
|
261
|
+
/* A returning user: a first visit opens the picker, and this test is about the
|
|
262
|
+
repository rows, so the stored preference says "show the list". */
|
|
263
|
+
localStorage: { getItem: () => 'false', setItem: () => {} },
|
|
264
|
+
__load: null,
|
|
265
|
+
}
|
|
266
|
+
sandbox.window = sandbox
|
|
267
|
+
sandbox.globalThis = sandbox
|
|
268
|
+
sandbox.window.__ModuleLoader__ = {
|
|
269
|
+
load: ({ factory }) => {
|
|
270
|
+
sandbox.__load = factory((name) => {
|
|
271
|
+
if (name === 'react') return React
|
|
272
|
+
throw new Error(`unexpected require: ${name}`)
|
|
273
|
+
})
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
vm.createContext(sandbox)
|
|
278
|
+
vm.runInContext(source, sandbox, { filename: 'client.js' })
|
|
279
|
+
|
|
280
|
+
check('the client registers itself with the loader', typeof sandbox.__load?.apply === 'function')
|
|
281
|
+
check('the client declares the services it needs', Array.isArray(sandbox.__load?.inject) && sandbox.__load.inject.includes('slots'))
|
|
282
|
+
|
|
283
|
+
/* Mount it the way the Host does, and capture what it registers. */
|
|
284
|
+
const registered = new Map()
|
|
285
|
+
/** What each registration asked for, so a test can tell WHICH slot it went into. */
|
|
286
|
+
const registrationMeta = new Map()
|
|
287
|
+
let dictionary = null
|
|
288
|
+
sandbox.__load.apply({
|
|
289
|
+
effect: (fn) => fn(),
|
|
290
|
+
locale: {
|
|
291
|
+
register: (ns, dicts) => { dictionary = dicts },
|
|
292
|
+
bind: () => translate,
|
|
293
|
+
},
|
|
294
|
+
slots: {
|
|
295
|
+
inject: (name, fn) => fn(),
|
|
296
|
+
/* Keyed by slot AND cell, which is what the runtime keys by.
|
|
297
|
+
Keying by `id` alone lost registrations two ways: one plugin contributing several
|
|
298
|
+
entries to one slot had its second silently replace the first (how a missing
|
|
299
|
+
settings page passed a whole suite), and the same id in two DIFFERENT slots —
|
|
300
|
+
which the runtime treats as two unrelated cells, and this plugin does use
|
|
301
|
+
(`dsh-cicd` is both its sidebar entry and its settings tab) — collided. */
|
|
302
|
+
register: (meta, component) => {
|
|
303
|
+
const cell = typeof meta.id === 'string' && meta.id !== ''
|
|
304
|
+
? meta.id
|
|
305
|
+
: (typeof meta.key === 'string' && meta.key !== '' ? meta.key : '')
|
|
306
|
+
const key = `${String(meta.name)}#${cell}`
|
|
307
|
+
registered.set(key, component)
|
|
308
|
+
registrationMeta.set(key, meta)
|
|
309
|
+
return () => {
|
|
310
|
+
registered.delete(key)
|
|
311
|
+
registrationMeta.delete(key)
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
/** The panel's own dictionary, with `{name}` substitution — what a user reads. */
|
|
318
|
+
function translate(key, params) {
|
|
319
|
+
const template = dictionary?.zh?.[key] ?? key
|
|
320
|
+
return params === undefined ? template : template.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** One registration, by the slot it went into and its cell id. */
|
|
324
|
+
function slotEntry(name, cell) {
|
|
325
|
+
return registered.get(`${name}#${cell}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** What a registration asked for, same addressing. */
|
|
329
|
+
function slotMeta(name, cell) {
|
|
330
|
+
return registrationMeta.get(`${name}#${cell}`)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
check('the main view is registered', typeof slotEntry('main', 'dsh-cicd') === 'function')
|
|
334
|
+
check('the theme is injected once', styleElements.length === 1)
|
|
335
|
+
check('the settings page is registered', typeof slotEntry('settings.plugins.tab', 'dsh-cicd') === 'function')
|
|
336
|
+
/*
|
|
337
|
+
* ONE page for the whole console, one row of the Plugins strip.
|
|
338
|
+
*
|
|
339
|
+
* Both shared surfaces here are flat lists — the top-level nav (`settings.section`)
|
|
340
|
+
* and the strip inside Plugins (`settings.plugins.tab`) — and this plugin's share of
|
|
341
|
+
* each is one row. It was three of thirteen nav rows before, all of them above
|
|
342
|
+
* 通用 / 模型 / 插件, while every other third-party plugin on this machine takes
|
|
343
|
+
* exactly one. Nothing else in the suite can tell those homes apart, so these are the
|
|
344
|
+
* checks that pin it.
|
|
345
|
+
*/
|
|
346
|
+
check('the settings page is a tab inside the Plugins section', slotMeta('settings.plugins.tab', 'dsh-cicd')?.name === 'settings.plugins.tab', String(slotMeta('settings.plugins.tab', 'dsh-cicd')?.name))
|
|
347
|
+
check('the console takes no top-level settings section at all', [...registrationMeta.values()].every((meta) => meta.name !== 'settings.section'), [...registrationMeta.values()].map((meta) => meta.name).join(','))
|
|
348
|
+
check('the console holds exactly one row of the Plugins strip', [...registrationMeta.values()].filter((meta) => meta.name === 'settings.plugins.tab').length === 1, [...registrationMeta.values()].filter((meta) => meta.name === 'settings.plugins.tab').map((meta) => meta.id).join(','))
|
|
349
|
+
check('the row sits after the inventory and before the marketplace', Number(slotMeta('settings.plugins.tab', 'dsh-cicd')?.order) === 20, String(slotMeta('settings.plugins.tab', 'dsh-cicd')?.order))
|
|
350
|
+
check('the row is named after the console, not after one credential', slotMeta('settings.plugins.tab', 'dsh-cicd')?.label?.() === 'DSH 插件发布台', String(slotMeta('settings.plugins.tab', 'dsh-cicd')?.label?.()))
|
|
351
|
+
/* The sidebar entry and the settings tab answer to the same id in two different slots.
|
|
352
|
+
The runtime keys by slot, so that is two cells; a harness that keyed by id alone
|
|
353
|
+
would have one registration quietly replace the other. */
|
|
354
|
+
check('the sidebar entry and the settings tab are two different cells', typeof slotEntry('sidebar.panellist', 'dsh-cicd') === 'function' && typeof slotEntry('settings.plugins.tab', 'dsh-cicd') === 'function' && slotEntry('sidebar.panellist', 'dsh-cicd') !== slotEntry('settings.plugins.tab', 'dsh-cicd'))
|
|
355
|
+
|
|
356
|
+
const ConsolePage = slotEntry('main', 'dsh-cicd')
|
|
357
|
+
const ConsoleSettingsPage = slotEntry('settings.plugins.tab', 'dsh-cicd')
|
|
358
|
+
|
|
359
|
+
/** One repository row fixture. */
|
|
360
|
+
function repoFixture(overrides) {
|
|
361
|
+
return {
|
|
362
|
+
repo: 'dsh-plugin-restart',
|
|
363
|
+
slug: 'SOH4C4759/dsh-plugin-restart',
|
|
364
|
+
label: 'dsh-plugin-restart',
|
|
365
|
+
localPath: 'F:\\CodeProj\\dsh-plugin-restart',
|
|
366
|
+
problems: [],
|
|
367
|
+
version: '1.0.0',
|
|
368
|
+
expectedTag: 'v1.0.0',
|
|
369
|
+
versionKnown: true,
|
|
370
|
+
published: true,
|
|
371
|
+
publishedTag: 'v1.0.0',
|
|
372
|
+
draftTag: null,
|
|
373
|
+
releaseCheck: { state: 'ready', code: 'version-free', tag: 'v1.0.0', owner: null, built: null, message: '', next: '1.0.1', nextTag: 'v1.0.1' },
|
|
374
|
+
latestRun: null,
|
|
375
|
+
runs: [],
|
|
376
|
+
releases: [{ tag: 'v1.0.0', name: '', draft: false, prerelease: false, targetCommitish: 'a'.repeat(40), createdAt: '2026-10-06T19:00:19Z', url: '', assets: [] }],
|
|
377
|
+
workflows: [{ name: 'CI', path: 'ci.yml', state: 'active' }, { name: 'Release', path: 'release.yml', state: 'active' }],
|
|
378
|
+
hasBuildWorkflow: true,
|
|
379
|
+
hasReleaseWorkflow: true,
|
|
380
|
+
local: { available: true, branch: 'main', head: 'b'.repeat(40), dirty: 0, ahead: 0, behind: 0, upstreamKnown: true },
|
|
381
|
+
/* The normal state on this machine: the profile points at a checkout, while the
|
|
382
|
+
release is a separate artifact that may well carry the same version. */
|
|
383
|
+
install: installFixture(),
|
|
384
|
+
...overrides,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** One `install` block, exactly as the Host sends it. */
|
|
389
|
+
function installFixture(overrides) {
|
|
390
|
+
return {
|
|
391
|
+
profile: 'desktop',
|
|
392
|
+
profileDir: 'C:\\Users\\x\\.dsh\\profiles\\desktop',
|
|
393
|
+
profileReadable: true,
|
|
394
|
+
packageName: 'dsh-plugin-restart',
|
|
395
|
+
present: true,
|
|
396
|
+
spec: 'link:F:\\CodeProj\\dsh-plugin-restart',
|
|
397
|
+
kind: 'link',
|
|
398
|
+
installedVersion: '1.0.0',
|
|
399
|
+
latestTag: 'v1.0.0',
|
|
400
|
+
latestVersion: '1.0.0',
|
|
401
|
+
latestAsset: 'dsh-plugin-restart-1.0.0.tgz',
|
|
402
|
+
state: 'checkout',
|
|
403
|
+
...overrides,
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** One `npm-status` entry, exactly as the Host sends it. */
|
|
408
|
+
function npmFixture(overrides) {
|
|
409
|
+
return {
|
|
410
|
+
repo: 'dsh-plugin-restart',
|
|
411
|
+
label: 'dsh-plugin-restart',
|
|
412
|
+
localPath: 'F:\\CodeProj\\dsh-plugin-restart',
|
|
413
|
+
packageName: 'dsh-plugin-restart',
|
|
414
|
+
version: '1.0.1',
|
|
415
|
+
dirty: 0,
|
|
416
|
+
privatePackage: false,
|
|
417
|
+
blockers: [],
|
|
418
|
+
canPublish: true,
|
|
419
|
+
state: 'unpublished',
|
|
420
|
+
latest: '1.0.0',
|
|
421
|
+
registryProblem: null,
|
|
422
|
+
registry: 'https://registry.npmjs.org/',
|
|
423
|
+
pageUrl: 'https://registry.npmjs.org/dsh-plugin-restart',
|
|
424
|
+
...overrides,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** One whole `npm-status` payload. */
|
|
429
|
+
function npmStatusValue(overrides) {
|
|
430
|
+
return {
|
|
431
|
+
fetchedAt: '2026-10-07T01:00:00Z',
|
|
432
|
+
cached: false,
|
|
433
|
+
registry: 'https://registry.npmjs.org/',
|
|
434
|
+
packageManager: { source: 'profile', command: 'node.exe' },
|
|
435
|
+
auth: { loggedIn: true, account: 'soh4c4759', message: null, npmrcPath: 'C:\\Users\\x\\.npmrc', npmrcReadable: true, npmrcHasToken: true },
|
|
436
|
+
repos: [npmFixture()],
|
|
437
|
+
...overrides,
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The protocol this client demands, read from the source it was loaded from.
|
|
443
|
+
*
|
|
444
|
+
* A hard-coded copy here goes stale on every bump, and its failure looks exactly
|
|
445
|
+
* like a product bug ("a missing gh is explained" stops passing because the panel
|
|
446
|
+
* decided the Host was stale). Reading the constant makes the fixture follow the
|
|
447
|
+
* code, which is what a fixture is for.
|
|
448
|
+
*/
|
|
449
|
+
const CLIENT_PROTOCOL = Number(/const PROTOCOL = (\d+)/.exec(source)?.[1] ?? 0)
|
|
450
|
+
check('the client declares a protocol this test can read', CLIENT_PROTOCOL > 0, String(CLIENT_PROTOCOL))
|
|
451
|
+
|
|
452
|
+
const baseStatus = {
|
|
453
|
+
ok: true,
|
|
454
|
+
value: {
|
|
455
|
+
protocol: CLIENT_PROTOCOL,
|
|
456
|
+
config: { buildWorkflow: 'ci.yml', releaseWorkflow: 'release.yml', defaultBranch: 'main', pollSeconds: 30 },
|
|
457
|
+
helper: { configureScript: 'F:\\CodeProj\\dsh-plugin-cicd\\scripts\\configure.mjs' },
|
|
458
|
+
gh: { path: 'gh', available: true, version: 'gh version 2.102.0', authenticated: true, account: 'SOH4C4759', scopes: ['repo', 'workflow'], missingScopes: [], message: null },
|
|
459
|
+
repos: [{ repo: 'dsh-plugin-restart', label: 'dsh-plugin-restart', localPath: 'F:\\CodeProj\\dsh-plugin-restart' }],
|
|
460
|
+
},
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Render the panel against one fixture, from a clean hook state. */
|
|
464
|
+
async function renderPanel(repos, statusValue = baseStatus.value, extra = {}, npmValue = npmStatusValue()) {
|
|
465
|
+
fixture = {
|
|
466
|
+
'/status': { ok: true, value: statusValue },
|
|
467
|
+
'/overview': { ok: true, value: { repos } },
|
|
468
|
+
/* The npm status is its own request; `null` means "the Host never answered",
|
|
469
|
+
which is a state the panel has to survive rather than fabricate around. */
|
|
470
|
+
...(npmValue === null ? {} : { '/npm-status': { ok: true, value: npmValue } }),
|
|
471
|
+
...extra,
|
|
472
|
+
}
|
|
473
|
+
values.clear()
|
|
474
|
+
effectSlots.clear()
|
|
475
|
+
calls.length = 0
|
|
476
|
+
return render(ConsolePage, { t: translate })
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Re-render the same mounted panel, keeping its state — after a click, say. */
|
|
480
|
+
async function rerender() {
|
|
481
|
+
return render(ConsolePage, { t: translate })
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Whether the last segment press found its button. */
|
|
485
|
+
let lastSegmentPress = true
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Render the console's settings page and land on one of its three segments.
|
|
489
|
+
*
|
|
490
|
+
* The segments are the page's own, not the platform's: `settings.plugins.tab` is a
|
|
491
|
+
* flat list of tabs and the plugin is one of them, so everything below that is a
|
|
492
|
+
* button inside our page. Reaching the npm page therefore means pressing its segment,
|
|
493
|
+
* exactly as a person would.
|
|
494
|
+
*/
|
|
495
|
+
async function renderSettings(segment = 'github') {
|
|
496
|
+
const first = await render(ConsoleSettingsPage, { t: translate })
|
|
497
|
+
if (segment === 'github') return first
|
|
498
|
+
const label = segment === 'bilibili' ? 'Bilibili' : 'npm'
|
|
499
|
+
lastSegmentPress = clickButton(first, label)
|
|
500
|
+
/* Re-render the same mounted shell: the segment press marked it dirty, so this pass
|
|
501
|
+
draws the segment that was asked for, with that page's own hooks under it. */
|
|
502
|
+
return render(ConsoleSettingsPage, { t: translate })
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Render the npm settings page against one npm-status fixture. */
|
|
506
|
+
async function renderNpmPage(npmValue = npmStatusValue(), extra = {}) {
|
|
507
|
+
const fixtures = { '/status': { ok: true, value: baseStatus.value }, ...extra }
|
|
508
|
+
/* Omitted rather than set to undefined: "the Host never answered" is a state this
|
|
509
|
+
page has to survive, and a fixture key of undefined would silently become one. */
|
|
510
|
+
if (npmValue !== null) fixtures['/npm-status'] = { ok: true, value: npmValue }
|
|
511
|
+
fixture = fixtures
|
|
512
|
+
values.clear()
|
|
513
|
+
effectSlots.clear()
|
|
514
|
+
calls.length = 0
|
|
515
|
+
return renderSettings('npm')
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Re-render the settings page, keeping its state — after a click, say. */
|
|
519
|
+
async function rerenderNpm() {
|
|
520
|
+
return renderSettings('npm')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Expand the first row and return the tree with its detail visible. */
|
|
524
|
+
async function expandFirstRow(tree) {
|
|
525
|
+
const pressed = clickButton(tree, '▾')
|
|
526
|
+
return { pressed, tree: await rerender() }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/* -- 0. One settings page, three segments -----------------------------------
|
|
530
|
+
The console's settings are ONE tab in the Plugins strip, and everything below that
|
|
531
|
+
is its own: the platform's strip is a flat list with no nesting, so the segment
|
|
532
|
+
strip is ours. One segment mounts at a time, which is not only about not probing
|
|
533
|
+
three credential systems on open — the Bilibili half reads a ledger and a cookie
|
|
534
|
+
file, and a malformed one must not take down the GitHub state someone opened this
|
|
535
|
+
page to read. */
|
|
536
|
+
{
|
|
537
|
+
fixture = {
|
|
538
|
+
'/status': { ok: true, value: baseStatus.value },
|
|
539
|
+
'/auth-state': { ok: true, value: { available: true, authenticated: true, account: 'octocat', scopes: ['repo'], missingScopes: [] } },
|
|
540
|
+
}
|
|
541
|
+
values.clear()
|
|
542
|
+
effectSlots.clear()
|
|
543
|
+
calls.length = 0
|
|
544
|
+
const opened = await renderSettings('github')
|
|
545
|
+
check('the page names the three credentials as segments', hasButton(opened, 'GitHub') && hasButton(opened, 'npm') && hasButton(opened, 'Bilibili'), textOf(opened).replace(/\s+/g, ' ').slice(0, 160))
|
|
546
|
+
check('it opens on GitHub rather than on whichever page loaded last', calls.includes('/status') === true)
|
|
547
|
+
check('opening it probes no other credential system', calls.includes('/npm-status') === false && calls.includes('/bilibili-status') === false, calls.join(','))
|
|
548
|
+
|
|
549
|
+
fixture['/npm-status'] = { ok: true, value: npmStatusValue() }
|
|
550
|
+
const onNpm = await renderSettings('npm')
|
|
551
|
+
check('a segment press finds its button', lastSegmentPress === true)
|
|
552
|
+
check('the pressed segment draws that credential\'s page', hasButton(onNpm, '写入并验证'), textOf(onNpm).replace(/\s+/g, ' ').slice(0, 200))
|
|
553
|
+
check('the npm probe happens only once its segment is opened', calls.includes('/npm-status') === true, calls.join(','))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/* -- 1. A version taken by another commit offers the bump ------------------- */
|
|
557
|
+
{
|
|
558
|
+
const collapsed = await renderPanel([repoFixture({
|
|
559
|
+
releaseCheck: {
|
|
560
|
+
state: 'blocked',
|
|
561
|
+
code: 'version-taken',
|
|
562
|
+
tag: 'v1.0.0',
|
|
563
|
+
owner: '48ce81c'.padEnd(40, '0'),
|
|
564
|
+
built: 'e6a1cc1'.padEnd(40, '0'),
|
|
565
|
+
message: 'english fallback',
|
|
566
|
+
next: '1.0.1',
|
|
567
|
+
nextTag: 'v1.0.1',
|
|
568
|
+
},
|
|
569
|
+
})])
|
|
570
|
+
const summary = textOf(collapsed)
|
|
571
|
+
|
|
572
|
+
check('the overview is actually read', calls.includes('/overview'), calls.join(','))
|
|
573
|
+
check('the blocked row is marked', summary.includes('版本被占用'), summary.replace(/\s+/g, ' ').slice(0, 140))
|
|
574
|
+
check('the blocked row offers the bump instead of a plain release', hasButton(collapsed, '升版本并发布'))
|
|
575
|
+
check('the English fallback is not what a Chinese user reads', summary.includes('english fallback') === false)
|
|
576
|
+
|
|
577
|
+
const { pressed, tree } = await expandFirstRow(collapsed)
|
|
578
|
+
const detail = textOf(tree)
|
|
579
|
+
check('a row can be expanded', pressed === true)
|
|
580
|
+
check('the blocked row names both commits', detail.includes('48ce81c') && detail.includes('e6a1cc1'), detail.replace(/\s+/g, ' ').slice(0, 160))
|
|
581
|
+
check('the reason is in the panel, not only in the logs', detail.includes('release.yml 会拒绝覆盖'))
|
|
582
|
+
check('the expansion offers the bump too', hasButton(tree, '升版本并发布'))
|
|
583
|
+
|
|
584
|
+
/* The bump commits and pushes, so it must be a deliberate second click — the
|
|
585
|
+
same shape as publishing a draft, which is the other one-way action here. */
|
|
586
|
+
const asked = clickButton(tree, '升版本并发布')
|
|
587
|
+
const confirm = textOf(await rerender())
|
|
588
|
+
check('the bump asks before it writes anything', asked === true && confirm.includes('这会创建一个提交'), confirm.replace(/\s+/g, ' ').slice(0, 200))
|
|
589
|
+
check('the confirmation names the version it would write', confirm.includes('1.0.1'))
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/* -- 2. A free version keeps the ordinary release button ------------------- */
|
|
593
|
+
{
|
|
594
|
+
const tree = await renderPanel([repoFixture({ published: false, publishedTag: null })])
|
|
595
|
+
const text = textOf(tree)
|
|
596
|
+
|
|
597
|
+
check('a free version offers 发布', hasButton(tree, '发布'))
|
|
598
|
+
check('a free version does not offer a bump', hasButton(tree, '升版本并发布') === false)
|
|
599
|
+
check('a free version is not marked as taken', text.includes('版本被占用') === false)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/* -- 3. An uncommitted tree warns that the release would miss it ----------- */
|
|
603
|
+
{
|
|
604
|
+
const collapsed = await renderPanel([repoFixture({
|
|
605
|
+
local: { available: true, branch: 'main', head: 'b'.repeat(40), dirty: 7, ahead: 0, behind: 0, upstreamKnown: true },
|
|
606
|
+
})])
|
|
607
|
+
check('uncommitted work is counted on the row', textOf(collapsed).includes('未提交 7'), textOf(collapsed).replace(/\s+/g, ' ').slice(0, 140))
|
|
608
|
+
const { tree } = await expandFirstRow(collapsed)
|
|
609
|
+
check('uncommitted work is explained, not just counted', textOf(tree).includes('不会进入发布包'), textOf(tree).replace(/\s+/g, ' ').slice(0, 200))
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/* -- 4. A stale Host is named as a version mismatch, not a bare error ------ */
|
|
613
|
+
{
|
|
614
|
+
const tree = await renderPanel([repoFixture()], { ...baseStatus.value, protocol: CLIENT_PROTOCOL - 1 })
|
|
615
|
+
check('a stale Host is named as such', textOf(tree).includes('页面与宿主半边版本不一致'), textOf(tree).replace(/\s+/g, ' ').slice(0, 140))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/* -- 5. A Host that needs setup says so instead of showing an empty list --- */
|
|
619
|
+
{
|
|
620
|
+
const tree = await renderPanel([repoFixture()], {
|
|
621
|
+
...baseStatus.value,
|
|
622
|
+
gh: { ...baseStatus.value.gh, available: false, message: 'gh not found' },
|
|
623
|
+
})
|
|
624
|
+
check('a missing gh is explained', textOf(tree).includes('没有找到 gh CLI'), textOf(tree).replace(/\s+/g, ' ').slice(0, 140))
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/* -- 6. A linked checkout keeps the published artifact out of the row ---------
|
|
628
|
+
The state the version comparison cannot reach: the checkout and the release both
|
|
629
|
+
say 1.0.0, yet only one of them is what someone who downloaded the release runs.
|
|
630
|
+
The capability stays — "does the released artifact install?" is a question this
|
|
631
|
+
panel exists to answer — but not on the row. A checkout is the normal state on the
|
|
632
|
+
machine that DEVELOPED the plugin, where the row's button would only replace the
|
|
633
|
+
code being edited with a tarball. */
|
|
634
|
+
{
|
|
635
|
+
const collapsed = await renderPanel([repoFixture({ install: installFixture({ state: 'checkout' }) })])
|
|
636
|
+
check('a linked checkout is NOT offered on the row', hasButton(collapsed, '装 Release v1.0.0') === false, textOf(collapsed).replace(/\s+/g, ' ').slice(0, 200))
|
|
637
|
+
check('a linked checkout is not marked as behind on the row', textOf(collapsed).includes('可更新') === false)
|
|
638
|
+
|
|
639
|
+
const { tree } = await expandFirstRow(collapsed)
|
|
640
|
+
const detail = textOf(tree)
|
|
641
|
+
check('the capability is still there, one level down', hasButton(tree, '装 Release v1.0.0'))
|
|
642
|
+
check('the expansion says the installed copy is a checkout, not the release', detail.includes('不是发布出去的那份'), detail.replace(/\s+/g, ' ').slice(0, 220))
|
|
643
|
+
check('the explanation names the link spec', detail.includes('link:F:\\CodeProj\\dsh-plugin-restart'))
|
|
644
|
+
|
|
645
|
+
/* Installing rewrites a profile dependency, so it asks first — the same shape as
|
|
646
|
+
the bump, which is the other action here that changes something on disk. */
|
|
647
|
+
const asked = clickButton(tree, '装 Release v1.0.0')
|
|
648
|
+
const confirm = await rerender()
|
|
649
|
+
check('installing asks before it rewrites the profile', asked === true && textOf(confirm).includes('这会改写该 profile 的依赖条目'), textOf(confirm).replace(/\s+/g, ' ').slice(0, 240))
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/* -- 6b. An "install" that would move BACKWARDS stays off the row ------------
|
|
653
|
+
The profile holds something newer than the release. Offering that button on the row
|
|
654
|
+
is offering a downgrade as if it were housekeeping. */
|
|
655
|
+
{
|
|
656
|
+
const collapsed = await renderPanel([repoFixture({ install: installFixture({ state: 'ahead', installedVersion: '2.0.0', latestTag: 'v1.0.0', latestVersion: '1.0.0' }) })])
|
|
657
|
+
check('a profile holding something newer is not offered a downgrade on the row', hasButton(collapsed, '装 Release v1.0.0') === false, textOf(collapsed).replace(/\s+/g, ' ').slice(0, 200))
|
|
658
|
+
|
|
659
|
+
const { tree } = await expandFirstRow(collapsed)
|
|
660
|
+
check('the downgrade is still explained where it is offered', hasButton(tree, '装 Release v1.0.0') && textOf(tree).includes('退回到旧版本'))
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/* -- 7. An update, then the restart question -------------------------------- */
|
|
664
|
+
{
|
|
665
|
+
const answers = {
|
|
666
|
+
'/update': { ok: true, value: { repo: 'dsh-plugin-restart', packageName: 'dsh-plugin-restart', tag: 'v1.0.1', version: '1.0.1', from: '1.0.0', restartRequired: true, pendingBuilds: [] } },
|
|
667
|
+
'/restart': { ok: true, value: { scheduled: true, armDelayMs: 1200 } },
|
|
668
|
+
}
|
|
669
|
+
const collapsed = await renderPanel(
|
|
670
|
+
[repoFixture({ install: installFixture({ state: 'update', installedVersion: '1.0.0', latestTag: 'v1.0.1', latestVersion: '1.0.1' }) })],
|
|
671
|
+
baseStatus.value,
|
|
672
|
+
answers,
|
|
673
|
+
)
|
|
674
|
+
check('an installed copy behind the release is marked on the row', textOf(collapsed).includes('可更新 v1.0.1'), textOf(collapsed).replace(/\s+/g, ' ').slice(0, 160))
|
|
675
|
+
check('the row offers the update by its target version', hasButton(collapsed, '更新到 v1.0.1'))
|
|
676
|
+
|
|
677
|
+
const { tree } = await expandFirstRow(collapsed)
|
|
678
|
+
clickButton(tree, '更新到 v1.0.1')
|
|
679
|
+
const confirmation = await rerender()
|
|
680
|
+
check('the update asks before it writes', textOf(confirmation).includes('换成 Release v1.0.1 的 tgz'), textOf(confirmation).replace(/\s+/g, ' ').slice(0, 240))
|
|
681
|
+
|
|
682
|
+
clickButton(confirmation, '确认更新')
|
|
683
|
+
await rerender()
|
|
684
|
+
const settled = await rerender()
|
|
685
|
+
const text = textOf(settled)
|
|
686
|
+
check('the update really posted to the Host', calls.includes('/update'), calls.join(','))
|
|
687
|
+
check('the panel says the new version needs a restart', text.includes('the new version') || text.includes('重启 DSH 才会真正生效'), text.replace(/\s+/g, ' ').slice(0, 240))
|
|
688
|
+
check('and it asks the restart question instead of assuming an answer', text.includes('现在重启'), text.replace(/\s+/g, ' ').slice(0, 240))
|
|
689
|
+
check('the restart is offered as a button', hasButton(settled, '立即重启 DSH'))
|
|
690
|
+
|
|
691
|
+
clickButton(settled, '立即重启 DSH')
|
|
692
|
+
await rerender()
|
|
693
|
+
const restarted = await rerender()
|
|
694
|
+
check('the restart really reached the restart route', calls.includes('/restart'), calls.join(','))
|
|
695
|
+
check('a scheduled restart is reported, not implied', textOf(restarted).includes('已安排重启'), textOf(restarted).replace(/\s+/g, ' ').slice(0, 240))
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/* -- 8. States with nothing to do offer no button --------------------------- */
|
|
699
|
+
{
|
|
700
|
+
const current = await renderPanel([repoFixture({ install: installFixture({ state: 'current' }) })])
|
|
701
|
+
const { tree: currentDetail } = await expandFirstRow(current)
|
|
702
|
+
check('a current install offers no update button', hasButton(currentDetail, '装 Release') === false && hasButton(currentDetail, '更新到') === false)
|
|
703
|
+
check('a current install says so', textOf(currentDetail).includes('已经是最新的'))
|
|
704
|
+
|
|
705
|
+
const missing = await renderPanel([repoFixture({ install: installFixture({ state: 'not-installed', present: false, spec: '', installedVersion: null }) })])
|
|
706
|
+
const { tree: missingDetail } = await expandFirstRow(missing)
|
|
707
|
+
check('an uninstalled package offers no update button', hasButton(missingDetail, '装 Release') === false && hasButton(missingDetail, '更新到') === false)
|
|
708
|
+
check('an uninstalled package names the profile that was searched', textOf(missingDetail).includes('desktop profile 的依赖里'), textOf(missingDetail).replace(/\s+/g, ' ').slice(0, 220))
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/* -- 8b. Nothing written means nothing to restart --------------------------- */
|
|
712
|
+
{
|
|
713
|
+
const panel = await renderPanel(
|
|
714
|
+
[repoFixture({ install: installFixture({ state: 'update', latestTag: 'v1.0.1', latestVersion: '1.0.1' }) })],
|
|
715
|
+
baseStatus.value,
|
|
716
|
+
{
|
|
717
|
+
/* The profile already pointed at exactly this tarball, so the Host answers
|
|
718
|
+
success with `changed: false` — the outcome that was asked for, reached
|
|
719
|
+
without writing anything. */
|
|
720
|
+
'/update': { ok: true, value: { repo: 'dsh-plugin-restart', packageName: 'dsh-plugin-restart', tag: 'v1.0.1', from: '1.0.1', changed: false, restartRequired: false, pendingBuilds: [] } },
|
|
721
|
+
},
|
|
722
|
+
)
|
|
723
|
+
const { tree } = await expandFirstRow(panel)
|
|
724
|
+
clickButton(tree, '更新到 v1.0.1')
|
|
725
|
+
const confirmation = await rerender()
|
|
726
|
+
clickButton(confirmation, '确认更新')
|
|
727
|
+
await rerender()
|
|
728
|
+
const settled = await rerender()
|
|
729
|
+
const text = textOf(settled)
|
|
730
|
+
check('a no-op update says nothing changed', text.includes('没有改动,也不需要重启'), text.replace(/\s+/g, ' ').slice(0, 240))
|
|
731
|
+
check('a no-op update does not claim a write that never happened', text.includes('已从') === false)
|
|
732
|
+
check('a no-op update asks for no restart', hasButton(settled, '立即重启 DSH') === false)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/* -- 9. A missing restart plugin is named, not swallowed -------------------- */
|
|
736
|
+
{
|
|
737
|
+
const panel = await renderPanel(
|
|
738
|
+
[repoFixture({ install: installFixture({ state: 'update', latestTag: 'v1.0.1', latestVersion: '1.0.1' }) })],
|
|
739
|
+
baseStatus.value,
|
|
740
|
+
{
|
|
741
|
+
'/update': { ok: true, value: { repo: 'dsh-plugin-restart', packageName: 'dsh-plugin-restart', tag: 'v1.0.1', from: '1.0.0', restartRequired: true, pendingBuilds: [] } },
|
|
742
|
+
'/restart': { ok: false, code: 'restart-unavailable', message: 'dsh-plugin-restart is not mounted on this Host, so nothing here can restart DSH', __status: 501 },
|
|
743
|
+
},
|
|
744
|
+
)
|
|
745
|
+
const { tree } = await expandFirstRow(panel)
|
|
746
|
+
clickButton(tree, '更新到 v1.0.1')
|
|
747
|
+
const confirmation = await rerender()
|
|
748
|
+
clickButton(confirmation, '确认更新')
|
|
749
|
+
await rerender()
|
|
750
|
+
const settled = await rerender()
|
|
751
|
+
clickButton(settled, '立即重启 DSH')
|
|
752
|
+
await rerender()
|
|
753
|
+
const after = await rerender()
|
|
754
|
+
check('a refused restart is reported as a failure, not as success', textOf(after).includes('重启不了'), textOf(after).replace(/\s+/g, ' ').slice(0, 240))
|
|
755
|
+
check('the refusal names the plugin that is missing', textOf(after).includes('dsh-plugin-restart'), textOf(after).replace(/\s+/g, ' ').slice(0, 200))
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/* -- 10. npm: a version that is not up there is a job waiting ---------------- */
|
|
759
|
+
{
|
|
760
|
+
const answers = {
|
|
761
|
+
'/npm-publish': { ok: true, value: { repo: 'dsh-plugin-restart', packageName: 'dsh-plugin-restart', version: '1.0.1', registry: 'https://registry.npmjs.org/', wasUnregistered: false, account: 'soh4c4759' } },
|
|
762
|
+
}
|
|
763
|
+
const collapsed = await renderPanel([repoFixture()], baseStatus.value, answers, npmStatusValue())
|
|
764
|
+
check('the npm status is actually asked for', calls.includes('/npm-status'), calls.join(','))
|
|
765
|
+
check('a version npm does not have is marked on the row', textOf(collapsed).includes('npm 待推 v1.0.1'), textOf(collapsed).replace(/\s+/g, ' ').slice(0, 180))
|
|
766
|
+
check('the npm account is shown in the header', textOf(collapsed).includes('npm soh4c4759'))
|
|
767
|
+
/* The four-step guide is for someone who has no credential; showing it to someone
|
|
768
|
+
who has one is the panel nagging about a solved problem. */
|
|
769
|
+
check('a signed-in machine is not shown the credential guide', textOf(collapsed).includes('① 还没有 npm 账号?') === false)
|
|
770
|
+
|
|
771
|
+
const { tree } = await expandFirstRow(collapsed)
|
|
772
|
+
const detail = textOf(tree)
|
|
773
|
+
check('the expansion says what npm currently has', detail.includes('npm 上是 1.0.0'), detail.replace(/\s+/g, ' ').slice(0, 240))
|
|
774
|
+
/* The GitHub Release for 1.0.1 does not exist in this fixture, and the two channels
|
|
775
|
+
are about to disagree in public — so the row says so before the click. */
|
|
776
|
+
check('a missing GitHub release for the same version is called out', detail.includes('先于 Release 面世'))
|
|
777
|
+
|
|
778
|
+
clickButton(tree, '推送到 npm v1.0.1')
|
|
779
|
+
const confirmation = await rerender()
|
|
780
|
+
const confirmText = textOf(confirmation)
|
|
781
|
+
check('the push asks before it uploads', confirmText.includes('npm 不允许同一版本推第二次'), confirmText.replace(/\s+/g, ' ').slice(0, 260))
|
|
782
|
+
check('the confirmation names the registry it would publish to', confirmText.includes('https://registry.npmjs.org/'))
|
|
783
|
+
check('the confirmation offers a place for a 2FA code', typeInto(confirmation, '6 位数字', '123456'))
|
|
784
|
+
|
|
785
|
+
clickButton(await rerender(), '确认推送')
|
|
786
|
+
await rerender()
|
|
787
|
+
const settled = await rerender()
|
|
788
|
+
check('the push really posted to the Host', calls.includes('/npm-publish'), calls.join(','))
|
|
789
|
+
check('a successful push is reported with the version', textOf(settled).includes('dsh-plugin-restart@1.0.1'), textOf(settled).replace(/\s+/g, ' ').slice(0, 240))
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/* -- 11. A name nobody owns says so ----------------------------------------- */
|
|
793
|
+
{
|
|
794
|
+
const panel = await renderPanel([repoFixture()], baseStatus.value, {}, npmStatusValue({
|
|
795
|
+
repos: [npmFixture({ state: 'unregistered', latest: null })],
|
|
796
|
+
}))
|
|
797
|
+
/* On the row, a first publish and a later one are the same job, so they share the
|
|
798
|
+
same chip; the difference — "nobody owns this name yet" — belongs in the
|
|
799
|
+
expansion, where there is room to explain what it means. */
|
|
800
|
+
check('an unregistered name is offered on the row like any other push', textOf(panel).includes('npm 待推 v1.0.1'), textOf(panel).replace(/\s+/g, ' ').slice(0, 180))
|
|
801
|
+
const { tree } = await expandFirstRow(panel)
|
|
802
|
+
check('an unregistered name is marked as unowned in the expansion', textOf(tree).includes('npm 上还没有'))
|
|
803
|
+
check('an unregistered name is explained as a first release', textOf(tree).includes('首次发布'))
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/* -- 12. Nothing to do renders no button ------------------------------------ */
|
|
807
|
+
{
|
|
808
|
+
const published = await renderPanel([repoFixture()], baseStatus.value, {}, npmStatusValue({
|
|
809
|
+
repos: [npmFixture({ state: 'published', canPublish: false, blockers: ['already-published'], version: '1.0.1' })],
|
|
810
|
+
}))
|
|
811
|
+
check('a published version is shown as published', textOf(published).includes('npm v1.0.1'), textOf(published).replace(/\s+/g, ' ').slice(0, 180))
|
|
812
|
+
const { tree } = await expandFirstRow(published)
|
|
813
|
+
check('a published version offers no push', hasButton(tree, '推送到 npm') === false)
|
|
814
|
+
check('and says npm never takes the same version twice', textOf(tree).includes('同一版本推第二次'))
|
|
815
|
+
|
|
816
|
+
/* `private: true` is an author's decision, not a mistake — the reason has to be
|
|
817
|
+
the one on screen, or the missing button looks like a bug. */
|
|
818
|
+
const blocked = await renderPanel([repoFixture()], baseStatus.value, {}, npmStatusValue({
|
|
819
|
+
repos: [npmFixture({ canPublish: false, blockers: ['private-package'], privatePackage: true })],
|
|
820
|
+
}))
|
|
821
|
+
const { tree: blockedDetail } = await expandFirstRow(blocked)
|
|
822
|
+
check('a private package offers no push', hasButton(blockedDetail, '推送到 npm') === false)
|
|
823
|
+
check('a private package says why', textOf(blockedDetail).includes('"private": true'), textOf(blockedDetail).replace(/\s+/g, ' ').slice(0, 240))
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/* -- 13. npm credentials without a terminal --------------------------------- */
|
|
827
|
+
{
|
|
828
|
+
const notSignedIn = npmStatusValue({
|
|
829
|
+
auth: { loggedIn: false, account: null, message: '[ERR_PNPM_WHOAMI_UNAUTHORIZED] You must be logged in to use whoami', npmrcPath: 'C:\\Users\\x\\.npmrc', npmrcReadable: false, npmrcHasToken: false },
|
|
830
|
+
repos: [npmFixture({ canPublish: false, blockers: ['not-logged-in'] })],
|
|
831
|
+
})
|
|
832
|
+
const panel = await renderPanel([repoFixture()], baseStatus.value, {
|
|
833
|
+
'/npm-login': { ok: true, value: { account: 'soh4c4759', registry: 'https://registry.npmjs.org/', npmrcPath: 'C:\\Users\\x\\.npmrc', replaced: false } },
|
|
834
|
+
}, notSignedIn)
|
|
835
|
+
const text = textOf(panel)
|
|
836
|
+
check('a missing npm credential is explained, not hidden', text.includes('还没有登录'), text.replace(/\s+/g, ' ').slice(0, 240))
|
|
837
|
+
/* What the package manager actually said, verbatim: "not signed in" and "that
|
|
838
|
+
token was revoked" are the same blank state and different problems. */
|
|
839
|
+
check('the package manager\'s own message is shown', text.includes('ERR_PNPM_WHOAMI_UNAUTHORIZED'))
|
|
840
|
+
check('the row offers no push while unauthenticated', hasButton(panel, '推送到 npm') === false)
|
|
841
|
+
/* The panel is the operational view: it carries the control, not the course. The
|
|
842
|
+
four steps moved to their own settings page, and the panel says where. */
|
|
843
|
+
/* The path names all three hops, and the segment, because the page moved twice: it is
|
|
844
|
+
a tab inside Plugins, and npm is a segment inside that tab. A hint that stopped at
|
|
845
|
+
设置 — or at the tab — would send someone to a row that is not there any more, or to
|
|
846
|
+
a page whose first screen is GitHub. */
|
|
847
|
+
check('the panel points at the settings guide, by its real path', text.includes('设置 → 插件 → DSH 插件发布台 · npm'), text.replace(/\s+/g, ' ').slice(0, 240))
|
|
848
|
+
check('the panel no longer walks the four steps itself', text.includes('① 还没有 npm 账号?') === false)
|
|
849
|
+
|
|
850
|
+
const typed = typeInto(panel, 'npm_', 'npm_abcdefghijklmnop')
|
|
851
|
+
const ready = await rerender()
|
|
852
|
+
check('the token field accepts a token', typed === true)
|
|
853
|
+
const asked = clickButton(ready, '写入并验证')
|
|
854
|
+
await rerender()
|
|
855
|
+
const after = await rerender()
|
|
856
|
+
check('the token is really posted to the Host', calls.includes('/npm-login'), calls.join(','))
|
|
857
|
+
check('writing a token is confirmed with the account', asked === true && textOf(after).includes('soh4c4759'), textOf(after).replace(/\s+/g, ' ').slice(0, 240))
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/* -- 13b. The npm credentials page: the guide, as configuration ------------- */
|
|
861
|
+
{
|
|
862
|
+
const notSignedIn = npmStatusValue({
|
|
863
|
+
auth: { state: 'none', loggedIn: false, account: null, message: '[ERR_PNPM_WHOAMI_UNAUTHORIZED] You must be logged in to use whoami', npmrcPath: 'C:\\Users\\x\\.npmrc', npmrcReadable: false, npmrcHasToken: false },
|
|
864
|
+
repos: [npmFixture({ canPublish: false, blockers: ['not-logged-in'] })],
|
|
865
|
+
})
|
|
866
|
+
const page = await renderNpmPage(notSignedIn, {
|
|
867
|
+
'/npm-login': { ok: true, value: { account: 'soh4c4759', registry: 'https://registry.npmjs.org/', npmrcPath: 'C:\\Users\\x\\.npmrc', replaced: false } },
|
|
868
|
+
})
|
|
869
|
+
const text = textOf(page)
|
|
870
|
+
check('the settings page asks npm on its own', calls.includes('/npm-status'), calls.join(','))
|
|
871
|
+
check('the page names the file npm itself reads', text.includes('C:\\Users\\x\\.npmrc'))
|
|
872
|
+
/* Zero-basics means the four steps are written out, not one sentence that assumes
|
|
873
|
+
the reader already knows how npm tokens work. */
|
|
874
|
+
check('the guide walks all four steps', ['① 还没有 npm 账号?', '② 生成一个 Access Token', '③ 粘贴到下面', '④ 账号开了 2FA?'].every((step) => text.includes(step)), text.replace(/\s+/g, ' ').slice(0, 320))
|
|
875
|
+
check('the guide links to the sign-up page', hasButton(page, '打开 npmjs.com 注册'))
|
|
876
|
+
check('the guide links to the token page', hasButton(page, '打开 token 页面'))
|
|
877
|
+
check('the guide links to the npm documentation', hasButton(page, 'npm 官方文档'))
|
|
878
|
+
/* The facts that changed in November 2025, and that a stale guide gets wrong:
|
|
879
|
+
classic tokens are gone, a write token has a 90-day ceiling, 2FA is on by default. */
|
|
880
|
+
check('the guide says classic tokens are gone', text.includes('Classic token 已于 2025-11-19'), text.replace(/\s+/g, ' ').slice(0, 320))
|
|
881
|
+
check('the guide states the 90-day ceiling', text.includes('最长 90 天'))
|
|
882
|
+
check('the guide says not to bypass 2FA for a local push', text.includes('不要勾'))
|
|
883
|
+
check('the guide says the email must be verified before publishing', text.includes('不允许未验证邮箱的账号发布'))
|
|
884
|
+
/* npmjs.com answers a whole-site 403 to some networks, and the token does not care
|
|
885
|
+
where it was minted — a guide that assumes this machine can open the site sends
|
|
886
|
+
someone to fight a bot challenge they cannot win from there. */
|
|
887
|
+
check('the guide offers the other-device workaround', text.includes('换一台设备或换一个网络'), text.replace(/\s+/g, ' ').slice(0, 320))
|
|
888
|
+
/* A step is only ticked where the Host can actually know: it can see a missing
|
|
889
|
+
token line, and it cannot see whether an account was ever registered. */
|
|
890
|
+
check('step ③ is the one step with a live state', text.includes('待做'))
|
|
891
|
+
check('the steps that cannot be checked say so instead of ticking', text.includes('无法自动检测'))
|
|
892
|
+
check('an uncheckable step is never ticked as done', text.includes('✓ 已有账号') === false)
|
|
893
|
+
check('the page points at trusted publishing for CI', text.includes('trusted publishing'))
|
|
894
|
+
/* Every package's npm posture, which is the configuration payoff of this page. */
|
|
895
|
+
check('the page lists each package', text.includes('dsh-plugin-restart') && text.includes('本地 1.0.1'))
|
|
896
|
+
check('a package nobody can push yet says why', text.includes('还没有登录 npm'))
|
|
897
|
+
|
|
898
|
+
const typed = typeInto(page, 'npm_', 'npm_abcdefghijklmnop')
|
|
899
|
+
const ready = await rerenderNpm()
|
|
900
|
+
check('the settings page accepts a token too', typed === true)
|
|
901
|
+
const asked = clickButton(ready, '写入并验证')
|
|
902
|
+
await rerenderNpm()
|
|
903
|
+
const after = await rerenderNpm()
|
|
904
|
+
check('the settings page posts the token to the Host', calls.includes('/npm-login'), calls.join(','))
|
|
905
|
+
check('the settings page confirms with the account', asked === true && textOf(after).includes('soh4c4759'), textOf(after).replace(/\s+/g, ' ').slice(0, 260))
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/* -- 13c. The page marks what it can verify, and only that ------------------ */
|
|
909
|
+
{
|
|
910
|
+
const signedIn = npmStatusValue({
|
|
911
|
+
auth: { state: 'signed-in', loggedIn: true, account: 'soh4c4759', message: null, npmrcPath: 'C:\\Users\\x\\.npmrc', npmrcReadable: true, npmrcHasToken: true },
|
|
912
|
+
repos: [npmFixture({ state: 'published', canPublish: false, blockers: ['already-published'], version: '1.0.1', latest: '1.0.1' })],
|
|
913
|
+
})
|
|
914
|
+
const page = await renderNpmPage(signedIn)
|
|
915
|
+
const text = textOf(page)
|
|
916
|
+
check('a confirmed account ticks the steps it proves', text.includes('✓ 已有账号 soh4c4759') && text.includes('✓ 已有可用的 token'), text.replace(/\s+/g, ' ').slice(0, 320))
|
|
917
|
+
check('a token line in .npmrc ticks step 3', text.includes('✓ C:\\Users\\x\\.npmrc 里已有凭据'))
|
|
918
|
+
check('the registry is named with its source', text.includes('源 https://registry.npmjs.org/'))
|
|
919
|
+
check('no message template leaks onto the page', text.includes('{registry}') === false && text.includes('{npmrc}') === false, text.replace(/\s+/g, ' ').slice(0, 260))
|
|
920
|
+
check('a published package is listed as published', text.includes('已在 npm 上'))
|
|
921
|
+
check('the page still states the 2FA step as unverifiable', text.includes('无法自动检测'))
|
|
922
|
+
|
|
923
|
+
const noAnswer = await renderNpmPage(null)
|
|
924
|
+
const noAnswerText = textOf(noAnswer)
|
|
925
|
+
check('an unanswered status is named, not guessed at', noAnswerText.includes('还没读到 npm 的状态'), noAnswerText.replace(/\s+/g, ' ').slice(0, 240))
|
|
926
|
+
check('an unanswered status ticks nothing', noAnswerText.includes('✓') === false)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/* -- 14. No npm answer means no npm claim ----------------------------------- */
|
|
930
|
+
{
|
|
931
|
+
const silent = await renderPanel([repoFixture()], baseStatus.value, {}, null)
|
|
932
|
+
const text = textOf(silent)
|
|
933
|
+
check('an unanswered npm status invents no state', text.includes('npm 待推') === false && text.includes('npm 上还没有') === false, text.replace(/\s+/g, ' ').slice(0, 180))
|
|
934
|
+
/* "No answer" is not "no credential": the four-step guide would claim this machine
|
|
935
|
+
has nothing to authenticate with, which nobody has said. */
|
|
936
|
+
check('an unanswered npm status does not claim there is no credential', text.includes('① 还没有 npm 账号?') === false)
|
|
937
|
+
const { tree } = await expandFirstRow(silent)
|
|
938
|
+
check('an unanswered npm status offers no push', hasButton(tree, '推送到 npm') === false)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/* -- 15. A failed push names the next step, not just the error --------------- */
|
|
942
|
+
{
|
|
943
|
+
const answers = { '/npm-publish': { ok: false, code: 'otp-required', message: 'npm ERR! code EOTP — this operation requires a one-time password', __status: 401 } }
|
|
944
|
+
const panel = await renderPanel([repoFixture()], baseStatus.value, answers, npmStatusValue())
|
|
945
|
+
const { tree } = await expandFirstRow(panel)
|
|
946
|
+
clickButton(tree, '推送到 npm v1.0.1')
|
|
947
|
+
clickButton(await rerender(), '确认推送')
|
|
948
|
+
await rerender()
|
|
949
|
+
const after = await rerender()
|
|
950
|
+
const text = textOf(after)
|
|
951
|
+
check('a one-time password demand becomes a next step', text.includes('要求一次性密码'), text.replace(/\s+/g, ' ').slice(0, 260))
|
|
952
|
+
check('the registry\'s own words are still shown', text.includes('EOTP'))
|
|
953
|
+
|
|
954
|
+
const forbidden = await renderPanel([repoFixture()], baseStatus.value, {
|
|
955
|
+
'/npm-publish': { ok: false, code: 'forbidden', message: 'npm ERR! 403 Forbidden - PUT … You do not have permission', __status: 401 },
|
|
956
|
+
}, npmStatusValue())
|
|
957
|
+
const { tree: forbiddenTree } = await expandFirstRow(forbidden)
|
|
958
|
+
clickButton(forbiddenTree, '推送到 npm v1.0.1')
|
|
959
|
+
clickButton(await rerender(), '确认推送')
|
|
960
|
+
await rerender()
|
|
961
|
+
const forbiddenText = textOf(await rerender())
|
|
962
|
+
check('a scope refusal points at the token\'s package scope', forbiddenText.includes('Packages 范围'), forbiddenText.replace(/\s+/g, ' ').slice(0, 260))
|
|
963
|
+
|
|
964
|
+
/* An unmapped code must print nothing rather than a dictionary key. */
|
|
965
|
+
const unmapped = await renderPanel([repoFixture()], baseStatus.value, {
|
|
966
|
+
'/npm-publish': { ok: false, code: 'something-new', message: 'a failure from a future version', __status: 502 },
|
|
967
|
+
}, npmStatusValue())
|
|
968
|
+
const { tree: unmappedTree } = await expandFirstRow(unmapped)
|
|
969
|
+
clickButton(unmappedTree, '推送到 npm v1.0.1')
|
|
970
|
+
clickButton(await rerender(), '确认推送')
|
|
971
|
+
await rerender()
|
|
972
|
+
const unmappedText = textOf(await rerender())
|
|
973
|
+
check('an unrecognised failure code prints no dictionary key', unmappedText.includes('npm.hint.') === false, unmappedText.replace(/\s+/g, ' ').slice(0, 220))
|
|
974
|
+
check('an unrecognised failure still reports the error', unmappedText.includes('a failure from a future version'))
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/* -- 15b. A credential whoami will not confirm is not "no credential" ------- */
|
|
978
|
+
{
|
|
979
|
+
/* The real shape: a granular token is package-scoped, so the user-level `whoami`
|
|
980
|
+
endpoint refuses it while `publish` would accept it. Showing the four-step guide
|
|
981
|
+
here would tell someone who just finished step 3 that they never started. */
|
|
982
|
+
const unconfirmed = npmStatusValue({
|
|
983
|
+
auth: { state: 'credential-present', loggedIn: false, account: null, message: '[ERR_PNPM_WHOAMI_UNAUTHORIZED] You must be logged in to use whoami', npmrcPath: 'C:\\Users\\x\\.npmrc', npmrcReadable: true, npmrcHasToken: true },
|
|
984
|
+
repos: [npmFixture()],
|
|
985
|
+
})
|
|
986
|
+
const panel = await renderPanel([repoFixture()], baseStatus.value, {}, unconfirmed)
|
|
987
|
+
const text = textOf(panel)
|
|
988
|
+
check('an unconfirmed credential is not treated as no credential', text.includes('已经有一行 token'), text.replace(/\s+/g, ' ').slice(0, 240))
|
|
989
|
+
check('an unconfirmed credential does not re-teach the four steps', text.includes('① 还没有 npm 账号?') === false)
|
|
990
|
+
check('an unconfirmed credential keeps the push available', text.includes('npm 待推 v1.0.1'))
|
|
991
|
+
const { tree } = await expandFirstRow(panel)
|
|
992
|
+
check('the push is offered with an unconfirmed credential', hasButton(tree, '推送到 npm v1.0.1'))
|
|
993
|
+
check('whoami\'s own refusal is still shown, not hidden', text.includes('ERR_PNPM_WHOAMI_UNAUTHORIZED'))
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/* -- 16. A refused token raises the checklist ------------------------------- */
|
|
997
|
+
{
|
|
998
|
+
const notSignedIn = npmStatusValue({
|
|
999
|
+
auth: { loggedIn: false, account: null, message: '[ERR_PNPM_WHOAMI_UNAUTHORIZED] You must be logged in to use whoami', npmrcPath: 'C:\\Users\\x\\.npmrc', npmrcReadable: true, npmrcHasToken: true },
|
|
1000
|
+
repos: [npmFixture({ canPublish: false, blockers: ['not-logged-in'] })],
|
|
1001
|
+
})
|
|
1002
|
+
const panel = await renderPanel([repoFixture()], baseStatus.value, {
|
|
1003
|
+
'/npm-login': { ok: false, code: 'token-rejected', message: 'the token was written to C:\\Users\\x\\.npmrc, but the registry did not accept it: ERR_PNPM_WHOAMI_UNAUTHORIZED', __status: 401 },
|
|
1004
|
+
}, notSignedIn)
|
|
1005
|
+
check('no checklist before a token was tried', textOf(panel).includes('这个 token 没有被接受') === false)
|
|
1006
|
+
typeInto(panel, 'npm_', 'npm_bogus_token')
|
|
1007
|
+
clickButton(await rerender(), '写入并验证')
|
|
1008
|
+
await rerender()
|
|
1009
|
+
const after = await rerender()
|
|
1010
|
+
const text = textOf(after)
|
|
1011
|
+
check('a refused token raises the checklist', text.includes('这个 token 没有被接受'), text.replace(/\s+/g, ' ').slice(0, 300))
|
|
1012
|
+
check('the checklist names the 90-day limit among its causes', text.includes('90 天有效期'))
|
|
1013
|
+
check('the refusal does not leak what was typed back into the page', text.includes('npm_bogus_token') === false)
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/* -- 17. The Bilibili update note: bound, previewed, then posted ------------- */
|
|
1017
|
+
|
|
1018
|
+
/** One `/bilibili-status` repository entry, as the Host sends it. */
|
|
1019
|
+
function biliFixture(overrides) {
|
|
1020
|
+
return {
|
|
1021
|
+
repo: 'dsh-plugin-restart',
|
|
1022
|
+
label: 'dsh-plugin-restart',
|
|
1023
|
+
binding: { bvid: 'BV1RopP6FEJp', auto: true },
|
|
1024
|
+
announced: [],
|
|
1025
|
+
failures: [],
|
|
1026
|
+
baseline: { tag: 'v1.0.0', at: '2026-10-06T19:00:19Z' },
|
|
1027
|
+
video: { ok: true, aid: 117396371216915, title: '一支视频', owner: 'UP' },
|
|
1028
|
+
commentUrl: 'https://www.bilibili.com/video/BV1RopP6FEJp/',
|
|
1029
|
+
...overrides,
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** The whole payload, including the credential the settings page reads. */
|
|
1034
|
+
function biliValue(overrides) {
|
|
1035
|
+
return {
|
|
1036
|
+
enabled: true,
|
|
1037
|
+
auto: true,
|
|
1038
|
+
watchSeconds: 90,
|
|
1039
|
+
template: '',
|
|
1040
|
+
credential: {
|
|
1041
|
+
state: 'ready',
|
|
1042
|
+
message: '',
|
|
1043
|
+
source: 'plugin',
|
|
1044
|
+
path: 'C:\\Users\\x\\.dsh\\dsh-plugin-cicd\\bilibili-cookies.json',
|
|
1045
|
+
ownPath: 'C:\\Users\\x\\.dsh\\dsh-plugin-cicd\\bilibili-cookies.json',
|
|
1046
|
+
configuredPath: '',
|
|
1047
|
+
platform: '',
|
|
1048
|
+
expiresAt: null,
|
|
1049
|
+
hasSession: true,
|
|
1050
|
+
hasCsrf: true,
|
|
1051
|
+
account: { mid: '42', uname: '白衣为卿曲' },
|
|
1052
|
+
},
|
|
1053
|
+
login: { state: 'idle', url: '', startedAt: null, expiresAt: null, scanned: false, message: '' },
|
|
1054
|
+
ledger: { file: 'C:\\Users\\x\\.dsh\\dsh-plugin-cicd\\bilibili-announcements.json', problem: null, entries: 1 },
|
|
1055
|
+
lastSweep: { at: '2026-10-07T01:00:00Z', reason: 'timer', results: [{ repo: 'dsh-plugin-restart', tag: 'v1.0.0', state: 'announced', message: '', url: null }] },
|
|
1056
|
+
repos: [biliFixture()],
|
|
1057
|
+
...overrides,
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/* The third segment, reached the way a person reaches it. This is also the check that
|
|
1062
|
+
the Bilibili page survives being mounted as a child of the console's shell rather
|
|
1063
|
+
than as a slot occupant: it reads the same `t` prop and nothing else. */
|
|
1064
|
+
{
|
|
1065
|
+
fixture = {
|
|
1066
|
+
'/status': { ok: true, value: baseStatus.value },
|
|
1067
|
+
'/bilibili-status': { ok: true, value: biliValue() },
|
|
1068
|
+
}
|
|
1069
|
+
values.clear()
|
|
1070
|
+
effectSlots.clear()
|
|
1071
|
+
calls.length = 0
|
|
1072
|
+
const onBilibili = await renderSettings('bilibili')
|
|
1073
|
+
check('the Bilibili segment draws the update-note page', hasButton(onBilibili, '登录 B 站') || hasButton(onBilibili, '退出 B 站登录'), textOf(onBilibili).replace(/\s+/g, ' ').slice(0, 220))
|
|
1074
|
+
check('the Bilibili probe happens only once its segment is opened', calls.includes('/bilibili-status') === true, calls.join(','))
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* The value of the first input whose placeholder contains this text.
|
|
1079
|
+
*
|
|
1080
|
+
* A binding is edited in a text field, and a field's value is not part of the
|
|
1081
|
+
* rendered text — so a test that only reads text cannot tell a prefilled field
|
|
1082
|
+
* from an empty one.
|
|
1083
|
+
*/
|
|
1084
|
+
function inputValue(node, placeholderPart) {
|
|
1085
|
+
if (node === null || typeof node !== 'object') return null
|
|
1086
|
+
if (Array.isArray(node)) {
|
|
1087
|
+
for (const child of node) {
|
|
1088
|
+
const found = inputValue(child, placeholderPart)
|
|
1089
|
+
if (found !== null) return found
|
|
1090
|
+
}
|
|
1091
|
+
return null
|
|
1092
|
+
}
|
|
1093
|
+
if (node.type === 'input' && String(node.props?.placeholder ?? '').includes(placeholderPart)) return String(node.props?.value ?? '')
|
|
1094
|
+
return inputValue(node.children, placeholderPart)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
{
|
|
1098
|
+
/* A published release the note has not gone out for: the row has to say so
|
|
1099
|
+
before anything is clicked, because that is the state the sweep acts on. */
|
|
1100
|
+
const panel = await renderPanel([repoFixture({ publishedTag: 'v1.0.1' })], baseStatus.value, {
|
|
1101
|
+
'/bilibili-status': { ok: true, value: biliValue() },
|
|
1102
|
+
'/bilibili-announce': { ok: true, value: previewFixture() },
|
|
1103
|
+
})
|
|
1104
|
+
check('a published release waiting on its note is flagged', textOf(panel).includes('待播报 v1.0.1'), textOf(panel).replace(/\s+/g, ' ').slice(0, 220))
|
|
1105
|
+
|
|
1106
|
+
const { tree } = await expandFirstRow(panel)
|
|
1107
|
+
const rowText = textOf(tree)
|
|
1108
|
+
check('the row names the video it is bound to', rowText.includes('绑定的视频') && rowText.includes('一支视频'), rowText.replace(/\s+/g, ' ').slice(-320))
|
|
1109
|
+
check('the binding is prefilled, so editing it does not start from nothing', inputValue(tree, 'BV') === 'BV1RopP6FEJp', inputValue(tree, 'BV'))
|
|
1110
|
+
check('the row offers the manual announcement', hasButton(tree, '发更新评论'))
|
|
1111
|
+
|
|
1112
|
+
/* The preview is the Host's own composition — the sentence shown is the sentence
|
|
1113
|
+
that would be sent, which is why it is asked for rather than built here. */
|
|
1114
|
+
clickButton(tree, '发更新评论')
|
|
1115
|
+
const previewed = await rerender()
|
|
1116
|
+
check('the preview shows the exact comment', textOf(previewed).includes('【更新 v1.0.1】新增 B 站更新播报'), textOf(previewed).replace(/\s+/g, ' ').slice(0, 260))
|
|
1117
|
+
check('the preview asks before posting', hasButton(previewed, '确认发送'))
|
|
1118
|
+
|
|
1119
|
+
clickButton(previewed, '确认发送')
|
|
1120
|
+
const after = await rerender()
|
|
1121
|
+
check('the post is sent with the previewed text', calls.includes('/bilibili-announce'), calls.join(','))
|
|
1122
|
+
check('posting reports where it went', textOf(after).includes('已发送到 BV1RopP6FEJp'), textOf(after).replace(/\s+/g, ' ').slice(0, 260))
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** What a dry run answers: the sentence, and the Host's verdict on it. */
|
|
1126
|
+
function previewFixture(overrides) {
|
|
1127
|
+
return {
|
|
1128
|
+
repo: 'dsh-plugin-restart',
|
|
1129
|
+
bvid: 'BV1RopP6FEJp',
|
|
1130
|
+
tag: 'v1.0.1',
|
|
1131
|
+
state: 'ready',
|
|
1132
|
+
message: '',
|
|
1133
|
+
text: '【更新 v1.0.1】新增 B 站更新播报',
|
|
1134
|
+
summary: '新增 B 站更新播报',
|
|
1135
|
+
unknown: [],
|
|
1136
|
+
release: { tag: 'v1.0.1', name: '新增 B 站更新播报', url: 'https://example.invalid/v1.0.1', createdAt: '2026-10-07T00:00:00Z' },
|
|
1137
|
+
ledgerProblem: null,
|
|
1138
|
+
attempts: 0,
|
|
1139
|
+
...overrides,
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
{
|
|
1144
|
+
/* The failure this whole feature is shaped around: the credential biliup writes
|
|
1145
|
+
by default is an APP login, and calling that "not signed in" sends the reader
|
|
1146
|
+
to re-login in the wrong place. */
|
|
1147
|
+
const panel = await renderPanel([repoFixture()], baseStatus.value, {
|
|
1148
|
+
'/bilibili-status': {
|
|
1149
|
+
ok: true,
|
|
1150
|
+
value: biliValue({
|
|
1151
|
+
credential: {
|
|
1152
|
+
...biliValue().credential,
|
|
1153
|
+
state: 'not-logged-in',
|
|
1154
|
+
account: null,
|
|
1155
|
+
platform: 'BiliTV',
|
|
1156
|
+
message: '这份凭据是 BiliTV 登录(APP/TV),B 站 Web 会员接口回 -101——发评论走的是 Web 接口,所以它发不了。',
|
|
1157
|
+
},
|
|
1158
|
+
}),
|
|
1159
|
+
},
|
|
1160
|
+
})
|
|
1161
|
+
const text = textOf(panel)
|
|
1162
|
+
check('a bound video with an unusable credential is raised in the panel', text.includes('更新评论发不出去'))
|
|
1163
|
+
check('the panel explains the APP credential rather than saying "not signed in"', text.includes('BiliTV'))
|
|
1164
|
+
check('the sign-in is offered right there', hasButton(panel, '登录 B 站'))
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/* -- 18. 提交, 构建, 发布 — the row in the order the work happens -------------
|
|
1168
|
+
Both of the other two act on what is on GitHub: a release builds the pushed commit
|
|
1169
|
+
and a build runs it, so neither can see work that is still only on this disk. 提交
|
|
1170
|
+
is the step that makes the other two able to see it, which is why it comes first and
|
|
1171
|
+
why it is never the primary button. */
|
|
1172
|
+
{
|
|
1173
|
+
/** The button labels of a tree, in the order they are drawn. */
|
|
1174
|
+
function buttonTexts(node, out = []) {
|
|
1175
|
+
if (node === null || typeof node !== 'object') return out
|
|
1176
|
+
if (Array.isArray(node)) {
|
|
1177
|
+
for (const child of node) buttonTexts(child, out)
|
|
1178
|
+
return out
|
|
1179
|
+
}
|
|
1180
|
+
if (node.type === 'button') out.push(textOf(node).trim())
|
|
1181
|
+
buttonTexts(node.children, out)
|
|
1182
|
+
return out
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** The first button carrying this label, so its props can be read. */
|
|
1186
|
+
function findButton(node, label) {
|
|
1187
|
+
if (node === null || typeof node !== 'object') return null
|
|
1188
|
+
if (Array.isArray(node)) {
|
|
1189
|
+
for (const child of node) {
|
|
1190
|
+
const found = findButton(child, label)
|
|
1191
|
+
if (found !== null) return found
|
|
1192
|
+
}
|
|
1193
|
+
return null
|
|
1194
|
+
}
|
|
1195
|
+
if (node.type === 'button' && textOf(node).includes(label)) return node
|
|
1196
|
+
return findButton(node.children, label)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/* A checkout with two changes waiting: the state the button exists for. */
|
|
1200
|
+
const dirtyPanel = await renderPanel([repoFixture({ local: { ...repoFixture().local, dirty: 2, ahead: 0, files: ['package.json', 'README.md'] } })])
|
|
1201
|
+
const labels = buttonTexts(dirtyPanel)
|
|
1202
|
+
const at = (label) => labels.findIndex((text) => text.includes(label))
|
|
1203
|
+
check('the row offers 提交', at('提交') !== -1, labels.join(' | '))
|
|
1204
|
+
check('提交 comes before 构建', at('提交') !== -1 && at('构建') !== -1 && at('提交') < at('构建'), labels.join(' | '))
|
|
1205
|
+
check('构建 comes before 发布', at('构建') !== -1 && at('发布') !== -1 && at('构建') < at('发布'), labels.join(' | '))
|
|
1206
|
+
check('提交 is not the primary button', findButton(dirtyPanel, '提交')?.props?.kind === undefined, String(findButton(dirtyPanel, '提交')?.props?.kind))
|
|
1207
|
+
|
|
1208
|
+
const { tree: dirtyOpen } = await expandFirstRow(dirtyPanel)
|
|
1209
|
+
check('the row explains what 提交 would commit', findButton(dirtyOpen, '提交')?.props?.title === '将提交 2 个改动:', String(findButton(dirtyOpen, '提交')?.props?.title))
|
|
1210
|
+
const asked = clickButton(dirtyOpen, '提交')
|
|
1211
|
+
const confirm = await rerender()
|
|
1212
|
+
/* `git add -A` is the one action here that can sweep in a file the author never meant
|
|
1213
|
+
to publish, so the names are on screen before the button, not after it. */
|
|
1214
|
+
check('the confirmation names the files that would be committed', textOf(confirm).includes('package.json') && textOf(confirm).includes('README.md'), textOf(confirm).replace(/\s+/g, ' ').slice(-260))
|
|
1215
|
+
/* Found by its placeholder: a placeholder is a prop, not text, so the message field
|
|
1216
|
+
is invisible to every text assertion in this file. */
|
|
1217
|
+
check('the confirmation asks for a message', inputValue(confirm, '提交信息') === '', String(inputValue(confirm, '提交信息')))
|
|
1218
|
+
check('the commit button is held until there is a message', findButton(confirm, '确认提交')?.props?.disabled === true, String(findButton(confirm, '确认提交')?.props?.disabled))
|
|
1219
|
+
check('asking to commit found its button', asked === true)
|
|
1220
|
+
|
|
1221
|
+
typeInto(confirm, '提交信息', 'feat: notes')
|
|
1222
|
+
const typed = await rerender()
|
|
1223
|
+
check('a message releases the commit button', findButton(typed, '确认提交')?.props?.disabled === false, String(findButton(typed, '确认提交')?.props?.disabled))
|
|
1224
|
+
|
|
1225
|
+
fixture['/commit'] = { ok: true, value: { repo: 'dsh-plugin-restart', branch: 'main', committed: true, commit: 'a'.repeat(40), files: ['package.json', 'README.md'], carried: 0 } }
|
|
1226
|
+
clickButton(typed, '确认提交')
|
|
1227
|
+
const afterCommit = await rerender()
|
|
1228
|
+
check('the commit is sent to the Host', calls.includes('/commit'), calls.join(','))
|
|
1229
|
+
check('the panel says what it committed, and where', textOf(afterCommit).includes('已提交并推送到 main'), textOf(afterCommit).replace(/\s+/g, ' ').slice(-240))
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/* The other half of the same trap: the work is committed and has never left this
|
|
1233
|
+
machine, which a release cannot see either. The button becomes 推送. */
|
|
1234
|
+
{
|
|
1235
|
+
const aheadPanel = await renderPanel([repoFixture({ local: { ...repoFixture().local, dirty: 0, ahead: 3 } })])
|
|
1236
|
+
check('a clean but unpushed branch offers 推送 instead', hasButton(aheadPanel, '推送') === true, textOf(aheadPanel).replace(/\s+/g, ' ').slice(0, 200))
|
|
1237
|
+
|
|
1238
|
+
const { tree } = await expandFirstRow(aheadPanel)
|
|
1239
|
+
clickButton(tree, '推送')
|
|
1240
|
+
const confirm = await rerender()
|
|
1241
|
+
check('the push confirmation counts the commits and names the branch', textOf(confirm).includes('把 3 个本地提交推送到 main?'), textOf(confirm).replace(/\s+/g, ' ').slice(-200))
|
|
1242
|
+
check('pushing needs no message', hasButton(confirm, '确认推送') === true)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/* Nothing to do: the button is there, disabled, and says why — rather than offering a
|
|
1246
|
+
call whose only outcome is a refusal. */
|
|
1247
|
+
{
|
|
1248
|
+
const cleanPanel = await renderPanel([repoFixture({ local: { ...repoFixture().local, dirty: 0, ahead: 0 } })])
|
|
1249
|
+
const button = (function find(node) {
|
|
1250
|
+
if (node === null || typeof node !== 'object') return null
|
|
1251
|
+
if (Array.isArray(node)) {
|
|
1252
|
+
for (const child of node) {
|
|
1253
|
+
const found = find(child)
|
|
1254
|
+
if (found !== null) return found
|
|
1255
|
+
}
|
|
1256
|
+
return null
|
|
1257
|
+
}
|
|
1258
|
+
if (node.type === 'button' && textOf(node).includes('提交')) return node
|
|
1259
|
+
return find(node.children)
|
|
1260
|
+
})(cleanPanel)
|
|
1261
|
+
|
|
1262
|
+
check('a clean, in-sync checkout cannot be committed', button?.props?.disabled === true, String(button?.props?.disabled))
|
|
1263
|
+
check('and the button says why', String(button?.props?.title ?? '').includes('没有可提交或推送的东西'), String(button?.props?.title))
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
console.log(`\n${results.length - failed}/${results.length} checks passed`)
|
|
1267
|
+
if (failed > 0) process.exit(1)
|