dsh-plugin-cicd 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +402 -2
- package/RELEASING.md +63 -0
- package/client.js +4417 -0
- package/cordis.patch.yml +63 -0
- package/icon.svg +8 -0
- package/index.js +4938 -0
- package/lib/bilibili.mjs +1000 -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 +450 -0
- package/tests/bump-e2e.mjs +431 -0
- package/tests/client-render.mjs +1483 -0
- package/tests/host-checks.mjs +576 -0
- package/tests/mount-check.mjs +300 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Checks for the Bilibili update notes: the pure decisions, the transport, and the
|
|
4
|
+
* routes, all without a real account and without a real `gh`.
|
|
5
|
+
*
|
|
6
|
+
* The rule this feature lives or dies by is idempotence — one comment per release,
|
|
7
|
+
* ever — and idempotence is exactly what a test against a live account cannot
|
|
8
|
+
* prove, because the second run of a live test is the bug. So every layer is
|
|
9
|
+
* driven here instead:
|
|
10
|
+
*
|
|
11
|
+
* - the pure module decides what to say and whether to say it at all;
|
|
12
|
+
* - `createBilibiliClient` is handed a fake `fetch`, so a `-412`, a QR code that
|
|
13
|
+
* was scanned but not confirmed, and a `Set-Cookie` sign-in are all reachable;
|
|
14
|
+
* - the mounted Host is given that same fake `fetch` through the row config, and
|
|
15
|
+
* a `ghPath` that cannot exist, so "the release list could not be read" is a
|
|
16
|
+
* deterministic result rather than a network accident.
|
|
17
|
+
*
|
|
18
|
+
* node tests/bilibili-check.mjs
|
|
19
|
+
* node /tmp/asset/package/tests/bilibili-check.mjs /tmp/asset/package
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createServer } from 'node:http'
|
|
23
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { tmpdir } from 'node:os'
|
|
25
|
+
import { join, resolve } from 'node:path'
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
27
|
+
import {
|
|
28
|
+
announcementVerdict,
|
|
29
|
+
composeComment,
|
|
30
|
+
cookieHeader,
|
|
31
|
+
cookiesFromLoginUrl,
|
|
32
|
+
createBilibiliClient,
|
|
33
|
+
credentialVerdict,
|
|
34
|
+
emptyLedger,
|
|
35
|
+
findLedgerEntry,
|
|
36
|
+
latestBaseline,
|
|
37
|
+
newestPublishedRelease,
|
|
38
|
+
normalizeBvid,
|
|
39
|
+
parseCookieJar,
|
|
40
|
+
parseLedger,
|
|
41
|
+
recordLedgerEntry,
|
|
42
|
+
replyFailure,
|
|
43
|
+
MAINTENANCE_NOTE,
|
|
44
|
+
summarizeCommits,
|
|
45
|
+
summarizeRelease,
|
|
46
|
+
withDeviceIds,
|
|
47
|
+
} from '../lib/bilibili.mjs'
|
|
48
|
+
|
|
49
|
+
const here = fileURLToPath(new URL('.', import.meta.url))
|
|
50
|
+
const packageRoot = resolve(process.argv[2] ?? join(here, '..'))
|
|
51
|
+
const module = await import(pathToFileURL(join(packageRoot, 'index.js')).href)
|
|
52
|
+
|
|
53
|
+
const results = []
|
|
54
|
+
let failed = 0
|
|
55
|
+
function check(label, condition, detail = '') {
|
|
56
|
+
const pass = condition === true
|
|
57
|
+
if (!pass) failed += 1
|
|
58
|
+
results.push({ label, pass })
|
|
59
|
+
console.log(`${pass ? 'ok ' : 'FAIL'} ${label}${detail === '' ? '' : ` — ${detail}`}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ---- 1. the BV id, and the three cookie shapes that really occur ---------- */
|
|
63
|
+
|
|
64
|
+
check('a BV id is accepted', normalizeBvid('BV1RopP6FEJp') === 'BV1RopP6FEJp')
|
|
65
|
+
check('a trimmed BV id is accepted', normalizeBvid(' BV1RopP6FEJp ') === 'BV1RopP6FEJp')
|
|
66
|
+
check('an aid is not a BV id', normalizeBvid('117396371216915') === null)
|
|
67
|
+
check('a short BV id is refused', normalizeBvid('BV1Rop') === null)
|
|
68
|
+
check('a non-string is refused', normalizeBvid(undefined) === null)
|
|
69
|
+
|
|
70
|
+
/* biliup's own file: `cookie_info.cookies[]`, with the platform that matters. */
|
|
71
|
+
const biliupJar = JSON.stringify({
|
|
72
|
+
cookie_info: { cookies: [
|
|
73
|
+
{ name: 'SESSDATA', value: 'abc%2C123%2Cdef', expires: 1806856570 },
|
|
74
|
+
{ name: 'bili_jct', value: 'cafebabe' },
|
|
75
|
+
{ name: 'DedeUserID', value: '41942885' },
|
|
76
|
+
] },
|
|
77
|
+
platform: 'BiliTV',
|
|
78
|
+
token_info: { access_token: 'x' },
|
|
79
|
+
})
|
|
80
|
+
const parsedBiliup = parseCookieJar(biliupJar)
|
|
81
|
+
check('biliup cookies.json is understood', parsedBiliup.ok === true && parsedBiliup.sessdata === 'abc%2C123%2Cdef')
|
|
82
|
+
check('the platform is carried, because it explains a -101', parsedBiliup.platform === 'BiliTV')
|
|
83
|
+
check('the expiry is read for display', parsedBiliup.expiresAt === 1806856570)
|
|
84
|
+
check('the csrf is the bili_jct cookie', parsedBiliup.csrf === 'cafebabe')
|
|
85
|
+
check('a jar becomes a Cookie header', cookieHeader({ a: '1', b: '2' }) === 'a=1; b=2')
|
|
86
|
+
|
|
87
|
+
/* The plugin's own file, and a raw header pasted out of a browser. */
|
|
88
|
+
check('our own file shape is understood', parseCookieJar({ cookies: { SESSDATA: 's', bili_jct: 'j' } }).csrf === 'j')
|
|
89
|
+
check('a pasted header is understood', parseCookieJar('SESSDATA=s; bili_jct=j').sessdata === 's')
|
|
90
|
+
check('a pasted header with spaces is understood', parseCookieJar(' SESSDATA = s ; bili_jct = j ').csrf === 'j')
|
|
91
|
+
check('an empty source is refused', parseCookieJar('').ok === false)
|
|
92
|
+
check('a jar without a session is reported incomplete', credentialVerdict({ jar: parseCookieJar({ cookies: { bili_jct: 'j' } }) }).state === 'incomplete')
|
|
93
|
+
check('an unreadable source is reported as such', credentialVerdict({ jar: parseCookieJar('nonsense') }).state === 'unreadable')
|
|
94
|
+
|
|
95
|
+
/* The measured trap: a live APP credential the web endpoints still refuse. */
|
|
96
|
+
const tvVerdict = credentialVerdict({ jar: parsedBiliup, account: { ok: false, code: -101, message: '账号未登录' } })
|
|
97
|
+
check('an APP credential is named, not called "not signed in"', tvVerdict.state === 'not-logged-in' && tvVerdict.message.includes('BiliTV'), tvVerdict.message)
|
|
98
|
+
check('a web session is ready once the account answers', credentialVerdict({ jar: parseCookieJar('SESSDATA=s; bili_jct=j'), account: { ok: true, mid: '1', uname: 'me' } }).state === 'ready')
|
|
99
|
+
|
|
100
|
+
/* ---- 2. what the comment would say --------------------------------------- */
|
|
101
|
+
|
|
102
|
+
check('markdown headings lose their hashes', summarizeRelease({ tag: 'v1', name: '## A title' }) === 'A title')
|
|
103
|
+
check('links keep their label and lose the URL', summarizeRelease({ tag: 'v1', name: 'see [the docs](https://x/y) now' }) === 'see the docs now')
|
|
104
|
+
check('a name equal to the tag is not a summary', summarizeRelease({ tag: 'v0.5.1', name: 'v0.5.1', body: 'Real change here' }) === 'Real change here')
|
|
105
|
+
const autoNotes = summarizeRelease({
|
|
106
|
+
tag: 'v2',
|
|
107
|
+
body: "## What's Changed\n* Fix the thing by @someone in https://github.com/o/r/pull/1\n\n**Full Changelog**: https://github.com/o/r/compare/v1...v2",
|
|
108
|
+
})
|
|
109
|
+
check('GitHub\'s own boilerplate is not the summary', autoNotes === 'Fix the thing', autoNotes)
|
|
110
|
+
check('an empty release still says something', summarizeRelease({ tag: 'v3' }, { fallback: '本次更新已发布。' }) === '本次更新已发布。')
|
|
111
|
+
check('a long summary is cut, not dumped', summarizeRelease({ tag: 'v1', name: 'x'.repeat(300) }).length <= 90)
|
|
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
|
+
|
|
163
|
+
const composed = composeComment({
|
|
164
|
+
label: '发布台',
|
|
165
|
+
repo: 'dsh-plugin-cicd',
|
|
166
|
+
tag: 'v0.5.1',
|
|
167
|
+
release: { tag: 'v0.5.1', name: '新增 B 站更新播报', url: 'https://example.invalid/v0.5.1' },
|
|
168
|
+
template: '',
|
|
169
|
+
})
|
|
170
|
+
check('the default template names the version and the change', composed.text === '【更新 v0.5.1】新增 B 站更新播报', composed.text)
|
|
171
|
+
check('the default template carries no link', composed.text.includes('http') === false, composed.text)
|
|
172
|
+
const withUrl = composeComment({ tag: 'v1', release: { tag: 'v1', name: 'n', url: 'https://example.invalid/1' }, template: '{tag} {url}' })
|
|
173
|
+
check('{url} is available when a template asks for it', withUrl.text === 'v1 https://example.invalid/1', withUrl.text)
|
|
174
|
+
const typo = composeComment({ tag: 'v1', release: { tag: 'v1', name: 'n' }, template: '{tag} {tagline}' })
|
|
175
|
+
check('an unknown placeholder is reported, not deleted', typo.unknown.includes('tagline') && typo.text.includes('{tagline}'), typo.text)
|
|
176
|
+
const overlong = composeComment({ tag: 'v1', release: { tag: 'v1', name: 'n' }, template: `{tag} ${'y'.repeat(1200)}` })
|
|
177
|
+
check('a comment longer than Bilibili allows is cut', overlong.truncated === true && overlong.text.length <= 1000, String(overlong.text.length))
|
|
178
|
+
|
|
179
|
+
/* ---- 3. has this been said already? -------------------------------------- */
|
|
180
|
+
|
|
181
|
+
const release = { tag: 'v1.2.0', name: 'x', createdAt: '2026-10-07T01:00:00Z', draft: false }
|
|
182
|
+
const draftOnly = newestPublishedRelease([{ tag: 'v1.3.0', draft: true, createdAt: '2026-10-07T02:00:00Z' }])
|
|
183
|
+
check('a draft is not a release anybody can read', draftOnly === null)
|
|
184
|
+
check('the newest published release wins', newestPublishedRelease([
|
|
185
|
+
{ tag: 'v1.0.0', draft: false, createdAt: '2026-10-01T00:00:00Z' },
|
|
186
|
+
{ tag: 'v1.1.0', draft: false, createdAt: '2026-10-06T00:00:00Z' },
|
|
187
|
+
]).tag === 'v1.1.0')
|
|
188
|
+
|
|
189
|
+
const binding = { repo: 'dsh-plugin-cicd', bvid: 'BV1RopP6FEJp' }
|
|
190
|
+
check('an unbound repository is refused', announcementVerdict({ binding: null, release }).state === 'unbound')
|
|
191
|
+
check('a repository with no release is refused', announcementVerdict({ binding, release: null }).state === 'no-release')
|
|
192
|
+
let ledger = recordLedgerEntry(emptyLedger(), { repo: binding.repo, tag: 'v1.2.0', at: '2026-10-07T02:00:00Z', state: 'announced', attempts: 0 })
|
|
193
|
+
check('an announced release is not announced twice', announcementVerdict({ binding, release, ledger }).state === 'already')
|
|
194
|
+
check('a forced announcement may repeat', announcementVerdict({ binding, release, ledger, force: true }).state === 'ready')
|
|
195
|
+
check('the ledger keeps one entry per repository and tag', ledger.entries.length === 1)
|
|
196
|
+
check('a ledger entry can be found', findLedgerEntry(ledger, binding.repo, 'v1.2.0')?.state === 'announced')
|
|
197
|
+
|
|
198
|
+
/* Binding seeds a baseline, so wiring a video up never announces the past. */
|
|
199
|
+
const baselined = recordLedgerEntry(emptyLedger(), { repo: binding.repo, tag: 'v1.2.0', at: '2026-10-07T03:00:00Z', state: 'baseline' })
|
|
200
|
+
check('the version already out when the video was bound is held back', announcementVerdict({ binding, release, ledger: baselined }).state === 'baseline')
|
|
201
|
+
check('a baseline is found by repository', latestBaseline(baselined, binding.repo)?.tag === 'v1.2.0')
|
|
202
|
+
check('a release newer than the binding is announced', announcementVerdict({
|
|
203
|
+
binding,
|
|
204
|
+
release: { ...release, tag: 'v1.3.0', createdAt: '2026-10-07T04:00:00Z' },
|
|
205
|
+
ledger: baselined,
|
|
206
|
+
}).state === 'ready')
|
|
207
|
+
/* A binding that could not read the release list holds back everything older. */
|
|
208
|
+
const unknownBaseline = recordLedgerEntry(emptyLedger(), { repo: binding.repo, tag: null, at: '2026-10-07T03:00:00Z', state: 'baseline' })
|
|
209
|
+
check('an unreadable baseline holds back older releases', announcementVerdict({ binding, release, ledger: unknownBaseline }).state === 'baseline')
|
|
210
|
+
check('an unreadable baseline lets newer releases through', announcementVerdict({
|
|
211
|
+
binding,
|
|
212
|
+
release: { ...release, createdAt: '2026-10-07T04:00:00Z' },
|
|
213
|
+
ledger: unknownBaseline,
|
|
214
|
+
}).state === 'ready')
|
|
215
|
+
let failedLedger = emptyLedger()
|
|
216
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
217
|
+
failedLedger = recordLedgerEntry(failedLedger, { repo: binding.repo, tag: 'v1.2.0', at: '2026-10-07T02:00:00Z', state: 'failed', attempts: attempt + 1 })
|
|
218
|
+
}
|
|
219
|
+
check('three failures stop the sweep retrying', announcementVerdict({ binding, release, ledger: failedLedger }).state === 'gave-up')
|
|
220
|
+
check('a ledger that cannot be parsed is refused, never emptied', parseLedger('{oops').ok === false)
|
|
221
|
+
check('an empty ledger file is a fresh ledger', parseLedger('').ok === true && parseLedger('').ledger.entries.length === 0)
|
|
222
|
+
|
|
223
|
+
check('a risk-control block is named', replyFailure(-412, '请求被拦截').kind === 'risk-control')
|
|
224
|
+
check('a login failure is named', replyFailure(-101, '账号未登录').kind === 'not-logged-in')
|
|
225
|
+
check('an unknown failure keeps Bilibili\'s words', replyFailure(999999, 'whatever').message === 'whatever')
|
|
226
|
+
|
|
227
|
+
/* ---- 4. the transport, against a fake fetch ------------------------------ */
|
|
228
|
+
|
|
229
|
+
const requests = []
|
|
230
|
+
/** One canned answer, and a record of what was asked. */
|
|
231
|
+
function fakeFetch(routes) {
|
|
232
|
+
return async (url, options = {}) => {
|
|
233
|
+
requests.push({ url: String(url), method: options.method ?? 'GET', body: options.body ?? null, headers: options.headers ?? {} })
|
|
234
|
+
for (const [match, answer] of routes) {
|
|
235
|
+
if (String(url).includes(match)) {
|
|
236
|
+
const payload = typeof answer === 'function' ? answer(String(url), options) : answer
|
|
237
|
+
return {
|
|
238
|
+
status: 200,
|
|
239
|
+
headers: { getSetCookie: () => payload.setCookie ?? [], get: () => null },
|
|
240
|
+
json: async () => payload.body ?? payload,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
throw new Error(`no fake route for ${url}`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const accountClient = createBilibiliClient({ fetchImpl: fakeFetch([['member/web/account', { code: 0, data: { mid: 42, uname: '白衣为卿曲' } }]]) })
|
|
249
|
+
const account = await accountClient.readAccount('SESSDATA=s; bili_jct=j')
|
|
250
|
+
check('the account endpoint names the credential', account.ok === true && account.uname === '白衣为卿曲', JSON.stringify(account))
|
|
251
|
+
check('the credential really travels', requests[0].headers.cookie === 'SESSDATA=s; bili_jct=j', JSON.stringify(requests[0].headers))
|
|
252
|
+
|
|
253
|
+
requests.length = 0
|
|
254
|
+
const refusedAccount = await createBilibiliClient({ fetchImpl: fakeFetch([['member/web/account', { code: -101, message: '账号未登录' }]]) }).readAccount('SESSDATA=s')
|
|
255
|
+
check('a refused credential is an answer, not an exception', refusedAccount.ok === false && refusedAccount.code === -101)
|
|
256
|
+
|
|
257
|
+
const videoClient = createBilibiliClient({ fetchImpl: fakeFetch([['web-interface/view', { code: 0, data: { aid: 117396371216915, title: '一支视频', owner: { name: 'UP' } } }]]) })
|
|
258
|
+
const video = await videoClient.resolveVideo('SESSDATA=s', 'BV1RopP6FEJp')
|
|
259
|
+
check('a video resolves to the oid a comment needs', video.ok === true && video.aid === 117396371216915)
|
|
260
|
+
check('the video lookup sends the id it was given', requests.some((entry) => entry.url.includes('bvid=BV1RopP6FEJp')))
|
|
261
|
+
|
|
262
|
+
requests.length = 0
|
|
263
|
+
const poster = createBilibiliClient({ fetchImpl: fakeFetch([['reply/add', { code: 0, data: { rpid: 123456789, rpid_str: '123456789' } }]]) })
|
|
264
|
+
const posted = await poster.postComment({ cookie: 'SESSDATA=s; bili_jct=j', csrf: 'j', aid: 1, bvid: 'BV1RopP6FEJp', message: '【更新 v1】x' })
|
|
265
|
+
check('a comment posts and reports its id', posted.ok === true && posted.rpid === '123456789', JSON.stringify(posted))
|
|
266
|
+
const postRequest = requests.find((entry) => entry.url.includes('reply/add'))
|
|
267
|
+
check('the csrf travels in the query and the body', postRequest.url.includes('csrf=j') && String(postRequest.body).includes('csrf=j'), postRequest.url)
|
|
268
|
+
check('the comment body carries the oid and the type', String(postRequest.body).includes('oid=1') && String(postRequest.body).includes('type=1'), String(postRequest.body))
|
|
269
|
+
check('the comment is sent to the video\'s own referer', postRequest.headers.referer === 'https://www.bilibili.com/video/BV1RopP6FEJp/', postRequest.headers.referer)
|
|
270
|
+
|
|
271
|
+
const blocked = await createBilibiliClient({ fetchImpl: fakeFetch([['reply/add', { code: -412, message: '请求被拦截' }]]) })
|
|
272
|
+
.postComment({ cookie: 'c', csrf: 'j', aid: 1, bvid: 'BV1RopP6FEJp', message: 'x' })
|
|
273
|
+
check('a risk-control refusal is classified for the panel', blocked.ok === false && blocked.failure.kind === 'risk-control', JSON.stringify(blocked.failure))
|
|
274
|
+
|
|
275
|
+
requests.length = 0
|
|
276
|
+
let polls = 0
|
|
277
|
+
const loginClient = createBilibiliClient({ fetchImpl: fakeFetch([
|
|
278
|
+
['qrcode/generate', { code: 0, data: { url: 'https://account.bilibili.com/h5/x?qrcode_key=k', qrcode_key: 'k' } }],
|
|
279
|
+
['qrcode/poll', () => {
|
|
280
|
+
polls += 1
|
|
281
|
+
return polls === 1
|
|
282
|
+
? { code: 0, data: { code: 86101, message: '未扫码' } }
|
|
283
|
+
: { code: 0, data: { code: 0 }, setCookie: ['SESSDATA=sess; Path=/', 'bili_jct=jct; Path=/', 'DedeUserID=42; Path=/'] }
|
|
284
|
+
}],
|
|
285
|
+
]) })
|
|
286
|
+
const started = await loginClient.startQrLogin()
|
|
287
|
+
check('the sign-in hands back a link and a key', started.ok === true && started.key === 'k', JSON.stringify(started))
|
|
288
|
+
const waiting = await loginClient.pollQrLogin('k')
|
|
289
|
+
check('an unscanned code is a state, not a failure', waiting.ok === true && waiting.state === 'waiting')
|
|
290
|
+
const confirmed = await loginClient.pollQrLogin('k')
|
|
291
|
+
check('a confirmed code yields the cookies Bilibili set', confirmed.state === 'succeeded' && confirmed.cookies.SESSDATA === 'sess', JSON.stringify(confirmed.cookies))
|
|
292
|
+
const expired = await createBilibiliClient({ fetchImpl: fakeFetch([['qrcode/poll', { code: 0, data: { code: 86038 } }]]) }).pollQrLogin('k')
|
|
293
|
+
check('an expired code is named', expired.state === 'expired')
|
|
294
|
+
const noCookie = await createBilibiliClient({ fetchImpl: fakeFetch([['qrcode/poll', { code: 0, data: { code: 0 } }]]) }).pollQrLogin('k')
|
|
295
|
+
check('a success with no cookie is a failure, not a sign-in', noCookie.ok === false && noCookie.message.includes('Cookie'), noCookie.message)
|
|
296
|
+
|
|
297
|
+
/* The same credential also arrives as a query string on `data.url`, and which half
|
|
298
|
+
of the answer a client can see is not something this plugin controls. Reading only
|
|
299
|
+
`Set-Cookie` turned a completed sign-in into "回了成功,但没有下发 Cookie" — honest
|
|
300
|
+
and useless, because the credential was in the other half all along. */
|
|
301
|
+
const urlOnly = await createBilibiliClient({ fetchImpl: fakeFetch([['qrcode/poll', {
|
|
302
|
+
code: 0,
|
|
303
|
+
data: { code: 0, url: 'https://passport.biligame.com/crossDomain?DedeUserID=42&SESSDATA=sess%2Cfrom%2Curl&bili_jct=jct-from-url&gourl=https%3A%2F%2Fwww.bilibili.com&Expires=1806856570' },
|
|
304
|
+
}]]) }).pollQrLogin('k')
|
|
305
|
+
check('a sign-in whose credential arrives only in the URL still completes', urlOnly.ok === true && urlOnly.state === 'succeeded' && urlOnly.cookies.SESSDATA === 'sess,from,url', JSON.stringify(urlOnly.cookies))
|
|
306
|
+
check('the URL source does not invent cookies from gourl or Expires', urlOnly.cookies.gourl === undefined && urlOnly.cookies.Expires === undefined, Object.keys(urlOnly.cookies).join(','))
|
|
307
|
+
check('the account id comes along from the URL too', urlOnly.cookies.DedeUserID === '42')
|
|
308
|
+
|
|
309
|
+
const bothSources = await createBilibiliClient({ fetchImpl: fakeFetch([['qrcode/poll', {
|
|
310
|
+
code: 0,
|
|
311
|
+
data: { code: 0, url: 'https://passport.biligame.com/crossDomain?SESSDATA=from-url&bili_jct=from-url' },
|
|
312
|
+
setCookie: ['SESSDATA=from-header; Path=/', 'buvid3=dev; Path=/'],
|
|
313
|
+
}]]) }).pollQrLogin('k')
|
|
314
|
+
check('a partial Set-Cookie is filled in from the URL', bothSources.cookies.bili_jct === 'from-url', JSON.stringify(bothSources.cookies))
|
|
315
|
+
check('the header wins where both carry the same name', bothSources.cookies.SESSDATA === 'from-header', bothSources.cookies.SESSDATA)
|
|
316
|
+
check('the device cookie the header brought is kept', bothSources.cookies.buvid3 === 'dev')
|
|
317
|
+
|
|
318
|
+
check('a malformed login URL yields no cookies rather than throwing', Object.keys(cookiesFromLoginUrl('not a url')).length === 0)
|
|
319
|
+
check('an absent login URL yields no cookies', Object.keys(cookiesFromLoginUrl(undefined)).length === 0)
|
|
320
|
+
|
|
321
|
+
/* The device cookies: fetched in one call, both of which belong on the request. This
|
|
322
|
+
is the risk-control half that decides between a posted comment and a `-412`. */
|
|
323
|
+
const seeded = withDeviceIds({ SESSDATA: 's' }, { buvid3: 'b3', buvid4: 'b4' })
|
|
324
|
+
check('both device ids are added', seeded.buvid3 === 'b3' && seeded.buvid4 === 'b4', JSON.stringify(seeded))
|
|
325
|
+
check('the credential that was passed in is still there', seeded.SESSDATA === 's')
|
|
326
|
+
check('the credential it was given is not mutated', Object.keys({ SESSDATA: 's' }).length === 1)
|
|
327
|
+
const own = withDeviceIds({ buvid3: 'mine', buvid4: 'mine4' }, { buvid3: 'theirs', buvid4: 'theirs4' })
|
|
328
|
+
check('a device id the credential already carries is not replaced', own.buvid3 === 'mine' && own.buvid4 === 'mine4', JSON.stringify(own))
|
|
329
|
+
const nothing = withDeviceIds({ SESSDATA: 's' }, { buvid3: '', buvid4: ' ' })
|
|
330
|
+
check('an empty fingerprint adds nothing', nothing.buvid3 === undefined && nothing.buvid4 === undefined && nothing.SESSDATA === 's')
|
|
331
|
+
check('a missing fingerprint adds nothing rather than throwing', Object.keys(withDeviceIds({ SESSDATA: 's' })).length === 1)
|
|
332
|
+
check('missing cookies answer with the device ids alone', withDeviceIds(undefined, { buvid3: 'b3' }).buvid3 === 'b3')
|
|
333
|
+
|
|
334
|
+
/* ---- 5. the routes, mounted on a real server ----------------------------- */
|
|
335
|
+
|
|
336
|
+
const scratch = mkdtempSync(join(tmpdir(), 'dsh-cicd-bili-'))
|
|
337
|
+
const configFile = join(scratch, 'repos.json')
|
|
338
|
+
const credentialFile = join(scratch, 'bilibili-cookies.json')
|
|
339
|
+
const ledgerFile = join(scratch, 'bilibili-announcements.json')
|
|
340
|
+
writeFileSync(configFile, JSON.stringify({
|
|
341
|
+
owner: 'octocat',
|
|
342
|
+
repos: [{ repo: 'octocat/Hello-World' }, { repo: 'octocat/Spoon-Knife', bilibili: { bvid: 'BV1RopP6FEJp', auto: false } }],
|
|
343
|
+
}), 'utf8')
|
|
344
|
+
|
|
345
|
+
/** The Host's own shaping of the transport: one fake fetch, shared by every call. */
|
|
346
|
+
const hostRoutes = [
|
|
347
|
+
['member/web/account', { code: 0, data: { mid: 42, uname: '白衣为卿曲' } }],
|
|
348
|
+
['web-interface/view', { code: 0, data: { aid: 117396371216915, title: '一支视频', owner: { name: 'UP' } } }],
|
|
349
|
+
['reply/add', { code: 0, data: { rpid: 987654321 } }],
|
|
350
|
+
['finger/spi', { code: 0, data: { b_3: 'buvid3-value', b_4: 'buvid4-value' } }],
|
|
351
|
+
]
|
|
352
|
+
const hostFetch = fakeFetch(hostRoutes)
|
|
353
|
+
|
|
354
|
+
const routes = new Map()
|
|
355
|
+
const ctx = {
|
|
356
|
+
effect: (fn) => fn(),
|
|
357
|
+
logger: { info: () => {} },
|
|
358
|
+
webServer: { register: ({ path, handler }) => { routes.set(path, handler); return () => routes.delete(path) } },
|
|
359
|
+
}
|
|
360
|
+
module.apply(ctx, {
|
|
361
|
+
owner: 'octocat',
|
|
362
|
+
configFile,
|
|
363
|
+
// A `gh` that cannot exist makes "the release list could not be read" a fact of
|
|
364
|
+
// the test rather than a race with the network.
|
|
365
|
+
ghPath: join(scratch, 'no-such-gh.exe'),
|
|
366
|
+
bilibiliFetch: hostFetch,
|
|
367
|
+
bilibiliVerifyTtlMs: 0,
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const server = createServer((req, res) => {
|
|
371
|
+
const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
|
372
|
+
const handler = routes.get(path)
|
|
373
|
+
if (handler === undefined) {
|
|
374
|
+
res.writeHead(404, { 'content-type': 'application/json' })
|
|
375
|
+
res.end('{"ok":false}')
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
handler(req, res)
|
|
379
|
+
})
|
|
380
|
+
await new Promise((done) => server.listen(0, '127.0.0.1', done))
|
|
381
|
+
const base = `http://127.0.0.1:${server.address().port}`
|
|
382
|
+
const call = async (path, body = {}) => {
|
|
383
|
+
const response = await fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
384
|
+
let payload = null
|
|
385
|
+
try {
|
|
386
|
+
payload = await response.json()
|
|
387
|
+
} catch {
|
|
388
|
+
payload = null
|
|
389
|
+
}
|
|
390
|
+
return { status: response.status, payload }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const emptyStatus = (await call('/api/dsh-cicd/bilibili-status')).payload?.value ?? {}
|
|
394
|
+
check('the new routes are mounted as a family', routes.has('/api/dsh-cicd/bilibili-status') && routes.has('/api/dsh-cicd/bilibili-announce'))
|
|
395
|
+
check('status reports no credential as a state, not as an error', emptyStatus.credential?.state === 'none', JSON.stringify(emptyStatus.credential?.state))
|
|
396
|
+
check('status reports the repository\'s binding and its video', emptyStatus.repos?.find((entry) => entry.repo === 'octocat/Spoon-Knife')?.video?.title === '一支视频')
|
|
397
|
+
check('status reports the ledger file it would write', emptyStatus.ledger?.file === ledgerFile, String(emptyStatus.ledger?.file))
|
|
398
|
+
|
|
399
|
+
const badCredential = await call('/api/dsh-cicd/bilibili-credential', { cookie: 'nonsense' })
|
|
400
|
+
check('an unusable paste is refused before anything is written', badCredential.status === 400 && badCredential.payload?.code === 'incomplete-credential')
|
|
401
|
+
check('a refused paste writes no file', existsSync(credentialFile) === false)
|
|
402
|
+
|
|
403
|
+
const noLogin = await call('/api/dsh-cicd/bilibili-login-poll')
|
|
404
|
+
check('polling a sign-in that was never started is refused', noLogin.status === 409 && noLogin.payload?.code === 'no-login')
|
|
405
|
+
|
|
406
|
+
const badBvid = await call('/api/dsh-cicd/bilibili-bind', { repo: 'octocat/Hello-World', bvid: 'not-a-bv' })
|
|
407
|
+
check('a malformed BV id is refused', badBvid.status === 400 && badBvid.payload?.code === 'bad-bvid')
|
|
408
|
+
const unknownRepo = await call('/api/dsh-cicd/bilibili-bind', { repo: 'someone/else', bvid: 'BV1RopP6FEJp' })
|
|
409
|
+
check('binding an unconfigured repository is refused', unknownRepo.status === 400 && /not configured/.test(unknownRepo.payload?.message ?? ''))
|
|
410
|
+
|
|
411
|
+
/* Asked before anything is bound, because after that the answer is a different one. */
|
|
412
|
+
const unboundAnnounce = await call('/api/dsh-cicd/bilibili-announce', { repo: 'octocat/Hello-World', dryRun: true })
|
|
413
|
+
check('announcing is refused for a repository with no video', unboundAnnounce.status === 409 && unboundAnnounce.payload?.code === 'unbound', `HTTP ${unboundAnnounce.status} ${String(unboundAnnounce.payload?.code)}`)
|
|
414
|
+
|
|
415
|
+
const bound = await call('/api/dsh-cicd/bilibili-bind', { repo: 'octocat/Hello-World', bvid: 'BV1RopP6FEJp', auto: true })
|
|
416
|
+
check('a valid binding is stored', bound.status === 200 && bound.payload?.value?.bvid === 'BV1RopP6FEJp', JSON.stringify(bound.payload?.value ?? {}))
|
|
417
|
+
check('the binding reaches the managed file', JSON.parse(readFileSync(configFile, 'utf8')).repos.find((entry) => entry.repo === 'octocat/Hello-World')?.bilibili?.bvid === 'BV1RopP6FEJp')
|
|
418
|
+
check('binding seeds a baseline so the past is not announced', existsSync(ledgerFile) === true && JSON.parse(readFileSync(ledgerFile, 'utf8')).entries.some((entry) => entry.state === 'baseline'))
|
|
419
|
+
check('an unreadable release list is said out loud, not hidden', typeof bound.payload?.value?.note === 'string' && bound.payload.value.note.length > 0, bound.payload?.value?.note)
|
|
420
|
+
|
|
421
|
+
/* The credential is written by hand here: the paste route refuses one Bilibili has
|
|
422
|
+
not accepted, and this test's Bilibili is a fake. */
|
|
423
|
+
writeFileSync(credentialFile, JSON.stringify({ version: 1, source: 'paste', savedAt: new Date().toISOString(), account: { mid: '42', uname: '白衣为卿曲' }, cookies: { SESSDATA: 'sess', bili_jct: 'jct', DedeUserID: '42' } }), 'utf8')
|
|
424
|
+
const readyStatus = (await call('/api/dsh-cicd/bilibili-status', { force: true })).payload?.value ?? {}
|
|
425
|
+
check('a stored web credential is verified and named', readyStatus.credential?.state === 'ready' && readyStatus.credential.account?.uname === '白衣为卿曲', JSON.stringify(readyStatus.credential ?? {}))
|
|
426
|
+
|
|
427
|
+
requests.length = 0
|
|
428
|
+
const announceNoRelease = await call('/api/dsh-cicd/bilibili-announce', { repo: 'octocat/Spoon-Knife' })
|
|
429
|
+
check('an unreadable release list stops the announcement', announceNoRelease.status === 502 && announceNoRelease.payload?.code === 'gh-failed', JSON.stringify(announceNoRelease.payload ?? {}))
|
|
430
|
+
check('nothing was posted when the release could not be read', requests.some((entry) => entry.url.includes('reply/add')) === false, requests.map((entry) => entry.url).join(','))
|
|
431
|
+
|
|
432
|
+
const dryRun = await call('/api/dsh-cicd/bilibili-announce', { repo: 'octocat/Spoon-Knife', dryRun: true })
|
|
433
|
+
check('a dry run also stops when the release list is unreadable', dryRun.status === 502 && dryRun.payload?.code === 'gh-failed')
|
|
434
|
+
|
|
435
|
+
/* A ledger that cannot be read must block the post, not be replaced by an empty one. */
|
|
436
|
+
writeFileSync(ledgerFile, '{ this is not JSON', 'utf8')
|
|
437
|
+
const brokenLedger = (await call('/api/dsh-cicd/bilibili-status')).payload?.value ?? {}
|
|
438
|
+
check('a broken ledger is reported, not swallowed', typeof brokenLedger.ledger?.problem === 'string' && brokenLedger.ledger.problem.length > 0, String(brokenLedger.ledger?.problem))
|
|
439
|
+
writeFileSync(ledgerFile, JSON.stringify({ version: 1, entries: [] }), 'utf8')
|
|
440
|
+
|
|
441
|
+
const loggedOut = await call('/api/dsh-cicd/bilibili-logout')
|
|
442
|
+
check('signing out removes the credential this plugin stored', loggedOut.status === 200 && loggedOut.payload?.value?.removed === true)
|
|
443
|
+
check('the credential file is really gone', existsSync(credentialFile) === false)
|
|
444
|
+
|
|
445
|
+
server.close()
|
|
446
|
+
await new Promise((done) => server.close(done))
|
|
447
|
+
|
|
448
|
+
const passed = results.length - failed
|
|
449
|
+
console.log(`\n${passed}/${results.length} checks passed`)
|
|
450
|
+
if (failed > 0) process.exit(1)
|