@hyzyn/dsh-tty 0.18.3 → 0.19.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.
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @hyzyn/dsh-tty — 视觉预览 / 截图回归工具。
4
+ *
5
+ * 把 packages/tty/client.js 装进一个纯静态夹具页(scripts/preview/harness.html,
6
+ * 内含伪造的 DSH 宿主:module loader / fetch / WebSocket),用 headless Chrome
7
+ * 逐个场景渲染并截图到 .preview/shots/,用于改样式时的视觉走查。
8
+ *
9
+ * 用法:
10
+ * node scripts/preview.mjs # 全场景截图
11
+ * node scripts/preview.mjs local multi # 指定场景
12
+ * node scripts/preview.mjs --list # 列出场景
13
+ * node scripts/preview.mjs --build # 顺带重建 client.js(默认不重建,产物落后直接报错)
14
+ * node scripts/preview.mjs --theme=light # 浅色主题
15
+ *
16
+ * 需要本机有 Chrome/Chromium(默认找 playwright 缓存的 Chrome for Testing,
17
+ * 也可用 CHROME_PATH 指定)。
18
+ */
19
+ import { spawn } from 'node:child_process'
20
+ import { createRequire } from 'node:module'
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs'
22
+ import { dirname, join, resolve } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { homedir, tmpdir } from 'node:os'
25
+
26
+ const require = createRequire(import.meta.url)
27
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
28
+ const previewDir = join(root, '.preview')
29
+
30
+ const argv = process.argv.slice(2)
31
+ const flags = new Set(argv.filter((a) => a.startsWith('--')))
32
+ const positional = argv.filter((a) => !a.startsWith('--'))
33
+ const theme = (argv.find((a) => a.startsWith('--theme=')) || '--theme=dark').split('=')[1]
34
+ // 默认不再重建(0.19.0):此前隐式跑 build 会悄悄改掉必须入库的 client.js。
35
+ // 需要重建时显式 `--build`;产物落后于源码时直接报错,绝不「对着旧产物截图」。
36
+ const wantBuild = flags.has('--build')
37
+ /** 输出子目录:--out=shots-light 之类,便于并排比较明暗主题。 */
38
+ const outName = (argv.find((a) => a.startsWith('--out=')) || '--out=shots').split('=')[1]
39
+ const shotsDir = join(previewDir, outName)
40
+
41
+ /* ------------------------------- 场景清单 ------------------------------- */
42
+
43
+ const SCENARIOS = [
44
+ ['local', '单标签本地终端'],
45
+ ['stats-off', '服务器状态条关闭(整条收起)'],
46
+ ['stats-broken', '服务器状态条坏数据兜底(不崩、不变形)'],
47
+ ['multi', '多标签 + SSH 连接栏'],
48
+ ['menu', '「+」新建菜单'],
49
+ ['ssh', 'SSH 连接对话框'],
50
+ ['ssh-probe', 'SSH 连接对话框(试连结果)'],
51
+ ['ssh-edit', 'SSH 连接对话框(编辑)'],
52
+ ['settings', '设置卡片'],
53
+ ['settings-docker', '设置卡片(docker 与 tty 并排对照)'],
54
+ ['sftp', 'SFTP 单窗体(挂右侧挂载位)'],
55
+ ['sftp-dual', 'SFTP 双栏(挂右侧挂载位)'],
56
+ ['sftp-fallback', 'SFTP 落点回退(抽屉被容器面板占用 → 对话框)'],
57
+ ['dock-pane-tab', '挂载位跟着标签切(切走收起 / 切回恢复 / 不堵死另一个标签的入口)'],
58
+ ['minimized', '最小化(侧边栏徽标)'],
59
+ ['exited', '会话退出遮罩'],
60
+ ['error', '连接错误遮罩'],
61
+ ['tunnel', '隧道状态弹层'],
62
+ ['search', '搜索框展开'],
63
+ ['toast', 'toast 提醒'],
64
+ ['embed', '嵌入式终端(ttyTerminal.mount)'],
65
+ ['embed-panel', '嵌入终端与面板共存(关面板不掉线)'],
66
+ ['docker-panel', 'docker 面板(只读:变更组整组置灰)'],
67
+ ['docker-panel-rw', 'docker 面板(允许变更:动作条全可用)'],
68
+ ['docker-exec', 'docker 面板 → 终端抽屉(端到端)'],
69
+ ['docker-exec-logs', 'docker 面板 → 终端抽屉 + 日志页(折叠保留会话)'],
70
+ ['dock-pane', '终端面板右侧挂载位(ttyPanel 骨架)'],
71
+ ['dock-pane-bottom', '底部挂载位 + 终端重排(视口钉底)'],
72
+ ['docker-dock', '连接栏「容器」→ docker 面板挂进右侧 dock(终端不被盖住)'],
73
+ ]
74
+
75
+ if (flags.has('--list')) {
76
+ for (const [name, label] of SCENARIOS) console.log(name.padEnd(12), label)
77
+ process.exit(0)
78
+ }
79
+
80
+ const wanted = positional.length > 0 ? positional : SCENARIOS.map(([n]) => n)
81
+ for (const name of wanted) {
82
+ if (!SCENARIOS.some(([n]) => n === name)) {
83
+ console.error('未知场景:' + name + '(--list 查看可用场景)')
84
+ process.exit(1)
85
+ }
86
+ }
87
+
88
+ /* ------------------------------ 夹具准备 ------------------------------ */
89
+
90
+ function log(msg) {
91
+ process.stdout.write('[preview] ' + msg + '\n')
92
+ }
93
+
94
+ async function buildClient() {
95
+ await new Promise((res, rej) => {
96
+ const child = spawn(process.execPath, [join(root, 'scripts/build-client.mjs')], { cwd: root, stdio: 'inherit' })
97
+ child.on('exit', (code) => (code === 0 ? res() : rej(new Error('build-client 退出码 ' + code))))
98
+ })
99
+ }
100
+
101
+ /** 下载 React 18 UMD(只做一次,缓存在 .preview/vendor)。 */
102
+ async function ensureVendor() {
103
+ const dir = join(previewDir, 'vendor')
104
+ mkdirSync(dir, { recursive: true })
105
+ const targets = [
106
+ ['react.js', 'https://unpkg.com/react@18.3.1/umd/react.production.min.js'],
107
+ ['react-dom.js', 'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js'],
108
+ ]
109
+ for (const [file, url] of targets) {
110
+ const dest = join(dir, file)
111
+ if (existsSync(dest)) continue
112
+ log('下载 ' + file)
113
+ const res = await fetch(url)
114
+ if (!res.ok) throw new Error('下载 ' + url + ' 失败:HTTP ' + res.status)
115
+ writeFileSync(dest, Buffer.from(await res.arrayBuffer()))
116
+ }
117
+ }
118
+
119
+ /** 皮肤变量:优先用本机 skin-center 的真实 token 表,找不到则用内置兜底。 */
120
+ function skinCss() {
121
+ const candidates = []
122
+ const skinsRoot = join(homedir(), '.dsh/profiles/web/node_modules/@linxin666/dsh-client-ui-skin-center/skins')
123
+ if (existsSync(skinsRoot)) {
124
+ for (const name of readdirSync(skinsRoot)) {
125
+ const file = join(skinsRoot, name, 'skin.css')
126
+ if (existsSync(file)) candidates.push(file)
127
+ }
128
+ }
129
+ if (candidates.length > 0) return readFileSync(candidates[0], 'utf8')
130
+ log('未找到本机 skin token 表,使用内置兜底变量')
131
+ return FALLBACK_SKIN_CSS
132
+ }
133
+
134
+ const FALLBACK_SKIN_CSS = `
135
+ :root {
136
+ --dsw-alias-bg-base: #ffffff;
137
+ --dsw-alias-bg-layer-1: #f6f7f9;
138
+ --dsw-alias-bg-layer-2: #eef0f4;
139
+ --dsw-alias-bg-layer-3: #e5e8ee;
140
+ --dsw-alias-bg-mask-1: #00000059;
141
+ --dsw-alias-bg-overlay: #fffffff0;
142
+ --dsw-alias-border-l1: #00000014;
143
+ --dsw-alias-border-l2: #00000024;
144
+ --dsw-alias-border-l3: #00000038;
145
+ --dsw-alias-border-l4: #0000004d;
146
+ --dsw-alias-interactive-bg-hover: #0000000f;
147
+ --dsw-alias-interactive-bg-active: #0000001f;
148
+ --dsw-alias-interactive-bg-hover-danger: #c000000f;
149
+ --dsw-alias-label-primary: #0f1115;
150
+ --dsw-alias-label-secondary: #4b5158;
151
+ --dsw-alias-label-tertiary: #767c85;
152
+ --dsw-alias-label-dimmed: #9aa0a8;
153
+ --dsw-alias-label-primary-inverted: #ffffff;
154
+ --dsw-alias-brand-primary: #4d6bfe;
155
+ --dsw-alias-state-business-primary: #4d6bfe;
156
+ --dsw-alias-state-error-primary: #d1242f;
157
+ --dsw-alias-state-success-primary: #1a7f37;
158
+ --dsw-alias-state-warn-primary: #bf8700;
159
+ --dsw-specific-input-major: #ffffff;
160
+ --dsw-specific-menu: #ffffff;
161
+ --dsw-specific-tip: #f6f7f9;
162
+ --dsw-specific-sidebar-fill: #f2f3f5;
163
+ --dsw-specific-sidebar-nav-item-hover: #0000000f;
164
+ --dsw-specific-sidebar-nav-item-active: #0000001a;
165
+ --dsw-alias-scrollbar-bg-l1: #d0d3d8;
166
+ --dsw-alias-scrollbar-hover-l1: #b0b4ba;
167
+ --dsw-shadow-lv3: 0 12px 32px -8px #0000002e;
168
+ }
169
+ body[data-ds-dark-theme] {
170
+ --dsw-alias-bg-base: #1b1b1f;
171
+ --dsw-alias-bg-layer-1: #202024;
172
+ --dsw-alias-bg-layer-2: #26262b;
173
+ --dsw-alias-bg-layer-3: #2d2d33;
174
+ --dsw-alias-bg-mask-1: #0000008c;
175
+ --dsw-alias-bg-overlay: #202024eb;
176
+ --dsw-alias-border-l1: #ffffff14;
177
+ --dsw-alias-border-l2: #ffffff26;
178
+ --dsw-alias-border-l3: #ffffff3d;
179
+ --dsw-alias-border-l4: #ffffff52;
180
+ --dsw-alias-interactive-bg-hover: #ffffff14;
181
+ --dsw-alias-interactive-bg-active: #ffffff24;
182
+ --dsw-alias-interactive-bg-hover-danger: #ff6b6b1f;
183
+ --dsw-alias-label-primary: #ececf1;
184
+ --dsw-alias-label-secondary: #b4b4bd;
185
+ --dsw-alias-label-tertiary: #8e8e99;
186
+ --dsw-alias-label-dimmed: #6b6b75;
187
+ --dsw-alias-label-primary-inverted: #101014;
188
+ --dsw-alias-brand-primary: #7c9cff;
189
+ --dsw-alias-state-business-primary: #7c9cff;
190
+ --dsw-alias-state-error-primary: #ff6b6b;
191
+ --dsw-alias-state-success-primary: #4cc38a;
192
+ --dsw-alias-state-warn-primary: #e0a94a;
193
+ --dsw-specific-input-major: #17171a;
194
+ --dsw-specific-menu: #24242af0;
195
+ --dsw-specific-tip: #24242a;
196
+ --dsw-specific-sidebar-fill: #17171a;
197
+ --dsw-specific-sidebar-nav-item-hover: #ffffff12;
198
+ --dsw-specific-sidebar-nav-item-active: #ffffff1f;
199
+ --dsw-alias-scrollbar-bg-l1: #3a3a42;
200
+ --dsw-alias-scrollbar-hover-l1: #4d4d57;
201
+ --dsw-shadow-lv3: 0 12px 32px -8px #000000a6;
202
+ }
203
+ `
204
+
205
+ function prepare() {
206
+ mkdirSync(shotsDir, { recursive: true })
207
+ for (const file of ['harness.html', 'harness.js', 'mock-host.js']) {
208
+ writeFileSync(join(previewDir, file), readFileSync(join(root, 'scripts/preview', file)))
209
+ }
210
+ writeFileSync(join(previewDir, 'skin.css'), skinCss())
211
+ writeFileSync(join(previewDir, 'client.js'), readFileSync(join(root, 'client.js')))
212
+ // dsh-docker 的浏览器半体(可选):在同一页面里跑「tty 提供服务 + docker 消费」的联调场景
213
+ const dockerClient = join(root, '..', 'docker', 'client.js')
214
+ if (existsSync(dockerClient)) writeFileSync(join(previewDir, 'docker-client.js'), readFileSync(dockerClient))
215
+ }
216
+
217
+ /* ------------------------------ Chrome/CDP ------------------------------ */
218
+
219
+ function findChrome() {
220
+ if (process.env.CHROME_PATH) return process.env.CHROME_PATH
221
+ const roots = [join(homedir(), 'Library/Caches/ms-playwright'), join(homedir(), '.cache/ms-playwright')]
222
+ const candidates = []
223
+ for (const base of roots) {
224
+ if (!existsSync(base)) continue
225
+ for (const dir of readdirSync(base)) {
226
+ if (!dir.startsWith('chromium')) continue
227
+ candidates.push(join(base, dir, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'))
228
+ candidates.push(join(base, dir, 'chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'))
229
+ candidates.push(join(base, dir, 'chrome-linux/chrome'))
230
+ }
231
+ }
232
+ candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
233
+ candidates.push('/Applications/Chromium.app/Contents/MacOS/Chromium')
234
+ for (const c of candidates) if (existsSync(c)) return c
235
+ throw new Error('找不到 Chrome/Chromium,请设置 CHROME_PATH')
236
+ }
237
+
238
+ const WebSocketImpl = (() => {
239
+ try {
240
+ return require('ws')
241
+ } catch {
242
+ return null // 回退到 Node 内置 WebSocket
243
+ }
244
+ })()
245
+
246
+ class Cdp {
247
+ constructor(socket, kind) {
248
+ this.socket = socket
249
+ this.kind = kind
250
+ this.id = 0
251
+ this.pending = new Map()
252
+ this.listeners = new Set()
253
+ const handle = (raw) => {
254
+ let msg
255
+ try {
256
+ msg = JSON.parse(typeof raw === 'string' ? raw : String(raw))
257
+ } catch {
258
+ return
259
+ }
260
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
261
+ const { resolve, reject } = this.pending.get(msg.id)
262
+ this.pending.delete(msg.id)
263
+ if (msg.error) reject(new Error((msg.error && msg.error.message) || JSON.stringify(msg.error)))
264
+ else resolve(msg.result)
265
+ return
266
+ }
267
+ for (const fn of this.listeners) fn(msg)
268
+ }
269
+ if (kind === 'ws') socket.on('message', handle)
270
+ else socket.addEventListener('message', (event) => handle(event.data))
271
+ }
272
+
273
+ static async connect(url) {
274
+ if (WebSocketImpl !== null) {
275
+ const socket = new WebSocketImpl(url, { perMessageDeflate: false })
276
+ await new Promise((res, rej) => {
277
+ socket.once('open', res)
278
+ socket.once('error', rej)
279
+ })
280
+ return new Cdp(socket, 'ws')
281
+ }
282
+ const socket = new WebSocket(url)
283
+ await new Promise((res, rej) => {
284
+ socket.addEventListener('open', res, { once: true })
285
+ socket.addEventListener('error', () => rej(new Error('CDP 连接失败')), { once: true })
286
+ })
287
+ return new Cdp(socket, 'node')
288
+ }
289
+
290
+ send(method, params = {}) {
291
+ const id = ++this.id
292
+ this.socket.send(JSON.stringify({ id, method, params }))
293
+ return new Promise((resolve, reject) => {
294
+ this.pending.set(id, { resolve, reject })
295
+ setTimeout(() => {
296
+ if (this.pending.has(id)) {
297
+ this.pending.delete(id)
298
+ reject(new Error('CDP 超时:' + method))
299
+ }
300
+ }, 30000)
301
+ })
302
+ }
303
+
304
+ on(fn) {
305
+ this.listeners.add(fn)
306
+ return () => this.listeners.delete(fn)
307
+ }
308
+ }
309
+
310
+ async function launchChrome() {
311
+ const chrome = findChrome()
312
+ const userDataDir = join(previewDir, 'chrome-profile')
313
+ rmSync(userDataDir, { recursive: true, force: true })
314
+ const child = spawn(chrome, [
315
+ '--headless=new',
316
+ '--remote-debugging-port=0',
317
+ '--user-data-dir=' + userDataDir,
318
+ '--no-first-run',
319
+ '--no-default-browser-check',
320
+ '--disable-extensions',
321
+ '--disable-background-networking',
322
+ '--disable-sync',
323
+ '--disable-features=Translate,OptimizationGuideModelDownloading,MediaRouter',
324
+ // 关掉 WebGL:xterm 的 WebglAddon 在无 GPU 的 headless 里会拿到一个画不出
325
+ // 东西的上下文(不抛错 → 不会回退 DOM 渲染器),截图里终端永远空白
326
+ '--disable-3d-apis',
327
+ '--disable-webgl',
328
+ '--force-color-profile=srgb',
329
+ '--font-render-hinting=none',
330
+ 'about:blank',
331
+ ], { stdio: ['ignore', 'ignore', 'pipe'] })
332
+
333
+ const wsUrl = await new Promise((resolvePromise, reject) => {
334
+ let buffer = ''
335
+ const timer = setTimeout(() => reject(new Error('等待 Chrome DevTools 端口超时')), 25000)
336
+ child.stderr.on('data', (chunk) => {
337
+ buffer += String(chunk)
338
+ const match = buffer.match(/DevTools listening on (ws:\/\/\S+)/)
339
+ if (match) {
340
+ clearTimeout(timer)
341
+ resolvePromise(match[1])
342
+ }
343
+ })
344
+ child.on('exit', (code) => {
345
+ clearTimeout(timer)
346
+ reject(new Error('Chrome 退出,code=' + code))
347
+ })
348
+ })
349
+
350
+ // 直接连页面 target:省掉 Target.attachToTarget 的 session 路由,命令最简单
351
+ const port = new URL(wsUrl).port
352
+ const targets = await (await fetch('http://127.0.0.1:' + port + '/json/list')).json()
353
+ const page = targets.find((t) => t.type === 'page')
354
+ if (!page) throw new Error('Chrome 没有可用的页面 target')
355
+ const cdp = await Cdp.connect(page.webSocketDebuggerUrl)
356
+ await cdp.send('Page.enable')
357
+ await cdp.send('Runtime.enable')
358
+ return { child, cdp, userDataDir }
359
+ }
360
+
361
+ /* ------------------------------- 截图流程 ------------------------------- */
362
+
363
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
364
+
365
+ async function shoot(cdp, name, label) {
366
+ const problems = []
367
+ const off = cdp.on((msg) => {
368
+ if (msg.method === 'Runtime.exceptionThrown') {
369
+ problems.push('异常:' + (msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text))
370
+ } else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
371
+ problems.push('console.error:' + msg.params.args.map((a) => a.description || a.value).join(' '))
372
+ }
373
+ })
374
+ try {
375
+ const url = 'file://' + join(previewDir, 'harness.html') + '?scenario=' + encodeURIComponent(name) + '&theme=' + theme
376
+ // 样例路径随机器变化:运行时注入,避免把开发者目录写进夹具源码
377
+ + '&cwd=' + encodeURIComponent(root) + '&home=' + encodeURIComponent(homedir())
378
+ await cdp.send('Page.navigate', { url })
379
+ // 导航提交是异步的:必须等到新文档真的挂上了本次场景,否则会拿到上一个
380
+ // 场景的 __previewReady(截图就会停在半成品状态)
381
+ let ready = false
382
+ for (let i = 0; i < 120; i += 1) {
383
+ const probe = await cdp.send('Runtime.evaluate', {
384
+ expression: 'window.__previewState ? window.__previewState.name : null',
385
+ returnByValue: true,
386
+ }).catch(() => null)
387
+ if (probe?.result?.value === name) {
388
+ ready = true
389
+ break
390
+ }
391
+ await sleep(50)
392
+ }
393
+ if (!ready) problems.push('场景未挂载(导航超时)')
394
+ const outcome = await cdp.send('Runtime.evaluate', {
395
+ expression: 'window.__previewReady.then(() => ({ ok: true })).catch((e) => ({ ok: false, error: String((e && e.message) || e) }))',
396
+ awaitPromise: true,
397
+ returnByValue: true,
398
+ })
399
+ const value = outcome?.result?.value
400
+ if (value && value.ok === false) problems.push('场景失败:' + value.error)
401
+ /*
402
+ * 场景可以挂一份**函数形态**的断言到 `window.__previewAssert`:返回 null 表示通过,
403
+ * 返回字符串/数组表示失败(与各场景既有写法一致)。截图工具顺手把它跑掉——否则
404
+ * 那些断言只有手工 CDP 取 diag 时才有人看,回归就白钉了。
405
+ *
406
+ * 单独发一条 evaluate:`window.__previewDiag()` 的返回值里带着函数
407
+ * (`assert: window.__previewAssert`),和断言结果混在一个 `returnByValue` 里会
408
+ * 让整条求值失败——那正是「断言看着跑了、其实一条都没生效」的由来。
409
+ * 对象形态的 `__previewAssert`(纯数据快照,供人工比对)不在此列。
410
+ */
411
+ const check = await cdp.send('Runtime.evaluate', {
412
+ expression: '(async () => {'
413
+ + ' const a = window.__previewAssert;'
414
+ + ' if (typeof a !== "function") return null;'
415
+ + ' const r = await a();'
416
+ + ' return Array.isArray(r) ? r.join(";") : (r == null ? null : String(r));'
417
+ + '})()',
418
+ awaitPromise: true,
419
+ returnByValue: true,
420
+ })
421
+ if (check?.exceptionDetails !== undefined) {
422
+ // 断言自己抛错(拼错选择器之类)也要算失败,不能悄悄变成「通过」
423
+ problems.push('断言执行出错:' + String(check.exceptionDetails.exception?.description ?? JSON.stringify(check.exceptionDetails)))
424
+ } else if (typeof check?.result?.value === 'string' && check.result.value !== '') {
425
+ problems.push('断言未通过:' + check.result.value)
426
+ }
427
+ if (process.env.PREVIEW_DIAG) {
428
+ const diag = await cdp.send('Runtime.evaluate', {
429
+ expression: 'JSON.stringify({ ...window.__previewDiag(), assert: undefined })',
430
+ returnByValue: true,
431
+ })
432
+ if (typeof diag?.result?.value === 'string') log(' · diag ' + diag.result.value)
433
+ }
434
+ await sleep(500)
435
+ const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
436
+ const file = join(shotsDir, name + '.png')
437
+ writeFileSync(file, Buffer.from(shot.data, 'base64'))
438
+ return { file, problems }
439
+ } finally {
440
+ off()
441
+ }
442
+ }
443
+
444
+ /* --------------------------------- main --------------------------------- */
445
+
446
+ /** client-src/ 下最新源码的 mtime(递归一层足够)。 */
447
+ function newestSourceMtime() {
448
+ let newest = 0
449
+ const srcDir = join(root, 'client-src')
450
+ for (const entry of readdirSync(srcDir)) {
451
+ const st = statSync(join(srcDir, entry))
452
+ if (st.isFile()) newest = Math.max(newest, st.mtimeMs)
453
+ }
454
+ return newest
455
+ }
456
+
457
+ async function main() {
458
+ const artifact = join(root, 'client.js')
459
+ if (wantBuild) {
460
+ log('重新打包 client.js')
461
+ await buildClient()
462
+ } else {
463
+ if (!existsSync(artifact)) {
464
+ throw new Error('client.js 不存在:先跑 pnpm -r build,或用 --build 让 preview 重建')
465
+ }
466
+ if (newestSourceMtime() > statSync(artifact).mtimeMs) {
467
+ throw new Error('client.js 落后于 client-src/:先跑 pnpm -r build,或用 --build 让 preview 重建(不再默认隐式重建)')
468
+ }
469
+ }
470
+ prepare()
471
+ await ensureVendor()
472
+ log('启动 headless Chrome(' + theme + ' 主题)')
473
+ const { child, cdp, userDataDir } = await launchChrome()
474
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width: 1680, height: 1050, deviceScaleFactor: 2, mobile: false })
475
+ let failed = 0
476
+ try {
477
+ for (const name of wanted) {
478
+ const label = SCENARIOS.find(([n]) => n === name)[1]
479
+ const { file, problems } = await shoot(cdp, name, label)
480
+ const mark = problems.length > 0 ? '✗' : '✓'
481
+ log(mark + ' ' + name.padEnd(12) + label + ' → ' + file.replace(process.cwd() + '/', ''))
482
+ for (const p of problems) log(' ⚠ ' + p)
483
+ if (problems.length > 0) failed += 1
484
+ }
485
+ } finally {
486
+ child.kill('SIGTERM')
487
+ // Chrome 收尾可能还在写 profile:清理失败不该让整轮截图算失败
488
+ try {
489
+ rmSync(userDataDir, { recursive: true, force: true })
490
+ } catch {
491
+ /* 下次运行会覆盖 */
492
+ }
493
+ }
494
+ log('完成:' + (wanted.length - failed) + '/' + wanted.length + ' 场景正常,截图在 ' + shotsDir.replace(process.cwd() + '/', ''))
495
+ if (failed > 0) process.exitCode = 1
496
+ }
497
+
498
+ main().catch((error) => {
499
+ console.error('[preview] ' + (error && error.stack ? error.stack : error))
500
+ process.exit(1)
501
+ })
502
+
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @hyzyn/dsh-tty — probe 路由定向冒烟:只验证 /api/dsh-tty/probe(不依赖
4
+ * spawn/PTY,独立于 integration.mjs 的 B1 环境要求)。
5
+ *
6
+ * 用法:node scripts/probe-route-smoke.mjs
7
+ * 退出码:0 = 全部 PASS,1 = 任一 FAIL。
8
+ */
9
+ import { Context } from '@deepseek-ai/cordis'
10
+ import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
11
+ import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
12
+ import net from 'node:net'
13
+ import { name, inject, apply } from '../lib/index.js'
14
+ import { startSftpSshd, TEST_USER, TEST_PASSWORD } from './lib/test-sshd.mjs'
15
+
16
+ const RESULTS = []
17
+ function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
18
+ function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
19
+
20
+ async function run() {
21
+ const app = new Context()
22
+ const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
23
+ // settings / tools stub(probe 路由本身不需要,但插件启动时会注入)
24
+ const stubFiber = app.plugin({
25
+ name: 'settings-stub',
26
+ apply: (ctx) => {
27
+ ctx.provide('settings', { register: () => ({ get: () => ({}), update: async () => {} }) })
28
+ ctx.provide('tools', { register: () => () => {} })
29
+ // 与真实契约同形(section/context 返回注销器):插件热应用时会调用重建
30
+ ctx.provide('systemPrompt', {
31
+ section: () => () => {},
32
+ context: () => () => {},
33
+ })
34
+ },
35
+ })
36
+ await stubFiber.await()
37
+ const pluginFiber = app.plugin({ name, inject, apply }, { maxSessions: 4, term: 'xterm-256color' })
38
+ await wsFiber.await()
39
+ await pluginFiber.await()
40
+ const port = app.webServer.port
41
+ await new Promise((resolve) => setTimeout(resolve, 300)) // 等路由注册
42
+ const probe = async (body) => {
43
+ const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/probe`, {
44
+ method: 'POST',
45
+ headers: { 'content-type': 'application/json' },
46
+ body: JSON.stringify(body),
47
+ })
48
+ return res.json()
49
+ }
50
+ const getConfig = async () => (await (await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`)).json()).config
51
+
52
+ const sftpd = await startSftpSshd({ rootDir: process.cwd() })
53
+ const spec = { host: '127.0.0.1', port: sftpd.port, username: TEST_USER, auth: 'password', password: TEST_PASSWORD }
54
+ const hostsBefore = (Array.isArray((await getConfig()).hostKeys) ? (await getConfig()).hostKeys : []).length
55
+
56
+ // R1 试连成功(bookRecord=false):auth.ok,不落盘
57
+ const r1 = await probe({ ...spec, bookRecord: false })
58
+ if (r1.ok === true && r1.result?.auth?.ok === true && r1.result?.tcp?.ok === true) pass('R1 试连成功(hostkey=' + String(r1.result?.hostkey?.state) + ')')
59
+ else fail('R1 试连成功', JSON.stringify(r1))
60
+ const hostsMid = (Array.isArray((await getConfig()).hostKeys) ? (await getConfig()).hostKeys : []).length
61
+ if (hostsMid === hostsBefore) pass('R2 试连不落盘 hostKeys')
62
+ else fail('R2 试连不落盘 hostKeys', `before=${hostsBefore} after=${hostsMid}`)
63
+
64
+ // R3 连接簿测试(bookRecord=true):新指纹 TOFU 记录
65
+ const r3 = await probe({ ...spec, bookRecord: true })
66
+ if (r3.ok === true && r3.result?.auth?.ok === true && r3.result?.hostkey?.state === 'recorded') pass('R3 连接簿测试:新指纹 TOFU 记录')
67
+ else fail('R3 连接簿测试:新指纹 TOFU 记录', JSON.stringify(r3))
68
+ const hostsAfter = (Array.isArray((await getConfig()).hostKeys) ? (await getConfig()).hostKeys : [])
69
+ // 0.19.0 起记录形态为 {host, port, fingerprints[]}(旧单 fingerprint 字段兼容读取)
70
+ const recordedKey = hostsAfter.some((hk) => hk.host === '127.0.0.1' && Number(hk.port) === sftpd.port && ((Array.isArray(hk.fingerprints) && hk.fingerprints.length > 0) || typeof hk.fingerprint === 'string'))
71
+ if (recordedKey) pass('R4 指纹已持久化到 hostKeys')
72
+ else fail('R4 指纹已持久化到 hostKeys', JSON.stringify(hostsAfter))
73
+ const r5 = await probe({ ...spec, bookRecord: true })
74
+ if (r5.ok === true && r5.result?.auth?.ok === true && r5.result?.hostkey?.state === 'matched') pass('R5 二次测试:指纹匹配放行')
75
+ else fail('R5 二次测试:指纹匹配放行', JSON.stringify(r5))
76
+
77
+ // R6 坏密码 → 认证被拒
78
+ const r6 = await probe({ ...spec, password: 'wrong', bookRecord: true })
79
+ if (r6.ok === false && r6.result?.auth?.ok === false && /认证被拒绝/.test(r6.result?.auth?.error || '')) pass('R6 坏密码 → 认证被拒绝')
80
+ else fail('R6 坏密码 → 认证被拒绝', JSON.stringify(r6))
81
+
82
+ // R7 端口不通 → TCP 拒绝
83
+ const dead = net.createServer()
84
+ await new Promise((resolve) => dead.listen(0, '127.0.0.1', resolve))
85
+ const deadPort = dead.address().port
86
+ await new Promise((resolve) => dead.close(resolve))
87
+ const r7 = await probe({ ...spec, port: deadPort, bookRecord: false })
88
+ if (r7.ok === false && r7.result?.tcp?.ok === false && r7.result?.auth?.ok === false) pass('R7 端口不通 → tcp 拒绝')
89
+ else fail('R7 端口不通 → tcp 拒绝', JSON.stringify(r7))
90
+
91
+ // R8/R9 字段校验
92
+ const r8 = await probe({ host: '127.0.0.1', port: 22, username: '', auth: 'password', password: 'x' })
93
+ if (r8.ok === false && /主机|用户名/.test(r8.error || '')) pass('R8 缺字段 → 友好错误')
94
+ else fail('R8 缺字段 → 友好错误', JSON.stringify(r8))
95
+ const r9 = await probe({ host: '127.0.0.1', port: 70000, username: 'u', auth: 'password', password: 'x' })
96
+ if (r9.ok === false && /端口/.test(r9.error || '')) pass('R9 非法端口 → 友好错误')
97
+ else fail('R9 非法端口 → 友好错误', JSON.stringify(r9))
98
+
99
+ // loopback 围栏与既有路由同源(isLoopbackHttp:非 loopback 拒绝);host
100
+ // 头伪造在带同源 Origin 的浏览器 fetch 里不适用,此处不重复断言。
101
+
102
+ await sftpd.close()
103
+ const failedCount = RESULTS.filter(([kind]) => kind === 'FAIL').length
104
+ console.log(failedCount === 0 ? '\nprobe route smoke: 全部 PASS' : `\nprobe route smoke: ${failedCount} 个 FAIL`)
105
+ process.exit(failedCount === 0 ? 0 : 1)
106
+ }
107
+
108
+ run().catch((error) => {
109
+ console.error('[probe-route-smoke] 崩溃: ' + (error && error.stack ? error.stack : error))
110
+ process.exit(2)
111
+ })