@hyzyn/dsh-tty 0.24.1 → 0.26.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.
@@ -24,6 +24,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync
24
24
  import { dirname, join, resolve } from 'node:path'
25
25
  import { fileURLToPath } from 'node:url'
26
26
  import { homedir, tmpdir } from 'node:os'
27
+ import { describeChromeSearch, findChrome, playwrightChromeCandidates } from '../../../scripts/chrome-path.mjs'
27
28
 
28
29
  const require = createRequire(import.meta.url)
29
30
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@@ -69,6 +70,7 @@ const SCENARIOS = [
69
70
  ['settings-docker', '设置卡片(docker 与 tty 并排对照)'],
70
71
  ['docker-stale-book', 'docker 设置卡片:失效的连接簿引用要看得出来'],
71
72
  ['tunnel-edit', '端口转发:编辑隧道(回填 / 改端口换名 / 停用的不被启用)'],
73
+ ['port-warn', '端口转发:保存时宿主报端口占用(警告档,不是成功也不是失败)'],
72
74
  ['sftp', 'SFTP 单窗体(挂右侧挂载位)'],
73
75
  ['sftp-dual', 'SFTP 双栏(挂右侧挂载位)'],
74
76
  ['sftp-fallback', 'SFTP 落点回退(抽屉被容器面板占用 → 对话框)'],
@@ -150,11 +152,47 @@ function skinCss() {
150
152
  if (existsSync(file)) candidates.push(file)
151
153
  }
152
154
  }
153
- if (candidates.length > 0) return readFileSync(candidates[0], 'utf8')
155
+ // 基础层**始终**在前:它住在宿主的 base.css / focus.css / corner-shape.css 里,
156
+ // 真皮肤表里没有这些名字(实测 blue-fantasy 一条都不含)。
157
+ if (candidates.length > 0) return BASE_LAYER_CSS + '\n' + readFileSync(candidates[0], 'utf8')
154
158
  log('未找到本机 skin token 表,使用内置兜底变量')
155
- return FALLBACK_SKIN_CSS
159
+ return BASE_LAYER_CSS + '\n' + FALLBACK_SKIN_CSS
156
160
  }
157
161
 
162
+ /**
163
+ * 宿主基础层:圆角阶梯、卡片材质、焦点环、全局超椭圆。
164
+ *
165
+ * 值取自 `@deepseek-ai/dsh-client-ui-theme` 的 `base.css` / `focus.css` / `corner-shape.css`
166
+ * ——**名字与值都必须与真主题逐字一致**:夹具里缺一个名字的代价是「预览是对的、只有真机是错的」
167
+ * (卡片改走 `--dsw-radius-xl` 后,缺了它圆角算成 0,而真机是 20px)。
168
+ * 真皮肤表只有「皮肤」那一层,基础层必须由这里补。
169
+ *
170
+ * 本包 client-src 引用的**每一个** --dsw-* 都必须在这里(或真皮肤表里)能解析出值:生产 CSS
171
+ * 已按 client-lint 检查十六去掉所有颜色字面量兜底,假主题就是「脱离宿主」时唯一的兜底。
172
+ */
173
+ const BASE_LAYER_CSS = `
174
+ :root{
175
+ --dsw-radius-xs:4px; --dsw-radius-sm:8px; --dsw-radius-md:12px; --dsw-radius-lg:16px; --dsw-radius-xl:20px;
176
+ --dsw-radius-panel:28px;
177
+ --dsw-alias-settings-card-fill:var(--dsw-alias-bg-layer-2);
178
+ --dsw-alias-settings-card-stroke:var(--dsw-alias-border-l4);
179
+ --dsw-focus-ring-width:2px;
180
+ /* 宿主 focus.css 只声明宽度,颜色走 var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary))
181
+ —— 这里按宿主那条兜底表达式把「最近语义令牌」补上,别写死某个蓝。 */
182
+ --dsw-focus-ring-color:var(--dsw-alias-state-business-primary);
183
+ }
184
+ /* elevation 与宿主 gradient-shadow-text.css 逐字相同,且必须声明在 body 上(不是 :root):
185
+ 它内含 var(--dsw-elevation-stroke-color) → var(--dsw-alias-border-l4),在 :root 上求值就
186
+ 钉死了浅色档,深色主题下描边色不会跟着走。 */
187
+ body, body *{
188
+ --dsw-elevation-stroke-color:var(--dsw-alias-border-l4);
189
+ --dsw-elevation-stroke:0 0 0 .5px var(--dsw-elevation-stroke-color);
190
+ --dsw-elevation-panel:var(--dsw-elevation-stroke), 0 3px 8px 0 #00000008, 0 0 16px 0 #00000005;
191
+ --dsw-elevation-prominent:var(--dsw-elevation-stroke), 0 3px 8px 0 #0000000a, 0 0 20px 0 #0000000d;
192
+ }
193
+ @supports (corner-shape:superellipse(1.5)){:root{--dsw-corner-shape:superellipse(1.5)}*,:before,:after{corner-shape:var(--dsw-corner-shape)}}
194
+ `
195
+
158
196
  const FALLBACK_SKIN_CSS = `
159
197
  :root {
160
198
  --dsw-alias-bg-base: #ffffff;
@@ -180,6 +218,10 @@ const FALLBACK_SKIN_CSS = `
180
218
  --dsw-alias-state-error-primary: #d1242f;
181
219
  --dsw-alias-state-success-primary: #1a7f37;
182
220
  --dsw-alias-state-warn-primary: #bf8700;
221
+ --dsw-alias-button-primary-fill: #0f1115;
222
+ --dsw-alias-button-primary-hover: #43454a;
223
+ --dsw-alias-interactive-bg-hover-solid: #f1f3f5;
224
+ --dsw-alias-label-primary-foreground: #fff;
183
225
  --dsw-specific-input-major: #ffffff;
184
226
  --dsw-specific-menu: #ffffff;
185
227
  --dsw-specific-tip: #f6f7f9;
@@ -214,6 +256,10 @@ body[data-ds-dark-theme] {
214
256
  --dsw-alias-state-error-primary: #ff6b6b;
215
257
  --dsw-alias-state-success-primary: #4cc38a;
216
258
  --dsw-alias-state-warn-primary: #e0a94a;
259
+ --dsw-alias-button-primary-fill: #f9fafb;
260
+ --dsw-alias-button-primary-hover: #ebeef2;
261
+ --dsw-alias-interactive-bg-hover-solid: #353638;
262
+ --dsw-alias-label-primary-foreground: #0f1115;
217
263
  --dsw-specific-input-major: #17171a;
218
264
  --dsw-specific-menu: #24242af0;
219
265
  --dsw-specific-tip: #24242a;
@@ -240,23 +286,17 @@ function prepare() {
240
286
 
241
287
  /* ------------------------------ Chrome/CDP ------------------------------ */
242
288
 
243
- function findChrome() {
244
- if (process.env.CHROME_PATH) return process.env.CHROME_PATH
245
- const roots = [join(homedir(), 'Library/Caches/ms-playwright'), join(homedir(), '.cache/ms-playwright')]
246
- const candidates = []
247
- for (const base of roots) {
248
- if (!existsSync(base)) continue
249
- for (const dir of readdirSync(base)) {
250
- if (!dir.startsWith('chromium')) continue
251
- candidates.push(join(base, dir, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'))
252
- candidates.push(join(base, dir, 'chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'))
253
- candidates.push(join(base, dir, 'chrome-linux/chrome'))
254
- }
255
- }
256
- candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
257
- candidates.push('/Applications/Chromium.app/Contents/MacOS/Chromium')
258
- for (const c of candidates) if (existsSync(c)) return c
259
- throw new Error('找不到 Chrome/Chromium,请设置 CHROME_PATH')
289
+ /*
290
+ * 浏览器发现走仓库根的共享模块(`scripts/chrome-path.mjs`):此前这里抄了一份
291
+ * 「扫 playwright 缓存 → 退回平台候选」的实现,`packages/search` 那份与它**逐字相同**,
292
+ * 两份都只在 macOS 上找得到浏览器(Linux 上必然抛「找不到 Chrome/Chromium」)。
293
+ * playwright 缓存仍排在平台候选**之前**——缓存里那个 Chrome for Testing 版本确定,
294
+ * 比机器上随便装的 Chrome 更适合当截图基准(顺序经 `extraCandidates` 原样保留)。
295
+ */
296
+ function resolveChrome() {
297
+ const found = findChrome({ extraCandidates: playwrightChromeCandidates })
298
+ if (found === null) throw new Error('找不到 Chrome/Chromium:' + describeChromeSearch())
299
+ return found
260
300
  }
261
301
 
262
302
  const WebSocketImpl = (() => {
@@ -332,7 +372,7 @@ class Cdp {
332
372
  }
333
373
 
334
374
  async function launchChrome() {
335
- const chrome = findChrome()
375
+ const chrome = resolveChrome()
336
376
  const userDataDir = join(previewDir, 'chrome-profile')
337
377
  rmSync(userDataDir, { recursive: true, force: true })
338
378
  const child = spawn(chrome, [
@@ -20,19 +20,68 @@ import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
20
20
  import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
21
21
  import WebSocket from 'ws'
22
22
  import { name, inject, apply } from '../lib/index.js'
23
+ import { createPhaseTracer, describeCrashPoint, traceSync } from './lib/crash-trace.mjs'
23
24
 
24
25
  if (process.platform !== 'win32') {
25
26
  console.log(`[windows-smoke] 跳过:当前平台 ${process.platform}——本脚本只在 Windows 上跑(真实验证 cmd.exe / ConPTY 链路),CI 的 windows-latest job 会执行它`)
26
27
  process.exit(0)
27
28
  }
28
29
 
30
+ /**
31
+ * 阶段留痕(2026-10-04 加,起因是 CI 上那次 `0xC0000374` 原生崩溃)。
32
+ *
33
+ * **为什么不能用 `console.log`**:Node 官方规定 **Windows 上 stdout 到管道是异步的**
34
+ * (Linux/macOS 同步)。CI 上 stdout 正是管道,于是 `console.log('[W6] …')` 只把字节交给
35
+ * libuv 写队列——**进程原生崩溃那一刻队列一起没了**,日志里连 `[W6]` 都看不到,
36
+ * 只剩一个「什么都没说」的退出码。所以这里用 `fs.writeSync` 直写 fd 1(见 crash-trace.mjs)。
37
+ *
38
+ * **判据是配对**:`enter` 没配对 `leave` 的阶段 = 崩溃最可能的位置;若最后一条 `enter`
39
+ * 有对应的 `leave`,则崩溃在**下一个阶段入口之前**。2026-10-04 那次正是后者
40
+ * (W5 的 leave 打出来了、W6 的 enter 没打出来 ⇒ 崩在 W5 结束后、W6 入口前)。
41
+ */
42
+ const tracer = createPhaseTracer()
43
+
29
44
  /* 看门狗:任何环节卡死时留痕退出(正常路径会先 process.exit)。 */
30
45
  const watchdog = setTimeout(() => {
46
+ // 卡死也是「失败」,先把诊断同步打出去(console.error 在 Windows 管道上同样会丢)
47
+ traceSync(describeCrashPoint({ openPhases: tracer.snapshot(), exitCode: 2 }))
31
48
  console.error('[watchdog] 90s 看门狗触发:windows-smoke 卡死')
32
49
  process.exit(2)
33
50
  }, 90000)
34
51
  watchdog.unref()
35
52
 
53
+ /**
54
+ * 原生崩溃(段错误 / 堆损坏)会**绕过**所有 JS 异常处理,`uncaughtException` 也接不到。
55
+ * 所以真正的保证是上面那条「每阶段同步留痕」——崩溃点 = 最后一条没配对 `leave` 的 `enter`。
56
+ *
57
+ * 下面这几层是**尽力而为**的补充,只捕获 JS 层的漏网异常(那类崩溃此前也只剩一个退出码)。
58
+ */
59
+ let exitingNormally = false
60
+
61
+ function reportCrash(why, detail) {
62
+ traceSync(describeCrashPoint({ openPhases: tracer.snapshot(), exitCode: process.exitCode }))
63
+ traceSync(`${why}${detail ? ':' + detail : ''}`)
64
+ }
65
+ process.on('uncaughtException', (error) => {
66
+ exitingNormally = true // 自己报过了,别让 exit 钩子再报一遍
67
+ reportCrash('uncaughtException', error?.stack ?? String(error))
68
+ process.exit(1)
69
+ })
70
+ process.on('unhandledRejection', (reason) => {
71
+ exitingNormally = true
72
+ reportCrash('unhandledRejection', reason instanceof Error ? reason.stack : String(reason))
73
+ process.exit(1)
74
+ })
75
+ process.on('exit', (code) => {
76
+ /*
77
+ * 只在**异常终止**时报:正常收尾(末尾那段汇总 + flush 屏障 + exit)已经把事情说清了,
78
+ * 再打一段「崩溃点诊断」只会误导(断言失败被说成崩溃)。
79
+ * 判据用 `exitingNormally` 而不是 code:正常的失败路径 code 也是 1。
80
+ */
81
+ if (exitingNormally) return
82
+ reportCrash('进程在收尾之前退出', `code=${String(code)}`)
83
+ })
84
+
36
85
  const RESULTS = []
37
86
  function pass(label) { RESULTS.push(['PASS', label]); console.log(' ✔ PASS ' + label) }
38
87
  function fail(label, detail) { RESULTS.push(['FAIL', label, detail]); console.error(' ✘ FAIL ' + label + (detail ? ' — ' + detail : '')) }
@@ -117,8 +166,11 @@ async function run() {
117
166
 
118
167
  // W1:spawn → ready(默认 shell 必须是 %COMSPEC%,且真的起来了)
119
168
  console.log('\n[W1] spawn → ready(默认 shell = %COMSPEC%)')
169
+ tracer.enter('W1 openSession + ws 连接')
120
170
  const s = openSession(port)
121
171
  await s.open()
172
+ tracer.leave('W1 openSession + ws 连接')
173
+ tracer.enter('W1 spawn → ready')
122
174
  s.client.send(JSON.stringify({ t: 'spawn', cols: 100, rows: 30 }))
123
175
  try {
124
176
  await s.waitFor(() => s.state.ready !== null, 20000, 'ready')
@@ -131,9 +183,11 @@ async function run() {
131
183
  } catch (error) {
132
184
  fail('W1 spawn → ready', error.message)
133
185
  }
186
+ tracer.leave('W1 spawn → ready')
134
187
 
135
188
  // W2:跑起来的是 %COMSPEC%(cmd.exe),不是 POSIX 包装层
136
189
  console.log('\n[W2] 输入回显:跑起来的是 cmd.exe')
190
+ tracer.enter('W2 %COMSPEC% 回显')
137
191
  try {
138
192
  s.client.send(JSON.stringify({ t: 'input', d: `echo IT_SHELL_%COMSPEC%${ENTER}` }))
139
193
  await s.waitFor(() => /IT_SHELL_.*cmd\.exe/i.test(s.state.text), 15000, 'cmd.exe 回显')
@@ -141,9 +195,11 @@ async function run() {
141
195
  } catch (error) {
142
196
  fail('W2 %COMSPEC% 生效', error.message)
143
197
  }
198
+ tracer.leave('W2 %COMSPEC% 回显')
144
199
 
145
200
  // W3:命令真的被执行(不是只回显了输入)——靠 %OS% 展开证明
146
201
  console.log('\n[W3] 命令执行(%OS% 由 shell 展开)')
202
+ tracer.enter('W3 %OS% 展开')
147
203
  try {
148
204
  s.client.send(JSON.stringify({ t: 'input', d: `echo IT_WIN_%OS%${ENTER}` }))
149
205
  await s.waitFor(() => /IT_WIN_Windows_NT/.test(s.state.text), 15000, '%OS% 展开')
@@ -151,9 +207,11 @@ async function run() {
151
207
  } catch (error) {
152
208
  fail('W3 命令执行生效', error.message)
153
209
  }
210
+ tracer.leave('W3 %OS% 展开')
154
211
 
155
212
  // W4:kill 帧 → exit,会话名额释放
156
213
  console.log('\n[W4] kill → exit')
214
+ tracer.enter('W4 kill → exit')
157
215
  try {
158
216
  s.client.send(JSON.stringify({ t: 'kill' }))
159
217
  await s.waitFor(() => s.state.exited !== null, 15000, 'exit 帧')
@@ -161,11 +219,17 @@ async function run() {
161
219
  } catch (error) {
162
220
  fail('W4 kill 帧结束会话', error.message)
163
221
  }
222
+ tracer.leave('W4 kill → exit')
223
+ tracer.enter('W4 s.client.close()')
164
224
  try { s.client.close() } catch { /* 已关闭 */ }
225
+ tracer.leave('W4 s.client.close()')
165
226
 
166
227
  // W5:kill 之后还能再开一个(名额与清理都正确)
167
228
  console.log('\n[W5] kill 后重新 spawn')
229
+ tracer.enter('W5 openSession')
168
230
  const s2 = openSession(port)
231
+ tracer.leave('W5 openSession')
232
+ tracer.enter('W5 第二次 spawn → ready → kill → exit')
169
233
  try {
170
234
  await s2.open()
171
235
  s2.client.send(JSON.stringify({ t: 'spawn', cols: 80, rows: 24 }))
@@ -176,7 +240,10 @@ async function run() {
176
240
  } catch (error) {
177
241
  fail('W5 kill 后可重新 spawn', error.message)
178
242
  }
243
+ tracer.leave('W5 第二次 spawn → ready → kill → exit')
244
+ tracer.enter('W5 s2.client.close()')
179
245
  try { s2.client.close() } catch { /* 已关闭 */ }
246
+ tracer.leave('W5 s2.client.close()')
180
247
 
181
248
  // W6:agent 侧 `tty_send` 发**裸 LF** 也能提交命令(D74,2026-09-27 真机报告)
182
249
  //
@@ -186,6 +253,7 @@ async function run() {
186
253
  // 这里用 `%OS%` 展开做判据,理由同 W3:回显里只有 `%OS%` 字面量。
187
254
  console.log('\n[W6] tty_send 的 \\n 归一化(真实 conhost:裸 LF 不提交命令)')
188
255
  let agentSid = null
256
+ tracer.enter('W6 tty_open → tty_send → tty_capture')
189
257
  try {
190
258
  const open = TOOLS.get('tty_open')
191
259
  const send = TOOLS.get('tty_send')
@@ -208,6 +276,7 @@ async function run() {
208
276
  } catch (error) {
209
277
  fail('W6 tty_send 的裸 LF 提交命令(D74)', error.message)
210
278
  }
279
+ tracer.leave('W6 tty_open → tty_send → tty_capture')
211
280
  if (agentSid !== null) {
212
281
  try {
213
282
  await TOOLS.get('tty_close').execute({ sid: agentSid })
@@ -222,6 +291,7 @@ async function run() {
222
291
  // ConPTY 侧进程退出、句柄收尾、宿主把会话**留在表里**而不是摘掉;本地单测用的是
223
292
  // 假 PTY,验不到「真机上退出之后 tty_capture 还读得到输出」。
224
293
  console.log('\n[W7] 退出后的只读保留(D77)')
294
+ tracer.enter('W7 退出后的只读保留')
225
295
  let retainedSid = null
226
296
  try {
227
297
  const open = TOOLS.get('tty_open')
@@ -270,11 +340,17 @@ async function run() {
270
340
  fail('W7d tty_close 释放保留态', error.message)
271
341
  }
272
342
  }
343
+ tracer.leave('W7 退出后的只读保留')
273
344
  }
274
345
 
275
346
  await run()
347
+ // 正常收尾(无论有没有断言失败):告诉 exit 钩子「别打崩溃诊断」——那段是给
348
+ // **异常终止**用的,断言失败被说成「崩溃」只会误导。
349
+ exitingNormally = true
276
350
  const failed = RESULTS.filter((row) => row[0] === 'FAIL')
277
351
  console.log('\n==== Windows 冒烟:' + String(RESULTS.length - failed.length) + '/' + String(RESULTS.length) + ' PASS ====')
352
+ // 同步再打一份结论:万一下面的 flush 屏障之前进程就没了(原生崩溃),至少这一行一定在。
353
+ traceSync(`Windows 冒烟结论:${String(RESULTS.length - failed.length)}/${String(RESULTS.length)} PASS`)
278
354
  for (const row of failed) console.error(' ✘ ' + row[1] + (row[2] ? ' — ' + row[2] : ''))
279
355
  clearTimeout(watchdog)
280
356
  /**