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,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 };