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.
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mount the Host half for real and serve its routes.
4
+ *
5
+ * The rest of the suite imports functions and calls them directly. This one does
6
+ * what the loader does — calls `apply(ctx, config)` with a `webServer` that
7
+ * actually mounts the handlers on an `http.Server` — and then talks to it over
8
+ * HTTP. That is the difference between "the code is correct" and "the package
9
+ * activates": a route that is never registered, or registered under a path the
10
+ * client does not call, passes every unit check and still leaves the panel empty.
11
+ *
12
+ * It takes the package root as an argument, so it can be pointed at an unpacked
13
+ * release asset to prove the *published* copy activates, not just the checkout:
14
+ *
15
+ * node tests/mount-check.mjs
16
+ * node /tmp/asset/package/tests/mount-check.mjs /tmp/asset/package
17
+ *
18
+ * `DSH_CICD_LIVE=1` adds the calls that reach GitHub.
19
+ */
20
+
21
+ import { createServer } from 'node:http'
22
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
23
+ import { tmpdir } from 'node:os'
24
+ import { join, resolve } from 'node:path'
25
+ import { fileURLToPath, pathToFileURL } from 'node:url'
26
+
27
+ const here = fileURLToPath(new URL('.', import.meta.url))
28
+ const packageRoot = resolve(process.argv[2] ?? join(here, '..'))
29
+ const module = await import(pathToFileURL(join(packageRoot, 'index.js')).href)
30
+
31
+ const results = []
32
+ let failed = 0
33
+ function check(label, condition, detail = '') {
34
+ const pass = condition === true
35
+ if (!pass) failed += 1
36
+ results.push({ label, pass })
37
+ console.log(`${pass ? 'ok ' : 'FAIL'} ${label}${detail === '' ? '' : ` — ${detail}`}`)
38
+ }
39
+
40
+ /* A config file of our own, so this test never depends on the machine's list. */
41
+ const scratch = mkdtempSync(join(tmpdir(), 'dsh-cicd-mount-'))
42
+ const configFile = join(scratch, 'repos.json')
43
+ writeFileSync(configFile, JSON.stringify({ owner: 'octocat', repos: [{ repo: 'octocat/Hello-World' }] }), 'utf8')
44
+
45
+ /** Capture what `apply` registers, exactly as a loader would. */
46
+ const routes = new Map()
47
+ const effects = []
48
+ const logs = []
49
+ const ctx = {
50
+ effect: (fn) => {
51
+ effects.push(fn)
52
+ return fn
53
+ },
54
+ logger: { info: (...args) => logs.push(args.join(' ')) },
55
+ webServer: {
56
+ register: ({ path, handler }) => {
57
+ routes.set(path, handler)
58
+ return () => routes.delete(path)
59
+ },
60
+ },
61
+ }
62
+
63
+ module.apply(ctx, { owner: 'octocat', configFile })
64
+
65
+ const EXPECTED = [
66
+ '/api/dsh-cicd/status',
67
+ '/api/dsh-cicd/overview',
68
+ '/api/dsh-cicd/runs',
69
+ '/api/dsh-cicd/dispatch',
70
+ '/api/dsh-cicd/run-action',
71
+ '/api/dsh-cicd/release-action',
72
+ // The step 发布 was missing: the release workflow refuses to reuse a version
73
+ // that belongs to another commit, so the console has to be able to move the
74
+ // version forward itself.
75
+ '/api/dsh-cicd/version-bump',
76
+ // The step before 构建 and 发布: a release builds the PUSHED commit, so the console
77
+ // that can cut a release has to be able to get the work onto GitHub first.
78
+ '/api/dsh-cicd/commit',
79
+ '/api/dsh-cicd/logs',
80
+ // Taking the release, not only cutting it: the artifact is installed into the
81
+ // profile through the Host's own plugin manager, and the restart is forwarded to
82
+ // the plugin that owns restarting.
83
+ '/api/dsh-cicd/update',
84
+ '/api/dsh-cicd/restart',
85
+ // The second distribution channel: what npm holds, the credential that publishes
86
+ // it, and the push itself.
87
+ '/api/dsh-cicd/npm-status',
88
+ '/api/dsh-cicd/npm-login',
89
+ '/api/dsh-cicd/npm-publish',
90
+ // Setup without a terminal: the browser sign-in and the repository list are
91
+ // routes, not instructions to go and run something elsewhere.
92
+ '/api/dsh-cicd/auth-start',
93
+ '/api/dsh-cicd/auth-state',
94
+ '/api/dsh-cicd/auth-cancel',
95
+ // Signing out belongs here for the same reason as signing in: the panel owns the
96
+ // credential's whole lifecycle, so no step of it should require a terminal.
97
+ '/api/dsh-cicd/auth-logout',
98
+ '/api/dsh-cicd/repos-available',
99
+ '/api/dsh-cicd/config-add',
100
+ '/api/dsh-cicd/config-remove',
101
+ // The third channel: an update note under the video that introduces the plugin.
102
+ // A separate credential system with its own sign-in, its own storage, and one
103
+ // route that writes something the whole world can read.
104
+ '/api/dsh-cicd/bilibili-status',
105
+ '/api/dsh-cicd/bilibili-login-start',
106
+ '/api/dsh-cicd/bilibili-login-poll',
107
+ '/api/dsh-cicd/bilibili-login-cancel',
108
+ '/api/dsh-cicd/bilibili-credential',
109
+ '/api/dsh-cicd/bilibili-logout',
110
+ '/api/dsh-cicd/bilibili-bind',
111
+ '/api/dsh-cicd/bilibili-announce',
112
+ ]
113
+ check('every route is registered', EXPECTED.every((path) => routes.has(path)), `${routes.size} registered`)
114
+ check('nothing extra is registered', routes.size === EXPECTED.length, [...routes.keys()].join(','))
115
+ check('the mount was logged', logs.some((line) => line.includes('/api/dsh-cicd')), logs[0] ?? '')
116
+
117
+ const server = createServer((req, res) => {
118
+ const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
119
+ const handler = routes.get(path)
120
+ if (handler === undefined) {
121
+ res.writeHead(404, { 'content-type': 'application/json' })
122
+ res.end('{"ok":false,"code":"no-route"}')
123
+ return
124
+ }
125
+ void handler(req, res)
126
+ })
127
+ await new Promise((done) => {
128
+ server.listen(0, '127.0.0.1', done)
129
+ })
130
+ const base = `http://127.0.0.1:${server.address().port}`
131
+
132
+ const call = async (path, body, method = 'POST') => {
133
+ const response = await fetch(`${base}${path}`, {
134
+ method,
135
+ headers: { 'content-type': 'application/json' },
136
+ body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined,
137
+ })
138
+ let payload = null
139
+ try {
140
+ payload = await response.json()
141
+ } catch {
142
+ payload = null
143
+ }
144
+ return { status: response.status, payload }
145
+ }
146
+
147
+ try {
148
+ const status = await call('/api/dsh-cicd/status', {})
149
+ check('status answers over HTTP', status.status === 200 && status.payload?.ok === true, `HTTP ${status.status}`)
150
+ const value = status.payload?.value ?? {}
151
+ // The page and the Host half load independently, so the handshake is part of the
152
+ // contract: without it a stale Host looks like a broken account.
153
+ check('the Host declares a protocol version', typeof value.protocol === 'number' && value.protocol >= 2, String(value.protocol))
154
+ // `gh` ships on GitHub's runners and on the machine this was written for, so
155
+ // "installed" is a safe expectation; "signed in" is not, and is not asserted.
156
+ check('gh is reported as available', value.gh?.available === true, String(value.gh?.version ?? value.gh?.message))
157
+ check('the managed file is the reported source', value.configSource === 'file', String(value.configSource))
158
+ check('the configured repository is reported', value.repos?.[0]?.repo === 'octocat/Hello-World', JSON.stringify(value.repos))
159
+ check('the install path is quoted for the setup guide', typeof value.helper?.configureScript === 'string' && value.helper.configureScript.endsWith('configure.mjs'), String(value.helper?.configureScript))
160
+ check('the scope fields exist', Array.isArray(value.gh?.scopes) && Array.isArray(value.gh?.missingScopes))
161
+
162
+ const get = await call('/api/dsh-cicd/status', {}, 'GET')
163
+ check('a GET is refused', get.status === 405 && get.payload?.code === 'method-not-allowed', `HTTP ${get.status}`)
164
+
165
+ const unknownRoute = await call('/api/dsh-cicd/nope', {})
166
+ check('an unknown path is not ours', unknownRoute.status === 404, `HTTP ${unknownRoute.status}`)
167
+
168
+ const unconfigured = await call('/api/dsh-cicd/runs', { repo: 'someone/else' })
169
+ check('an unconfigured repository is refused', unconfigured.status === 400 && /not configured/.test(unconfigured.payload?.message ?? ''), unconfigured.payload?.message ?? '')
170
+
171
+ const badAction = await call('/api/dsh-cicd/run-action', { repo: 'octocat/Hello-World', runId: 1, action: 'obliterate' })
172
+ check('an unsupported run action is refused before gh runs', badAction.status === 400, badAction.payload?.message ?? '')
173
+
174
+ const badTag = await call('/api/dsh-cicd/release-action', { repo: 'octocat/Hello-World', tag: '$(rm -rf /)', action: 'publish' })
175
+ check('an unusable tag is refused before gh runs', badTag.status === 400, badTag.payload?.message ?? '')
176
+
177
+ /* --- taking a release ---------------------------------------------------
178
+ The two refusals below are the ones that must happen BEFORE any network call,
179
+ because that is the ordering claim the update route makes: an unconfigured
180
+ repository and a Host without a plugin manager are both local facts. `ctx` here
181
+ carries only `webServer` on purpose — that IS the "no pluginManager"
182
+ composition — so this asserts the 501 rather than mocking an install, which
183
+ would either need a real profile or prove nothing about one. */
184
+ const updateUnknown = await call('/api/dsh-cicd/update', { repo: 'someone/else' })
185
+ check('an update of an unconfigured repository is refused', updateUnknown.status === 400 && /not configured/.test(updateUnknown.payload?.message ?? ''), updateUnknown.payload?.message ?? '')
186
+
187
+ const updateNoManager = await call('/api/dsh-cicd/update', { repo: 'octocat/Hello-World' })
188
+ check('an update without a plugin manager is refused before GitHub', updateNoManager.status === 501 && updateNoManager.payload?.code === 'plugin-manager-missing', updateNoManager.payload?.message ?? '')
189
+
190
+ /* No restart plugin is mounted on this test server, so the forward meets a 404 —
191
+ which is the real shape of "dsh-plugin-restart is not installed". */
192
+ const restartMissing = await call('/api/dsh-cicd/restart', {})
193
+ check('a restart without the restart plugin is named, not a bare failure', restartMissing.status === 501 && restartMissing.payload?.code === 'restart-unavailable', restartMissing.payload?.message ?? '')
194
+
195
+ /* --- the npm channel ----------------------------------------------------
196
+ Every refusal below happens before anything leaves this process, which is the
197
+ claim worth pinning: an unconfigured repository, a repository with no checkout to
198
+ publish, an empty token and a token that would forge a second `.npmrc` line are
199
+ all local facts. The status route is asserted only as "a verdict, not a hang" —
200
+ it reaches registry.npmjs.org, and a test that needs the public registry to
201
+ answer is a test that fails on a plane. */
202
+ const npmUnknown = await call('/api/dsh-cicd/npm-publish', { repo: 'someone/else' })
203
+ check('publishing an unconfigured repository is refused', npmUnknown.status === 400 && /not configured/.test(npmUnknown.payload?.message ?? ''), npmUnknown.payload?.message ?? '')
204
+
205
+ const npmNoCheckout = await call('/api/dsh-cicd/npm-publish', { repo: 'octocat/Hello-World' })
206
+ check('publishing without a local checkout is refused before npm is asked', npmNoCheckout.status === 400 && npmNoCheckout.payload?.code === 'no-checkout', npmNoCheckout.payload?.message ?? '')
207
+
208
+ const npmEmptyToken = await call('/api/dsh-cicd/npm-login', { token: ' ' })
209
+ check('an empty npm token is refused', npmEmptyToken.status === 400 && npmEmptyToken.payload?.code === 'bad-token', npmEmptyToken.payload?.message ?? '')
210
+
211
+ const npmInjectedToken = await call('/api/dsh-cicd/npm-login', { token: 'abc\ndef' })
212
+ check('a token that would forge a second .npmrc line is refused', npmInjectedToken.status === 400 && npmInjectedToken.payload?.code === 'bad-token', npmInjectedToken.payload?.message ?? '')
213
+
214
+ const npmStatus = await call('/api/dsh-cicd/npm-status', {})
215
+ check('npm-status answers with a verdict', npmStatus.payload !== null && typeof npmStatus.payload.ok === 'boolean', `HTTP ${npmStatus.status}`)
216
+
217
+ /*
218
+ * The Bilibili family, on the paths that must not touch the network: no
219
+ * credential is stored in this scratch directory, no repository is bound to a
220
+ * video, and a BV id that is not one is refused before anything is asked of
221
+ * Bilibili. The deeper flow — a bound video, a staged sign-in, a comment that
222
+ * posts — is driven with a fake transport in `tests/bilibili-check.mjs`.
223
+ */
224
+ const biliStatus = await call('/api/dsh-cicd/bilibili-status', {})
225
+ check('bilibili-status answers with a credential verdict', biliStatus.status === 200 && typeof biliStatus.payload?.value?.credential?.state === 'string', `HTTP ${biliStatus.status}`)
226
+ const biliBadBvid = await call('/api/dsh-cicd/bilibili-bind', { repo: 'octocat/Hello-World', bvid: 'not-a-bv' })
227
+ check('a BV id that is not one is refused', biliBadBvid.status === 400 && biliBadBvid.payload?.code === 'bad-bvid', biliBadBvid.payload?.message ?? '')
228
+ const biliUnknownRepo = await call('/api/dsh-cicd/bilibili-announce', { repo: 'someone/else' })
229
+ check('announcing an unconfigured repository is refused', biliUnknownRepo.status === 400 && /not configured/.test(biliUnknownRepo.payload?.message ?? ''), biliUnknownRepo.payload?.message ?? '')
230
+ const biliNoLogin = await call('/api/dsh-cicd/bilibili-login-poll', {})
231
+ check('polling a sign-in that was never started is refused', biliNoLogin.status === 409 && biliNoLogin.payload?.code === 'no-login', biliNoLogin.payload?.message ?? '')
232
+
233
+ /* --- setup routes -------------------------------------------------------
234
+ Only the refusal paths of `auth-start` are exercised: its success path spawns
235
+ `gh auth login --web`, which would start a real device flow and leave a child
236
+ polling GitHub for ten minutes. A test must not do that to whoever runs it. */
237
+ const idle = await call('/api/dsh-cicd/auth-state', {})
238
+ check('auth-state starts idle', idle.status === 200 && idle.payload?.value?.state === 'idle', JSON.stringify(idle.payload?.value))
239
+
240
+ const badMode = await call('/api/dsh-cicd/auth-start', { mode: 'sudo' })
241
+ check('an unsupported auth mode is refused without spawning', badMode.status === 400, badMode.payload?.message ?? '')
242
+
243
+ const emptyRefresh = await call('/api/dsh-cicd/auth-start', { mode: 'refresh', scopes: [] })
244
+ check('a refresh with no justifiable scope is refused', emptyRefresh.status === 400, emptyRefresh.payload?.message ?? '')
245
+
246
+ const cancelIdle = await call('/api/dsh-cicd/auth-cancel', {})
247
+ // Cancelling with nothing in flight is a no-op that reports the truth. Saying
248
+ // "cancelled" here would be a small lie the panel would then render.
249
+ check('cancelling with nothing in flight reports idle', cancelIdle.status === 200 && cancelIdle.payload?.value?.state === 'idle', JSON.stringify(cancelIdle.payload?.value))
250
+
251
+ const badAdd = await call('/api/dsh-cicd/config-add', { repo: '--flag' })
252
+ check('an unusable repository name is refused', badAdd.status === 400, badAdd.payload?.message ?? '')
253
+
254
+ const relativeAdd = await call('/api/dsh-cicd/config-add', { repo: 'octocat/Hello-World', localPath: 'relative\\dir' })
255
+ check('a relative localPath is refused', relativeAdd.status === 400, relativeAdd.payload?.message ?? '')
256
+
257
+ const added = await call('/api/dsh-cicd/config-add', { repo: 'octocat/Spoon-Knife' })
258
+ check('a repository can be registered from the panel', added.status === 200 && added.payload?.value?.registered === 2, JSON.stringify(added.payload?.value))
259
+ const listAfterAdd = JSON.parse(readFileSync(configFile, 'utf8'))
260
+ check('the registration really reached the file', listAfterAdd.repos.some((entry) => entry.repo === 'octocat/Spoon-Knife'), JSON.stringify(listAfterAdd.repos.map((entry) => entry.repo)))
261
+ check('a backup of the previous revision is kept', existsSync(`${configFile}.bak`))
262
+
263
+ const removed = await call('/api/dsh-cicd/config-remove', { repo: 'octocat/Spoon-Knife' })
264
+ check('a repository can be unregistered from the panel', removed.status === 200 && removed.payload?.value?.registered === 1, JSON.stringify(removed.payload?.value))
265
+ const removeAgain = await call('/api/dsh-cicd/config-remove', { repo: 'octocat/Spoon-Knife' })
266
+ check('removing twice is refused, not silent', removeAgain.status === 400, removeAgain.payload?.message ?? '')
267
+
268
+ const available = await call('/api/dsh-cicd/repos-available', { limit: 1 })
269
+ // Either it lists (signed in) or it reports why not (not signed in). What must
270
+ // never happen is a non-JSON answer or a hang.
271
+ check('repos-available answers with a verdict', available.payload !== null && typeof available.payload.ok === 'boolean', `HTTP ${available.status}`)
272
+
273
+ if (process.env.DSH_CICD_LIVE === '1') {
274
+ console.log('\n-- live over HTTP (DSH_CICD_LIVE=1) --')
275
+ const live = await call('/api/dsh-cicd/overview', { force: true })
276
+ check('overview answers over HTTP', live.status === 200 && live.payload?.ok === true, `HTTP ${live.status}`)
277
+ // octocat/Hello-World is a real public repository, so a real read is possible
278
+ // without any credential at all.
279
+ check('overview carries the configured repository', (live.payload?.value?.repos ?? []).length === 1, JSON.stringify((live.payload?.value?.repos ?? []).map((entry) => entry.repo)))
280
+ }
281
+ } finally {
282
+ await new Promise((done) => {
283
+ server.close(done)
284
+ })
285
+ for (const effect of effects) {
286
+ // Unregister through the captured effects, which is what a teardown does.
287
+ try {
288
+ const disposer = effect()
289
+ if (typeof disposer === 'function') disposer()
290
+ } catch {
291
+ /* a teardown that throws is not this test's subject */
292
+ }
293
+ }
294
+ }
295
+
296
+ console.log(`\n${results.length - failed}/${results.length} checks passed`)
297
+ if (failed > 0) process.exit(1)