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,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,5 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ export declare const name = "browser-verify";
3
+ export declare const inject: string[];
4
+ export declare function apply(ctx: Context): void;
5
+ //# sourceMappingURL=index.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
@@ -0,0 +1,3 @@
1
+ /** Race a promise against a deadline; the loser's work is abandoned, not awaited. */
2
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>;
3
+ //# sourceMappingURL=timeout.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
+ }