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.
- package/LICENSE +201 -0
- package/README.md +113 -0
- package/README.zh.md +110 -0
- package/cordis.patch.yml +17 -0
- package/lib/cli.js +104 -0
- package/lib/driver-DxMldTYf.js +424 -0
- package/lib/index.js +470 -0
- package/lib/types/attachments.d.ts +44 -0
- package/lib/types/browser/discover.d.ts +30 -0
- package/lib/types/browser/driver.d.ts +82 -0
- package/lib/types/browser/scenario.d.ts +76 -0
- package/lib/types/cleanup.d.ts +17 -0
- package/lib/types/cli.d.ts +19 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/tools/index.d.ts +8 -0
- package/lib/types/tools/timeout.d.ts +3 -0
- package/package.json +55 -0
- package/src/attachments.ts +91 -0
- package/src/browser/discover.ts +104 -0
- package/src/browser/driver.ts +213 -0
- package/src/browser/scenario.ts +173 -0
- package/src/cleanup.ts +33 -0
- package/src/cli.ts +92 -0
- package/src/index.ts +40 -0
- package/src/tools/index.ts +186 -0
- package/src/tools/timeout.ts +10 -0
|
@@ -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
|
+
}
|