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,17 @@
|
|
|
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
|
+
/** One line from `ps -Ao pid=,ppid=,command=` (macOS). */
|
|
7
|
+
export declare function parseZombiePids(psText: string, prefix: string, selfPid: number): number[];
|
|
8
|
+
export interface OrphanDir {
|
|
9
|
+
path: string;
|
|
10
|
+
mtimeMs: number;
|
|
11
|
+
}
|
|
12
|
+
/** Pick degraded-run temp dirs: name prefixed, old enough, not the current pid dir. */
|
|
13
|
+
export declare function selectOrphanDirs(entries: Array<{
|
|
14
|
+
path: string;
|
|
15
|
+
mtimeMs: number;
|
|
16
|
+
}>, nowMs: number, ageMs: number, prefix: string): OrphanDir[];
|
|
17
|
+
//# sourceMappingURL=cleanup.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
export interface CliOptions {
|
|
7
|
+
url: string;
|
|
8
|
+
mockFile?: string;
|
|
9
|
+
waitSelector?: string;
|
|
10
|
+
assertSelector?: string;
|
|
11
|
+
screenshot?: boolean;
|
|
12
|
+
persistDir?: string;
|
|
13
|
+
viewport: {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export declare function parseCliArgs(argv: string[]): CliOptions;
|
|
19
|
+
//# sourceMappingURL=cli.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
export declare function registerBrowserTools(ctx: Context): void;
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-browser-verify",
|
|
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.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@11.7.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
9
|
+
"dsh": ">=0.1.2-alpha.1"
|
|
10
|
+
},
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"types": "lib/types/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./lib/types/index.d.ts",
|
|
16
|
+
"default": "./lib/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node -e \"require('fs').rmSync('lib', { recursive: true, force: true })\" && tsc -b && tsdown",
|
|
27
|
+
"typecheck": "tsc -b --pretty false && tsc -p tsconfig.vitest.json --pretty false",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"playwright-core": "1.62.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
36
|
+
"@deepseek-ai/dsh-attachment": "^0.1.2-alpha.4",
|
|
37
|
+
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.4",
|
|
38
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-alpha.4",
|
|
39
|
+
"@types/node": "^22.20.0",
|
|
40
|
+
"@vitest/coverage-v8": "^3.0.0",
|
|
41
|
+
"tsdown": "0.22.2",
|
|
42
|
+
"typescript": "~5.7.2",
|
|
43
|
+
"vitest": "^3.0.0"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"lib/**/*.js",
|
|
47
|
+
"lib/**/*.d.ts",
|
|
48
|
+
"src",
|
|
49
|
+
"cordis.patch.yml",
|
|
50
|
+
"README.md",
|
|
51
|
+
"README.zh.md",
|
|
52
|
+
"LICENSE"
|
|
53
|
+
],
|
|
54
|
+
"license": "Apache-2.0"
|
|
55
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot persistence + model projection, mirroring the read_image output
|
|
3
|
+
* direction: save into the durable attachment store, render a text envelope
|
|
4
|
+
* beside the image block the harness projects into the next model request.
|
|
5
|
+
* @module dsh-browser-verify/attachments
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
|
10
|
+
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
|
|
11
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
|
|
13
|
+
export interface ScreenshotImage {
|
|
14
|
+
attachmentId: string
|
|
15
|
+
mediaType: ImageMediaType
|
|
16
|
+
bytes: number
|
|
17
|
+
width: number
|
|
18
|
+
height: number
|
|
19
|
+
name?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScreenshotValue {
|
|
23
|
+
image: ScreenshotImage
|
|
24
|
+
sha256: string
|
|
25
|
+
identicalToPrevious: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function imageRefFromValue(image: ScreenshotImage): ImageAttachmentRef {
|
|
29
|
+
return {
|
|
30
|
+
attachmentId: AttachmentId(image.attachmentId),
|
|
31
|
+
mediaType: image.mediaType,
|
|
32
|
+
bytes: image.bytes,
|
|
33
|
+
width: image.width,
|
|
34
|
+
height: image.height,
|
|
35
|
+
...image.name === undefined ? {} : { name: image.name },
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function renderScreenshotBlocks(value: ScreenshotValue): ContentBlock[] {
|
|
40
|
+
const dup = value.identicalToPrevious
|
|
41
|
+
? '(与上一张截图哈希相同,疑似页面未刷新;请 browser_open 重开场景后重试)'
|
|
42
|
+
: ''
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
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>`,
|
|
47
|
+
},
|
|
48
|
+
{ type: 'image', attachment: imageRefFromValue(value.image) },
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Persist screenshot bytes, mapping store refusals to actionable errors. */
|
|
53
|
+
export async function saveScreenshot(ctx: Context, data: Buffer, name: string | undefined): Promise<ImageAttachmentRef> {
|
|
54
|
+
const attachments = ctx.get('attachments')
|
|
55
|
+
if (attachments === undefined) {
|
|
56
|
+
throw new Error('browser-verify: 附件存储未挂载,无法持久化截图。请检查当前 DSH 组合是否包含 attachment 插件。')
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return await attachments.saveImage({ data, mediaType: 'image/png', ...name === undefined ? {} : { name } })
|
|
60
|
+
} catch (error: unknown) {
|
|
61
|
+
if (!(error instanceof AttachmentError)) throw error
|
|
62
|
+
if (error.code === 'IMAGE_TOO_LARGE') {
|
|
63
|
+
throw new Error('browser-verify: 截图超过 attachment 存储字节上限。请改用 fullPage:false 或调低 deviceScaleFactor 后重试。', { cause: error })
|
|
64
|
+
}
|
|
65
|
+
if (error.code === 'IMAGE_DIMENSION_TOO_LARGE' || error.code === 'IMAGE_TOO_MANY_PIXELS') {
|
|
66
|
+
throw new Error('browser-verify: 截图尺寸超过 attachment 存储限制。请改用 fullPage:false 或调低 deviceScaleFactor 后重试。', { cause: error })
|
|
67
|
+
}
|
|
68
|
+
if (error.code === 'IMAGE_TYPE_MISMATCH') {
|
|
69
|
+
throw new Error(`browser-verify: 截图格式校验失败:${error.message} 请改用其他附件类型或重试。`, { cause: error })
|
|
70
|
+
}
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Gate: the calling route must be able to see image input (mirror of read-image). */
|
|
76
|
+
export async function assertImageCapable(
|
|
77
|
+
ctx: Context,
|
|
78
|
+
exec: { agent?: { session?: { requestHeader?: () => { config?: { provider?: string; model?: string } } | undefined }; options?: { provider?: string; model?: string } } },
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const routed = exec.agent?.session?.requestHeader?.()?.config
|
|
81
|
+
const provider = routed?.provider ?? exec.agent?.options?.provider
|
|
82
|
+
const model = routed?.model ?? exec.agent?.options?.model
|
|
83
|
+
const llm = ctx.get('llm')
|
|
84
|
+
if (provider === undefined || model === undefined || llm === undefined) {
|
|
85
|
+
throw new Error('browser-verify: 无法解析当前模型路由(或模型服务未挂载),无法判断图片输入能力。请检查当前会话的模型路由配置后重试。')
|
|
86
|
+
}
|
|
87
|
+
const active = await llm.resolveModelInfo(provider, model)
|
|
88
|
+
if (active.inputModalities === undefined || !active.inputModalities.includes('image')) {
|
|
89
|
+
throw new Error('browser-verify: 当前模型不支持看图:请改用 browser_assert 做文本断言(更省 token),或切换到图片模型后重试。')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate a Browser-for-Testing binary in the machine playwright cache. Pure:
|
|
3
|
+
* filesystem probing is injected so every branch is unit-testable.
|
|
4
|
+
* @module dsh-browser-verify/browser/discover
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
export type BrowserKind = 'headless-shell' | 'chromium' | 'custom'
|
|
12
|
+
|
|
13
|
+
/** Revision numbers verified against the matching playwright-core browsers.json. */
|
|
14
|
+
export const KNOWN_REVISIONS: Readonly<Record<number, string>> = {
|
|
15
|
+
1234: '1.62.x',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DiscoveredBrowser {
|
|
19
|
+
executablePath: string
|
|
20
|
+
kind: BrowserKind
|
|
21
|
+
revision: number
|
|
22
|
+
known: boolean
|
|
23
|
+
versionHint: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiscoverOptions {
|
|
27
|
+
cacheDir?: string
|
|
28
|
+
overridePath?: string
|
|
29
|
+
exists?: (path: string) => boolean
|
|
30
|
+
/** Directory listing of cacheDir; defaults to readdirSync(cacheDir) (throws → treated as missing). */
|
|
31
|
+
entries?: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SUBDIRS: Readonly<Record<'headless-shell' | 'chromium', string>> = {
|
|
35
|
+
'headless-shell': 'chrome-headless-shell-mac-arm64/chrome-headless-shell',
|
|
36
|
+
chromium: 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LIST_PREFIXES: Readonly<Array<{ kind: 'headless-shell' | 'chromium'; prefix: string }>> = [
|
|
40
|
+
{ kind: 'headless-shell', prefix: 'chromium_headless_shell-' },
|
|
41
|
+
{ kind: 'chromium', prefix: 'chromium-' },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** Default cache location on macOS. */
|
|
45
|
+
export function defaultCacheDir(): string {
|
|
46
|
+
return join(homedir(), 'Library', 'Caches', 'ms-playwright')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function maxRevision(list: string[], prefix: string): number | null {
|
|
50
|
+
let max: number | null = null
|
|
51
|
+
for (const entry of list) {
|
|
52
|
+
if (!entry.startsWith(prefix)) continue
|
|
53
|
+
const suffix = entry.slice(prefix.length)
|
|
54
|
+
if (!/^\d+$/.test(suffix)) continue
|
|
55
|
+
const value = Number(suffix)
|
|
56
|
+
if (max === null || value > max) max = value
|
|
57
|
+
}
|
|
58
|
+
return max
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find the browser binary: env override wins, then headless shell (highest
|
|
63
|
+
* revision), then full chromium. Throws with an install hint when absent.
|
|
64
|
+
*/
|
|
65
|
+
export function discoverBrowser(opts: DiscoverOptions = {}): DiscoveredBrowser {
|
|
66
|
+
const exists = opts.exists ?? existsSync
|
|
67
|
+
if (opts.overridePath !== undefined) {
|
|
68
|
+
if (!exists(opts.overridePath)) {
|
|
69
|
+
throw new Error(`browser-verify: DSH_BROWSER_VERIFY_CHROMIUM 指向的二进制不存在: ${opts.overridePath}。请检查路径或取消该环境变量。`)
|
|
70
|
+
}
|
|
71
|
+
return { executablePath: opts.overridePath, kind: 'custom', revision: 0, known: true, versionHint: null }
|
|
72
|
+
}
|
|
73
|
+
const cacheDir = opts.cacheDir ?? defaultCacheDir()
|
|
74
|
+
let list: string[] | null = null
|
|
75
|
+
if (opts.entries !== undefined) {
|
|
76
|
+
list = opts.entries
|
|
77
|
+
} else {
|
|
78
|
+
try {
|
|
79
|
+
list = readdirSync(cacheDir)
|
|
80
|
+
} catch {
|
|
81
|
+
list = null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (list === null) {
|
|
85
|
+
throw new Error(`browser-verify: 未找到浏览器缓存目录 ${cacheDir}。请先安装:npx playwright install chromium(需 playwright-core@1.62.0),或设置 DSH_BROWSER_VERIFY_CHROMIUM=<完整路径>。`)
|
|
86
|
+
}
|
|
87
|
+
for (const { kind, prefix } of LIST_PREFIXES) {
|
|
88
|
+
const revision = maxRevision(list, prefix)
|
|
89
|
+
if (revision === null) continue
|
|
90
|
+
const executablePath = join(cacheDir, `${kind === 'headless-shell' ? `chromium_headless_shell-${revision}` : `chromium-${revision}`}`, SUBDIRS[kind])
|
|
91
|
+
if (!exists(executablePath)) {
|
|
92
|
+
throw new Error(`browser-verify: 缓存目录存在 ${prefix}${revision} 但可执行文件缺失(${cacheDir})。请删除该目录后重新执行 npx playwright install chromium。`)
|
|
93
|
+
}
|
|
94
|
+
const known = KNOWN_REVISIONS[revision] !== undefined
|
|
95
|
+
return {
|
|
96
|
+
executablePath,
|
|
97
|
+
kind,
|
|
98
|
+
revision,
|
|
99
|
+
known,
|
|
100
|
+
versionHint: known ? null : `浏览器 revision ${revision} 不在已认证表(playwright-core 1.62.0 认证 ${Object.keys(KNOWN_REVISIONS).join('/')});若协议异常,请安装匹配版本`,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`browser-verify: 未找到浏览器二进制。请先安装:npx playwright install chromium(需 playwright-core@1.62.0),或设置 DSH_BROWSER_VERIFY_CHROMIUM=<完整路径>。`)
|
|
104
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser driving: one lazy launch per process, one active verification
|
|
3
|
+
* scenario, FIFO-serialized tool access, idle reclamation, graceful close +
|
|
4
|
+
* temp dir removal on dispose. launch args are pure for unit tests.
|
|
5
|
+
* @module dsh-browser-verify/browser/driver
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { exec } from 'node:child_process'
|
|
9
|
+
import { rmSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { chromium, type Browser } from 'playwright-core'
|
|
13
|
+
import { discoverBrowser, type DiscoveredBrowser } from './discover.ts'
|
|
14
|
+
import { Scenario, type OpenResult } from './scenario.ts'
|
|
15
|
+
|
|
16
|
+
export interface OpenScenarioResult extends OpenResult {
|
|
17
|
+
browserKnown: boolean
|
|
18
|
+
versionHint: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Headless launch args. Deviation D8-3: playwright-core >= 1.41 rejects
|
|
23
|
+
* `--user-data-dir` inside `args` (misuse error, both launch and
|
|
24
|
+
* launchPersistentContext); the user data dir must be passed as the
|
|
25
|
+
* launchPersistentContext first parameter. Only the headless-mode flag remains.
|
|
26
|
+
*/
|
|
27
|
+
export function buildLaunchArgs(headlessShell: boolean): string[] {
|
|
28
|
+
return [headlessShell ? '--headless' : '--headless=new']
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class BrowserDriver {
|
|
32
|
+
private browser: Browser | null = null
|
|
33
|
+
private discovered: DiscoveredBrowser | null = null
|
|
34
|
+
private scenario: Scenario | null = null
|
|
35
|
+
private readonly userDataDir = join(tmpdir(), `dsh-browser-verify-${process.pid}`, 'profile')
|
|
36
|
+
private idleTimer: NodeJS.Timeout | null = null
|
|
37
|
+
private lockChain: Promise<unknown> = Promise.resolve()
|
|
38
|
+
private disposed = false
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly opts: {
|
|
42
|
+
discover?: typeof discoverBrowser
|
|
43
|
+
viewport?: { width: number; height: number }
|
|
44
|
+
deviceScaleFactor?: number
|
|
45
|
+
timeoutMs?: number
|
|
46
|
+
idleMs?: number
|
|
47
|
+
} = {},
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
/** FIFO serialization: every tool op runs alone. */
|
|
51
|
+
private chain<T>(fn: () => Promise<T>): Promise<T> {
|
|
52
|
+
const run = this.lockChain.then(fn)
|
|
53
|
+
this.lockChain = run.catch(() => undefined)
|
|
54
|
+
return run
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reject new op entries once disposed; the engine cannot come back. */
|
|
58
|
+
private ensureNotDisposed(): void {
|
|
59
|
+
if (this.disposed) {
|
|
60
|
+
throw new Error('browser-verify: 验证引擎已停止。请重新调用 browser_open 开始新的验证。')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Normalize errors at the driver boundary: prefix + context + advice. */
|
|
65
|
+
private wrapError(error: unknown, context: string, advice: string): Error {
|
|
66
|
+
if (error instanceof Error && error.message.startsWith('browser-verify: ')) return error
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
68
|
+
return new Error(`browser-verify: ${context}: ${message}。${advice}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
withScenario<T>(fn: (scenario: Scenario) => Promise<T>): Promise<T> {
|
|
72
|
+
this.ensureNotDisposed()
|
|
73
|
+
return this.chain(async () => {
|
|
74
|
+
this.ensureNotDisposed()
|
|
75
|
+
this.resetIdleTimer()
|
|
76
|
+
try {
|
|
77
|
+
return await fn(this.requireScenario())
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw this.wrapError(error, '场景操作失败', '请 browser_open 重开场景后重试。')
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Open a fresh verification scenario; per design, each open = new context+page. */
|
|
85
|
+
async startScenario(reset: { url: string; waitSelector?: string; timeoutMs?: number; viewport?: { width: number; height: number }; deviceScaleFactor?: number; mocks?: Array<{ urlPattern: string; json: unknown; status?: number }> }): Promise<OpenScenarioResult> {
|
|
86
|
+
this.ensureNotDisposed()
|
|
87
|
+
return this.chain(async () => {
|
|
88
|
+
this.ensureNotDisposed()
|
|
89
|
+
this.resetIdleTimer()
|
|
90
|
+
return this.openScenario(reset)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async openScenario(reset: { url: string; waitSelector?: string; timeoutMs?: number; viewport?: { width: number; height: number }; deviceScaleFactor?: number; mocks?: Array<{ urlPattern: string; json: unknown; status?: number }> }): Promise<OpenScenarioResult> {
|
|
95
|
+
const browser = await this.ensureBrowser()
|
|
96
|
+
await this.scenario?.close()
|
|
97
|
+
const context = await browser.newContext({
|
|
98
|
+
viewport: reset.viewport ?? this.opts.viewport ?? { width: 390, height: 844 },
|
|
99
|
+
deviceScaleFactor: reset.deviceScaleFactor ?? this.opts.deviceScaleFactor ?? 2,
|
|
100
|
+
})
|
|
101
|
+
const page = await context.newPage()
|
|
102
|
+
this.scenario = new Scenario(page, context)
|
|
103
|
+
this.resetIdleTimer()
|
|
104
|
+
try {
|
|
105
|
+
// Deviation D8-5: pre-register mocks before the first navigations so the
|
|
106
|
+
// app boots against mocked APIs (some apps bounce to a fallback route
|
|
107
|
+
// when real APIs answer "session invalid").
|
|
108
|
+
for (const rule of reset.mocks ?? []) {
|
|
109
|
+
await this.scenario.addMock({ ...rule, reload: false })
|
|
110
|
+
}
|
|
111
|
+
const opened = await this.scenario.navigate({
|
|
112
|
+
url: reset.url,
|
|
113
|
+
waitSelector: reset.waitSelector,
|
|
114
|
+
timeoutMs: reset.timeoutMs ?? this.opts.timeoutMs,
|
|
115
|
+
})
|
|
116
|
+
return {
|
|
117
|
+
...opened,
|
|
118
|
+
browserKnown: this.discovered?.known ?? true,
|
|
119
|
+
versionHint: this.discovered?.versionHint ?? null,
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
await this.scenario.close()
|
|
123
|
+
this.scenario = null
|
|
124
|
+
throw this.wrapError(error, '打开页面失败', '请检查 URL 是否可访问、页面是否可在超时内加载,必要时调大 DSH_BROWSER_VERIFY_TIMEOUT。')
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private resetIdleTimer(): void {
|
|
129
|
+
if (this.idleTimer !== null) clearTimeout(this.idleTimer)
|
|
130
|
+
this.idleTimer = setTimeout(() => { void this.dispose() }, this.opts.idleMs ?? 600000)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async ensureBrowser(): Promise<Browser> {
|
|
134
|
+
this.ensureNotDisposed()
|
|
135
|
+
if (this.browser !== null) return this.browser
|
|
136
|
+
try {
|
|
137
|
+
const found = (this.opts.discover ?? discoverBrowser)({ overridePath: process.env.DSH_BROWSER_VERIFY_CHROMIUM ?? undefined })
|
|
138
|
+
this.discovered = found
|
|
139
|
+
// Deviation D8-3: launchPersistentContext is the only launch path that
|
|
140
|
+
// puts our predictable temp dir on the chromium command line
|
|
141
|
+
// (`--user-data-dir` is appended by playwright itself), which the
|
|
142
|
+
// cleanup/zombie matchers and the smoke rely on.
|
|
143
|
+
const persistent = await chromium.launchPersistentContext(this.userDataDir, {
|
|
144
|
+
executablePath: found.executablePath,
|
|
145
|
+
args: buildLaunchArgs(found.kind === 'headless-shell'),
|
|
146
|
+
headless: true,
|
|
147
|
+
})
|
|
148
|
+
const browser = persistent.browser()
|
|
149
|
+
if (browser === null) throw new Error('browser-verify: 持久化上下文未返回浏览器实例。请检查 DSH_BROWSER_VERIFY_CHROMIUM 指向的浏览器,或重新执行 npx playwright install chromium。')
|
|
150
|
+
this.browser = browser
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw this.wrapError(error, '浏览器启动失败', '请检查 DSH_BROWSER_VERIFY_CHROMIUM 指向的浏览器路径,或重新执行 npx playwright install chromium。')
|
|
153
|
+
}
|
|
154
|
+
this.resetIdleTimer()
|
|
155
|
+
return this.browser
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private requireScenario(): Scenario {
|
|
159
|
+
if (this.scenario === null) {
|
|
160
|
+
throw new Error('browser-verify: 尚未打开验证会话。请先调用 browser_open 打开页面。')
|
|
161
|
+
}
|
|
162
|
+
return this.scenario
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Reset on dispose: mark disposed first (idempotent, blocks new ops), then
|
|
167
|
+
* run the teardown serialized on the FIFO chain so it waits out any
|
|
168
|
+
* in-flight op and cannot interleave with a launch or scenario op.
|
|
169
|
+
*/
|
|
170
|
+
async dispose(): Promise<void> {
|
|
171
|
+
if (this.disposed) return
|
|
172
|
+
this.disposed = true
|
|
173
|
+
await this.chain(() => this.teardown())
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Best-effort teardown: close scenario + browser, delete the profile dir. */
|
|
177
|
+
private async teardown(): Promise<void> {
|
|
178
|
+
if (this.idleTimer !== null) { clearTimeout(this.idleTimer); this.idleTimer = null }
|
|
179
|
+
const scenario = this.scenario
|
|
180
|
+
this.scenario = null
|
|
181
|
+
if (scenario !== null) { try { await scenario.close() } catch { /* ignore */ } }
|
|
182
|
+
const browser = this.browser
|
|
183
|
+
this.browser = null
|
|
184
|
+
if (browser !== null) {
|
|
185
|
+
try { await browser.close() } catch {
|
|
186
|
+
// Graceful close failed (hung process / dead transport; the persistent
|
|
187
|
+
// context path has no internal kill fallback). Hard-kill any process
|
|
188
|
+
// still carrying our user-data-dir so no orphan survives the teardown.
|
|
189
|
+
try { await this.hardKillChromium() } catch { /* ignore */ }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
try { rmSync(join(tmpdir(), `dsh-browser-verify-${process.pid}`), { recursive: true, force: true }) } catch { /* ignore */ }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Hard-kill fallback: SIGKILL every process whose command line still
|
|
197
|
+
* carries our user-data-dir (playwright appends `--user-data-dir` itself;
|
|
198
|
+
* our predictable temp dir is the reverse-lookup key, per design §6).
|
|
199
|
+
*/
|
|
200
|
+
private async hardKillChromium(): Promise<void> {
|
|
201
|
+
const out = await new Promise<string>((resolve) => {
|
|
202
|
+
exec('ps -Ao pid=,ppid=,command=', { maxBuffer: 10 * 1024 * 1024 }, (error, stdout) => resolve(error ? '' : stdout))
|
|
203
|
+
})
|
|
204
|
+
const marker = `--user-data-dir=${this.userDataDir}`
|
|
205
|
+
for (const line of out.split('\n')) {
|
|
206
|
+
const m = /^\s*(\d+)\s+\d+\s+(.+)$/.exec(line)
|
|
207
|
+
if (m === null) continue
|
|
208
|
+
if (m[2].includes(marker)) {
|
|
209
|
+
try { process.kill(Number(m[1]), 'SIGKILL') } catch { /* already gone */ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|