dsh-users-open-source 0.1.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,656 @@
1
+ /**
2
+ * dsh-users-open-source 冒烟测试:不需要 DSH 运行时,node 直接跑。
3
+ *
4
+ * 1. 宿主半边:apply() 起来 → 真起一次测速(回环流量)→ 校验速率与配置落盘;
5
+ * 2. 客户端半边:用最小 React/DOM 桩加载 lib/client.js → 校验三个席位注册 →
6
+ * 把各个组件在「空数据 / 测速中 / 有历史」三种快照下浅渲染一遍,确保不抛错。
7
+ *
8
+ * 用法:node tests/smoke.mjs
9
+ */
10
+ import { strict as assert } from 'node:assert'
11
+ import { createServer } from 'node:http'
12
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
13
+ import { tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import vm from 'node:vm'
17
+
18
+ /** 测试期间把 DSH home 指到临时目录,别污染真身。 */
19
+ process.env.DSH_HOME = join(tmpdir(), 'dsh-users-open-source-smoke')
20
+
21
+ const CLIENT_PATH = new URL('../lib/client.js', import.meta.url)
22
+ /** 收集失败,最后统一决定退出码。 */
23
+ const failures = []
24
+
25
+ /**
26
+ * 跑一个测试用例,失败不中断(好把问题一次看全)。
27
+ * @param {string} title - 用例名。
28
+ * @param {() => Promise<void>|void} body - 用例体。
29
+ * @returns {Promise<void>} 结束。
30
+ */
31
+ async function test(title, body) {
32
+ try {
33
+ await body()
34
+ console.log(` ✓ ${title}`)
35
+ } catch (error) {
36
+ failures.push(`${title}: ${String((error && error.stack) || error)}`)
37
+ console.log(` ✗ ${title}`)
38
+ }
39
+ }
40
+
41
+ // ═══════════════════════════════════════════════════════════════════════
42
+ // 1. 宿主半边
43
+ // ═══════════════════════════════════════════════════════════════════════
44
+ console.log('宿主半边:')
45
+
46
+ const host = await import(new URL('../lib/host.js', import.meta.url).href)
47
+ /** 路由表:path → handler。 */
48
+ const routes = new Map()
49
+ /** effect 清理函数。 */
50
+ const disposers = []
51
+ const logs = []
52
+ /** 假的宿主服务表(按需往里塞服务)。 */
53
+ const services = {}
54
+
55
+ await test('模块导出 name / inject / apply', () => {
56
+ assert.equal(host.name, 'dsh-users-open-source')
57
+ assert.deepEqual(Array.from(host.inject), ['webServer'])
58
+ assert.equal(typeof host.apply, 'function')
59
+ })
60
+
61
+ const ctx = {
62
+ get: (name) => services[name],
63
+ logger: {
64
+ info: (message) => logs.push(`[info] ${message}`),
65
+ warn: (message) => logs.push(`[warn] ${message}`)
66
+ },
67
+ effect: (factory) => {
68
+ const dispose = factory()
69
+ if (typeof dispose === 'function') disposers.push(dispose)
70
+ return () => {}
71
+ },
72
+ webServer: {
73
+ register: (route) => {
74
+ routes.set(route.path, route.handler)
75
+ return () => routes.delete(route.path)
76
+ }
77
+ }
78
+ }
79
+
80
+ await test('apply() 挂上 8 条路由', () => {
81
+ host.apply(ctx)
82
+ assert.deepEqual(
83
+ Array.from(routes.keys()).sort(),
84
+ [
85
+ '/dsh-users-open-source/asset/code-upload.jpg',
86
+ '/dsh-users-open-source/asset/image',
87
+ '/dsh-users-open-source/asset/image/reset',
88
+ '/dsh-users-open-source/cancel',
89
+ '/dsh-users-open-source/config',
90
+ '/dsh-users-open-source/history',
91
+ '/dsh-users-open-source/run',
92
+ '/dsh-users-open-source/state'
93
+ ]
94
+ )
95
+ })
96
+
97
+ // 「云端备份」(打开工作区目录)那一半已拆成独立包 dsh-users-open-source-workspace,
98
+ // 它的路由与三条兜底线索在那边自己的 tests/smoke.mjs 里测。
99
+
100
+ /** 把插件路由挂到一个真 HTTP 口上,方便用 fetch 打。 */
101
+ const bridge = createServer((req, res) => {
102
+ const url = new URL(typeof req.url === 'string' ? req.url : '/', 'http://127.0.0.1')
103
+ const handler = routes.get(url.pathname)
104
+ if (handler === undefined) {
105
+ res.writeHead(404, { 'content-type': 'text/plain' })
106
+ res.end('no route')
107
+ return
108
+ }
109
+ Promise.resolve(handler(req, res)).catch(() => {
110
+ try {
111
+ res.destroy()
112
+ } catch {
113
+ /* 忽略 */
114
+ }
115
+ })
116
+ })
117
+ await new Promise((resolve) => bridge.listen(0, '127.0.0.1', resolve))
118
+ const bridgePort = bridge.address().port
119
+ const base = `http://127.0.0.1:${bridgePort}`
120
+
121
+ /**
122
+ * 打一个插件接口。
123
+ * @param {string} path - 路径。
124
+ * @param {object} [init] - fetch 选项。
125
+ * @returns {Promise<{status:number, body:any}>} 状态码与 JSON。
126
+ */
127
+ async function call(path, init) {
128
+ const res = await fetch(base + path, init)
129
+ let body = null
130
+ try {
131
+ body = await res.json()
132
+ } catch {
133
+ body = null
134
+ }
135
+ return { status: res.status, body }
136
+ }
137
+
138
+ /**
139
+ * 发一个 JSON POST。
140
+ * @param {string} path - 路径。
141
+ * @param {object} payload - 请求体。
142
+ * @returns {Promise<{status:number, body:any}>} 结果。
143
+ */
144
+ function postJson(path, payload) {
145
+ return call(path, {
146
+ method: 'POST',
147
+ headers: { 'content-type': 'application/json' },
148
+ body: JSON.stringify(payload)
149
+ })
150
+ }
151
+
152
+ /**
153
+ * 等一小会儿。
154
+ * @param {number} ms - 毫秒。
155
+ * @returns {Promise<void>} 结束。
156
+ */
157
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
158
+
159
+ let readySnapshot = null
160
+ await test('回环测速口在 5 秒内就绪', async () => {
161
+ for (let index = 0; index < 50; index += 1) {
162
+ const { body } = await call('/dsh-users-open-source/state')
163
+ if (body !== null && body.loopbackPort > 0) {
164
+ readySnapshot = body
165
+ return
166
+ }
167
+ await sleep(100)
168
+ }
169
+ throw new Error('loopbackPort 一直是 0')
170
+ })
171
+
172
+ await test('配置能存能读(定时 = 每 90 分钟)', async () => {
173
+ const { status, body } = await postJson('/dsh-users-open-source/config', {
174
+ config: { durationMs: 800, connections: 2, schedule: { mode: 'interval', intervalMinutes: 90, dailyTime: '04:30' } }
175
+ })
176
+ assert.equal(status, 200)
177
+ assert.equal(body.ok, true)
178
+ assert.equal(body.config.durationMs, 800)
179
+ assert.equal(body.config.connections, 2)
180
+ assert.equal(body.config.sidebarImageSize, 120, '侧栏图片大小默认 120px')
181
+ assert.equal(body.config.schedule.mode, 'interval')
182
+ assert.equal(body.config.schedule.intervalMinutes, 90)
183
+ })
184
+
185
+ await test('配置的非法值被夹回边界', async () => {
186
+ const { body } = await postJson('/dsh-users-open-source/config', {
187
+ config: { durationMs: 999999, connections: 0, schedule: { mode: 'daily', intervalMinutes: 0, dailyTime: '99:99' } }
188
+ })
189
+ assert.equal(body.config.durationMs, 15000)
190
+ assert.equal(body.config.connections, 1)
191
+ assert.equal(body.config.schedule.dailyTime, '04:30')
192
+ })
193
+
194
+ await test('POST /run 启动测速,重复点击返回 409', async () => {
195
+ const first = await postJson('/dsh-users-open-source/run', {})
196
+ assert.equal(first.status, 200)
197
+ assert.equal(first.body.started, true)
198
+ const second = await postJson('/dsh-users-open-source/run', {})
199
+ assert.equal(second.status, 409)
200
+ assert.equal(second.body.error, 'busy')
201
+ })
202
+
203
+ await test('测速跑完并给出正速率', async () => {
204
+ const deadline = Date.now() + 40000
205
+ let snapshot = null
206
+ while (Date.now() < deadline) {
207
+ const { body } = await call('/dsh-users-open-source/state')
208
+ if (body !== null && body.running === false && body.last !== null) {
209
+ snapshot = body
210
+ break
211
+ }
212
+ await sleep(200)
213
+ }
214
+ assert.ok(snapshot !== null, '40 秒内没跑完')
215
+ const record = snapshot.last
216
+ assert.ok(record.download.bps > 0, `下载速率应为正数,实际 ${record.download.bps}`)
217
+ assert.ok(record.upload.bps > 0, `上传速率应为正数,实际 ${record.upload.bps}`)
218
+ assert.ok(record.download.bytes > 0 && record.upload.bytes > 0)
219
+ assert.equal(record.source, 'manual')
220
+ assert.ok(Array.isArray(snapshot.history) && snapshot.history.length >= 1)
221
+ console.log(` · 本次实测:下载 ${(record.download.bps / 1e6).toFixed(0)} MB/s · 上传 ${(record.upload.bps / 1e6).toFixed(0)} MB/s(回环,不连外网)`)
222
+ })
223
+
224
+ await test('自动测速(定时)在 1 分钟间隔下会自己跑一次', async () => {
225
+ await postJson('/dsh-users-open-source/config', {
226
+ config: { durationMs: 500, connections: 1, schedule: { mode: 'interval', intervalMinutes: 1, dailyTime: '03:00' } }
227
+ })
228
+ const before = await call('/dsh-users-open-source/state')
229
+ const countBefore = before.body.history.length
230
+ const deadline = Date.now() + 75000
231
+ let found = false
232
+ while (Date.now() < deadline) {
233
+ const { body } = await call('/dsh-users-open-source/state')
234
+ if (body.history.length > countBefore && body.history[0].source === 'schedule') {
235
+ found = true
236
+ break
237
+ }
238
+ await sleep(1000)
239
+ }
240
+ assert.ok(found, '75 秒内没看到定时测速的结果')
241
+ await postJson('/dsh-users-open-source/config', { config: { schedule: { mode: 'off' } } })
242
+ })
243
+
244
+ await test('GET /cancel 不炸;GET /config 被拒(只收 POST)', async () => {
245
+ const cancel = await call('/dsh-users-open-source/cancel')
246
+ assert.equal(cancel.status, 405)
247
+ const config = await call('/dsh-users-open-source/config')
248
+ assert.equal(config.status, 405)
249
+ const cancelPost = await postJson('/dsh-users-open-source/cancel', {})
250
+ assert.equal(cancelPost.status, 200)
251
+ })
252
+
253
+ await test('POST /history 能删单条、能清空,并落盘', async () => {
254
+ const before = await call('/dsh-users-open-source/state')
255
+ const items = before.body.history
256
+ assert.ok(Array.isArray(items) && items.length >= 2, `前置条件:至少两条历史,实际 ${items.length}`)
257
+
258
+ const target = items[0].at
259
+ const deleted = await postJson('/dsh-users-open-source/history', { action: 'delete', at: target })
260
+ assert.equal(deleted.status, 200)
261
+ assert.equal(deleted.body.ok, true)
262
+ assert.ok(!deleted.body.history.some((entry) => entry.at === target), '被删的那条不该还在')
263
+ assert.equal(deleted.body.history.length, items.length - 1)
264
+ assert.equal(deleted.body.last.at, items[1].at, 'last 应回退到剩下最新的一条')
265
+
266
+ const missingAt = await postJson('/dsh-users-open-source/history', { action: 'delete' })
267
+ assert.equal(missingAt.status, 400)
268
+ const unknown = await postJson('/dsh-users-open-source/history', { action: 'nope' })
269
+ assert.equal(unknown.status, 400)
270
+ const wrongMethod = await call('/dsh-users-open-source/history')
271
+ assert.equal(wrongMethod.status, 405)
272
+
273
+ const cleared = await postJson('/dsh-users-open-source/history', { action: 'clear' })
274
+ assert.equal(cleared.body.ok, true)
275
+ assert.deepEqual(cleared.body.history, [])
276
+ assert.equal(cleared.body.last, null)
277
+
278
+ const after = await call('/dsh-users-open-source/state')
279
+ assert.deepEqual(after.body.history, [], '清空后状态里也不该有记录')
280
+ assert.equal(after.body.last, null)
281
+
282
+ // 重启一次宿主(重新 apply)应能读到清空后的文件
283
+ host.apply(ctx)
284
+ const reloaded = await call('/dsh-users-open-source/state')
285
+ assert.deepEqual(reloaded.body.history, [], '落盘后再读回来仍应为空')
286
+ })
287
+
288
+ await test('图片:默认图能取、能换成自己上传的、能恢复默认', async () => {
289
+ const fallback = await fetch(`${base}/dsh-users-open-source/asset/image`)
290
+ assert.equal(fallback.status, 200)
291
+ assert.equal(fallback.headers.get('content-type'), 'image/jpeg')
292
+ const fallbackBytes = (await fallback.arrayBuffer()).byteLength
293
+ assert.ok(fallbackBytes > 1000, '默认图应该有内容')
294
+
295
+ // 一张 1x1 的 PNG
296
+ const pixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
297
+ const uploaded = await postJson('/dsh-users-open-source/asset/image', { type: 'image/png', data: pixel })
298
+ assert.equal(uploaded.status, 200)
299
+ assert.equal(uploaded.body.ok, true)
300
+
301
+ const afterUpload = await call('/dsh-users-open-source/state')
302
+ assert.equal(afterUpload.body.customImage, true, '上传后 state 应标记为自定义图')
303
+
304
+ const custom = await fetch(`${base}/dsh-users-open-source/asset/image`)
305
+ assert.equal(custom.headers.get('content-type'), 'image/png')
306
+ assert.ok((await custom.arrayBuffer()).byteLength < fallbackBytes, '现在发的应该是那张 1x1 小图')
307
+
308
+ const badType = await postJson('/dsh-users-open-source/asset/image', { type: 'text/plain', data: pixel })
309
+ assert.equal(badType.status, 400)
310
+ const noData = await postJson('/dsh-users-open-source/asset/image', { type: 'image/png' })
311
+ assert.equal(noData.status, 400)
312
+
313
+ const wrongMethod = await call('/dsh-users-open-source/asset/image/reset')
314
+ assert.equal(wrongMethod.status, 405)
315
+
316
+ const reset = await postJson('/dsh-users-open-source/asset/image/reset', {})
317
+ assert.equal(reset.body.ok, true)
318
+ const afterReset = await call('/dsh-users-open-source/state')
319
+ assert.equal(afterReset.body.customImage, false)
320
+ const back = await fetch(`${base}/dsh-users-open-source/asset/image`)
321
+ assert.equal(back.headers.get('content-type'), 'image/jpeg', '恢复默认后又是包内那张 JPEG')
322
+ })
323
+
324
+ // ═══════════════════════════════════════════════════════════════════════
325
+ // 2. 客户端半边
326
+ // ═══════════════════════════════════════════════════════════════════════
327
+ console.log('客户端半边:')
328
+
329
+ /** 最小 React 桩:只提供组件体跑起来需要的那几个 API。 */
330
+ const reactStub = {
331
+ createElement: (type, props, ...children) => ({ type, props: props === null || props === undefined ? {} : props, children }),
332
+ useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
333
+ useEffect: () => {},
334
+ useCallback: (fn) => fn,
335
+ useRef: (initial) => ({ current: initial })
336
+ }
337
+
338
+ let loadedId = null
339
+ let clientModule = null
340
+ /** 记录注册过的席位。 */
341
+ const registrations = []
342
+
343
+ const clientCode = await readFile(CLIENT_PATH, 'utf8')
344
+ const sandbox = {
345
+ console,
346
+ setTimeout,
347
+ clearTimeout,
348
+ setInterval,
349
+ clearInterval,
350
+ fetch: async () => {
351
+ throw new Error('smoke: 桩里没有宿主')
352
+ },
353
+ document: {
354
+ head: { appendChild: () => {} },
355
+ createElement: () => ({ textContent: '', remove: () => {} })
356
+ }
357
+ }
358
+ sandbox.window = {
359
+ __ModuleLoader__: {
360
+ load: ({ id, factory }) => {
361
+ loadedId = id
362
+ clientModule = factory((name) => {
363
+ if (name === 'react') return reactStub
364
+ throw new Error(`smoke: 意料之外的 require(${name})`)
365
+ })
366
+ }
367
+ }
368
+ }
369
+
370
+ await test('client.js 能被 ModuleLoader 加载,id 与包名一致', () => {
371
+ vm.runInNewContext(clientCode, sandbox, { filename: 'lib/client.js' })
372
+ assert.equal(loadedId, 'dsh-users-open-source')
373
+ assert.equal(typeof clientModule.apply, 'function')
374
+ assert.deepEqual(Array.from(clientModule.inject), ['slots'])
375
+ assert.equal(typeof clientModule.__test, 'object')
376
+ })
377
+
378
+ /** 客户端插件的假 ctx:后面几个用例还要借它的服务表。 */
379
+ const slotCtx = {
380
+ effect: () => () => {},
381
+ get: () => undefined,
382
+ slots: {
383
+ inject: (name, callback) => {
384
+ const effect = callback()
385
+ registrations.push({ name, effect })
386
+ return () => {}
387
+ },
388
+ register: (spec, renderer) => ({ spec, renderer })
389
+ }
390
+ }
391
+
392
+ await test('apply() 注册设置页 / 侧栏栏目 / 主面板三个席位', () => {
393
+ clientModule.apply(slotCtx)
394
+ const specs = registrations.map((entry) => entry.effect.spec)
395
+ assert.deepEqual(specs.map((spec) => spec.name).sort(), ['main', 'settings.section', 'sidebar.panellist'])
396
+ const section = specs.find((spec) => spec.name === 'settings.section')
397
+ assert.equal(section.label, '开源用户')
398
+ const panel = specs.find((spec) => spec.name === 'sidebar.panellist')
399
+ assert.equal(panel.id, 'net-speed')
400
+ assert.equal(panel.order, 10)
401
+ assert.equal(typeof panel.label, 'function')
402
+ const main = specs.find((spec) => spec.name === 'main')
403
+ assert.equal(main.key, 'net-speed')
404
+ })
405
+
406
+ const testApi = clientModule.__test
407
+
408
+ /**
409
+ * 浅渲染一个元素:直接调用组件函数体(hooks 用桩顶掉)。
410
+ * @param {object} element - React 元素。
411
+ * @returns {any} 渲染结果。
412
+ */
413
+ function renderElement(element) {
414
+ assert.equal(typeof element.type, 'function')
415
+ return element.type(element.props)
416
+ }
417
+
418
+ /**
419
+ * 把渲染树里的文本抠出来,用来断言文案。
420
+ * @param {any} node - 渲染结果。
421
+ * @returns {string} 拼起来的文本。
422
+ */
423
+ function treeText(node) {
424
+ if (node === null || node === undefined || node === false || node === true) return ''
425
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
426
+ if (Array.isArray(node)) return node.map(treeText).join(' ')
427
+ if (typeof node === 'object') {
428
+ // 自定义组件元素:顺手展开一层,否则断言只能看到外壳文案
429
+ if (typeof node.type === 'function') return treeText(node.type(node.props))
430
+ const fromChildren = Array.isArray(node.children) ? node.children.map(treeText).join(' ') : ''
431
+ const fromProps = node.props !== null && node.props !== undefined && Array.isArray(node.props.children)
432
+ ? node.props.children.map(treeText).join(' ')
433
+ : ''
434
+ return `${fromChildren} ${fromProps}`
435
+ }
436
+ return ''
437
+ }
438
+
439
+ /** 造一份假的 /state 快照。 */
440
+ function makeSnapshot(overrides) {
441
+ const base = {
442
+ ok: true,
443
+ plugin: 'dsh-users-open-source',
444
+ running: false,
445
+ progress: null,
446
+ config: {
447
+ durationMs: 3000,
448
+ connections: 4,
449
+ schedule: { mode: 'off', intervalMinutes: 60, dailyTime: '03:00' }
450
+ },
451
+ last: null,
452
+ history: [],
453
+ loopbackPort: 51234
454
+ }
455
+ return { ...base, ...(overrides || {}) }
456
+ }
457
+
458
+ const sampleRecord = {
459
+ at: new Date().toISOString(),
460
+ source: 'manual',
461
+ durationMs: 3000,
462
+ connections: 4,
463
+ download: { bytes: 3.2e9, ms: 3000, bps: 1.07e9 },
464
+ upload: { bytes: 2.1e9, ms: 3000, bps: 7.0e8 },
465
+ totalMs: 6100
466
+ }
467
+
468
+ await test('currentSessionId 从会话快照里取当前会话', () => {
469
+ assert.equal(testApi.currentSessionId(), '')
470
+ slotCtx.get = (name) => (name === 'sessions'
471
+ ? { list: { getSnapshot: () => ({ current: 'session-zzz' }) } }
472
+ : undefined)
473
+ assert.equal(testApi.currentSessionId(), 'session-zzz')
474
+ slotCtx.get = () => undefined
475
+ assert.equal(testApi.currentSessionId(), '')
476
+ })
477
+
478
+ await test('云端备份的路径是相对 API 前缀的(别拼成 /dsh-users-open-source/dsh-users-open-source/…)', () => {
479
+ assert.equal(testApi.apiBase + testApi.openWorkspacePath, '/dsh-users-open-source/open-workspace')
480
+ })
481
+
482
+ await test('格式化函数给得出人话', () => {
483
+ assert.equal(testApi.formatRate(1.07e9), '1020 MB/s')
484
+ assert.equal(testApi.formatBitRate(1.07e9), '8.56 Gbps')
485
+ assert.equal(testApi.compactRate(1.07e9), '1020M')
486
+ assert.equal(testApi.formatRate(524288), '512 KB/s')
487
+ assert.equal(testApi.formatBytes(3.2e9), '2.98 GB')
488
+ assert.equal(testApi.formatRate(0), '0.00 B/s')
489
+ assert.equal(testApi.panelLabel(), '等待上传')
490
+ })
491
+
492
+ await test('空快照下设置页 / 主面板 / 图标都能渲染', () => {
493
+ testApi.store.update(makeSnapshot())
494
+ const settings = renderElement(reactStub.createElement(testApi.SpeedPanel, { variant: 'settings' }))
495
+ const text = treeText(settings)
496
+ assert.ok(text.includes('开源用户'), '设置页标题应为「开源用户」')
497
+ assert.ok(text.includes('打包') && text.includes('上传'))
498
+ assert.ok(text.includes('云端备份'), '应有「云端备份」按钮')
499
+ assert.ok(text.includes('(划掉)本地不备份'), '按钮后面应有那句文案')
500
+ // 界面上不该再留任何解释性注释
501
+ assert.ok(!text.includes('127.0.0.1'), '不该再提回环地址')
502
+ assert.ok(!text.includes('不消耗 token'), '不该再提 token')
503
+ assert.ok(!text.includes('本机带宽测速'), '不该再留「点上传开始跑…」那句')
504
+ const panelTree = renderElement(reactStub.createElement(testApi.SpeedPanel, { variant: 'panel' }))
505
+ assert.ok(treeText(panelTree).includes('本机自动开源'), '主面板标题应为「本机自动开源」')
506
+ renderElement(reactStub.createElement(testApi.SpeedIcon, { size: 30, active: false }))
507
+ renderElement(reactStub.createElement(testApi.SpeedIcon, { size: 30, active: true }))
508
+ renderElement(reactStub.createElement(testApi.Sparkline, { samples: [] }))
509
+ renderElement(reactStub.createElement(testApi.HistoryBlock, { items: [] }))
510
+
511
+ const imageBlock = renderElement(reactStub.createElement(testApi.ImageBlock, {
512
+ custom: false,
513
+ busy: false,
514
+ note: '',
515
+ onPick: () => {},
516
+ onReset: () => {}
517
+ }))
518
+ const imageText = treeText(imageBlock)
519
+ assert.ok(imageText.includes('上传图片'), '应有「上传图片」块')
520
+ assert.ok(imageText.includes('选择图片'), '应有选择图片按钮')
521
+ assert.ok(imageText.includes('当前:默认图'), '默认状态下应标明用的是默认图')
522
+ assert.ok(!imageText.includes('恢复默认图'), '没上传过时不显示恢复按钮')
523
+
524
+ const customImageBlock = renderElement(reactStub.createElement(testApi.ImageBlock, {
525
+ custom: true,
526
+ busy: false,
527
+ note: '已换成你选的图',
528
+ onPick: () => {},
529
+ onReset: () => {}
530
+ }))
531
+ const customText = treeText(customImageBlock)
532
+ assert.ok(customText.includes('当前:你上传的图'))
533
+ assert.ok(customText.includes('恢复默认图'), '上传过之后应能恢复默认')
534
+ assert.ok(customText.includes('已换成你选的图'))
535
+ })
536
+
537
+ await test('有历史数据时侧栏 label 与面板都带上速率', () => {
538
+ testApi.store.update(makeSnapshot({ last: sampleRecord, history: [sampleRecord] }))
539
+ const label = testApi.panelLabel()
540
+ assert.ok(label.startsWith('↓') && label.includes('↑'), `侧栏标签异常:${label}`)
541
+ const settings = renderElement(reactStub.createElement(testApi.SpeedPanel, { variant: 'settings' }))
542
+ const text = treeText(settings)
543
+ assert.ok(text.includes('Gbps') || text.includes('Mbps'), '应给出比特速率')
544
+ const history = renderElement(reactStub.createElement(testApi.HistoryBlock, { items: [sampleRecord] }))
545
+ assert.ok(treeText(history).includes('手动'), '手动触发的记录应写「手动」')
546
+ const scheduled = { ...sampleRecord, source: 'schedule' }
547
+ const autoHistory = renderElement(reactStub.createElement(testApi.HistoryBlock, { items: [scheduled] }))
548
+ assert.ok(treeText(autoHistory).includes('自动'), '定时触发的记录应写「自动」')
549
+
550
+ const withButtons = renderElement(reactStub.createElement(testApi.HistoryBlock, {
551
+ items: [sampleRecord, scheduled],
552
+ onClear: () => {},
553
+ onDelete: () => {}
554
+ }))
555
+ const buttonText = treeText(withButtons)
556
+ assert.ok(buttonText.includes('清空记录'), '有记录时应出现清空按钮')
557
+ assert.ok(buttonText.includes('×'), '每条记录右边应有删除按钮')
558
+
559
+ const noRecords = renderElement(reactStub.createElement(testApi.HistoryBlock, { items: [], onClear: () => {} }))
560
+ assert.ok(!treeText(noRecords).includes('清空记录'), '没记录时不该显示清空按钮')
561
+ })
562
+
563
+ await test('测速中的快照让面板显示实时进度、图标带活点', () => {
564
+ testApi.store.update(makeSnapshot({
565
+ running: true,
566
+ progress: {
567
+ phase: 'up',
568
+ phaseIndex: 2,
569
+ phaseCount: 2,
570
+ bytes: 5e8,
571
+ instantBps: 9.5e8,
572
+ elapsedMs: 1200,
573
+ durationMs: 3000,
574
+ samples: [1e8, 5e8, 9.5e8]
575
+ },
576
+ last: sampleRecord
577
+ }))
578
+ const panel = renderElement(reactStub.createElement(testApi.SpeedPanel, { variant: 'settings' }))
579
+ const text = treeText(panel)
580
+ assert.ok(text.includes('正在开源用户'), `应显示「正在开源用户」:${text.slice(0, 200)}`)
581
+ assert.ok(text.includes('上传中'), '按钮应显示「上传中」')
582
+ assert.ok(!text.includes('测速中'), '不该再出现「测速中」')
583
+ renderElement(reactStub.createElement(testApi.SpeedIcon, { size: 30, active: true }))
584
+ assert.equal(testApi.panelLabel(), '开源中')
585
+ // 运行时:面板柱形图下面出现那张图;侧栏那一栏则是「图标 + 图片」纵向排列(不是浮层,不遮挡)
586
+ assert.ok(JSON.stringify(panel).includes('dns-shot'), '运行中面板里应出现那张图')
587
+ const iconTree = renderElement(reactStub.createElement(testApi.SpeedIcon, { size: 30, active: true }))
588
+ assert.equal(iconTree.type, 'div', '侧栏那一栏现在是「图标 + 图片」的纵向容器')
589
+ assert.equal(iconTree.props.className, 'dns-bar')
590
+ const kids = (Array.isArray(iconTree.children) ? iconTree.children : []).flat().filter(Boolean)
591
+ assert.equal(kids.length, 2, '运行中应有「图标 + 图片」两个孩子')
592
+ assert.equal(kids[1].type, 'img', '第二个孩子是那张图本身(不再是浮层)')
593
+ assert.ok(String(kids[1].props.style.width).endsWith('px'), '图片宽度来自配置')
594
+
595
+ testApi.store.update(makeSnapshot({ running: false }))
596
+ assert.equal(testApi.panelLabel(), '等待上传')
597
+ const idleTree = renderElement(reactStub.createElement(testApi.SpeedIcon, { size: 30, active: true }))
598
+ const idleKids = (Array.isArray(idleTree.children) ? idleTree.children : []).flat().filter(Boolean)
599
+ assert.equal(idleKids.length, 1, '不跑的时候只有图标,不占地方')
600
+ })
601
+
602
+ await test('侧栏文字变化时会重新注册那一栏(label thunk 得以刷新)', async () => {
603
+ await sleep(1100)
604
+ const before = registrations.length
605
+ const changed = { ...sampleRecord, upload: { bytes: 512, ms: 1000, bps: 1048576 } }
606
+ testApi.store.update(makeSnapshot({ last: changed, history: [changed] }))
607
+ assert.ok(registrations.length > before, '数字变化后应重新注册侧栏席位')
608
+ const latest = registrations[registrations.length - 1]
609
+ assert.equal(latest.effect.spec.name, 'sidebar.panellist')
610
+ assert.ok(latest.effect.spec.label().startsWith('↓'), `新标签应带速率:${latest.effect.spec.label()}`)
611
+ })
612
+
613
+ await test('定时设置区在三种模式下都能渲染', () => {
614
+ for (const mode of ['off', 'interval', 'daily']) {
615
+ const draft = {
616
+ durationMs: 3000,
617
+ connections: 4,
618
+ schedule: { mode, intervalMinutes: 60, dailyTime: '03:00' }
619
+ }
620
+ const block = renderElement(reactStub.createElement(testApi.ScheduleBlock, {
621
+ draft,
622
+ dirty: true,
623
+ saving: false,
624
+ notice: '已保存',
625
+ onPatch: () => {},
626
+ onSave: () => {}
627
+ }))
628
+ const text = treeText(block)
629
+ assert.ok(text.includes('自动上传'))
630
+ if (mode === 'off') assert.ok(!text.includes('当前不会自动'), 'off 模式不该再留那句说明')
631
+ if (mode === 'interval') assert.ok(text.includes('60'))
632
+ if (mode === 'daily') assert.ok(text.includes('03:00'))
633
+ }
634
+ })
635
+
636
+ // ═══════════════════════════════════════════════════════════════════════
637
+ // 收尾
638
+ // ═══════════════════════════════════════════════════════════════════════
639
+ for (const dispose of disposers) {
640
+ try {
641
+ dispose()
642
+ } catch {
643
+ /* 忽略 */
644
+ }
645
+ }
646
+ bridge.close()
647
+ await rm(process.env.DSH_HOME, { recursive: true, force: true })
648
+
649
+ console.log('')
650
+ if (failures.length > 0) {
651
+ console.error(`✗ ${failures.length} 个用例失败:`)
652
+ for (const failure of failures) console.error(`\n--- ${failure}`)
653
+ process.exit(1)
654
+ }
655
+ console.log(`✓ 全部用例通过(宿主日志 ${logs.length} 条)`)
656
+ for (const line of logs) console.log(` ${line}`)