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,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)
|