dsh-browser-playwright-codex 0.5.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/CHANGELOG.md +486 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/SECURITY.md +114 -0
- package/cordis.patch.yml +66 -0
- package/lib/cdp-policy.d.ts +69 -0
- package/lib/cdp-policy.js +105 -0
- package/lib/compat.d.ts +37 -0
- package/lib/compat.js +31 -0
- package/lib/contract.d.ts +74 -0
- package/lib/contract.js +117 -0
- package/lib/errors.d.ts +25 -0
- package/lib/errors.js +21 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +12 -0
- package/lib/injected.d.ts +35 -0
- package/lib/injected.js +350 -0
- package/lib/playwright.d.ts +647 -0
- package/lib/playwright.js +2515 -0
- package/lib/runtime-state.d.ts +45 -0
- package/lib/runtime-state.js +181 -0
- package/lib/service.d.ts +56 -0
- package/lib/service.js +96 -0
- package/lib/snapshot-aria.d.ts +121 -0
- package/lib/snapshot-aria.js +485 -0
- package/lib/snapshot-render.d.ts +28 -0
- package/lib/snapshot-render.js +100 -0
- package/lib/tool.d.ts +77 -0
- package/lib/tool.js +907 -0
- package/lib/types.d.ts +242 -0
- package/lib/types.js +5 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +108 -0
- package/scripts/analysis/hl-compare.mjs +149 -0
- package/scripts/analysis/live-handoff-probe.mjs +97 -0
- package/scripts/analysis/needs-human-probe.mjs +83 -0
- package/scripts/analysis/repro-minimize.mjs +112 -0
- package/scripts/autocommit.ps1 +23 -0
- package/scripts/check-doc-links.py +112 -0
- package/scripts/doctor.mjs +144 -0
- package/scripts/dsh-browser-verify.mjs +475 -0
- package/scripts/measure-prompt-cost.mjs +57 -0
- package/scripts/prompt-cost.mjs +73 -0
- package/scripts/shipped-gates.mjs +43 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-browser-verify — 一条命令跑完 dsh-browser-playwright-codex 的快照取名/折叠与
|
|
4
|
+
* click/hover 两种定位模式的回归。改任何取名规则或工具说明后必须跑通它。
|
|
5
|
+
*
|
|
6
|
+
* 跑法(包内即自包含,无需 _deps):
|
|
7
|
+
* node scripts/dsh-browser-verify.mjs [--live]
|
|
8
|
+
* --live 额外在 news.baidu.com 上采样真实页面的统计(会联网)
|
|
9
|
+
* 退出码 0 = 全绿;1 = 有失败项。
|
|
10
|
+
*
|
|
11
|
+
* 期望值不写死在这里:工具面从 src/contract.ts 推导,所以新增或调整工具时
|
|
12
|
+
* 只有 contract.ts 需要改,这份脚本不会因为"数字过期"而假报警。
|
|
13
|
+
*
|
|
14
|
+
* 读 lib/,但**必须先 build**:npm run verify 会先跑 npm run build,直接
|
|
15
|
+
* node scripts/... 跑则会撞上最后一组检查(lib/ 不旧于 src/)。
|
|
16
|
+
*
|
|
17
|
+
* 为什么不像测试那样用 tsx 直读 src/:tsx(esbuild keepNames)会把传给
|
|
18
|
+
* page.evaluate 的函数包成 __name(...),而页面里没有这个符号 ——
|
|
19
|
+
* "ReferenceError: __name is not defined",悬停/文字定位这类页面侧函数会全部
|
|
20
|
+
* 失效。lib/ 是编译产物,没有这层包裹,也正好是宿主真正加载的东西。
|
|
21
|
+
*/
|
|
22
|
+
import http from 'node:http'
|
|
23
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
24
|
+
import os from 'node:os'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { Config as ConfigSchema, PlaywrightProvider } from '../lib/playwright.js'
|
|
27
|
+
import { renderSnapshot } from '../lib/snapshot-render.js'
|
|
28
|
+
import { getEffectiveEngine, setSnapshotEngine } from '../lib/runtime-state.js'
|
|
29
|
+
|
|
30
|
+
// 验收必须只看代码,不看这台机器上用户的选择。
|
|
31
|
+
// 设置页的「页面识别方式」存在 ~/.dsh/dsh-browser-playwright.state.json,运行时状态层
|
|
32
|
+
// 允许用 DSH_BROWSER_STATE_FILE 把它指向别处(src/runtime-state.ts 里为测试留的开关)。
|
|
33
|
+
// 指向一个不存在的文件 = engine 回到配置默认值 legacy,本脚本的期望值就是按它写的。
|
|
34
|
+
// 不隔离的话:用户把开关拨到「智能模式」,这份 57 项验收就会假失败(2026-10-01 实测)。
|
|
35
|
+
// 注意 [1]–[7] 因此都跑在兼容模式下;智能模式由 [8] 显式切过去跑(隔离状态文件里切,
|
|
36
|
+
// 不碰用户的真实选择)。
|
|
37
|
+
process.env.DSH_BROWSER_STATE_FILE = path.join(os.tmpdir(), 'dsh-browser-verify-state-isolated.json')
|
|
38
|
+
|
|
39
|
+
let pass = 0
|
|
40
|
+
const failures = []
|
|
41
|
+
function check(name, ok, detail) {
|
|
42
|
+
if (ok) {
|
|
43
|
+
pass += 1
|
|
44
|
+
console.log(' ok ' + name)
|
|
45
|
+
} else {
|
|
46
|
+
failures.push(name)
|
|
47
|
+
console.log(' FAIL ' + name + (detail ? ' :: ' + detail : ''))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function has(tree, fragment) {
|
|
51
|
+
return tree.indexOf(fragment) !== -1
|
|
52
|
+
}
|
|
53
|
+
function lineAfter(tree, fragment) {
|
|
54
|
+
const lines = tree.split('\n')
|
|
55
|
+
const i = lines.findIndex((l) => l.includes(fragment))
|
|
56
|
+
return i >= 0 ? lines[i + 1] || '' : '(not found)'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HTML = `<!doctype html><html><head><meta charset="utf-8"><title>v</title></head><body>
|
|
60
|
+
<h1>verify</h1>
|
|
61
|
+
<a href="/1"><span>折叠链接</span></a>
|
|
62
|
+
<a href="/2"><div><span>多层折叠</span></div></a>
|
|
63
|
+
<a href="/3"><h3>标题链接</h3></a>
|
|
64
|
+
<button value="v">按钮文字</button>
|
|
65
|
+
<input type="button" value="输入按钮">
|
|
66
|
+
<select><option>甲</option><option selected>乙</option></select>
|
|
67
|
+
<a href="/4"><img src="x.png" alt="图标链接"></a>
|
|
68
|
+
<a href="/5"><img src="x.png" alt="图标"><span>图文链接</span></a>
|
|
69
|
+
<a href="/6">尾<span>前</span></a>
|
|
70
|
+
<button><span>多段</span><span>按钮</span></button>
|
|
71
|
+
<a href="/7"><span aria-hidden="true">装饰</span><span>真名</span></a>
|
|
72
|
+
<table><tr><td>单元格</td></tr></table>
|
|
73
|
+
<button id="reveal">显示详情</button>
|
|
74
|
+
<button id="closebtn" aria-label="关闭弹层"><span>×</span></button>
|
|
75
|
+
<div id="detail" style="display:none">详情 <a href="/d">详情链接</a></div>
|
|
76
|
+
<span id="settings">设置</span>
|
|
77
|
+
<div id="panel" style="display:none"><a href="/a">菜单甲</a><a href="/b">菜单乙</a></div>
|
|
78
|
+
<div id="contentsWrap" style="display:contents"><a href="/dc">内容链接</a></div>
|
|
79
|
+
<span class="dup">重复项</span><span class="dup">重复项</span>
|
|
80
|
+
<button id="del">删除记录</button><div id="row">记录存在</div>
|
|
81
|
+
<button id="ask">弹提示</button>
|
|
82
|
+
<script>
|
|
83
|
+
document.getElementById('settings').addEventListener('mouseenter', () => { document.getElementById('panel').style.display = 'block'; });
|
|
84
|
+
document.getElementById('settings').addEventListener('mouseleave', () => { document.getElementById('panel').style.display = 'none'; });
|
|
85
|
+
document.getElementById('reveal').addEventListener('click', () => {
|
|
86
|
+
document.getElementById('detail').style.display = 'block';
|
|
87
|
+
document.getElementById('reveal').textContent = '详情已显示';
|
|
88
|
+
});
|
|
89
|
+
document.getElementById('closebtn').addEventListener('click', () => { document.title = 'closed'; });
|
|
90
|
+
document.getElementById('del').addEventListener('click', () => {
|
|
91
|
+
if (window.confirm('确定删除这条记录吗?')) { document.getElementById('row').style.display = 'none'; }
|
|
92
|
+
});
|
|
93
|
+
document.getElementById('ask').addEventListener('click', () => { window.alert('操作成功'); });
|
|
94
|
+
</script></body></html>`
|
|
95
|
+
|
|
96
|
+
// [8] 用的页面:故意在输入框与按钮之前排两个「可交互但不可见」的元素。
|
|
97
|
+
// 这正是旧版智能模式会整体错位的形态:DOM 侧按文档顺序给每一个可交互元素编号
|
|
98
|
+
// (含隐藏的),aria 树里只有可见节点,于是后面每个 ref 都错位一格 ——
|
|
99
|
+
// 实测 2026-10-02 在 dsh-plugin.market 上就是这样把「输入框」指成了一个 0×0 的隐藏链接。
|
|
100
|
+
const ARIA_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>v</title></head><body>
|
|
101
|
+
<h1>aria</h1>
|
|
102
|
+
<button id="evil" style="display:none" onclick="document.title='HIDDEN-WRONG'">隐藏按钮</button>
|
|
103
|
+
<a href="#x" hidden>隐藏链接</a>
|
|
104
|
+
<span id="settings2">设置</span>
|
|
105
|
+
<label for="field">昵称</label>
|
|
106
|
+
<input id="field" placeholder="在这里输入">
|
|
107
|
+
<button id="ok">ARIA 提交</button>
|
|
108
|
+
<p id="res">(未点)</p>
|
|
109
|
+
<script>
|
|
110
|
+
document.getElementById('ok').addEventListener('click', () => {
|
|
111
|
+
document.getElementById('res').textContent = 'ARIA-CLICK-OK';
|
|
112
|
+
});
|
|
113
|
+
</script></body></html>`
|
|
114
|
+
|
|
115
|
+
const server = http.createServer((req, res) => {
|
|
116
|
+
res.setHeader('content-type', 'text/html; charset=utf-8')
|
|
117
|
+
res.end(req.url && req.url.startsWith('/aria') ? ARIA_HTML : HTML)
|
|
118
|
+
})
|
|
119
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
120
|
+
const URL_ = 'http://127.0.0.1:' + server.address().port + '/'
|
|
121
|
+
const ARIA_URL_ = URL_ + 'aria'
|
|
122
|
+
const provider = new PlaywrightProvider(
|
|
123
|
+
ConfigSchema({ launch: { headless: true, persistent: false, navigationTimeoutMs: 20000 }, idleTimeoutMs: 0 }),
|
|
124
|
+
)
|
|
125
|
+
const session = await provider.acquire('verify')
|
|
126
|
+
|
|
127
|
+
console.log('[1] 快照取名与折叠')
|
|
128
|
+
const s0 = await session.navigate(URL_, 'load')
|
|
129
|
+
const tree = renderSnapshot(s0)
|
|
130
|
+
check(
|
|
131
|
+
'折叠直接子节点 (a>span)',
|
|
132
|
+
has(tree, 'link "折叠链接" [ref=') && !lineAfter(tree, 'link "折叠链接"').includes('折叠链接'),
|
|
133
|
+
)
|
|
134
|
+
check('折叠多层包裹 (a>div>span)', has(tree, 'link "多层折叠" [ref=') && !has(tree, 'generic "多层折叠"'))
|
|
135
|
+
check('标题链接折叠 (有意取舍)', has(tree, 'link "标题链接" [ref=') && !has(tree, 'heading "标题链接"'))
|
|
136
|
+
check('<button> 取文字不取 value', has(tree, 'button "按钮文字" [ref=') && !has(tree, 'button "v" [ref='))
|
|
137
|
+
check('<input type=button> 取 value', has(tree, 'button "输入按钮" [ref='))
|
|
138
|
+
check('<select> 显示当前选项', has(tree, 'combobox "乙" [ref=') && has(tree, 'option "甲" [ref='))
|
|
139
|
+
check('纯图标链接折叠 img', has(tree, 'link "图标链接" [ref=') && !has(tree, 'img "图标链接"'))
|
|
140
|
+
check('图文链接保留 img', has(tree, 'link "图文链接" [ref=') && has(tree, 'img "图标"'))
|
|
141
|
+
check('自身+子孙文字合并', has(tree, 'link "尾 前" [ref='))
|
|
142
|
+
check('多段文字折叠干净', has(tree, 'button "多段 按钮" [ref=') && !has(tree, 'generic "多段"'))
|
|
143
|
+
check('aria-hidden 子文字被跳过', has(tree, 'link "真名" [ref=') && !has(tree, '装饰'))
|
|
144
|
+
check('表格行不从单元格取名', has(tree, 'row [ref=') && has(tree, 'cell "单元格"'))
|
|
145
|
+
check('display:contents 容器继续下钻(0×0 不得剪掉子树)', has(tree, 'link "内容链接" [ref='))
|
|
146
|
+
|
|
147
|
+
console.log('[2] 悬停:ref 模式 / text 模式 / 错误路径')
|
|
148
|
+
const settingsRef = (function find(ns) {
|
|
149
|
+
for (const n of ns) {
|
|
150
|
+
if (n.ref && n.name === '设置') return n.ref
|
|
151
|
+
const h = find(n.children || [])
|
|
152
|
+
if (h) return h
|
|
153
|
+
}
|
|
154
|
+
})(s0.nodes)
|
|
155
|
+
check('「设置」确实没有 ref', settingsRef === undefined)
|
|
156
|
+
const h1 = await session.hoverText('设置')
|
|
157
|
+
check('text 模式悬停成功且指出目标', has(h1.landingNote || '', '设置') && /\+\d+/.test(h1.landingNote || ''))
|
|
158
|
+
function treeHas(nodes, name) {
|
|
159
|
+
return (nodes || []).some((n) => n.name === name || treeHas(n.children, name))
|
|
160
|
+
}
|
|
161
|
+
check('悬停揭示出菜单项', treeHas(h1.nodes, '菜单甲'))
|
|
162
|
+
const h2 = await session.hoverText('重复项')
|
|
163
|
+
check('同名时提示 FIRST of N', has(h2.landingNote || '', 'FIRST of 2'))
|
|
164
|
+
let hoverErr = ''
|
|
165
|
+
try {
|
|
166
|
+
await session.hoverText('不存在的标签')
|
|
167
|
+
} catch (e) {
|
|
168
|
+
hoverErr = e.code
|
|
169
|
+
}
|
|
170
|
+
check('找不到时报 HOVER_TARGET_NOT_FOUND', hoverErr === 'HOVER_TARGET_NOT_FOUND')
|
|
171
|
+
|
|
172
|
+
console.log('[3] 点击:ref 模式 / text 模式 / 错误路径')
|
|
173
|
+
const revealRef = (function find(ns) {
|
|
174
|
+
for (const n of ns) {
|
|
175
|
+
if (n.ref && n.name === '显示详情') return n.ref
|
|
176
|
+
const h = find(n.children || [])
|
|
177
|
+
if (h) return h
|
|
178
|
+
}
|
|
179
|
+
})(s0.nodes)
|
|
180
|
+
const c1 = await session.clickText('显示详情')
|
|
181
|
+
check('text 模式点击让页面真的变', (c1.totalRefs || 0) > (s0.totalRefs || 0) && has(renderSnapshot(c1), '详情已显示'))
|
|
182
|
+
const c2 = await session.click(revealRef)
|
|
183
|
+
check('ref 模式仍可用', has(renderSnapshot(c2), '[ref='))
|
|
184
|
+
const c3 = await session.clickText('重复项')
|
|
185
|
+
check('同名点击提示 FIRST of 2', has(c3.landingNote || '', 'FIRST of 2'))
|
|
186
|
+
let clickErr = ''
|
|
187
|
+
try {
|
|
188
|
+
await session.clickText('不存在的按钮')
|
|
189
|
+
} catch (e) {
|
|
190
|
+
clickErr = e.code
|
|
191
|
+
}
|
|
192
|
+
check('找不到时报 CLICK_TARGET_NOT_FOUND', clickErr === 'CLICK_TARGET_NOT_FOUND')
|
|
193
|
+
let ariaNote = ''
|
|
194
|
+
let ariaHit = false
|
|
195
|
+
try {
|
|
196
|
+
ariaNote = (await session.clickText('关闭弹层')).landingNote || ''
|
|
197
|
+
ariaHit = true
|
|
198
|
+
} catch (e) {
|
|
199
|
+
ariaNote = 'ERR ' + String(e.message || e)
|
|
200
|
+
}
|
|
201
|
+
const ariaEffect = await session.evaluate('document.title')
|
|
202
|
+
check(
|
|
203
|
+
'text 模式能按 aria-label 命中(且真的点到了)',
|
|
204
|
+
ariaHit && ariaEffect === 'closed',
|
|
205
|
+
ariaNote.slice(0, 90) + ' | title=' + ariaEffect,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const leftover = await session.evaluate("document.querySelectorAll('[data-dsh-label-target]').length")
|
|
209
|
+
check('页面无 label 残留', leftover === 0)
|
|
210
|
+
|
|
211
|
+
console.log('[4] 原生弹窗:挂起 + 独立工具回应')
|
|
212
|
+
const findRef = (ns, name) => {
|
|
213
|
+
for (const n of ns) {
|
|
214
|
+
if (n.ref && n.name === name) return n.ref
|
|
215
|
+
const h = findRef(n.children || [], name)
|
|
216
|
+
if (h) return h
|
|
217
|
+
}
|
|
218
|
+
return undefined
|
|
219
|
+
}
|
|
220
|
+
const rowDisplay = () => session.evaluate("getComputedStyle(document.getElementById('row')).display")
|
|
221
|
+
|
|
222
|
+
// a) 没有待处理弹窗时,回应必须被拒
|
|
223
|
+
let noDlgErr = ''
|
|
224
|
+
try {
|
|
225
|
+
await session.resolveDialog(true)
|
|
226
|
+
} catch (e) {
|
|
227
|
+
noDlgErr = e.code || String(e.message).slice(0, 40)
|
|
228
|
+
}
|
|
229
|
+
check('没有待处理弹窗时拒绝回应(NO_DIALOG)', noDlgErr === 'NO_DIALOG', noDlgErr)
|
|
230
|
+
|
|
231
|
+
// b) 触发弹窗:动作不卡死,弹窗如实上报为待处理,且还没人决定
|
|
232
|
+
const sDel = await session.snapshot({})
|
|
233
|
+
const delRef = findRef(sDel.nodes, '删除记录')
|
|
234
|
+
const t0 = Date.now()
|
|
235
|
+
const pendSnap = await session.click(delRef)
|
|
236
|
+
const elapsed = Date.now() - t0
|
|
237
|
+
const pendNote = pendSnap.dialogNote || ''
|
|
238
|
+
check('触发弹窗的动作不卡死(<3s)', elapsed < 3000, elapsed + 'ms')
|
|
239
|
+
check(
|
|
240
|
+
'弹窗如实上报为待处理',
|
|
241
|
+
/confirm/i.test(pendNote) && /PENDING/i.test(pendNote) && pendNote.includes('确定删除这条记录吗'),
|
|
242
|
+
pendNote.slice(0, 120),
|
|
243
|
+
)
|
|
244
|
+
// 注意:弹窗挂起期间页面被阻塞,这里**不能**去碰页面(rowDisplay 会卡死)。
|
|
245
|
+
// "还没人决定"改在取消之后验证:记录仍在即证明期间没有发生任何接受。
|
|
246
|
+
|
|
247
|
+
// c) 挂起期间取快照必须立刻返回(不能去碰被阻塞的页面)
|
|
248
|
+
const tBlock = Date.now()
|
|
249
|
+
const blockedSnap = await session.snapshot({})
|
|
250
|
+
const t1ms = Date.now() - tBlock
|
|
251
|
+
check('挂起期间快照立刻返回(<2s)', t1ms < 2000, t1ms + 'ms')
|
|
252
|
+
check(
|
|
253
|
+
'挂起期间的快照也提示待处理',
|
|
254
|
+
/PENDING/i.test(blockedSnap.dialogNote || ''),
|
|
255
|
+
(blockedSnap.dialogNote || '').slice(0, 90),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
// d) 取消 → 页面解锁,动作未执行
|
|
259
|
+
const disSnap = await session.resolveDialog(false)
|
|
260
|
+
check('取消后写明 DISMISSED', /DISMISSED/.test(disSnap.landingNote || ''), (disSnap.landingNote || '').slice(0, 110))
|
|
261
|
+
check('取消后记录还在', (await rowDisplay()) !== 'none')
|
|
262
|
+
const afterDismiss = await session.snapshot({})
|
|
263
|
+
check('取消后页面解锁(快照可用)', (afterDismiss.totalRefs || 0) > 0)
|
|
264
|
+
|
|
265
|
+
// F1~F4:红队发现的三类「卡死 / 绕过守门」,逐条固化成用例
|
|
266
|
+
const withTimeout = (p, ms, tag) =>
|
|
267
|
+
Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('TIMEOUT ' + tag)), ms))])
|
|
268
|
+
let ct1 = null
|
|
269
|
+
try {
|
|
270
|
+
ct1 = await withTimeout(session.clickText('删除记录'), 6000, 'clickText')
|
|
271
|
+
} catch (e) {
|
|
272
|
+
ct1 = { dialogNote: 'ERR ' + String(e.message || e).slice(0, 40) }
|
|
273
|
+
}
|
|
274
|
+
check('F1 clickText 自己触发弹窗时不卡死', !/TIMEOUT/.test(ct1.dialogNote || ''), (ct1.dialogNote || '').slice(0, 90))
|
|
275
|
+
await session.resolveDialog(false).catch(() => {})
|
|
276
|
+
|
|
277
|
+
const sF = await session.snapshot({})
|
|
278
|
+
await session.click(findRef(sF.nodes, '删除记录'))
|
|
279
|
+
let swRes = ''
|
|
280
|
+
try {
|
|
281
|
+
await withTimeout(session.switchTab(0), 5000, 'switchTab')
|
|
282
|
+
swRes = 'no-throw'
|
|
283
|
+
} catch (e) {
|
|
284
|
+
swRes = e.code || String(e.message).slice(0, 24)
|
|
285
|
+
}
|
|
286
|
+
check('F2 挂起时 switchTab 被拒而不是卡死', swRes === 'DIALOG_PENDING', swRes)
|
|
287
|
+
let otRes = ''
|
|
288
|
+
try {
|
|
289
|
+
await withTimeout(session.openTab(URL_, 'load'), 5000, 'openTab')
|
|
290
|
+
otRes = 'no-throw'
|
|
291
|
+
} catch (e) {
|
|
292
|
+
otRes = e.code || String(e.message).slice(0, 24)
|
|
293
|
+
}
|
|
294
|
+
check('F3 挂起时 openTab 被拒而不是卡死', otRes === 'DIALOG_PENDING', otRes)
|
|
295
|
+
let ctbRes = ''
|
|
296
|
+
try {
|
|
297
|
+
await withTimeout(session.closeTab(0), 5000, 'closeTab')
|
|
298
|
+
ctbRes = 'no-throw'
|
|
299
|
+
} catch (e) {
|
|
300
|
+
ctbRes = e.code || String(e.message).slice(0, 24)
|
|
301
|
+
}
|
|
302
|
+
check('F4 挂起时 closeTab 被拒(不得隐式取消)', ctbRes === 'DIALOG_PENDING', ctbRes)
|
|
303
|
+
check('F4 之后弹窗仍然挂起(没被偷偷取消)', /PENDING/.test((await session.snapshot({})).dialogNote || ''))
|
|
304
|
+
await session.resolveDialog(false)
|
|
305
|
+
|
|
306
|
+
// e) 再弹一次 → 接受 → 动作真的执行
|
|
307
|
+
const sDel2 = await session.snapshot({})
|
|
308
|
+
await session.click(findRef(sDel2.nodes, '删除记录'))
|
|
309
|
+
const accSnap = await session.resolveDialog(true)
|
|
310
|
+
check('接受后写明 ACCEPTED', /ACCEPTED/.test(accSnap.landingNote || ''), (accSnap.landingNote || '').slice(0, 110))
|
|
311
|
+
check('接受后动作真的执行了(记录被删)', (await rowDisplay()) === 'none')
|
|
312
|
+
|
|
313
|
+
// f) alert 同样挂起、同样要独立回应
|
|
314
|
+
const sAsk = await session.snapshot({})
|
|
315
|
+
await session.click(findRef(sAsk.nodes, '弹提示'))
|
|
316
|
+
const askSnap = await session.snapshot({})
|
|
317
|
+
check(
|
|
318
|
+
'alert 同样挂起上报',
|
|
319
|
+
/alert/i.test(askSnap.dialogNote || '') && /PENDING/i.test(askSnap.dialogNote || ''),
|
|
320
|
+
(askSnap.dialogNote || '').slice(0, 90),
|
|
321
|
+
)
|
|
322
|
+
await session.resolveDialog(true)
|
|
323
|
+
check('alert 回应后页面解锁', ((await session.snapshot({})).totalRefs || 0) > 0)
|
|
324
|
+
|
|
325
|
+
// F7:畸形信号不得遗弃 promise(旧实现会 unhandledRejection 崩宿主 / 卡死)
|
|
326
|
+
const sSig = await session.snapshot({})
|
|
327
|
+
let sig7 = 'ok'
|
|
328
|
+
try {
|
|
329
|
+
await withTimeout(session.click(findRef(sSig.nodes, '删除记录'), true), 6000, 'bogusSignal')
|
|
330
|
+
} catch (e) {
|
|
331
|
+
sig7 = 'ERR ' + String(e.message).slice(0, 30)
|
|
332
|
+
}
|
|
333
|
+
check('F7 畸形信号不卡死也不崩', sig7 === 'ok', sig7)
|
|
334
|
+
await session.resolveDialog(false).catch(() => {})
|
|
335
|
+
|
|
336
|
+
// F6:persistent 模式下多个会话共享同一页,别人的挂起弹窗不得被接管
|
|
337
|
+
const fakePage = {}
|
|
338
|
+
provider.pendingDialogs.set(fakePage, { dialog: null, type: 'confirm', message: 'x', owner: 'owner-A' })
|
|
339
|
+
check('F6 别的 owner 拿不到别人的挂起弹窗', provider.takePendingDialog(fakePage, 'owner-B') === undefined)
|
|
340
|
+
check('F6 本人 owner 可以拿到', (provider.takePendingDialog(fakePage, 'owner-A') || {}).owner === 'owner-A')
|
|
341
|
+
provider.pendingDialogs.delete(fakePage)
|
|
342
|
+
|
|
343
|
+
console.log('[5] 工具面:新增 browser_dialog,删除 acceptDialog')
|
|
344
|
+
const toolMod = await import('../lib/tool.js')
|
|
345
|
+
const contractMod = await import('../lib/contract.js')
|
|
346
|
+
const reg = []
|
|
347
|
+
const toolConfig = toolMod.Config({})
|
|
348
|
+
// The plugin checks its own surface against contract.ts at load time, so the
|
|
349
|
+
// fake registry has to answer schemas() the way the real one does.
|
|
350
|
+
toolMod.apply(
|
|
351
|
+
{
|
|
352
|
+
browser: { acquire: async () => ({}) },
|
|
353
|
+
get: () => undefined,
|
|
354
|
+
tools: { register: (t) => reg.push(t), schemas: () => reg },
|
|
355
|
+
},
|
|
356
|
+
toolConfig,
|
|
357
|
+
)
|
|
358
|
+
const expectedTools = contractMod.registeredToolNames(toolConfig.toolPrefix, {
|
|
359
|
+
allowEvaluate: toolConfig.allowEvaluate,
|
|
360
|
+
allowCdp: toolConfig.allowCdp,
|
|
361
|
+
extractConfigured: toolConfig.extract?.provider !== undefined && toolConfig.extract?.model !== undefined,
|
|
362
|
+
registerDisabledTools: toolConfig.registerDisabledTools,
|
|
363
|
+
})
|
|
364
|
+
const byName = (n) => reg.find((t) => t.name === n)
|
|
365
|
+
check('存在 browser_dialog', byName('browser_dialog') !== undefined)
|
|
366
|
+
check(
|
|
367
|
+
'browser_dialog 有 accept 参数',
|
|
368
|
+
!!byName('browser_dialog') && !!(byName('browser_dialog').parameters.properties || {}).accept,
|
|
369
|
+
)
|
|
370
|
+
check(
|
|
371
|
+
'browser_click 不再有 acceptDialog',
|
|
372
|
+
!!byName('browser_click') && !('acceptDialog' in (byName('browser_click').parameters.properties || {})),
|
|
373
|
+
)
|
|
374
|
+
check(
|
|
375
|
+
'工具面与 contract 一致(' + expectedTools.length + ' 个)',
|
|
376
|
+
reg.length === expectedTools.length && expectedTools.every((n) => byName(n) !== undefined),
|
|
377
|
+
'count=' + reg.length + ' expected=' + expectedTools.length,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
const t1 = await session.tabs()
|
|
381
|
+
check(
|
|
382
|
+
'tabs 每项带 active 且只有一个 true',
|
|
383
|
+
t1.every((t) => typeof t.active === 'boolean') && t1.filter((t) => t.active).length === 1,
|
|
384
|
+
)
|
|
385
|
+
const activeBefore = (t1.find((t) => t.active) || {}).index
|
|
386
|
+
await session.openTab(URL_ + '?second', 'load')
|
|
387
|
+
const t2 = await session.tabs()
|
|
388
|
+
const activeNow = (t2.find((t) => t.active) || {}).index
|
|
389
|
+
check(
|
|
390
|
+
'新开的标签页成为 active',
|
|
391
|
+
t2.length === t1.length + 1 && activeNow === t2.length - 1 && activeNow !== activeBefore,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if (process.argv.includes('--live')) {
|
|
395
|
+
console.log('[7] 真实页面采样 (news.baidu.com)')
|
|
396
|
+
try {
|
|
397
|
+
const live = await session.navigate('https://news.baidu.com/', 'load')
|
|
398
|
+
const t = renderSnapshot(await session.snapshot({}))
|
|
399
|
+
const unnamed = t
|
|
400
|
+
.split('\n')
|
|
401
|
+
.filter((l) =>
|
|
402
|
+
/^- (link|button|menuitem|tab|combobox|textbox|checkbox|radio|switch|option|slider|spinbutton) \[ref=/.test(
|
|
403
|
+
l.trim(),
|
|
404
|
+
),
|
|
405
|
+
).length
|
|
406
|
+
console.log(' info refs=' + live.totalRefs + ' chars=' + t.length + ' unnamedActionable=' + unnamed)
|
|
407
|
+
check('真实页可快照', live.totalRefs > 0)
|
|
408
|
+
} catch (e) {
|
|
409
|
+
console.log(' info 联网采样失败: ' + String(e.message || e).slice(0, 120))
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
console.log('[6] CDP:绑定调用真的能发出命令')
|
|
414
|
+
// 这条用例是回归闸门:CDPSession.send 是原型方法,一旦被解绑成裸函数,
|
|
415
|
+
// 整条 CDP 链路会以 "Cannot read properties of undefined (reading '_channel')" 失败。
|
|
416
|
+
const cdpReply = await session
|
|
417
|
+
.cdp('DOM.getDocument', { depth: 1 }, undefined)
|
|
418
|
+
.then((reply) => (reply === null || typeof reply !== 'object' ? null : reply.root))
|
|
419
|
+
.catch(() => null)
|
|
420
|
+
check('session.cdp 允许的方法可用(绑定调用)', cdpReply !== null && cdpReply.nodeName === '#document')
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// [8] 智能模式(aria 引擎)端到端:ref 必须指回它命名的那个元素
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// 上面 [1]–[7] 全跑在兼容模式下(隔离状态文件 + engine 默认 legacy)。这一段把开关
|
|
426
|
+
// 切到 aria,检查的是 2026-10-02 修掉的那个真 bug:ref 可能「名字对一个元素、解析到
|
|
427
|
+
// 另一个元素」。运行时状态层每次取快照都重读开关,所以这里切完立刻生效;
|
|
428
|
+
// 状态文件是隔离的临时文件,不碰用户真实的选择。
|
|
429
|
+
console.log('[8] 智能模式(aria 引擎)端到端')
|
|
430
|
+
setSnapshotEngine('aria')
|
|
431
|
+
check('运行时开关可切到 aria', getEffectiveEngine() === 'aria', getEffectiveEngine())
|
|
432
|
+
|
|
433
|
+
const ariaSnap = await session.navigate(ARIA_URL_, 'load')
|
|
434
|
+
const ariaTree = renderSnapshot(ariaSnap)
|
|
435
|
+
// engineNote 只在「读不出来、退回兼容模式」时出现:它缺席 + 树里有 textbox,
|
|
436
|
+
// 才说明这一步真的走了智能模式,而不是悄悄回退(回退现在会明说,见 snapshotValue)。
|
|
437
|
+
check(
|
|
438
|
+
'智能模式读得出来(未回退,且明说规则未触发)',
|
|
439
|
+
ariaSnap.engineNote === undefined && has(ariaTree, 'textbox'),
|
|
440
|
+
ariaSnap.engineNote ?? ariaTree.slice(0, 160),
|
|
441
|
+
)
|
|
442
|
+
const ariaBtnLine = ariaTree.split('\n').find((l) => l.includes('button "ARIA 提交"'))
|
|
443
|
+
const ariaBtnRef = ariaBtnLine === undefined ? null : ((ariaBtnLine.match(/ref=([^\]\s]+)/) ?? [])[1] ?? null)
|
|
444
|
+
check('树里能找到「ARIA 提交」的 ref', ariaBtnRef !== null, ariaBtnLine ?? '(no line)')
|
|
445
|
+
if (ariaBtnRef !== null) {
|
|
446
|
+
const afterClick = renderSnapshot(await session.click(ariaBtnRef, undefined))
|
|
447
|
+
check('按 ref 点击命中它命名的那个元素', has(afterClick, 'ARIA-CLICK-OK'), afterClick.slice(0, 200))
|
|
448
|
+
// 排在它前面的隐藏按钮如果被「按位置」错配走这个 ref,就会触发它的处理函数。
|
|
449
|
+
check('没有把点击落到排在前面的隐藏元素上', !has(afterClick, 'HIDDEN-WRONG'))
|
|
450
|
+
}
|
|
451
|
+
setSnapshotEngine('legacy')
|
|
452
|
+
|
|
453
|
+
await provider.dispose()
|
|
454
|
+
server.close()
|
|
455
|
+
|
|
456
|
+
// [7] lib/ 是宿主真正加载的产物:源码改完没 build,跑绿了也是白跑。
|
|
457
|
+
console.log('[7] lib/ 与 src/ 同步(宿主加载 lib/)')
|
|
458
|
+
const stale = []
|
|
459
|
+
for (const file of readdirSync(new URL('../src', import.meta.url))) {
|
|
460
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts')) continue
|
|
461
|
+
const src = statSync(new URL('../src/' + file, import.meta.url)).mtimeMs
|
|
462
|
+
const built = statSync(new URL('../lib/' + file.replace(/\.ts$/, '.js'), import.meta.url), {
|
|
463
|
+
throwIfNoEntry: false,
|
|
464
|
+
})
|
|
465
|
+
if (built === undefined || built.mtimeMs < src) stale.push(file.replace(/\.ts$/, '.js'))
|
|
466
|
+
}
|
|
467
|
+
check('lib/ 不旧于 src/(旧了就先 npm run build)', stale.length === 0, stale.join(', '))
|
|
468
|
+
|
|
469
|
+
console.log('')
|
|
470
|
+
console.log(
|
|
471
|
+
failures.length === 0
|
|
472
|
+
? '全部通过 (' + pass + ' 项)'
|
|
473
|
+
: '失败 ' + failures.length + ' 项 / 共 ' + (pass + failures.length) + ' 项: ' + failures.join(', '),
|
|
474
|
+
)
|
|
475
|
+
process.exit(failures.length === 0 ? 0 : 1)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* measure-prompt-cost —— 常驻 prompt 成本体检。
|
|
4
|
+
*
|
|
5
|
+
* 工具 schema 是随系统提示每轮常驻的:这个脚本把它量出来,改动前后各跑一次,
|
|
6
|
+
* 就知道这次改动有没有偷偷加税。和 doctor 一样,它不碰浏览器,只组装工具面。
|
|
7
|
+
*
|
|
8
|
+
* 用法: node node_modules/tsx/dist/cli.mjs scripts/measure-prompt-cost.mjs
|
|
9
|
+
* (或 npm run cost)
|
|
10
|
+
*
|
|
11
|
+
* 三行分别对应三种"默认",别混:
|
|
12
|
+
* ① 配置默认值(Config({}),不含任何补丁层)
|
|
13
|
+
* ② 出厂 bundle 层 —— 本包 cordis.patch.yml 里给 browser-tool 的配置,
|
|
14
|
+
* 也就是 profile 挂上这个 bundle 后真正生效的那份。两个闸门默认关闭,
|
|
15
|
+
* 所以这一行正常情况下应当与 ① 相同;包里的默认值一改,它自己跟着变。
|
|
16
|
+
* ③ 全量注册(再加 registerDisabledTools: true)
|
|
17
|
+
*
|
|
18
|
+
* 注意:唯一会抬高这个数字的动作是「注册新工具」。文档、脚本、git 都不进 prompt。
|
|
19
|
+
*
|
|
20
|
+
* 测量实现放在 scripts/prompt-cost.mjs:脚本与文档守卫测试(tests/docs-consistency)
|
|
21
|
+
* 共用一份,免得两边各写一遍再各自漂移。
|
|
22
|
+
*/
|
|
23
|
+
import { CONFIG_DEFAULTS, shippedConfig, everythingConfig, measureSurface, approxTokens } from './prompt-cost.mjs'
|
|
24
|
+
|
|
25
|
+
function report(label, { rows, total }) {
|
|
26
|
+
console.log('')
|
|
27
|
+
console.log(label)
|
|
28
|
+
console.log(' 工具数: ' + rows.length + ' wire 字符总数: ' + total + ' 粗估 token: ~' + approxTokens(total))
|
|
29
|
+
console.log(' 最贵的 5 个:')
|
|
30
|
+
for (const [name, wire, desc] of rows.slice(0, 5)) {
|
|
31
|
+
console.log(' ' + name.padEnd(26) + ' wire ' + String(wire).padStart(5) + ' (描述 ' + desc + ')')
|
|
32
|
+
}
|
|
33
|
+
return total
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log('常驻 prompt 成本体检(工具 schema 每轮都在系统提示里)')
|
|
37
|
+
const t0 = report('① 配置默认值 Config({})', await measureSurface(CONFIG_DEFAULTS))
|
|
38
|
+
const shipped = shippedConfig()
|
|
39
|
+
const t1 = report(
|
|
40
|
+
'② 出厂 bundle 层(cordis.patch.yml:allowEvaluate=' +
|
|
41
|
+
shipped.allowEvaluate +
|
|
42
|
+
', allowCdp=' +
|
|
43
|
+
shipped.allowCdp +
|
|
44
|
+
')',
|
|
45
|
+
await measureSurface(shipped),
|
|
46
|
+
)
|
|
47
|
+
const t2 = report('③ 全量注册(registerDisabledTools: true,再加 extract)', await measureSurface(everythingConfig()))
|
|
48
|
+
|
|
49
|
+
console.log('')
|
|
50
|
+
console.log(
|
|
51
|
+
'② → ③ 的差 = 被能力开关挡住、没有关掉的那些 schema:' +
|
|
52
|
+
(t2 - t1) +
|
|
53
|
+
' 字符 ≈ ' +
|
|
54
|
+
approxTokens(t2 - t1) +
|
|
55
|
+
' token/轮。',
|
|
56
|
+
)
|
|
57
|
+
console.log('想让这个数字更小,就在 cordis.patch.yml 里把用不上的能力关掉(关掉即不注册)。')
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt-cost — 「工具面常驻成本」的**唯一测量实现**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成模块:这个测量既被 `measure-prompt-cost.mjs`(给人看的 CLI)用,也被文档守卫测试
|
|
5
|
+
* (`tests/docs-consistency.test.ts`)用。两份实现各自漂移过一次了(cost 脚本的标签被注释骗到),
|
|
6
|
+
* 所以这里只留一份。
|
|
7
|
+
*
|
|
8
|
+
* 它只做一件事:组装工具面、读 `ctx.tools.schemas()` 的 wire 尺寸。
|
|
9
|
+
* **不 acquire 会话、不开浏览器** —— 所以测试里也能直接跑。
|
|
10
|
+
*/
|
|
11
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
|
13
|
+
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
14
|
+
import BrowserRuntime from '../src/service.ts'
|
|
15
|
+
import { PlaywrightProvider } from '../src/playwright.ts'
|
|
16
|
+
import * as browserTool from '../src/tool.ts'
|
|
17
|
+
import { bundleConfig } from './shipped-gates.mjs'
|
|
18
|
+
|
|
19
|
+
/** Launch config for the throwaway context (no page is ever opened, so this is inert). */
|
|
20
|
+
const PW_CONFIG = {
|
|
21
|
+
launch: {
|
|
22
|
+
channel: 'chrome',
|
|
23
|
+
headless: true,
|
|
24
|
+
viewport: { width: 1280, height: 800 },
|
|
25
|
+
navigationTimeoutMs: 5000,
|
|
26
|
+
ignoreHTTPSErrors: false,
|
|
27
|
+
},
|
|
28
|
+
idleTimeoutMs: 0,
|
|
29
|
+
maxSessions: 2,
|
|
30
|
+
snapshot: { maxNodes: 200, maxNameLength: 120, maxTextLength: 300 },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `Config({})`: the schema defaults, before any patch layer. */
|
|
34
|
+
export const CONFIG_DEFAULTS = {
|
|
35
|
+
toolPrefix: 'browser_',
|
|
36
|
+
allowEvaluate: false,
|
|
37
|
+
allowCdp: false,
|
|
38
|
+
registerDisabledTools: false,
|
|
39
|
+
maxWaitMs: 60000,
|
|
40
|
+
interactiveOnlyDefault: false,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The shipped bundle layer: Config defaults plus this package's `cordis.patch.yml`. */
|
|
44
|
+
export function shippedConfig() {
|
|
45
|
+
return { ...CONFIG_DEFAULTS, ...bundleConfig() }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Everything registered: gated tools stay registered while off, which also pulls in `extract`. */
|
|
49
|
+
export function everythingConfig() {
|
|
50
|
+
return { ...shippedConfig(), registerDisabledTools: true }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Assemble one tool surface and return its per-schema wire sizes. */
|
|
54
|
+
export async function measureSurface(config) {
|
|
55
|
+
const root = new Context()
|
|
56
|
+
await root.plugin(SystemPrompt)
|
|
57
|
+
await root.plugin(ToolRuntime)
|
|
58
|
+
await root.plugin(BrowserRuntime)
|
|
59
|
+
root.browser.registerProvider(new PlaywrightProvider(PW_CONFIG))
|
|
60
|
+
browserTool.apply(root, config)
|
|
61
|
+
const rows = root.tools
|
|
62
|
+
.schemas()
|
|
63
|
+
.map((s) => [
|
|
64
|
+
s.name,
|
|
65
|
+
JSON.stringify({ name: s.name, description: s.description, parameters: s.parameters }).length,
|
|
66
|
+
s.description.length,
|
|
67
|
+
])
|
|
68
|
+
.sort((a, b) => b[1] - a[1])
|
|
69
|
+
return { rows, total: rows.reduce((n, r) => n + r[1], 0) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The rough per-turn token estimate the docs quote: characters / 3.6, rounded. */
|
|
73
|
+
export const approxTokens = (chars) => Math.round(chars / 3.6)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shipped-gates — 「出厂包里那两个闸门到底是什么值」,只此一份实现。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要单独成模块:这段逻辑被注释骗过一次(2026-10-02)。包里 0.4.7 起两个闸门都是
|
|
5
|
+
* false,而 cordis.patch.yml 里同时写着「怎么开启」的注释示例 —— 里面先出现一行
|
|
6
|
+
* `allowEvaluate: true`。一个不剥注释的正则会匹配到**注释**,于是 cost 脚本把出厂层
|
|
7
|
+
* 报成 "两个都开着 / 22 工具 ≈ 4842",而真实是 "都关着 / 20 工具 ≈ 4176"。
|
|
8
|
+
* 抽出来之后:脚本用它、守卫测试也用它(并对着一份独立解析比对),谁改坏了都会当场失败。
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read the shipped bundle layer: what a deployment actually gets.
|
|
14
|
+
*
|
|
15
|
+
* Comment lines are stripped first, and that is not cosmetic: the file documents the
|
|
16
|
+
* opt-in snippet, so a naive regex matches the *example* `allowEvaluate: true` inside a
|
|
17
|
+
* comment. Attribute lines inside comments are documentation, not configuration.
|
|
18
|
+
*
|
|
19
|
+
* @param {URL} [patchUrl] - defaults to this package's cordis.patch.yml.
|
|
20
|
+
* @returns {{ allowEvaluate: boolean, allowCdp: boolean }}
|
|
21
|
+
*/
|
|
22
|
+
export function bundleConfig(patchUrl = new URL('../cordis.patch.yml', import.meta.url)) {
|
|
23
|
+
const yml = readFileSync(patchUrl, 'utf8')
|
|
24
|
+
const code = stripComments(yml)
|
|
25
|
+
const row = code.split(/\n(?=\s*- id: )/).find((block) => block.includes('dsh-browser-playwright-codex/tool')) ?? ''
|
|
26
|
+
if (row === '') throw new Error('cordis.patch.yml: no dsh-browser-playwright-codex/tool row found')
|
|
27
|
+
const flag = (name) => new RegExp(name + ':\\s*(true|false)').exec(row)?.[1] === 'true'
|
|
28
|
+
return { allowEvaluate: flag('allowEvaluate'), allowCdp: flag('allowCdp') }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Drop `#` comments (full-line and trailing) so documentation examples cannot be read
|
|
33
|
+
* as configuration. Exported so a guard test can compare an independent parse.
|
|
34
|
+
* @param {string} yml
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function stripComments(yml) {
|
|
38
|
+
return yml
|
|
39
|
+
.split('\n')
|
|
40
|
+
.map((line) => line.replace(/\s+#.*$/, ''))
|
|
41
|
+
.filter((line) => !/^\s*#/.test(line))
|
|
42
|
+
.join('\n')
|
|
43
|
+
}
|