@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.
- package/README.en.md +76 -4
- package/README.md +76 -6
- package/client.js +135 -36
- package/lib/index.d.ts +51 -0
- package/lib/index.js +507 -99
- package/lib/index.js.map +1 -1
- package/lib/probe.d.ts +29 -1
- package/lib/probe.js +98 -19
- package/lib/probe.js.map +1 -1
- package/lib/sftp.js +66 -22
- package/lib/sftp.js.map +1 -1
- package/lib/ssh-config.d.ts +34 -2
- package/lib/ssh-config.js +150 -32
- package/lib/ssh-config.js.map +1 -1
- package/lib/ssh.d.ts +256 -2
- package/lib/ssh.js +637 -6
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.d.ts +2 -0
- package/lib/tunnels.js +79 -7
- package/lib/tunnels.js.map +1 -1
- package/package.json +7 -5
- package/scripts/integration.mjs +51 -19
- package/scripts/jump-smoke.mjs +175 -0
- package/scripts/lib/proxy-bridge.mjs +30 -0
- package/scripts/preview/harness.js +74 -26
- package/scripts/probe-route-smoke.mjs +10 -1
- package/scripts/proxycommand-smoke.mjs +267 -0
- package/scripts/windows-smoke.mjs +23 -0
|
@@ -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
|
-
|
|
140
|
-
|
|
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) ||
|
|
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
|
|
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()
|
|
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].
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
507
|
-
if (buttonsIn(body).find((b) => b
|
|
508
|
-
if (buttonsIn(body).find((b) => b
|
|
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
|
|
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
|
|
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
|
|
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
|
|
912
|
+
if (acts.some((b) => says(b, '隧道', 'Tunnel'))) {
|
|
865
913
|
throw new Error('没有隧道的连接却出现了常驻「隧道」按钮')
|
|
866
914
|
}
|
|
867
|
-
const more = acts.find((b) => b
|
|
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
|
|
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 (
|
|
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()
|