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,424 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
5
|
+
import { chromium } from "playwright-core";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
//#region src/browser/discover.ts
|
|
8
|
+
/**
|
|
9
|
+
* Locate a Browser-for-Testing binary in the machine playwright cache. Pure:
|
|
10
|
+
* filesystem probing is injected so every branch is unit-testable.
|
|
11
|
+
* @module dsh-browser-verify/browser/discover
|
|
12
|
+
*/
|
|
13
|
+
/** Revision numbers verified against the matching playwright-core browsers.json. */
|
|
14
|
+
const KNOWN_REVISIONS = { 1234: "1.62.x" };
|
|
15
|
+
const SUBDIRS = {
|
|
16
|
+
"headless-shell": "chrome-headless-shell-mac-arm64/chrome-headless-shell",
|
|
17
|
+
chromium: "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
|
|
18
|
+
};
|
|
19
|
+
const LIST_PREFIXES = [{
|
|
20
|
+
kind: "headless-shell",
|
|
21
|
+
prefix: "chromium_headless_shell-"
|
|
22
|
+
}, {
|
|
23
|
+
kind: "chromium",
|
|
24
|
+
prefix: "chromium-"
|
|
25
|
+
}];
|
|
26
|
+
/** Default cache location on macOS. */
|
|
27
|
+
function defaultCacheDir() {
|
|
28
|
+
return join(homedir(), "Library", "Caches", "ms-playwright");
|
|
29
|
+
}
|
|
30
|
+
function maxRevision(list, prefix) {
|
|
31
|
+
let max = null;
|
|
32
|
+
for (const entry of list) {
|
|
33
|
+
if (!entry.startsWith(prefix)) continue;
|
|
34
|
+
const suffix = entry.slice(prefix.length);
|
|
35
|
+
if (!/^\d+$/.test(suffix)) continue;
|
|
36
|
+
const value = Number(suffix);
|
|
37
|
+
if (max === null || value > max) max = value;
|
|
38
|
+
}
|
|
39
|
+
return max;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Find the browser binary: env override wins, then headless shell (highest
|
|
43
|
+
* revision), then full chromium. Throws with an install hint when absent.
|
|
44
|
+
*/
|
|
45
|
+
function discoverBrowser(opts = {}) {
|
|
46
|
+
const exists = opts.exists ?? existsSync;
|
|
47
|
+
if (opts.overridePath !== void 0) {
|
|
48
|
+
if (!exists(opts.overridePath)) throw new Error(`browser-verify: DSH_BROWSER_VERIFY_CHROMIUM 指向的二进制不存在: ${opts.overridePath}。请检查路径或取消该环境变量。`);
|
|
49
|
+
return {
|
|
50
|
+
executablePath: opts.overridePath,
|
|
51
|
+
kind: "custom",
|
|
52
|
+
revision: 0,
|
|
53
|
+
known: true,
|
|
54
|
+
versionHint: null
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const cacheDir = opts.cacheDir ?? defaultCacheDir();
|
|
58
|
+
let list = null;
|
|
59
|
+
if (opts.entries !== void 0) list = opts.entries;
|
|
60
|
+
else try {
|
|
61
|
+
list = readdirSync(cacheDir);
|
|
62
|
+
} catch {
|
|
63
|
+
list = null;
|
|
64
|
+
}
|
|
65
|
+
if (list === null) throw new Error(`browser-verify: 未找到浏览器缓存目录 ${cacheDir}。请先安装:npx playwright install chromium(需 playwright-core@1.62.0),或设置 DSH_BROWSER_VERIFY_CHROMIUM=<完整路径>。`);
|
|
66
|
+
for (const { kind, prefix } of LIST_PREFIXES) {
|
|
67
|
+
const revision = maxRevision(list, prefix);
|
|
68
|
+
if (revision === null) continue;
|
|
69
|
+
const executablePath = join(cacheDir, `${kind === "headless-shell" ? `chromium_headless_shell-${revision}` : `chromium-${revision}`}`, SUBDIRS[kind]);
|
|
70
|
+
if (!exists(executablePath)) throw new Error(`browser-verify: 缓存目录存在 ${prefix}${revision} 但可执行文件缺失(${cacheDir})。请删除该目录后重新执行 npx playwright install chromium。`);
|
|
71
|
+
const known = KNOWN_REVISIONS[revision] !== void 0;
|
|
72
|
+
return {
|
|
73
|
+
executablePath,
|
|
74
|
+
kind,
|
|
75
|
+
revision,
|
|
76
|
+
known,
|
|
77
|
+
versionHint: known ? null : `浏览器 revision ${revision} 不在已认证表(playwright-core 1.62.0 认证 ${Object.keys(KNOWN_REVISIONS).join("/")});若协议异常,请安装匹配版本`
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`browser-verify: 未找到浏览器二进制。请先安装:npx playwright install chromium(需 playwright-core@1.62.0),或设置 DSH_BROWSER_VERIFY_CHROMIUM=<完整路径>。`);
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/browser/scenario.ts
|
|
84
|
+
/**
|
|
85
|
+
* One verification scenario = one page + its request mocks + assertion/shot
|
|
86
|
+
* state. Pure helpers are exported for unit tests; the IO methods use
|
|
87
|
+
* playwright-core. Polluting nothing outside the page's own requests.
|
|
88
|
+
* @module dsh-browser-verify/browser/scenario
|
|
89
|
+
*/
|
|
90
|
+
const MAX_VISIBLE = 8;
|
|
91
|
+
const MAX_VISIBLE_LEN = 40;
|
|
92
|
+
const MAX_ERRORS = 5;
|
|
93
|
+
const MAX_ERROR_LEN = 120;
|
|
94
|
+
function assertNoMockConflict(patterns, next) {
|
|
95
|
+
if (patterns.includes(next)) throw new Error(`browser-verify: 拦截 pattern 已存在: ${next}(已有: ${patterns.join(", ")})。请先 browser_open 重开场景或用不同的 urlPattern。`);
|
|
96
|
+
}
|
|
97
|
+
function normalizeCountSpec(count) {
|
|
98
|
+
if (typeof count === "number") return {
|
|
99
|
+
min: count,
|
|
100
|
+
max: count
|
|
101
|
+
};
|
|
102
|
+
if (count !== void 0 && typeof count.min === "number" && typeof count.max === "number") return {
|
|
103
|
+
min: count.min,
|
|
104
|
+
max: count.max
|
|
105
|
+
};
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function summarizeVisibleText(texts) {
|
|
109
|
+
const seen = /* @__PURE__ */ new Set();
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const raw of texts) {
|
|
112
|
+
const trimmed = raw.trim();
|
|
113
|
+
if (trimmed === "") continue;
|
|
114
|
+
const reduced = trimmed.length > MAX_VISIBLE_LEN ? trimmed.slice(0, MAX_VISIBLE_LEN) : trimmed;
|
|
115
|
+
if (seen.has(reduced)) continue;
|
|
116
|
+
seen.add(reduced);
|
|
117
|
+
out.push(reduced);
|
|
118
|
+
if (out.length >= MAX_VISIBLE) break;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
function capConsoleErrors(errors) {
|
|
123
|
+
return errors.slice(0, MAX_ERRORS).map((e) => e.length > MAX_ERROR_LEN ? `${e.slice(0, MAX_ERROR_LEN - 1)}…` : e);
|
|
124
|
+
}
|
|
125
|
+
function sha256Hex(data) {
|
|
126
|
+
return createHash("sha256").update(data).digest("hex");
|
|
127
|
+
}
|
|
128
|
+
/** Visible-text extraction, evaluated in the page: text of visible elements. */
|
|
129
|
+
const VISIBLE_TEXT_SCRIPT = `
|
|
130
|
+
Array.from(document.querySelectorAll('body *')).map(el => {
|
|
131
|
+
const rect = el.getBoundingClientRect()
|
|
132
|
+
if (rect.width === 0 || rect.height === 0) return ''
|
|
133
|
+
const text = (el.childElementCount === 0 ? el.textContent ?? '' : '').trim()
|
|
134
|
+
return text.length > 0 ? text : ''
|
|
135
|
+
})
|
|
136
|
+
`;
|
|
137
|
+
var Scenario = class {
|
|
138
|
+
page;
|
|
139
|
+
context;
|
|
140
|
+
mocks = /* @__PURE__ */ new Map();
|
|
141
|
+
lastScreenshotHash = null;
|
|
142
|
+
constructor(page, context) {
|
|
143
|
+
this.page = page;
|
|
144
|
+
this.context = context;
|
|
145
|
+
}
|
|
146
|
+
async navigate(opts) {
|
|
147
|
+
const started = Date.now();
|
|
148
|
+
const timeout = opts.timeoutMs ?? 1e4;
|
|
149
|
+
const errors = [];
|
|
150
|
+
const onError = (message) => {
|
|
151
|
+
errors.push(message);
|
|
152
|
+
};
|
|
153
|
+
this.page.on("console", (msg) => {
|
|
154
|
+
if (msg.type() === "error") onError(msg.text());
|
|
155
|
+
});
|
|
156
|
+
this.page.on("pageerror", (err) => onError(String(err)));
|
|
157
|
+
const response = await this.page.goto(opts.url, {
|
|
158
|
+
waitUntil: "domcontentloaded",
|
|
159
|
+
timeout
|
|
160
|
+
});
|
|
161
|
+
if (opts.waitSelector !== void 0) await this.page.waitForSelector(opts.waitSelector, { timeout });
|
|
162
|
+
const texts = await this.page.evaluate(VISIBLE_TEXT_SCRIPT);
|
|
163
|
+
return {
|
|
164
|
+
title: await this.page.title(),
|
|
165
|
+
url: this.page.url(),
|
|
166
|
+
status: response?.status() ?? null,
|
|
167
|
+
visible: summarizeVisibleText(texts),
|
|
168
|
+
consoleErrors: capConsoleErrors(errors),
|
|
169
|
+
elapsedMs: Date.now() - started
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async addMock(rule) {
|
|
173
|
+
assertNoMockConflict([...this.mocks.keys()], rule.urlPattern);
|
|
174
|
+
const status = rule.status ?? 200;
|
|
175
|
+
this.mocks.set(rule.urlPattern, {
|
|
176
|
+
json: rule.json,
|
|
177
|
+
status
|
|
178
|
+
});
|
|
179
|
+
await this.context.route(rule.urlPattern, async (route) => {
|
|
180
|
+
const body = Buffer.from(JSON.stringify(this.mocks.get(rule.urlPattern)?.json ?? rule.json));
|
|
181
|
+
await route.fulfill({
|
|
182
|
+
status,
|
|
183
|
+
body,
|
|
184
|
+
contentType: "application/json; charset=utf-8"
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
if (rule.reload !== false) await this.page.reload({
|
|
188
|
+
waitUntil: "domcontentloaded",
|
|
189
|
+
timeout: rule.timeoutMs ?? 1e4
|
|
190
|
+
});
|
|
191
|
+
return [...this.mocks.keys()];
|
|
192
|
+
}
|
|
193
|
+
async assert(opts) {
|
|
194
|
+
const started = Date.now();
|
|
195
|
+
const expected = normalizeCountSpec(opts.count);
|
|
196
|
+
try {
|
|
197
|
+
await this.page.waitForSelector(opts.selector, {
|
|
198
|
+
state: "attached",
|
|
199
|
+
timeout: opts.timeoutMs
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof Error && /timeout/i.test(error.message)) return {
|
|
203
|
+
pass: expected !== null && expected.min === 0 && expected.max === 0 && opts.text === void 0,
|
|
204
|
+
count: 0,
|
|
205
|
+
actualText: null,
|
|
206
|
+
elapsedMs: Date.now() - started
|
|
207
|
+
};
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
const count = await this.page.locator(opts.selector).count();
|
|
211
|
+
const actualText = await this.page.locator(opts.selector).first().textContent();
|
|
212
|
+
return {
|
|
213
|
+
pass: (expected === null || count >= expected.min && count <= expected.max) && (opts.text === void 0 || actualText !== null && actualText.includes(opts.text)),
|
|
214
|
+
count,
|
|
215
|
+
actualText,
|
|
216
|
+
elapsedMs: Date.now() - started
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async screenshot(opts) {
|
|
220
|
+
const data = await this.page.screenshot({
|
|
221
|
+
fullPage: opts.fullPage ?? false,
|
|
222
|
+
type: "png"
|
|
223
|
+
});
|
|
224
|
+
const sha256 = sha256Hex(data);
|
|
225
|
+
const identicalToPrevious = sha256 === this.lastScreenshotHash;
|
|
226
|
+
this.lastScreenshotHash = sha256;
|
|
227
|
+
return {
|
|
228
|
+
data,
|
|
229
|
+
sha256,
|
|
230
|
+
identicalToPrevious
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async close() {
|
|
234
|
+
try {
|
|
235
|
+
await this.context.close();
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/browser/driver.ts
|
|
241
|
+
/**
|
|
242
|
+
* Browser driving: one lazy launch per process, one active verification
|
|
243
|
+
* scenario, FIFO-serialized tool access, idle reclamation, graceful close +
|
|
244
|
+
* temp dir removal on dispose. launch args are pure for unit tests.
|
|
245
|
+
* @module dsh-browser-verify/browser/driver
|
|
246
|
+
*/
|
|
247
|
+
/**
|
|
248
|
+
* Headless launch args. Deviation D8-3: playwright-core >= 1.41 rejects
|
|
249
|
+
* `--user-data-dir` inside `args` (misuse error, both launch and
|
|
250
|
+
* launchPersistentContext); the user data dir must be passed as the
|
|
251
|
+
* launchPersistentContext first parameter. Only the headless-mode flag remains.
|
|
252
|
+
*/
|
|
253
|
+
function buildLaunchArgs(headlessShell) {
|
|
254
|
+
return [headlessShell ? "--headless" : "--headless=new"];
|
|
255
|
+
}
|
|
256
|
+
var BrowserDriver = class {
|
|
257
|
+
opts;
|
|
258
|
+
browser = null;
|
|
259
|
+
discovered = null;
|
|
260
|
+
scenario = null;
|
|
261
|
+
userDataDir = join(tmpdir(), `dsh-browser-verify-${process.pid}`, "profile");
|
|
262
|
+
idleTimer = null;
|
|
263
|
+
lockChain = Promise.resolve();
|
|
264
|
+
disposed = false;
|
|
265
|
+
constructor(opts = {}) {
|
|
266
|
+
this.opts = opts;
|
|
267
|
+
}
|
|
268
|
+
/** FIFO serialization: every tool op runs alone. */
|
|
269
|
+
chain(fn) {
|
|
270
|
+
const run = this.lockChain.then(fn);
|
|
271
|
+
this.lockChain = run.catch(() => void 0);
|
|
272
|
+
return run;
|
|
273
|
+
}
|
|
274
|
+
/** Reject new op entries once disposed; the engine cannot come back. */
|
|
275
|
+
ensureNotDisposed() {
|
|
276
|
+
if (this.disposed) throw new Error("browser-verify: 验证引擎已停止。请重新调用 browser_open 开始新的验证。");
|
|
277
|
+
}
|
|
278
|
+
/** Normalize errors at the driver boundary: prefix + context + advice. */
|
|
279
|
+
wrapError(error, context, advice) {
|
|
280
|
+
if (error instanceof Error && error.message.startsWith("browser-verify: ")) return error;
|
|
281
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
282
|
+
return /* @__PURE__ */ new Error(`browser-verify: ${context}: ${message}。${advice}`);
|
|
283
|
+
}
|
|
284
|
+
withScenario(fn) {
|
|
285
|
+
this.ensureNotDisposed();
|
|
286
|
+
return this.chain(async () => {
|
|
287
|
+
this.ensureNotDisposed();
|
|
288
|
+
this.resetIdleTimer();
|
|
289
|
+
try {
|
|
290
|
+
return await fn(this.requireScenario());
|
|
291
|
+
} catch (error) {
|
|
292
|
+
throw this.wrapError(error, "场景操作失败", "请 browser_open 重开场景后重试。");
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/** Open a fresh verification scenario; per design, each open = new context+page. */
|
|
297
|
+
async startScenario(reset) {
|
|
298
|
+
this.ensureNotDisposed();
|
|
299
|
+
return this.chain(async () => {
|
|
300
|
+
this.ensureNotDisposed();
|
|
301
|
+
this.resetIdleTimer();
|
|
302
|
+
return this.openScenario(reset);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async openScenario(reset) {
|
|
306
|
+
const browser = await this.ensureBrowser();
|
|
307
|
+
await this.scenario?.close();
|
|
308
|
+
const context = await browser.newContext({
|
|
309
|
+
viewport: reset.viewport ?? this.opts.viewport ?? {
|
|
310
|
+
width: 390,
|
|
311
|
+
height: 844
|
|
312
|
+
},
|
|
313
|
+
deviceScaleFactor: reset.deviceScaleFactor ?? this.opts.deviceScaleFactor ?? 2
|
|
314
|
+
});
|
|
315
|
+
const page = await context.newPage();
|
|
316
|
+
this.scenario = new Scenario(page, context);
|
|
317
|
+
this.resetIdleTimer();
|
|
318
|
+
try {
|
|
319
|
+
for (const rule of reset.mocks ?? []) await this.scenario.addMock({
|
|
320
|
+
...rule,
|
|
321
|
+
reload: false
|
|
322
|
+
});
|
|
323
|
+
return {
|
|
324
|
+
...await this.scenario.navigate({
|
|
325
|
+
url: reset.url,
|
|
326
|
+
waitSelector: reset.waitSelector,
|
|
327
|
+
timeoutMs: reset.timeoutMs ?? this.opts.timeoutMs
|
|
328
|
+
}),
|
|
329
|
+
browserKnown: this.discovered?.known ?? true,
|
|
330
|
+
versionHint: this.discovered?.versionHint ?? null
|
|
331
|
+
};
|
|
332
|
+
} catch (error) {
|
|
333
|
+
await this.scenario.close();
|
|
334
|
+
this.scenario = null;
|
|
335
|
+
throw this.wrapError(error, "打开页面失败", "请检查 URL 是否可访问、页面是否可在超时内加载,必要时调大 DSH_BROWSER_VERIFY_TIMEOUT。");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
resetIdleTimer() {
|
|
339
|
+
if (this.idleTimer !== null) clearTimeout(this.idleTimer);
|
|
340
|
+
this.idleTimer = setTimeout(() => {
|
|
341
|
+
this.dispose();
|
|
342
|
+
}, this.opts.idleMs ?? 6e5);
|
|
343
|
+
}
|
|
344
|
+
async ensureBrowser() {
|
|
345
|
+
this.ensureNotDisposed();
|
|
346
|
+
if (this.browser !== null) return this.browser;
|
|
347
|
+
try {
|
|
348
|
+
const found = (this.opts.discover ?? discoverBrowser)({ overridePath: process.env.DSH_BROWSER_VERIFY_CHROMIUM ?? void 0 });
|
|
349
|
+
this.discovered = found;
|
|
350
|
+
const browser = (await chromium.launchPersistentContext(this.userDataDir, {
|
|
351
|
+
executablePath: found.executablePath,
|
|
352
|
+
args: buildLaunchArgs(found.kind === "headless-shell"),
|
|
353
|
+
headless: true
|
|
354
|
+
})).browser();
|
|
355
|
+
if (browser === null) throw new Error("browser-verify: 持久化上下文未返回浏览器实例。请检查 DSH_BROWSER_VERIFY_CHROMIUM 指向的浏览器,或重新执行 npx playwright install chromium。");
|
|
356
|
+
this.browser = browser;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw this.wrapError(error, "浏览器启动失败", "请检查 DSH_BROWSER_VERIFY_CHROMIUM 指向的浏览器路径,或重新执行 npx playwright install chromium。");
|
|
359
|
+
}
|
|
360
|
+
this.resetIdleTimer();
|
|
361
|
+
return this.browser;
|
|
362
|
+
}
|
|
363
|
+
requireScenario() {
|
|
364
|
+
if (this.scenario === null) throw new Error("browser-verify: 尚未打开验证会话。请先调用 browser_open 打开页面。");
|
|
365
|
+
return this.scenario;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Reset on dispose: mark disposed first (idempotent, blocks new ops), then
|
|
369
|
+
* run the teardown serialized on the FIFO chain so it waits out any
|
|
370
|
+
* in-flight op and cannot interleave with a launch or scenario op.
|
|
371
|
+
*/
|
|
372
|
+
async dispose() {
|
|
373
|
+
if (this.disposed) return;
|
|
374
|
+
this.disposed = true;
|
|
375
|
+
await this.chain(() => this.teardown());
|
|
376
|
+
}
|
|
377
|
+
/** Best-effort teardown: close scenario + browser, delete the profile dir. */
|
|
378
|
+
async teardown() {
|
|
379
|
+
if (this.idleTimer !== null) {
|
|
380
|
+
clearTimeout(this.idleTimer);
|
|
381
|
+
this.idleTimer = null;
|
|
382
|
+
}
|
|
383
|
+
const scenario = this.scenario;
|
|
384
|
+
this.scenario = null;
|
|
385
|
+
if (scenario !== null) try {
|
|
386
|
+
await scenario.close();
|
|
387
|
+
} catch {}
|
|
388
|
+
const browser = this.browser;
|
|
389
|
+
this.browser = null;
|
|
390
|
+
if (browser !== null) try {
|
|
391
|
+
await browser.close();
|
|
392
|
+
} catch {
|
|
393
|
+
try {
|
|
394
|
+
await this.hardKillChromium();
|
|
395
|
+
} catch {}
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
rmSync(join(tmpdir(), `dsh-browser-verify-${process.pid}`), {
|
|
399
|
+
recursive: true,
|
|
400
|
+
force: true
|
|
401
|
+
});
|
|
402
|
+
} catch {}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Hard-kill fallback: SIGKILL every process whose command line still
|
|
406
|
+
* carries our user-data-dir (playwright appends `--user-data-dir` itself;
|
|
407
|
+
* our predictable temp dir is the reverse-lookup key, per design §6).
|
|
408
|
+
*/
|
|
409
|
+
async hardKillChromium() {
|
|
410
|
+
const out = await new Promise((resolve) => {
|
|
411
|
+
exec("ps -Ao pid=,ppid=,command=", { maxBuffer: 10 * 1024 * 1024 }, (error, stdout) => resolve(error ? "" : stdout));
|
|
412
|
+
});
|
|
413
|
+
const marker = `--user-data-dir=${this.userDataDir}`;
|
|
414
|
+
for (const line of out.split("\n")) {
|
|
415
|
+
const m = /^\s*(\d+)\s+\d+\s+(.+)$/.exec(line);
|
|
416
|
+
if (m === null) continue;
|
|
417
|
+
if (m[2].includes(marker)) try {
|
|
418
|
+
process.kill(Number(m[1]), "SIGKILL");
|
|
419
|
+
} catch {}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
//#endregion
|
|
424
|
+
export { BrowserDriver as t };
|