dsh-browser-playwright-codex 0.8.1 → 0.8.3
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/CHANGELOG.md +65 -0
- package/README.md +13 -0
- package/SECURITY.md +12 -4
- package/lib/browser-lifecycle.js +16 -2
- package/lib/client.js +97 -0
- package/lib/config.d.ts +5 -0
- package/lib/config.js +5 -0
- package/lib/gates-patch.d.ts +29 -0
- package/lib/gates-patch.js +209 -0
- package/lib/provider.js +4 -0
- package/lib/proxy.d.ts +56 -0
- package/lib/proxy.js +131 -0
- package/lib/settings-card.js +62 -1
- package/lib/snapshot-aria.d.ts +0 -20
- package/lib/snapshot-aria.js +68 -3
- package/lib/swarm-intro.js +4 -4
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,68 @@
|
|
|
1
|
+
## 0.8.3 — 2026-11-12
|
|
2
|
+
|
|
3
|
+
一句话摘要:**插件可以走代理了**——以前它启动的浏览器只能直连,凡是要代理的站点(维基、GitHub 网页、大量国外站)一律打不开。现在多一个 `launch.proxy` 配置项和设置页里的「代理」输入框;**默认留空 = 直连,行为与之前完全一样**。实测同一台机器:维基直连 31 秒超时,走代理 1 秒返回。
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`launch.proxy`**:填 `http://host:port`、`https://host:port` 或 `socks5://host:port`,只写 `host:port` 会按 Playwright 的规则补成 `http://`。代理交给**浏览器启动参数**(`launchOptions.proxy`),整个浏览器走代理——不是在某个请求上加头。
|
|
8
|
+
- **设置页「代理」输入框**:放在「已登录的站点」正前方(这张卡的最后一块),不再散落在中间。占位提示 `http://host:port`,示例用保留域名 `http://proxy.example:8080`,**不写任何本机真实地址**;默认值为空。
|
|
9
|
+
- **强制绕过本机地址**:配置代理时自动带上 `bypass: localhost,127.0.0.1`。不加这个,插件自己的本地页面(verify、本地普查、任何本地服务)会被塞进代理而连不上。
|
|
10
|
+
- **地址写错当场报错,绝不静默回退直连**:缺 scheme 时按 `host:port` 补 http;端口必须 1–65535;带路径/查询/片段、未知 scheme 都拒绝,并给出「写成 `scheme://host:port`」这句人话。静默回退会把"代理没生效"伪装成"网站打不开",比报错更难查。
|
|
11
|
+
- **不支持需要账号密码的代理**:带账号密码的地址在校验阶段就拒。两个理由——密码会明文落在用户的 `cordis.patch.yml` 里;而且 Chromium/Playwright 本身不支持 SOCKS5 认证(`Browser does not support socks5 proxy authentication`)。宁可少一个能力,不埋一个明文密码。
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **保存代理只改一行**:`playwright.launch` 下的 `proxy:` 这一行,与三个能力闸门同一个模式(已知键、按行改)。其它键(`profileDir` / `channel` / `viewport` / `maxSessions` / `toolPrefix` / `maxWaitMs` 等)**逐字节不动**;清空 = 删掉那一行(留空=直连必须是干净的,不能留空值);结构认不出就**拒绝写入**,绝不整块重渲染。
|
|
16
|
+
- **生效时机**:代理是浏览器启动参数,插件在加载时读配置,所以**改完要重启 DSH 才生效**;设置页保存成功后会直接提示这一点。
|
|
17
|
+
|
|
18
|
+
### Tests
|
|
19
|
+
|
|
20
|
+
- 新增 `tests/proxy.test.ts`(9 条):空配置不带 proxy;填了带 server + bypass;`host:port` 短写补 http;端口缺失/非数字/0/65536;认证代理拒绝;未知 scheme 与带路径拒绝;启动路径静态守卫(防止只加配置没接上);**代理不影响 `allowedDomains` 判定**。
|
|
21
|
+
- `tests/settings-card.test.ts` 新增 4 条:保存后 `launch.proxy` 出现且其它键完全不变(含 `profileDir`);清空后那一行被删;非法地址 400 且文件不变;重复保存同一值幂等无重复键。
|
|
22
|
+
- 全套现在 **390 条 / 387 过 / 0 失败 / cancelled 0 / skipped 3 / todo 0**。
|
|
23
|
+
|
|
24
|
+
### Verified
|
|
25
|
+
|
|
26
|
+
- **走代理真跑 live 三条:3/3 通过**。`example.com` 两条照常;`wikipedia REST summary` 在直连时 31 秒 `NAVIGATION_TIMEOUT` 失败,配代理后 **1.0 秒通过**——这就是这次改动的直接证据(此前 0.8.2 发布时这条是 2/3)。
|
|
27
|
+
- verify 60 项全过、preflight 0 硬错误、打包 101 个文件(新增 `lib/proxy.js` / `lib/proxy.d.ts`)。
|
|
28
|
+
- 已知限制:设置页里没有"测试代理连通性"的按钮;填错端口但格式合法时,要到实际启动浏览器才会发现连不上。
|
|
29
|
+
|
|
30
|
+
## 0.8.2 — 2026-11-12
|
|
31
|
+
|
|
32
|
+
一句话摘要:**发版门禁不再会"假绿",快照引擎第一次在真实网站上被量过**——preflight 过去只读 `ℹ fail`(而且"fail 0"是写死的字符串),被取消的用例看不出、也不报;设置页这个全仓最大的零测试模块补上了走真实 HTTP 的测试,顺手修掉"已有未标记装载行时写出第二行"的坏文件路径;智能模式(aria)的解析器与可交互集对齐后,130 个任务页的降级从 18 页降到 3 页。
|
|
33
|
+
|
|
34
|
+
### Fixed — 门禁与默认值
|
|
35
|
+
|
|
36
|
+
- **preflight 的假绿通道(最要紧)**:旧实现只解析 `ℹ fail N`,摘要里的 `fail 0` 是硬编码字符串,`cancelled` / `todo` 一次都没读。0.7.8 有 7 条用例被打成 `cancelledByParent`,对外仍说"278 通过",实际只跑了 271 条。现在判读抽成 `scripts/preflight-suite.mjs`,读全部六个计数,并把下列情形都判成硬错误:`fail > 0`、`cancelled > 0`、`todo > 0`、恒等式 `tests ≠ pass + fail + cancelled + skipped + todo`、任一计数读不到、命令非零退出、出现 `cancelledByParent > 0`。**生产脚本里不允许任何外部注入测试结果的入口**(第一版加过、被复核否掉,并留了守卫测试防止再加回来)。
|
|
37
|
+
- **`swarm.enabled` 的默认方向反了**:`config.ts` 的 schema 是 `default(false)`,而 `swarm-intro.ts` 的三个入口写成 `?? true` —— 配置整体缺失时会播。改成 `?? false`,并补测试:`config` 为 `undefined` / `{}` / `{ swarm: {} }` 三种形状都不播,另有一条 `enabled: true` 的对照防止测试空转。
|
|
38
|
+
- **设置页写出重复装载行**:补丁层里已有未标记的 `- id: browser` 行时,旧实现会再追加一次模板,同一个 id 被挂两行(与 issue #4 同一类"保存写出坏文件")。现在改为**收编**那一行(保留用户键、外面套标记、只改三个闸门);若那一行属于别的包,**拒绝写入并报错**,文件一个字节不动。
|
|
39
|
+
- **SECURITY.md 与真实行为不符**:旧文写着"页面自己的 JS 仍能离开白名单",实际名单装在浏览器 context 上、请求层就会拒。按真实行为重写,并保留两条诚实的限制(Chromium 自家网络栈里的跳转靠动作后校验最终 URL;已经停在不允许域名上的标签页仍打开、只是不驱动)。
|
|
40
|
+
|
|
41
|
+
### Fixed — 快照引擎(aria / smart 模式)
|
|
42
|
+
|
|
43
|
+
- **解析器读不懂"整行被引号包住"的节点**:Playwright 在名字含双引号时会写成 `- 'tab "Section #1" [ref=e5]'`(可能带尾冒号)。旧解析器把它算格式漂移,整页降级——MiniWoB 的标签页/折叠题共 8 页因此读不了。现在容忍外层引号 + 可选尾冒号,并加了"原来能读的行不许读坏"的守卫测试。
|
|
44
|
+
- **两套"可交互"定义对不齐**:DOM 铸造集与 aria 树的可交互集不一致,会触发对齐守卫、整页回退。对齐了这几处:`tr→row`、`td→cell`、`th→columnheader`、`li→listitem`、`table/list/p/h1-6/img` 的隐式角色;`<option>` 的可见性看它的 `<select>`;`role="presentation"/"none"` 在可聚焦链接上忽略(ARIA 规范,jQuery-UI 标签页就是这样);`<input type=number>` → `spinbutton`。
|
|
45
|
+
- **实测**:MiniWoB++ 130 个任务页里,智能模式降级 **18 → 3**;`ref` 落不到 DOM 元素 **0 页 / 0 个**;130/130 有效、0 失败。真实网站 7 页 5 站(含 GitHub 仓库页、runoob 表格页、w3school 表单页、element-plus 组件页)全部 `unresolved = 0`,点击落点逐个核对。
|
|
46
|
+
- **代价与旋钮**:GitHub 仓库页从此前的"读不了、回退 legacy(81 refs / 8,517 字符)"变成"智能模式可读(134 refs / 20,780 字符)"——结构更全、也更贵。真正决定体积的是 `snapshot.maxNodes`(默认 500),不是 `interactiveOnly`:同一页 `interactiveOnly` 是 18,351 字符 / 239 refs,**只省 12% 且 ref 反而更多**(预算重新分配,不减少总量)。README 与 PLUGIN-README 已写明这个旋钮。
|
|
47
|
+
|
|
48
|
+
### Added — 测试与流程
|
|
49
|
+
|
|
50
|
+
- **设置卡端到端测试**(`tests/settings-card.test.ts`):全仓最大的零测试模块(537 行)补上 10 条,走**真实 HTTP**(`node:http` 起服务、把设置卡注册的 handler 原样接上:方法/请求头/请求体/状态码都走真实栈)。钉住 issue #4(保存后 YAML 可重新解析、无重复键、只有一个装载行)、切开关不抹 `profileDir` 等用户配置、六个路由的 `x-dsh-plugin` 防伪头、被拒请求不动文件。其中"重复装载行"与"拒绝写入"两条做过变异验证(把修复改回去,测试立刻红)。
|
|
51
|
+
- **覆盖率**:`npm run coverage`(`scripts/coverage.mjs`)。用 Node 自带的 `--experimental-test-coverage`,不引入新依赖(本机 `@deepseek-ai/*` 是指向活运行时的软链接,不能 `npm install`)。自带报告只列被加载过的文件,脚本额外把 `src/**` 全清单求差集,**把 0 覆盖模块明着列出来**。首测:43 个模块、41 个被加载、2 个 0 覆盖(纯类型文件)。**不是门禁**。
|
|
52
|
+
- **跨仓库检查显式化**:配套件文档检查移出默认套件(`tests/cross-repo/`),默认套件的数字在任何机器上一致;没设 `DSH_TOGGLE_DIR` 时 preflight 明写**「未验证」**,不再以"跳过"冒充通过。
|
|
53
|
+
- **live 三条接进发版清单**:`docs/RELEASING.md` 新增发版清单,要求发版前手动跑 `DSH_BROWSER_LIVE=1 npm run test:live`,结论写进 Release;没跑就在 CHANGELOG 明写「本次未跑 live」。preflight 每次固定打一行「未验证:live 三条」,不再悄悄消失。
|
|
54
|
+
- **一次性发布文档挪位**:`RELEASE-0.7.0.md` / `MERGE-PROGRESS.md` 移到 `docs/releases/`(**不删**,里面有回退命令),preflight 把整个目录当历史豁免——根目录发版噪声从 5 处降到 3 处。
|
|
55
|
+
|
|
56
|
+
### live 三条:本次跑过,2/3 通过
|
|
57
|
+
|
|
58
|
+
按 `docs/RELEASING.md` 手动跑了一次 `DSH_BROWSER_LIVE=1 npm run test:live`(2026-11-12):
|
|
59
|
+
|
|
60
|
+
- ✔ `example.com renders, snapshots, and screenshots`(1.3s)
|
|
61
|
+
- ✔ `example.com rejects disallowed schemes consistently`(0.03s)
|
|
62
|
+
- ✖ `wikipedia REST summary parses through evaluate` —— **本机网络到不了 wikipedia**:`page.goto` 在 30 秒后 `NAVIGATION_TIMEOUT`(这条不经过插件的新代码,属环境不可达)
|
|
63
|
+
|
|
64
|
+
所以本次 live **未全绿**,原因记录在此;它不在 `prepublishOnly` 里,不影响发布门禁。preflight 的「未验证:live 三条」行同样保留。
|
|
65
|
+
|
|
1
66
|
## 0.8.1 — 2026-11-11
|
|
2
67
|
|
|
3
68
|
一句话摘要:**快照第一次真正"看得见状态"**——填进去的值、勾选状态进快照也进页面指纹,正常的填字和勾选不再被误报成"疑似反爬墙";密码 / 验证码 / 卡号即使有值也绝不进快照。顺带削掉真实站点快照里最贵的一类垃圾(跳转包裹 URL、同一 href 的重复打印),并让 `browser_evaluate` 返回非标量时给出能照着改的错误。**另外:进场动画重新随包分发——作者 Dhvanil Patel 于 2026-11-11 邮件书面许可打包与修改,默认仍然关闭。**
|
package/README.md
CHANGED
|
@@ -378,6 +378,19 @@ The tool schemas join the system-prompt assembly through `ctx.tools.register()`;
|
|
|
378
378
|
|
|
379
379
|
One tool schema block per tool (small, fixed), plus per-call result text proportional to the visible page, capped by ``snapshot.maxNodes`` and name/text limits. Actions return a fresh snapshot, so interactive flows cost roughly one snapshot per step.
|
|
380
380
|
|
|
381
|
+
The merged tools carry their own allowance rather than the default 1,650 characters. `browser_click` (
|
|
382
|
+
which absorbed the old separate point-click and hover tools) measures **2,165 characters** against a
|
|
383
|
+
budget of **2,400** — about **235 characters** of headroom left. `npm run cost` prints that per-tool
|
|
384
|
+
figure on every run, so it is measured, not estimated.
|
|
385
|
+
|
|
386
|
+
**The size knob is `snapshot.maxNodes`, not the reading mode.** The node cap decides how many
|
|
387
|
+
characters a snapshot can spend: at the default 500 the engine stops once 500 nodes are in the tree.
|
|
388
|
+
On a very large page whose snapshot is re-sent at every step, lowering it is the lever — the price is
|
|
389
|
+
fewer elements visible. `interactiveOnly` does **not** shrink the total: it spends the same node
|
|
390
|
+
budget on actionable elements only, so the same page can carry *more* refs that way. Measured on one
|
|
391
|
+
real page (a GitHub repository page, 2026-11-12): full snapshot **20,780 characters / 134 refs**; the
|
|
392
|
+
same page with `interactiveOnly: true` **18,351 characters / 239 refs**.
|
|
393
|
+
|
|
381
394
|
Measured with ``npm run cost`` (per-turn, resident in the system prompt):
|
|
382
395
|
|
|
383
396
|
| Surface | Tools | Schema characters | ≈ tokens/turn |
|
package/SECURITY.md
CHANGED
|
@@ -37,10 +37,18 @@ call — a click that raises `confirm()` returns the parked dialog instead of ac
|
|
|
37
37
|
|
|
38
38
|
**Which hosts it may touch is policy, not goodwill.** `allowedDomains` is enforced on every path that
|
|
39
39
|
can navigate: `browser_navigate`, `browser_open_tab`, every click variant (ref, text, geometric,
|
|
40
|
-
raw coordinates) and `browser_switch_tab`.
|
|
41
|
-
(`
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
raw coordinates) and `browser_switch_tab`. **The list is also installed on the browser context itself**
|
|
41
|
+
(`src/navigation-guard.ts`), so the browser — not just the tool layer — refuses a document request to a
|
|
42
|
+
host outside it. A page's own JavaScript (`location.href = …`), a `meta refresh`, a `<form>` posting
|
|
43
|
+
off-list and a page-opened popup all stop at the request; `fetch` / `xhr` / `EventSource` to an
|
|
44
|
+
off-list host are refused too, while rendering assets are left alone so a page does not look broken.
|
|
45
|
+
|
|
46
|
+
Two limits remain, and they are the honest part of this section. (1) Chromium follows a redirect inside
|
|
47
|
+
its own network stack, where request interception never sees the hop: after every action the page's
|
|
48
|
+
final URL is checked, an off-list landing is refused with `URL_NOT_ALLOWED`, and the tab is reset to
|
|
49
|
+
`about:blank`. (2) A tab that was **already** sitting on a disallowed host when this session took it over
|
|
50
|
+
(a profile written by an older version) stays open — the agent simply refuses to drive it. For hard
|
|
51
|
+
isolation from the whole host stack, still put an external network guard around it.
|
|
44
52
|
|
|
45
53
|
**`browser_cdp` is CDP with an allow-list.** Nineteen methods pass, matched by **exact name with no
|
|
46
54
|
family prefixes**: DOM reads (geometry, node lookup, attributes, markup), one focus action, the four
|
package/lib/browser-lifecycle.js
CHANGED
|
@@ -7,6 +7,7 @@ import { chromium } from 'playwright-core';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import { AUTO_CHANNELS, HUMANIZED_LAUNCH } from "./config.js";
|
|
10
|
+
import { proxyLaunchOption } from "./proxy.js";
|
|
10
11
|
import { SNAPSHOT_SCRIPT } from "./injected.js";
|
|
11
12
|
import { BrowserError, launchFailed } from "./errors.js";
|
|
12
13
|
import { forgetOnBrowserClose, showWelcome } from "./swarm-intro.js";
|
|
@@ -72,6 +73,9 @@ async function launchContext(provider) {
|
|
|
72
73
|
// Headless mode keeps the fixed viewport (no window to track).
|
|
73
74
|
...(headless ? { viewport: provider.config.launch.viewport } : { viewport: null }),
|
|
74
75
|
ignoreHTTPSErrors: provider.config.launch.ignoreHTTPSErrors,
|
|
76
|
+
// Proxy at the browser level (launchOptions.proxy): request headers cannot route a whole
|
|
77
|
+
// browser. Empty proxy yields {} so the default stays a direct connection.
|
|
78
|
+
...proxyLaunchOption(provider.config.launch.proxy),
|
|
75
79
|
...HUMANIZED_LAUNCH,
|
|
76
80
|
args: [
|
|
77
81
|
'--no-first-run',
|
|
@@ -226,7 +230,12 @@ async function launchBrowser(provider) {
|
|
|
226
230
|
const attempts = [];
|
|
227
231
|
if (executablePath !== undefined) {
|
|
228
232
|
try {
|
|
229
|
-
provider.browser = await chromium.launch({
|
|
233
|
+
provider.browser = await chromium.launch({
|
|
234
|
+
executablePath,
|
|
235
|
+
headless,
|
|
236
|
+
...HUMANIZED_LAUNCH,
|
|
237
|
+
...proxyLaunchOption(provider.config.launch.proxy),
|
|
238
|
+
});
|
|
230
239
|
provider.launchedHeadless = headless;
|
|
231
240
|
return provider.browser;
|
|
232
241
|
}
|
|
@@ -239,7 +248,12 @@ async function launchBrowser(provider) {
|
|
|
239
248
|
const channels = channel !== undefined && channel !== '' ? [channel] : [...AUTO_CHANNELS];
|
|
240
249
|
for (const candidate of channels) {
|
|
241
250
|
try {
|
|
242
|
-
provider.browser = await chromium.launch({
|
|
251
|
+
provider.browser = await chromium.launch({
|
|
252
|
+
channel: candidate,
|
|
253
|
+
headless,
|
|
254
|
+
...HUMANIZED_LAUNCH,
|
|
255
|
+
...proxyLaunchOption(provider.config.launch.proxy),
|
|
256
|
+
});
|
|
243
257
|
provider.launchedHeadless = headless;
|
|
244
258
|
return provider.browser;
|
|
245
259
|
}
|
package/lib/client.js
CHANGED
|
@@ -403,6 +403,13 @@ window.__ModuleLoader__.load({
|
|
|
403
403
|
const [logins, setLogins] = useState(null)
|
|
404
404
|
const [headless, setHeadless] = useState(null)
|
|
405
405
|
const [headlessUserChosen, setHeadlessUserChosen] = useState(false)
|
|
406
|
+
// 代理(浏览器启动参数):'' = 直连。
|
|
407
|
+
const [proxy, setProxy] = useState('')
|
|
408
|
+
const [proxyDraft, setProxyDraft] = useState('')
|
|
409
|
+
const [proxyBusy, setProxyBusy] = useState(false)
|
|
410
|
+
const [proxyMsg, setProxyMsg] = useState(null)
|
|
411
|
+
// 输入框有焦点时,5 秒一次的轮询不许覆盖用户正在打的字。
|
|
412
|
+
const proxyFocusedRef = useRef(false)
|
|
406
413
|
const [showAll, setShowAll] = useState(false)
|
|
407
414
|
const [openGroups, setOpenGroups] = useState([])
|
|
408
415
|
const [hiddenDomains, setHiddenDomains] = useState(readHiddenDomains())
|
|
@@ -436,6 +443,10 @@ window.__ModuleLoader__.load({
|
|
|
436
443
|
if (typeof s.swarmPersistPlayed === 'boolean') setSwarmPersist(s.swarmPersistPlayed)
|
|
437
444
|
if (typeof s.headless === 'boolean') setHeadless(s.headless)
|
|
438
445
|
setHeadlessUserChosen(s.headlessUserChosen === true)
|
|
446
|
+
if (typeof s.proxy === 'string') {
|
|
447
|
+
setProxy(s.proxy)
|
|
448
|
+
if (!proxyFocusedRef.current) setProxyDraft(s.proxy)
|
|
449
|
+
}
|
|
439
450
|
setGates({
|
|
440
451
|
allowEvaluate: typeof s.allowEvaluate === 'boolean' ? s.allowEvaluate : null,
|
|
441
452
|
allowCdp: typeof s.allowCdp === 'boolean' ? s.allowCdp : null,
|
|
@@ -573,6 +584,30 @@ window.__ModuleLoader__.load({
|
|
|
573
584
|
}
|
|
574
585
|
}
|
|
575
586
|
|
|
587
|
+
// 保存代理:只改 cordis.patch.yml 里 launch.proxy 那一行;空 = 删掉(直连)。
|
|
588
|
+
const saveProxy = async () => {
|
|
589
|
+
if (proxyBusy) return
|
|
590
|
+
setProxyBusy(true)
|
|
591
|
+
setProxyMsg(null)
|
|
592
|
+
try {
|
|
593
|
+
const r = await api('/proxy', 'POST', { proxy: proxyDraft })
|
|
594
|
+
if (r && r.error) {
|
|
595
|
+
setProxyMsg({ kind: 'error', text: r.error.message || r.error.code || '保存失败' })
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
const saved = typeof r.proxy === 'string' ? r.proxy : ''
|
|
599
|
+
setProxy(saved)
|
|
600
|
+
setProxyDraft(saved)
|
|
601
|
+
setProxyMsg({
|
|
602
|
+
kind: 'ok',
|
|
603
|
+
text: (saved === '' ? '已保存:留空 = 直连。' : '已保存:' + saved + '。') + '重启 DSH 后生效。',
|
|
604
|
+
})
|
|
605
|
+
} catch (error) {
|
|
606
|
+
setProxyMsg({ kind: 'error', text: '保存失败:' + (error?.message ?? String(error)) })
|
|
607
|
+
} finally {
|
|
608
|
+
setProxyBusy(false)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
576
611
|
const chooseWindow = async (nextHeadless) => {
|
|
577
612
|
if (headless === null || busy || nextHeadless === headless) return
|
|
578
613
|
setBusy(true)
|
|
@@ -1153,6 +1188,68 @@ window.__ModuleLoader__.load({
|
|
|
1153
1188
|
)
|
|
1154
1189
|
: null,
|
|
1155
1190
|
|
|
1191
|
+
// ---- 代理(浏览器启动参数)----
|
|
1192
|
+
// 放在「已登录的站点」正前方(用户要求的最后一块),样式与其它分组一致:
|
|
1193
|
+
// 标题行 + 一整块内容。留空 = 直连;改了要重启 DSH 才生效(保存成功时会说明)。
|
|
1194
|
+
h('div', { key: 'proxyGroup', style: S.group }, ['代理']),
|
|
1195
|
+
h('div', { key: 'proxyBody', style: { ...S.list, marginTop: 8 } }, [
|
|
1196
|
+
h('div', { key: 'row', style: { display: 'flex', alignItems: 'center', gap: 8 } }, [
|
|
1197
|
+
h('input', {
|
|
1198
|
+
key: 'input',
|
|
1199
|
+
type: 'text',
|
|
1200
|
+
value: proxyDraft,
|
|
1201
|
+
placeholder: 'http://host:port',
|
|
1202
|
+
disabled: proxyBusy,
|
|
1203
|
+
spellCheck: false,
|
|
1204
|
+
onFocus: () => {
|
|
1205
|
+
proxyFocusedRef.current = true
|
|
1206
|
+
},
|
|
1207
|
+
onBlur: () => {
|
|
1208
|
+
proxyFocusedRef.current = false
|
|
1209
|
+
},
|
|
1210
|
+
onChange: (e) => {
|
|
1211
|
+
setProxyDraft(e.target.value)
|
|
1212
|
+
setProxyMsg(null)
|
|
1213
|
+
},
|
|
1214
|
+
style: {
|
|
1215
|
+
flex: '1 1 auto',
|
|
1216
|
+
minWidth: 160,
|
|
1217
|
+
height: 28,
|
|
1218
|
+
padding: '0 10px',
|
|
1219
|
+
border: '0.5px solid ' + T.borderL3,
|
|
1220
|
+
borderRadius: 14,
|
|
1221
|
+
background: 'transparent',
|
|
1222
|
+
font: 'inherit',
|
|
1223
|
+
fontSize: 12,
|
|
1224
|
+
lineHeight: '18px',
|
|
1225
|
+
color: T.primary,
|
|
1226
|
+
},
|
|
1227
|
+
}),
|
|
1228
|
+
h(
|
|
1229
|
+
Btn,
|
|
1230
|
+
{
|
|
1231
|
+
key: 'save',
|
|
1232
|
+
disabled: proxyBusy || proxyDraft === proxy,
|
|
1233
|
+
},
|
|
1234
|
+
proxyBusy ? '保存中…' : '保存',
|
|
1235
|
+
),
|
|
1236
|
+
]),
|
|
1237
|
+
h(
|
|
1238
|
+
'p',
|
|
1239
|
+
{ key: 'hint', style: S.note },
|
|
1240
|
+
'留空 = 直连。要填的话写成 http://主机:端口 或 socks5://主机:端口,例如 http://proxy.example:8080;本机页面不走代理。不支持需要账号密码的代理。',
|
|
1241
|
+
),
|
|
1242
|
+
proxyMsg
|
|
1243
|
+
? h(
|
|
1244
|
+
'p',
|
|
1245
|
+
{
|
|
1246
|
+
key: 'msg',
|
|
1247
|
+
style: { ...S.note, color: proxyMsg.kind === 'error' ? T.error : T.success },
|
|
1248
|
+
},
|
|
1249
|
+
proxyMsg.text,
|
|
1250
|
+
)
|
|
1251
|
+
: null,
|
|
1252
|
+
]),
|
|
1156
1253
|
// ---- 已登录站点(默认收起) ----
|
|
1157
1254
|
h(
|
|
1158
1255
|
'div',
|
package/lib/config.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface PlaywrightConfig {
|
|
|
25
25
|
navigationTimeoutMs: number;
|
|
26
26
|
/** Ignore HTTPS certificate errors. */
|
|
27
27
|
ignoreHTTPSErrors: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Proxy for the whole browser, e.g. `http://proxy.example:8080` (http / https / socks5).
|
|
30
|
+
* Empty = connect directly — the default, and the previous behaviour.
|
|
31
|
+
*/
|
|
32
|
+
proxy?: string;
|
|
28
33
|
};
|
|
29
34
|
/** Host suffixes the browser may visit. Empty = any http(s) host. */
|
|
30
35
|
allowedDomains?: string[];
|
package/lib/config.js
CHANGED
|
@@ -27,6 +27,11 @@ export const Config = z.object({
|
|
|
27
27
|
// one second; the default stays 30s.
|
|
28
28
|
navigationTimeoutMs: z.number().min(1000).default(30000),
|
|
29
29
|
ignoreHTTPSErrors: z.boolean().default(false),
|
|
30
|
+
// Proxy for the whole browser (launchOptions.proxy), e.g. http://proxy.example:8080.
|
|
31
|
+
// Empty = direct, which is the default and keeps every existing deployment unchanged.
|
|
32
|
+
// Validation lives in src/proxy.ts so a bad address fails loudly instead of silently
|
|
33
|
+
// connecting directly (which would look like "the site is down").
|
|
34
|
+
proxy: z.string().default(''),
|
|
30
35
|
}),
|
|
31
36
|
allowedDomains: z.array(z.string()),
|
|
32
37
|
idleTimeoutMs: z.number().default(600000),
|
package/lib/gates-patch.d.ts
CHANGED
|
@@ -86,4 +86,33 @@ export declare function writeGates(allowEvaluate: boolean, allowCdp: boolean, di
|
|
|
86
86
|
allowCdp: boolean;
|
|
87
87
|
diagnostics: boolean;
|
|
88
88
|
}>;
|
|
89
|
+
/**
|
|
90
|
+
* 改写块里的 proxy 行:有就改、没有就插、空字符串就删。其它行逐字节保留。
|
|
91
|
+
*
|
|
92
|
+
* @param body - 托管块全文(含标记)
|
|
93
|
+
* @param proxy - 已经过 normalizeProxy 校验的地址;'' 表示删掉这一行
|
|
94
|
+
* @returns 改好的块
|
|
95
|
+
*/
|
|
96
|
+
export declare function patchBlockProxy(body: string, proxy: string): string;
|
|
97
|
+
/** 读托管块里的 proxy 值;没有就是 ''(= 直连)。 */
|
|
98
|
+
export declare function parseBrowserBlockProxy(text: string): string;
|
|
99
|
+
/**
|
|
100
|
+
* 把 proxy 合并进文件内容:没有托管块就先按模板建一个,再改这一行。
|
|
101
|
+
*
|
|
102
|
+
* @param original - 原文件内容
|
|
103
|
+
* @param proxy - 已校验的地址;'' = 删掉
|
|
104
|
+
* @returns 新文件内容
|
|
105
|
+
*/
|
|
106
|
+
export declare function mergeBrowserBlockProxy(original: string, proxy: string): string;
|
|
107
|
+
/**
|
|
108
|
+
* 写 proxy:备份 → 原子替换 → 读回校验;校验失败恢复原文件。
|
|
109
|
+
*
|
|
110
|
+
* @param proxy - 已校验的地址;'' 表示删掉这一行(= 直连)
|
|
111
|
+
* @param env - 环境(测试用 DSH_HOME 覆盖)
|
|
112
|
+
* @returns 写到的文件与现在的值
|
|
113
|
+
*/
|
|
114
|
+
export declare function writeProxy(proxy: string, env?: NodeJS.ProcessEnv): Promise<{
|
|
115
|
+
patch: string;
|
|
116
|
+
proxy: string;
|
|
117
|
+
}>;
|
|
89
118
|
export {};
|
package/lib/gates-patch.js
CHANGED
|
@@ -174,6 +174,41 @@ function findBlock(original) {
|
|
|
174
174
|
}
|
|
175
175
|
return undefined;
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* A **top-level** loader row for `id: browser` that is not inside a managed block.
|
|
179
|
+
*
|
|
180
|
+
* Why this exists (2026-11-12, found while writing the settings-card tests): when the
|
|
181
|
+
* user's patch layer already carried an unmarked `- id: browser` row (hand-edited, or
|
|
182
|
+
* written by a version before the markers existed), `mergeBrowserBlock` used to append
|
|
183
|
+
* the full template — producing **two loader rows for the same id**. The loader then
|
|
184
|
+
* sees the package twice, which is the same class of failure as issue #4 (a save writes
|
|
185
|
+
* a patch that breaks the next start). Adopting the row instead keeps the user's keys
|
|
186
|
+
* and leaves exactly one row.
|
|
187
|
+
*
|
|
188
|
+
* @param original - the whole patch file
|
|
189
|
+
* @returns the row's bounds, or undefined when there is none
|
|
190
|
+
*/
|
|
191
|
+
function findUnmarkedRow(original) {
|
|
192
|
+
const head = /^([ \t]*)-[ \t]+id:[ \t]*browser[ \t]*(?:#.*)?$/m.exec(original);
|
|
193
|
+
if (head === null)
|
|
194
|
+
return undefined;
|
|
195
|
+
const start = head.index;
|
|
196
|
+
const indent = (head[1] ?? '').length;
|
|
197
|
+
const bodyStart = start + head[0].length;
|
|
198
|
+
// The row ends at the next sequence item at the same (or a shallower) indent.
|
|
199
|
+
let end = original.length;
|
|
200
|
+
const item = /^([ \t]*)-[ \t]+/gm;
|
|
201
|
+
item.lastIndex = bodyStart;
|
|
202
|
+
for (let found = item.exec(original); found !== null; found = item.exec(original)) {
|
|
203
|
+
if ((found[1] ?? '').length <= indent) {
|
|
204
|
+
end = found.index;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const body = original.slice(start, end);
|
|
209
|
+
const namesUs = /['"]dsh-browser-playwright-codex['"]/.test(body);
|
|
210
|
+
return { start, end, namesUs, indent };
|
|
211
|
+
}
|
|
177
212
|
/**
|
|
178
213
|
* Merge the gate values into `original`.
|
|
179
214
|
*
|
|
@@ -189,6 +224,31 @@ export function mergeBrowserBlock(original, allowEvaluate, allowCdp, diagnostics
|
|
|
189
224
|
const gates = { allowEvaluate, allowCdp, diagnostics };
|
|
190
225
|
const found = findBlock(original);
|
|
191
226
|
if (found === undefined) {
|
|
227
|
+
const row = findUnmarkedRow(original);
|
|
228
|
+
if (row !== undefined) {
|
|
229
|
+
// An unmarked row already mounts this package. Wrapping it (instead of appending a
|
|
230
|
+
// second one) is what keeps "one loader row per package" true — the same invariant
|
|
231
|
+
// the 0.7.0 merge had to satisfy.
|
|
232
|
+
if (!row.namesUs) {
|
|
233
|
+
// It is somebody else's `- id: browser` row. Refuse rather than write a file that
|
|
234
|
+
// mounts two different packages under one id: a clear error now beats a fatal start.
|
|
235
|
+
throw new Error('the home patch already has an `- id: browser` row that is not this package; ' +
|
|
236
|
+
'refusing to add a second row for the same id — rename one of them by hand, then retry');
|
|
237
|
+
}
|
|
238
|
+
const nl = original.includes('\r\n') ? '\r\n' : '\n';
|
|
239
|
+
const body = original.slice(row.start, row.end).replace(/[ \t]+$/, '');
|
|
240
|
+
const wrapped = BEGIN + nl + body + nl + END + nl;
|
|
241
|
+
const edited = patchBlockGates(wrapped, gates);
|
|
242
|
+
const check = parseBrowserBlock(edited);
|
|
243
|
+
if (check !== undefined &&
|
|
244
|
+
check.allowEvaluate === allowEvaluate &&
|
|
245
|
+
check.allowCdp === allowCdp &&
|
|
246
|
+
check.diagnostics === diagnostics) {
|
|
247
|
+
return original.slice(0, row.start) + edited + original.slice(row.end);
|
|
248
|
+
}
|
|
249
|
+
const rebuilt = BEGIN + nl + renderBrowserBlock(allowEvaluate, allowCdp, diagnostics) + END + nl;
|
|
250
|
+
return original.slice(0, row.start) + rebuilt + original.slice(row.end);
|
|
251
|
+
}
|
|
192
252
|
const block = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
|
|
193
253
|
return (original === '' || original.endsWith('\n') ? original : original + '\n') + '\n' + block;
|
|
194
254
|
}
|
|
@@ -263,3 +323,152 @@ export async function writeGates(allowEvaluate, allowCdp, diagnostics, env = pro
|
|
|
263
323
|
}
|
|
264
324
|
return { patch: file, allowEvaluate, allowCdp, diagnostics };
|
|
265
325
|
}
|
|
326
|
+
/** 在块的文本里找 `launch:` 与它的 `proxy:` 子行。认不出结构就抛错(拒绝写入)。 */
|
|
327
|
+
function findLaunchSection(lines) {
|
|
328
|
+
let launchAt = -1;
|
|
329
|
+
let launchIndent = '';
|
|
330
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
331
|
+
const m = /^(\s*)launch\s*:\s*(#.*)?$/.exec(lines[i] ?? '');
|
|
332
|
+
if (m !== null) {
|
|
333
|
+
launchAt = i;
|
|
334
|
+
launchIndent = m[1] ?? '';
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (launchAt === -1) {
|
|
339
|
+
throw new Error('the managed block has no `playwright.launch:` section, so launch.proxy cannot be edited safely; ' +
|
|
340
|
+
'nothing was written. Fix the block by hand (or remove it and save a gate once to rebuild it).');
|
|
341
|
+
}
|
|
342
|
+
const baseIndent = launchIndent.length;
|
|
343
|
+
let childIndent = '';
|
|
344
|
+
let proxy;
|
|
345
|
+
for (let i = launchAt + 1; i < lines.length; i += 1) {
|
|
346
|
+
const line = lines[i] ?? '';
|
|
347
|
+
const trimmed = line.trim();
|
|
348
|
+
if (trimmed === '')
|
|
349
|
+
continue;
|
|
350
|
+
const indent = (/^\s*/.exec(line)?.[0] ?? '').length;
|
|
351
|
+
if (indent <= baseIndent)
|
|
352
|
+
break; // 出了 launch 这一节
|
|
353
|
+
if (trimmed.startsWith('#'))
|
|
354
|
+
continue;
|
|
355
|
+
if (childIndent === '')
|
|
356
|
+
childIndent = ' '.repeat(indent);
|
|
357
|
+
const p = /^(\s*)proxy\s*:/.exec(line);
|
|
358
|
+
if (p !== null)
|
|
359
|
+
proxy = { at: i, indent: p[1] ?? '' };
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
at: launchAt,
|
|
363
|
+
indent: baseIndent,
|
|
364
|
+
childIndent: childIndent === '' ? ' '.repeat(baseIndent + 2) : childIndent,
|
|
365
|
+
...(proxy === undefined ? {} : { proxy }),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 改写块里的 proxy 行:有就改、没有就插、空字符串就删。其它行逐字节保留。
|
|
370
|
+
*
|
|
371
|
+
* @param body - 托管块全文(含标记)
|
|
372
|
+
* @param proxy - 已经过 normalizeProxy 校验的地址;'' 表示删掉这一行
|
|
373
|
+
* @returns 改好的块
|
|
374
|
+
*/
|
|
375
|
+
export function patchBlockProxy(body, proxy) {
|
|
376
|
+
const nl = body.includes('\r\n') ? '\r\n' : '\n';
|
|
377
|
+
const lines = body.split(/\r?\n/);
|
|
378
|
+
const section = findLaunchSection(lines);
|
|
379
|
+
const value = proxy.trim();
|
|
380
|
+
const rendered = section.childIndent + 'proxy: ' + JSON.stringify(value);
|
|
381
|
+
if (section.proxy !== undefined) {
|
|
382
|
+
if (value === '')
|
|
383
|
+
lines.splice(section.proxy.at, 1);
|
|
384
|
+
else
|
|
385
|
+
lines[section.proxy.at] = rendered;
|
|
386
|
+
return lines.join(nl);
|
|
387
|
+
}
|
|
388
|
+
if (value === '')
|
|
389
|
+
return lines.join(nl); // 本来就没有,删空是幂等的
|
|
390
|
+
lines.splice(section.at + 1, 0, rendered);
|
|
391
|
+
return lines.join(nl);
|
|
392
|
+
}
|
|
393
|
+
/** 读托管块里的 proxy 值;没有就是 ''(= 直连)。 */
|
|
394
|
+
export function parseBrowserBlockProxy(text) {
|
|
395
|
+
const found = findBlock(text);
|
|
396
|
+
if (found === undefined)
|
|
397
|
+
return '';
|
|
398
|
+
const body = text.slice(found.start, found.after);
|
|
399
|
+
const lines = body.split(/\r?\n/);
|
|
400
|
+
let section;
|
|
401
|
+
try {
|
|
402
|
+
section = findLaunchSection(lines);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return '';
|
|
406
|
+
}
|
|
407
|
+
if (section?.proxy === undefined)
|
|
408
|
+
return '';
|
|
409
|
+
const raw = (lines[section.proxy.at] ?? '').replace(/^\s*proxy\s*:\s*/, '');
|
|
410
|
+
const withoutComment = raw.replace(/\s+#.*$/, '').trim();
|
|
411
|
+
if (withoutComment === '')
|
|
412
|
+
return '';
|
|
413
|
+
if ((withoutComment.startsWith('"') && withoutComment.endsWith('"')) ||
|
|
414
|
+
(withoutComment.startsWith("'") && withoutComment.endsWith("'"))) {
|
|
415
|
+
return withoutComment.slice(1, -1);
|
|
416
|
+
}
|
|
417
|
+
return withoutComment;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* 把 proxy 合并进文件内容:没有托管块就先按模板建一个,再改这一行。
|
|
421
|
+
*
|
|
422
|
+
* @param original - 原文件内容
|
|
423
|
+
* @param proxy - 已校验的地址;'' = 删掉
|
|
424
|
+
* @returns 新文件内容
|
|
425
|
+
*/
|
|
426
|
+
export function mergeBrowserBlockProxy(original, proxy) {
|
|
427
|
+
const value = proxy.trim();
|
|
428
|
+
let base = original;
|
|
429
|
+
let found = findBlock(base);
|
|
430
|
+
if (found === undefined) {
|
|
431
|
+
base = (base === '' || base.endsWith('\n') ? base : base + '\n') + '\n' + renderBrowserBlock(false, false, false);
|
|
432
|
+
found = findBlock(base);
|
|
433
|
+
}
|
|
434
|
+
if (found === undefined)
|
|
435
|
+
throw new Error('could not create the managed block; nothing was written');
|
|
436
|
+
const edited = patchBlockProxy(base.slice(found.start, found.after), value);
|
|
437
|
+
if (parseBrowserBlockProxy(edited) !== value) {
|
|
438
|
+
throw new Error('proxy line self-check failed; nothing was written');
|
|
439
|
+
}
|
|
440
|
+
return base.slice(0, found.start) + edited + base.slice(found.after);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* 写 proxy:备份 → 原子替换 → 读回校验;校验失败恢复原文件。
|
|
444
|
+
*
|
|
445
|
+
* @param proxy - 已校验的地址;'' 表示删掉这一行(= 直连)
|
|
446
|
+
* @param env - 环境(测试用 DSH_HOME 覆盖)
|
|
447
|
+
* @returns 写到的文件与现在的值
|
|
448
|
+
*/
|
|
449
|
+
export async function writeProxy(proxy, env = process.env) {
|
|
450
|
+
const file = homePatchPath(env);
|
|
451
|
+
const value = proxy.trim();
|
|
452
|
+
let original = '';
|
|
453
|
+
try {
|
|
454
|
+
original = await readFile(file, 'utf8');
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
original = '';
|
|
458
|
+
}
|
|
459
|
+
const next = mergeBrowserBlockProxy(original, value);
|
|
460
|
+
if (original !== '')
|
|
461
|
+
await copyFile(file, file + '.bak-browser-toggle').catch(() => { });
|
|
462
|
+
const tmp = file + '.tmp-browser-toggle';
|
|
463
|
+
await writeFile(tmp, next, 'utf8');
|
|
464
|
+
await rename(tmp, file);
|
|
465
|
+
const check = await readFile(file, 'utf8');
|
|
466
|
+
if (parseBrowserBlockProxy(check) !== value) {
|
|
467
|
+
if (original !== '') {
|
|
468
|
+
await writeFile(file, original, 'utf8');
|
|
469
|
+
throw new Error('proxy self-check failed after write; previous content restored');
|
|
470
|
+
}
|
|
471
|
+
throw new Error('proxy self-check failed after write');
|
|
472
|
+
}
|
|
473
|
+
return { patch: file, proxy: value };
|
|
474
|
+
}
|
package/lib/provider.js
CHANGED
|
@@ -17,6 +17,7 @@ import { swarmBootScript } from "./swarm-curtain.js";
|
|
|
17
17
|
import { forgetOnBrowserClose } from "./swarm-intro.js";
|
|
18
18
|
import { isWindowMinimized } from "./window-focus.js";
|
|
19
19
|
import { installHostGuard } from "./navigation-guard.js";
|
|
20
|
+
import { normalizeProxy } from "./proxy.js";
|
|
20
21
|
export class PlaywrightProvider {
|
|
21
22
|
config;
|
|
22
23
|
id = 'playwright';
|
|
@@ -62,6 +63,9 @@ export class PlaywrightProvider {
|
|
|
62
63
|
blockedNavigation = undefined;
|
|
63
64
|
constructor(config) {
|
|
64
65
|
this.config = config;
|
|
66
|
+
// Fail at load time, not at launch time: a bad proxy address must produce the one
|
|
67
|
+
// message that explains it (src/proxy.ts), not a wrapped "tried channel …" failure.
|
|
68
|
+
normalizeProxy(config.launch.proxy);
|
|
65
69
|
// Tell the settings card which page-reading mode this deployment was
|
|
66
70
|
// configured with, so it can show "当前:X(默认)" truthfully instead of
|
|
67
71
|
// assuming the built-in default.
|
package/lib/proxy.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy support for the browser launch (2026-11-12).
|
|
3
|
+
*
|
|
4
|
+
* The plugin starts its own Chromium-family browser, so a proxy has to go into the
|
|
5
|
+
* **launch** options (`launchOptions.proxy`) — request headers cannot route a whole
|
|
6
|
+
* browser. Measured here: Wikipedia through a proxy answered in 4.4 s; direct it timed
|
|
7
|
+
* out at 15 s, which is what every proxied site looked like before this.
|
|
8
|
+
*
|
|
9
|
+
* Three rules, each with a reason:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Empty means direct.** No configured proxy keeps today's behaviour exactly.
|
|
12
|
+
* 2. **`bypass` always includes the local addresses.** Without it the plugin's own
|
|
13
|
+
* `127.0.0.1` pages (verify, the MiniWoB sweep, any local server) would be pushed
|
|
14
|
+
* through the proxy and fail. Playwright passes `bypass` straight to Chromium's
|
|
15
|
+
* proxy bypass list.
|
|
16
|
+
* 3. **A bad address is an error, never a silent fallback to direct.** Quietly going
|
|
17
|
+
* direct would look like "the site is down" and send the user debugging the wrong thing.
|
|
18
|
+
*
|
|
19
|
+
* Authentication is deliberately **not** supported in this version: a password in the
|
|
20
|
+
* config would be stored in plain text in the user's `cordis.patch.yml`, and Playwright
|
|
21
|
+
* refuses SOCKS5 authentication outright (`Browser does not support socks5 proxy
|
|
22
|
+
* authentication`). Rather than ship a plaintext password path, credentials are rejected
|
|
23
|
+
* here with a message that says why.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-browser-playwright-codex/proxy
|
|
26
|
+
*/
|
|
27
|
+
/** The shape Playwright accepts in `launchOptions.proxy`. */
|
|
28
|
+
export interface LaunchProxy {
|
|
29
|
+
/** `http://host:port`, `https://host:port` or `socks5://host:port`. */
|
|
30
|
+
server: string;
|
|
31
|
+
/** Local addresses bypass the proxy, so the plugin's own local pages keep working. */
|
|
32
|
+
bypass: string;
|
|
33
|
+
}
|
|
34
|
+
/** Hosts that must never be proxied (the plugin's own local pages live there). */
|
|
35
|
+
export declare const PROXY_BYPASS = "localhost,127.0.0.1";
|
|
36
|
+
/**
|
|
37
|
+
* Turn a configured proxy address into Playwright's proxy object.
|
|
38
|
+
*
|
|
39
|
+
* Accepted shapes: `host:port` (treated as HTTP, like Playwright does), `http://host:port`,
|
|
40
|
+
* `https://host:port`, `socks5://host:port`. The port is required and must be 1–65535.
|
|
41
|
+
*
|
|
42
|
+
* @param raw - the configured value; empty/undefined means "connect directly"
|
|
43
|
+
* @returns the proxy settings (server + local bypass), or undefined for a direct connection
|
|
44
|
+
* @throws Error with an actionable message when the address cannot be used
|
|
45
|
+
*/
|
|
46
|
+
export declare function normalizeProxy(raw: string | undefined | null): LaunchProxy | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* The spread-in fragment for Playwright's launch options: `{}` when direct,
|
|
49
|
+
* `{ proxy: … }` when configured. Every launch path uses this so they cannot drift.
|
|
50
|
+
*
|
|
51
|
+
* @param raw - the configured proxy value
|
|
52
|
+
* @returns an object to spread into the launch options
|
|
53
|
+
*/
|
|
54
|
+
export declare function proxyLaunchOption(raw: string | undefined | null): {
|
|
55
|
+
proxy?: LaunchProxy;
|
|
56
|
+
};
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy support for the browser launch (2026-11-12).
|
|
3
|
+
*
|
|
4
|
+
* The plugin starts its own Chromium-family browser, so a proxy has to go into the
|
|
5
|
+
* **launch** options (`launchOptions.proxy`) — request headers cannot route a whole
|
|
6
|
+
* browser. Measured here: Wikipedia through a proxy answered in 4.4 s; direct it timed
|
|
7
|
+
* out at 15 s, which is what every proxied site looked like before this.
|
|
8
|
+
*
|
|
9
|
+
* Three rules, each with a reason:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Empty means direct.** No configured proxy keeps today's behaviour exactly.
|
|
12
|
+
* 2. **`bypass` always includes the local addresses.** Without it the plugin's own
|
|
13
|
+
* `127.0.0.1` pages (verify, the MiniWoB sweep, any local server) would be pushed
|
|
14
|
+
* through the proxy and fail. Playwright passes `bypass` straight to Chromium's
|
|
15
|
+
* proxy bypass list.
|
|
16
|
+
* 3. **A bad address is an error, never a silent fallback to direct.** Quietly going
|
|
17
|
+
* direct would look like "the site is down" and send the user debugging the wrong thing.
|
|
18
|
+
*
|
|
19
|
+
* Authentication is deliberately **not** supported in this version: a password in the
|
|
20
|
+
* config would be stored in plain text in the user's `cordis.patch.yml`, and Playwright
|
|
21
|
+
* refuses SOCKS5 authentication outright (`Browser does not support socks5 proxy
|
|
22
|
+
* authentication`). Rather than ship a plaintext password path, credentials are rejected
|
|
23
|
+
* here with a message that says why.
|
|
24
|
+
*
|
|
25
|
+
* @module dsh-browser-playwright-codex/proxy
|
|
26
|
+
*/
|
|
27
|
+
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'socks5:']);
|
|
28
|
+
/** Hosts that must never be proxied (the plugin's own local pages live there). */
|
|
29
|
+
export const PROXY_BYPASS = 'localhost,127.0.0.1';
|
|
30
|
+
/** One clear sentence for every rejection; the value is echoed so a typo is visible. */
|
|
31
|
+
function reject(value, why) {
|
|
32
|
+
throw new Error('launch.proxy is not a usable proxy address (' +
|
|
33
|
+
why +
|
|
34
|
+
'): "' +
|
|
35
|
+
value +
|
|
36
|
+
'". ' +
|
|
37
|
+
'Write it as scheme://host:port (or just host:port) — e.g. http://proxy.example:8080, ' +
|
|
38
|
+
'https://proxy.example:8443 or socks5://proxy.example:1080. ' +
|
|
39
|
+
'Leave it empty to connect directly. Authenticated proxies are not supported in this version.');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Turn a configured proxy address into Playwright's proxy object.
|
|
43
|
+
*
|
|
44
|
+
* Accepted shapes: `host:port` (treated as HTTP, like Playwright does), `http://host:port`,
|
|
45
|
+
* `https://host:port`, `socks5://host:port`. The port is required and must be 1–65535.
|
|
46
|
+
*
|
|
47
|
+
* @param raw - the configured value; empty/undefined means "connect directly"
|
|
48
|
+
* @returns the proxy settings (server + local bypass), or undefined for a direct connection
|
|
49
|
+
* @throws Error with an actionable message when the address cannot be used
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeProxy(raw) {
|
|
52
|
+
const value = (raw ?? '').trim();
|
|
53
|
+
if (value === '')
|
|
54
|
+
return undefined;
|
|
55
|
+
// Credentials would have to live in plain text in the user's patch file, and SOCKS5
|
|
56
|
+
// authentication is not supported by the browser anyway — refuse before launch.
|
|
57
|
+
if (/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i.test(value) || /^[^/?#]*@/.test(value)) {
|
|
58
|
+
throw new Error('launch.proxy must not carry a username or password (found a credential in "' +
|
|
59
|
+
value +
|
|
60
|
+
'"). ' +
|
|
61
|
+
'This version supports proxies WITHOUT authentication only: a password in the config ' +
|
|
62
|
+
'would be stored in plain text in your patch file, and SOCKS5 authentication is not ' +
|
|
63
|
+
'supported by Chromium/Playwright at all. Configure an unauthenticated proxy, or leave ' +
|
|
64
|
+
'this empty to connect directly.');
|
|
65
|
+
}
|
|
66
|
+
// Short form: Playwright itself reads `host:port` as HTTP, so accept it the same way.
|
|
67
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : 'http://' + value;
|
|
68
|
+
// Check the port by hand first: `new URL('http://host:abc')` throws a generic message,
|
|
69
|
+
// and "the port is not a number" is what the user needs to read.
|
|
70
|
+
const hostPart = withScheme.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0] ?? '';
|
|
71
|
+
const hostOnly = hostPart.split('?')[0] ?? '';
|
|
72
|
+
const portText = (() => {
|
|
73
|
+
if (hostOnly.startsWith('[')) {
|
|
74
|
+
const close = hostOnly.indexOf(']');
|
|
75
|
+
if (close === -1)
|
|
76
|
+
return '';
|
|
77
|
+
const rest = hostOnly.slice(close + 1);
|
|
78
|
+
return rest.startsWith(':') ? rest.slice(1) : '';
|
|
79
|
+
}
|
|
80
|
+
const colon = hostOnly.lastIndexOf(':');
|
|
81
|
+
return colon === -1 ? '' : hostOnly.slice(colon + 1);
|
|
82
|
+
})();
|
|
83
|
+
if (portText === '')
|
|
84
|
+
reject(value, 'the port is missing');
|
|
85
|
+
if (!/^\d+$/.test(portText ?? ''))
|
|
86
|
+
reject(value, 'the port is not a number');
|
|
87
|
+
// Check the range before `new URL` does: an out-of-range port makes the constructor
|
|
88
|
+
// throw a generic error, and the useful sentence is "the port must be 1–65535".
|
|
89
|
+
const portNumber = Number(portText);
|
|
90
|
+
if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
|
|
91
|
+
reject(value, 'the port must be 1–65535');
|
|
92
|
+
}
|
|
93
|
+
let url;
|
|
94
|
+
try {
|
|
95
|
+
url = new URL(withScheme);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return reject(value, 'it does not parse as a URL');
|
|
99
|
+
}
|
|
100
|
+
if (!ALLOWED_SCHEMES.has(url.protocol.toLowerCase())) {
|
|
101
|
+
reject(value, 'only http, https and socks5 are supported');
|
|
102
|
+
}
|
|
103
|
+
if (url.hostname === '')
|
|
104
|
+
reject(value, 'the host is empty');
|
|
105
|
+
if (url.port === '' || !/^\d+$/.test(url.port))
|
|
106
|
+
reject(value, 'the port is not a number');
|
|
107
|
+
const port = Number(url.port);
|
|
108
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
109
|
+
reject(value, 'the port must be 1–65535');
|
|
110
|
+
// A non-special scheme (socks5) parses with an EMPTY pathname while http/https give '/',
|
|
111
|
+
// so both are "no path". Anything else means the user appended something.
|
|
112
|
+
if ((url.pathname !== '' && url.pathname !== '/') || url.search !== '' || url.hash !== '') {
|
|
113
|
+
reject(value, 'a proxy address has no path, query or fragment');
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
server: url.protocol + '//' + url.hostname + ':' + url.port,
|
|
117
|
+
// Never proxy the plugin's own local pages.
|
|
118
|
+
bypass: PROXY_BYPASS,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The spread-in fragment for Playwright's launch options: `{}` when direct,
|
|
123
|
+
* `{ proxy: … }` when configured. Every launch path uses this so they cannot drift.
|
|
124
|
+
*
|
|
125
|
+
* @param raw - the configured proxy value
|
|
126
|
+
* @returns an object to spread into the launch options
|
|
127
|
+
*/
|
|
128
|
+
export function proxyLaunchOption(raw) {
|
|
129
|
+
const proxy = normalizeProxy(raw);
|
|
130
|
+
return proxy === undefined ? {} : { proxy };
|
|
131
|
+
}
|
package/lib/settings-card.js
CHANGED
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
import * as runtimeState from "./runtime-state.js";
|
|
63
63
|
// The patch writer lives in its own dependency-free module so it can be tested
|
|
64
64
|
// without a DSH host (a half-written patch layer would break every plugin load).
|
|
65
|
-
import { homePatchPath, parseGatesBlock, writeGates } from "./gates-patch.js";
|
|
65
|
+
import { homePatchPath, parseBrowserBlockProxy, parseGatesBlock, writeGates, writeProxy } from "./gates-patch.js";
|
|
66
|
+
import { normalizeProxy } from "./proxy.js";
|
|
66
67
|
import { onPersistPlayedChanged, resetPlayed } from "./swarm-intro.js";
|
|
67
68
|
import { readFileSync } from 'node:fs';
|
|
68
69
|
import { clean, isTargetId, resolvePaths, scan } from "./cache-clean.js";
|
|
@@ -90,6 +91,14 @@ function readGates() {
|
|
|
90
91
|
* shows the *effective* gates from readGates(); this is the file-level view used
|
|
91
92
|
* for diagnostics when the two disagree (i.e. the reload has not landed yet).
|
|
92
93
|
*/
|
|
94
|
+
function managedBlockProxy() {
|
|
95
|
+
try {
|
|
96
|
+
return parseBrowserBlockProxy(readFileSync(homePatchPath(), 'utf8'));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
93
102
|
function managedBlockGates() {
|
|
94
103
|
try {
|
|
95
104
|
const text = readFileSync(homePatchPath(), 'utf8');
|
|
@@ -391,6 +400,8 @@ export function apply(ctx) {
|
|
|
391
400
|
// What the user's own layer says right now. Differs from the above
|
|
392
401
|
// between a write and the live reload that applies it.
|
|
393
402
|
patchGates: managedBlockGates() ?? null,
|
|
403
|
+
// launch.proxy(设置页的「代理」输入框)。空 = 直连。
|
|
404
|
+
proxy: managedBlockProxy(),
|
|
394
405
|
});
|
|
395
406
|
},
|
|
396
407
|
}));
|
|
@@ -523,5 +534,55 @@ export function apply(ctx) {
|
|
|
523
534
|
sendJson(res, 200, { logins });
|
|
524
535
|
},
|
|
525
536
|
}));
|
|
537
|
+
// ---- launch.proxy:设置页里的「代理」输入框 ----
|
|
538
|
+
// 只改托管块里 `playwright.launch.proxy` 这一行(与三个闸门同一个模式),
|
|
539
|
+
// 其它键逐字节不动;空字符串 = 删掉那一行 = 直连。
|
|
540
|
+
ctx.effect(() => ctx.webServer.register({
|
|
541
|
+
kind: 'exact',
|
|
542
|
+
path: `${PREFIX}/proxy`,
|
|
543
|
+
handler: async (req, res) => {
|
|
544
|
+
if (req.method !== 'POST')
|
|
545
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
|
|
546
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
547
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
548
|
+
const body = await readBody(req);
|
|
549
|
+
if (!body.ok)
|
|
550
|
+
return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
|
|
551
|
+
let payload;
|
|
552
|
+
try {
|
|
553
|
+
payload = JSON.parse(body.text || '{}');
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
|
|
557
|
+
}
|
|
558
|
+
if (typeof payload.proxy !== 'string')
|
|
559
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`proxy` must be a string' } });
|
|
560
|
+
// 校验直接用 proxy.ts 的同一份实现:非法地址当场报错,文件一个字节不动。
|
|
561
|
+
let server = '';
|
|
562
|
+
try {
|
|
563
|
+
const normalized = normalizeProxy(payload.proxy);
|
|
564
|
+
server = normalized === undefined ? '' : normalized.server;
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
return sendJson(res, 400, {
|
|
568
|
+
error: { code: 'INVALID_PROXY', message: String(error?.message ?? error) },
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
const written = await writeProxy(server);
|
|
573
|
+
sendJson(res, 200, {
|
|
574
|
+
proxy: written.proxy,
|
|
575
|
+
patch: written.patch,
|
|
576
|
+
// 代理是浏览器启动参数:插件在加载时读配置,所以下次启动 DSH 才生效。
|
|
577
|
+
appliedOn: 'restart',
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
sendJson(res, 500, {
|
|
582
|
+
error: { code: 'PATCH_WRITE', message: String(error?.message ?? error) },
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
}));
|
|
526
587
|
}
|
|
527
588
|
export default { name, inject, apply };
|
package/lib/snapshot-aria.d.ts
CHANGED
|
@@ -48,26 +48,6 @@ export interface AriaParseResult {
|
|
|
48
48
|
/** Content lines that could not be parsed — non-empty means format drift. */
|
|
49
49
|
readonly unparsed: string[];
|
|
50
50
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Parse Playwright's official ariaSnapshot YAML into a semantic node tree.
|
|
53
|
-
*
|
|
54
|
-
* The real format (verified against playwright-core 1.62, 2026-09-30) is:
|
|
55
|
-
*
|
|
56
|
-
* - generic [active] [ref=e1]: ← trailing ':' = the node has children
|
|
57
|
-
* - link "One" [ref=e6] [cursor=pointer]:
|
|
58
|
-
* - /url: /one ← href rides a deeper child line
|
|
59
|
-
* - text: Name ← text content uses 'text: <value>'
|
|
60
|
-
*
|
|
61
|
-
* The first version of this parser assumed a hand-rolled dialect
|
|
62
|
-
* (`- role "name" [flags] -> href`, nothing after the flags) that real output
|
|
63
|
-
* never produces. Every container line ends in ':', so that regex matched none
|
|
64
|
-
* of them: the whole hierarchy was dropped, links and their hrefs vanished, and
|
|
65
|
-
* `captureAriaSnapshot` still reported success. Unreadable lines are now
|
|
66
|
-
* collected in `unparsed` so the capture layer can refuse the tree and let the
|
|
67
|
-
* provider fall back to the legacy engine.
|
|
68
|
-
* @param yaml - the ariaSnapshot text.
|
|
69
|
-
* @returns parsed roots plus any unreadable content lines.
|
|
70
|
-
*/
|
|
71
51
|
export declare function parseAriaSnapshotWithStats(yaml: string): AriaParseResult;
|
|
72
52
|
/**
|
|
73
53
|
* Parse Playwright's ariaSnapshot YAML into a semantic node tree.
|
package/lib/snapshot-aria.js
CHANGED
|
@@ -54,12 +54,19 @@ export const ARIA_REF_SCRIPT = String.raw `
|
|
|
54
54
|
window.__dshAriaRefsV = 2
|
|
55
55
|
const INTERACTIVE = new Set(['button','link','checkbox','radio','menuitem','menuitemcheckbox','menuitemradio','tab','switch','combobox','listbox','option','textbox','searchbox','slider','spinbutton','row','gridcell'])
|
|
56
56
|
const TAGS = new Set(['a','button','select','textarea','option','summary'])
|
|
57
|
-
const INPUT_ROLE = { checkbox:'checkbox', radio:'radio', button:'button', submit:'button', reset:'button', image:'button', range:'slider', search:'searchbox', hidden:'null' }
|
|
57
|
+
const INPUT_ROLE = { checkbox:'checkbox', radio:'radio', button:'button', submit:'button', reset:'button', image:'button', range:'slider', number:'spinbutton', search:'searchbox', hidden:'null' }
|
|
58
58
|
const DOC_NONCE = String(Math.floor(Math.random() * 1e9)).padStart(9, '0')
|
|
59
59
|
let refSeq = 0
|
|
60
60
|
const roleOf = (el) => {
|
|
61
61
|
const explicit = el.getAttribute && el.getAttribute('role')
|
|
62
|
-
|
|
62
|
+
const explicitRole = explicit && explicit.trim() ? explicit.trim().toLowerCase() : ''
|
|
63
|
+
// aria: an explicit role="presentation"/"none" is IGNORED on a focusable
|
|
64
|
+
// element — the tree still exposes its native role. jQuery-UI tabs do exactly
|
|
65
|
+
// this: <a href="#tabs-1" role="presentation"> shows up as a link in the aria
|
|
66
|
+
// tree (measured 2026-11-12 on MiniWoB click-tab).
|
|
67
|
+
const presentational = explicitRole === 'presentation' || explicitRole === 'none'
|
|
68
|
+
const focusableLink = el.tagName.toLowerCase() === 'a' && el.hasAttribute('href')
|
|
69
|
+
if (explicitRole !== '' && !(presentational && focusableLink)) return explicitRole
|
|
63
70
|
const tag = el.tagName.toLowerCase()
|
|
64
71
|
if (tag === 'input') return INPUT_ROLE[(el.getAttribute('type') || 'text').toLowerCase()] || 'textbox'
|
|
65
72
|
if (tag === 'a') return 'link'
|
|
@@ -68,10 +75,34 @@ export const ARIA_REF_SCRIPT = String.raw `
|
|
|
68
75
|
if (tag === 'textarea') return 'textarea'
|
|
69
76
|
if (tag === 'option') return 'option'
|
|
70
77
|
if (tag === 'summary') return 'summary'
|
|
78
|
+
// Implicit roles for the elements the accessibility tree annotates anyway.
|
|
79
|
+
// Without these the DOM side said "generic" for a <tr> while the tree said
|
|
80
|
+
// "row" (an actionable role), so the minted ref list and the tree disagreed
|
|
81
|
+
// and the whole page fell back (measured 2026-11-12: 6 of the 18 fallback
|
|
82
|
+
// MiniWoB pages were table/row tasks).
|
|
83
|
+
if (tag === 'tr') return 'row'
|
|
84
|
+
if (tag === 'td') return 'cell'
|
|
85
|
+
if (tag === 'th') return 'columnheader'
|
|
86
|
+
if (tag === 'li') return 'listitem'
|
|
87
|
+
if (tag === 'table') return 'table'
|
|
88
|
+
if (tag === 'ul' || tag === 'ol') return 'list'
|
|
89
|
+
if (tag === 'p') return 'paragraph'
|
|
90
|
+
if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') return 'heading'
|
|
91
|
+
if (tag === 'img') return 'img'
|
|
71
92
|
return 'generic'
|
|
72
93
|
}
|
|
73
94
|
const isActionable = (el) => {
|
|
74
95
|
if (!(el instanceof Element)) return false
|
|
96
|
+
// An explicit role of presentation / none tells the accessibility tree to drop
|
|
97
|
+
// the element's own semantics — EXCEPT on a focusable link, where the spec says
|
|
98
|
+
// the role is ignored (see roleOf). Skipping those here would mint one ref too
|
|
99
|
+
// few and put the lists out of step (measured 2026-11-12: the MiniWoB tab strips
|
|
100
|
+
// wrap each tab in <a href role="presentation">; 4 pages fell back).
|
|
101
|
+
const explicit = el.getAttribute('role')
|
|
102
|
+
const explicitRole = explicit && explicit.trim() ? explicit.trim().toLowerCase() : ''
|
|
103
|
+
const presentational = explicitRole === 'presentation' || explicitRole === 'none'
|
|
104
|
+
const focusableLink = el.tagName.toLowerCase() === 'a' && el.hasAttribute('href')
|
|
105
|
+
if (presentational && !focusableLink) return false
|
|
75
106
|
const tag = el.tagName.toLowerCase()
|
|
76
107
|
if (TAGS.has(tag)) return true
|
|
77
108
|
const role = roleOf(el)
|
|
@@ -89,6 +120,17 @@ export const ARIA_REF_SCRIPT = String.raw `
|
|
|
89
120
|
if (el.getAttribute('hidden') !== null || el.getAttribute('aria-hidden') === 'true') return false
|
|
90
121
|
const style = getComputedStyle(el)
|
|
91
122
|
if (style.display === 'none' || style.visibility === 'hidden') return false
|
|
123
|
+
// <option> has no layout box of its own inside a closed <select>, yet the
|
|
124
|
+
// accessibility tree lists it — so it must keep its slot here, or the two
|
|
125
|
+
// lists drift (measured 2026-11-12: choose-list minted 2 refs while the tree
|
|
126
|
+
// had 7 actionable nodes). Its <select> is the visibility signal: options of
|
|
127
|
+
// a display:none select are absent from the tree too.
|
|
128
|
+
if (el.tagName.toLowerCase() === 'option') {
|
|
129
|
+
const select = el.closest('select')
|
|
130
|
+
if (select === null) return false
|
|
131
|
+
const selectStyle = getComputedStyle(select)
|
|
132
|
+
return selectStyle.display !== 'none' && selectStyle.visibility !== 'hidden'
|
|
133
|
+
}
|
|
92
134
|
// A 'display: contents' box has no rect of its own but lays its children
|
|
93
135
|
// out normally — it belongs to the tree, so it keeps its slot.
|
|
94
136
|
if (style.display === 'contents') return true
|
|
@@ -177,6 +219,29 @@ function readQuoted(text) {
|
|
|
177
219
|
* @param yaml - the ariaSnapshot text.
|
|
178
220
|
* @returns parsed roots plus any unreadable content lines.
|
|
179
221
|
*/
|
|
222
|
+
/**
|
|
223
|
+
* Playwright quotes a whole node line when the name itself contains a double quote:
|
|
224
|
+
* - 'tab "Section #1" [ref=e5]'
|
|
225
|
+
* - 'link "Tab #1" [ref=e7] [cursor=pointer]':
|
|
226
|
+
* Strip the outer single quotes (and the optional trailing ':' that marks children),
|
|
227
|
+
* then parse the inner text like any other line. Measured 2026-11-12: 8 of 130
|
|
228
|
+
* MiniWoB pages fell back to the legacy engine purely over this shape (tab strips,
|
|
229
|
+
* collapsible sections), while the pages themselves read perfectly.
|
|
230
|
+
*/
|
|
231
|
+
function unwrapQuotedNodeBody(body) {
|
|
232
|
+
if (body.length < 3 || body[0] !== "'")
|
|
233
|
+
return body;
|
|
234
|
+
const close = body.lastIndexOf("'");
|
|
235
|
+
if (close <= 0)
|
|
236
|
+
return body;
|
|
237
|
+
const inside = body.slice(1, close);
|
|
238
|
+
const after = body.slice(close + 1).trim();
|
|
239
|
+
// Anything else after the closing quote is not the shape we know; leave it to
|
|
240
|
+
// the normal path so an unknown line is reported rather than silently accepted.
|
|
241
|
+
if (after !== '' && after !== ':')
|
|
242
|
+
return body;
|
|
243
|
+
return inside;
|
|
244
|
+
}
|
|
180
245
|
export function parseAriaSnapshotWithStats(yaml) {
|
|
181
246
|
const roots = [];
|
|
182
247
|
const stack = [];
|
|
@@ -206,7 +271,7 @@ export function parseAriaSnapshotWithStats(yaml) {
|
|
|
206
271
|
continue;
|
|
207
272
|
}
|
|
208
273
|
const indent = Math.floor((m[1] ?? '').length / 2);
|
|
209
|
-
const body = m[2] ?? '';
|
|
274
|
+
const body = unwrapQuotedNodeBody(m[2] ?? '');
|
|
210
275
|
// Attribute child lines describe the node declared above: '- /url: /one',
|
|
211
276
|
// '- /placeholder: Search'. Playwright adds facts over time, and an attribute
|
|
212
277
|
// line carries no tree structure — so an unknown one is safe to ignore.
|
package/lib/swarm-intro.js
CHANGED
|
@@ -729,7 +729,7 @@ export async function showWelcome(page, config) {
|
|
|
729
729
|
return; // 页面还没准备好:留一张空白页也不影响任何事
|
|
730
730
|
}
|
|
731
731
|
const userChoice = runtimeState.getSwarm();
|
|
732
|
-
const enabled = userChoice ?? config?.swarm?.enabled ??
|
|
732
|
+
const enabled = userChoice ?? config?.swarm?.enabled ?? false;
|
|
733
733
|
if (!enabled)
|
|
734
734
|
return;
|
|
735
735
|
const tuning = runtimeState.getSwarmTuning();
|
|
@@ -756,7 +756,7 @@ export function willPlaySwarm(targetUrl, config, headless) {
|
|
|
756
756
|
if (host === '')
|
|
757
757
|
return false;
|
|
758
758
|
const userChoice = runtimeState.getSwarm();
|
|
759
|
-
const enabled = userChoice ?? config?.swarm?.enabled ??
|
|
759
|
+
const enabled = userChoice ?? config?.swarm?.enabled ?? false;
|
|
760
760
|
if (!enabled)
|
|
761
761
|
return false;
|
|
762
762
|
const tuning = runtimeState.getSwarmTuning();
|
|
@@ -783,9 +783,9 @@ export function maybePlaySwarm(page, config, headless) {
|
|
|
783
783
|
const host = new URL(url).hostname;
|
|
784
784
|
if (host === '')
|
|
785
785
|
return;
|
|
786
|
-
//
|
|
786
|
+
// 用户的开关优先;没表态就用配置里的默认值(出厂是关,与 config.ts 的 schema 一致)。
|
|
787
787
|
const userChoice = runtimeState.getSwarm();
|
|
788
|
-
const enabled = userChoice ?? config?.swarm?.enabled ??
|
|
788
|
+
const enabled = userChoice ?? config?.swarm?.enabled ?? false;
|
|
789
789
|
if (!enabled) {
|
|
790
790
|
// 关着也要把可能已提前启动的引擎撤掉:标记可能是上一次导航残留的,
|
|
791
791
|
// 不清就会白屏卡住(2026-11-09 复核 find B)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Browser automation for DeepSeek Harness, one plugin: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised; native dialogs are parked instead of confirmed, CAPTCHA pages are flagged with a note that says stop and hand the page back, password / one-time-code / card fields are refused by browser_fill, and the Settings card (enable/disable, page-reading mode, capability gates) now lives inside this package.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -65,7 +65,9 @@
|
|
|
65
65
|
"format": "node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
66
66
|
"format:check": "node node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
67
67
|
"prepublishOnly": "npm run build && npm run typecheck && npm test && npm run swarm:check && npm run verify && npm run preflight",
|
|
68
|
-
"preflight": "node scripts/preflight.mjs ."
|
|
68
|
+
"preflight": "node scripts/preflight.mjs .",
|
|
69
|
+
"test:toggle": "node --test tests/cross-repo/*.test.ts",
|
|
70
|
+
"coverage": "node scripts/coverage.mjs"
|
|
69
71
|
},
|
|
70
72
|
"dependencies": {
|
|
71
73
|
"@deepseek-ai/schemastery": "^3.18.4",
|