@hyzyn/dsh-tty 0.20.2 → 0.22.0-rc.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,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @hyzyn/dsh-tty — 跳板机(ProxyJump 单跳)的真机冒烟。
4
+ *
5
+ * 为什么必须真机跑一遍:`forwardOut → ConnectConfig.sock` 这条链路的正确性**不可能**靠单测
6
+ * 证明——假 ssh2 只能验「谁先连、config 长什么样」,而「通道真的把字节送到了另一台 sshd、
7
+ * 目标在通道上完成了握手并开了 SFTP 通道」只有真实现才能给。本脚本起**两个真的 ssh2 服务端**:
8
+ *
9
+ * - bastion:接受密码认证,并处理 `direct-tcpip`(把通道接到目标端口);
10
+ * - target:`scripts/lib/test-sshd.mjs` 的内存 SFTP 服务端。
11
+ *
12
+ * **两者的凭据刻意不同**(bastion: jump/jump-secret,target: test/secret):这样「跳板机凭据
13
+ * 没被目标凭据顶掉」也能一并验到——那正是 `jumpSpecOf` 的继承规则最容易写错的地方。
14
+ *
15
+ * 三个用例:
16
+ * J1 经跳板机列 SFTP 目录(正向:通道 + 目标握手 + 通道建立)
17
+ * J2 跳板机密码错 → 文案点名**跳板机**(而不是笼统的目标超时)
18
+ * J3 通道通了但目标不可达 → 文案同时点名目标与**经跳板机 X**(两跳归属不能混)
19
+ *
20
+ * 退出码:全部 PASS → 0;任一 FAIL → 1(CI 与发布闸都跑它)。
21
+ */
22
+ import net from 'node:net'
23
+ import os from 'node:os'
24
+ import path from 'node:path'
25
+ import fsp from 'node:fs/promises'
26
+ import ssh2 from 'ssh2'
27
+ import { generateKeyPairSync } from 'node:crypto'
28
+ import { SftpManager } from '../lib/sftp.js'
29
+ import { startSftpSshd } from './lib/test-sshd.mjs'
30
+
31
+ const BASTION_USER = 'jump'
32
+ const BASTION_PASSWORD = 'jump-secret'
33
+
34
+ let failed = 0
35
+ function pass(name) {
36
+ console.log(` ✔ PASS ${name}`)
37
+ }
38
+ function fail(name, detail) {
39
+ failed += 1
40
+ console.log(` ✘ FAIL ${name}${detail === undefined ? '' : ` — ${detail}`}`)
41
+ }
42
+
43
+ /** 起一个「能当跳板机」的 sshd:密码认证 + `direct-tcpip` 转发到目标端口。 */
44
+ async function startBastion() {
45
+ const { privateKey } = generateKeyPairSync('rsa', {
46
+ modulusLength: 2048,
47
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
48
+ privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
49
+ })
50
+ /** 观测量:建了几条连接、开了几条转发通道(用例里断言「确实过了跳板机」)。 */
51
+ const seen = { connections: 0, forwards: 0, closed: 0 }
52
+ const server = new ssh2.Server({ hostKeys: [privateKey] }, (client) => {
53
+ seen.connections += 1
54
+ client.on('authentication', (ctx) => {
55
+ if (ctx.method === 'password' && ctx.username === BASTION_USER && ctx.password === BASTION_PASSWORD) {
56
+ ctx.accept()
57
+ return
58
+ }
59
+ ctx.reject()
60
+ })
61
+ client.on('ready', () => {
62
+ // direct-tcpip:这就是 forwardOut 落在服务端的那一半
63
+ client.on('tcpip', (accept, reject, info) => {
64
+ seen.forwards += 1
65
+ const channel = accept()
66
+ const socket = net.connect(info.destPort, info.destIP)
67
+ socket.on('connect', () => {
68
+ channel.pipe(socket).pipe(channel)
69
+ })
70
+ socket.on('error', () => {
71
+ try {
72
+ channel.close()
73
+ } catch {
74
+ /* 已关闭 */
75
+ }
76
+ })
77
+ channel.on('close', () => socket.destroy())
78
+ })
79
+ })
80
+ client.on('close', () => {
81
+ seen.closed += 1
82
+ })
83
+ client.on('error', () => {
84
+ /* 认证失败 / 断开:用例自己断言 */
85
+ })
86
+ })
87
+ await new Promise((resolve, reject) => {
88
+ server.once('error', reject)
89
+ server.listen(0, '127.0.0.1', () => resolve())
90
+ })
91
+ const port = server.address().port
92
+ return {
93
+ port,
94
+ seen,
95
+ close: () => new Promise((resolve) => server.close(() => resolve())),
96
+ }
97
+ }
98
+
99
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-jump-'))
100
+ await fsp.writeFile(path.join(root, 'hello.txt'), 'via bastion\n')
101
+
102
+ const bastion = await startBastion()
103
+ const target = await startSftpSshd({ rootDir: root })
104
+
105
+ /** 经跳板机连目标的规格(凭据刻意不同:bastion 用 jump/jump-secret)。 */
106
+ const specWithJump = (jumpOverride = {}) => ({
107
+ host: '127.0.0.1',
108
+ port: target.port,
109
+ username: 'test',
110
+ auth: 'password',
111
+ password: 'secret',
112
+ jump: { host: '127.0.0.1', port: bastion.port, username: BASTION_USER, auth: 'password', password: BASTION_PASSWORD, ...jumpOverride },
113
+ })
114
+
115
+ const sftp = new SftpManager({ info: () => {}, warn: () => {} })
116
+
117
+ try {
118
+ // ---- J1:正向。SFTP 的 list 要经跳板机通道到达目标 ----
119
+ try {
120
+ const listed = await sftp.list(specWithJump(), root)
121
+ const names = listed.entries.map((entry) => entry.name)
122
+ if (names.includes('hello.txt') && bastion.seen.forwards >= 1) {
123
+ pass(`J1 经跳板机列 SFTP 目录(跳板机转发 ${bastion.seen.forwards} 次,目标 ${names.length} 项)`)
124
+ } else {
125
+ fail('J1 经跳板机列 SFTP 目录', `files=${JSON.stringify(names)} forwards=${bastion.seen.forwards}`)
126
+ }
127
+ } catch (error) {
128
+ fail('J1 经跳板机列 SFTP 目录', error instanceof Error ? error.message : String(error))
129
+ }
130
+
131
+ // ---- J2:跳板机密码错 → 必须点名跳板机 ----
132
+ try {
133
+ await sftp.list(specWithJump({ password: 'wrong' }), root)
134
+ fail('J2 跳板机密码错', '竟然没报错')
135
+ } catch (error) {
136
+ const message = error instanceof Error ? error.message : String(error)
137
+ if (message.includes('跳板机') && message.includes(`${BASTION_USER}@127.0.0.1:${String(bastion.port)}`)) {
138
+ pass('J2 跳板机密码错 → 文案点名跳板机')
139
+ } else {
140
+ fail('J2 跳板机密码错 → 文案点名跳板机', message)
141
+ }
142
+ }
143
+
144
+ // ---- J3:通道通了但目标不可达 → 目标与「经跳板机 X」都要出现 ----
145
+ try {
146
+ const deadPort = target.port + 1
147
+ // 指向一个没人监听的端口:bastion 的 net.connect 会成功(本机 127.0.0.1),
148
+ // 但通道上的目标握手会失败
149
+ await sftp.list({ ...specWithJump(), port: deadPort }, root)
150
+ fail('J3 通道通了但目标不可达', '竟然没报错')
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : String(error)
153
+ if (message.includes('经跳板机') && message.includes('127.0.0.1')) {
154
+ pass('J3 目标不可达 → 文案同时点名目标与跳板机')
155
+ } else {
156
+ fail('J3 目标不可达 → 文案同时点名目标与跳板机', message)
157
+ }
158
+ }
159
+
160
+ // ---- 清理:跳板机连接必须被关掉(不留 keepalive 养着的连接) ----
161
+ await sftp.disposeAll()
162
+ await new Promise((resolve) => setTimeout(resolve, 200))
163
+ if (bastion.seen.closed >= bastion.seen.connections) {
164
+ pass(`J4 收尾后跳板机连接全部关闭(${bastion.seen.closed}/${bastion.seen.connections})`)
165
+ } else {
166
+ fail('J4 收尾后跳板机连接全部关闭', `closed=${bastion.seen.closed} opened=${bastion.seen.connections}`)
167
+ }
168
+ } finally {
169
+ await bastion.close().catch(() => {})
170
+ await target.close().catch(() => {})
171
+ await fsp.rm(root, { recursive: true, force: true }).catch(() => {})
172
+ }
173
+
174
+ console.log(failed === 0 ? '\njump smoke: 全部 PASS' : `\njump smoke: ${failed} 个 FAIL`)
175
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 最小 `ProxyCommand` 桥:把 stdio 接到 `host port`。
4
+ *
5
+ * 这就是 `ssh -W %h:%p bastion` / `nc host port` 的原语,写成脚本是为了**不依赖系统工具**
6
+ * (CI 上未必有 ssh / nc,参数还各不相同)。真机冒烟 `scripts/proxycommand-smoke.mjs` 用它
7
+ * 当代理命令,从而把「子进程 stdio ↔ SSH 传输」这条链路整条跑通。
8
+ *
9
+ * 用法:node proxy-bridge.mjs <host> <port>
10
+ */
11
+ import net from 'node:net'
12
+
13
+ const [host, port] = process.argv.slice(2)
14
+ if (host === undefined || port === undefined) {
15
+ process.stderr.write('usage: proxy-bridge.mjs <host> <port>\n')
16
+ process.exit(2)
17
+ }
18
+
19
+ const socket = net.connect(Number(port), host)
20
+ socket.on('connect', () => {
21
+ process.stdin.pipe(socket)
22
+ socket.pipe(process.stdout)
23
+ })
24
+ socket.on('error', (error) => {
25
+ process.stderr.write(`${error.message}\n`)
26
+ process.exit(1)
27
+ })
28
+ socket.on('close', () => {
29
+ process.exit(0)
30
+ })
@@ -24,6 +24,29 @@
24
24
  const q = (sel) => document.querySelector(sel)
25
25
  const qa = (sel) => [...document.querySelectorAll(sel)]
26
26
 
27
+ /*
28
+ * 预览语言:`?locale=en`(或 CDP 注入 `window.__previewLocale = 'en'`)预览英文界面,
29
+ * 默认 zh。与 search 的 mock-host / codegraph 的 preview-card 同一个开关形状
30
+ * (见 docs/i18n.md § 预览与 smoke 夹具)。夹具**提供**了 locale 服务,就必须能选语言,
31
+ * 否则它验的是一条不存在的路(「老宿主没有 locale 服务」那条路由中文兜底覆盖)。
32
+ */
33
+ try {
34
+ const wanted = new URLSearchParams(location.search).get('locale')
35
+ if (wanted === 'en' || wanted === 'zh') window.__previewLocale = wanted
36
+ } catch {
37
+ /* file:// 下 location.search 可能不可用 */
38
+ }
39
+
40
+ /*
41
+ * 按**界面文案**找元素:文案已经进目录(i18n),所以定位串必须**双语共存**——只认中文
42
+ * 会把英文界面下的走查挡在门外(同 search 的 `titles: ['通用设置', 'General']`)。
43
+ * 三个谓词都收「任一语言」,断言的失败信息仍是中文(开发者看的)。
44
+ */
45
+ const says = (el, ...names) => names.some((name) => (el.textContent || '').includes(name))
46
+ const saysExact = (el, ...names) => names.some((name) => (el.textContent || '').trim() === name)
47
+ const attributeSays = (el, attr, ...names) => names.some((name) => (el.getAttribute(attr) || '') === name)
48
+ const findByAria = (root, ...names) => [...root.querySelectorAll('[aria-label]')].find((el) => attributeSays(el, 'aria-label', ...names))
49
+
27
50
  /* ---------- 挂载插件 ---------- */
28
51
  // 场景之间共用同一个页面 target,必须先清掉上一个场景的标签持久化
29
52
  try {
@@ -99,6 +122,30 @@
99
122
  // 宿主会话服务也进服务表:docker 那一半是经 ctx.inject(['sessions']) 取的(夹具里
100
123
  // ctx.sessions 是直接属性、不走服务表,这里补一份,免得那条 inject 永远等不到依赖)
101
124
  services.set('sessions', ctx.sessions)
125
+
126
+ /*
127
+ * 假 locale 服务(`@deepseek-ai/dsh-client-locale`):插件经 `ctx.inject(['locale'], cb)`
128
+ * **动态**取它,所以服务表里没有它时 callback 不触发、`t` 保持中文兜底(= 老宿主那条路)。
129
+ * 桩的形状与 search 的 mock-host 一致:`register(ns, locale, dict)` + `bind(ns)`,
130
+ * 词条按 `window.__previewLocale` 选语言。
131
+ */
132
+ const localeDicts = new Map()
133
+ const localeStub = {
134
+ register: (ns, locale, dict) => {
135
+ const entry = localeDicts.get(ns) || {}
136
+ entry[locale] = dict
137
+ localeDicts.set(ns, entry)
138
+ return () => {}
139
+ },
140
+ bind: (ns) => (key, params) => {
141
+ const active = window.__previewLocale === 'en' ? 'en' : 'zh'
142
+ const dict = localeDicts.get(ns) || {}
143
+ const text = (dict[active] || {})[key] || key
144
+ if (params === undefined) return text
145
+ return String(text).replace(/\{(\w+)\}/g, (_match, name) => (params[name] === undefined ? '' : String(params[name])))
146
+ },
147
+ }
148
+ services.set('locale', localeStub)
102
149
  exports.apply(ctx)
103
150
 
104
151
  /**
@@ -136,8 +183,9 @@
136
183
  await waitFor(() => q('.tt_addMenu'))
137
184
  await sleep(80)
138
185
  }
139
- const clickMenuItem = async (label) => {
140
- const item = await waitFor(() => qa('.tt_addMenuItem').find((el) => el.textContent.includes(label)))
186
+ // 菜单项按**双语**匹配(文案已进目录,见上面的 says)
187
+ const clickMenuItem = async (...labels) => {
188
+ const item = await waitFor(() => qa('.tt_addMenuItem').find((el) => says(el, ...labels)))
141
189
  item.click()
142
190
  await sleep(200)
143
191
  return item
@@ -210,7 +258,7 @@
210
258
  if (bar === null) return '状态条 DOM 缺失'
211
259
  if (bar.hidden === true) return '状态条未显示(stats 订阅链路可能断开)'
212
260
  const text = bar.textContent
213
- if (!/CPU/.test(text) || !/内存/.test(text) || !/网络/.test(text)) return '状态条内容不完整:' + text
261
+ if (!/CPU/.test(text) || !/(内存|Memory)/.test(text) || !/(网络|Network)/.test(text)) return '状态条内容不完整:' + text
214
262
  /*
215
263
  * 订阅时序:客户端**故意**在 spawn 之前就发一次 statsOn(switchTab 早于 spawnTab),
216
264
  * 宿主此时还没登记这个 sid,会按「未知 sid」忽略——这是设计里的正常现象,所以
@@ -309,7 +357,7 @@
309
357
  await clickMenuItem('staging-db')
310
358
  await waitFor(() => tabs().length === 3)
311
359
  await clickAdd()
312
- await clickMenuItem('本地终端')
360
+ await clickMenuItem('本地终端', 'Local terminal')
313
361
  await waitFor(() => tabs().length === 4)
314
362
  tabs()[1].click()
315
363
  await sleep(250)
@@ -338,7 +386,7 @@
338
386
  async ssh() {
339
387
  await openPanel()
340
388
  await clickAdd()
341
- await clickMenuItem('SSH 连接…')
389
+ await clickMenuItem('SSH 连接…', 'SSH connection…')
342
390
  await waitFor(() => q('.tt_sshCard'))
343
391
  await sleep(150)
344
392
  },
@@ -346,16 +394,16 @@
346
394
  async 'ssh-probe'() {
347
395
  await openPanel()
348
396
  await clickAdd()
349
- await clickMenuItem('SSH 连接…')
397
+ await clickMenuItem('SSH 连接…', 'SSH connection…')
350
398
  await waitFor(() => q('.tt_sshCard'))
351
399
  const inputs = qa('.tt_sshCard input')
352
400
  inputs[0].value = '192.0.2.10'
353
401
  inputs[1].value = '22'
354
402
  inputs[2].value = 'root'
355
- const probeBtn = qa('.tt_sshActions .tt_toolBtn').find((b) => b.textContent.includes('试连'))
403
+ const probeBtn = qa('.tt_sshActions .tt_toolBtn').find((b) => says(b, '试连', 'Test'))
356
404
  probeBtn.click()
357
405
  const resultEl = () => q('.tt_sshProbeResult')
358
- await waitFor(() => resultEl() && resultEl().textContent !== '' && !resultEl().textContent.includes('测试中'))
406
+ await waitFor(() => resultEl() && resultEl().textContent !== '' && !says(resultEl(), '测试中', 'Testing'))
359
407
  await sleep(250)
360
408
  },
361
409
  /* SSH 连接对话框(编辑连接簿条目,含 env 选择器) */
@@ -366,7 +414,7 @@
366
414
  const r = qa('.tt_addMenuRow')
367
415
  return r.length > 0 ? r : null
368
416
  })
369
- const editBtn = rows[1].querySelector('.tt_addMenuEdit[title="编辑连接"]')
417
+ const editBtn = [...rows[1].querySelectorAll('.tt_addMenuEdit')].find((b) => attributeSays(b, 'title', '编辑连接', 'Edit connection'))
370
418
  editBtn.click()
371
419
  await waitFor(() => q('.tt_sshCard'))
372
420
  await sleep(200)
@@ -407,7 +455,7 @@
407
455
  // 「编辑」按钮是本次新增的能力:先在第一条隧道行上找到它
408
456
  const firstRow = rowOf('staging-pg')
409
457
  if (firstRow === undefined) throw new Error('找不到 staging-pg 行(fixture 应有该隧道)')
410
- const editBtn = buttonsIn(firstRow).find((b) => b.textContent === '编辑')
458
+ const editBtn = buttonsIn(firstRow).find((b) => saysExact(b, '编辑', 'Edit'))
411
459
  if (editBtn === undefined) throw new Error('隧道行没有「编辑」按钮')
412
460
  editBtn.click()
413
461
  await sleep(150)
@@ -421,7 +469,7 @@
421
469
  await sleep(120)
422
470
 
423
471
  // 回填:本地转发那边「127.0.0.1:<端口>」成对呈现,端口输入带 aria-label 标识
424
- const localPortInput = document.querySelector('#preview-settings [aria-label="本机监听端口"]')
472
+ const localPortInput = findByAria(document.querySelector('#preview-settings') || document, '本机监听端口', 'Local listen port')
425
473
  if (localPortInput === null) throw new Error('本地转发表单没渲染(找不到本机监听端口输入)')
426
474
  if (localPortInput.value !== '15432') throw new Error('编辑未回填本地端口:' + String(localPortInput.value))
427
475
 
@@ -444,7 +492,7 @@
444
492
 
445
493
  // 切到「远程 -R」:固定端应换到**右侧**(本机拨号),左侧变成可填的服务器侧监听地址
446
494
  // ——两个方向的固定端不同,这正是不能照搬单一形状的原因
447
- segBtns.find((b) => (b.textContent ?? '').includes('远程')).click()
495
+ segBtns.find((b) => says(b, '远程 -R', 'Remote -R')).click()
448
496
  await sleep(200)
449
497
  scrollTunnelIntoView()
450
498
  const remoteEndpoints = [...document.querySelectorAll('#preview-settings .tt_tunnelEndpoint')]
@@ -460,7 +508,7 @@
460
508
  throw new Error('远程方向的箭头没有翻转标记')
461
509
  }
462
510
  // 切回本地 -L,后续断言按本地方向的期望继续
463
- segBtns.find((b) => (b.textContent ?? '').includes('本地')).click()
511
+ segBtns.find((b) => says(b, '本地 -L', 'Local -L')).click()
464
512
  await sleep(200)
465
513
  scrollTunnelIntoView()
466
514
  await sleep(80)
@@ -499,13 +547,13 @@
499
547
  }
500
548
 
501
549
  // 方向切换会重渲表单:DOM 节点可能被替换,重新取一次(别用切换前的引用)
502
- const localPortInput2 = document.querySelector('#preview-settings [aria-label="本机监听端口"]')
550
+ const localPortInput2 = findByAria(document.querySelector('#preview-settings') || document, '本机监听端口', 'Local listen port')
503
551
  if (localPortInput2 === null) throw new Error('切回本地后表单没渲染')
504
552
 
505
553
  const body = q('#preview-settings .tt_cardBody')
506
- if (buttonsIn(body).find((b) => b.textContent === '保存修改') === undefined) throw new Error('编辑态没有「保存修改」')
507
- if (buttonsIn(body).find((b) => b.textContent === '取消') === undefined) throw new Error('编辑态没有「取消」')
508
- if (buttonsIn(body).find((b) => b.textContent === '添加隧道') !== undefined) {
554
+ if (buttonsIn(body).find((b) => saysExact(b, '保存修改', 'Save changes')) === undefined) throw new Error('编辑态没有「保存修改」')
555
+ if (buttonsIn(body).find((b) => saysExact(b, '取消', 'Cancel')) === undefined) throw new Error('编辑态没有「取消」')
556
+ if (buttonsIn(body).find((b) => saysExact(b, '添加隧道', 'Add tunnel')) !== undefined) {
509
557
  throw new Error('编辑态仍显示「添加隧道」(会误加一条而不是改这条)')
510
558
  }
511
559
  if (document.querySelectorAll('#preview-settings .tt_sshHostRow[data-editing]').length !== 1) {
@@ -530,7 +578,7 @@
530
578
  }
531
579
  setReactInput(localPortInput2, '15433')
532
580
  await sleep(150)
533
- buttonsIn(body).find((b) => b.textContent === '保存修改').click()
581
+ buttonsIn(body).find((b) => saysExact(b, '保存修改', 'Save changes')).click()
534
582
  await sleep(400)
535
583
 
536
584
  window.__previewAssert = async () => {
@@ -546,7 +594,7 @@
546
594
  if (!names.some((n) => n.startsWith('prod-redis'))) {
547
595
  problems.push('编辑波及了别的隧道')
548
596
  }
549
- if (buttonsIn(document.querySelector('#preview-settings .tt_cardBody')).find((b) => b.textContent === '添加隧道') === undefined) {
597
+ if (buttonsIn(document.querySelector('#preview-settings .tt_cardBody')).find((b) => saysExact(b, '添加隧道', 'Add tunnel')) === undefined) {
550
598
  problems.push('保存后没有退出编辑态')
551
599
  }
552
600
  return problems.length > 0 ? problems.join(';') : null
@@ -839,7 +887,7 @@
839
887
  await clickMenuItem('staging-db')
840
888
  await waitFor(() => tabs().length === 2)
841
889
  await sleep(200)
842
- const btn = qa('.tt_connAct').find((b) => b.textContent.includes('隧道'))
890
+ const btn = qa('.tt_connAct').find((b) => says(b, '隧道', 'Tunnel'))
843
891
  if (btn) btn.click()
844
892
  await waitFor(() => q('.tt_tunnelPop'))
845
893
  await sleep(300)
@@ -861,17 +909,17 @@
861
909
 
862
910
  const acts = qa('.tt_connAct')
863
911
  // 没有隧道 ⇒ 不该出现常驻「隧道 N」按钮
864
- if (acts.some((b) => b.textContent.includes('隧道'))) {
912
+ if (acts.some((b) => says(b, '隧道', 'Tunnel'))) {
865
913
  throw new Error('没有隧道的连接却出现了常驻「隧道」按钮')
866
914
  }
867
- const more = acts.find((b) => b.textContent.includes('更多'))
915
+ const more = acts.find((b) => says(b, '更多', 'More'))
868
916
  if (more === undefined) throw new Error('没有隧道的连接缺少「⋯ 更多」入口')
869
917
  more.click()
870
918
  await waitFor(() => q('.tt_connMore'))
871
919
  await sleep(250)
872
920
 
873
921
  // 闭环:菜单项必须真的可点、并给出可执行的信息(该连接没有启用隧道 + 去哪配)
874
- const item = qa('.tt_connMore .tt_addMenuItem').find((el) => el.textContent.includes('端口转发'))
922
+ const item = qa('.tt_connMore .tt_addMenuItem').find((el) => says(el, '端口转发', 'Port forwarding'))
875
923
  if (item === undefined) throw new Error('「⋯」菜单里没有端口转发项')
876
924
  item.click()
877
925
  await waitFor(() => q('.tt_tunnelPop'))
@@ -880,8 +928,8 @@
880
928
  const pop = q('.tt_tunnelPop')
881
929
  if (pop === null) return '点击菜单项后没有打开隧道弹层'
882
930
  const text = pop.textContent ?? ''
883
- if (!text.includes('暂无启用')) return '弹层没有说明「该连接暂无启用的隧道」:' + text
884
- if (!text.includes('插件配置') && !text.includes('设置')) return '弹层没有告诉用户去哪配置'
931
+ if (!text.includes('暂无启用') && !text.includes('No enabled tunnels')) return '弹层没有说明「该连接暂无启用的隧道」:' + text
932
+ if (!/插件配置|设置|Settings/.test(text)) return '弹层没有告诉用户去哪配置'
885
933
  return null
886
934
  }
887
935
  },
@@ -1081,7 +1129,7 @@
1081
1129
  await openPanel()
1082
1130
  window.__PREVIEW_AT_LIMIT = true
1083
1131
  await clickAdd()
1084
- await clickMenuItem('本地终端')
1132
+ await clickMenuItem('本地终端', 'Local terminal')
1085
1133
  await waitFor(() => q('.tt_toast'))
1086
1134
  await sleep(200)
1087
1135
  },
@@ -13,6 +13,15 @@ import net from 'node:net'
13
13
  import { name, inject, apply } from '../lib/index.js'
14
14
  import { startSftpSshd, TEST_USER, TEST_PASSWORD } from './lib/test-sshd.mjs'
15
15
 
16
+ /*
17
+ * 变更端点要求「同源证明」(docker D32 口径,见 docs/architecture.md § 7 与
18
+ * packages/tty/src/index.ts 的 gateRoute):浏览器同源 fetch 必带
19
+ * `sec-fetch-site: same-origin`——本脚本模拟的正是**面板**这条调用路径,所以照带。
20
+ * 少了它,/probe 与 /sftp 的写动作会被 403「缺少同源证明」(本脚本此前就踩在这里)。
21
+ */
22
+ const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin' }
23
+
24
+
16
25
  const RESULTS = []
17
26
  function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
18
27
  function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
@@ -44,7 +53,7 @@ async function run() {
44
53
  const probe = async (body) => {
45
54
  const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/probe`, {
46
55
  method: 'POST',
47
- headers: { 'content-type': 'application/json' },
56
+ headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
48
57
  body: JSON.stringify(body),
49
58
  })
50
59
  return res.json()