dsh-plugin-cicd 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,8 @@ import {
40
40
  parseLedger,
41
41
  recordLedgerEntry,
42
42
  replyFailure,
43
+ MAINTENANCE_NOTE,
44
+ summarizeCommits,
43
45
  summarizeRelease,
44
46
  withDeviceIds,
45
47
  } from '../lib/bilibili.mjs'
@@ -108,6 +110,56 @@ check('GitHub\'s own boilerplate is not the summary', autoNotes === 'Fix the thi
108
110
  check('an empty release still says something', summarizeRelease({ tag: 'v3' }, { fallback: '本次更新已发布。' }) === '本次更新已发布。')
109
111
  check('a long summary is cut, not dumped', summarizeRelease({ tag: 'v1', name: 'x'.repeat(300) }).length <= 90)
110
112
 
113
+ /* What changed, out of the commits between two releases.
114
+ This is the ONLY source for these repositories: their release bodies are exactly
115
+ `**Full Changelog**: <url>`, because the release workflow creates them with no notes.
116
+ So "介绍这次更新" has to come from the history, and the filtering is what decides
117
+ whether a viewer reads news or bookkeeping. */
118
+ const commit = (message) => ({ commit: { message } })
119
+ check('commit subjects become the summary', summarizeCommits([commit('feat: 新增语音'), commit('fix: 音量')]) === '新功能:新增语音;修复:音量')
120
+ check('the type becomes a word a viewer reads', summarizeCommits([commit('fix: 音量')]) === '修复:音量' && summarizeCommits([commit('perf: 启动更快')]) === '性能:启动更快')
121
+ check('merge commits are bookkeeping, not news', summarizeCommits([commit('Merge pull request #7 from o/r'), commit('feat: a')]) === '新功能:a')
122
+ check('a release chore is not news either', summarizeCommits([commit('chore(release): v1.0.1'), commit('feat: a')]) === '新功能:a')
123
+ check('a build tweak is not news, which is most of these histories', summarizeCommits([commit('ci: also publish the installable tarball'), commit('feat: a')]) === '新功能:a')
124
+ /* It put "文档:document the release procedure" FIRST in a comment under a demo video
125
+ before this rule: the reader came for the feature list. */
126
+ check('release documentation is not news either', summarizeCommits([commit('docs: document the release procedure'), commit('fix: a')]) === '修复:a')
127
+ check('a skip-ci marker is not news', summarizeCommits([commit('test: x [skip ci]'), commit('feat: a')]) === '新功能:a')
128
+ check('only the first line of a message is used', summarizeCommits([commit('feat: a\n\nlong body nobody reads')]) === '新功能:a')
129
+ check('the same subject is said once', summarizeCommits([commit('feat: a'), commit('feat: a'), commit('fix: b')]) === '新功能:a;修复:b')
130
+ check('a pull-request number tail is stripped', summarizeCommits([commit('fix: a (#12)')]) === '修复:a')
131
+ /* Order is a decision, and this one was made from a real comment: the compare API hands
132
+ commits oldest first, and taking the first three put a release's EARLIEST work in the
133
+ note — for v0.5.0, three npm details, while 提交, the Bilibili note and the one-page
134
+ settings were cut off the end. A version culminates in what it is for. */
135
+ check('a feature outranks a fix, even when the fix is newer', summarizeCommits([commit('feat: a'), commit('fix: b')]) === '新功能:a;修复:b')
136
+ check('within a kind, the newest comes first', summarizeCommits([commit('feat: older'), commit('feat: newer')]) === '新功能:newer;新功能:older')
137
+ check('three at most, because this is a comment box', summarizeCommits([commit('feat: 1'), commit('fix: 2'), commit('perf: 3'), commit('refactor: 4')]) === '性能:3;新功能:1;重构:4')
138
+ /* A type the repository invented is still a type: the scope belongs to its conventions,
139
+ not to the viewer reading a comment. */
140
+ check('an unknown type loses its prefix rather than showing it', summarizeCommits([commit('polish(bilibili): size the code')]) === 'size the code')
141
+ check('and is not mistaken for bookkeeping', summarizeCommits([commit('polish: tidy the row')]) === 'tidy the row')
142
+ check('a subject in another language is passed through untouched', summarizeCommits([commit('fix: 音量包了一层')]) === '修复:音量包了一层')
143
+ check('no history at all is an empty string, so the caller can fall back', summarizeCommits([]) === '' && summarizeCommits(null) === '')
144
+ check('a range that is all bookkeeping says so instead of nothing', summarizeCommits([commit('ci: a'), commit('chore(release): v1')]) === MAINTENANCE_NOTE)
145
+ check('plain strings work too, which is what the ledger already holds', summarizeCommits(['feat: a']) === '新功能:a')
146
+
147
+ const silentRelease = { tag: 'v1.0.1', name: 'v1.0.1', body: '**Full Changelog**: https://github.com/o/r/compare/v1.0.0...v1.0.1' }
148
+ const withCommits = summarizeRelease(silentRelease, { commits: [commit('feat: 这次真的改了东西')] })
149
+ check('a release that says nothing falls back to the commits', withCommits === '新功能:这次真的改了东西', withCommits)
150
+ check('a release that says something still wins over the commits', summarizeRelease({ tag: 'v1', name: 'v1', body: '手写的更新说明' }, { commits: [commit('feat: x')] }) === '手写的更新说明')
151
+ /* The release workflow names every release `<repo> <tag>`, so that name is the tag
152
+ repeated — treating it as a description is what made every comment read
153
+ "【更新 v1.0.1】dsh-plugin-restart v1.0.1" and hid the changes behind it. */
154
+ const namedLikeTheWorkflow = summarizeRelease({ tag: 'v1.0.1', name: 'dsh-plugin-restart v1.0.1', body: '' }, { names: ['dsh-plugin-restart'], commits: [commit('fix: 真的修了东西')] })
155
+ check('a release named after its own repo and tag is not a description', namedLikeTheWorkflow === '修复:真的修了东西', namedLikeTheWorkflow)
156
+ check('nor is the same name with a colon', summarizeRelease({ tag: 'v0.1.1', name: 'dsh-ui-sound: v0.1.1', body: '' }, { names: ['dsh-ui-sound'], commits: [commit('fix: x')] }) === '修复:x')
157
+ check('but a real title still wins', summarizeRelease({ tag: 'v1', name: '发布台:B 站更新播报', body: '' }, { names: ['dsh-plugin-cicd'], commits: [commit('feat: x')] }) === '发布台:B 站更新播报')
158
+
159
+ const changesOnly = composeComment({ tag: 'v2', release: silentRelease, template: '【{tag}】本版更新:{changes}', commits: [commit('feat: a'), commit('fix: b')] })
160
+ check('{changes} is available to a template on its own', changesOnly.text === '【v2】本版更新:新功能:a;修复:b', changesOnly.text)
161
+ check('and an unknown placeholder is still reported, not invented', changesOnly.unknown.length === 0)
162
+
111
163
  const composed = composeComment({
112
164
  label: '发布台',
113
165
  repo: 'dsh-plugin-cicd',
@@ -347,6 +347,85 @@ async function callRoutes(routes, path, body) {
347
347
  check('a repository with no checkout is refused here too', answer.status === 400 && answer.payload?.code === 'no-checkout', `${String(answer.status)} ${String(answer.payload?.code)}`)
348
348
  }
349
349
 
350
+ /* -- 9. 克隆: a registered repository with no working tree ------------------- */
351
+ /*
352
+ * Cloned from the fixture's own bare origin, so this is a real `git clone` with no
353
+ * network involved. It is the step that makes a registered repository usable at all:
354
+ * every action the console offers needs a working tree, and the address is derived
355
+ * rather than demanded.
356
+ */
357
+ {
358
+ const checkout = makeCheckout()
359
+ const root = mkdtempSync(join(scratch, 'projects-'))
360
+ const configFile = join(scratch, 'repos-clone.json')
361
+ writeFileSync(configFile, JSON.stringify({
362
+ owner: 'octocat',
363
+ projectsRoot: root,
364
+ repos: [{ repo: 'octocat/fixture' }],
365
+ }), 'utf8')
366
+ const routes = new Map()
367
+ module.apply({
368
+ effect: (fn) => fn,
369
+ logger: { info: () => {} },
370
+ webServer: { register: ({ path, handler }) => { routes.set(path, handler); return () => {} } },
371
+ /* `projectsRoot` is ROW config, not a key of the managed file: writing it into
372
+ repos.json would look right and be ignored. */
373
+ }, { owner: 'octocat', configFile, projectsRoot: root })
374
+
375
+ const target = join(root, 'fixture')
376
+ const answer = await callRoutes(routes, '/api/dsh-cicd/clone', { repo: 'octocat/fixture', url: checkout.origin })
377
+ check('a repository with no checkout can be cloned', answer.status === 200 && answer.payload?.ok === true, JSON.stringify(answer.payload?.value ?? answer.payload))
378
+ check('it lands where the console looks for a checkout next', answer.payload?.value?.path === target, String(answer.payload?.value?.path))
379
+ check('the working tree really is a checkout', readFileSync(join(target, 'package.json'), 'utf8').includes('"fixture"'))
380
+ check('and the branch came with it', git(target, ['rev-parse', '--abbrev-ref', 'HEAD']) === 'main')
381
+ check('the config now records where it is', JSON.parse(readFileSync(configFile, 'utf8')).repos[0].localPath === target)
382
+
383
+ /* Idempotence matters more than it looks: this button sits next to 提交, and a second
384
+ press must not make a second copy of the repository somewhere else. */
385
+ const again = await callRoutes(routes, '/api/dsh-cicd/clone', { repo: 'octocat/fixture', url: checkout.origin })
386
+ check('cloning over an existing checkout is refused', again.status === 409 && again.payload?.code === 'already-cloned', `${String(again.status)} ${String(again.payload?.code)}`)
387
+
388
+ /* A second repository, never cloned, for the refusals that only make sense before a
389
+ working tree exists — `already-cloned` wins over everything else by design, since
390
+ with a checkout in place the rest of the request is moot. */
391
+ const configFile2 = join(scratch, 'repos-clone2.json')
392
+ writeFileSync(configFile2, JSON.stringify({ owner: 'octocat', repos: [{ repo: 'octocat/other' }] }), 'utf8')
393
+ const routes2 = new Map()
394
+ module.apply({
395
+ effect: (fn) => fn,
396
+ logger: { info: () => {} },
397
+ webServer: { register: ({ path, handler }) => { routes2.set(path, handler); return () => {} } },
398
+ }, { owner: 'octocat', configFile: configFile2, projectsRoot: root })
399
+
400
+ /* An address that is not an address is refused by name — before git is asked to fetch
401
+ something that could never be a repository. */
402
+ const nonsense = await callRoutes(routes2, '/api/dsh-cicd/clone', { repo: 'octocat/other', url: 'not a url' })
403
+ check('a nonsense address is refused by name', nonsense.status === 400 && nonsense.payload?.code === 'bad-url', `${String(nonsense.status)} ${String(nonsense.payload?.code)}`)
404
+
405
+ /* A clone that fails reports git's own reason and the address it tried. A local path
406
+ that does not exist, on purpose: the alternative is a test that reaches for
407
+ github.com, which would make CI depend on the network and on a repository that is
408
+ not this one. */
409
+ const missing = await callRoutes(routes2, '/api/dsh-cicd/clone', { repo: 'octocat/other', url: join(scratch, 'no-such-origin.git') })
410
+ check('a clone that fails says so, with git\'s own words', missing.status === 502 && missing.payload?.code === 'clone-failed', `${String(missing.status)} ${String(missing.payload?.code)}`)
411
+ check('and names the address it tried', String(missing.payload?.value?.url ?? '').includes('no-such-origin.git'), String(missing.payload?.value?.url))
412
+ check('nothing was recorded for a clone that did not happen', JSON.parse(readFileSync(configFile2, 'utf8')).repos[0].localPath === undefined)
413
+ }
414
+
415
+ /* A repository with nowhere to land says so, rather than failing obscurely. */
416
+ {
417
+ const configFile = join(scratch, 'repos-clone3.json')
418
+ writeFileSync(configFile, JSON.stringify({ owner: 'octocat', repos: [{ repo: 'octocat/fixture' }] }), 'utf8')
419
+ const routes = new Map()
420
+ module.apply({
421
+ effect: (fn) => fn,
422
+ logger: { info: () => {} },
423
+ webServer: { register: ({ path, handler }) => { routes.set(path, handler); return () => {} } },
424
+ }, { owner: 'octocat', configFile })
425
+ const answer = await callRoutes(routes, '/api/dsh-cicd/clone', { repo: 'octocat/fixture' })
426
+ check('no projectsRoot is a named refusal, not a mystery', answer.status === 409 && answer.payload?.code === 'no-projects-root', `${String(answer.status)} ${String(answer.payload?.code)}`)
427
+ }
428
+
350
429
  await new Promise((settle) => { rmSync(scratch, { recursive: true, force: true }); settle() })
351
430
  console.log(`\n${results.length - failed}/${results.length} checks passed`)
352
431
  if (failed > 0) process.exit(1)
@@ -1106,7 +1106,13 @@ function inputValue(node, placeholderPart) {
1106
1106
  const { tree } = await expandFirstRow(panel)
1107
1107
  const rowText = textOf(tree)
1108
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'))
1109
+ /* A bound video is a STATE, not a draft. Editing it in place would change which video
1110
+ a repository announces under while the announcement history still points at the old
1111
+ one, so the field is gone once something is bound and the only action left is 解绑. */
1112
+ check('a bound video is shown, not offered for editing', textOf(tree).includes('BV1RopP6FEJp') && inputValue(tree, 'BV') === null, String(inputValue(tree, 'BV')))
1113
+ check('and the way to change it is spelled out', textOf(tree).includes('要换视频先【解绑】'))
1114
+ check('binding again is not on offer', hasButton(tree, '绑定') === false, textOf(tree).replace(/\s+/g, ' ').slice(-200))
1115
+ check('unbinding is', hasButton(tree, '解绑'))
1110
1116
  check('the row offers the manual announcement', hasButton(tree, '发更新评论'))
1111
1117
 
1112
1118
  /* The preview is the Host's own composition — the sentence shown is the sentence
@@ -1263,5 +1269,215 @@ function previewFixture(overrides) {
1263
1269
  check('and the button says why', String(button?.props?.title ?? '').includes('没有可提交或推送的东西'), String(button?.props?.title))
1264
1270
  }
1265
1271
 
1272
+ /* -- 19. The sign-in code is drawn, not merely linked -------------------------
1273
+ The URL the sign-in hands back exists to be SCANNED, and the panel used to answer it
1274
+ with a link while its own hint said "scan it with the phone" — nothing on screen to
1275
+ scan. So the encoder that draws it is checked module for module against a matrix a
1276
+ reference implementation produced, frozen here as a hash and one full row. A single
1277
+ flipped module changes both: a code that looks right and does not scan is worse than
1278
+ no code, because the person holding the phone is the one who finds out.
1279
+ (The reference — `qrcode`, which another package in this profile happens to depend
1280
+ on — is NOT a dependency of this plugin: it was the oracle while the encoder was
1281
+ written, and what it produced is what is frozen below.) */
1282
+ {
1283
+ /** The first `<svg>` in a tree, which is the code. */
1284
+ function findSvg(node) {
1285
+ if (node === null || typeof node !== 'object') return null
1286
+ if (Array.isArray(node)) {
1287
+ for (const child of node) {
1288
+ const found = findSvg(child)
1289
+ if (found !== null) return found
1290
+ }
1291
+ return null
1292
+ }
1293
+ if (node.type === 'svg') return node
1294
+ return findSvg(node.children)
1295
+ }
1296
+
1297
+ /** FNV-1a over the modules, row by row. */
1298
+ function moduleHash(grid) {
1299
+ let hash = 0x811c9dc5
1300
+ for (const row of grid) {
1301
+ for (const value of row) {
1302
+ hash ^= value === 1 ? 49 : 48
1303
+ hash = Math.imul(hash, 0x01000193) >>> 0
1304
+ }
1305
+ }
1306
+ return hash
1307
+ }
1308
+
1309
+ const rowText = (grid, index) => Array.from(grid[index]).join('')
1310
+
1311
+ /* Signed in — which the fixture is by default. Every control that exists to OBTAIN a
1312
+ credential must be gone: the page said 已登录:<name> and then, directly under it,
1313
+ invited the reader to sign in again. The block was gated on "is a sign-in running"
1314
+ and never on the credential, so a successful sign-in changed the status line and
1315
+ nothing else. */
1316
+ fixture = {
1317
+ '/status': { ok: true, value: baseStatus.value },
1318
+ '/bilibili-status': { ok: true, value: biliValue() },
1319
+ }
1320
+ values.clear()
1321
+ effectSlots.clear()
1322
+ calls.length = 0
1323
+ const signedIn = await renderSettings('bilibili')
1324
+ check('a signed-in page says who is signed in', textOf(signedIn).includes('已登录 白衣为卿曲'), textOf(signedIn).replace(/\s+/g, ' ').slice(0, 200))
1325
+ check('and offers no sign-in prompt', hasButton(signedIn, '登录 B 站') === false, textOf(signedIn).replace(/\s+/g, ' ').slice(0, 240))
1326
+ check('and no hint inviting a scan', textOf(signedIn).includes('点一下会生成一个二维码') === false)
1327
+ check('and no paste field, which is another way to obtain one', inputValue(signedIn, 'SESSDATA') === null && hasButton(signedIn, '保存并验证') === false)
1328
+ check('the one action left is to stop being signed in', hasButton(signedIn, '退出 B 站登录'))
1329
+
1330
+ const url = 'https://account.bilibili.com/h5/account-h5/auth/scan-web?navhide=1&callback=close&qrcode_key=fda852ed16a061fae96dd764c797168f&from='
1331
+ const signedOut = {
1332
+ ...biliValue().credential,
1333
+ state: 'none',
1334
+ account: null,
1335
+ hasSession: false,
1336
+ hasCsrf: false,
1337
+ message: '',
1338
+ }
1339
+ fixture = {
1340
+ '/status': { ok: true, value: baseStatus.value },
1341
+ '/bilibili-status': {
1342
+ ok: true,
1343
+ value: biliValue({
1344
+ credential: signedOut,
1345
+ login: { state: 'waiting', url, startedAt: '2026-10-08T01:00:00Z', expiresAt: '2026-10-08T01:03:00Z', scanned: false, message: '' },
1346
+ }),
1347
+ },
1348
+ }
1349
+ values.clear()
1350
+ effectSlots.clear()
1351
+ calls.length = 0
1352
+ const waiting = await renderSettings('bilibili')
1353
+ const svg = findSvg(waiting)
1354
+
1355
+ check('a waiting sign-in draws something to scan', svg !== null, textOf(waiting).replace(/\s+/g, ' ').slice(0, 200))
1356
+ if (svg !== null) {
1357
+ const size = Number(String(svg.props?.viewBox ?? '').split(' ')[2])
1358
+ const grid = Array.from({ length: size }, () => new Uint8Array(size))
1359
+ for (const rect of svg.children ?? []) {
1360
+ const x = Number(rect.props?.x)
1361
+ const y = Number(rect.props?.y)
1362
+ const width = Number(rect.props?.width)
1363
+ for (let k = 0; k < width; k += 1) grid[y][x + k] = 1
1364
+ }
1365
+ check('the code is the version the reference chose', size === 49, String(size))
1366
+ check('every module matches the reference', moduleHash(grid) === 2409592449, String(moduleHash(grid)))
1367
+ check('the first row matches as well, so a hash collision cannot hide a difference', rowText(grid, 0) === '1111111000101110111010010111110011011100101111111', rowText(grid, 0))
1368
+ check('the code is announced as an image, for a screen reader', svg.props?.role === 'img' && svg.props?.['aria-label'] === 'B 站登录二维码', String(svg.props?.['aria-label']))
1369
+ }
1370
+
1371
+ /* That URL is the H5 page for a PHONE: a desktop browser answers it with the
1372
+ Bilibili app's APK, which is a download rather than a sign-in. The code is the way
1373
+ through, and offering the page beside it is offering a dead end. */
1374
+ check('nothing offers to open the sign-in page', hasButton(waiting, '打开登录页面') === false, textOf(waiting).replace(/\s+/g, ' ').slice(0, 220))
1375
+
1376
+ /* Longer than version 10 holds. The panel shows the fallback and says why, never a
1377
+ code that encodes something else. */
1378
+ fixture['/bilibili-status'] = {
1379
+ ok: true,
1380
+ value: biliValue({
1381
+ credential: signedOut,
1382
+ login: { state: 'waiting', url: `https://example.com/${'a'.repeat(260)}`, startedAt: '2026-10-08T01:00:00Z', expiresAt: '2026-10-08T01:03:00Z', scanned: false, message: '' },
1383
+ }),
1384
+ }
1385
+ values.clear()
1386
+ effectSlots.clear()
1387
+ const tooLong = await renderSettings('bilibili')
1388
+ check('a URL too long to encode draws no code', findSvg(tooLong) === null)
1389
+ check('and says so, instead of drawing something wrong', textOf(tooLong).includes('画不成二维码'), textOf(tooLong).replace(/\s+/g, ' ').slice(-200))
1390
+ /* The fallback is the paste field, not a browser page: that URL is the phone's H5
1391
+ page, and a desktop browser answers it with the app's APK instead of a sign-in. */
1392
+ check('the fallback offered is the paste field, not a browser page', hasButton(tooLong, '保存并验证') && hasButton(tooLong, '打开登录页面') === false, textOf(tooLong).replace(/\s+/g, ' ').slice(-260))
1393
+ }
1394
+
1395
+ /* -- 20. A repository with no working tree can fetch one ----------------------
1396
+ Registered but not cloned, every action on this page is unavailable: 提交 has nothing
1397
+ to commit, 构建 and 发布 act on what is on GitHub rather than on this disk, and the
1398
+ local column reads as broken. The panel answers the one question that unblocks all of
1399
+ it — where is it, and shall I fetch it — with the address already filled in, because
1400
+ the Host can derive it from the name it already has. */
1401
+ {
1402
+ const cloneFixture = (overrides) => repoFixture({
1403
+ localPath: '',
1404
+ cloneUrl: 'https://github.com/SOH4C4759/dsh-plugin-restart.git',
1405
+ checkoutRoot: 'F:\\CodeProj',
1406
+ local: { available: false, reason: 'no localPath configured' },
1407
+ ...overrides,
1408
+ })
1409
+
1410
+ const panel = await renderPanel([cloneFixture({})])
1411
+ const { tree } = await expandFirstRow(panel)
1412
+ check('a repository with no checkout says so', textOf(tree).includes('还没有本地检出'), textOf(tree).replace(/\s+/g, ' ').slice(0, 200))
1413
+ check('and offers the address already filled in', inputValue(tree, '仓库地址') === 'https://github.com/SOH4C4759/dsh-plugin-restart.git', String(inputValue(tree, '仓库地址')))
1414
+ check('and says where it would land', textOf(tree).includes('将克隆到 F:\\CodeProj\\dsh-plugin-restart'), textOf(tree).replace(/\s+/g, ' ').slice(-200))
1415
+ check('with a clone button', hasButton(tree, '克隆'))
1416
+
1417
+ fixture['/clone'] = { ok: true, value: { repo: 'dsh-plugin-restart', path: 'F:\\CodeProj\\dsh-plugin-restart', url: 'https://github.com/SOH4C4759/dsh-plugin-restart.git' } }
1418
+ const asked = clickButton(tree, '克隆')
1419
+ const after = await rerender()
1420
+ check('the clone is sent to the Host', asked === true && calls.includes('/clone'), calls.join(','))
1421
+ check('and the panel says where it landed', textOf(after).includes('已克隆到 F:\\CodeProj\\dsh-plugin-restart'), textOf(after).replace(/\s+/g, ' ').slice(-200))
1422
+
1423
+ /* Nowhere to put it: a named refusal with the key that fixes it, rather than a button
1424
+ whose only outcome is a refusal in English from the Host. */
1425
+ const noRoot = await renderPanel([cloneFixture({ checkoutRoot: '' })])
1426
+ const { tree: noRootTree } = await expandFirstRow(noRoot)
1427
+ check('nowhere to clone into is explained', textOf(noRootTree).includes('还没设置检出根目录'), textOf(noRootTree).replace(/\s+/g, ' ').slice(-220))
1428
+ check('and the key that fixes it is named', textOf(noRootTree).includes('projectsRoot'))
1429
+ }
1430
+
1431
+ /* -- 21. "已发送" and "读得到" are two different facts ------------------------
1432
+ Bilibili answers `code: 0` with an rpid for a comment the public listing may never
1433
+ serve. Two of these were filed as announced and then answered `12006 没有该评论` when
1434
+ looked up, and the panel showed "已播报" the whole time. The Host now reads the comment
1435
+ back before answering, records what it found, and the panel says which of the two
1436
+ happened. */
1437
+ {
1438
+ const announcedEntry = (overrides) => ({
1439
+ repo: 'dsh-plugin-restart',
1440
+ tag: 'v1.0.1',
1441
+ bvid: 'BV1RopP6FEJp',
1442
+ at: '2026-10-08T02:00:00Z',
1443
+ state: 'announced',
1444
+ attempts: 0,
1445
+ text: '【更新 v1.0.1】修复:x',
1446
+ rpid: '316418818849',
1447
+ code: 0,
1448
+ message: '',
1449
+ failure: null,
1450
+ url: 'https://www.bilibili.com/video/BV1RopP6FEJp/#reply316418818849',
1451
+ ...overrides,
1452
+ })
1453
+
1454
+ const invisible = await renderPanel([repoFixture({ publishedTag: 'v1.0.1' })], baseStatus.value, {
1455
+ '/bilibili-status': { ok: true, value: biliValue({ repos: [biliFixture({ announced: [announcedEntry({ visible: false, visibleCode: 12006 })] })] }) },
1456
+ })
1457
+ const { tree } = await expandFirstRow(invisible)
1458
+ check('an announcement nobody can read is not shown as a success', textOf(tree).includes('已发送但读不到'), textOf(tree).replace(/\s+/g, ' ').slice(-260))
1459
+
1460
+ const visible = await renderPanel([repoFixture({ publishedTag: 'v1.0.1' })], baseStatus.value, {
1461
+ '/bilibili-status': { ok: true, value: biliValue({ repos: [biliFixture({ announced: [announcedEntry({ visible: true, visibleCode: 0 })] })] }) },
1462
+ })
1463
+ const { tree: visibleTree } = await expandFirstRow(visible)
1464
+ check('and one that reads back is not flagged', textOf(visibleTree).includes('已发送但读不到') === false)
1465
+
1466
+ /* The manual post reports what the Host found, rather than assuming the cheerful one. */
1467
+ const posting = await renderPanel([repoFixture({ publishedTag: 'v1.0.1' })], baseStatus.value, {
1468
+ '/bilibili-status': { ok: true, value: biliValue() },
1469
+ '/bilibili-announce': { ok: true, value: { repo: 'dsh-plugin-restart', tag: 'v1.0.1', bvid: 'BV1RopP6FEJp', rpid: '1', url: 'u', text: 't', visible: false, note: '读不到' } },
1470
+ })
1471
+ const { tree: postingTree } = await expandFirstRow(posting)
1472
+ clickButton(postingTree, '发更新评论')
1473
+ const previewed = await rerender()
1474
+ /* The fixture's binding wrote a baseline, so the Host's verdict is "already known" and
1475
+ the panel offers the override: 【仍然发送】, which is also the path a person uses to
1476
+ repost something that vanished. */
1477
+ clickButton(previewed, '仍然发送')
1478
+ const afterPost = await rerender()
1479
+ check('a post that does not read back says so', textOf(afterPost).includes('但公开列表里读不到'), textOf(afterPost).replace(/\s+/g, ' ').slice(-260))
1480
+ }
1481
+
1266
1482
  console.log(`\n${results.length - failed}/${results.length} checks passed`)
1267
1483
  if (failed > 0) process.exit(1)
@@ -20,7 +20,7 @@
20
20
  import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
21
21
  import { tmpdir } from 'node:os'
22
22
  import { join } from 'node:path'
23
- import { classifyPublishFailure, classifySpec, collectRepo, commandFailureLine, compareVersions, describeInstall, effectiveConfig, firstMeaningfulLine, fullSha, ghJson, hasNpmToken, nextVersion, normalizeRegistry, normalizeRepoEntry, npmAuthState, npmPackageState, npmPublishVerdict, npmrcAuthKey, packumentUrl, parseAuthStatus, parseReposFile, pickInstallableRelease, readDirtyCount, readLocalState, readLocalVersion, readManifest, readProfileInstall, releasePreflight, resolveConfig, resolveConfigFilePath, resolveGhPath, resolveNpmrcPath, resolvePackageManagerInvocation, resolveProfileDir, resolveSlug, rewriteVersion, runTool, statusPath, updateState, upsertAuthToken, versionFromTag } from '../index.js'
23
+ import { classifyPublishFailure, classifySpec, collectRepo, commandFailureLine, compareVersions, confirmVisible, defaultCloneUrl, describeInstall, effectiveConfig, firstMeaningfulLine, fullSha, ghJson, hasNpmToken, nextVersion, normalizeRegistry, normalizeRepoEntry, npmAuthState, npmPackageState, npmPublishVerdict, npmrcAuthKey, packumentUrl, parseAuthStatus, parseReposFile, pickInstallableRelease, readDirtyCount, readLocalState, readLocalVersion, readManifest, readProfileInstall, releasePreflight, resolveConfig, resolveConfigFilePath, resolveGhPath, resolveNpmrcPath, resolvePackageManagerInvocation, resolveProfileDir, resolveSlug, rewriteVersion, runTool, statusPath, updateState, upsertAuthToken, versionFromTag } from '../index.js'
24
24
 
25
25
  const results = []
26
26
  let failed = 0
@@ -463,6 +463,61 @@ check('a rename is passed through as git spelled it', statusPath('R old.md -> n
463
463
  check('a line too short to hold a path is not sliced into nonsense', statusPath('??') === '??')
464
464
  check('a registry that already carries a query keeps the name out of it', packumentUrl('https://r.example/npm/?a=1', 'x') === 'https://r.example/npm/x?a=1&write=true')
465
465
 
466
+ /* The address a 克隆 would use, derived rather than demanded: being asked to paste the
467
+ clone URL of a repository the Host can already name is exactly the kind of step this
468
+ console exists to remove. */
469
+ check('an owner/repo id gives its own address', defaultCloneUrl('', 'SOH4C4759/dsh-ui-sound') === 'https://github.com/SOH4C4759/dsh-ui-sound.git')
470
+ check('a bare name uses the configured owner', defaultCloneUrl('octocat', 'Hello-World') === 'https://github.com/octocat/Hello-World.git')
471
+ check('a bare name with no owner has no address to guess at', defaultCloneUrl('', 'Hello-World') === '')
472
+ check('no repository, no address', defaultCloneUrl('octocat', '') === '' && defaultCloneUrl(undefined, undefined) === '')
473
+ check('a stray slash is tidied rather than doubled', defaultCloneUrl('', '/octocat/Hello-World/') === 'https://github.com/octocat/Hello-World.git')
474
+
475
+ /* Accepted is not the same as readable. Bilibili answers `code: 0` with an rpid for a
476
+ comment the public listing may never serve, and two of these were filed as announced
477
+ and then answered `12006 没有该评论` when looked up.
478
+ The waiting is measured, not superstition: a fresh comment answers 12006 immediately
479
+ and reads back from about four seconds, so a single check would libel a good comment. */
480
+ {
481
+ const scripted = (answers) => {
482
+ let calls = 0
483
+ return {
484
+ read: async () => {
485
+ const answer = answers[Math.min(calls, answers.length - 1)]
486
+ calls += 1
487
+ return answer
488
+ },
489
+ calls: () => calls,
490
+ }
491
+ }
492
+ const waits = []
493
+ const wait = async (ms) => { waits.push(ms) }
494
+
495
+ const delayed = scripted([
496
+ { ok: true, visible: false, code: 12006, message: '没有该评论' },
497
+ { ok: true, visible: false, code: 12006, message: '没有该评论' },
498
+ { ok: true, visible: true, code: 0, message: '' },
499
+ ])
500
+ const late = await confirmVisible({ read: delayed.read, aid: 1, rpid: '2', attempts: 3, delayMs: 3000, wait })
501
+ check('a comment that appears late is still confirmed', late.visible === true && late.attempts === 3, JSON.stringify(late))
502
+ check('and it waited between the asks rather than hammering', waits.length === 2 && waits[0] === 3000, JSON.stringify(waits))
503
+
504
+ const never = scripted([{ ok: true, visible: false, code: 12006, message: '没有该评论' }])
505
+ const missing = await confirmVisible({ read: never.read, aid: 1, rpid: '2', attempts: 3, delayMs: 0, wait })
506
+ check('a comment that never appears is reported as not visible', missing.visible === false && missing.attempts === 3, JSON.stringify(missing))
507
+ check('and Bilibili\'s own reason comes with it', missing.code === 12006 && missing.message === '没有该评论', JSON.stringify(missing))
508
+
509
+ const first = scripted([{ ok: true, visible: true, code: 0, message: '' }])
510
+ const immediate = await confirmVisible({ read: first.read, aid: 1, rpid: '2', attempts: 3, delayMs: 3000, wait })
511
+ check('a comment that is already readable costs one call', immediate.visible === true && first.calls() === 1, String(first.calls()))
512
+
513
+ /* A transport failure is not a verdict: it is asked again like any other miss. */
514
+ const broken = scripted([{ ok: false, visible: false, code: null, message: 'HTTP 500' }])
515
+ const failed = await confirmVisible({ read: broken.read, aid: 1, rpid: '2', attempts: 2, delayMs: 0, wait })
516
+ check('a transport failure is retried and then reported honestly', failed.visible === false && broken.calls() === 2, JSON.stringify(failed))
517
+ const tolerated = await confirmVisible({ read: async () => undefined, aid: 1, rpid: '2', attempts: 2, delayMs: 0, wait })
518
+ check('a reader that answers nothing at all does not crash the check', tolerated.visible === false, JSON.stringify(tolerated))
519
+ }
520
+
466
521
  /* The line the panel shows. Captured from a real `pnpm publish` of an
467
522
  already-published version: every byte goes to STDOUT (measured: stderr = 0), the
468
523
  first line is the progress line, the rest is a stack trace, and node's own
@@ -87,6 +87,9 @@ const EXPECTED = [
87
87
  '/api/dsh-cicd/npm-status',
88
88
  '/api/dsh-cicd/npm-login',
89
89
  '/api/dsh-cicd/npm-publish',
90
+ // A registered repository with no working tree cannot be released from here, so the
91
+ // console fetches one: the address is derivable from the name it already has.
92
+ '/api/dsh-cicd/clone',
90
93
  // Setup without a terminal: the browser sign-in and the repository list are
91
94
  // routes, not instructions to go and run something elsewhere.
92
95
  '/api/dsh-cicd/auth-start',