dsh-browser-verify 0.1.0

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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * One verification scenario = one page + its request mocks + assertion/shot
3
+ * state. Pure helpers are exported for unit tests; the IO methods use
4
+ * playwright-core. Polluting nothing outside the page's own requests.
5
+ * @module dsh-browser-verify/browser/scenario
6
+ */
7
+
8
+ import { createHash } from 'node:crypto'
9
+ import type { BrowserContext, Page } from 'playwright-core'
10
+
11
+ export interface MockRule {
12
+ json: unknown
13
+ status: number
14
+ }
15
+
16
+ export interface OpenResult {
17
+ title: string
18
+ url: string
19
+ status: number | null
20
+ visible: string[]
21
+ consoleErrors: string[]
22
+ elapsedMs: number
23
+ }
24
+
25
+ export interface AssertResult {
26
+ pass: boolean
27
+ count: number
28
+ actualText: string | null
29
+ elapsedMs: number
30
+ }
31
+
32
+ const MAX_VISIBLE = 8
33
+ const MAX_VISIBLE_LEN = 40
34
+ const MAX_ERRORS = 5
35
+ const MAX_ERROR_LEN = 120
36
+ const MAX_DIFF_LEN = 120
37
+
38
+ export function assertNoMockConflict(patterns: string[], next: string): void {
39
+ if (patterns.includes(next)) {
40
+ throw new Error(`browser-verify: 拦截 pattern 已存在: ${next}(已有: ${patterns.join(', ')})。请先 browser_open 重开场景或用不同的 urlPattern。`)
41
+ }
42
+ }
43
+
44
+ export function normalizeCountSpec(count: number | { min: number; max: number } | undefined): { min: number; max: number } | null {
45
+ if (typeof count === 'number') return { min: count, max: count }
46
+ if (count !== undefined && typeof count.min === 'number' && typeof count.max === 'number') return { min: count.min, max: count.max }
47
+ return null
48
+ }
49
+
50
+ export function summarizeVisibleText(texts: string[]): string[] {
51
+ const seen = new Set<string>()
52
+ const out: string[] = []
53
+ for (const raw of texts) {
54
+ const trimmed = raw.trim()
55
+ if (trimmed === '') continue
56
+ const reduced = trimmed.length > MAX_VISIBLE_LEN ? trimmed.slice(0, MAX_VISIBLE_LEN) : trimmed
57
+ if (seen.has(reduced)) continue
58
+ seen.add(reduced)
59
+ out.push(reduced)
60
+ if (out.length >= MAX_VISIBLE) break
61
+ }
62
+ return out
63
+ }
64
+
65
+ export function capConsoleErrors(errors: string[]): string[] {
66
+ return errors.slice(0, MAX_ERRORS).map(e => e.length > MAX_ERROR_LEN ? `${e.slice(0, MAX_ERROR_LEN - 1)}…` : e)
67
+ }
68
+
69
+ export function sha256Hex(data: Buffer): string {
70
+ return createHash('sha256').update(data).digest('hex')
71
+ }
72
+
73
+ export function textDiff(actual: string | null, expected: string): string {
74
+ if (actual === null) return `未找到匹配元素文本(期望包含: ${expected.slice(0, MAX_DIFF_LEN)})`
75
+ if (actual === expected) return '文本一致'
76
+ const head = actual.length > MAX_DIFF_LEN ? `${actual.slice(0, MAX_DIFF_LEN)}…` : actual
77
+ return `期望包含「${expected.slice(0, MAX_DIFF_LEN)}」,实际: ${head}`
78
+ }
79
+
80
+ /** Visible-text extraction, evaluated in the page: text of visible elements. */
81
+ export const VISIBLE_TEXT_SCRIPT = `
82
+ Array.from(document.querySelectorAll('body *')).map(el => {
83
+ const rect = el.getBoundingClientRect()
84
+ if (rect.width === 0 || rect.height === 0) return ''
85
+ const text = (el.childElementCount === 0 ? el.textContent ?? '' : '').trim()
86
+ return text.length > 0 ? text : ''
87
+ })
88
+ `
89
+
90
+ export class Scenario {
91
+ readonly mocks = new Map<string, MockRule>()
92
+ lastScreenshotHash: string | null = null
93
+
94
+ constructor(
95
+ readonly page: Page,
96
+ readonly context: BrowserContext,
97
+ ) {}
98
+
99
+ async navigate(opts: { url: string; waitSelector?: string; timeoutMs?: number }): Promise<OpenResult> {
100
+ const started = Date.now()
101
+ const timeout = opts.timeoutMs ?? 10000
102
+ const errors: string[] = []
103
+ const onError = (message: string): void => { errors.push(message) }
104
+ this.page.on('console', msg => { if (msg.type() === 'error') onError(msg.text()) })
105
+ this.page.on('pageerror', err => onError(String(err)))
106
+ const response = await this.page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout })
107
+ if (opts.waitSelector !== undefined) {
108
+ await this.page.waitForSelector(opts.waitSelector, { timeout })
109
+ }
110
+ const texts = await this.page.evaluate(VISIBLE_TEXT_SCRIPT) as string[]
111
+ return {
112
+ title: await this.page.title(),
113
+ url: this.page.url(),
114
+ status: response?.status() ?? null,
115
+ visible: summarizeVisibleText(texts),
116
+ consoleErrors: capConsoleErrors(errors),
117
+ elapsedMs: Date.now() - started,
118
+ }
119
+ }
120
+
121
+ async addMock(rule: { urlPattern: string; json: unknown; status?: number; reload?: boolean; timeoutMs?: number }): Promise<string[]> {
122
+ assertNoMockConflict([...this.mocks.keys()], rule.urlPattern)
123
+ const status = rule.status ?? 200
124
+ this.mocks.set(rule.urlPattern, { json: rule.json, status })
125
+ await this.context.route(rule.urlPattern, async route => {
126
+ const body = Buffer.from(JSON.stringify(this.mocks.get(rule.urlPattern)?.json ?? rule.json))
127
+ await route.fulfill({ status, body, contentType: 'application/json; charset=utf-8' })
128
+ })
129
+ if (rule.reload !== false) {
130
+ await this.page.reload({ waitUntil: 'domcontentloaded', timeout: rule.timeoutMs ?? 10000 })
131
+ }
132
+ return [...this.mocks.keys()]
133
+ }
134
+
135
+ async assert(opts: { selector: string; count?: number | { min: number; max: number }; text?: string; timeoutMs: number }): Promise<AssertResult> {
136
+ const started = Date.now()
137
+ const expected = normalizeCountSpec(opts.count)
138
+ try {
139
+ await this.page.waitForSelector(opts.selector, { state: 'attached', timeout: opts.timeoutMs })
140
+ } catch (error) {
141
+ const timedOut = error instanceof Error && /timeout/i.test(error.message)
142
+ // Element never appeared: a normal verification outcome, not a thrown
143
+ // failure. An explicit absence assertion (count 0..0, no text) passes;
144
+ // everything else stays a normal pass:false.
145
+ if (timedOut) {
146
+ return {
147
+ pass: expected !== null && expected.min === 0 && expected.max === 0 && opts.text === undefined,
148
+ count: 0,
149
+ actualText: null,
150
+ elapsedMs: Date.now() - started,
151
+ }
152
+ }
153
+ throw error
154
+ }
155
+ const count = await this.page.locator(opts.selector).count()
156
+ const actualText = await this.page.locator(opts.selector).first().textContent()
157
+ const pass = (expected === null || (count >= expected.min && count <= expected.max))
158
+ && (opts.text === undefined || (actualText !== null && actualText.includes(opts.text)))
159
+ return { pass, count, actualText, elapsedMs: Date.now() - started }
160
+ }
161
+
162
+ async screenshot(opts: { fullPage?: boolean }): Promise<{ data: Buffer; sha256: string; identicalToPrevious: boolean }> {
163
+ const data = await this.page.screenshot({ fullPage: opts.fullPage ?? false, type: 'png' })
164
+ const sha256 = sha256Hex(data)
165
+ const identicalToPrevious = sha256 === this.lastScreenshotHash
166
+ this.lastScreenshotHash = sha256
167
+ return { data, sha256, identicalToPrevious }
168
+ }
169
+
170
+ async close(): Promise<void> {
171
+ try { await this.context.close() } catch { /* already gone */ }
172
+ }
173
+ }
package/src/cleanup.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Orphan cleanup for dsh-browser-verify: temp dirs and zombie Chromium
3
+ * processes left by crashes. Parsers are pure; callers do the I/O.
4
+ * @module dsh-browser-verify/cleanup
5
+ */
6
+
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+
10
+ /** One line from `ps -Ao pid=,ppid=,command=` (macOS). */
11
+ export function parseZombiePids(psText: string, prefix: string, selfPid: number): number[] {
12
+ const pids: number[] = []
13
+ for (const line of psText.split('\n')) {
14
+ const match = /^\s*(\d+)\s+\d+\s+(.+)$/.exec(line)
15
+ if (match === null) continue
16
+ const pid = Number(match[1])
17
+ if (pid === selfPid) continue
18
+ if (match[2].includes(`--user-data-dir=${join(tmpdir(), prefix)}`)) pids.push(pid)
19
+ }
20
+ return pids
21
+ }
22
+
23
+ export interface OrphanDir {
24
+ path: string
25
+ mtimeMs: number
26
+ }
27
+
28
+ /** Pick degraded-run temp dirs: name prefixed, old enough, not the current pid dir. */
29
+ export function selectOrphanDirs(entries: Array<{ path: string; mtimeMs: number }>, nowMs: number, ageMs: number, prefix: string): OrphanDir[] {
30
+ return entries
31
+ .filter(e => e.path.includes(`/${prefix}`) && nowMs - e.mtimeMs > ageMs)
32
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
33
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Debug CLI for dsh-browser-verify: exercises the same core the tools use,
3
+ * without the harness. Prints structured JSON results.
4
+ * @module dsh-browser-verify/cli
5
+ */
6
+
7
+ import { readFile } from 'node:fs/promises'
8
+ import { tmpdir } from 'node:os'
9
+ import { join, resolve } from 'node:path'
10
+ import { BrowserDriver } from './browser/driver.ts'
11
+
12
+ export interface CliOptions {
13
+ url: string
14
+ mockFile?: string
15
+ waitSelector?: string
16
+ assertSelector?: string
17
+ screenshot?: boolean
18
+ persistDir?: string
19
+ viewport: { width: number; height: number }
20
+ }
21
+
22
+ export function parseCliArgs(argv: string[]): CliOptions {
23
+ const opts: CliOptions = { url: '', viewport: { width: 390, height: 844 } }
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const arg = argv[i]
26
+ const next = (): string => { const v = argv[++i]; if (v === undefined) throw new Error(`browser-verify: 参数 ${arg} 缺少值。请按 --url <url> 的用法补充。`); return v }
27
+ if (arg === '--url') opts.url = next()
28
+ else if (arg === '--mock') opts.mockFile = next()
29
+ else if (arg === '--wait-selector') opts.waitSelector = next()
30
+ else if (arg === '--assert') opts.assertSelector = next()
31
+ else if (arg === '--screenshot') opts.screenshot = true
32
+ else if (arg === '--persist') opts.persistDir = next()
33
+ else if (arg === '--viewport') {
34
+ const [w, h] = next().split('x').map(Number)
35
+ opts.viewport = { width: w, height: h }
36
+ } else throw new Error(`browser-verify: 未知参数 ${arg}。请检查命令行用法(--url/--mock/--assert/--screenshot/--persist/--viewport/--wait-selector)。`)
37
+ }
38
+ if (opts.url === '') throw new Error('browser-verify: 缺少 --url。请提供页面地址,如 --url http://localhost:5173/hweb/#/pages/lyp/livingPayment。')
39
+ return opts
40
+ }
41
+
42
+ async function main(): Promise<void> {
43
+ const opts = parseCliArgs(process.argv.slice(2))
44
+ // Deviation D8-4: read the fixture before opening, then pass it as
45
+ // startScenario `mocks` so it is registered before the first navigation
46
+ // (the app boot may bounce to a fallback route if unmocked APIs answer).
47
+ let rule: { urlPattern: string; json: unknown; status?: number } | null = null
48
+ if (opts.mockFile !== undefined) {
49
+ try {
50
+ rule = JSON.parse(await readFile(opts.mockFile, 'utf8')) as { urlPattern: string; json: unknown; status?: number }
51
+ } catch (error) {
52
+ const raw = error instanceof Error ? error.message : String(error)
53
+ throw new Error(`browser-verify: 读取 --mock 文件失败: ${raw}。请检查文件路径与 JSON 格式。`)
54
+ }
55
+ }
56
+ const driver = new BrowserDriver({ viewport: opts.viewport })
57
+ try {
58
+ const opened = await driver.startScenario({
59
+ url: opts.url,
60
+ timeoutMs: 15000,
61
+ waitSelector: opts.waitSelector,
62
+ mocks: rule === null ? undefined : [{ urlPattern: rule.urlPattern, json: rule.json, status: rule.status }],
63
+ })
64
+ console.log(JSON.stringify({ step: 'open', ...opened }))
65
+ if (rule !== null) {
66
+ console.log(JSON.stringify({ step: 'mock', patterns: [rule.urlPattern] }))
67
+ }
68
+ if (opts.assertSelector !== undefined) {
69
+ // Deviation D8-2: TS does not narrow property accesses inside closures.
70
+ const assertSelector = opts.assertSelector
71
+ const result = await driver.withScenario(s => s.assert({ selector: assertSelector, timeoutMs: 5000 }))
72
+ console.log(JSON.stringify({ step: 'assert', ...result }))
73
+ }
74
+ if (opts.screenshot === true) {
75
+ const shot = await driver.withScenario(s => s.screenshot({ fullPage: false }))
76
+ // Default: inside the pid temp dir (deleted by driver.dispose). --persist keeps it.
77
+ const path = opts.persistDir === undefined
78
+ ? join(tmpdir(), `dsh-browser-verify-${process.pid}`, 'cli-last.png')
79
+ : join(resolve(opts.persistDir), `browser-verify-${Date.now()}.png`)
80
+ await import('node:fs/promises').then(fs => fs.writeFile(path, shot.data))
81
+ console.log(JSON.stringify({ step: 'screenshot', path, keep: opts.persistDir !== undefined, sha256: shot.sha256, identicalToPrevious: shot.identicalToPrevious }))
82
+ }
83
+ } finally {
84
+ await driver.dispose()
85
+ }
86
+ }
87
+
88
+ // Deviation D8-1 (brief omitted the guard): only auto-run when this module is
89
+ // the CLI entry, so importing parseCliArgs (vitest) has no side effects.
90
+ if (process.argv[1] !== undefined && /(^|[\\/])cli\.(js|ts)$/.test(process.argv[1])) {
91
+ void main().catch(error => { console.error(String(error)); process.exitCode = 1 })
92
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { exec as execCb } from 'node:child_process'
3
+ import { readdir, rm, stat } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { parseZombiePids, selectOrphanDirs } from './cleanup.ts'
7
+ import { registerBrowserTools } from './tools/index.ts'
8
+
9
+ export const name = 'browser-verify'
10
+ export const inject = ['tools']
11
+
12
+ const prefix = 'dsh-browser-verify-'
13
+
14
+ /** One-shot startup sweep: old temp dirs + stray Chromium, limited to our prefix. */
15
+ async function sweepOrphans(): Promise<void> {
16
+ const root = tmpdir()
17
+ const entries: Array<{ path: string; mtimeMs: number }> = []
18
+ const dirents = await readdir(root, { withFileTypes: true }).catch(() => [])
19
+ for (const dirent of dirents) {
20
+ if (dirent.name === `${prefix}${process.pid}`) continue
21
+ if (!dirent.name.startsWith(prefix) || !dirent.isDirectory()) continue
22
+ const full = join(root, dirent.name)
23
+ const info = await stat(full).catch(() => null)
24
+ if (info !== null) entries.push({ path: full, mtimeMs: info.mtimeMs })
25
+ }
26
+ for (const orphan of selectOrphanDirs(entries, Date.now(), 3_600_000, prefix)) {
27
+ await rm(orphan.path, { recursive: true, force: true }).catch(() => undefined)
28
+ }
29
+ execCb('ps -Ao pid=,ppid=,command=', (error, stdout) => {
30
+ if (error !== null) return
31
+ for (const pid of parseZombiePids(String(stdout), prefix, process.pid)) {
32
+ try { process.kill(pid, 'SIGKILL') } catch { /* already gone */ }
33
+ }
34
+ })
35
+ }
36
+
37
+ export function apply(ctx: Context): void {
38
+ void sweepOrphans().catch(() => {})
39
+ registerBrowserTools(ctx)
40
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The four model-facing browser verification tools. Thin shells: validate
3
+ * args, take the driver lock, run the scenario core, translate failures.
4
+ * @module dsh-browser-verify/tools
5
+ */
6
+
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import { defineTool } from '@deepseek-ai/dsh-tools'
9
+ import { BrowserDriver } from '../browser/driver.ts'
10
+ import { assertImageCapable, renderScreenshotBlocks, saveScreenshot } from '../attachments.ts'
11
+ import { withTimeout } from './timeout.ts'
12
+
13
+ /** Parse a positive-integer env var; NaN/zero/negative falls back to the default. */
14
+ const numberFromEnv = (name: string, fallback: number): number => {
15
+ const value = Number(process.env[name] ?? fallback)
16
+ return Number.isFinite(value) && value > 0 ? value : fallback
17
+ }
18
+ const envTimeoutMs = (): number => numberFromEnv('DSH_BROWSER_VERIFY_TIMEOUT', 10000)
19
+ const envIdleMs = (): number => numberFromEnv('DSH_BROWSER_VERIFY_IDLE_MS', 600000)
20
+
21
+ export function registerBrowserTools(ctx: Context): void {
22
+ const driver = new BrowserDriver({ timeoutMs: envTimeoutMs(), idleMs: envIdleMs() })
23
+ ctx.effect(() => () => { void driver.dispose() })
24
+
25
+ ctx.tools.register(defineTool({
26
+ name: 'browser_open',
27
+ description: '在无头浏览器中打开一个页面并返回页面状态(标题/状态码/可见文本摘要/console 错误)用于验证前端页面;可选 waitSelector 等待关键元素出现,默认视口 390×844 @2x(移动端形态)。可传 mocks 在打开时拦截接口(用于启动即依赖接口数据的页面)。验证顺序:先 browser_assert 做 DOM 断言,确需看版式再 browser_screenshot。',
28
+ parameters: {
29
+ url: { type: 'string', required: true, description: '页面地址,如 http://localhost:5173/hweb/pages/...' },
30
+ viewport: {
31
+ type: 'object',
32
+ additionalProperties: false,
33
+ properties: {
34
+ width: { type: 'number', required: true, description: '视口宽' },
35
+ height: { type: 'number', required: true, description: '视口高' },
36
+ },
37
+ description: '视口尺寸 {width, height},默认 390x844',
38
+ },
39
+ deviceScaleFactor: { type: 'number', description: '缩放比,默认 2' },
40
+ mocks: {
41
+ type: 'array',
42
+ items: {
43
+ type: 'object',
44
+ additionalProperties: false,
45
+ properties: {
46
+ urlPattern: { type: 'string', required: true, description: 'glob 模式,如 **/api/*.do*' },
47
+ json: { type: 'json', required: true, description: '拦截响应体(任意 JSON)' },
48
+ status: { type: 'number', description: '响应状态码,默认 200' },
49
+ },
50
+ },
51
+ description: '可选:页面启动前注册的接口拦截(glob urlPattern + json,如 [{urlPattern: "**/api/*.do*", json: {...}}])',
52
+ },
53
+ waitSelector: { type: 'string', description: '可选:等待该选择器出现后再返回(优先于固定等待)' },
54
+ timeoutMs: { type: 'number', description: `加载超时,默认 ${envTimeoutMs()}ms` },
55
+ },
56
+ output: {
57
+ schema: {
58
+ type: 'object',
59
+ additionalProperties: false,
60
+ properties: {
61
+ title: { type: 'string', required: true },
62
+ url: { type: 'string', required: true },
63
+ status: { oneOf: [{ type: 'number' }, { type: 'null' }], required: true },
64
+ visible: { type: 'array', items: { type: 'string' }, required: true },
65
+ consoleErrors: { type: 'array', items: { type: 'string' }, required: true },
66
+ elapsedMs: { type: 'number', required: true },
67
+ browserKnown: { type: 'boolean', required: true },
68
+ versionHint: { oneOf: [{ type: 'string' }, { type: 'null' }], required: true, description: '浏览器 revision 认证提示(null=认证通过)' },
69
+ },
70
+ },
71
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
72
+ },
73
+ async execute(args) {
74
+ const timeoutMs = args.timeoutMs ?? envTimeoutMs()
75
+ return withTimeout(
76
+ driver.startScenario({ url: args.url, waitSelector: args.waitSelector, timeoutMs, viewport: args.viewport, deviceScaleFactor: args.deviceScaleFactor, mocks: args.mocks }),
77
+ timeoutMs, 'browser_open',
78
+ )
79
+ },
80
+ }))
81
+
82
+ ctx.tools.register(defineTool({
83
+ name: 'browser_mock',
84
+ description: '为当前验证场景注册接口拦截并自动重新加载页面:urlPattern 用 playwright glob(如 **/api/lifeIndex.do*),拦截后返回指定 json,用于 mock 空态/异常态。与已注册 pattern 完全相同时报错;请先 browser_open。',
85
+ parameters: {
86
+ urlPattern: { type: 'string', required: true, description: 'glob 模式,如 **/api/lifeIndex.do*' },
87
+ json: { type: 'json', description: '拦截响应体(任意 JSON)', required: true },
88
+ status: { type: 'number', description: '响应状态码,默认 200' },
89
+ reload: { type: 'boolean', description: '注册后自动 reload 当前页,默认 true' },
90
+ },
91
+ output: {
92
+ schema: {
93
+ type: 'object',
94
+ additionalProperties: false,
95
+ properties: {
96
+ patterns: { type: 'array', items: { type: 'string' }, required: true },
97
+ },
98
+ },
99
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
100
+ },
101
+ async execute(args) {
102
+ return driver.withScenario(async scenario => ({ patterns: await scenario.addMock({ urlPattern: args.urlPattern, json: args.json, status: args.status, reload: args.reload, timeoutMs: envTimeoutMs() }) }))
103
+ },
104
+ }))
105
+
106
+ ctx.tools.register(defineTool({
107
+ name: 'browser_assert',
108
+ description: '对当前页面 DOM 断言:selector 必须存在,可校验匹配数量(count,数字或 {min,max})与文本包含(text)。不满足时返回 pass:false 并附差异、不抛错。这是最省 token 的验证手段,优先于截图。',
109
+ parameters: {
110
+ selector: { type: 'string', required: true, description: 'CSS 选择器' },
111
+ count: { oneOf: [{ type: 'number' }, { type: 'object', additionalProperties: false, properties: { min: { type: 'number', required: true }, max: { type: 'number', required: true } } }], description: '期望匹配数量:数字=精确,或 {min,max}=范围' },
112
+ text: { type: 'string', description: '期望包含于首个匹配元素文本(contains 谓词)' },
113
+ timeoutMs: { type: 'number', description: '等待选择器出现的超时,默认 5000ms' },
114
+ },
115
+ output: {
116
+ schema: {
117
+ type: 'object',
118
+ additionalProperties: false,
119
+ properties: {
120
+ pass: { type: 'boolean', required: true },
121
+ count: { type: 'number', required: true },
122
+ actualText: { oneOf: [{ type: 'string' }, { type: 'null' }], required: true },
123
+ elapsedMs: { type: 'number', required: true },
124
+ },
125
+ },
126
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
127
+ },
128
+ async execute(args) {
129
+ return driver.withScenario(scenario => scenario.assert({
130
+ selector: args.selector, count: args.count, text: args.text, timeoutMs: args.timeoutMs ?? 5000,
131
+ }))
132
+ },
133
+ }))
134
+
135
+ ctx.tools.register(defineTool({
136
+ name: 'browser_screenshot',
137
+ description: '截图当前页面并自动投影进模型上下文(图片块),返回尺寸与哈希;与上一张完全一致时 identicalToPrevious:true(疑似页面未刷新,请 browser_open 重开)。仅需要检查版式时使用——能断言就别截图。',
138
+ parameters: {
139
+ name: { type: 'string', description: '可选命名(进入附件名)' },
140
+ fullPage: { type: 'boolean', description: '是否整页截图,默认 false' },
141
+ },
142
+ output: {
143
+ schema: {
144
+ type: 'object',
145
+ additionalProperties: false,
146
+ properties: {
147
+ image: {
148
+ type: 'object',
149
+ additionalProperties: false,
150
+ required: true,
151
+ properties: {
152
+ attachmentId: { type: 'string', required: true },
153
+ mediaType: { type: 'string', enum: ['image/png'], required: true },
154
+ bytes: { type: 'integer', required: true },
155
+ width: { type: 'integer', required: true },
156
+ height: { type: 'integer', required: true },
157
+ name: { type: 'string' },
158
+ },
159
+ },
160
+ sha256: { type: 'string', required: true },
161
+ identicalToPrevious: { type: 'boolean', required: true },
162
+ },
163
+ },
164
+ render: (_args, value) => renderScreenshotBlocks(value),
165
+ },
166
+ async execute(args, exec) {
167
+ await assertImageCapable(ctx, exec)
168
+ return driver.withScenario(async scenario => {
169
+ const shot = await scenario.screenshot({ fullPage: args.fullPage })
170
+ const ref = await saveScreenshot(ctx, shot.data, args.name)
171
+ return {
172
+ image: {
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
+ }
183
+ })
184
+ },
185
+ }))
186
+ }
@@ -0,0 +1,10 @@
1
+ /** Race a promise against a deadline; the loser's work is abandoned, not awaited. */
2
+ export function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
3
+ return new Promise<T>((resolve, reject) => {
4
+ const timer = setTimeout(() => reject(new Error(`browser-verify: ${label} 超时(${ms}ms)。请检查页面或调大 DSH_BROWSER_VERIFY_TIMEOUT 后重试。`)), ms)
5
+ promise.then(
6
+ value => { clearTimeout(timer); resolve(value) },
7
+ error => { clearTimeout(timer); reject(error) },
8
+ )
9
+ })
10
+ }