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,521 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Checks for the dsh-plugin-cicd Host half.
|
|
4
|
+
*
|
|
5
|
+
* The point of this file is that the Host half's hard part is not the HTTP
|
|
6
|
+
* plumbing — it is the two boundaries: what a hand-written profile patch can ask
|
|
7
|
+
* for, and what a configured repository name turns into once it becomes one
|
|
8
|
+
* argv element of a `gh` invocation. Both are testable without DSH, without a
|
|
9
|
+
* browser, and (for the first half) without a network.
|
|
10
|
+
*
|
|
11
|
+
* node tests/host-checks.mjs offline checks only — what CI runs
|
|
12
|
+
* DSH_CICD_LIVE=1 node tests/host-checks.mjs adds checks against the real gh
|
|
13
|
+
*
|
|
14
|
+
* The live checks are opt-in because they need an authenticated `gh` AND a
|
|
15
|
+
* specific repository state; asserting "dsh-plugin-restart has a draft release"
|
|
16
|
+
* in CI would fail the moment that release is published, which would train
|
|
17
|
+
* everyone to ignore the suite.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
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'
|
|
24
|
+
|
|
25
|
+
const results = []
|
|
26
|
+
let failed = 0
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record and print one check.
|
|
30
|
+
* @param {string} label - what was asserted.
|
|
31
|
+
* @param {boolean} condition - the assertion.
|
|
32
|
+
* @param {string} [detail] - evidence, printed either way.
|
|
33
|
+
*/
|
|
34
|
+
function check(label, condition, detail = '') {
|
|
35
|
+
const pass = condition === true
|
|
36
|
+
if (!pass) failed += 1
|
|
37
|
+
results.push({ label, pass })
|
|
38
|
+
console.log(`${pass ? 'ok ' : 'FAIL'} ${label}${detail === '' ? '' : ` — ${detail}`}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* -- 1. Entry normalization -------------------------------------------------
|
|
42
|
+
A repository name is passed to `gh` as one argv element with no shell in
|
|
43
|
+
between, so the shape check is not cosmetic: it is what keeps `--help` or a
|
|
44
|
+
flag-looking string from being read as an option. */
|
|
45
|
+
check('bare name accepted', normalizeRepoEntry('dsh-plugin-restart')?.repo === 'dsh-plugin-restart')
|
|
46
|
+
check('owner/name accepted', normalizeRepoEntry('other/repo')?.repo === 'other/repo')
|
|
47
|
+
/* `path.isAbsolute` is platform-specific, and this suite runs on a Linux runner
|
|
48
|
+
as well as on the Windows machine the plugin actually targets. So the shape
|
|
49
|
+
that must hold everywhere is asserted everywhere, and the Windows drive-letter
|
|
50
|
+
shape is pinned only where it is meaningful — asserting it on Linux would fail
|
|
51
|
+
for a reason that has nothing to do with the code under test. */
|
|
52
|
+
const absoluteCheckout = join(tmpdir(), 'some-checkout')
|
|
53
|
+
check('object form accepted', normalizeRepoEntry({ repo: 'x', localPath: absoluteCheckout })?.localPath === absoluteCheckout)
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
check('a Windows absolute path is kept', normalizeRepoEntry({ repo: 'x', localPath: 'F:\\CodeProj\\x' })?.localPath === 'F:\\CodeProj\\x')
|
|
56
|
+
}
|
|
57
|
+
check('label falls back to the name', normalizeRepoEntry({ repo: 'x' })?.label === '')
|
|
58
|
+
check('relative localPath refused', normalizeRepoEntry({ repo: 'x', localPath: 'CodeProj\\x' })?.localPath === '')
|
|
59
|
+
check('flag-shaped name refused', normalizeRepoEntry('--help') === null)
|
|
60
|
+
check('shell metacharacters refused', normalizeRepoEntry('a;rm -rf /') === null)
|
|
61
|
+
check('space in name refused', normalizeRepoEntry('a b') === null)
|
|
62
|
+
check('empty string refused', normalizeRepoEntry('') === null)
|
|
63
|
+
check('number refused', normalizeRepoEntry(42) === null)
|
|
64
|
+
check('null refused', normalizeRepoEntry(null) === null)
|
|
65
|
+
|
|
66
|
+
/* -- 2. Config clamping -----------------------------------------------------
|
|
67
|
+
A hand-written patch must never be able to stop the Host from starting. The
|
|
68
|
+
worst acceptable outcome is a route that reports "not configured". */
|
|
69
|
+
const clamped = resolveConfig({
|
|
70
|
+
owner: 'SOH4C4759',
|
|
71
|
+
repos: ['dsh-plugin-restart', 'dsh-plugin-restart', { repo: 'dsh-ui-sound' }, '!!bad', 7],
|
|
72
|
+
requestTimeoutMs: 99_999_999,
|
|
73
|
+
pollSeconds: 1,
|
|
74
|
+
logTailLines: 99_999,
|
|
75
|
+
overviewTtlMs: -5,
|
|
76
|
+
})
|
|
77
|
+
check('duplicate repository collapsed', clamped.repos.length === 2, clamped.repos.map((entry) => entry.repo).join(','))
|
|
78
|
+
check('unusable entry dropped', clamped.repos.every((entry) => /^[A-Za-z0-9._-]+$/.test(entry.repo)))
|
|
79
|
+
check('timeout clamped to the ceiling', clamped.requestTimeoutMs === 120_000, String(clamped.requestTimeoutMs))
|
|
80
|
+
check('poll interval floored', clamped.pollSeconds === 10, String(clamped.pollSeconds))
|
|
81
|
+
check('log tail capped', clamped.logTailLines === 400, String(clamped.logTailLines))
|
|
82
|
+
check('negative ttl floored to zero', clamped.overviewTtlMs === 0, String(clamped.overviewTtlMs))
|
|
83
|
+
check('missing config yields defaults', resolveConfig(undefined).defaultBranch === 'main')
|
|
84
|
+
check('non-array repos yields an empty list', resolveConfig({ repos: 'nope' }).repos.length === 0)
|
|
85
|
+
check('null config survives', resolveConfig(null).repos.length === 0)
|
|
86
|
+
check('repo cap enforced', resolveConfig({ repos: Array.from({ length: 100 }, (_, index) => `r${index}`) }).repos.length === 40)
|
|
87
|
+
|
|
88
|
+
/* -- 2b. The Bilibili binding ----------------------------------------------
|
|
89
|
+
The binding decides which video's comment section a repository speaks to. A
|
|
90
|
+
malformed one has to become "no binding" rather than travel onward: the failure
|
|
91
|
+
mode of the other choice is a comment posted under somebody else's video. */
|
|
92
|
+
check('a binding is kept', normalizeRepoEntry({ repo: 'x', bilibili: { bvid: 'BV1RopP6FEJp' } })?.bilibili?.bvid === 'BV1RopP6FEJp')
|
|
93
|
+
check('a binding is automatic unless it says otherwise', normalizeRepoEntry({ repo: 'x', bilibili: { bvid: 'BV1RopP6FEJp' } })?.bilibili?.auto === true)
|
|
94
|
+
check('a binding can be manual', normalizeRepoEntry({ repo: 'x', bilibili: { bvid: 'BV1RopP6FEJp', auto: false } })?.bilibili?.auto === false)
|
|
95
|
+
check('a malformed BV id becomes no binding', normalizeRepoEntry({ repo: 'x', bilibili: { bvid: 'BV-nope' } })?.bilibili === null)
|
|
96
|
+
check('a binding with no id becomes no binding', normalizeRepoEntry({ repo: 'x', bilibili: {} })?.bilibili === null)
|
|
97
|
+
check('a repository with no binding says so', normalizeRepoEntry({ repo: 'x' })?.bilibili === null)
|
|
98
|
+
check('the BV shorthand is accepted', normalizeRepoEntry({ repo: 'x', bilibili: { bv: 'BV1RopP6FEJp' } })?.bilibili?.bvid === 'BV1RopP6FEJp')
|
|
99
|
+
check('bilibili notes are on by default', resolveConfig(undefined).bilibiliEnabled === true)
|
|
100
|
+
check('the sweep interval is clamped', resolveConfig({ bilibiliWatchSeconds: 999_999 }).bilibiliWatchSeconds === 3600)
|
|
101
|
+
check('the sweep can be switched off', resolveConfig({ bilibiliWatchSeconds: 0 }).bilibiliWatchSeconds === 0)
|
|
102
|
+
check('an external credential file is remembered', resolveConfig({ bilibiliCookieFile: 'C:\\Users\\x\\cookies.json' }).bilibiliCookieFile === 'C:\\Users\\x\\cookies.json')
|
|
103
|
+
check('no external credential file is the default', resolveConfig(undefined).bilibiliCookieFile === '')
|
|
104
|
+
check('the template is kept verbatim', resolveConfig({ bilibiliTemplate: '【更新 {tag}】{summary}' }).bilibiliTemplate === '【更新 {tag}】{summary}')
|
|
105
|
+
|
|
106
|
+
/* -- 3. Slug resolution ----------------------------------------------------- */
|
|
107
|
+
check('owner prefixes a bare name', resolveSlug('SOH4C4759', 'dsh-plugin-restart') === 'SOH4C4759/dsh-plugin-restart')
|
|
108
|
+
check('owner is ignored for owner/name', resolveSlug('someone', 'a/b') === 'a/b')
|
|
109
|
+
check('bare name without owner is refused', resolveSlug('', 'bare') === null)
|
|
110
|
+
|
|
111
|
+
/* -- 4. gh resolution -------------------------------------------------------
|
|
112
|
+
The bug this section pins: `path.isAbsolute` says a Windows path is NOT
|
|
113
|
+
absolute on POSIX, so a resolver that trusted it handed
|
|
114
|
+
`C:\Program Files\GitHub CLI\gh.exe` to `spawn` as if it were a command name
|
|
115
|
+
on every Linux machine. */
|
|
116
|
+
const ghPath = resolveGhPath(resolveConfig({ owner: 'SOH4C4759' }))
|
|
117
|
+
check('gh resolves to something runnable', ghPath !== '', ghPath)
|
|
118
|
+
check('an explicit command name is kept', resolveGhPath(resolveConfig({ ghPath: 'gh' }), 'linux') === 'gh')
|
|
119
|
+
check('an explicit absolute path is kept', resolveGhPath(resolveConfig({ ghPath: join(tmpdir(), 'gh.exe') }), 'linux') === join(tmpdir(), 'gh.exe'))
|
|
120
|
+
const posix = resolveGhPath(resolveConfig({}), 'linux')
|
|
121
|
+
check('POSIX never answers with a Windows path', posix === 'gh', posix)
|
|
122
|
+
const windows = resolveGhPath(resolveConfig({}), 'win32')
|
|
123
|
+
check('Windows answers with a real path or the command name', windows === 'gh' || existsSync(windows), windows)
|
|
124
|
+
|
|
125
|
+
/* -- 5. Local-state helpers -------------------------------------------------
|
|
126
|
+
These run against real directories on any machine: the point is the
|
|
127
|
+
degradation path, not the specific checkout. */
|
|
128
|
+
/* The absent-path cases use the OS temp directory rather than a hard-coded
|
|
129
|
+
Windows path, so the same suite is meaningful on a Linux runner. */
|
|
130
|
+
check('missing directory yields null', readLocalVersion(join(tmpdir(), '__definitely-not-here__')) === null)
|
|
131
|
+
check('empty path yields null', readLocalVersion('') === null)
|
|
132
|
+
const noPath = await readLocalState('', 5_000)
|
|
133
|
+
check('no localPath explains itself', noPath.available === false && noPath.reason === 'no localPath configured', noPath.reason)
|
|
134
|
+
const notARepo = await readLocalState(tmpdir(), 5_000)
|
|
135
|
+
check('a non-checkout explains itself', notARepo.available === false, notARepo.reason)
|
|
136
|
+
|
|
137
|
+
/* -- 6. The managed config file ---------------------------------------------
|
|
138
|
+
The file exists so the repository list can change without hand-editing YAML,
|
|
139
|
+
which makes its failure modes the interesting part: a file the plugin cannot
|
|
140
|
+
read must be reported, never silently treated as "no repositories". */
|
|
141
|
+
const scratch = mkdtempSync(join(tmpdir(), 'dsh-cicd-'))
|
|
142
|
+
const managedFile = join(scratch, 'repos.json')
|
|
143
|
+
|
|
144
|
+
check('an explicit config path is honoured', resolveConfigFilePath(resolveConfig({ configFile: managedFile })) === managedFile)
|
|
145
|
+
check('the default path lives under DSH_HOME', resolveConfigFilePath(resolveConfig({})).endsWith(join('dsh-plugin-cicd', 'repos.json')))
|
|
146
|
+
|
|
147
|
+
const parsedGood = parseReposFile(JSON.stringify({ owner: 'me', repos: ['a', { repo: 'b', localPath: join(tmpdir(), 'b') }, '!!bad', 'a'] }))
|
|
148
|
+
check('the managed file parses', parsedGood.ok === true && parsedGood.owner === 'me', parsedGood.ok ? '' : parsedGood.message)
|
|
149
|
+
check('valid entries are kept', parsedGood.ok === true && parsedGood.repos.length === 2, parsedGood.ok ? parsedGood.repos.map((entry) => entry.repo).join(',') : '')
|
|
150
|
+
check('dropped entries are counted', parsedGood.ok === true && parsedGood.dropped === 2, String(parsedGood.ok ? parsedGood.dropped : 'n/a'))
|
|
151
|
+
check('invalid JSON is refused', parseReposFile('{').ok === false)
|
|
152
|
+
check('a bare array is refused', parseReposFile('[]').ok === false)
|
|
153
|
+
check('a non-array repos is refused', parseReposFile('{"repos":{}}').ok === false)
|
|
154
|
+
check('a missing repos key is an empty list', parseReposFile('{"owner":"me"}').repos.length === 0)
|
|
155
|
+
|
|
156
|
+
/* File absent: the hand-written row stays authoritative. */
|
|
157
|
+
const rowOnly = effectiveConfig(resolveConfig({ owner: 'me', repos: ['solo'], configFile: managedFile }))
|
|
158
|
+
check('an absent file falls back to the row', rowOnly.configSource === 'row' && rowOnly.repos.length === 1, rowOnly.configSource)
|
|
159
|
+
check('an absent file is not a problem', rowOnly.configProblem === null)
|
|
160
|
+
|
|
161
|
+
writeFileSync(managedFile, JSON.stringify({ owner: 'file-owner', repos: [{ repo: 'from-file' }] }), 'utf8')
|
|
162
|
+
const fileBacked = effectiveConfig(resolveConfig({ owner: 'row-owner', repos: ['from-row'], configFile: managedFile }))
|
|
163
|
+
check('the file wins over the row list', fileBacked.repos.length === 1 && fileBacked.repos[0].repo === 'from-file')
|
|
164
|
+
check('the file owner wins too', fileBacked.owner === 'file-owner', fileBacked.owner)
|
|
165
|
+
check('the source is reported as file', fileBacked.configSource === 'file')
|
|
166
|
+
|
|
167
|
+
writeFileSync(managedFile, 'not json at all', 'utf8')
|
|
168
|
+
const broken = effectiveConfig(resolveConfig({ owner: 'me', repos: ['from-row'], configFile: managedFile }))
|
|
169
|
+
check('a broken file is reported, not swallowed', broken.configSource === 'file-invalid' && typeof broken.configProblem === 'string', String(broken.configProblem))
|
|
170
|
+
check('a broken file does not silently empty the list', broken.repos.length === 1 && broken.repos[0].repo === 'from-row')
|
|
171
|
+
|
|
172
|
+
/* -- 7. `gh auth status` parsing --------------------------------------------
|
|
173
|
+
The scope list is the difference between "the buttons work" and "the button
|
|
174
|
+
fails when you press it", so it is parsed rather than assumed. */
|
|
175
|
+
const loggedIn = parseAuthStatus([
|
|
176
|
+
'github.com',
|
|
177
|
+
' ✓ Logged in to github.com account SOH4C4759 (keyring)',
|
|
178
|
+
' - Active account: true',
|
|
179
|
+
" - Token scopes: 'gist', 'read:org', 'repo', 'workflow'",
|
|
180
|
+
].join('\n'))
|
|
181
|
+
check('a signed-in status is recognised', loggedIn.authenticated === true)
|
|
182
|
+
check('the account is extracted', loggedIn.account === 'SOH4C4759', String(loggedIn.account))
|
|
183
|
+
check('scopes are extracted', loggedIn.scopes.join(',') === 'gist,read:org,repo,workflow', loggedIn.scopes.join(','))
|
|
184
|
+
check('a complete scope set reports nothing missing', loggedIn.missingScopes.length === 0)
|
|
185
|
+
|
|
186
|
+
const partialScopes = parseAuthStatus(" ✓ Logged in to github.com account me (keyring)\n - Token scopes: 'repo'")
|
|
187
|
+
check('a partial scope set names what is missing', partialScopes.missingScopes.join(',') === 'workflow', partialScopes.missingScopes.join(','))
|
|
188
|
+
|
|
189
|
+
/* A fine-grained token prints no scope line at all. Reporting that as "missing
|
|
190
|
+
repo, workflow" would send the user to fix something that is not broken. */
|
|
191
|
+
const opaqueToken = parseAuthStatus(' ✓ Logged in to github.com account me (keyring)\n - Token: github_pat_***')
|
|
192
|
+
check('an unreported scope list is unknown, not empty', opaqueToken.missingScopes.length === 0)
|
|
193
|
+
check('an unreported scope list stays empty', opaqueToken.scopes.length === 0)
|
|
194
|
+
|
|
195
|
+
const signedOut = parseAuthStatus('github.com\n X No oauth token found for github.com')
|
|
196
|
+
check('a signed-out status is recognised', signedOut.authenticated === false)
|
|
197
|
+
check('a signed-out status reports no account', signedOut.account === null)
|
|
198
|
+
|
|
199
|
+
/* -- 8. Release preflight and the version bump ------------------------------
|
|
200
|
+
Every repository here releases from `package.json`'s version, and the release
|
|
201
|
+
workflow refuses to reuse a version that already belongs to another commit — so a
|
|
202
|
+
dispatch without a bump could only fail (measured four times, ~10 s in, at the
|
|
203
|
+
first step, while the panel announced a draft that never appeared). Two
|
|
204
|
+
properties matter, and they pull in opposite directions: a PROVEN mismatch must
|
|
205
|
+
be blocked, and an unprovable one must never be. */
|
|
206
|
+
const shaA = 'a'.repeat(40)
|
|
207
|
+
const shaB = 'b'.repeat(40)
|
|
208
|
+
const releaseAt = (sha, draft = false) => ({ tag: 'v1.0.0', draft, targetCommitish: sha })
|
|
209
|
+
|
|
210
|
+
const blockedVerdict = releasePreflight({ version: '1.0.0', expectedTag: 'v1.0.0', releases: [releaseAt(shaA)], builtSha: shaB })
|
|
211
|
+
check('a version taken by another commit blocks the release', blockedVerdict.state === 'blocked' && blockedVerdict.code === 'version-taken', blockedVerdict.code)
|
|
212
|
+
check('the block names both commits', blockedVerdict.owner === shaA && blockedVerdict.built === shaB)
|
|
213
|
+
check('a DRAFT of that version blocks it too', releasePreflight({ version: '1.0.0', expectedTag: 'v1.0.0', releases: [releaseAt(shaA, true)], builtSha: shaB }).state === 'blocked')
|
|
214
|
+
check('an unreleased version is ready', releasePreflight({ version: '1.0.0', expectedTag: 'v1.0.0', releases: [], builtSha: shaB }).state === 'ready')
|
|
215
|
+
check('the same commit is ready — replacing its assets is safe', releasePreflight({ version: '1.0.0', expectedTag: 'v1.0.0', releases: [releaseAt(shaB)], builtSha: shaB }).state === 'ready')
|
|
216
|
+
check('a branch-name target cannot prove a mismatch, so it does not block', releasePreflight({ version: '1.0.0', expectedTag: 'v1.0.0', releases: [{ tag: 'v1.0.0', draft: false, targetCommitish: 'main' }], builtSha: shaB }).state === 'ready')
|
|
217
|
+
check('an unknown build commit cannot prove one either', releasePreflight({ version: '1.0.0', expectedTag: 'v1.0.0', releases: [releaseAt(shaA)], builtSha: null }).state === 'ready')
|
|
218
|
+
check('no local version is unknown, not blocked', releasePreflight({ version: null, expectedTag: null, releases: [releaseAt(shaA)], builtSha: shaB }).state === 'unknown')
|
|
219
|
+
check('an empty call answers instead of throwing', releasePreflight().state === 'unknown')
|
|
220
|
+
check('the tag is derived from the version when absent', releasePreflight({ version: '1.0.0', expectedTag: null, releases: [releaseAt(shaA)], builtSha: shaB }).state === 'blocked')
|
|
221
|
+
check('fullSha accepts a full id only', fullSha(shaA) === shaA && fullSha('abc1234') === null && fullSha(42) === null)
|
|
222
|
+
|
|
223
|
+
check('patch bumps the third field', nextVersion('1.0.1', 'patch').to === '1.0.2')
|
|
224
|
+
check('minor resets the patch', nextVersion('1.0.1', 'minor').to === '1.1.0')
|
|
225
|
+
check('major resets both', nextVersion('1.2.3', 'major').to === '2.0.0')
|
|
226
|
+
check('a prerelease is refused rather than guessed', nextVersion('1.0.0-rc.1', 'patch').ok === false)
|
|
227
|
+
check('an unknown release kind is refused', nextVersion('1.0.0', 'rollup').ok === false)
|
|
228
|
+
|
|
229
|
+
/* The manifest is hand-written prose in places (escaped CJK, key order), so the
|
|
230
|
+
bump must be a one-line edit, never a JSON round-trip. */
|
|
231
|
+
const manifest = '{\n "name": "x",\n "version": "1.0.0",\n "meta": { "title": "\\u4e00" }\n}\n'
|
|
232
|
+
const rewrittenManifest = rewriteVersion(manifest, '1.0.1')
|
|
233
|
+
check('only the version line changes', rewrittenManifest.ok === true && rewrittenManifest.text === manifest.replace('"version": "1.0.0"', '"version": "1.0.1"'), rewrittenManifest.ok ? '' : rewrittenManifest.message)
|
|
234
|
+
check('the rewritten manifest still parses', JSON.parse(rewrittenManifest.text).version === '1.0.1')
|
|
235
|
+
check('escaping elsewhere is untouched', rewrittenManifest.ok === true && rewrittenManifest.text.includes('\\u4e00') === manifest.includes('\\u4e00'))
|
|
236
|
+
check('two version keys are refused, not guessed at', rewriteVersion('{\n "version": "1.0.0",\n "x": {\n "version": "2.0.0"\n }\n}\n', '1.0.1').ok === false)
|
|
237
|
+
check('a non-version target is refused', rewriteVersion(manifest, '1.0').ok === false)
|
|
238
|
+
check('an empty manifest is refused', rewriteVersion('', '1.0.1').ok === false)
|
|
239
|
+
|
|
240
|
+
/* -- 9. The installed copy vs the release -----------------------------------
|
|
241
|
+
The update button's whole judgement lives here, and none of it needs GitHub: the
|
|
242
|
+
profile's manifest and the checkout's manifest are two files on disk, and the
|
|
243
|
+
release list is the normalized shape `collectRepo` already produces. A fixture
|
|
244
|
+
profile is built in the temp directory so the assertions are about the rules
|
|
245
|
+
rather than about whatever this machine happens to have installed. */
|
|
246
|
+
const profileDir = mkdtempSync(join(tmpdir(), 'dsh-cicd-profile-'))
|
|
247
|
+
const checkoutDir = mkdtempSync(join(tmpdir(), 'dsh-cicd-checkout-'))
|
|
248
|
+
const otherCheckout = mkdtempSync(join(tmpdir(), 'dsh-cicd-other-'))
|
|
249
|
+
const writeManifest = (dir, manifest) => writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
|
250
|
+
writeManifest(checkoutDir, { name: 'dsh-demo', version: '1.2.0' })
|
|
251
|
+
|
|
252
|
+
/* Every Windows spelling below is asserted on BOTH platforms on purpose. The bug
|
|
253
|
+
this pins: classifying `link:`/`file:` through `path.isAbsolute` made every
|
|
254
|
+
Windows-written spec `other` on the Linux runner, which turned a `link:` checkout
|
|
255
|
+
into "current" — the one answer that hides "you are not running the published
|
|
256
|
+
code". So the spellings stay Windows-shaped here and the assertions must hold
|
|
257
|
+
wherever this suite runs. */
|
|
258
|
+
check('a link spec is classified as a checkout', classifySpec('link:F:\\CodeProj\\x').kind === 'link')
|
|
259
|
+
check('a file tarball is classified as a tarball', classifySpec('file:F:\\dl\\x-1.0.0.tgz').kind === 'tarball')
|
|
260
|
+
check('a file directory is classified as a path', classifySpec('file:F:\\CodeProj\\x').kind === 'path')
|
|
261
|
+
check('a bare drive-letter path is a path on any platform', classifySpec('F:\\CodeProj\\x').kind === 'path')
|
|
262
|
+
check('a prefixed spec keeps its path verbatim', classifySpec('link:F:\\CodeProj\\x').path === 'F:\\CodeProj\\x')
|
|
263
|
+
check('link: without a path is not a kind', classifySpec('link:').kind === 'other')
|
|
264
|
+
check('a registry range is classified as registry', classifySpec('^1.2.3').kind === 'registry')
|
|
265
|
+
check('a git spec is not mistaken for a registry name', classifySpec('github:me/repo').kind === 'other')
|
|
266
|
+
check('an empty spec is not a kind', classifySpec('').kind === 'other')
|
|
267
|
+
|
|
268
|
+
check('equal versions compare equal', compareVersions('1.2.3', '1.2.3') === 0)
|
|
269
|
+
check('the v prefix is ignored', compareVersions('v1.2.3', '1.2.3') === 0 || compareVersions('1.2.3', 'v1.2.3') === 0)
|
|
270
|
+
check('a lower minor is older', compareVersions('1.2.3', '1.3.0') === -1)
|
|
271
|
+
check('a higher patch is newer', compareVersions('1.2.4', '1.2.3') === 1)
|
|
272
|
+
check('a prerelease is not comparable, not equal', compareVersions('1.2.3-rc.1', '1.2.3') === null)
|
|
273
|
+
check('a missing version is not comparable', compareVersions(null, '1.2.3') === null)
|
|
274
|
+
check('a tag yields its version', versionFromTag('v1.2.3') === '1.2.3' && versionFromTag('1.2.3') === '1.2.3')
|
|
275
|
+
check('a non-version tag yields null', versionFromTag('release') === null)
|
|
276
|
+
|
|
277
|
+
const releaseOf = (tag, asset, draft = false) => ({ tag, draft, name: '', prerelease: false, targetCommitish: '', createdAt: '', url: '', assets: asset === null ? [] : [{ name: asset, size: 1 }] })
|
|
278
|
+
const tgzOf = (tag) => releaseOf(tag, `dsh-demo-${tag.replace(/^v/, '')}.tgz`)
|
|
279
|
+
|
|
280
|
+
check('the newest release with a tarball is chosen', pickInstallableRelease([tgzOf('v1.3.0'), tgzOf('v1.2.0')])?.tag === 'v1.3.0')
|
|
281
|
+
check('a draft is skipped', pickInstallableRelease([releaseOf('v1.3.0', 'dsh-demo-1.3.0.tgz', true), tgzOf('v1.2.0')])?.tag === 'v1.2.0')
|
|
282
|
+
check('a release without a tarball is skipped', pickInstallableRelease([releaseOf('v1.3.0', 'dsh-demo-1.3.0.zip'), tgzOf('v1.2.0')])?.tag === 'v1.2.0')
|
|
283
|
+
check('an explicit tag narrows the choice', pickInstallableRelease([tgzOf('v1.3.0'), tgzOf('v1.2.0')], 'v1.2.0')?.tag === 'v1.2.0')
|
|
284
|
+
check('an explicit draft tag is refused', pickInstallableRelease([releaseOf('v1.3.0', 'x.tgz', true)], 'v1.3.0') === null)
|
|
285
|
+
check('nothing installable yields null', pickInstallableRelease([]) === null)
|
|
286
|
+
check('the tarball name is carried, not guessed', pickInstallableRelease([tgzOf('v1.3.0')])?.asset === 'dsh-demo-1.3.0.tgz')
|
|
287
|
+
|
|
288
|
+
/* A `link:` dependency by name. It points at a fixture checkout, so nothing here
|
|
289
|
+
depends on what this machine has installed. */
|
|
290
|
+
writeManifest(profileDir, { name: 'dsh-profile-x', dependencies: { 'dsh-demo': `link:${checkoutDir}` } })
|
|
291
|
+
const linkInstall = readProfileInstall(profileDir, 'dsh-demo')
|
|
292
|
+
check('a linked dependency is found by name', linkInstall.present === true && linkInstall.kind === 'link', JSON.stringify(linkInstall))
|
|
293
|
+
|
|
294
|
+
/* The name alone is not enough: a checkout can publish under a name that is not its
|
|
295
|
+
dependency key, which is the real shape of
|
|
296
|
+
dsh-plugin-knowledge-console → dsh-knowledge-console. */
|
|
297
|
+
writeManifest(profileDir, { name: 'dsh-profile-x', dependencies: { 'dsh-renamed': `link:${otherCheckout}` } })
|
|
298
|
+
const byPath = readProfileInstall(profileDir, 'dsh-absent-from-the-manifest', otherCheckout)
|
|
299
|
+
check('a checkout found by path keeps its dependency name', byPath.present === true && byPath.packageName === 'dsh-renamed', JSON.stringify(byPath))
|
|
300
|
+
|
|
301
|
+
writeManifest(profileDir, { name: 'dsh-profile-x', dependencies: {} })
|
|
302
|
+
const absent = readProfileInstall(profileDir, 'dsh-demo')
|
|
303
|
+
check('an uninstalled package is reported absent', absent.present === false)
|
|
304
|
+
check('an uninstalled package still reports the profile is readable', absent.profileReadable === true)
|
|
305
|
+
|
|
306
|
+
/* A tarball install is the case where the version decides, and the unpacked copy
|
|
307
|
+
under node_modules is what pnpm actually loaded — not the spec string. */
|
|
308
|
+
const installedDir = join(profileDir, 'node_modules', 'dsh-demo')
|
|
309
|
+
mkdirSync(installedDir, { recursive: true })
|
|
310
|
+
writeManifest(profileDir, { name: 'dsh-profile-x', dependencies: { 'dsh-demo': 'file:F:\\dl\\dsh-demo-1.2.0.tgz' } })
|
|
311
|
+
writeManifest(installedDir, { name: 'dsh-demo', version: '1.2.0' })
|
|
312
|
+
check('an installed tarball reports the version from node_modules', readProfileInstall(profileDir, 'dsh-demo').installedVersion === '1.2.0', JSON.stringify(readProfileInstall(profileDir, 'dsh-demo')))
|
|
313
|
+
|
|
314
|
+
const entry = { repo: 'dsh-plugin-demo', localPath: checkoutDir, label: '' }
|
|
315
|
+
/**
|
|
316
|
+
* Describe one state: the profile's dependency, the version pnpm unpacked, and the
|
|
317
|
+
* releases GitHub would report. All three decide the verdict together.
|
|
318
|
+
*/
|
|
319
|
+
const describeOf = (deps, releases, installedVersion = '1.2.0') => {
|
|
320
|
+
writeManifest(profileDir, { name: 'dsh-profile-x', dependencies: deps })
|
|
321
|
+
mkdirSync(installedDir, { recursive: true })
|
|
322
|
+
writeManifest(installedDir, { name: 'dsh-demo', version: installedVersion })
|
|
323
|
+
return describeInstall({ profileDir, profileName: 'desktop', entry, releases })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* The consequence, not just the classification: a Windows-written `link:` spec whose
|
|
327
|
+
version EQUALS the release must still be `checkout`. If it ever reads `current`
|
|
328
|
+
again, the panel has gone back to telling a developer their profile runs the
|
|
329
|
+
published artifact while it runs a checkout. */
|
|
330
|
+
check('a Windows-spelled link is a checkout even when the version matches', describeOf({ 'dsh-demo': 'link:F:\\CodeProj\\dsh-plugin-demo' }, [tgzOf('v1.2.0')], '1.2.0').state === 'checkout')
|
|
331
|
+
check('an installed copy behind the release offers an update', describeOf({ 'dsh-demo': 'file:F:\\dl\\dsh-demo-1.2.0.tgz' }, [tgzOf('v1.3.0')], '1.2.0').state === 'update')
|
|
332
|
+
check('an installed copy at the release version is current', describeOf({ 'dsh-demo': 'file:F:\\dl\\dsh-demo-1.3.0.tgz' }, [tgzOf('v1.3.0')], '1.3.0').state === 'current')
|
|
333
|
+
check('an installed copy past the release says so instead of offering a downgrade silently', describeOf({ 'dsh-demo': 'file:F:\\dl\\dsh-demo-2.0.0.tgz' }, [tgzOf('v1.3.0')], '2.0.0').state === 'ahead')
|
|
334
|
+
check('an unorderable version is not claimed to be behind', describeOf({ 'dsh-demo': 'file:F:\\dl\\dsh-demo-1.2.0-rc.1.tgz' }, [tgzOf('v1.3.0')], '1.2.0-rc.1').state === 'differs')
|
|
335
|
+
/* The state the version comparison cannot see: same version string, different code. */
|
|
336
|
+
check('a linked checkout is its own state, never "current"', describeOf({ 'dsh-demo': `link:${checkoutDir}` }, [tgzOf('v1.2.0')], '1.2.0').state === 'checkout')
|
|
337
|
+
check('a package that is not installed is not-installed', describeOf({}, [tgzOf('v1.3.0')]).state === 'not-installed')
|
|
338
|
+
check('a package with no release is no-release', describeOf({ 'dsh-demo': `link:${checkoutDir}` }, []).state === 'no-release')
|
|
339
|
+
check('the state rules answer without throwing on nulls', updateState(null, null) === 'not-installed' && updateState({ present: true, kind: 'registry' }, null) === 'no-release')
|
|
340
|
+
check('the checkout name wins over the repository name', describeOf({ 'dsh-demo': `link:${checkoutDir}` }, [tgzOf('v1.3.0')]).packageName === 'dsh-demo')
|
|
341
|
+
check('the release tag and asset reach the panel', describeOf({}, [tgzOf('v1.3.0')]).latestTag === 'v1.3.0' && describeOf({}, [tgzOf('v1.3.0')]).latestAsset === 'dsh-demo-1.3.0.tgz')
|
|
342
|
+
check('a profile path falls back to the environment', resolveProfileDir({ DSH_PROFILE_DIR: 'C:\\p' }) === 'C:\\p')
|
|
343
|
+
check('a missing environment still yields a path', /profiles[\\/]desktop$/.test(resolveProfileDir({ DSH_HOME: 'C:\\h', DSH_PROFILE: 'desktop' })), resolveProfileDir({ DSH_HOME: 'C:\\h', DSH_PROFILE: 'desktop' }))
|
|
344
|
+
check('an unreadable profile is reported, not thrown', readManifest(join(tmpdir(), '__dsh-cicd-nope__')) === null)
|
|
345
|
+
|
|
346
|
+
/* -- 10. The npm channel ----------------------------------------------------
|
|
347
|
+
The push itself needs a registry and a credential, but almost none of its
|
|
348
|
+
judgement does: which registry to talk to, whether a token line exists, whether a
|
|
349
|
+
version is already published, and every reason a push must be refused are all pure
|
|
350
|
+
functions of strings and objects — which is exactly what makes them worth pinning. */
|
|
351
|
+
check('a registry gains the trailing slash every URL assumes', normalizeRegistry('https://registry.npmjs.org') === 'https://registry.npmjs.org/')
|
|
352
|
+
check('an explicit registry is kept', normalizeRegistry('https://npm.pkg.github.com/') === 'https://npm.pkg.github.com/')
|
|
353
|
+
check('a registry with a path keeps it', normalizeRegistry('https://example.com/npm') === 'https://example.com/npm/')
|
|
354
|
+
check('a non-http registry falls back rather than being used', normalizeRegistry('ftp://example.com/') === 'https://registry.npmjs.org/')
|
|
355
|
+
/* A credential smuggled into a registry URL would end up on screen and in logs. */
|
|
356
|
+
check('credentials inside a registry URL are refused', normalizeRegistry('https://user:pass@example.com/') === 'https://registry.npmjs.org/')
|
|
357
|
+
check('an unparseable registry falls back', normalizeRegistry('not a url') === 'https://registry.npmjs.org/')
|
|
358
|
+
check('an empty registry falls back', normalizeRegistry('') === 'https://registry.npmjs.org/')
|
|
359
|
+
check('the auth key is the registry host and path', npmrcAuthKey('https://registry.npmjs.org') === '//registry.npmjs.org/')
|
|
360
|
+
|
|
361
|
+
check('no .npmrc means no token', hasNpmToken('', 'https://registry.npmjs.org/') === false)
|
|
362
|
+
check('a token line is found', hasNpmToken('//registry.npmjs.org/:_authToken=abc\n', 'https://registry.npmjs.org/') === true)
|
|
363
|
+
check('a token for another registry is not this one', hasNpmToken('//npm.pkg.github.com/:_authToken=abc\n', 'https://registry.npmjs.org/') === false)
|
|
364
|
+
check('a commented-out token is not a token', hasNpmToken('# //registry.npmjs.org/:_authToken=abc\n', 'https://registry.npmjs.org/') === false)
|
|
365
|
+
check('an empty token value is not a token', hasNpmToken('//registry.npmjs.org/:_authToken=\n', 'https://registry.npmjs.org/') === false)
|
|
366
|
+
|
|
367
|
+
const npmrcSource = 'registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=old\n//npm.pkg.github.com/:_authToken=other\n'
|
|
368
|
+
const npmrcUpdated = upsertAuthToken(npmrcSource, 'https://registry.npmjs.org/', 'new-token')
|
|
369
|
+
check('an existing token is replaced in place', npmrcUpdated.ok === true && npmrcUpdated.text.includes('//registry.npmjs.org/:_authToken=new-token'))
|
|
370
|
+
check('another registry\'s token is left alone', npmrcUpdated.text.includes('//npm.pkg.github.com/:_authToken=other'))
|
|
371
|
+
check('unrelated settings survive', npmrcUpdated.text.includes('registry=https://registry.npmjs.org/'))
|
|
372
|
+
check('the replacement is reported as such', npmrcUpdated.replaced === true)
|
|
373
|
+
check('the old token is gone, not duplicated', npmrcUpdated.text.includes('_authToken=old') === false)
|
|
374
|
+
const npmrcAppended = upsertAuthToken('registry=https://registry.npmjs.org/\n', 'https://registry.npmjs.org/', 'tok')
|
|
375
|
+
check('a missing token line is appended', npmrcAppended.ok === true && npmrcAppended.text.includes('_authToken=tok') && npmrcAppended.replaced === false)
|
|
376
|
+
/* `.npmrc` is line-oriented: a value that could start a line would let a pasted
|
|
377
|
+
string become a second setting. */
|
|
378
|
+
check('a token that would forge a second line is refused', upsertAuthToken('', 'https://registry.npmjs.org/', 'abc\ndef').ok === false)
|
|
379
|
+
check('an empty token is refused', upsertAuthToken('', 'https://registry.npmjs.org/', ' ').ok === false)
|
|
380
|
+
|
|
381
|
+
check('an unknown name is unregistered', npmPackageState(null, '1.0.0').state === 'unregistered')
|
|
382
|
+
check('a published version is recognised', npmPackageState({ versions: { '1.0.0': {} }, 'dist-tags': { latest: '1.0.0' } }, '1.0.0').published === true)
|
|
383
|
+
check('a version that is not up there is unpublished', npmPackageState({ versions: { '1.0.0': {} }, 'dist-tags': { latest: '1.0.0' } }, '1.1.0').state === 'unpublished')
|
|
384
|
+
check('the latest tag is carried even when it is not this version', npmPackageState({ versions: {}, 'dist-tags': { latest: '2.0.0' } }, '1.0.0').latest === '2.0.0')
|
|
385
|
+
check('a malformed packument is unknown, not unregistered', npmPackageState('nope', '1.0.0').state === 'unknown')
|
|
386
|
+
|
|
387
|
+
const pushable = { packageName: 'x', version: '1.0.0', manifest: { private: false }, registryState: { state: 'unpublished', published: false, latest: '0.9.0' }, authed: true, dirty: 0 }
|
|
388
|
+
check('a clean, signed-in, unpublished package can be pushed', npmPublishVerdict(pushable).canPublish === true && npmPublishVerdict(pushable).blockers.length === 0)
|
|
389
|
+
for (const [blocker, override] of [
|
|
390
|
+
['private-package', { manifest: { private: true } }],
|
|
391
|
+
['not-logged-in', { authed: false }],
|
|
392
|
+
['dirty-tree', { dirty: 2 }],
|
|
393
|
+
['already-published', { registryState: { state: 'published', published: true, latest: '1.0.0' } }],
|
|
394
|
+
['registry-unreachable', { registryState: null }],
|
|
395
|
+
['no-version', { version: null }],
|
|
396
|
+
['no-checkout', { manifest: null }],
|
|
397
|
+
]) {
|
|
398
|
+
const verdict = npmPublishVerdict({ ...pushable, ...override })
|
|
399
|
+
check(`a push is refused when: ${blocker}`, verdict.canPublish === false && verdict.blockers.includes(blocker), verdict.blockers.join(',') || '(none)')
|
|
400
|
+
}
|
|
401
|
+
check('a refusal answers without throwing on nothing', npmPublishVerdict().canPublish === false)
|
|
402
|
+
|
|
403
|
+
/* Three states, because a granular token is scoped to packages and `whoami` is a
|
|
404
|
+
user-level endpoint: it can refuse a token that publishes perfectly well. Reading
|
|
405
|
+
that refusal as "not signed in" would tell a first-time publisher their brand-new
|
|
406
|
+
token is not a credential — the worst place for a false negative. */
|
|
407
|
+
check('a confirmed account is signed-in', npmAuthState({ whoami: true, hasToken: true }) === 'signed-in')
|
|
408
|
+
check('whoami alone is enough', npmAuthState({ whoami: true, hasToken: false }) === 'signed-in')
|
|
409
|
+
check('a token line without whoami is still a credential', npmAuthState({ whoami: false, hasToken: true }) === 'credential-present')
|
|
410
|
+
check('nothing at all is the state the guide belongs to', npmAuthState({ whoami: false, hasToken: false }) === 'none')
|
|
411
|
+
check('the state helper answers on an empty call', npmAuthState() === 'none')
|
|
412
|
+
/* And the verdict must not refuse a push just because whoami stayed quiet. */
|
|
413
|
+
check('an unconfirmed credential still offers the push', npmPublishVerdict({ ...pushable, authed: true }).canPublish === true)
|
|
414
|
+
|
|
415
|
+
check('the user-level .npmrc is where npm looks', resolveNpmrcPath({ USERPROFILE: 'C:\\u' }) === join('C:\\u', '.npmrc'))
|
|
416
|
+
check('an explicit NPM_CONFIG_USERCONFIG wins', resolveNpmrcPath({ USERPROFILE: 'C:\\u', NPM_CONFIG_USERCONFIG: 'D:\\x\\.npmrc' }) === 'D:\\x\\.npmrc')
|
|
417
|
+
|
|
418
|
+
/* The package manager is reused, not re-found — the same principle as reusing `gh`
|
|
419
|
+
instead of storing a token of this plugin's own. */
|
|
420
|
+
const profileInvocation = resolvePackageManagerInvocation({ get: () => ({ packageManager: { command: 'node.exe', args: ['pnpm.cjs'], env: { DSH_X: '1' } } }) })
|
|
421
|
+
check('the profile\'s own package manager is reused', profileInvocation.command === 'node.exe' && profileInvocation.args[0] === 'pnpm.cjs' && profileInvocation.source === 'profile')
|
|
422
|
+
check('its environment comes with it', profileInvocation.env.DSH_X === '1')
|
|
423
|
+
const fallbackInvocation = resolvePackageManagerInvocation({ get: () => undefined })
|
|
424
|
+
check('a Host without a profile still names something runnable', typeof fallbackInvocation.command === 'string' && fallbackInvocation.command !== '', fallbackInvocation.source)
|
|
425
|
+
check('the fallback reports where it got the answer', ['DSH_PNPM', 'bundled', 'path'].includes(fallbackInvocation.source), fallbackInvocation.source)
|
|
426
|
+
|
|
427
|
+
check('a directory that is not a checkout has no dirty count', (await readDirtyCount(join(tmpdir(), '__dsh-cicd-nope__'), 5_000)) === null)
|
|
428
|
+
check('an empty path has no dirty count', (await readDirtyCount('', 5_000)) === null)
|
|
429
|
+
|
|
430
|
+
/* -- 10b. What a failed publish means ----------------------------------------
|
|
431
|
+
This is what makes the credential guide usable rather than decorative: npm's own
|
|
432
|
+
words are jargon, and each of these five is a different next step on screen. The
|
|
433
|
+
order of the rules matters — `E403` shows up inside the email and scope messages,
|
|
434
|
+
so it must not be the first thing that matches. */
|
|
435
|
+
check('a one-time password demand is recognised', classifyPublishFailure('npm ERR! code EOTP\nThis operation requires a one-time password') === 'otp-required')
|
|
436
|
+
check('pnpm\'s own OTP error is recognised', classifyPublishFailure('[ERR_PNPM_OTP_REQUIRED] pass --otp') === 'otp-required')
|
|
437
|
+
check('an unverified email is recognised', classifyPublishFailure('npm ERR! 403 Forbidden - PUT … You must verify your email before publishing') === 'email-unverified')
|
|
438
|
+
check('an unverified email is not reported as a plain 403', classifyPublishFailure('E403 You must verify your email') === 'email-unverified')
|
|
439
|
+
check('a missing credential is recognised', classifyPublishFailure('npm ERR! code ENEEDAUTH') === 'not-logged-in')
|
|
440
|
+
check('a version conflict is recognised', classifyPublishFailure('npm ERR! code EPUBLISHCONFLICT cannot publish over previously published version') === 'already-published')
|
|
441
|
+
check('the paid-private-package refusal is recognised', classifyPublishFailure('npm ERR! 402 Payment Required — private packages require a paid account') === 'payment-required')
|
|
442
|
+
check('a scope refusal is recognised as forbidden', classifyPublishFailure('npm ERR! 403 Forbidden - PUT https://registry.npmjs.org/x - You do not have permission') === 'forbidden')
|
|
443
|
+
check('a missing name is recognised', classifyPublishFailure('npm ERR! 404 Not Found - PUT https://registry.npmjs.org/x') === 'not-found')
|
|
444
|
+
check('rate limiting is recognised', classifyPublishFailure('npm ERR! 429 Too Many Requests') === 'rate-limited')
|
|
445
|
+
check('a registry 5xx is not blamed on the user', classifyPublishFailure('npm ERR! 503 Service Unavailable') === 'registry-error')
|
|
446
|
+
check('a network failure is recognised', classifyPublishFailure('request to https://registry.npmjs.org failed, reason: getaddrinfo ENOTFOUND') === 'network')
|
|
447
|
+
check('unrecognised output is named unknown rather than guessed at', classifyPublishFailure('something else entirely') === 'unknown')
|
|
448
|
+
check('empty output is unknown, not a crash', classifyPublishFailure('') === 'unknown' && classifyPublishFailure(undefined) === 'unknown')
|
|
449
|
+
|
|
450
|
+
/* The read that decides whether a version is already on npm goes through a CDN with
|
|
451
|
+
`max-age=300` — measured, a plain GET came back with `age: 116`, so for up to five
|
|
452
|
+
minutes after a successful publish the panel still described the previous state and
|
|
453
|
+
kept offering a push the registry would refuse. */
|
|
454
|
+
check('the packument is read from the origin, not a five-minute-old CDN copy', packumentUrl('https://registry.npmjs.org/', 'dsh-plugin-restart') === 'https://registry.npmjs.org/dsh-plugin-restart?write=true')
|
|
455
|
+
|
|
456
|
+
/* The names a 提交 would carry, read out of `git status --porcelain`. This list is what
|
|
457
|
+
makes `git add -A` an informed action rather than a blind sweep, so the two shapes
|
|
458
|
+
that actually occur — a status prefix, and a rename — have to come out readable. */
|
|
459
|
+
check('a modified file keeps only its path', statusPath(' M package.json') === 'package.json')
|
|
460
|
+
check('an untracked file keeps only its path', statusPath('?? notes.md') === 'notes.md')
|
|
461
|
+
check('a staged file keeps only its path', statusPath('A src/new.js') === 'src/new.js')
|
|
462
|
+
check('a rename is passed through as git spelled it', statusPath('R old.md -> new.md') === 'old.md -> new.md')
|
|
463
|
+
check('a line too short to hold a path is not sliced into nonsense', statusPath('??') === '??')
|
|
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
|
+
|
|
466
|
+
/* The line the panel shows. Captured from a real `pnpm publish` of an
|
|
467
|
+
already-published version: every byte goes to STDOUT (measured: stderr = 0), the
|
|
468
|
+
first line is the progress line, the rest is a stack trace, and node's own
|
|
469
|
+
`Command failed: <the whole command>` is what a naive "first line" picks up. */
|
|
470
|
+
const realConflictOutput = [
|
|
471
|
+
'📦 dsh-plugin-restart@1.0.1 → https://registry.npmjs.org/',
|
|
472
|
+
'[E403] 403 Forbidden - PUT https://registry.npmjs.org/dsh-plugin-restart - You cannot publish over the previously published versions: 1.0.1.',
|
|
473
|
+
'',
|
|
474
|
+
'pnpm: 403 Forbidden - PUT https://registry.npmjs.org/dsh-plugin-restart - You cannot publish over the previously published versions: 1.0.1.',
|
|
475
|
+
' at file:///C:/Users/Administrator/AppData/Local/Programs/DeepSeek%20Harness/resources/runtime/pnpm/dist/pnpm.mjs:257427:17',
|
|
476
|
+
' at async publish3 (file:///C:/Users/Administrator/AppData/Local/Programs/DeepSeek%20Harness/resources/runtime/pnpm/dist/pnpm.mjs:281124:19)',
|
|
477
|
+
].join('\n')
|
|
478
|
+
const shown = firstMeaningfulLine(realConflictOutput, 'the publish failed')
|
|
479
|
+
check('the reason is shown, not the progress line', shown.startsWith('[E403] 403 Forbidden'), shown)
|
|
480
|
+
check('the shown line carries the actual reason', shown.includes('cannot publish over the previously published versions'))
|
|
481
|
+
check('the progress line is not mistaken for the error', shown.startsWith('📦') === false)
|
|
482
|
+
check('a stack frame is never the answer', /^\s*at /.test(shown) === false)
|
|
483
|
+
check('node\'s command-line wrapper never reaches the panel', shown.startsWith('Command failed:') === false)
|
|
484
|
+
check('pnpm\'s own sentence is used when there is no coded line', firstMeaningfulLine('📦 x@1.0.0 → reg\n\npnpm: something went wrong\n at x.js:1:1').startsWith('pnpm: something went wrong'))
|
|
485
|
+
check('the wrapper is skipped when it is all there is', firstMeaningfulLine('Command failed: pnpm publish', 'the publish failed') === 'the publish failed')
|
|
486
|
+
check('nothing usable answers with the caller\'s sentence', firstMeaningfulLine('', 'the publish failed') === 'the publish failed')
|
|
487
|
+
check('stdout is searched at all, so an empty stderr cannot hide the reason', firstMeaningfulLine('[ERR_PNPM_WHOAMI_UNAUTHORIZED] You must be logged in to use whoami', 'x').startsWith('[ERR_PNPM_WHOAMI_UNAUTHORIZED]'))
|
|
488
|
+
check('a failed result is read across both streams', commandFailureLine({ stdout: realConflictOutput, stderr: '', error: 'Command failed: <the whole command>' }, 'the publish failed').startsWith('[E403]'))
|
|
489
|
+
check('the child-process error is the last resort, not the first line', commandFailureLine({ stdout: '', stderr: '', error: 'Command failed: pnpm publish' }, 'the publish failed') === 'Command failed: pnpm publish')
|
|
490
|
+
check('a spawn failure with no message still says something', commandFailureLine({}, 'the publish failed') === 'the publish failed')
|
|
491
|
+
|
|
492
|
+
/* -- 11. Live checks (opt-in) ----------------------------------------------- */
|
|
493
|
+
if (process.env.DSH_CICD_LIVE === '1') {
|
|
494
|
+
console.log('\n-- live checks (DSH_CICD_LIVE=1) --')
|
|
495
|
+
const version = await runTool(ghPath, ['--version'], 10_000)
|
|
496
|
+
check('gh runs', version.ok && /gh version/.test(version.stdout.split('\n')[0] ?? ''), version.stdout.split('\n')[0] ?? version.stderr)
|
|
497
|
+
|
|
498
|
+
const whoami = await runTool(ghPath, ['api', 'user', '--jq', '.login'], 15_000)
|
|
499
|
+
check('gh is authenticated', whoami.ok && whoami.stdout.trim() !== '', whoami.stdout.trim() || whoami.stderr.trim())
|
|
500
|
+
|
|
501
|
+
const owner = whoami.ok ? whoami.stdout.trim() : ''
|
|
502
|
+
const live = resolveConfig({ owner, repos: [{ repo: 'dsh-plugin-restart', localPath: 'F:\\CodeProj\\dsh-plugin-restart' }] })
|
|
503
|
+
const overview = await collectRepo({ config: live, ghPath, entry: live.repos[0] })
|
|
504
|
+
check('overview has no problems', overview.problems.length === 0, overview.problems.join(' | '))
|
|
505
|
+
check('runs parsed', Array.isArray(overview.runs) && overview.runs.length > 0, `${overview.runs.length} runs`)
|
|
506
|
+
check('run fields typed', typeof overview.latestRun?.id === 'number' && typeof overview.latestRun?.workflow === 'string')
|
|
507
|
+
check('workflows parsed', overview.workflows.length >= 1, overview.workflows.map((entry) => entry.path).join(','))
|
|
508
|
+
check('build workflow detected', overview.hasBuildWorkflow === true)
|
|
509
|
+
check('release workflow detected', overview.hasReleaseWorkflow === true)
|
|
510
|
+
|
|
511
|
+
const missing = await collectRepo({ config: live, ghPath, entry: { repo: 'this-repo-does-not-exist-xyz', localPath: '', label: '' } })
|
|
512
|
+
check('a missing repository degrades to a message', missing.problems.length > 0, missing.problems[0] ?? '')
|
|
513
|
+
check('a missing repository still returns a shape', Array.isArray(missing.runs) && missing.runs.length === 0)
|
|
514
|
+
check('a missing repository is not an exception', missing.latestRun === null)
|
|
515
|
+
|
|
516
|
+
const nonsense = await ghJson(ghPath, ['api', 'repos/this/does-not-exist-xyz'], 15_000)
|
|
517
|
+
check('gh JSON failure is a message, not a throw', nonsense.ok === false && typeof nonsense.message === 'string', nonsense.message)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
console.log(`\n${results.length - failed}/${results.length} checks passed`)
|
|
521
|
+
if (failed > 0) process.exit(1)
|