dsh-browser-verify 0.1.3 → 0.1.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/README.md +29 -8
- package/README.zh.md +30 -12
- package/lib/cli.js +1 -1
- package/lib/{driver-Dwuu4MgP.js → driver-POR2-Czc.js} +61 -3
- package/lib/index.js +62 -18
- package/lib/types/attachments.d.ts +25 -0
- package/lib/types/browser/discover.d.ts +8 -1
- package/package.json +1 -1
- package/src/attachments.ts +48 -1
- package/src/browser/discover.ts +90 -17
- package/src/tools/index.ts +12 -14
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ verification is just tool calls.
|
|
|
18
18
|
## Quick start
|
|
19
19
|
|
|
20
20
|
```sh
|
|
21
|
-
dsh plugin --profile web add dsh-browser-verify@0.1.
|
|
21
|
+
dsh plugin --profile web add dsh-browser-verify@0.1.5
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
1. **Install** with the command above (or see [Install](#install)).
|
|
@@ -52,6 +52,22 @@ state?"; add `browser_screenshot` when you need to see the layout, or
|
|
|
52
52
|
Use `browser_assert` before `browser_screenshot`: an assertion is cheaper, and
|
|
53
53
|
a screenshot is for when the rendering itself must be judged.
|
|
54
54
|
|
|
55
|
+
## Real model experience
|
|
56
|
+
|
|
57
|
+
> **"Pleasant to use, cleanly layered."** — a real model's verdict, after
|
|
58
|
+
> putting the four tools to work on a real business page.
|
|
59
|
+
|
|
60
|
+
- **A three-state verification in 4–5 tool calls, zero environment setup** —
|
|
61
|
+
the same job used to take ~20 manual script steps.
|
|
62
|
+
- **Mock, then it reloads itself** — no more "same URL, screenshot unchanged"
|
|
63
|
+
traps.
|
|
64
|
+
- **Screenshots land right in the model's context** — no file paths to chase.
|
|
65
|
+
- **Structured asserts** (count / text / elapsed) that even warn you when the
|
|
66
|
+
page didn't actually refresh.
|
|
67
|
+
- **Every open resets the world** — states never bleed into each other.
|
|
68
|
+
|
|
69
|
+
Verification should be a few tool calls, not script choreography.
|
|
70
|
+
|
|
55
71
|
### Worked example — two states, six calls
|
|
56
72
|
|
|
57
73
|
The typical verification (empty state + normal state) is 6 calls:
|
|
@@ -69,7 +85,7 @@ browser_screenshot
|
|
|
69
85
|
## Install
|
|
70
86
|
|
|
71
87
|
```sh
|
|
72
|
-
dsh plugin --profile web add dsh-browser-verify@0.1.
|
|
88
|
+
dsh plugin --profile web add dsh-browser-verify@0.1.5
|
|
73
89
|
```
|
|
74
90
|
|
|
75
91
|
The version is pinned on purpose: pnpm 11 holds back packages published in the
|
|
@@ -79,18 +95,23 @@ of this deployment — use your own profile name if it differs.
|
|
|
79
95
|
|
|
80
96
|
Requires **dsh ≥ 0.1.2-alpha.1**.
|
|
81
97
|
|
|
82
|
-
### Browser prerequisite (
|
|
98
|
+
### Browser prerequisite (usually none)
|
|
99
|
+
|
|
100
|
+
The plugin does **not** download Chromium — it finds a browser on your machine:
|
|
101
|
+
the playwright cache (any installed version), then system Chrome / Chromium /
|
|
102
|
+
Edge on common install paths and `$PATH`. Most machines — anything that ever
|
|
103
|
+
ran a playwright project, or has Chrome installed — work with zero setup.
|
|
83
104
|
|
|
84
|
-
|
|
85
|
-
instead. Install it once with:
|
|
105
|
+
Only when neither exists, install the browser once:
|
|
86
106
|
|
|
87
107
|
```sh
|
|
88
|
-
npx playwright install chromium
|
|
108
|
+
npx playwright install chromium
|
|
89
109
|
```
|
|
90
110
|
|
|
91
111
|
or point the plugin at an existing binary via `DSH_BROWSER_VERIFY_CHROMIUM`
|
|
92
|
-
(see [Environment variables](#environment-variables)).
|
|
93
|
-
|
|
112
|
+
(see [Environment variables](#environment-variables)). A system browser is
|
|
113
|
+
used as-is with an "unverified version" hint; without any browser, the first
|
|
114
|
+
`browser_open` fails with an actionable install hint.
|
|
94
115
|
|
|
95
116
|
## Environment variables
|
|
96
117
|
|
package/README.zh.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
## 快速上手
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
|
-
dsh plugin --profile web add dsh-browser-verify@0.1.
|
|
15
|
+
dsh plugin --profile web add dsh-browser-verify@0.1.5
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
1. **安装**(更多方式见 [安装](#安装))。
|
|
@@ -34,16 +34,29 @@ browser_assert selector=".empty-wrap" text="暂无可用缴费服务"
|
|
|
34
34
|
|
|
35
35
|
## 它做什么
|
|
36
36
|
|
|
37
|
-
| 工具 | 用途
|
|
38
|
-
| ---------------------- |
|
|
39
|
-
| `browser_open` | 在全新场景中打开 URL(无头 Chromium,默认视口 390×844 @2x),返回 title / HTTP 状态 / 可见文本摘要 / console
|
|
40
|
-
| `browser_mock` | 注册 playwright glob 路由(如`**/api/*.do*`)返回指定 JSON,并自动 reload 页面展示 mock 状态——不碰后端最快验证空态/异常态。重复 pattern 会提示报错。
|
|
41
|
-
| `browser_assert` | 最省 token 也最精确的验证:等待 CSS 选择器出现,校验数量与包含文本,返回`{pass, count, actualText, elapsedMs}`。不满足时返回 `pass:false`(附差异),**绝不抛错**——失败是一等公民的结果,而不是要调试的异常。
|
|
42
|
-
| `browser_screenshot` | 截取当前页面(视口或整页),**自动以 image block 投影到模型上下文**——模型直接"看见"版式,无需任何文件处理。返回尺寸、sha256;与上一张完全一致时 `identicalToPrevious:true`(疑似页面未刷新)。
|
|
37
|
+
| 工具 | 用途 |
|
|
38
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
39
|
+
| `browser_open` | 在全新场景中打开 URL(无头 Chromium,默认视口 390×844 @2x),返回 title / HTTP 状态 / 可见文本摘要 / console 错误。不传`waitSelector` 时默认等待页面**渲染稳定**后再采样(连续两次相同可见文本即稳定,上限约 3s),不会采到启动骨架帧;`加载中...` 等加载态文案自动过滤。可选 `waitSelector` 等待关键元素出现后再返回;可选内联 `mocks` 在**首次导航前**拦截接口——用于一启动就依赖 mock 数据的页面。 |
|
|
40
|
+
| `browser_mock` | 注册 playwright glob 路由(如`**/api/*.do*`)返回指定 JSON,并自动 reload 页面展示 mock 状态——不碰后端最快验证空态/异常态。重复 pattern 会提示报错。 |
|
|
41
|
+
| `browser_assert` | 最省 token 也最精确的验证:等待 CSS 选择器出现,校验数量与包含文本,返回`{pass, count, actualText, elapsedMs}`。不满足时返回 `pass:false`(附差异),**绝不抛错**——失败是一等公民的结果,而不是要调试的异常。 |
|
|
42
|
+
| `browser_screenshot` | 截取当前页面(视口或整页),**自动以 image block 投影到模型上下文**——模型直接"看见"版式,无需任何文件处理。返回尺寸、sha256;与上一张完全一致时 `identicalToPrevious:true`(疑似页面未刷新)。 |
|
|
43
43
|
|
|
44
44
|
先 `browser_assert` 再 `browser_screenshot`:断言更便宜,截图留给必须判断
|
|
45
45
|
渲染效果的时刻。
|
|
46
46
|
|
|
47
|
+
## 真实模型使用体验
|
|
48
|
+
|
|
49
|
+
> **"好用,层级分明。"** —— 一位真实使用过的大模型的评价:把四件套拿真实的
|
|
50
|
+
> 业务页面轮了一遍。
|
|
51
|
+
|
|
52
|
+
- **三态验证 4–5 次工具调用跑完,零环境搭建**——以前同样的事要 ~20 步手动脚本;
|
|
53
|
+
- **mock 后自动 reload**——不会再踩"同 URL、截图不变"的坑;
|
|
54
|
+
- **截图直接进模型上下文**——不用追文件路径;
|
|
55
|
+
- **断言结构化**(count / text / 耗时),页面没真刷新还会主动提醒你;
|
|
56
|
+
- **每次打开场景全新**——三态之间互不串扰。
|
|
57
|
+
|
|
58
|
+
验证就该是几次工具调用,而不是脚本编排。
|
|
59
|
+
|
|
47
60
|
### 完整示例——两态六次调用
|
|
48
61
|
|
|
49
62
|
典型验证(空态 + 正常态)只需 6 次调用:
|
|
@@ -61,7 +74,7 @@ browser_screenshot
|
|
|
61
74
|
## 安装
|
|
62
75
|
|
|
63
76
|
```sh
|
|
64
|
-
dsh plugin --profile web add dsh-browser-verify@0.1.
|
|
77
|
+
dsh plugin --profile web add dsh-browser-verify@0.1.5
|
|
65
78
|
```
|
|
66
79
|
|
|
67
80
|
版本故意钉死:pnpm 11 会暂缓 24 小时内新发布的包,裸写 `add dsh-browser-verify`(latest)会在发布当天装到上一个版本。`--profile web`
|
|
@@ -69,16 +82,21 @@ dsh plugin --profile web add dsh-browser-verify@0.1.3
|
|
|
69
82
|
|
|
70
83
|
要求 **dsh ≥ 0.1.2-alpha.1**。
|
|
71
84
|
|
|
72
|
-
###
|
|
85
|
+
### 浏览器前置(通常无需)
|
|
86
|
+
|
|
87
|
+
本插件**不自带 Chromium**——它在你机器上找浏览器:playwright 缓存(任意安装版本),
|
|
88
|
+
然后是系统 Chrome / Chromium / Edge 的常见安装路径与 `$PATH`。多数机器——装过任何
|
|
89
|
+
playwright 项目,或装有 Chrome——零配置直接可用。
|
|
73
90
|
|
|
74
|
-
|
|
91
|
+
两者都没有时才需要装一次:
|
|
75
92
|
|
|
76
93
|
```sh
|
|
77
|
-
npx playwright install chromium
|
|
94
|
+
npx playwright install chromium
|
|
78
95
|
```
|
|
79
96
|
|
|
80
97
|
或通过 `DSH_BROWSER_VERIFY_CHROMIUM` 指向已有二进制(见
|
|
81
|
-
[环境变量](#环境变量)
|
|
98
|
+
[环境变量](#环境变量))。系统浏览器直接使用(附"未认证版本"提示);一个浏览器
|
|
99
|
+
都没有时,第一次 `browser_open` 会给出可操作的安装提示。
|
|
82
100
|
|
|
83
101
|
## 环境变量
|
|
84
102
|
|
package/lib/cli.js
CHANGED
|
@@ -23,6 +23,48 @@ const LIST_PREFIXES = [{
|
|
|
23
23
|
kind: "chromium",
|
|
24
24
|
prefix: "chromium-"
|
|
25
25
|
}];
|
|
26
|
+
/** PATH command names probed as a last-resort system-browser fallback. */
|
|
27
|
+
const SYSTEM_COMMANDS = [
|
|
28
|
+
"google-chrome",
|
|
29
|
+
"google-chrome-stable",
|
|
30
|
+
"chromium",
|
|
31
|
+
"chromium-browser",
|
|
32
|
+
"chrome",
|
|
33
|
+
"chrome.exe"
|
|
34
|
+
];
|
|
35
|
+
/** Common install locations for system Chromium-based browsers, per platform. */
|
|
36
|
+
function systemBrowserCandidates(platform = process.platform, home = homedir()) {
|
|
37
|
+
if (platform === "win32") return [
|
|
38
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
39
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
40
|
+
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
41
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
|
|
42
|
+
];
|
|
43
|
+
if (platform === "linux") return [
|
|
44
|
+
"/usr/bin/google-chrome",
|
|
45
|
+
"/usr/bin/google-chrome-stable",
|
|
46
|
+
"/usr/bin/chromium",
|
|
47
|
+
"/usr/bin/chromium-browser",
|
|
48
|
+
"/usr/bin/chrome",
|
|
49
|
+
"/snap/bin/chromium",
|
|
50
|
+
"/snap/bin/google-chrome"
|
|
51
|
+
];
|
|
52
|
+
return [
|
|
53
|
+
"Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
54
|
+
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
55
|
+
"Chromium.app/Contents/MacOS/Chromium"
|
|
56
|
+
].flatMap((app) => [`/Applications/${app}`, join(home, "Applications", app)]);
|
|
57
|
+
}
|
|
58
|
+
/** Resolve a command name on $PATH using the injected exists probe (no exec). */
|
|
59
|
+
function resolveCommandOnPath(command, pathEnv, exists, sep = process.platform === "win32" ? ";" : ":") {
|
|
60
|
+
if (pathEnv === void 0 || pathEnv === "") return null;
|
|
61
|
+
for (const dir of pathEnv.split(sep)) {
|
|
62
|
+
if (dir === "") continue;
|
|
63
|
+
const candidate = join(dir, command);
|
|
64
|
+
if (exists(candidate)) return candidate;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
26
68
|
/** Default cache location on macOS. */
|
|
27
69
|
function defaultCacheDir() {
|
|
28
70
|
return join(homedir(), "Library", "Caches", "ms-playwright");
|
|
@@ -62,8 +104,7 @@ function discoverBrowser(opts = {}) {
|
|
|
62
104
|
} catch {
|
|
63
105
|
list = null;
|
|
64
106
|
}
|
|
65
|
-
if (list
|
|
66
|
-
for (const { kind, prefix } of LIST_PREFIXES) {
|
|
107
|
+
if (list !== null) for (const { kind, prefix } of LIST_PREFIXES) {
|
|
67
108
|
const revision = maxRevision(list, prefix);
|
|
68
109
|
if (revision === null) continue;
|
|
69
110
|
const executablePath = join(cacheDir, `${kind === "headless-shell" ? `chromium_headless_shell-${revision}` : `chromium-${revision}`}`, SUBDIRS[kind]);
|
|
@@ -77,7 +118,24 @@ function discoverBrowser(opts = {}) {
|
|
|
77
118
|
versionHint: known ? null : `浏览器 revision ${revision} 不在已认证表(playwright-core 1.62.0 认证 ${Object.keys(KNOWN_REVISIONS).join("/")});若协议异常,请安装匹配版本`
|
|
78
119
|
};
|
|
79
120
|
}
|
|
80
|
-
|
|
121
|
+
const system = findSystemBrowser(opts, exists);
|
|
122
|
+
if (system !== null) return system;
|
|
123
|
+
throw new Error(`browser-verify: 未找到可用的浏览器(已探测:playwright 缓存 ${cacheDir} 与系统 Chrome/Chromium/Edge 常见路径)。请执行一次 npx playwright install chromium 后重试,或用 DSH_BROWSER_VERIFY_CHROMIUM 指定已有二进制。`);
|
|
124
|
+
}
|
|
125
|
+
/** First existing system-browser candidate, or null. */
|
|
126
|
+
function findSystemBrowser(opts, exists) {
|
|
127
|
+
const candidates = opts.systemCandidates ?? [...systemBrowserCandidates(), ...SYSTEM_COMMANDS.map((cmd) => resolveCommandOnPath(cmd, opts.pathEnv ?? process.env.PATH, exists)).filter((p) => p !== null)];
|
|
128
|
+
for (const candidate of candidates) {
|
|
129
|
+
if (!exists(candidate)) continue;
|
|
130
|
+
return {
|
|
131
|
+
executablePath: candidate,
|
|
132
|
+
kind: "system",
|
|
133
|
+
revision: 0,
|
|
134
|
+
known: false,
|
|
135
|
+
versionHint: `系统浏览器(未与插件认证版本 ${Object.keys(KNOWN_REVISIONS).join("/")} 对齐);若渲染异常,请执行 npx playwright install chromium 安装匹配版本`
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
81
139
|
}
|
|
82
140
|
//#endregion
|
|
83
141
|
//#region src/browser/scenario.ts
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as BrowserDriver } from "./driver-
|
|
1
|
+
import { t as BrowserDriver } from "./driver-POR2-Czc.js";
|
|
2
2
|
import { exec } from "node:child_process";
|
|
3
3
|
import { readdir, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -29,6 +29,18 @@ function selectOrphanDirs(entries, nowMs, ageMs, prefix) {
|
|
|
29
29
|
}
|
|
30
30
|
//#endregion
|
|
31
31
|
//#region src/attachments.ts
|
|
32
|
+
/**
|
|
33
|
+
* Formats a stored screenshot can carry: Playwright captures PNG, and the
|
|
34
|
+
* attachment store re-encodes above its normalization budget (JPEG, or WebP
|
|
35
|
+
* when the source keeps alpha). Every entry is a possible store fact — never
|
|
36
|
+
* assume PNG.
|
|
37
|
+
*/
|
|
38
|
+
const SCREENSHOT_MEDIA_TYPES = [
|
|
39
|
+
"image/png",
|
|
40
|
+
"image/jpeg",
|
|
41
|
+
"image/webp",
|
|
42
|
+
"image/gif"
|
|
43
|
+
];
|
|
32
44
|
function imageRefFromValue(image) {
|
|
33
45
|
return {
|
|
34
46
|
attachmentId: AttachmentId(image.attachmentId),
|
|
@@ -36,14 +48,44 @@ function imageRefFromValue(image) {
|
|
|
36
48
|
bytes: image.bytes,
|
|
37
49
|
width: image.width,
|
|
38
50
|
height: image.height,
|
|
39
|
-
...image.name === void 0 ? {} : { name: image.name }
|
|
51
|
+
...image.name === void 0 ? {} : { name: image.name },
|
|
52
|
+
...image.originalDimensions === void 0 ? {} : { originalDimensions: image.originalDimensions }
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the tool value from the store reference alone. Every fact — format,
|
|
57
|
+
* byte length, dimensions — is copied from what the store actually published,
|
|
58
|
+
* because the read path re-derives them from the stored bytes and rejects any
|
|
59
|
+
* reference that disagrees. Restating a constant here (e.g. PNG for a capture
|
|
60
|
+
* the store normalized to JPEG) writes a self-contradicting attachment
|
|
61
|
+
* reference into immutable history, which fails every later model request.
|
|
62
|
+
* @param ref - reference returned by `saveImage`.
|
|
63
|
+
* @param sha256 - digest of the captured bytes.
|
|
64
|
+
* @param identicalToPrevious - whether this capture repeated the previous one.
|
|
65
|
+
* @returns the tool-facing value projected into the model context.
|
|
66
|
+
*/
|
|
67
|
+
function screenshotValueFrom(ref, sha256, identicalToPrevious) {
|
|
68
|
+
return {
|
|
69
|
+
image: {
|
|
70
|
+
attachmentId: String(ref.attachmentId),
|
|
71
|
+
mediaType: ref.mediaType,
|
|
72
|
+
bytes: ref.bytes,
|
|
73
|
+
width: ref.width,
|
|
74
|
+
height: ref.height,
|
|
75
|
+
...ref.name === void 0 ? {} : { name: ref.name },
|
|
76
|
+
...ref.originalDimensions === void 0 ? {} : { originalDimensions: ref.originalDimensions }
|
|
77
|
+
},
|
|
78
|
+
sha256,
|
|
79
|
+
identicalToPrevious
|
|
40
80
|
};
|
|
41
81
|
}
|
|
42
82
|
function renderScreenshotBlocks(value) {
|
|
43
83
|
const dup = value.identicalToPrevious ? "(与上一张截图哈希相同,疑似页面未刷新;请 browser_open 重开场景后重试)" : "";
|
|
84
|
+
const original = value.image.originalDimensions;
|
|
85
|
+
const scaled = original === void 0 ? "" : `(原图 ${original.width}x${original.height},已按宿主预算缩放)`;
|
|
44
86
|
return [{
|
|
45
87
|
type: "text",
|
|
46
|
-
text: `<type>screenshot</type>\n<content>\n${value.image.mediaType}, ${value.image.width}x${value.image.height} px, ${value.image.bytes} bytes, sha256 ${value.sha256.slice(0, 12)}${dup}\n</content>`
|
|
88
|
+
text: `<type>screenshot</type>\n<content>\n${value.image.mediaType}, ${value.image.width}x${value.image.height} px${scaled}, ${value.image.bytes} bytes, sha256 ${value.sha256.slice(0, 12)}${dup}\n</content>`
|
|
47
89
|
}, {
|
|
48
90
|
type: "image",
|
|
49
91
|
attachment: imageRefFromValue(value.image)
|
|
@@ -380,7 +422,7 @@ function registerBrowserTools(ctx) {
|
|
|
380
422
|
},
|
|
381
423
|
mediaType: {
|
|
382
424
|
type: "string",
|
|
383
|
-
enum: [
|
|
425
|
+
enum: [...SCREENSHOT_MEDIA_TYPES],
|
|
384
426
|
required: true
|
|
385
427
|
},
|
|
386
428
|
bytes: {
|
|
@@ -395,7 +437,21 @@ function registerBrowserTools(ctx) {
|
|
|
395
437
|
type: "integer",
|
|
396
438
|
required: true
|
|
397
439
|
},
|
|
398
|
-
name: { type: "string" }
|
|
440
|
+
name: { type: "string" },
|
|
441
|
+
originalDimensions: {
|
|
442
|
+
type: "object",
|
|
443
|
+
additionalProperties: false,
|
|
444
|
+
properties: {
|
|
445
|
+
width: {
|
|
446
|
+
type: "number",
|
|
447
|
+
required: true
|
|
448
|
+
},
|
|
449
|
+
height: {
|
|
450
|
+
type: "number",
|
|
451
|
+
required: true
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
399
455
|
}
|
|
400
456
|
},
|
|
401
457
|
sha256: {
|
|
@@ -414,19 +470,7 @@ function registerBrowserTools(ctx) {
|
|
|
414
470
|
await assertImageCapable(ctx, exec);
|
|
415
471
|
return driver.withScenario(async (scenario) => {
|
|
416
472
|
const shot = await scenario.screenshot({ fullPage: args.fullPage });
|
|
417
|
-
|
|
418
|
-
return {
|
|
419
|
-
image: {
|
|
420
|
-
attachmentId: String(ref.attachmentId),
|
|
421
|
-
mediaType: "image/png",
|
|
422
|
-
bytes: ref.bytes,
|
|
423
|
-
width: ref.width,
|
|
424
|
-
height: ref.height,
|
|
425
|
-
...ref.name === void 0 ? {} : { name: ref.name }
|
|
426
|
-
},
|
|
427
|
-
sha256: shot.sha256,
|
|
428
|
-
identicalToPrevious: shot.identicalToPrevious
|
|
429
|
-
};
|
|
473
|
+
return screenshotValueFrom(await saveScreenshot(ctx, shot.data, args.name), shot.sha256, shot.identicalToPrevious);
|
|
430
474
|
});
|
|
431
475
|
}
|
|
432
476
|
}));
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
import type { Context } from '@deepseek-ai/cordis';
|
|
8
8
|
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment';
|
|
9
9
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm';
|
|
10
|
+
/**
|
|
11
|
+
* Formats a stored screenshot can carry: Playwright captures PNG, and the
|
|
12
|
+
* attachment store re-encodes above its normalization budget (JPEG, or WebP
|
|
13
|
+
* when the source keeps alpha). Every entry is a possible store fact — never
|
|
14
|
+
* assume PNG.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SCREENSHOT_MEDIA_TYPES: readonly ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
10
17
|
export interface ScreenshotImage {
|
|
11
18
|
attachmentId: string;
|
|
12
19
|
mediaType: ImageMediaType;
|
|
@@ -14,6 +21,11 @@ export interface ScreenshotImage {
|
|
|
14
21
|
width: number;
|
|
15
22
|
height: number;
|
|
16
23
|
name?: string;
|
|
24
|
+
/** Present only when the store downscaled the capture; the pre-scaling pixels. */
|
|
25
|
+
originalDimensions?: {
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
};
|
|
17
29
|
}
|
|
18
30
|
export interface ScreenshotValue {
|
|
19
31
|
image: ScreenshotImage;
|
|
@@ -21,6 +33,19 @@ export interface ScreenshotValue {
|
|
|
21
33
|
identicalToPrevious: boolean;
|
|
22
34
|
}
|
|
23
35
|
export declare function imageRefFromValue(image: ScreenshotImage): ImageAttachmentRef;
|
|
36
|
+
/**
|
|
37
|
+
* Build the tool value from the store reference alone. Every fact — format,
|
|
38
|
+
* byte length, dimensions — is copied from what the store actually published,
|
|
39
|
+
* because the read path re-derives them from the stored bytes and rejects any
|
|
40
|
+
* reference that disagrees. Restating a constant here (e.g. PNG for a capture
|
|
41
|
+
* the store normalized to JPEG) writes a self-contradicting attachment
|
|
42
|
+
* reference into immutable history, which fails every later model request.
|
|
43
|
+
* @param ref - reference returned by `saveImage`.
|
|
44
|
+
* @param sha256 - digest of the captured bytes.
|
|
45
|
+
* @param identicalToPrevious - whether this capture repeated the previous one.
|
|
46
|
+
* @returns the tool-facing value projected into the model context.
|
|
47
|
+
*/
|
|
48
|
+
export declare function screenshotValueFrom(ref: ImageAttachmentRef, sha256: string, identicalToPrevious: boolean): ScreenshotValue;
|
|
24
49
|
export declare function renderScreenshotBlocks(value: ScreenshotValue): ContentBlock[];
|
|
25
50
|
/** Persist screenshot bytes, mapping store refusals to actionable errors. */
|
|
26
51
|
export declare function saveScreenshot(ctx: Context, data: Buffer, name: string | undefined): Promise<ImageAttachmentRef>;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* filesystem probing is injected so every branch is unit-testable.
|
|
4
4
|
* @module dsh-browser-verify/browser/discover
|
|
5
5
|
*/
|
|
6
|
-
export type BrowserKind = 'headless-shell' | 'chromium' | 'custom';
|
|
6
|
+
export type BrowserKind = 'headless-shell' | 'chromium' | 'custom' | 'system';
|
|
7
7
|
/** Revision numbers verified against the matching playwright-core browsers.json. */
|
|
8
8
|
export declare const KNOWN_REVISIONS: Readonly<Record<number, string>>;
|
|
9
9
|
export interface DiscoveredBrowser {
|
|
@@ -19,7 +19,14 @@ export interface DiscoverOptions {
|
|
|
19
19
|
exists?: (path: string) => boolean;
|
|
20
20
|
/** Directory listing of cacheDir; defaults to readdirSync(cacheDir) (throws → treated as missing). */
|
|
21
21
|
entries?: string[];
|
|
22
|
+
/** System-browser candidate paths; defaults to common install locations + PATH commands. */
|
|
23
|
+
systemCandidates?: string[];
|
|
24
|
+
pathEnv?: string;
|
|
22
25
|
}
|
|
26
|
+
/** Common install locations for system Chromium-based browsers, per platform. */
|
|
27
|
+
export declare function systemBrowserCandidates(platform?: string, home?: string): string[];
|
|
28
|
+
/** Resolve a command name on $PATH using the injected exists probe (no exec). */
|
|
29
|
+
export declare function resolveCommandOnPath(command: string, pathEnv: string | undefined, exists: (path: string) => boolean, sep?: string): string | null;
|
|
23
30
|
/** Default cache location on macOS. */
|
|
24
31
|
export declare function defaultCacheDir(): string;
|
|
25
32
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-verify",
|
|
3
3
|
"description": "Read-only browser verification tools for the DeepSeek Harness web GUI: browser_open / browser_mock / browser_assert / browser_screenshot — verify a page (H5/desktop) in ≤4 tool calls with mock interception, DOM assertions, and screenshots that auto-project into the model context.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.7.0",
|
|
7
7
|
"engines": {
|
package/src/attachments.ts
CHANGED
|
@@ -10,6 +10,16 @@ import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attach
|
|
|
10
10
|
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
|
|
11
11
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Formats a stored screenshot can carry: Playwright captures PNG, and the
|
|
15
|
+
* attachment store re-encodes above its normalization budget (JPEG, or WebP
|
|
16
|
+
* when the source keeps alpha). Every entry is a possible store fact — never
|
|
17
|
+
* assume PNG.
|
|
18
|
+
*/
|
|
19
|
+
export const SCREENSHOT_MEDIA_TYPES = [
|
|
20
|
+
'image/png', 'image/jpeg', 'image/webp', 'image/gif',
|
|
21
|
+
] as const satisfies readonly ImageMediaType[]
|
|
22
|
+
|
|
13
23
|
export interface ScreenshotImage {
|
|
14
24
|
attachmentId: string
|
|
15
25
|
mediaType: ImageMediaType
|
|
@@ -17,6 +27,8 @@ export interface ScreenshotImage {
|
|
|
17
27
|
width: number
|
|
18
28
|
height: number
|
|
19
29
|
name?: string
|
|
30
|
+
/** Present only when the store downscaled the capture; the pre-scaling pixels. */
|
|
31
|
+
originalDimensions?: { width: number; height: number }
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
export interface ScreenshotValue {
|
|
@@ -33,6 +45,39 @@ export function imageRefFromValue(image: ScreenshotImage): ImageAttachmentRef {
|
|
|
33
45
|
width: image.width,
|
|
34
46
|
height: image.height,
|
|
35
47
|
...image.name === undefined ? {} : { name: image.name },
|
|
48
|
+
...image.originalDimensions === undefined ? {} : { originalDimensions: image.originalDimensions },
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the tool value from the store reference alone. Every fact — format,
|
|
54
|
+
* byte length, dimensions — is copied from what the store actually published,
|
|
55
|
+
* because the read path re-derives them from the stored bytes and rejects any
|
|
56
|
+
* reference that disagrees. Restating a constant here (e.g. PNG for a capture
|
|
57
|
+
* the store normalized to JPEG) writes a self-contradicting attachment
|
|
58
|
+
* reference into immutable history, which fails every later model request.
|
|
59
|
+
* @param ref - reference returned by `saveImage`.
|
|
60
|
+
* @param sha256 - digest of the captured bytes.
|
|
61
|
+
* @param identicalToPrevious - whether this capture repeated the previous one.
|
|
62
|
+
* @returns the tool-facing value projected into the model context.
|
|
63
|
+
*/
|
|
64
|
+
export function screenshotValueFrom(
|
|
65
|
+
ref: ImageAttachmentRef,
|
|
66
|
+
sha256: string,
|
|
67
|
+
identicalToPrevious: boolean,
|
|
68
|
+
): ScreenshotValue {
|
|
69
|
+
return {
|
|
70
|
+
image: {
|
|
71
|
+
attachmentId: String(ref.attachmentId),
|
|
72
|
+
mediaType: ref.mediaType,
|
|
73
|
+
bytes: ref.bytes,
|
|
74
|
+
width: ref.width,
|
|
75
|
+
height: ref.height,
|
|
76
|
+
...ref.name === undefined ? {} : { name: ref.name },
|
|
77
|
+
...ref.originalDimensions === undefined ? {} : { originalDimensions: ref.originalDimensions },
|
|
78
|
+
},
|
|
79
|
+
sha256,
|
|
80
|
+
identicalToPrevious,
|
|
36
81
|
}
|
|
37
82
|
}
|
|
38
83
|
|
|
@@ -40,10 +85,12 @@ export function renderScreenshotBlocks(value: ScreenshotValue): ContentBlock[] {
|
|
|
40
85
|
const dup = value.identicalToPrevious
|
|
41
86
|
? '(与上一张截图哈希相同,疑似页面未刷新;请 browser_open 重开场景后重试)'
|
|
42
87
|
: ''
|
|
88
|
+
const original = value.image.originalDimensions
|
|
89
|
+
const scaled = original === undefined ? '' : `(原图 ${original.width}x${original.height},已按宿主预算缩放)`
|
|
43
90
|
return [
|
|
44
91
|
{
|
|
45
92
|
type: 'text',
|
|
46
|
-
text: `<type>screenshot</type>\n<content>\n${value.image.mediaType}, ${value.image.width}x${value.image.height} px, ${value.image.bytes} bytes, sha256 ${value.sha256.slice(0, 12)}${dup}\n</content>`,
|
|
93
|
+
text: `<type>screenshot</type>\n<content>\n${value.image.mediaType}, ${value.image.width}x${value.image.height} px${scaled}, ${value.image.bytes} bytes, sha256 ${value.sha256.slice(0, 12)}${dup}\n</content>`,
|
|
47
94
|
},
|
|
48
95
|
{ type: 'image', attachment: imageRefFromValue(value.image) },
|
|
49
96
|
]
|
package/src/browser/discover.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { existsSync, readdirSync } from 'node:fs'
|
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
|
|
11
|
-
export type BrowserKind = 'headless-shell' | 'chromium' | 'custom'
|
|
11
|
+
export type BrowserKind = 'headless-shell' | 'chromium' | 'custom' | 'system'
|
|
12
12
|
|
|
13
13
|
/** Revision numbers verified against the matching playwright-core browsers.json. */
|
|
14
14
|
export const KNOWN_REVISIONS: Readonly<Record<number, string>> = {
|
|
@@ -29,6 +29,9 @@ export interface DiscoverOptions {
|
|
|
29
29
|
exists?: (path: string) => boolean
|
|
30
30
|
/** Directory listing of cacheDir; defaults to readdirSync(cacheDir) (throws → treated as missing). */
|
|
31
31
|
entries?: string[]
|
|
32
|
+
/** System-browser candidate paths; defaults to common install locations + PATH commands. */
|
|
33
|
+
systemCandidates?: string[]
|
|
34
|
+
pathEnv?: string
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
const SUBDIRS: Readonly<Record<'headless-shell' | 'chromium', string>> = {
|
|
@@ -41,6 +44,55 @@ const LIST_PREFIXES: Readonly<Array<{ kind: 'headless-shell' | 'chromium'; prefi
|
|
|
41
44
|
{ kind: 'chromium', prefix: 'chromium-' },
|
|
42
45
|
]
|
|
43
46
|
|
|
47
|
+
/** PATH command names probed as a last-resort system-browser fallback. */
|
|
48
|
+
const SYSTEM_COMMANDS = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome', 'chrome.exe']
|
|
49
|
+
|
|
50
|
+
/** Common install locations for system Chromium-based browsers, per platform. */
|
|
51
|
+
export function systemBrowserCandidates(platform: string = process.platform, home: string = homedir()): string[] {
|
|
52
|
+
if (platform === 'win32') {
|
|
53
|
+
return [
|
|
54
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
55
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
56
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
57
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
if (platform === 'linux') {
|
|
61
|
+
return [
|
|
62
|
+
'/usr/bin/google-chrome',
|
|
63
|
+
'/usr/bin/google-chrome-stable',
|
|
64
|
+
'/usr/bin/chromium',
|
|
65
|
+
'/usr/bin/chromium-browser',
|
|
66
|
+
'/usr/bin/chrome',
|
|
67
|
+
'/snap/bin/chromium',
|
|
68
|
+
'/snap/bin/google-chrome',
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
// darwin (and anything else unix-like): app bundles in system + user Applications.
|
|
72
|
+
const apps = [
|
|
73
|
+
'Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
74
|
+
'Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
75
|
+
'Chromium.app/Contents/MacOS/Chromium',
|
|
76
|
+
]
|
|
77
|
+
return apps.flatMap(app => [`/Applications/${app}`, join(home, 'Applications', app)])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Resolve a command name on $PATH using the injected exists probe (no exec). */
|
|
81
|
+
export function resolveCommandOnPath(
|
|
82
|
+
command: string,
|
|
83
|
+
pathEnv: string | undefined,
|
|
84
|
+
exists: (path: string) => boolean,
|
|
85
|
+
sep: string = process.platform === 'win32' ? ';' : ':',
|
|
86
|
+
): string | null {
|
|
87
|
+
if (pathEnv === undefined || pathEnv === '') return null
|
|
88
|
+
for (const dir of pathEnv.split(sep)) {
|
|
89
|
+
if (dir === '') continue
|
|
90
|
+
const candidate = join(dir, command)
|
|
91
|
+
if (exists(candidate)) return candidate
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
44
96
|
/** Default cache location on macOS. */
|
|
45
97
|
export function defaultCacheDir(): string {
|
|
46
98
|
return join(homedir(), 'Library', 'Caches', 'ms-playwright')
|
|
@@ -81,24 +133,45 @@ export function discoverBrowser(opts: DiscoverOptions = {}): DiscoveredBrowser {
|
|
|
81
133
|
list = null
|
|
82
134
|
}
|
|
83
135
|
}
|
|
84
|
-
if (list
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
136
|
+
if (list !== null) {
|
|
137
|
+
for (const { kind, prefix } of LIST_PREFIXES) {
|
|
138
|
+
const revision = maxRevision(list, prefix)
|
|
139
|
+
if (revision === null) continue
|
|
140
|
+
const executablePath = join(cacheDir, `${kind === 'headless-shell' ? `chromium_headless_shell-${revision}` : `chromium-${revision}`}`, SUBDIRS[kind])
|
|
141
|
+
if (!exists(executablePath)) {
|
|
142
|
+
throw new Error(`browser-verify: 缓存目录存在 ${prefix}${revision} 但可执行文件缺失(${cacheDir})。请删除该目录后重新执行 npx playwright install chromium。`)
|
|
143
|
+
}
|
|
144
|
+
const known = KNOWN_REVISIONS[revision] !== undefined
|
|
145
|
+
return {
|
|
146
|
+
executablePath,
|
|
147
|
+
kind,
|
|
148
|
+
revision,
|
|
149
|
+
known,
|
|
150
|
+
versionHint: known ? null : `浏览器 revision ${revision} 不在已认证表(playwright-core 1.62.0 认证 ${Object.keys(KNOWN_REVISIONS).join('/')});若协议异常,请安装匹配版本`,
|
|
151
|
+
}
|
|
93
152
|
}
|
|
94
|
-
|
|
153
|
+
}
|
|
154
|
+
// Cache had nothing usable → last-resort system browser (zero download).
|
|
155
|
+
const system = findSystemBrowser(opts, exists)
|
|
156
|
+
if (system !== null) return system
|
|
157
|
+
throw new Error(`browser-verify: 未找到可用的浏览器(已探测:playwright 缓存 ${cacheDir} 与系统 Chrome/Chromium/Edge 常见路径)。请执行一次 npx playwright install chromium 后重试,或用 DSH_BROWSER_VERIFY_CHROMIUM 指定已有二进制。`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** First existing system-browser candidate, or null. */
|
|
161
|
+
function findSystemBrowser(opts: DiscoverOptions, exists: (path: string) => boolean): DiscoveredBrowser | null {
|
|
162
|
+
const candidates = opts.systemCandidates ?? [
|
|
163
|
+
...systemBrowserCandidates(),
|
|
164
|
+
...SYSTEM_COMMANDS.map(cmd => resolveCommandOnPath(cmd, opts.pathEnv ?? process.env.PATH, exists)).filter((p): p is string => p !== null),
|
|
165
|
+
]
|
|
166
|
+
for (const candidate of candidates) {
|
|
167
|
+
if (!exists(candidate)) continue
|
|
95
168
|
return {
|
|
96
|
-
executablePath,
|
|
97
|
-
kind,
|
|
98
|
-
revision,
|
|
99
|
-
known,
|
|
100
|
-
versionHint:
|
|
169
|
+
executablePath: candidate,
|
|
170
|
+
kind: 'system',
|
|
171
|
+
revision: 0,
|
|
172
|
+
known: false,
|
|
173
|
+
versionHint: `系统浏览器(未与插件认证版本 ${Object.keys(KNOWN_REVISIONS).join('/')} 对齐);若渲染异常,请执行 npx playwright install chromium 安装匹配版本`,
|
|
101
174
|
}
|
|
102
175
|
}
|
|
103
|
-
|
|
176
|
+
return null
|
|
104
177
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { Context } from '@deepseek-ai/cordis'
|
|
8
8
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
9
|
import { BrowserDriver } from '../browser/driver.ts'
|
|
10
|
-
import { assertImageCapable, renderScreenshotBlocks, saveScreenshot } from '../attachments.ts'
|
|
10
|
+
import { assertImageCapable, renderScreenshotBlocks, saveScreenshot, SCREENSHOT_MEDIA_TYPES, screenshotValueFrom } from '../attachments.ts'
|
|
11
11
|
import { withTimeout } from './timeout.ts'
|
|
12
12
|
|
|
13
13
|
/** Parse a positive-integer env var; NaN/zero/negative falls back to the default. */
|
|
@@ -150,11 +150,19 @@ export function registerBrowserTools(ctx: Context): void {
|
|
|
150
150
|
required: true,
|
|
151
151
|
properties: {
|
|
152
152
|
attachmentId: { type: 'string', required: true },
|
|
153
|
-
mediaType: { type: 'string', enum: [
|
|
153
|
+
mediaType: { type: 'string', enum: [...SCREENSHOT_MEDIA_TYPES], required: true },
|
|
154
154
|
bytes: { type: 'integer', required: true },
|
|
155
155
|
width: { type: 'integer', required: true },
|
|
156
156
|
height: { type: 'integer', required: true },
|
|
157
157
|
name: { type: 'string' },
|
|
158
|
+
originalDimensions: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
additionalProperties: false,
|
|
161
|
+
properties: {
|
|
162
|
+
width: { type: 'number', required: true },
|
|
163
|
+
height: { type: 'number', required: true },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
158
166
|
},
|
|
159
167
|
},
|
|
160
168
|
sha256: { type: 'string', required: true },
|
|
@@ -168,18 +176,8 @@ export function registerBrowserTools(ctx: Context): void {
|
|
|
168
176
|
return driver.withScenario(async scenario => {
|
|
169
177
|
const shot = await scenario.screenshot({ fullPage: args.fullPage })
|
|
170
178
|
const ref = await saveScreenshot(ctx, shot.data, args.name)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
attachmentId: String(ref.attachmentId),
|
|
174
|
-
mediaType: 'image/png' as const,
|
|
175
|
-
bytes: ref.bytes,
|
|
176
|
-
width: ref.width,
|
|
177
|
-
height: ref.height,
|
|
178
|
-
...ref.name === undefined ? {} : { name: ref.name },
|
|
179
|
-
},
|
|
180
|
-
sha256: shot.sha256,
|
|
181
|
-
identicalToPrevious: shot.identicalToPrevious,
|
|
182
|
-
}
|
|
179
|
+
// Facts come from the store ref: a normalized capture is no longer a PNG.
|
|
180
|
+
return screenshotValueFrom(ref, shot.sha256, shot.identicalToPrevious)
|
|
183
181
|
})
|
|
184
182
|
},
|
|
185
183
|
}))
|