dsh-browser-playwright-codex 0.6.1 → 0.6.5
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 +29 -0
- package/README.md +35 -2
- package/lib/challenge-signatures.d.ts +105 -0
- package/lib/challenge-signatures.js +154 -0
- package/lib/challenge.d.ts +48 -8
- package/lib/challenge.js +211 -26
- package/lib/injected.js +13 -0
- package/lib/page-actions.d.ts +7 -56
- package/lib/page-actions.js +1 -1
- package/lib/page-diagnostics.js +23 -5
- package/lib/page-snapshot.d.ts +14 -8
- package/lib/page-snapshot.js +48 -1
- package/lib/session.d.ts +21 -82
- package/lib/session.js +15 -3
- package/lib/snapshot-aria.js +16 -0
- package/lib/tool-render.js +21 -1
- package/lib/tool-types.d.ts +10 -0
- package/lib/types.d.ts +23 -0
- package/package.json +4 -3
- package/scripts/analysis/hl-compare.mjs +0 -149
- package/scripts/analysis/live-handoff-probe.mjs +0 -97
- package/scripts/analysis/needs-human-probe.mjs +0 -83
- package/scripts/analysis/repro-minimize.mjs +0 -112
- package/scripts/autocommit.ps1 +0 -23
- package/scripts/check-doc-links.py +0 -112
- package/scripts/measure-prompt-cost.mjs +0 -57
- package/scripts/preflight.mjs +0 -192
- package/scripts/prompt-cost.mjs +0 -73
- package/scripts/shipped-gates.mjs +0 -43
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Headless-mode capability probe, aimed at the three "needs a human" cases:
|
|
3
|
-
*
|
|
4
|
-
* 1. Does the window-state CDP call used by the minimize gate even work headless?
|
|
5
|
-
* (If it throws, our gate returns "minimized" and silently disables the
|
|
6
|
-
* login-state export in headless — i.e. session cookies would never be saved.)
|
|
7
|
-
* 2. Do page-level native dialogs (alert/confirm/prompt) park and answer headless?
|
|
8
|
-
* 3. Does a file input accept setInputFiles headless, i.e. no OS picker needed?
|
|
9
|
-
*/
|
|
10
|
-
import { createRequire } from 'node:module'
|
|
11
|
-
import path from 'node:path'
|
|
12
|
-
import fs from 'node:fs'
|
|
13
|
-
import os from 'node:os'
|
|
14
|
-
|
|
15
|
-
const require = createRequire(import.meta.url)
|
|
16
|
-
const { chromium } = require(process.env.PW_CORE_PATH ?? 'playwright-core')
|
|
17
|
-
|
|
18
|
-
const headless = (process.env.MODE ?? 'headless') === 'headless'
|
|
19
|
-
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-needsprobe-'))
|
|
20
|
-
const context = await chromium.launchPersistentContext(profile, {
|
|
21
|
-
headless,
|
|
22
|
-
channel: process.env.CHANNEL || undefined,
|
|
23
|
-
...(headless ? { viewport: { width: 1280, height: 800 } } : { viewport: null }),
|
|
24
|
-
ignoreDefaultArgs: ['--enable-automation'],
|
|
25
|
-
args: ['--no-first-run', '--no-default-browser-check', '--disable-blink-features=AutomationControlled'],
|
|
26
|
-
})
|
|
27
|
-
const page = context.pages()[0] ?? (await context.newPage())
|
|
28
|
-
const out = { mode: headless ? 'headless' : 'headful' }
|
|
29
|
-
|
|
30
|
-
// --- 1. the window-state probe the plugin's gate relies on -------------------
|
|
31
|
-
try {
|
|
32
|
-
const s = await context.newCDPSession(page)
|
|
33
|
-
const { windowId } = await s.send('Browser.getWindowForTarget')
|
|
34
|
-
const { bounds } = await s.send('Browser.getWindowBounds', { windowId })
|
|
35
|
-
out.windowStateProbe = { ok: true, windowId, windowState: bounds.windowState, bounds }
|
|
36
|
-
out.gateWouldReturn = bounds.windowState === 'minimized'
|
|
37
|
-
await s.detach()
|
|
38
|
-
} catch (e) {
|
|
39
|
-
out.windowStateProbe = { ok: false, error: String(e?.message ?? e).split('\n')[0] }
|
|
40
|
-
// The plugin's catch returns true == "treat as minimized".
|
|
41
|
-
out.gateWouldReturn = true
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// --- 2. native dialog without a window --------------------------------------
|
|
45
|
-
const dialogLog = []
|
|
46
|
-
page.on('dialog', async (d) => {
|
|
47
|
-
dialogLog.push({ type: d.type(), message: d.message() })
|
|
48
|
-
await d.accept('typed-by-test').catch(async () => {
|
|
49
|
-
await d.dismiss().catch(() => {})
|
|
50
|
-
})
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
async function dialogRoundTrip() {
|
|
54
|
-
await page.setContent('<html><body><button id=b>go</button></body></html>')
|
|
55
|
-
const result = await page.evaluate(() => {
|
|
56
|
-
const answer = prompt('probe-prompt', 'default')
|
|
57
|
-
return 'prompt-returned:' + String(answer)
|
|
58
|
-
})
|
|
59
|
-
return result
|
|
60
|
-
}
|
|
61
|
-
try {
|
|
62
|
-
out.dialog = { evaluateResult: await dialogRoundTrip(), events: dialogLog }
|
|
63
|
-
} catch (e) {
|
|
64
|
-
out.dialog = { error: String(e?.message ?? e).split('\n')[0], events: dialogLog }
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// --- 3. file input without an OS picker ------------------------------------
|
|
68
|
-
try {
|
|
69
|
-
const tmpFile = path.join(os.tmpdir(), 'pw-upload-probe.txt')
|
|
70
|
-
fs.writeFileSync(tmpFile, 'upload-probe')
|
|
71
|
-
await page.setContent('<html><body><input id=f type=file></body></html>')
|
|
72
|
-
await page.setInputFiles('#f', tmpFile)
|
|
73
|
-
out.fileInput = {
|
|
74
|
-
ok: true,
|
|
75
|
-
files: await page.evaluate(() => Array.from(document.querySelector('#f').files).map((f) => f.name)),
|
|
76
|
-
}
|
|
77
|
-
} catch (e) {
|
|
78
|
-
out.fileInput = { ok: false, error: String(e?.message ?? e).split('\n')[0] }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
console.log(JSON.stringify(out, null, 2))
|
|
82
|
-
await context.close()
|
|
83
|
-
fs.rmSync(profile, { recursive: true, force: true })
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal, plugin-free reproduction of "automation raises a minimized window".
|
|
3
|
-
*
|
|
4
|
-
* Two modes, matching the two ways dsh-browser-playwright can own a window:
|
|
5
|
-
* PW_MODE=launch -> chromium.launch() + browser.newContext() (upstream 0.1.x default shape)
|
|
6
|
-
* PW_MODE=persistent -> chromium.launchPersistentContext() (persistent-profile forks)
|
|
7
|
-
*
|
|
8
|
-
* The OS window state is read back through CDP (Browser.getWindowBounds) and the
|
|
9
|
-
* window is minimized through CDP too, so the verdict does not depend on anyone
|
|
10
|
-
* watching the screen.
|
|
11
|
-
*
|
|
12
|
-
* Usage (Windows, using an installed Chrome):
|
|
13
|
-
* set PW_MODE=launch
|
|
14
|
-
* set PW_CHANNEL=chrome
|
|
15
|
-
* set PW_CORE_PATH=D:\path\to\node_modules\playwright-core
|
|
16
|
-
* node repro-minimize.mjs
|
|
17
|
-
*/
|
|
18
|
-
import { createRequire } from 'node:module'
|
|
19
|
-
import os from 'node:os'
|
|
20
|
-
import path from 'node:path'
|
|
21
|
-
import fs from 'node:fs'
|
|
22
|
-
|
|
23
|
-
const require = createRequire(import.meta.url)
|
|
24
|
-
const corePath = process.env.PW_CORE_PATH ?? 'playwright-core'
|
|
25
|
-
const { chromium } = require(corePath)
|
|
26
|
-
let coreVersion = 'unknown'
|
|
27
|
-
try {
|
|
28
|
-
coreVersion = require(path.join(corePath, 'package.json')).version
|
|
29
|
-
} catch {}
|
|
30
|
-
|
|
31
|
-
const MODE = process.env.PW_MODE ?? 'launch'
|
|
32
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
33
|
-
const launchOptions = {
|
|
34
|
-
headless: false,
|
|
35
|
-
channel: process.env.PW_CHANNEL || undefined, // e.g. 'chrome' to use the local Chrome
|
|
36
|
-
args: ['--no-first-run', '--no-default-browser-check'],
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
let browser
|
|
40
|
-
let context
|
|
41
|
-
let profile
|
|
42
|
-
if (MODE === 'persistent') {
|
|
43
|
-
profile = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-focus-repro-'))
|
|
44
|
-
context = await chromium.launchPersistentContext(profile, { ...launchOptions, viewport: null })
|
|
45
|
-
} else {
|
|
46
|
-
browser = await chromium.launch(launchOptions)
|
|
47
|
-
context = await browser.newContext({ viewport: null })
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const anchor = context.pages()[0] ?? (await context.newPage())
|
|
51
|
-
// Real content matters: an empty about:blank window can behave differently.
|
|
52
|
-
await anchor.goto('https://example.com', { waitUntil: 'domcontentloaded' }).catch(() => {})
|
|
53
|
-
await sleep(500)
|
|
54
|
-
|
|
55
|
-
const cdp = await context.newCDPSession(anchor)
|
|
56
|
-
const windowId = async () => (await cdp.send('Browser.getWindowForTarget')).windowId
|
|
57
|
-
const state = async () => (await cdp.send('Browser.getWindowBounds', { windowId: await windowId() })).bounds.windowState
|
|
58
|
-
const setState = async (windowState) => {
|
|
59
|
-
const id = await windowId()
|
|
60
|
-
return cdp.send('Browser.setWindowBounds', { windowId: id, bounds: { windowState } })
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const results = []
|
|
64
|
-
async function probe(name, fn) {
|
|
65
|
-
await setState('normal')
|
|
66
|
-
await sleep(400)
|
|
67
|
-
await setState('minimized')
|
|
68
|
-
await sleep(900)
|
|
69
|
-
const before = await state()
|
|
70
|
-
let err = ''
|
|
71
|
-
try {
|
|
72
|
-
await fn()
|
|
73
|
-
} catch (e) {
|
|
74
|
-
err = ' [' + (e?.message ?? e) + ']'
|
|
75
|
-
}
|
|
76
|
-
await sleep(1400)
|
|
77
|
-
const after = await state()
|
|
78
|
-
const raised = before === 'minimized' && after !== 'minimized'
|
|
79
|
-
results.push({ name, before, after, raised })
|
|
80
|
-
console.log(`${raised ? 'RAISES ' : 'safe '} ${name.padEnd(46)} before=${before} after=${after}${err}`)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
console.log(`mode=${MODE} playwright-core ${coreVersion} node ${process.version}`)
|
|
84
|
-
console.log(`channel=${process.env.PW_CHANNEL || '(bundled chromium)'}\n`)
|
|
85
|
-
|
|
86
|
-
await probe('baseline: sleep only (3s)', () => sleep(3000))
|
|
87
|
-
await probe('context.newPage()', async () => {
|
|
88
|
-
const p = await context.newPage()
|
|
89
|
-
await p.goto('about:blank')
|
|
90
|
-
await p.close()
|
|
91
|
-
})
|
|
92
|
-
await probe('CDP Target.createTarget({background:true})', async () => {
|
|
93
|
-
await cdp.send('Target.createTarget', { url: 'about:blank', background: true })
|
|
94
|
-
})
|
|
95
|
-
await probe('page.bringToFront()', () => anchor.bringToFront())
|
|
96
|
-
await probe('context.newCDPSession(anchor)', async () => {
|
|
97
|
-
const s = await context.newCDPSession(anchor)
|
|
98
|
-
await s.detach()
|
|
99
|
-
})
|
|
100
|
-
await probe('context.storageState()', () => context.storageState())
|
|
101
|
-
await probe('anchor.goto() (same tab)', () =>
|
|
102
|
-
anchor.goto('https://example.com/?x=1', { waitUntil: 'domcontentloaded' }),
|
|
103
|
-
)
|
|
104
|
-
await probe('anchor.screenshot()', () => anchor.screenshot({ path: path.join(os.tmpdir(), 'pw-repro.png') }))
|
|
105
|
-
|
|
106
|
-
console.log('\n--- summary ---')
|
|
107
|
-
for (const r of results) console.log(`${r.raised ? 'RAISES' : 'safe '} ${r.name}`)
|
|
108
|
-
|
|
109
|
-
await setState('normal').catch(() => {})
|
|
110
|
-
await context.close().catch(() => {})
|
|
111
|
-
await browser?.close().catch(() => {})
|
|
112
|
-
if (profile) fs.rmSync(profile, { recursive: true, force: true })
|
package/scripts/autocommit.ps1
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# autocommit.ps1 —— 每日自动存档:有改动就提交一条带时间戳的 commit。
|
|
2
|
-
#
|
|
3
|
-
# 用法:
|
|
4
|
-
# powershell -ExecutionPolicy Bypass -File D:\dsh-plugins\dsh-browser-playwright-codex\scripts\autocommit.ps1
|
|
5
|
-
#
|
|
6
|
-
# 注册成每天 23:55 的计划任务(一次即可,用户级,不需要管理员):
|
|
7
|
-
# schtasks /create /tn "dsh-browser-playwright autocommit" /sc daily /st 23:55 ^
|
|
8
|
-
# /tr "powershell -ExecutionPolicy Bypass -File D:\dsh-plugins\dsh-browser-playwright-codex\scripts\autocommit.ps1"
|
|
9
|
-
#
|
|
10
|
-
# 说明:这是"存档",不是"分支管理"。它只提交,从不 push、reset 或丢弃改动,
|
|
11
|
-
# 所以最坏情况也只是历史里多一条记录。
|
|
12
|
-
$ErrorActionPreference = 'Stop'
|
|
13
|
-
$repo = Split-Path -Parent $PSScriptRoot
|
|
14
|
-
Set-Location $repo
|
|
15
|
-
|
|
16
|
-
if (-not (Test-Path (Join-Path $repo '.git'))) { Write-Output "not a git repo: $repo"; exit 0 }
|
|
17
|
-
|
|
18
|
-
$changes = git status --porcelain
|
|
19
|
-
if (-not $changes) { Write-Output '没有改动,跳过'; exit 0 }
|
|
20
|
-
|
|
21
|
-
git add -A
|
|
22
|
-
git commit -q -m ("auto: " + (Get-Date -Format 'yyyy-MM-dd HH:mm'))
|
|
23
|
-
Write-Output ("已存档: " + (Get-Date -Format 'yyyy-MM-dd HH:mm'))
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
"""校验仓库里所有 markdown 链接:文件路径要存在,锚点要能在目标文档里找到。
|
|
2
|
-
|
|
3
|
-
为什么需要:
|
|
4
|
-
1. 那几篇文档原来放在 GitHub 的 docs/ 下,链接写的是 ../xxx;搬进本仓库根目录后
|
|
5
|
-
../ 就指到仓库外了。靠肉眼容易漏,脚本一跑就知道。
|
|
6
|
-
2. 锚点也会坏:曾经有过 `[help wanted #2](README.md#help-wanted--求助)`,
|
|
7
|
-
而 README 里根本没有那一节 —— 文件在、锚点不在,照样是死链。
|
|
8
|
-
|
|
9
|
-
跑法(无需依赖,Python 3.8+):
|
|
10
|
-
python scripts/check-doc-links.py
|
|
11
|
-
退出码 0 = 全部有效;1 = 有坏链(逐条列出,标明是文件缺失还是锚点缺失)。
|
|
12
|
-
"""
|
|
13
|
-
import os
|
|
14
|
-
import re
|
|
15
|
-
import sys
|
|
16
|
-
|
|
17
|
-
# 以本文件位置为准:scripts/ 的上一级就是仓库根,换机器也不用改路径
|
|
18
|
-
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
19
|
-
LINK = re.compile(r"\]\(([^)\s]+)\)")
|
|
20
|
-
HEADING = re.compile(r"^#{1,6}\s+(.*?)\s*$")
|
|
21
|
-
FENCE = re.compile(r"^\s*(```|~~~)")
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def slug(heading: str) -> str:
|
|
25
|
-
"""GitHub 的标题锚点规则(近似实现):小写;去掉非字母数字/连字符/下划线;
|
|
26
|
-
空格转连字符。中文是字母数字(str.isalnum() 为真)所以保留,与 GitHub 一致。"""
|
|
27
|
-
out = []
|
|
28
|
-
for ch in heading.strip().lower():
|
|
29
|
-
if ch.isalnum() or ch in "-_":
|
|
30
|
-
out.append(ch)
|
|
31
|
-
elif ch == " ":
|
|
32
|
-
out.append("-")
|
|
33
|
-
return "".join(out)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def anchors_of(path: str) -> set:
|
|
37
|
-
"""该文档所有标题的锚点。代码块内的 # 行不算标题。"""
|
|
38
|
-
found = set()
|
|
39
|
-
in_fence = False
|
|
40
|
-
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
41
|
-
for line in f:
|
|
42
|
-
if FENCE.match(line):
|
|
43
|
-
in_fence = not in_fence
|
|
44
|
-
continue
|
|
45
|
-
if in_fence:
|
|
46
|
-
continue
|
|
47
|
-
m = HEADING.match(line)
|
|
48
|
-
if m:
|
|
49
|
-
found.add(slug(m.group(1)))
|
|
50
|
-
return found
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def main() -> int:
|
|
54
|
-
bad = []
|
|
55
|
-
checked_files = 0
|
|
56
|
-
checked_anchors = 0
|
|
57
|
-
anchor_cache = {}
|
|
58
|
-
|
|
59
|
-
for root, dirs, files in os.walk(REPO):
|
|
60
|
-
dirs[:] = [d for d in dirs if d not in (".git", "node_modules", "lib")]
|
|
61
|
-
for name in files:
|
|
62
|
-
if not name.endswith(".md"):
|
|
63
|
-
continue
|
|
64
|
-
path = os.path.join(root, name)
|
|
65
|
-
rel = os.path.relpath(path, REPO)
|
|
66
|
-
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
67
|
-
text = f.read()
|
|
68
|
-
|
|
69
|
-
for target in LINK.findall(text):
|
|
70
|
-
if target.startswith(("http://", "https://", "mailto:")):
|
|
71
|
-
continue
|
|
72
|
-
file_part, _, anchor = target.partition("#")
|
|
73
|
-
file_part = file_part.split("?", 1)[0]
|
|
74
|
-
|
|
75
|
-
if file_part == "":
|
|
76
|
-
# 同文档锚点:指向"更靠下的小节标题"是常见写法,这里只校验它存在
|
|
77
|
-
target_path = path
|
|
78
|
-
else:
|
|
79
|
-
target_path = os.path.normpath(
|
|
80
|
-
os.path.join(os.path.dirname(path), file_part.replace("/", os.sep))
|
|
81
|
-
)
|
|
82
|
-
checked_files += 1
|
|
83
|
-
if not os.path.exists(target_path):
|
|
84
|
-
bad.append((rel, target, "文件不存在: " + os.path.relpath(target_path, REPO)))
|
|
85
|
-
continue
|
|
86
|
-
|
|
87
|
-
if anchor == "":
|
|
88
|
-
continue
|
|
89
|
-
if not target_path.endswith(".md"):
|
|
90
|
-
continue
|
|
91
|
-
checked_anchors += 1
|
|
92
|
-
if target_path not in anchor_cache:
|
|
93
|
-
anchor_cache[target_path] = anchors_of(target_path)
|
|
94
|
-
if anchor not in anchor_cache[target_path]:
|
|
95
|
-
bad.append(
|
|
96
|
-
(
|
|
97
|
-
rel,
|
|
98
|
-
target,
|
|
99
|
-
"锚点不存在: #" + anchor + " (该文档有 "
|
|
100
|
-
+ str(len(anchor_cache[target_path]))
|
|
101
|
-
+ " 个标题锚点)",
|
|
102
|
-
)
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
print(f"检查了 {checked_files} 个文件链接、{checked_anchors} 个锚点,坏链 {len(bad)} 个")
|
|
106
|
-
for src, target, why in bad:
|
|
107
|
-
print(f" BAD {src} -> {target} ({why})")
|
|
108
|
-
return 1 if bad else 0
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if __name__ == "__main__":
|
|
112
|
-
sys.exit(main())
|
|
@@ -1,57 +0,0 @@
|
|
|
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 里把用不上的能力关掉(关掉即不注册)。')
|
package/scripts/preflight.mjs
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 发版前置审计(preflight):把"文档对世界说的话"和"包里到底装了什么"一次查干净。
|
|
3
|
-
*
|
|
4
|
-
* 为什么有这个脚本(2026-10-11,用户第二次因此不满):
|
|
5
|
-
* 之前是"只改点不看面"—— 修了 README 的 npm 说法,却没查 SECURITY 的计数、NOTICE 的路径、
|
|
6
|
-
* 发帖草稿里的数字、以及"文档引用的文件/行号在重构后是否还存在"。用户的原话是
|
|
7
|
-
* 「你能不能检查清楚再发新版本啊,别给我检查出来又一堆 bug」。所以这份检查挂在 `prepublishOnly`
|
|
8
|
-
* 上:**不通过就发不出去**,而不是等用户事后发现。
|
|
9
|
-
*
|
|
10
|
-
* 三类输出,语义严格区分:
|
|
11
|
-
* ✗ 必须修 —— 硬错误(文件读不到、清单缺项、路径/行号/链接失效、测试失败、计数与实测不符)
|
|
12
|
-
* ? 待确认 —— 可能是历史、上游或有意为之(例如 "published since v0.5.1"、上游插件的旧包名)
|
|
13
|
-
* ✓ 已一致 —— 已经核对过的基线数字
|
|
14
|
-
*
|
|
15
|
-
* 规则刻意收窄避免噪声(第一版误报过:把 IP 当版本号、把上游文档的版本当我们的、把竞品的工具数
|
|
16
|
-
* 当我们的、把"三个闸门合计 127 token"这个子项当成总成本)。
|
|
17
|
-
*
|
|
18
|
-
* 用法: node preflight.mjs [项目根] 退出码: 0 通过 / 2 有硬错误
|
|
19
|
-
*/
|
|
20
|
-
import { execFileSync } from 'node:child_process'
|
|
21
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
22
|
-
import { dirname, join, relative, resolve } from 'node:path'
|
|
23
|
-
import { fileURLToPath } from 'node:url'
|
|
24
|
-
|
|
25
|
-
const root = resolve(process.argv[2] ?? join(dirname(fileURLToPath(import.meta.url)), '..'))
|
|
26
|
-
const problems = []
|
|
27
|
-
const confirm = []
|
|
28
|
-
const ok = []
|
|
29
|
-
|
|
30
|
-
const readOrThrow = (rel) => {
|
|
31
|
-
const p = join(root, rel)
|
|
32
|
-
if (!existsSync(p)) throw new Error('读不到文件(硬错误,不许当通过):' + rel)
|
|
33
|
-
return readFileSync(p, 'utf8')
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ── A. 包里装了什么 ───────────────────────────────────────────────────────────
|
|
37
|
-
const packed = new Set(
|
|
38
|
-
JSON.parse(
|
|
39
|
-
execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: root, encoding: 'utf8', shell: true }),
|
|
40
|
-
)[0].files.map((f) => f.path),
|
|
41
|
-
)
|
|
42
|
-
// 这些是"用户从 npm 装到之后必须能看到"的文件:许可证与归属声明(NOTICE)、安全说明、变更记录、
|
|
43
|
-
// 以及让 Gradle 式的 loader 能挂载的 cordis.patch.yml。2026-10-11 的审计就是靠这份清单抓到
|
|
44
|
-
// NOTICE.md 一直没进包(npm 只会自动带上 LICENSE,不会带 NOTICE)。
|
|
45
|
-
const SHOULD_SHIP = [
|
|
46
|
-
'package.json',
|
|
47
|
-
'README.md',
|
|
48
|
-
'CHANGELOG.md',
|
|
49
|
-
'SECURITY.md',
|
|
50
|
-
'NOTICE.md',
|
|
51
|
-
'LICENSE',
|
|
52
|
-
'cordis.patch.yml',
|
|
53
|
-
'locale/en.json',
|
|
54
|
-
'locale/zh.json',
|
|
55
|
-
]
|
|
56
|
-
for (const f of SHOULD_SHIP) if (!packed.has(f)) problems.push('包里缺文件:' + f)
|
|
57
|
-
if (![...packed].some((p) => p.startsWith('lib/'))) problems.push('包里没有 lib/(插件实际加载的就是它)')
|
|
58
|
-
if ([...packed].some((p) => p.startsWith('src/') || p.startsWith('tests/'))) problems.push('包里不该有 src/ 或 tests/')
|
|
59
|
-
if ([...packed].some((p) => p.endsWith('.bak'))) problems.push('包里有 .bak 残渣')
|
|
60
|
-
ok.push('包内 ' + packed.size + ' 个文件;已确认含 lib/、locale/、cordis.patch.yml,且不含 src/tests/.bak')
|
|
61
|
-
|
|
62
|
-
// ── B. 基线数字(来自命令本身,不是文档自述) ─────────────────────────────────
|
|
63
|
-
const pkg = JSON.parse(readOrThrow('package.json'))
|
|
64
|
-
const cost = execFileSync('npm', ['run', 'cost'], { cwd: root, encoding: 'utf8', shell: true })
|
|
65
|
-
const toolCount = Number((cost.match(/工具数[::]\s*(\d+)/) ?? [])[1])
|
|
66
|
-
const chars = Number((cost.match(/字符总数[::]\s*(\d+)/) ?? [])[1])
|
|
67
|
-
const tokens = Number((cost.match(/粗估 token[::]\s*~?(\d+)/) ?? [])[1])
|
|
68
|
-
if (!toolCount || !chars || !tokens) problems.push('拿不到 npm run cost 的数字')
|
|
69
|
-
else ok.push('成本基线:' + toolCount + ' 工具 / ' + chars + ' 字符 / ~' + tokens + ' token/轮')
|
|
70
|
-
|
|
71
|
-
const suite = execFileSync('node', ['--test', 'tests/*.test.ts'], { cwd: root, encoding: 'utf8', shell: true })
|
|
72
|
-
const n = (re) => Number((suite.match(re) ?? [])[1])
|
|
73
|
-
if (n(/ℹ fail (\d+)/) > 0) problems.push('测试有失败:' + n(/ℹ fail (\d+)/) + ' 条')
|
|
74
|
-
else
|
|
75
|
-
ok.push(
|
|
76
|
-
'测试:' + n(/ℹ tests (\d+)/) + ' / pass ' + n(/ℹ pass (\d+)/) + ' / fail 0 / skipped ' + n(/ℹ skipped (\d+)/),
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
// ── C. 文档断言 ───────────────────────────────────────────────────────────────
|
|
80
|
-
const docFiles = []
|
|
81
|
-
const walk = (dir) => {
|
|
82
|
-
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
83
|
-
if (e.name === 'node_modules' || e.name === '.git') continue
|
|
84
|
-
const p = join(dir, e.name)
|
|
85
|
-
if (e.isDirectory()) walk(p)
|
|
86
|
-
else if (e.name.endsWith('.md')) docFiles.push(relative(root, p).replace(/\\/g, '/'))
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
walk(root)
|
|
90
|
-
const HISTORY = new Set(['CHANGELOG.md'])
|
|
91
|
-
/** 上游项目(被借鉴的那个插件)自己的文档:里面出现旧包名/旧版本是对的。 */
|
|
92
|
-
const UPSTREAM_DOCS = new Set([
|
|
93
|
-
'PLUGIN-README.md',
|
|
94
|
-
'codex-rules.md',
|
|
95
|
-
'patches/README.md',
|
|
96
|
-
'docs/articles/focus-stealing.md',
|
|
97
|
-
])
|
|
98
|
-
const current = docFiles.filter((f) => !HISTORY.has(f))
|
|
99
|
-
|
|
100
|
-
const basenames = new Set()
|
|
101
|
-
for (const dir of ['', 'src', 'lib', 'scripts', 'tests', 'locale', 'patches', 'docs', 'docs/articles']) {
|
|
102
|
-
const abs = dir ? join(root, dir) : root
|
|
103
|
-
if (!existsSync(abs)) continue
|
|
104
|
-
for (const e of readdirSync(abs, { withFileTypes: true })) if (e.isFile()) basenames.add(e.name)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
for (const f of docFiles) {
|
|
108
|
-
const text = readOrThrow(f)
|
|
109
|
-
const currentDoc = current.includes(f)
|
|
110
|
-
text.split('\n').forEach((line, i) => {
|
|
111
|
-
const where = f + ':' + (i + 1)
|
|
112
|
-
const historical = /(since|since v|起|之前|以后|published|发布|历史|历史值|for the record|v0[.]4)/i.test(line)
|
|
113
|
-
|
|
114
|
-
// C1. 看起来是"本包版本"的数字
|
|
115
|
-
if (currentDoc && !historical) {
|
|
116
|
-
const notOurs =
|
|
117
|
-
/(Node|node|playwright-core|upstream|上游|Chenyu|Codex|scan of|扫描|dsh-plugin-vet|electron)/i.test(line)
|
|
118
|
-
for (const m of line.matchAll(/\bv(\d+\.\d+\.\d+)\b/g)) {
|
|
119
|
-
if (notOurs) break
|
|
120
|
-
if (m[1] !== pkg.version)
|
|
121
|
-
confirm.push(where + ' 提到版本 v' + m[1] + '(当前 ' + pkg.version + ')→ ' + line.trim().slice(0, 80))
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// C2. 写死的工具数:只有当这句在说"我们"时才核对(竞品对照说的是别人)
|
|
126
|
-
const tc = line.match(/\b(\d{2})\s*(?:个)?\s*(?:browser_\*?\s*)?(?:tools|工具)/i)
|
|
127
|
-
if (currentDoc && tc && Number(tc[1]) >= 10) {
|
|
128
|
-
const aboutUs = /(我们|this package|本包|registered|注册)/i.test(line)
|
|
129
|
-
const aboutThem = /(同类|竞品|其它|其他|competitor|alternative|更轻)/i.test(line)
|
|
130
|
-
if (aboutUs && !aboutThem && Number(tc[1]) !== toolCount)
|
|
131
|
-
problems.push(where + ' 写死工具数 ' + tc[1] + '(实测 ' + toolCount + ')')
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// C3. 写死的总成本:只核对"总计/常驻"这类句子,且量级要像总数(≈127 这种子项不算)
|
|
135
|
-
const tk = line.match(/\b(\d[\d,]{2,6})\s*(?:tokens?|token)/i)
|
|
136
|
-
if (currentDoc && tk) {
|
|
137
|
-
const value = Number(tk[1].replace(/,/g, ''))
|
|
138
|
-
const aboutTotal = /(常驻|总计|合计|resident|per turn|每轮)/i.test(line)
|
|
139
|
-
if (aboutTotal && value >= 500 && Math.abs(value - tokens) > 200)
|
|
140
|
-
problems.push(where + ' 写死的总 token 数 ' + tk[1] + '(实测 ~' + tokens + ')')
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// C4. 旧包名:.state.json 是刻意保留的;上游文档里出现是对的
|
|
144
|
-
if (
|
|
145
|
-
/dsh-browser-playwright(?!-codex)/.test(line) &&
|
|
146
|
-
!UPSTREAM_DOCS.has(f) &&
|
|
147
|
-
!/\.state\.json/.test(line) &&
|
|
148
|
-
!/(上游|upstream|Chenyu)/i.test(line)
|
|
149
|
-
) {
|
|
150
|
-
confirm.push(where + ' 出现旧包名(确认语境)→ ' + line.trim().slice(0, 80))
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// C5. 反引号里的仓库路径
|
|
154
|
-
for (const m of line.matchAll(/`([A-Za-z0-9._/-]+\.(?:md|ts|js|mjs|cjs|json|yml|yaml))`/g)) {
|
|
155
|
-
const p = m[1]
|
|
156
|
-
if (!p.includes('/') || p.startsWith('~') || p.startsWith('http')) continue
|
|
157
|
-
const abs = p.startsWith('.') ? resolve(root, f, '..', p) : join(root, p)
|
|
158
|
-
if (existsSync(abs)) continue
|
|
159
|
-
if (basenames.has(p.slice(p.lastIndexOf('/') + 1)))
|
|
160
|
-
problems.push(where + ' 路径写错:`' + p + '`(文件在本仓库,但不在这个位置)')
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// C6. 「文件:行号」引用
|
|
164
|
-
for (const m of line.matchAll(/([A-Za-z0-9_./-]+\.(?:ts|js|mjs|cjs|json|yml))[^\n]{0,12}?[:(]\s*~?:?(\d{3,5})/g)) {
|
|
165
|
-
const abs = join(root, m[1].replace(/^\.\//, ''))
|
|
166
|
-
if (!existsSync(abs)) continue
|
|
167
|
-
const lines = readFileSync(abs, 'utf8').split('\n').length
|
|
168
|
-
if (Number(m[2]) > lines)
|
|
169
|
-
problems.push(where + ' 行号越界:' + m[1] + ':' + m[2] + '(该文件现在 ' + lines + ' 行)')
|
|
170
|
-
}
|
|
171
|
-
})
|
|
172
|
-
// C7. markdown 相对链接
|
|
173
|
-
for (const m of readOrThrow(f).matchAll(/\]\(([^)#\s]+)(?:#[^)]*)?\)/g)) {
|
|
174
|
-
const target = m[1]
|
|
175
|
-
if (/^(https?:|mailto:)/.test(target) || target.startsWith('#')) continue
|
|
176
|
-
if (!existsSync(resolve(root, f, '..', target))) problems.push(f + ' markdown 链接失效:' + target)
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ── 输出 ─────────────────────────────────────────────────────────────────────
|
|
181
|
-
console.log('=== ✗ 必须修(硬错误)===')
|
|
182
|
-
console.log(problems.length ? problems.map((p) => ' ✗ ' + p).join('\n') : ' (无)')
|
|
183
|
-
console.log('')
|
|
184
|
-
console.log('=== ? 待确认(历史 / 上游 / 有意为之)===')
|
|
185
|
-
const uniqueConfirm = [...new Set(confirm)]
|
|
186
|
-
console.log(uniqueConfirm.length ? uniqueConfirm.map((c) => ' ? ' + c).join('\n') : ' (无)')
|
|
187
|
-
console.log('')
|
|
188
|
-
console.log('=== ✓ 已核对 ===')
|
|
189
|
-
console.log(ok.map((o) => ' ✓ ' + o).join('\n'))
|
|
190
|
-
console.log('')
|
|
191
|
-
console.log('结论:' + problems.length + ' 处硬错误、' + uniqueConfirm.length + ' 处待确认。')
|
|
192
|
-
process.exitCode = problems.length ? 2 : 0
|
package/scripts/prompt-cost.mjs
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
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/provider.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)
|