dsh-browser-view 0.1.2 → 0.3.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/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
- import { isAbsolute, join, resolve } from "node:path";
1
+ import { dirname, isAbsolute, join, resolve } from "node:path";
2
+ import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  //#region lib/types/lib/config.js
5
5
  function typeOf(value) {
@@ -105,6 +105,13 @@ function sendText(res, status, text, headers = {}) {
105
105
  }
106
106
  //#endregion
107
107
  //#region lib/types/lib/tools.js
108
+ /** 可选的诊断日志(由 registerTools 注入 ctx.logger;未注入时静默)。 */
109
+ let logFn = null;
110
+ let cwdLogged = false;
111
+ /** 注入日志函数(插件 apply 时调用一次)。 */
112
+ function setToolsLogger(fn) {
113
+ logFn = fn;
114
+ }
108
115
  /**
109
116
  * Resolve the workspace working directory for one tool execution.
110
117
  * Prefers the calling agent's session cwd; falls back to the host process cwd.
@@ -114,9 +121,43 @@ function workspaceCwd(exec) {
114
121
  const cwd = exec?.agent?.session?.meta?.cwd;
115
122
  if (typeof cwd === "string" && cwd.length > 0) return cwd;
116
123
  } catch {}
124
+ try {
125
+ const cwd = exec?.cwd;
126
+ if (typeof cwd === "string" && cwd.length > 0) return cwd;
127
+ } catch {}
117
128
  return process.cwd();
118
129
  }
119
130
  /**
131
+ * 截图/下载/上传等文件的落盘基准目录:
132
+ * 1. config.workspaceDir 显式指定(相对路径按 process.cwd() 解析)——最高优先;
133
+ * 2. 会话 cwd 探测链(workspaceCwd);
134
+ * 首次解析打一次日志,记录来源,便于诊断「文件落错位置」类问题。
135
+ */
136
+ function workspaceBaseDir(config, exec) {
137
+ const explicit = config.workspaceDir;
138
+ if (typeof explicit === "string" && explicit.length > 0) {
139
+ const dir = isAbsolute(explicit) ? explicit : resolve(process.cwd(), explicit);
140
+ if (!cwdLogged) {
141
+ cwdLogged = true;
142
+ logFn?.(`dsh-browser: 工作目录基准 = ${dir} (来源: config.workspaceDir)`);
143
+ }
144
+ return dir;
145
+ }
146
+ const dir = workspaceCwd(exec);
147
+ if (!cwdLogged) {
148
+ cwdLogged = true;
149
+ const fromSession = (() => {
150
+ try {
151
+ return typeof exec?.agent?.session?.meta?.cwd === "string" && exec.agent.session.meta.cwd.length > 0;
152
+ } catch {
153
+ return false;
154
+ }
155
+ })();
156
+ logFn?.(`dsh-browser: 工作目录基准 = ${dir} (来源: ${fromSession ? "session.meta.cwd" : "process.cwd"})`);
157
+ }
158
+ return dir;
159
+ }
160
+ /**
120
161
  * Build a plain ToolDefinition for `ctx.tools.register` without depending on
121
162
  * any @deepseek-ai package (max version-alignment tolerance).
122
163
  */
@@ -163,24 +204,56 @@ const historyByProfile = /* @__PURE__ */ new Map();
163
204
  const savedForms = /* @__PURE__ */ new Map();
164
205
  /** 最近一次 browser_form 填充的字段(供 browser_form_save 无参保存)。 */
165
206
  let lastFormFields = [];
207
+ const refRegistry = /* @__PURE__ */ new WeakMap();
208
+ /** 记录一次 aria 快照分配的 ref(整表替换)。 */
209
+ function setPageRefs(page, refs) {
210
+ refRegistry.set(page, refs);
211
+ }
212
+ /** 查询某个 ref 的定位信息;未注册返回 undefined。 */
213
+ function pageRef(page, ref) {
214
+ return refRegistry.get(page)?.get(ref);
215
+ }
216
+ /** 清空页面的 ref 表(导航/新快照时调用)。 */
217
+ function clearPageRefs(page) {
218
+ refRegistry.delete(page);
219
+ }
220
+ /** 取页面当前完整 ref 表(browser_find 增量续号合并用;无则空 Map)。 */
221
+ function pageRefsOf(page) {
222
+ return refRegistry.get(page) ?? /* @__PURE__ */ new Map();
223
+ }
224
+ const snapshotCaches = /* @__PURE__ */ new WeakMap();
225
+ function snapshotCacheOf(page) {
226
+ return snapshotCaches.get(page);
227
+ }
228
+ function setSnapshotCache(page, cache) {
229
+ snapshotCaches.set(page, cache);
230
+ }
166
231
  /** 已保存的录制脚本:name → 步骤数组。 */
167
232
  const savedRecordings = /* @__PURE__ */ new Map();
168
233
  /** 当前录制中的 buffer(非空 = 录制中)。 */
169
234
  let recordingSteps = null;
235
+ /** 最近一次 start→stop 的录制结果(save 优先使用;下次 start 时覆盖)。 */
236
+ let lastRecording = null;
170
237
  /** 操作轨迹(全局,记录 currentProfile;上限 500 条)。 */
171
238
  const testLog = [];
172
239
  function isRecording() {
173
240
  return recordingSteps !== null;
174
241
  }
175
242
  function startRecording() {
243
+ lastRecording = null;
176
244
  recordingSteps = [];
177
245
  }
178
246
  function stopRecording() {
179
247
  const steps = recordingSteps ?? [];
180
248
  recordingSteps = null;
249
+ lastRecording = steps;
181
250
  return steps;
182
251
  }
183
- /** 操作轨迹追加(同时写入录制 buffer,排除 record/replay 自身)。 */
252
+ /** 最近一次完整录制(start→stop)的 buffer;从未录制过返回 null。 */
253
+ function lastRecordingOf() {
254
+ return lastRecording;
255
+ }
256
+ /** 操作轨迹追加(同时写入录制 buffer,排除 record/replay 自身)。返回存入的调用对象(供调用方补 meta)。 */
184
257
  function recordCall(call) {
185
258
  testLog.push(call);
186
259
  if (testLog.length > 500) testLog.shift();
@@ -188,11 +261,201 @@ function recordCall(call) {
188
261
  recordingSteps.push(call);
189
262
  if (recordingSteps.length > 500) recordingSteps.shift();
190
263
  }
264
+ return call;
191
265
  }
192
266
  /** 操作轨迹(按时间倒序,最新在前)。 */
193
267
  function testLogOf() {
194
268
  return [...testLog].reverse();
195
269
  }
270
+ /**
271
+ * 当前人工接管等待状态(browser_pause_for_human 等待期间非空),
272
+ * 供实时画面面板展示「等待人工操作」提示。
273
+ */
274
+ let pauseState = null;
275
+ function setPauseInstruction(instruction) {
276
+ pauseState = {
277
+ instruction,
278
+ startedAt: Date.now()
279
+ };
280
+ }
281
+ function clearPauseInstruction() {
282
+ pauseState = null;
283
+ }
284
+ function currentPause() {
285
+ return pauseState;
286
+ }
287
+ let pendingConfirm = null;
288
+ function setPendingConfirm(entry) {
289
+ pendingConfirm = entry;
290
+ }
291
+ function getPendingConfirm() {
292
+ return pendingConfirm;
293
+ }
294
+ function clearPendingConfirm() {
295
+ pendingConfirm = null;
296
+ }
297
+ /** 面板提交审批决策;id 不匹配(已被新审批取代/超时清理)时返回 false。 */
298
+ function resolveConfirm(id, decision) {
299
+ if (pendingConfirm === null || pendingConfirm.id !== id) return false;
300
+ pendingConfirm.decision = decision;
301
+ return true;
302
+ }
303
+ /** window 上的回传 binding 名(须为合法 JS 标识符)。 */
304
+ const HUMAN_BINDING = "__dshBrowserHumanEvent";
305
+ /** 最近一次 agent DOM 操作(点击/输入/选择)的时间戳;用于区分人手与工具回声。 */
306
+ let lastAgentActionAt = 0;
307
+ /** agent 即将/刚刚执行了 DOM 操作(register 包装层调用);1.5s 内的页内事件视为工具回声。 */
308
+ function noteAgentAction() {
309
+ lastAgentActionAt = Date.now();
310
+ }
311
+ /** 导出供测试:距上次 agent 操作是否在去重窗口内。 */
312
+ function agentActionRecent(now = Date.now(), windowMs = 1500) {
313
+ return now - lastAgentActionAt < windowMs;
314
+ }
315
+ /**
316
+ * 页内人工操作事件 → 录制步骤(纯函数,导出供测试)。
317
+ * payload 由 installHumanRecorder 生成:{ kind, selector, value?, testid?, role?, name?, id?, text?, tag? }。
318
+ * 无法映射(缺 selector / 未知 kind / 空输入)返回 null。
319
+ */
320
+ function humanEventToStep(payload) {
321
+ if (payload === null || typeof payload !== "object") return null;
322
+ const p = payload;
323
+ const selector = typeof p.selector === "string" ? p.selector : "";
324
+ if (!selector) return null;
325
+ const hint = {
326
+ ...p.testid !== void 0 && p.testid !== "" ? { testid: String(p.testid) } : {},
327
+ ...p.role !== void 0 && p.role !== "" ? { role: String(p.role) } : {},
328
+ ...p.name !== void 0 && p.name !== "" ? { name: String(p.name) } : {},
329
+ ...p.id !== void 0 && p.id !== "" ? { id: String(p.id) } : {},
330
+ ...p.text !== void 0 && p.text !== "" ? { text: String(p.text) } : {},
331
+ ...p.tag !== void 0 && p.tag !== "" ? { tag: String(p.tag) } : {}
332
+ };
333
+ if (p.kind === "click") return {
334
+ tool: "browser_click",
335
+ args: { selector },
336
+ meta: {
337
+ hint,
338
+ source: "human"
339
+ }
340
+ };
341
+ if (p.kind === "input") {
342
+ const text = typeof p.value === "string" ? p.value : "";
343
+ if (text === "") return null;
344
+ return {
345
+ tool: "browser_type",
346
+ args: {
347
+ selector,
348
+ text
349
+ },
350
+ meta: {
351
+ hint,
352
+ source: "human"
353
+ }
354
+ };
355
+ }
356
+ if (p.kind === "select") {
357
+ const option = typeof p.value === "string" ? p.value.trim() : "";
358
+ if (!option) return null;
359
+ return {
360
+ tool: "browser_select",
361
+ args: {
362
+ selector,
363
+ option
364
+ },
365
+ meta: {
366
+ hint,
367
+ source: "human"
368
+ }
369
+ };
370
+ }
371
+ return null;
372
+ }
373
+ /** binding 回调入站口:仅录制中生效;去重窗口内(agent 工具回声)丢弃。 */
374
+ function handleHumanEvent(payload) {
375
+ if (!isRecording()) return;
376
+ if (agentActionRecent()) return;
377
+ const step = humanEventToStep(payload);
378
+ if (step === null) return;
379
+ recordCall({
380
+ tool: step.tool,
381
+ args: step.args,
382
+ ok: true,
383
+ ms: 0,
384
+ ts: Date.now(),
385
+ meta: step.meta,
386
+ source: "human"
387
+ });
388
+ }
389
+ /**
390
+ * 页内注入的人工操作监听(addInitScript 序列化;每次导航自动重装)。
391
+ * 监听常驻但 node 侧只在录制中生效,关闭录制等价于"移除"。
392
+ * 只上报可交互元素上的 click 与表单控件的 change,避免逐键噪音。
393
+ */
394
+ function installHumanRecorder() {
395
+ const w = window;
396
+ if (w.__dshHumanRecorder === true) return;
397
+ w.__dshHumanRecorder = true;
398
+ const BINDING = "__dshBrowserHumanEvent";
399
+ const INTERACTIVE = "a,button,input,select,textarea,summary,[role='button'],[role='tab'],[role='menuitem'],[role='option'],[role='switch'],[role='checkbox']";
400
+ function cssPath(el) {
401
+ if (el.id) return `#${CSS.escape(el.id)}`;
402
+ const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
403
+ if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
404
+ const parts = [];
405
+ let cur = el;
406
+ while (cur && cur !== document.body && parts.length < 6) {
407
+ const tag = cur.tagName.toLowerCase();
408
+ const parent = cur.parentElement;
409
+ if (!parent) {
410
+ parts.unshift(tag);
411
+ break;
412
+ }
413
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
414
+ const idx = sameTag.indexOf(cur) + 1;
415
+ parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
416
+ cur = parent;
417
+ }
418
+ return parts.join(" > ");
419
+ }
420
+ function payloadOf(el, kind, value) {
421
+ const aria = el.getAttribute("aria-label");
422
+ return {
423
+ kind,
424
+ selector: cssPath(el),
425
+ ...value !== void 0 ? { value } : {},
426
+ testid: el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? "",
427
+ role: el.getAttribute("role") ?? "",
428
+ name: aria && aria.trim() ? aria.trim().slice(0, 40) : "",
429
+ id: el.id ?? "",
430
+ text: (el.innerText || el.textContent || "").trim().slice(0, 40),
431
+ tag: el.tagName.toLowerCase()
432
+ };
433
+ }
434
+ function send(payload) {
435
+ try {
436
+ const fn = w[BINDING];
437
+ if (typeof fn === "function") Promise.resolve(fn(payload)).catch(() => {});
438
+ } catch {}
439
+ }
440
+ document.addEventListener("click", (e) => {
441
+ const target = e.target instanceof Element ? e.target.closest(INTERACTIVE) : null;
442
+ if (target === null) return;
443
+ send(payloadOf(target, "click"));
444
+ }, true);
445
+ document.addEventListener("change", (e) => {
446
+ const t = e.target instanceof Element ? e.target : null;
447
+ if (t === null) return;
448
+ if (t.tagName === "SELECT") {
449
+ const opt = t.selectedOptions[0];
450
+ send(payloadOf(t, "select", (opt ? opt.textContent : "").trim()));
451
+ } else if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") {
452
+ const type = (t.getAttribute("type") ?? "text").toLowerCase();
453
+ if (type === "checkbox" || type === "radio") return;
454
+ if (type === "file") return;
455
+ send(payloadOf(t, "input", t.value));
456
+ }
457
+ }, true);
458
+ }
196
459
  const networkByProfile = /* @__PURE__ */ new Map();
197
460
  function networkOf(name) {
198
461
  let list = networkByProfile.get(name);
@@ -286,6 +549,7 @@ function attachPage(page, config) {
286
549
  const profile = currentProfile;
287
550
  page.on("framenavigated", (frame) => {
288
551
  if (frame !== page.mainFrame()) return;
552
+ clearPageRefs(page);
289
553
  const url = frame.url();
290
554
  if (!url || url === "about:blank") return;
291
555
  recordNav(profile, url);
@@ -355,6 +619,10 @@ function attachPage(page, config) {
355
619
  durationMs: Date.now() - info.startedAt
356
620
  });
357
621
  });
622
+ page.exposeBinding(HUMAN_BINDING, (_source, payload) => {
623
+ handleHumanEvent(payload);
624
+ return null;
625
+ }).then(() => page.addInitScript(installHumanRecorder)).then(() => page.evaluate(installHumanRecorder).catch(() => {})).catch(() => {});
358
626
  }
359
627
  async function loadPlaywright() {
360
628
  try {
@@ -421,6 +689,17 @@ async function launchProfile(name, config) {
421
689
  function stateOf() {
422
690
  return getProfileState(currentProfile);
423
691
  }
692
+ const namedContexts = /* @__PURE__ */ new Map();
693
+ function namedContextsOf() {
694
+ return namedContexts;
695
+ }
696
+ /** 把一个已 attach 的页面注册进 profile 标签表并激活,返回分配的 tab id。 */
697
+ function registerTabPage(state, page) {
698
+ const id = state.counter++;
699
+ state.pages.set(id, page);
700
+ state.activeId = id;
701
+ return id;
702
+ }
424
703
  /** 新建一个页面(当前 profile 会话未启动时先启动)。 */
425
704
  async function newPage(config) {
426
705
  const state = stateOf();
@@ -477,6 +756,7 @@ async function tabList() {
477
756
  return tabs;
478
757
  }
479
758
  async function closeBrowser() {
759
+ namedContexts.clear();
480
760
  for (const [name, state] of profileStates) {
481
761
  const { browser, context } = state;
482
762
  state.browser = null;
@@ -600,13 +880,14 @@ function loadRecordings() {
600
880
  } catch {}
601
881
  return loaded;
602
882
  }
603
- /** 写一个录制到磁盘(原子替换);返回文件路径。 */
883
+ /** 写一个录制到磁盘(原子替换);返回文件路径。formatVersion 2 起步骤可带 meta(选择器指纹)与 {{var}} 占位符。 */
604
884
  function saveRecordingFile(name, steps) {
605
885
  const dir = recordingsDir();
606
886
  mkdirSync(dir, { recursive: true });
607
887
  const file = join(dir, `${safeName(name)}.json`);
608
888
  const tmp = `${file}.tmp`;
609
889
  writeFileSync(tmp, JSON.stringify({
890
+ formatVersion: 2,
610
891
  steps,
611
892
  savedAt: Date.now()
612
893
  }, null, 2));
@@ -648,7 +929,308 @@ function deleteRecording(name) {
648
929
  return existed;
649
930
  }
650
931
  //#endregion
932
+ //#region lib/types/browser/extract.js
933
+ /** 把一行里的值转成 CSV 单元格文本(引号双写转义,对象 JSON 化)。 */
934
+ function csvCell(value) {
935
+ if (value === null || value === void 0) return "";
936
+ const s = typeof value === "string" ? value : JSON.stringify(value);
937
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
938
+ }
939
+ /** 行的表头:所有行 key 的并集(按首次出现顺序)。 */
940
+ function headerOf(rows) {
941
+ const keys = [];
942
+ for (const row of rows) for (const k of Object.keys(row)) if (!keys.includes(k)) keys.push(k);
943
+ return keys;
944
+ }
945
+ function toCsv(rows) {
946
+ const header = headerOf(rows);
947
+ const lines = [header.map(csvCell).join(",")];
948
+ for (const row of rows) lines.push(header.map((k) => csvCell(row[k])).join(","));
949
+ return lines.join("\n") + "\n";
950
+ }
951
+ function toJsonl(rows) {
952
+ return rows.map((r) => JSON.stringify(r)).join("\n") + (rows.length > 0 ? "\n" : "");
953
+ }
954
+ function toMarkdownTable(rows) {
955
+ const header = headerOf(rows);
956
+ if (header.length === 0) return "(空)\n";
957
+ const cell = (v) => {
958
+ if (v === null || v === void 0) return "";
959
+ return (typeof v === "string" ? v : JSON.stringify(v)).replace(/\|/g, "\\|").replace(/\n/g, " ");
960
+ };
961
+ return [
962
+ `| ${header.join(" | ")} |`,
963
+ `| ${header.map(() => "---").join(" | ")} |`,
964
+ ...rows.map((r) => `| ${header.map((k) => cell(r[k])).join(" | ")} |`)
965
+ ].join("\n") + "\n";
966
+ }
967
+ /** 按格式序列化抽取结果(json 为 pretty JSON 字符串)。 */
968
+ function serializeRows(rows, format) {
969
+ if (format === "csv") return toCsv(rows);
970
+ if (format === "jsonl") return toJsonl(rows);
971
+ if (format === "markdown") return toMarkdownTable(rows);
972
+ return JSON.stringify(rows, null, 2) + "\n";
973
+ }
974
+ /** 由文件扩展名推断序列化格式(显式 format 优先)。 */
975
+ function formatFromFile(file) {
976
+ if (/\.csv$/i.test(file)) return "csv";
977
+ if (/\.jsonl$/i.test(file)) return "jsonl";
978
+ if (/\.(md|markdown)$/i.test(file)) return "markdown";
979
+ if (/\.json$/i.test(file)) return "json";
980
+ return null;
981
+ }
982
+ /**
983
+ * 解析 CSV 文本为行对象数组(首行为表头;支持引号包裹、双引号转义、
984
+ * 逗号/换行内嵌)。供 browser_replay 的 dataFile 使用。
985
+ */
986
+ function parseCsv(text) {
987
+ const rows = [];
988
+ let cur = [];
989
+ let field = "";
990
+ let inQuotes = false;
991
+ let i = 0;
992
+ const pushField = () => {
993
+ cur.push(field);
994
+ field = "";
995
+ };
996
+ const pushRow = () => {
997
+ pushField();
998
+ rows.push(cur);
999
+ cur = [];
1000
+ };
1001
+ while (i < text.length) {
1002
+ const ch = text[i];
1003
+ if (inQuotes) {
1004
+ if (ch === "\"") {
1005
+ if (text[i + 1] === "\"") {
1006
+ field += "\"";
1007
+ i += 2;
1008
+ continue;
1009
+ }
1010
+ inQuotes = false;
1011
+ i++;
1012
+ continue;
1013
+ }
1014
+ field += ch;
1015
+ i++;
1016
+ continue;
1017
+ }
1018
+ if (ch === "\"") {
1019
+ inQuotes = true;
1020
+ i++;
1021
+ continue;
1022
+ }
1023
+ if (ch === ",") {
1024
+ pushField();
1025
+ i++;
1026
+ continue;
1027
+ }
1028
+ if (ch === "\r") {
1029
+ i++;
1030
+ continue;
1031
+ }
1032
+ if (ch === "\n") {
1033
+ pushRow();
1034
+ i++;
1035
+ continue;
1036
+ }
1037
+ field += ch;
1038
+ i++;
1039
+ }
1040
+ if (field !== "" || cur.length > 0) pushRow();
1041
+ const header = rows[0]?.map((h) => h.trim()) ?? [];
1042
+ const out = [];
1043
+ for (let r = 1; r < rows.length; r++) {
1044
+ const cells = rows[r];
1045
+ if (cells.length === 1 && cells[0] === "") continue;
1046
+ const obj = {};
1047
+ header.forEach((h, c) => {
1048
+ obj[h] = cells[c] ?? "";
1049
+ });
1050
+ out.push(obj);
1051
+ }
1052
+ return out;
1053
+ }
1054
+ //#endregion
1055
+ //#region lib/types/browser/reports.js
1056
+ /** 报告存储目录($DSH_HOME/.dsh-browser/reports)。 */
1057
+ function reportsDir() {
1058
+ const home = process.env.DSH_HOME || join(homedir(), ".dsh");
1059
+ return join(home, ".dsh-browser", "reports");
1060
+ }
1061
+ function runsFile() {
1062
+ return join(reportsDir(), "runs.jsonl");
1063
+ }
1064
+ /** 生成 runId:YYYYMMDD-HHmmss-随机4位。 */
1065
+ function makeRunId(ts) {
1066
+ const d = new Date(ts);
1067
+ const pad = (n) => String(n).padStart(2, "0");
1068
+ return `${`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`}-${Math.random().toString(36).slice(2, 6)}`;
1069
+ }
1070
+ const MAX_LINES = 1e3;
1071
+ const MAX_BYTES = 5242880;
1072
+ /** 追加一条运行记录;超限时截断旧行(原子替换)。 */
1073
+ function appendRun(record) {
1074
+ const dir = reportsDir();
1075
+ mkdirSync(dir, { recursive: true });
1076
+ const file = runsFile();
1077
+ appendFileSync(file, JSON.stringify(record) + "\n", "utf8");
1078
+ let size = 0;
1079
+ try {
1080
+ size = statSync(file).size;
1081
+ } catch {
1082
+ return;
1083
+ }
1084
+ if (size <= MAX_BYTES) {
1085
+ try {
1086
+ const lines = readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "");
1087
+ if (lines.length <= MAX_LINES) return;
1088
+ const kept = lines.slice(-1e3).join("\n") + "\n";
1089
+ const tmp = `${file}.tmp`;
1090
+ writeFileSync(tmp, kept, "utf8");
1091
+ renameSync(tmp, file);
1092
+ } catch {}
1093
+ return;
1094
+ }
1095
+ try {
1096
+ const kept = readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").slice(-1e3).join("\n") + "\n";
1097
+ const tmp = `${file}.tmp`;
1098
+ writeFileSync(tmp, kept, "utf8");
1099
+ renameSync(tmp, file);
1100
+ } catch {}
1101
+ }
1102
+ /** 读取全部运行记录(按时间倒序);坏行静默跳过。 */
1103
+ function listRuns(limit = 50) {
1104
+ let lines = [];
1105
+ try {
1106
+ lines = readFileSync(runsFile(), "utf8").split("\n").filter((l) => l.trim() !== "");
1107
+ } catch {
1108
+ return [];
1109
+ }
1110
+ const runs = [];
1111
+ for (let i = lines.length - 1; i >= 0 && runs.length < limit; i--) try {
1112
+ const r = JSON.parse(lines[i]);
1113
+ if (r !== null && typeof r === "object" && typeof r.runId === "string") runs.push(r);
1114
+ } catch {}
1115
+ return runs;
1116
+ }
1117
+ /** 最近一次运行(无记录返回 null)。 */
1118
+ function latestRun() {
1119
+ return listRuns(1)[0] ?? null;
1120
+ }
1121
+ /** 按 runId 取单条记录。 */
1122
+ function getRun(runId) {
1123
+ return listRuns(MAX_LINES).find((r) => r.runId === runId) ?? null;
1124
+ }
1125
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1126
+ const fmtTs = (ts) => new Date(ts).toLocaleString("zh-CN", { hour12: false });
1127
+ /** 聚合统计:失败步骤 TopN 与自愈步骤清单(两个渲染器共用)。 */
1128
+ function analyze(runs) {
1129
+ const failMap = /* @__PURE__ */ new Map();
1130
+ const healed = [];
1131
+ for (const run of runs) for (const step of run.results) if (!step.ok) {
1132
+ const key = `${run.source}|${step.tool}|${step.error ?? ""}`;
1133
+ const cur = failMap.get(key);
1134
+ if (cur !== void 0) cur.count++;
1135
+ else failMap.set(key, {
1136
+ source: run.source,
1137
+ tool: step.tool,
1138
+ error: step.error ?? "",
1139
+ count: 1
1140
+ });
1141
+ } else if (step.healed === true) healed.push({
1142
+ ts: run.ts,
1143
+ source: run.source,
1144
+ tool: step.tool,
1145
+ via: step.via ?? "",
1146
+ resolved: step.resolved ?? ""
1147
+ });
1148
+ return {
1149
+ failures: [...failMap.values()].sort((a, b) => b.count - a.count).slice(0, 10),
1150
+ healed
1151
+ };
1152
+ }
1153
+ /** 生成 Markdown 回归报告。 */
1154
+ function renderMarkdownReport(runs, source) {
1155
+ const lines = [
1156
+ `# ${source !== void 0 && source !== "" ? `回归报告 — ${source}` : "回归报告 — 全部来源"}`,
1157
+ "",
1158
+ `> 生成时间:${fmtTs(Date.now())} · 共 ${runs.length} 次运行`,
1159
+ "",
1160
+ "## 运行趋势(最新在前)",
1161
+ "",
1162
+ "| 时间 | 来源 | 通过/总数 | 失败 | 自愈 | 耗时 |",
1163
+ "| --- | --- | --- | --- | --- | --- |"
1164
+ ];
1165
+ for (const r of runs) lines.push(`| ${fmtTs(r.ts)} | ${r.source}${r.row !== void 0 ? ` (行${r.row})` : ""} | ${r.passed}/${r.total} | ${r.failed} | ${r.healedCount} | ${(r.durationMs / 1e3).toFixed(1)}s |`);
1166
+ const { failures, healed } = analyze(runs);
1167
+ lines.push("", "## 失败步骤 Top", "");
1168
+ if (failures.length === 0) lines.push("全部通过 🎉");
1169
+ else {
1170
+ lines.push("| 次数 | 来源 | 步骤 | 错误 |", "| --- | --- | --- | --- |");
1171
+ for (const f of failures) lines.push(`| ${f.count} | ${f.source} | ${f.tool} | ${f.error.slice(0, 120).replace(/\|/g, "\\|").replace(/\n/g, " ")} |`);
1172
+ }
1173
+ if (healed.length > 0) {
1174
+ lines.push("", "## 自愈步骤(建议修复录制中的选择器)", "", "| 时间 | 来源 | 步骤 | 自愈方式 | 新选择器 |", "| --- | --- | --- | --- | --- |");
1175
+ for (const h of healed.slice(0, 20)) lines.push(`| ${fmtTs(h.ts)} | ${h.source} | ${h.tool} | ${h.via} | \`${h.resolved}\` |`);
1176
+ }
1177
+ lines.push("");
1178
+ return lines.join("\n");
1179
+ }
1180
+ /** 生成自包含 HTML 回归报告(内联样式,无外部资源)。 */
1181
+ function renderHtmlReport(runs, source) {
1182
+ const title = source !== void 0 && source !== "" ? `回归报告 — ${source}` : "回归报告 — 全部来源";
1183
+ const { failures, healed } = analyze(runs);
1184
+ const rowHtml = runs.map((r) => {
1185
+ const ok = r.failed === 0;
1186
+ return `<tr><td>${esc(fmtTs(r.ts))}</td><td>${esc(r.source)}${r.row !== void 0 ? ` (行${r.row})` : ""}</td><td class="${ok ? "ok" : "bad"}">${r.passed}/${r.total}</td><td>${r.failed}</td><td>${r.healedCount}</td><td>${(r.durationMs / 1e3).toFixed(1)}s</td></tr>`;
1187
+ }).join("\n");
1188
+ const failRows = failures.length === 0 ? `<tr><td colspan="4" class="ok">全部通过 🎉</td></tr>` : failures.map((f) => `<tr><td>${f.count}</td><td>${esc(f.source)}</td><td>${esc(f.tool)}</td><td>${esc(f.error.slice(0, 200))}</td></tr>`).join("\n");
1189
+ const healRows = healed.slice(0, 20).map((h) => `<tr><td>${esc(fmtTs(h.ts))}</td><td>${esc(h.source)}</td><td>${esc(h.tool)}</td><td>${esc(h.via)}</td><td><code>${esc(h.resolved)}</code></td></tr>`).join("\n");
1190
+ return `<!doctype html>
1191
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>${esc(title)}</title>
1192
+ <style>
1193
+ body{font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;max-width:960px;margin:24px auto;padding:0 16px;color:#1f2933}
1194
+ h1{font-size:20px}h2{font-size:16px;margin-top:28px}
1195
+ table{border-collapse:collapse;width:100%;font-size:13px}
1196
+ th,td{border:1px solid #d9e2ec;padding:6px 10px;text-align:left;vertical-align:top}
1197
+ th{background:#f0f4f8}.ok{color:#0f9d58;font-weight:600}.bad{color:#d64545;font-weight:600}
1198
+ code{background:#f0f4f8;padding:1px 4px;border-radius:4px;font-size:12px}
1199
+ .meta{color:#7b8794;font-size:13px}
1200
+ </style></head><body>
1201
+ <h1>${esc(title)}</h1>
1202
+ <p class="meta">生成时间:${esc(fmtTs(Date.now()))} · 共 ${runs.length} 次运行</p>
1203
+ <h2>运行趋势(最新在前)</h2>
1204
+ <table><thead><tr><th>时间</th><th>来源</th><th>通过/总数</th><th>失败</th><th>自愈</th><th>耗时</th></tr></thead>
1205
+ <tbody>${rowHtml}</tbody></table>
1206
+ <h2>失败步骤 Top</h2>
1207
+ <table><thead><tr><th>次数</th><th>来源</th><th>步骤</th><th>错误</th></tr></thead>
1208
+ <tbody>${failRows}</tbody></table>
1209
+ ${healRows !== "" ? `<h2>自愈步骤(建议修复录制中的选择器)</h2>
1210
+ <table><thead><tr><th>时间</th><th>来源</th><th>步骤</th><th>自愈方式</th><th>新选择器</th></tr></thead>
1211
+ <tbody>${healRows}</tbody></table>` : ""}
1212
+ </body></html>
1213
+ `;
1214
+ }
1215
+ //#endregion
651
1216
  //#region lib/types/browser/tools.js
1217
+ /** 读取 PNG 文件头(IHDR)中的宽高,不解码像素(导出供测试)。 */
1218
+ function pngSize(buf) {
1219
+ if (buf.length < 24 || buf.readUInt32BE(0) !== 2303741511) throw new Error("not a PNG file");
1220
+ return {
1221
+ width: buf.readUInt32BE(16),
1222
+ height: buf.readUInt32BE(20)
1223
+ };
1224
+ }
1225
+ /** 按缩放比例换算 mask 矩形(导出供测试;与页内比对共用同一套坐标语义)。 */
1226
+ function scaleRects(rects, scale) {
1227
+ return rects.map((r) => ({
1228
+ x: Math.round(r.x * scale),
1229
+ y: Math.round(r.y * scale),
1230
+ width: Math.round(r.width * scale),
1231
+ height: Math.round(r.height * scale)
1232
+ }));
1233
+ }
652
1234
  async function openTool(config, args) {
653
1235
  const page = await getPage(config);
654
1236
  await page.goto(String(args.url), {
@@ -661,53 +1243,734 @@ async function openTool(config, args) {
661
1243
  status: "ok"
662
1244
  };
663
1245
  }
1246
+ /**
1247
+ * 快照文本压缩(导出供测试):
1248
+ * - 去掉首尾空白行;
1249
+ * - 连续空白行折叠为 1 行;
1250
+ * - 连续重复的非空行(表格空单元格/占位行产生的噪音)折叠为「首行 + …(重复 N 行)」。
1251
+ * 只折叠完全相同的相邻行,不重排、不删改非重复内容。
1252
+ */
1253
+ function compressText(text) {
1254
+ const lines = text.split("\n");
1255
+ const out = [];
1256
+ let blankRun = 0;
1257
+ for (let i = 0; i < lines.length; i++) {
1258
+ const trimmed = lines[i].trim();
1259
+ if (trimmed === "") {
1260
+ blankRun++;
1261
+ if (blankRun === 1 && out.length > 0) out.push("");
1262
+ continue;
1263
+ }
1264
+ blankRun = 0;
1265
+ let dup = 0;
1266
+ while (i + dup + 1 < lines.length && lines[i + dup + 1].trim() === trimmed) dup++;
1267
+ out.push(lines[i]);
1268
+ if (dup > 0) {
1269
+ out.push(`…(重复 ${dup} 行)`);
1270
+ i += dup;
1271
+ }
1272
+ }
1273
+ while (out.length > 0 && out[out.length - 1] === "") out.pop();
1274
+ return out.join("\n");
1275
+ }
1276
+ /**
1277
+ * 页内 ARIA 树构建(自包含函数,page.evaluate 序列化注入;禁止引用模块级变量)。
1278
+ * 遍历 DOM:结构角色(navigation/main/form/table/heading…)直接打印;
1279
+ * 可交互元素分配 ref=e1..eN 并附稳定 CSS 路径;无语义容器(div/span)穿透。
1280
+ * 输出形如 `- button "登录" [ref=e3]` 的缩进树,供纯文本模型定位操作目标。
1281
+ */
1282
+ function buildAriaTree(payload) {
1283
+ const INTERACTIVE = /* @__PURE__ */ new Set([
1284
+ "link",
1285
+ "button",
1286
+ "textbox",
1287
+ "checkbox",
1288
+ "radio",
1289
+ "combobox",
1290
+ "listbox",
1291
+ "searchbox",
1292
+ "slider",
1293
+ "spinbutton",
1294
+ "switch",
1295
+ "tab",
1296
+ "menuitem",
1297
+ "option",
1298
+ "treeitem"
1299
+ ]);
1300
+ const STRUCTURAL = /* @__PURE__ */ new Set([
1301
+ "navigation",
1302
+ "main",
1303
+ "banner",
1304
+ "contentinfo",
1305
+ "form",
1306
+ "search",
1307
+ "complementary",
1308
+ "region",
1309
+ "dialog",
1310
+ "alert",
1311
+ "table",
1312
+ "list",
1313
+ "heading",
1314
+ "toolbar",
1315
+ "menu",
1316
+ "menubar",
1317
+ "tablist",
1318
+ "tree",
1319
+ "grid"
1320
+ ]);
1321
+ const SKIP_TAGS = /* @__PURE__ */ new Set([
1322
+ "script",
1323
+ "style",
1324
+ "template",
1325
+ "noscript",
1326
+ "svg",
1327
+ "path",
1328
+ "link",
1329
+ "meta",
1330
+ "br"
1331
+ ]);
1332
+ function roleOf(el) {
1333
+ const explicit = el.getAttribute("role");
1334
+ if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
1335
+ switch (el.tagName.toLowerCase()) {
1336
+ case "a": return el.hasAttribute("href") ? "link" : "";
1337
+ case "button": return "button";
1338
+ case "input": {
1339
+ const t = (el.getAttribute("type") ?? "text").toLowerCase();
1340
+ if (t === "checkbox") return "checkbox";
1341
+ if (t === "radio") return "radio";
1342
+ if (t === "range") return "slider";
1343
+ if (t === "number") return "spinbutton";
1344
+ if (t === "search") return "searchbox";
1345
+ if ([
1346
+ "button",
1347
+ "submit",
1348
+ "reset",
1349
+ "image"
1350
+ ].includes(t)) return "button";
1351
+ if (["hidden", "file"].includes(t)) return "";
1352
+ return "textbox";
1353
+ }
1354
+ case "textarea": return "textbox";
1355
+ case "select": return "combobox";
1356
+ case "nav": return "navigation";
1357
+ case "main": return "main";
1358
+ case "header": return "banner";
1359
+ case "footer": return "contentinfo";
1360
+ case "form": return "form";
1361
+ case "h1":
1362
+ case "h2":
1363
+ case "h3":
1364
+ case "h4":
1365
+ case "h5":
1366
+ case "h6": return "heading";
1367
+ case "ul":
1368
+ case "ol": return "list";
1369
+ case "li": return "listitem";
1370
+ case "table": return "table";
1371
+ case "dialog": return "dialog";
1372
+ case "summary": return "button";
1373
+ default: return "";
1374
+ }
1375
+ }
1376
+ function nameOf(el, role) {
1377
+ const aria = el.getAttribute("aria-label");
1378
+ if (aria && aria.trim()) return aria.trim().slice(0, 60);
1379
+ const labelledby = el.getAttribute("aria-labelledby");
1380
+ if (labelledby) {
1381
+ const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
1382
+ if (t) return t.slice(0, 60);
1383
+ }
1384
+ if (el.id) {
1385
+ const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
1386
+ if (t) return t.slice(0, 60);
1387
+ }
1388
+ if ([
1389
+ "textbox",
1390
+ "searchbox",
1391
+ "combobox",
1392
+ "spinbutton"
1393
+ ].includes(role)) {
1394
+ const ph = el.getAttribute("placeholder");
1395
+ if (ph && ph.trim()) return ph.trim().slice(0, 60);
1396
+ }
1397
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
1398
+ if (text) return text.slice(0, 60);
1399
+ const title = el.getAttribute("title");
1400
+ if (title && title.trim()) return title.trim().slice(0, 60);
1401
+ return "";
1402
+ }
1403
+ /** 稳定 CSS 路径:#id → [data-testid] → nth-of-type 链(≤6 层)。 */
1404
+ function cssPath(el) {
1405
+ if (el.id) return `#${CSS.escape(el.id)}`;
1406
+ const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
1407
+ if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
1408
+ const parts = [];
1409
+ let cur = el;
1410
+ while (cur && cur !== document.body && parts.length < 6) {
1411
+ const tag = cur.tagName.toLowerCase();
1412
+ const parent = cur.parentElement;
1413
+ if (!parent) {
1414
+ parts.unshift(tag);
1415
+ break;
1416
+ }
1417
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
1418
+ const idx = sameTag.indexOf(cur) + 1;
1419
+ parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
1420
+ cur = parent;
1421
+ }
1422
+ return parts.join(" > ");
1423
+ }
1424
+ function isVisible(el) {
1425
+ if (el.getAttribute("aria-hidden") === "true") return false;
1426
+ const rect = el.getBoundingClientRect();
1427
+ if (rect.width === 0 && rect.height === 0) return false;
1428
+ const style = getComputedStyle(el);
1429
+ return style.visibility !== "hidden" && style.display !== "none";
1430
+ }
1431
+ const lines = [];
1432
+ const refs = [];
1433
+ let truncated = false;
1434
+ function walk(el, depth) {
1435
+ if (truncated) return;
1436
+ if (lines.length >= 400 || depth > 10) {
1437
+ truncated = true;
1438
+ return;
1439
+ }
1440
+ const role = roleOf(el);
1441
+ if (INTERACTIVE.has(role)) {
1442
+ if (!isVisible(el)) return;
1443
+ const name = nameOf(el, role);
1444
+ const ref = refs.length < payload.maxRefs ? `e${refs.length + 1}` : "";
1445
+ if (ref !== "") refs.push({
1446
+ ref,
1447
+ role,
1448
+ name,
1449
+ selector: cssPath(el)
1450
+ });
1451
+ const expanded = el.getAttribute("aria-expanded");
1452
+ const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
1453
+ lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${ref !== "" ? ` [ref=${ref}]` : ""}${expanded !== null ? ` [expanded=${expanded}]` : ""}${disabled ? " [disabled]" : ""}`);
1454
+ return;
1455
+ }
1456
+ if (STRUCTURAL.has(role)) {
1457
+ if (!isVisible(el)) return;
1458
+ const name = nameOf(el, role);
1459
+ const level = role === "heading" ? ` [level=${el.tagName[1] ?? "1"}]` : "";
1460
+ lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${level}`);
1461
+ for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth + 1);
1462
+ return;
1463
+ }
1464
+ for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth);
1465
+ }
1466
+ if (document.body === null) return {
1467
+ tree: "",
1468
+ refs,
1469
+ truncated: false,
1470
+ scopeMissing: false
1471
+ };
1472
+ const root = payload.scope ? document.querySelector(payload.scope) : document.body;
1473
+ if (root === null) return {
1474
+ tree: "",
1475
+ refs,
1476
+ truncated: false,
1477
+ scopeMissing: true
1478
+ };
1479
+ walk(root, 0);
1480
+ return {
1481
+ tree: lines.join("\n"),
1482
+ refs,
1483
+ truncated,
1484
+ scopeMissing: false
1485
+ };
1486
+ }
1487
+ /**
1488
+ * 页内语义查找:只收集匹配 query 的可交互元素(不构建全量树)。
1489
+ * role/name/cssPath/isVisible 与 buildAriaTree 保持同一套语义(此处内联副本,
1490
+ * evaluate 序列化不允许闭包引用);改动任一处时同步另一处。
1491
+ */
1492
+ function findInteractive(payload) {
1493
+ const INTERACTIVE = /* @__PURE__ */ new Set([
1494
+ "link",
1495
+ "button",
1496
+ "textbox",
1497
+ "checkbox",
1498
+ "radio",
1499
+ "combobox",
1500
+ "listbox",
1501
+ "searchbox",
1502
+ "slider",
1503
+ "spinbutton",
1504
+ "switch",
1505
+ "tab",
1506
+ "menuitem",
1507
+ "option",
1508
+ "treeitem"
1509
+ ]);
1510
+ const STRUCTURAL = /* @__PURE__ */ new Set([
1511
+ "navigation",
1512
+ "main",
1513
+ "banner",
1514
+ "contentinfo",
1515
+ "form",
1516
+ "search",
1517
+ "complementary",
1518
+ "region",
1519
+ "dialog",
1520
+ "alert",
1521
+ "table",
1522
+ "list",
1523
+ "heading",
1524
+ "toolbar",
1525
+ "menu",
1526
+ "menubar",
1527
+ "tablist",
1528
+ "tree",
1529
+ "grid"
1530
+ ]);
1531
+ const SKIP_TAGS = /* @__PURE__ */ new Set([
1532
+ "script",
1533
+ "style",
1534
+ "template",
1535
+ "noscript",
1536
+ "svg",
1537
+ "path",
1538
+ "link",
1539
+ "meta",
1540
+ "br"
1541
+ ]);
1542
+ function roleOf(el) {
1543
+ const explicit = el.getAttribute("role");
1544
+ if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
1545
+ switch (el.tagName.toLowerCase()) {
1546
+ case "a": return el.hasAttribute("href") ? "link" : "";
1547
+ case "button": return "button";
1548
+ case "input": {
1549
+ const t = (el.getAttribute("type") ?? "text").toLowerCase();
1550
+ if (t === "checkbox") return "checkbox";
1551
+ if (t === "radio") return "radio";
1552
+ if (t === "range") return "slider";
1553
+ if (t === "number") return "spinbutton";
1554
+ if (t === "search") return "searchbox";
1555
+ if ([
1556
+ "button",
1557
+ "submit",
1558
+ "reset",
1559
+ "image"
1560
+ ].includes(t)) return "button";
1561
+ if (["hidden", "file"].includes(t)) return "";
1562
+ return "textbox";
1563
+ }
1564
+ case "textarea": return "textbox";
1565
+ case "select": return "combobox";
1566
+ case "summary": return "button";
1567
+ default: return "";
1568
+ }
1569
+ }
1570
+ function nameOf(el, role) {
1571
+ const aria = el.getAttribute("aria-label");
1572
+ if (aria && aria.trim()) return aria.trim().slice(0, 60);
1573
+ const labelledby = el.getAttribute("aria-labelledby");
1574
+ if (labelledby) {
1575
+ const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
1576
+ if (t) return t.slice(0, 60);
1577
+ }
1578
+ if (el.id) {
1579
+ const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
1580
+ if (t) return t.slice(0, 60);
1581
+ }
1582
+ if ([
1583
+ "textbox",
1584
+ "searchbox",
1585
+ "combobox",
1586
+ "spinbutton"
1587
+ ].includes(role)) {
1588
+ const ph = el.getAttribute("placeholder");
1589
+ if (ph && ph.trim()) return ph.trim().slice(0, 60);
1590
+ }
1591
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
1592
+ if (text) return text.slice(0, 60);
1593
+ const title = el.getAttribute("title");
1594
+ if (title && title.trim()) return title.trim().slice(0, 60);
1595
+ return "";
1596
+ }
1597
+ function cssPath(el) {
1598
+ if (el.id) return `#${CSS.escape(el.id)}`;
1599
+ const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
1600
+ if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
1601
+ const parts = [];
1602
+ let cur = el;
1603
+ while (cur && cur !== document.body && parts.length < 6) {
1604
+ const tag = cur.tagName.toLowerCase();
1605
+ const parent = cur.parentElement;
1606
+ if (!parent) {
1607
+ parts.unshift(tag);
1608
+ break;
1609
+ }
1610
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
1611
+ const idx = sameTag.indexOf(cur) + 1;
1612
+ parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
1613
+ cur = parent;
1614
+ }
1615
+ return parts.join(" > ");
1616
+ }
1617
+ function isVisible(el) {
1618
+ if (el.getAttribute("aria-hidden") === "true") return false;
1619
+ const rect = el.getBoundingClientRect();
1620
+ if (rect.width === 0 && rect.height === 0) return false;
1621
+ const style = getComputedStyle(el);
1622
+ return style.visibility !== "hidden" && style.display !== "none";
1623
+ }
1624
+ /** 最近的结构化祖先(区域上下文,帮助区分多命中)。 */
1625
+ function ancestorsOf(el) {
1626
+ const out = [];
1627
+ let cur = el.parentElement;
1628
+ while (cur && cur !== document.body && out.length < 3) {
1629
+ const role = roleOf(cur);
1630
+ if (STRUCTURAL.has(role)) {
1631
+ const name = nameOf(cur, role);
1632
+ out.push(name !== "" ? `${role} "${name.replace(/"/g, "'")}"` : role);
1633
+ }
1634
+ cur = cur.parentElement;
1635
+ }
1636
+ return out;
1637
+ }
1638
+ const q = payload.query.trim().toLowerCase();
1639
+ const matches = [];
1640
+ const candidates = [];
1641
+ let scanned = 0;
1642
+ const root = payload.scope ? document.querySelector(payload.scope) : document.body;
1643
+ if (root === null) return {
1644
+ matches: [],
1645
+ candidates,
1646
+ scanned: 0
1647
+ };
1648
+ const stack = [root];
1649
+ while (stack.length > 0) {
1650
+ const el = stack.pop();
1651
+ const role = roleOf(el);
1652
+ if (INTERACTIVE.has(role)) {
1653
+ if (!isVisible(el)) continue;
1654
+ const name = nameOf(el, role);
1655
+ const idOrTestid = el.id || el.getAttribute("data-testid") || "";
1656
+ let hit = false;
1657
+ if (name !== "") {
1658
+ const n = name.toLowerCase();
1659
+ hit = payload.exact ? n === q : n.includes(q);
1660
+ }
1661
+ if (!hit && idOrTestid !== "" && idOrTestid.toLowerCase() === q) hit = true;
1662
+ scanned += 1;
1663
+ if (name !== "" && candidates.length < 50) candidates.push({
1664
+ role,
1665
+ name
1666
+ });
1667
+ if (hit && matches.length < payload.limit) matches.push({
1668
+ role,
1669
+ name,
1670
+ selector: cssPath(el),
1671
+ ancestors: ancestorsOf(el)
1672
+ });
1673
+ continue;
1674
+ }
1675
+ for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) stack.push(c);
1676
+ }
1677
+ return {
1678
+ matches,
1679
+ candidates,
1680
+ scanned
1681
+ };
1682
+ }
1683
+ /** 最长公共子串长度(小写比较;零依赖,导出供测试)。 */
1684
+ function lcsScore(a, b) {
1685
+ const x = a.toLowerCase();
1686
+ const y = b.toLowerCase();
1687
+ if (x === "" || y === "") return 0;
1688
+ let best = 0;
1689
+ const prev = new Array(y.length + 1).fill(0);
1690
+ for (let i = 1; i <= x.length; i++) {
1691
+ let diag = 0;
1692
+ for (let j = 1; j <= y.length; j++) {
1693
+ const tmp = prev[j];
1694
+ prev[j] = x[i - 1] === y[j - 1] ? diag + 1 : 0;
1695
+ if (prev[j] > best) best = prev[j];
1696
+ diag = tmp;
1697
+ }
1698
+ }
1699
+ return best;
1700
+ }
1701
+ /** 零命中时按相似度挑 top5 候选(导出供测试)。 */
1702
+ function topSuggestions(query, candidates, count = 5) {
1703
+ const min = query.trim().length <= 3 ? 1 : 2;
1704
+ return candidates.map((c) => ({
1705
+ ...c,
1706
+ score: lcsScore(query, c.name)
1707
+ })).filter((c) => c.score >= min).sort((a, b) => b.score - a.score).slice(0, count).map(({ role, name, score }) => ({
1708
+ role,
1709
+ name,
1710
+ score
1711
+ }));
1712
+ }
1713
+ /**
1714
+ * 为命中元素增量分配 ref:沿用既有注册表的编号继续(不与上次快照冲突),
1715
+ * 合并写回;返回带 ref 的命中列表(上限 maxRefs,超出标 truncated)。
1716
+ * 导出供测试(纯逻辑,不依赖浏览器)。
1717
+ */
1718
+ function assignRefs(existing, matches, maxRefs) {
1719
+ let next = 0;
1720
+ for (const key of existing.keys()) {
1721
+ const n = Number(key.replace(/^e/, ""));
1722
+ if (Number.isFinite(n) && n > next) next = n;
1723
+ }
1724
+ const merged = new Map(existing);
1725
+ const withRefs = [];
1726
+ let truncated = false;
1727
+ for (const m of matches) {
1728
+ if (merged.size >= maxRefs) {
1729
+ truncated = true;
1730
+ break;
1731
+ }
1732
+ next += 1;
1733
+ const ref = `e${next}`;
1734
+ merged.set(ref, {
1735
+ role: m.role,
1736
+ name: m.name,
1737
+ selector: m.selector
1738
+ });
1739
+ withRefs.push({
1740
+ ref,
1741
+ ...m
1742
+ });
1743
+ }
1744
+ return {
1745
+ withRefs,
1746
+ merged,
1747
+ truncated
1748
+ };
1749
+ }
1750
+ /**
1751
+ * 语义元素定位:按名称(或 id/data-testid)在可交互元素中查找,只返回命中的
1752
+ * ref + 区域上下文,不输出全量 aria 树 —— 大页面定位单个目标时 token 开销
1753
+ * 远小于 browser_snapshot { mode: "aria" }。返回的 ref 可直接用于
1754
+ * click/type/select/press。零命中不抛错,附最相近候选供二次定位。
1755
+ */
1756
+ async function findTool(config, args) {
1757
+ const query = typeof args.query === "string" ? args.query.trim() : "";
1758
+ if (!query) throw new Error("缺少 query: 要找的元素名称(如「提交」「登录」,或 id/data-testid)");
1759
+ const page = await getPage(config);
1760
+ const maxRefs = Math.min(Math.max(Number(args.maxRefs ?? config.maxRefs ?? 200) || 200, 1), 500);
1761
+ const data = await page.evaluate(findInteractive, {
1762
+ query,
1763
+ exact: args.exact === true,
1764
+ scope: typeof args.scope === "string" && args.scope ? args.scope : "",
1765
+ limit: 20
1766
+ });
1767
+ const { withRefs, merged, truncated } = assignRefs(pageRefsOf(page), data.matches, maxRefs);
1768
+ setPageRefs(page, merged);
1769
+ const suggestions = withRefs.length === 0 ? topSuggestions(query, data.candidates) : [];
1770
+ return {
1771
+ found: withRefs.length,
1772
+ matches: withRefs.slice(0, 10).map(({ ref, role, name, ancestors }) => ({
1773
+ ref,
1774
+ role,
1775
+ name,
1776
+ ancestors
1777
+ })),
1778
+ ...suggestions.length > 0 ? { suggestions } : {},
1779
+ ...truncated ? { truncated } : {},
1780
+ scanned: data.scanned,
1781
+ url: page.url(),
1782
+ title: await page.title()
1783
+ };
1784
+ }
1785
+ /**
1786
+ * 行级 diff:掐掉公共前缀/后缀,返回变化区。导出供测试。
1787
+ * 全等返回 { unchanged: true };否则 changedFrom 为新文本中变化区首行(1 起)。
1788
+ */
1789
+ function diffLines(oldText, newText) {
1790
+ const a = oldText === "" ? [] : oldText.split("\n");
1791
+ const b = newText === "" ? [] : newText.split("\n");
1792
+ let i = 0;
1793
+ while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
1794
+ if (i === a.length && i === b.length) return { unchanged: true };
1795
+ let j = 0;
1796
+ while (j < a.length - i && j < b.length - i && a[a.length - 1 - j] === b[b.length - 1 - j]) j += 1;
1797
+ return {
1798
+ unchanged: false,
1799
+ changedFrom: i + 1,
1800
+ lines: b.slice(i, b.length - j),
1801
+ removed: a.length - i - j
1802
+ };
1803
+ }
664
1804
  async function snapshotTool(config, args) {
665
1805
  const page = await getPage(config);
666
1806
  const maxText = args.maxTextChars ?? config.maxTextChars;
667
- const maxLinks = args.maxLinks ?? config.maxLinks;
668
- const data = await page.evaluate(([maxT, maxL]) => {
669
- const text = document.body ? document.body.innerText : "";
670
- const links = Array.from(document.querySelectorAll("a")).slice(0, maxL).map((a) => ({
671
- text: (a.innerText || a.textContent || "").trim().slice(0, 200),
672
- href: a.href
673
- }));
674
- const inputs = document.querySelectorAll("input, textarea, select").length;
1807
+ const scope = typeof args.scope === "string" && args.scope ? args.scope : "";
1808
+ const since = args.since !== void 0 ? Number(args.since) : void 0;
1809
+ const ariaMode = args.mode === "aria";
1810
+ const identity = {
1811
+ url: page.url(),
1812
+ title: await page.title()
1813
+ };
1814
+ const cache = snapshotCacheOf(page);
1815
+ const canDiff = since !== void 0 && cache !== void 0 && cache.version === since;
1816
+ let content;
1817
+ let extra = {};
1818
+ let walkerTruncated = false;
1819
+ if (ariaMode) {
1820
+ const maxRefs = Math.min(Math.max(Number(args.maxRefs ?? config.maxRefs ?? 200) || 200, 1), 500);
1821
+ const data = await page.evaluate(buildAriaTree, {
1822
+ maxRefs,
1823
+ scope
1824
+ });
1825
+ if (data.scopeMissing) throw new Error(`scope 未命中: 页面中没有匹配 "${scope}" 的元素`);
1826
+ setPageRefs(page, new Map(data.refs.map((r) => [r.ref, {
1827
+ role: r.role,
1828
+ name: r.name,
1829
+ selector: r.selector
1830
+ }])));
1831
+ content = data.tree;
1832
+ walkerTruncated = data.truncated;
1833
+ extra = { refs: data.refs.length };
1834
+ } else {
1835
+ const maxLinks = args.maxLinks ?? config.maxLinks;
1836
+ const data = await page.evaluate((payload) => {
1837
+ const root = payload.sc ? document.querySelector(payload.sc) : document.body;
1838
+ const text = root instanceof HTMLElement ? root.innerText : "";
1839
+ const scopeEl = root ?? document;
1840
+ return {
1841
+ text,
1842
+ links: Array.from(scopeEl.querySelectorAll("a")).slice(0, payload.maxL).map((a) => ({
1843
+ text: (a.innerText || a.textContent || "").trim().slice(0, 200),
1844
+ href: a.href
1845
+ })),
1846
+ inputs: scopeEl.querySelectorAll("input, textarea, select").length,
1847
+ scopeMissing: payload.sc !== "" && root === null
1848
+ };
1849
+ }, {
1850
+ maxL: maxLinks,
1851
+ sc: scope
1852
+ });
1853
+ if (data.scopeMissing) throw new Error(`scope 未命中: 页面中没有匹配 "${scope}" 的元素`);
1854
+ content = compressText(data.text);
1855
+ extra = {
1856
+ links: data.links,
1857
+ inputs: data.inputs
1858
+ };
1859
+ }
1860
+ const key = ariaMode ? "aria" : "text";
1861
+ if (canDiff && cache[key] === content) return {
1862
+ unchanged: true,
1863
+ contentVersion: cache.version,
1864
+ ...scope !== "" ? { scope } : {},
1865
+ ...identity,
1866
+ ...extra
1867
+ };
1868
+ if (canDiff) {
1869
+ const d = diffLines(cache[key], content);
1870
+ setSnapshotCache(page, {
1871
+ version: cache.version + 1,
1872
+ text: ariaMode ? cache.text : content,
1873
+ aria: ariaMode ? content : cache.aria
1874
+ });
1875
+ if (d.unchanged) return {
1876
+ unchanged: true,
1877
+ contentVersion: cache.version + 1,
1878
+ ...scope !== "" ? { scope } : {},
1879
+ ...identity,
1880
+ ...extra
1881
+ };
675
1882
  return {
676
- text: text.slice(0, maxT),
677
- truncated: text.length > maxT,
678
- links,
679
- inputs
1883
+ diff: true,
1884
+ contentVersion: cache.version + 1,
1885
+ changedFrom: d.changedFrom,
1886
+ removed: d.removed,
1887
+ ...scope !== "" ? { scope } : {},
1888
+ [ariaMode ? "tree" : "text"]: (d.lines ?? []).join("\n").slice(0, maxText),
1889
+ truncated: false,
1890
+ ...identity,
1891
+ ...extra
680
1892
  };
681
- }, [maxText, maxLinks]);
1893
+ }
1894
+ setSnapshotCache(page, {
1895
+ version: (cache?.version ?? 0) + 1,
1896
+ text: ariaMode ? cache?.text ?? "" : content,
1897
+ aria: ariaMode ? content : cache?.aria ?? ""
1898
+ });
682
1899
  return {
683
- url: page.url(),
684
- title: await page.title(),
685
- ...data
1900
+ contentVersion: (cache?.version ?? 0) + 1,
1901
+ ...since !== void 0 ? { stale: true } : {},
1902
+ ...scope !== "" ? { scope } : {},
1903
+ [ariaMode ? "tree" : "text"]: content.slice(0, maxText),
1904
+ truncated: ariaMode ? walkerTruncated || content.length > maxText : content.length > maxText,
1905
+ ...identity,
1906
+ ...extra
686
1907
  };
687
1908
  }
1909
+ /**
1910
+ * 解析点击/输入目标为 Locator:selector 直译;ref 走注册表——
1911
+ * 先试快照时的 CSS 路径并校验 role/name 仍一致(防 ref 漂移误点),
1912
+ * 不一致则降级 getByRole(role, {name}),均失败报「ref 已失效」。
1913
+ */
1914
+ async function targetLocator(page, args) {
1915
+ if (args.ref !== void 0 && args.ref !== "") {
1916
+ const ref = String(args.ref);
1917
+ const entry = pageRef(page, ref);
1918
+ if (entry === void 0) throw new Error(`未知 ref: ${ref}(页面导航或重新快照后旧 ref 失效;请先 browser_snapshot { mode: "aria" })`);
1919
+ const byPath = page.locator(entry.selector).first();
1920
+ if (await byPath.count().catch(() => 0) > 0) {
1921
+ if (await byPath.evaluate((el, want) => {
1922
+ const tag = el.tagName.toLowerCase();
1923
+ const type = (el.getAttribute("type") ?? "").toLowerCase();
1924
+ const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
1925
+ const roleOk = want.role === "" || roleAttr === want.role || want.role === "link" && tag === "a" || want.role === "button" && (tag === "button" || tag === "summary" || tag === "input" && [
1926
+ "button",
1927
+ "submit",
1928
+ "reset",
1929
+ "image"
1930
+ ].includes(type)) || ["textbox", "searchbox"].includes(want.role) && (tag === "textarea" || tag === "input" && ![
1931
+ "hidden",
1932
+ "checkbox",
1933
+ "radio"
1934
+ ].includes(type)) || want.role === "combobox" && tag === "select" || want.role === "checkbox" && tag === "input" && type === "checkbox" || want.role === "radio" && tag === "input" && type === "radio";
1935
+ const aria = el.getAttribute("aria-label") ?? "";
1936
+ const ph = el.getAttribute("placeholder") ?? "";
1937
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
1938
+ const nameOk = want.name === "" || aria.includes(want.name) || ph.includes(want.name) || text.includes(want.name);
1939
+ return roleOk && nameOk;
1940
+ }, {
1941
+ role: entry.role,
1942
+ name: entry.name
1943
+ }).catch(() => false)) return byPath;
1944
+ }
1945
+ const byRole = entry.name !== "" ? page.getByRole(entry.role, { name: entry.name }).first() : page.getByRole(entry.role).first();
1946
+ if (await byRole.count().catch(() => 0) > 0) return byRole;
1947
+ throw new Error(`ref ${ref} 已失效(元素不存在或内容已变化);请重新 browser_snapshot { mode: "aria" }`);
1948
+ }
1949
+ if (args.selector === void 0) throw new Error("需要 selector 或 ref 之一");
1950
+ return page.locator(String(args.selector)).first();
1951
+ }
688
1952
  async function clickTool(config, args) {
689
1953
  const page = await getPage(config);
690
- const selector = String(args.selector);
691
- await page.click(selector, { timeout: args.timeoutMs ?? config.timeoutMs });
1954
+ await (await targetLocator(page, args)).click({ timeout: args.timeoutMs ?? config.timeoutMs });
692
1955
  await page.waitForLoadState("domcontentloaded").catch(() => {});
693
1956
  return {
694
- clicked: selector,
1957
+ clicked: String(args.ref ?? args.selector),
695
1958
  ...await pageIdentity(page)
696
1959
  };
697
1960
  }
698
1961
  async function typeTool(config, args) {
699
1962
  const page = await getPage(config);
700
- const selector = String(args.selector);
1963
+ const target = await targetLocator(page, args);
701
1964
  const text = String(args.text ?? "");
702
- if (args.clear === true) await page.fill(selector, text, { timeout: args.timeoutMs ?? config.timeoutMs });
1965
+ if (args.clear === true) await target.fill(text, { timeout: args.timeoutMs ?? config.timeoutMs });
703
1966
  else {
704
- await page.click(selector, { timeout: args.timeoutMs ?? config.timeoutMs });
1967
+ await target.click({ timeout: args.timeoutMs ?? config.timeoutMs });
705
1968
  await page.keyboard.type(text, { delay: args.delayMs ?? 0 });
706
1969
  }
707
1970
  if (args.submit === true) await page.keyboard.press("Enter");
708
1971
  return {
709
1972
  typed: true,
710
- selector,
1973
+ selector: String(args.ref ?? args.selector),
711
1974
  submit: args.submit === true,
712
1975
  ...await pageIdentity(page)
713
1976
  };
@@ -715,18 +1978,68 @@ async function typeTool(config, args) {
715
1978
  async function pressTool(config, args) {
716
1979
  const page = await getPage(config);
717
1980
  const key = String(args.key);
718
- if (args.selector) await page.press(String(args.selector), key, { timeout: args.timeoutMs ?? config.timeoutMs });
1981
+ if (args.selector !== void 0 || args.ref !== void 0) await (await targetLocator(page, args)).press(key, { timeout: args.timeoutMs ?? config.timeoutMs });
719
1982
  else await page.keyboard.press(key);
720
1983
  return {
721
1984
  pressed: key,
722
1985
  ...await pageIdentity(page)
723
1986
  };
724
1987
  }
725
- async function evalTool(config, args) {
1988
+ /** 组件库下拉框的可见选项选择器并集(antd / element-plus / 通用 ARIA listbox)。 */
1989
+ const DROPDOWN_OPTION_SELECTOR = [
1990
+ ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option",
1991
+ ".el-select-dropdown__item",
1992
+ "[role='listbox'] [role='option']"
1993
+ ].join(", ");
1994
+ /**
1995
+ * 下拉选择原语:原生 <select> 走 selectOption;组件库下拉(antd/element/listbox)
1996
+ * 走「点击触发框 → 等待可见选项 → 按文本点击」。找不到选项时抛出错误并附当前可见
1997
+ * 选项清单,agent 可直接据此修正,无需再手写 eval 探查。
1998
+ */
1999
+ async function selectTool(config, args) {
726
2000
  const page = await getPage(config);
727
- const script = String(args.script ?? "");
728
- const wrapped = /[\n;]|^\s*return\b/.test(script) ? `(async () => {\n${script}\n})()` : `(${script})`;
729
- const raw = await page.evaluate(wrapped);
2001
+ const target = await targetLocator(page, args);
2002
+ const label = String(args.ref ?? args.selector);
2003
+ const want = String(args.option ?? "");
2004
+ if (!want) throw new Error("select 需要 option(选项文本)");
2005
+ const exact = args.exact === true;
2006
+ const timeout = args.timeoutMs ?? config.timeoutMs;
2007
+ if (await target.evaluate((el) => el.tagName.toLowerCase()).catch(() => "") === "select") try {
2008
+ await target.selectOption({ label: want }, { timeout });
2009
+ return {
2010
+ selected: want,
2011
+ selector: label,
2012
+ ...await pageIdentity(page)
2013
+ };
2014
+ } catch {
2015
+ const options = await target.locator("option").allTextContents();
2016
+ throw new Error(`原生下拉无选项 "${want}";可用选项: ${options.map((t) => t.trim()).join(" / ")}`);
2017
+ }
2018
+ await target.click({ timeout });
2019
+ const options = page.locator(DROPDOWN_OPTION_SELECTOR);
2020
+ await options.first().waitFor({
2021
+ state: "visible",
2022
+ timeout: Math.min(timeout, 5e3)
2023
+ }).catch(() => {});
2024
+ const texts = (await options.allTextContents()).map((t) => t.trim()).filter(Boolean);
2025
+ if (texts.length === 0) throw new Error(`点击 ${label} 后未出现下拉选项(非原生 select,也不是已知的组件库下拉)`);
2026
+ const index = texts.findIndex((t) => exact ? t === want : t.includes(want));
2027
+ if (index === -1) {
2028
+ await page.keyboard.press("Escape").catch(() => {});
2029
+ throw new Error(`下拉选项中无 "${want}";可见选项: ${texts.join(" / ")}`);
2030
+ }
2031
+ await options.nth(index).click();
2032
+ return {
2033
+ selected: texts[index],
2034
+ selector: label,
2035
+ ...await pageIdentity(page)
2036
+ };
2037
+ }
2038
+ async function evalTool(config, args) {
2039
+ const page = await getPage(config);
2040
+ const script = String(args.script ?? "");
2041
+ const wrapped = /[\n;]|^\s*return\b/.test(script) ? `(async () => {\n${script}\n})()` : `(${script})`;
2042
+ const raw = await page.evaluate(wrapped);
730
2043
  let result;
731
2044
  try {
732
2045
  result = JSON.parse(JSON.stringify(raw ?? null));
@@ -737,12 +2050,29 @@ async function evalTool(config, args) {
737
2050
  }
738
2051
  async function screenshotTool(config, args, exec) {
739
2052
  const page = await getPage(config);
740
- const cwd = workspaceCwd(exec);
2053
+ const cwd = workspaceBaseDir(config, exec);
2054
+ if (typeof args.baseline === "string" && args.baseline !== "") {
2055
+ const baselineName = safeName(args.baseline);
2056
+ const baselineDir = config.baselineDir ?? ".dsh-browser/baselines";
2057
+ const dir = isAbsolute(baselineDir) ? baselineDir : resolve(cwd, baselineDir);
2058
+ mkdirSync(dir, { recursive: true });
2059
+ const filePath = join(dir, `${baselineName}.png`);
2060
+ const buffer = await page.screenshot({ type: "png" });
2061
+ writeFileSync(filePath, buffer);
2062
+ const size = pngSize(buffer);
2063
+ return {
2064
+ path: filePath,
2065
+ bytes: buffer.length,
2066
+ width: size.width,
2067
+ height: size.height,
2068
+ baseline: baselineName
2069
+ };
2070
+ }
741
2071
  const screenshotDir = config.screenshotDir ?? ".dsh-browser/screenshots";
742
2072
  const dir = isAbsolute(screenshotDir) ? screenshotDir : resolve(cwd, screenshotDir);
743
2073
  mkdirSync(dir, { recursive: true });
744
- const safeName = String(args.name ?? `shot-${Date.now()}`).replace(/[^A-Za-z0-9._-]/g, "_");
745
- const fileName = safeName.endsWith(".png") ? safeName : `${safeName}.png`;
2074
+ const safeName$1 = String(args.name ?? `shot-${Date.now()}`).replace(/[^A-Za-z0-9._-]/g, "_");
2075
+ const fileName = safeName$1.endsWith(".png") ? safeName$1 : `${safeName$1}.png`;
746
2076
  const filePath = join(dir, fileName);
747
2077
  const buffer = await page.screenshot({ type: "png" });
748
2078
  writeFileSync(filePath, buffer);
@@ -781,6 +2111,368 @@ async function reloadTool(config) {
781
2111
  await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
782
2112
  return { ...await pageIdentity(page) };
783
2113
  }
2114
+ /** 面板自检探针(由 index.ts 在注册 API 后注入;fetch 本机 /status)。 */
2115
+ let doctorProbe = null;
2116
+ function setDoctorProbe(fn) {
2117
+ doctorProbe = fn;
2118
+ }
2119
+ /** 目录可写性检查(mkdir + 写探针文件 + 删除)。导出供测试。 */
2120
+ function checkWritableDir(dir) {
2121
+ try {
2122
+ mkdirSync(dir, { recursive: true });
2123
+ const probe = join(dir, `.doctor-probe-${Date.now()}`);
2124
+ writeFileSync(probe, "ok");
2125
+ unlinkSync(probe);
2126
+ return {
2127
+ ok: true,
2128
+ detail: dir
2129
+ };
2130
+ } catch (e) {
2131
+ return {
2132
+ ok: false,
2133
+ detail: `${dir} — ${e instanceof Error ? e.message : String(e)}`
2134
+ };
2135
+ }
2136
+ }
2137
+ /** 可执行文件存在性检查(executablePath 显式配置用)。导出供测试。 */
2138
+ function checkExecutable(path) {
2139
+ if (!existsSync(path)) return {
2140
+ name: "executable",
2141
+ ok: false,
2142
+ detail: `executablePath 指向的文件不存在: ${path}`,
2143
+ advice: "修正 cordis.patch.yml 的 browser.executablePath,或清空该字段改用 channel 自动探测"
2144
+ };
2145
+ try {
2146
+ accessSync(path, constants.X_OK);
2147
+ } catch {
2148
+ return {
2149
+ name: "executable",
2150
+ ok: false,
2151
+ detail: `文件存在但不可执行: ${path}`,
2152
+ advice: "chmod +x 或确认路径指向浏览器主程序"
2153
+ };
2154
+ }
2155
+ const st = statSync(path);
2156
+ return {
2157
+ name: "executable",
2158
+ ok: st.isFile(),
2159
+ detail: st.isFile() ? path : `不是普通文件: ${path}`,
2160
+ ...st.isFile() ? {} : { advice: "确认路径指向浏览器可执行文件而非目录" }
2161
+ };
2162
+ }
2163
+ /**
2164
+ * 命名隔离上下文:同一浏览器进程内独立 cookies/storage 的"标签组"。
2165
+ * 页注册进 profile 标签表(所有既有工具直接可用),context 只做归属记录:
2166
+ * list / new(name?, url?) / switch(激活该组的一页,没有则新开) / close(整组关闭)。
2167
+ * 持久模式(userDataDir)浏览器只有单一 context,不支持本工具(用 profiles 替代)。
2168
+ */
2169
+ async function contextTool(config, args) {
2170
+ const action = args.action ?? "list";
2171
+ const state = stateOf();
2172
+ const contexts = namedContextsOf();
2173
+ if (action === "list") return {
2174
+ contexts: Array.from(contexts.entries()).map(([name, c]) => {
2175
+ return {
2176
+ name,
2177
+ pages: Array.from(c.pageIds).filter((id) => state.pages.has(id)).length,
2178
+ active: state.activeId !== null && c.pageIds.has(state.activeId),
2179
+ createdAt: c.createdAt
2180
+ };
2181
+ }),
2182
+ profile: currentProfile
2183
+ };
2184
+ if (action === "new") {
2185
+ const name = String(args.name ?? "").trim() || `ctx-${contexts.size + 1}`;
2186
+ if (contexts.has(name)) throw new Error(`context "${name}" 已存在;如需重建先 browser_context { action: "close", name }`);
2187
+ if (state.browser === null && state.context === null) await launchProfile(currentProfile, config);
2188
+ if (state.browser === null) throw new Error("持久模式(userDataDir)的浏览器只有单一 context,不支持多 context;需要多账号隔离请配置 profiles 多套 userDataDir");
2189
+ const context = await state.browser.newContext();
2190
+ const page = await context.newPage();
2191
+ attachPage(page, profileConfig(config, currentProfile));
2192
+ const id = registerTabPage(state, page);
2193
+ const entry = {
2194
+ context,
2195
+ pageIds: /* @__PURE__ */ new Set([id]),
2196
+ createdAt: Date.now()
2197
+ };
2198
+ contexts.set(name, entry);
2199
+ page.on("close", () => {
2200
+ entry.pageIds.delete(id);
2201
+ });
2202
+ let navigated;
2203
+ if (typeof args.url === "string" && args.url) {
2204
+ await page.goto(args.url, { waitUntil: "domcontentloaded" });
2205
+ navigated = page.url();
2206
+ }
2207
+ return {
2208
+ created: name,
2209
+ ...navigated !== void 0 ? { navigated } : {},
2210
+ ...await pageIdentity(page)
2211
+ };
2212
+ }
2213
+ if (action === "switch") {
2214
+ const name = String(args.name ?? "").trim();
2215
+ if (!name) throw new Error("switch 需要 name(用 list 查看已有 context)");
2216
+ const entry = contexts.get(name);
2217
+ if (entry === void 0) throw new Error(`context "${name}" 不存在;已有: ${Array.from(contexts.keys()).join("、") || "(无)"}`);
2218
+ for (const id of entry.pageIds) {
2219
+ const page = state.pages.get(id);
2220
+ if (page !== void 0) {
2221
+ state.activeId = id;
2222
+ return {
2223
+ switched: name,
2224
+ ...await pageIdentity(page)
2225
+ };
2226
+ }
2227
+ }
2228
+ const page = await entry.context.newPage();
2229
+ attachPage(page, profileConfig(config, currentProfile));
2230
+ const id = registerTabPage(state, page);
2231
+ entry.pageIds.add(id);
2232
+ page.on("close", () => {
2233
+ entry.pageIds.delete(id);
2234
+ });
2235
+ return {
2236
+ switched: name,
2237
+ pages: 1,
2238
+ ...await pageIdentity(page)
2239
+ };
2240
+ }
2241
+ if (action === "close") {
2242
+ const name = String(args.name ?? "").trim();
2243
+ if (!name) throw new Error("close 需要 name");
2244
+ const entry = contexts.get(name);
2245
+ if (entry === void 0) throw new Error(`context "${name}" 不存在`);
2246
+ for (const id of entry.pageIds) {
2247
+ state.pages.delete(id);
2248
+ if (state.activeId === id) state.activeId = Array.from(state.pages.keys()).pop() ?? null;
2249
+ }
2250
+ entry.pageIds.clear();
2251
+ contexts.delete(name);
2252
+ try {
2253
+ await entry.context.close();
2254
+ } catch {}
2255
+ return {
2256
+ closed: name,
2257
+ remainingTabs: state.pages.size
2258
+ };
2259
+ }
2260
+ throw new Error(`未知 action: ${action}(list | new | switch | close)`);
2261
+ }
2262
+ /**
2263
+ * 页内拾取器(evaluate 注入):一次性 click 捕获 —— 点击任意元素时阻止默认行为,
2264
+ * 计算稳定 CSS 路径 + 元素信息存 window.__dshPickResult(面板轮询读取后自清)。
2265
+ * 与 aria walker 的 cssPath 同一套语义(#id → [data-testid] → nth-of-type 链)。
2266
+ */
2267
+ function pickElementInjector() {
2268
+ const w = window;
2269
+ if (w.__dshPicking === true) return;
2270
+ w.__dshPicking = true;
2271
+ function cssPath(el) {
2272
+ if (el.id) return `#${CSS.escape(el.id)}`;
2273
+ const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
2274
+ if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
2275
+ const parts = [];
2276
+ let cur = el;
2277
+ while (cur && cur !== document.body && parts.length < 6) {
2278
+ const tag = cur.tagName.toLowerCase();
2279
+ const parent = cur.parentElement;
2280
+ if (!parent) {
2281
+ parts.unshift(tag);
2282
+ break;
2283
+ }
2284
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
2285
+ const idx = sameTag.indexOf(cur) + 1;
2286
+ parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
2287
+ cur = parent;
2288
+ }
2289
+ return parts.join(" > ");
2290
+ }
2291
+ const handler = (e) => {
2292
+ const el = e.target instanceof Element ? e.target : null;
2293
+ if (el === null) return;
2294
+ e.preventDefault();
2295
+ e.stopPropagation();
2296
+ const aria = el.getAttribute("aria-label");
2297
+ w.__dshPickResult = {
2298
+ selector: cssPath(el),
2299
+ tag: el.tagName.toLowerCase(),
2300
+ id: el.id ?? "",
2301
+ testid: el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? "",
2302
+ role: el.getAttribute("role") ?? "",
2303
+ name: aria && aria.trim() ? aria.trim().slice(0, 60) : (el.innerText || el.textContent || "").trim().slice(0, 60),
2304
+ url: location.href
2305
+ };
2306
+ w.__dshPicking = false;
2307
+ document.removeEventListener("click", handler, true);
2308
+ };
2309
+ document.addEventListener("click", handler, true);
2310
+ }
2311
+ /** 读取并清空拾取结果(ui.ts /pick GET 调用;无结果返回 null)。 */
2312
+ async function takePickResult(page) {
2313
+ try {
2314
+ return await page.evaluate(() => {
2315
+ const w = window;
2316
+ const out = w.__dshPickResult ?? null;
2317
+ w.__dshPickResult = null;
2318
+ w.__dshPicking = false;
2319
+ return out;
2320
+ });
2321
+ } catch {
2322
+ return null;
2323
+ }
2324
+ }
2325
+ /**
2326
+ * 操作预告高亮:agent 即将 click/type/select/press 的元素在页面上琥珀色描边
2327
+ * ~1.5 秒。异步不阻塞(fire-and-forget,任何失败静默),不影响操作时序。
2328
+ */
2329
+ function previewHighlight(page, selector, label) {
2330
+ if (page === null || selector === "") return;
2331
+ page.evaluate((arg) => {
2332
+ const el = document.querySelector(arg.selector);
2333
+ if (el === null) return;
2334
+ const rect = el.getBoundingClientRect();
2335
+ const box = document.createElement("div");
2336
+ box.setAttribute("data-dsh-preview", "");
2337
+ Object.assign(box.style, {
2338
+ position: "fixed",
2339
+ left: `${rect.left - 2}px`,
2340
+ top: `${rect.top - 2}px`,
2341
+ width: `${rect.width + 4}px`,
2342
+ height: `${rect.height + 4}px`,
2343
+ border: "2px solid #f59e0b",
2344
+ borderRadius: "4px",
2345
+ background: "rgba(245, 158, 11, 0.08)",
2346
+ pointerEvents: "none",
2347
+ zIndex: "2147483646",
2348
+ boxSizing: "border-box",
2349
+ transition: "opacity 0.3s"
2350
+ });
2351
+ const tip = document.createElement("div");
2352
+ Object.assign(tip.style, {
2353
+ position: "absolute",
2354
+ left: "0",
2355
+ top: "-20px",
2356
+ font: "12px/1.4 -apple-system,'PingFang SC',sans-serif",
2357
+ color: "#fff",
2358
+ background: "#f59e0b",
2359
+ padding: "1px 6px",
2360
+ borderRadius: "4px",
2361
+ whiteSpace: "nowrap"
2362
+ });
2363
+ tip.textContent = `agent: ${arg.label}`;
2364
+ box.appendChild(tip);
2365
+ document.documentElement.appendChild(box);
2366
+ setTimeout(() => {
2367
+ box.style.opacity = "0";
2368
+ setTimeout(() => box.remove(), 400);
2369
+ }, 1200);
2370
+ }, {
2371
+ selector,
2372
+ label
2373
+ }).catch(() => {});
2374
+ }
2375
+ /** 一次性健康诊断:不启动浏览器,只做静态/本地检查。 */
2376
+ async function doctorTool(config, _args, exec) {
2377
+ const checks = [];
2378
+ if (config.executablePath) checks.push(checkExecutable(config.executablePath));
2379
+ else {
2380
+ const channel = config.channel ?? "auto";
2381
+ checks.push({
2382
+ name: "channel",
2383
+ ok: true,
2384
+ detail: channel === "auto" || channel === "" ? "auto:按平台序探测已装浏览器(chrome → msedge → chromium;Windows 为 msedge 优先),启动失败会提示安装" : `channel = ${channel}(由 Playwright 在首次启动时解析)`
2385
+ });
2386
+ try {
2387
+ const bundled = (await loadPlaywright()).chromium.executablePath();
2388
+ checks.push({
2389
+ name: "bundled-chromium",
2390
+ ok: existsSync(bundled),
2391
+ detail: existsSync(bundled) ? bundled : `playwright 捆绑 chromium 未安装(${bundled})`,
2392
+ ...existsSync(bundled) ? {} : { advice: "可选:npx playwright install chromium 安装兜底浏览器,或依赖系统已装的 Edge/Chrome" }
2393
+ });
2394
+ } catch {
2395
+ checks.push({
2396
+ name: "playwright-core",
2397
+ ok: false,
2398
+ detail: "playwright-core 不可用",
2399
+ advice: "在 profile 中安装 playwright-core(dsh plugin --profile web add playwright-core)"
2400
+ });
2401
+ }
2402
+ }
2403
+ if (config.userDataDir) {
2404
+ const w = checkWritableDir(config.userDataDir);
2405
+ checks.push({
2406
+ name: "userDataDir",
2407
+ ok: w.ok,
2408
+ detail: w.ok ? `${config.userDataDir}(登录态将持久化)` : w.detail,
2409
+ ...!w.ok ? { advice: "确认目录存在且当前用户可写" } : {}
2410
+ });
2411
+ } else checks.push({
2412
+ name: "userDataDir",
2413
+ ok: true,
2414
+ detail: "未配置(登录态随会话,DSH 重启后失效)"
2415
+ });
2416
+ const baseDir = workspaceBaseDir(config, exec);
2417
+ const baseWritable = checkWritableDir(baseDir);
2418
+ checks.push({
2419
+ name: "workspaceDir",
2420
+ ok: baseWritable.ok,
2421
+ detail: baseWritable.ok ? `${baseDir}(来源: ${config.workspaceDir ? "config.workspaceDir" : "会话 cwd 探测"})` : baseWritable.detail,
2422
+ ...!baseWritable.ok ? { advice: "设置 config.workspaceDir 钉死基准目录,或修正会话工作区权限" } : {}
2423
+ });
2424
+ for (const [name, dir] of [
2425
+ ["screenshotDir", config.screenshotDir ?? ".dsh-browser/screenshots"],
2426
+ ["downloadDir", config.downloadDir ?? ".dsh-browser/downloads"],
2427
+ ["baselineDir", config.baselineDir ?? ".dsh-browser/baselines"]
2428
+ ]) {
2429
+ const abs = isAbsolute(dir) ? dir : resolve(baseDir, dir);
2430
+ const w = checkWritableDir(abs);
2431
+ checks.push({
2432
+ name,
2433
+ ok: w.ok,
2434
+ detail: w.ok ? abs : w.detail,
2435
+ ...!w.ok ? { advice: `检查 ${abs} 的创建/写权限(父目录需可写)` } : {}
2436
+ });
2437
+ }
2438
+ const state = stateOf();
2439
+ checks.push({
2440
+ name: "session",
2441
+ ok: true,
2442
+ detail: `当前 profile "${currentProfile}"${state.browser !== null ? " 已启动浏览器" : " 浏览器未启动(首次调用工具时自动启动)"} · ${state.pages.size} 个标签 · 共 ${profileStates.size} 个 profile`
2443
+ });
2444
+ const basePath = config.basePath ?? "/browser";
2445
+ if (doctorProbe !== null) try {
2446
+ const r = await doctorProbe(basePath);
2447
+ checks.push({
2448
+ name: "panel",
2449
+ ok: r.ok,
2450
+ detail: r.detail,
2451
+ ...r.advice ? { advice: r.advice } : {}
2452
+ });
2453
+ } catch (e) {
2454
+ checks.push({
2455
+ name: "panel",
2456
+ ok: false,
2457
+ detail: `探针执行失败: ${e instanceof Error ? e.message : String(e)}`,
2458
+ advice: `在浏览器打开 http://<DSH地址>${basePath}/status 手动验证`
2459
+ });
2460
+ }
2461
+ else checks.push({
2462
+ name: "panel",
2463
+ ok: true,
2464
+ detail: `本进程无 webServer(工具半),面板 API 基路径 ${basePath}`
2465
+ });
2466
+ const failed = checks.filter((c) => !c.ok).length;
2467
+ return {
2468
+ checks,
2469
+ summary: {
2470
+ ok: failed === 0,
2471
+ passed: checks.length - failed,
2472
+ failed
2473
+ }
2474
+ };
2475
+ }
784
2476
  async function statusTool() {
785
2477
  const page = activePage();
786
2478
  const state = stateOf();
@@ -885,12 +2577,22 @@ function resolveTabId(args) {
885
2577
  throw new Error("no tab id/index given and no active tab");
886
2578
  }
887
2579
  async function downloadTool(config, args, exec) {
888
- const recentDownloads = downloadsOf(currentProfile);
2580
+ let recentDownloads = downloadsOf(currentProfile);
2581
+ if (args.wait === true) {
2582
+ const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 1e4) || 1e4, 1e3), 6e4);
2583
+ const baseline = recentDownloads.length;
2584
+ const deadline = Date.now() + timeoutMs;
2585
+ while (recentDownloads.length <= baseline && Date.now() < deadline) {
2586
+ await new Promise((r) => setTimeout(r, 300));
2587
+ recentDownloads = downloadsOf(currentProfile);
2588
+ }
2589
+ if (recentDownloads.length <= baseline) throw new Error(`等待下载超时(${timeoutMs}ms):未检测到新下载,请先在页面触发下载动作`);
2590
+ }
889
2591
  if (recentDownloads.length === 0) throw new Error("no recent downloads. Trigger a download in the page first (e.g. browser_click on a download link).");
890
2592
  const index = args.index !== void 0 ? Number(args.index) : recentDownloads.length - 1;
891
2593
  const entry = recentDownloads[index];
892
2594
  if (entry === void 0) throw new Error(`no download at index ${index}`);
893
- const cwd = workspaceCwd(exec);
2595
+ const cwd = workspaceBaseDir(config, exec);
894
2596
  const downloadDir = config.downloadDir ?? ".dsh-browser/downloads";
895
2597
  const dir = isAbsolute(downloadDir) ? downloadDir : resolve(cwd, downloadDir);
896
2598
  mkdirSync(dir, { recursive: true });
@@ -907,7 +2609,7 @@ async function downloadTool(config, args, exec) {
907
2609
  }
908
2610
  async function uploadTool(config, args, exec) {
909
2611
  const page = await getPage(config);
910
- const cwd = workspaceCwd(exec);
2612
+ const cwd = workspaceBaseDir(config, exec);
911
2613
  const filePath = isAbsolute(args.path) ? String(args.path) : resolve(cwd, String(args.path));
912
2614
  let stat;
913
2615
  try {
@@ -1044,7 +2746,21 @@ async function profileTool(config, args) {
1044
2746
  async function elementsTool(config, args) {
1045
2747
  const page = await getPage(config);
1046
2748
  const max = Math.min(Math.max(Number(args.max ?? 60) || 60, 1), 200);
1047
- const elements = await page.evaluate((maxN) => {
2749
+ const data = await page.evaluate(([maxN, optionSelector]) => {
2750
+ /** 提取元素的关联 label:aria-label → label[for] → form-item 标签(antd/element)。 */
2751
+ function labelOf(el) {
2752
+ const aria = el.getAttribute("aria-label");
2753
+ if (aria) return aria.trim();
2754
+ if (el.id) {
2755
+ const text = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
2756
+ if (text) return text;
2757
+ }
2758
+ const antLabel = el.closest(".ant-form-item")?.querySelector(".ant-form-item-label")?.textContent?.trim();
2759
+ if (antLabel) return antLabel.replace(/[*::]\s*$/, "");
2760
+ const elLabel = el.closest(".el-form-item")?.querySelector(".el-form-item__label")?.textContent?.trim();
2761
+ if (elLabel) return elLabel.replace(/[*::]\s*$/, "");
2762
+ return "";
2763
+ }
1048
2764
  const out = [];
1049
2765
  const seen = /* @__PURE__ */ new Set();
1050
2766
  const nodes = Array.from(document.querySelectorAll("input, textarea, select, button, a[href]"));
@@ -1057,6 +2773,7 @@ async function elementsTool(config, args) {
1057
2773
  type: el.type ?? "",
1058
2774
  name: el.name ?? "",
1059
2775
  id: el.id ?? "",
2776
+ label: labelOf(el),
1060
2777
  placeholder: el.placeholder ?? "",
1061
2778
  ariaLabel: el.getAttribute("aria-label") ?? "",
1062
2779
  text: (el.innerText || el.textContent || "").trim().slice(0, 80),
@@ -1068,11 +2785,15 @@ async function elementsTool(config, args) {
1068
2785
  seen.add(key);
1069
2786
  out.push(info);
1070
2787
  }
1071
- return out;
1072
- }, max);
2788
+ return {
2789
+ elements: out,
2790
+ dropdownOptions: Array.from(document.querySelectorAll(optionSelector)).map((el) => (el.textContent ?? "").trim()).filter(Boolean).slice(0, 50)
2791
+ };
2792
+ }, [max, DROPDOWN_OPTION_SELECTOR]);
1073
2793
  return {
1074
- count: elements.length,
1075
- elements
2794
+ count: data.elements.length,
2795
+ elements: data.elements,
2796
+ dropdownOptions: data.dropdownOptions
1076
2797
  };
1077
2798
  }
1078
2799
  async function closeTool() {
@@ -1081,6 +2802,21 @@ async function closeTool() {
1081
2802
  }
1082
2803
  /** 单次检查:返回 { ok, detail }。不抛错,失败时 detail 给出原因。 */
1083
2804
  async function checkOnce(page, cond) {
2805
+ if (cond.ref !== void 0) {
2806
+ const state = String(cond.state ?? "visible");
2807
+ try {
2808
+ await (await targetLocator(page, { ref: cond.ref })).waitFor({
2809
+ state,
2810
+ timeout: 120
2811
+ });
2812
+ return { ok: true };
2813
+ } catch {
2814
+ return {
2815
+ ok: false,
2816
+ detail: `ref ${String(cond.ref)} 未达到 ${state}`
2817
+ };
2818
+ }
2819
+ }
1084
2820
  if (cond.selector !== void 0) {
1085
2821
  const locator = page.locator(String(cond.selector));
1086
2822
  const state = String(cond.state ?? "visible");
@@ -1210,9 +2946,133 @@ async function waitForLoginTool(config, args) {
1210
2946
  ...await pageIdentity(page)
1211
2947
  };
1212
2948
  }
1213
- /** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。 */
2949
+ /**
2950
+ * 人工接管等待(通用版 wait_for_login):agent 把控制权交还给用户(验证码 /
2951
+ * 二次确认 / 人工复核等)。instruction 会显示在实时画面面板;完成条件三选一:
2952
+ * successSelector(元素出现)/ successUrl(URL 正则)/ 两者都不给则以
2953
+ * URL 变化或页面内容签名变化视为完成。轮询间隔 2 秒,超时返回 completed:false
2954
+ * (不抛错)。导出 runPauseOnPage 供测试注入 fake page。
2955
+ */
2956
+ async function runPauseOnPage(page, args) {
2957
+ const instruction = typeof args.instruction === "string" ? args.instruction.trim() : "";
2958
+ if (!instruction) throw new Error("缺少 instruction: 请说明需要人工完成什么操作(如「请输入短信验证码」)");
2959
+ const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 3e5) || 3e5, 1e3), 6e5);
2960
+ let startUrl = "";
2961
+ try {
2962
+ startUrl = page.url();
2963
+ } catch {}
2964
+ const startedAt = Date.now();
2965
+ const pageSignature = async () => {
2966
+ try {
2967
+ return await page.evaluate(() => {
2968
+ const body = document.body;
2969
+ return `${location.href}|${body ? body.innerText.length : 0}|${document.querySelectorAll("input,button,a").length}`;
2970
+ });
2971
+ } catch {
2972
+ return page.url();
2973
+ }
2974
+ };
2975
+ const startSig = args.successSelector || args.successUrl ? "" : await pageSignature();
2976
+ let completed = false;
2977
+ let detail = `等待人工操作超时(${timeoutMs}ms)`;
2978
+ setPauseInstruction(instruction);
2979
+ try {
2980
+ const deadline = startedAt + timeoutMs;
2981
+ while (Date.now() < deadline) {
2982
+ try {
2983
+ if (args.successSelector) completed = await page.locator(String(args.successSelector)).first().isVisible();
2984
+ else if (args.successUrl) completed = new RegExp(String(args.successUrl)).test(page.url());
2985
+ else completed = page.url() !== startUrl || await pageSignature() !== startSig;
2986
+ } catch (e) {
2987
+ completed = false;
2988
+ detail = `检查完成条件出错: ${e instanceof Error ? e.message : String(e)}`;
2989
+ }
2990
+ if (completed) {
2991
+ detail = "人工操作完成";
2992
+ break;
2993
+ }
2994
+ await page.waitForTimeout(2e3);
2995
+ }
2996
+ } finally {
2997
+ clearPauseInstruction();
2998
+ }
2999
+ return {
3000
+ completed,
3001
+ waitedMs: Date.now() - startedAt,
3002
+ detail
3003
+ };
3004
+ }
3005
+ /** 人工接管等待工具入口(缺 instruction 在启动浏览器前即校验,失败更快)。 */
3006
+ async function pauseForHumanTool(config, args) {
3007
+ const instruction = typeof args.instruction === "string" ? args.instruction.trim() : "";
3008
+ if (!instruction) throw new Error("缺少 instruction: 请说明需要人工完成什么操作(如「请输入短信验证码」)");
3009
+ const page = await getPage(config);
3010
+ const result = await runPauseOnPage(page, args);
3011
+ return {
3012
+ completed: result.completed,
3013
+ status: result.completed ? "completed" : "timedOut",
3014
+ instruction,
3015
+ detail: result.detail,
3016
+ waitedMs: result.waitedMs,
3017
+ ...await pageIdentity(page)
3018
+ };
3019
+ }
3020
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3021
+ /**
3022
+ * 敏感操作审批:agent 在执行提交/删除/支付类动作前调用,把审批请求推到实时
3023
+ * 画面面板,挂起等待用户点「允许/拒绝」。超时视为拒绝(fail-closed)。
3024
+ * 用户允许时返回 { decision: "allowed", ... };拒绝/超时**抛错**(消息含原因),
3025
+ * 使 live 调用与 replay/batch 步骤统一按失败处理,agent 不应继续敏感动作。
3026
+ * 决策通过 session.resolveConfirm(面板 POST)写入。
3027
+ */
3028
+ async function confirmTool(config, args) {
3029
+ const reason = typeof args.reason === "string" ? args.reason.trim() : "";
3030
+ if (!reason) throw new Error("缺少 reason: 请说明要执行什么敏感操作、为什么需要审批(如「提交白名单表单,会产生线上数据变更」)");
3031
+ const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? config.confirmTimeoutMs ?? 6e4) || 6e4, 1e3), 6e5);
3032
+ const id = `cfm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
3033
+ setPendingConfirm({
3034
+ id,
3035
+ reason,
3036
+ tool: typeof args.tool === "string" && args.tool ? args.tool : void 0,
3037
+ args: args.args && typeof args.args === "object" ? args.args : void 0,
3038
+ startedAt: Date.now()
3039
+ });
3040
+ const startedAt = Date.now();
3041
+ let decision = "timeout";
3042
+ let detail = `等待审批超时(${timeoutMs}ms),按拒绝处理`;
3043
+ try {
3044
+ const deadline = startedAt + timeoutMs;
3045
+ while (Date.now() < deadline) {
3046
+ await sleep(500);
3047
+ const pending = getPendingConfirm();
3048
+ if (pending === null || pending.id !== id) {
3049
+ decision = "denied";
3050
+ detail = "审批请求已被新的审批取代,按拒绝处理";
3051
+ break;
3052
+ }
3053
+ if (pending.decision !== void 0) {
3054
+ decision = pending.decision;
3055
+ detail = decision === "allowed" ? "用户允许执行" : "用户拒绝执行";
3056
+ break;
3057
+ }
3058
+ }
3059
+ } finally {
3060
+ const pending = getPendingConfirm();
3061
+ if (pending !== null && pending.id === id) clearPendingConfirm();
3062
+ }
3063
+ if (decision !== "allowed") throw new Error(`敏感操作被拒绝: ${reason} — ${detail}`);
3064
+ return {
3065
+ decision,
3066
+ id,
3067
+ reason,
3068
+ waitedMs: Date.now() - startedAt,
3069
+ detail
3070
+ };
3071
+ }
3072
+ /** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。visual 断言走像素比对。 */
1214
3073
  async function assertTool(config, args, exec) {
1215
3074
  const page = await getPage(config);
3075
+ if (args.visual !== void 0 && typeof args.visual === "object") return visualAssert(config, args.visual, page, exec);
1216
3076
  const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 5e3) || 5e3, 100), 6e4);
1217
3077
  const intervalMs = Math.max(Number(args.intervalMs ?? 300) || 300, 50);
1218
3078
  const result = await pollCondition(page, args.condition, timeoutMs, intervalMs);
@@ -1220,7 +3080,7 @@ async function assertTool(config, args, exec) {
1220
3080
  passed: true,
1221
3081
  ...await pageIdentity(page)
1222
3082
  };
1223
- const cwd = workspaceCwd(exec);
3083
+ const cwd = workspaceBaseDir(config, exec);
1224
3084
  const screenshotDir = config.screenshotDir ?? ".dsh-browser/screenshots";
1225
3085
  const dir = isAbsolute(screenshotDir) ? screenshotDir : resolve(cwd, screenshotDir);
1226
3086
  mkdirSync(dir, { recursive: true });
@@ -1236,6 +3096,129 @@ async function assertTool(config, args, exec) {
1236
3096
  } catch {}
1237
3097
  throw new Error(`断言失败: ${result.detail ?? "条件未满足"}; 失败截图: ${filePath}`);
1238
3098
  }
3099
+ /**
3100
+ * 页内像素比对(自包含函数,page.evaluate 序列化注入):
3101
+ * 两张图按传入的 w/h 重绘,mask 区域填白后逐像素比较
3102
+ * (加权亮度差 / 255 > threshold 计为差异像素),差异像素在 diff 图上标红。
3103
+ * 注意:公式与测试里的 luminanceDelta 语义保持一致(0.2126/0.7152/0.0722)。
3104
+ */
3105
+ async function compareImagesInPage(payload) {
3106
+ const load = (src) => new Promise((res, rej) => {
3107
+ const img = new Image();
3108
+ img.onload = () => res(img);
3109
+ img.onerror = () => rej(/* @__PURE__ */ new Error("image decode failed"));
3110
+ img.src = src;
3111
+ });
3112
+ const [a, b] = await Promise.all([load(payload.baseline), load(payload.current)]);
3113
+ const { width: w, height: h } = payload;
3114
+ const draw = (img) => {
3115
+ const c = document.createElement("canvas");
3116
+ c.width = w;
3117
+ c.height = h;
3118
+ const ctx = c.getContext("2d");
3119
+ ctx.drawImage(img, 0, 0, w, h);
3120
+ return ctx;
3121
+ };
3122
+ const ctxA = draw(a);
3123
+ const ctxB = draw(b);
3124
+ for (const r of payload.maskRects) for (const ctx of [ctxA, ctxB]) {
3125
+ ctx.fillStyle = "#ffffff";
3126
+ ctx.fillRect(r.x, r.y, r.width, r.height);
3127
+ }
3128
+ const da = ctxA.getImageData(0, 0, w, h).data;
3129
+ const db = ctxB.getImageData(0, 0, w, h).data;
3130
+ const diffCanvas = document.createElement("canvas");
3131
+ diffCanvas.width = w;
3132
+ diffCanvas.height = h;
3133
+ const dctx = diffCanvas.getContext("2d");
3134
+ dctx.drawImage(b, 0, 0, w, h);
3135
+ const dd = dctx.getImageData(0, 0, w, h);
3136
+ let diff = 0;
3137
+ for (let i = 0; i < da.length; i += 4) if ((.2126 * Math.abs(da[i] - db[i]) + .7152 * Math.abs(da[i + 1] - db[i + 1]) + .0722 * Math.abs(da[i + 2] - db[i + 2])) / 255 > payload.threshold) {
3138
+ diff++;
3139
+ dd.data[i] = 255;
3140
+ dd.data[i + 1] = 0;
3141
+ dd.data[i + 2] = 0;
3142
+ dd.data[i + 3] = 255;
3143
+ }
3144
+ const total = w * h;
3145
+ return {
3146
+ diffPixels: diff,
3147
+ totalPixels: total,
3148
+ diffRatio: total > 0 ? diff / total : 0,
3149
+ diffImage: diff > 0 ? diffCanvas.toDataURL("image/png") : null
3150
+ };
3151
+ }
3152
+ /**
3153
+ * 视觉回归断言:与基线截图比对。基线缺失时报错提示先建基线;
3154
+ * diffRatio 超过 maxDiffRatio 时落盘当前截图与 diff 图并抛错。
3155
+ */
3156
+ async function visualAssert(config, visual, page, exec) {
3157
+ const baselineName = safeName(String(visual.baseline ?? ""));
3158
+ if (baselineName === "") throw new Error("visual 断言需要 baseline(基线名)");
3159
+ const cwd = workspaceBaseDir(config, exec);
3160
+ const baselineDir = config.baselineDir ?? ".dsh-browser/baselines";
3161
+ const dir = isAbsolute(baselineDir) ? baselineDir : resolve(cwd, baselineDir);
3162
+ const baselinePath = join(dir, `${baselineName}.png`);
3163
+ if (!existsSync(baselinePath)) throw new Error(`视觉基线不存在: ${baselinePath};先用 browser_screenshot { baseline: "${baselineName}" } 建立基线`);
3164
+ const baselineBuf = readFileSync(baselinePath);
3165
+ const currentBuf = await page.screenshot({ type: "png" });
3166
+ const baseSize = pngSize(baselineBuf);
3167
+ const curSize = pngSize(currentBuf);
3168
+ const screenshotDir = config.screenshotDir ?? ".dsh-browser/screenshots";
3169
+ const shotDir = isAbsolute(screenshotDir) ? screenshotDir : resolve(cwd, screenshotDir);
3170
+ const saveEvidence = (suffix, data) => {
3171
+ mkdirSync(shotDir, { recursive: true });
3172
+ const p = join(shotDir, `visual-${safeName(baselineName)}-${suffix}-${Date.now()}.png`);
3173
+ writeFileSync(p, typeof data === "string" ? Buffer.from(data.split(",")[1] ?? "", "base64") : data);
3174
+ noteScreenshotDir(shotDir);
3175
+ return p;
3176
+ };
3177
+ if (baseSize.width !== curSize.width || baseSize.height !== curSize.height) {
3178
+ const currentPath = saveEvidence("current", currentBuf);
3179
+ throw new Error(`视觉回归失败: 尺寸不一致(基线 ${baseSize.width}x${baseSize.height},当前 ${curSize.width}x${curSize.height}); 当前截图: ${currentPath}`);
3180
+ }
3181
+ const maskSelectors = Array.isArray(visual.mask) ? visual.mask.map(String) : [];
3182
+ const rawRects = [];
3183
+ for (const sel of maskSelectors) {
3184
+ const rects = await page.locator(sel).evaluateAll((els) => els.map((el) => {
3185
+ const r = el.getBoundingClientRect();
3186
+ return {
3187
+ x: r.x,
3188
+ y: r.y,
3189
+ width: r.width,
3190
+ height: r.height
3191
+ };
3192
+ })).catch(() => []);
3193
+ rawRects.push(...rects);
3194
+ }
3195
+ const scale = Math.min(1, 1e3 / Math.max(curSize.width, curSize.height));
3196
+ const w = Math.max(1, Math.round(curSize.width * scale));
3197
+ const h = Math.max(1, Math.round(curSize.height * scale));
3198
+ const result = await page.evaluate(compareImagesInPage, {
3199
+ baseline: `data:image/png;base64,${baselineBuf.toString("base64")}`,
3200
+ current: `data:image/png;base64,${currentBuf.toString("base64")}`,
3201
+ threshold: Math.min(Math.max(Number(visual.threshold ?? .1) || .1, 0), 1),
3202
+ maskRects: scaleRects(rawRects, scale),
3203
+ width: w,
3204
+ height: h
3205
+ });
3206
+ const maxDiffRatio = Math.min(Math.max(Number(visual.maxDiffRatio ?? .005) || 0, 0), 1);
3207
+ if (result.diffRatio > maxDiffRatio) {
3208
+ const currentPath = saveEvidence("current", currentBuf);
3209
+ const diffPath = result.diffImage !== null ? saveEvidence("diff", result.diffImage) : "(无 diff 图)";
3210
+ throw new Error(`视觉回归失败: diffRatio ${(result.diffRatio * 100).toFixed(3)}%(${result.diffPixels}/${result.totalPixels} 像素)> 阈值 ${(maxDiffRatio * 100).toFixed(3)}%; 当前截图: ${currentPath}; diff 图: ${diffPath}`);
3211
+ }
3212
+ return {
3213
+ passed: true,
3214
+ visual: {
3215
+ baseline: baselineName,
3216
+ diffRatio: result.diffRatio,
3217
+ diffPixels: result.diffPixels
3218
+ },
3219
+ ...await pageIdentity(page)
3220
+ };
3221
+ }
1239
3222
  function matchNetwork(entry, f) {
1240
3223
  if (f.urlSubstr && !entry.url.includes(String(f.urlSubstr))) return false;
1241
3224
  if (f.method && entry.method.toUpperCase() !== String(f.method).toUpperCase()) return false;
@@ -1251,13 +3234,14 @@ async function networkTool(args) {
1251
3234
  clearNetwork(currentProfile);
1252
3235
  return {
1253
3236
  cleared: true,
1254
- entries: 0
3237
+ entries: []
1255
3238
  };
1256
3239
  }
1257
3240
  if (action === "wait") {
1258
3241
  if (!args.url) throw new Error("wait 需要 url(正则字符串)");
1259
3242
  const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 1e4) || 1e4, 1e3), 6e4);
1260
- const deadline = Date.now() + timeoutMs;
3243
+ const startedAt = Date.now();
3244
+ const deadline = startedAt + timeoutMs;
1261
3245
  while (Date.now() < deadline) {
1262
3246
  const hit = [...entries].reverse().find((e) => {
1263
3247
  if (!new RegExp(String(args.url)).test(e.url)) return false;
@@ -1266,18 +3250,138 @@ async function networkTool(args) {
1266
3250
  });
1267
3251
  if (hit !== void 0) return {
1268
3252
  matched: hit,
1269
- waitMs: Date.now() + timeoutMs - deadline - (Date.now() + timeoutMs - deadline) + 0
3253
+ waitMs: Date.now() - startedAt
1270
3254
  };
1271
3255
  await new Promise((r) => setTimeout(r, 300));
1272
3256
  }
1273
- throw new Error(`等待接口超时(${timeoutMs}ms): ${args.url}`);
3257
+ const recent = entries.slice(-5).map((e) => `${e.method} ${e.url} → ${e.failed ? "FAILED" : e.status ?? "?"}`);
3258
+ throw new Error(`等待接口超时(${timeoutMs}ms): ${args.url}\n` + (recent.length > 0 ? `最近已见请求:\n ${recent.join("\n ")}` : "期间无任何接口请求记录"));
1274
3259
  }
1275
3260
  const filter = action === "failed" ? { failedOnly: true } : args;
1276
3261
  const list = [...entries].reverse().filter((e) => matchNetwork(e, filter)).slice(0, Math.min(Math.max(Number(args.limit ?? 50) || 50, 1), 200));
3262
+ if (action === "stats") {
3263
+ const byHost = /* @__PURE__ */ new Map();
3264
+ for (const e of entries) {
3265
+ let host;
3266
+ try {
3267
+ host = new URL(e.url).host;
3268
+ } catch {
3269
+ host = "(invalid-url)";
3270
+ }
3271
+ let bucket = byHost.get(host);
3272
+ if (bucket === void 0) {
3273
+ bucket = {
3274
+ host,
3275
+ total: 0,
3276
+ failed: 0,
3277
+ totalMs: 0,
3278
+ withDuration: 0,
3279
+ byStatus: {}
3280
+ };
3281
+ byHost.set(host, bucket);
3282
+ }
3283
+ bucket.total++;
3284
+ if (e.failed === true || e.status !== void 0 && e.status >= 400) bucket.failed++;
3285
+ if (e.durationMs !== void 0) {
3286
+ bucket.totalMs += e.durationMs;
3287
+ bucket.withDuration++;
3288
+ }
3289
+ if (e.status !== void 0) {
3290
+ const key = String(e.status);
3291
+ bucket.byStatus[key] = (bucket.byStatus[key] ?? 0) + 1;
3292
+ }
3293
+ }
3294
+ return { stats: [...byHost.values()].map((b) => ({
3295
+ host: b.host,
3296
+ total: b.total,
3297
+ failed: b.failed,
3298
+ avgMs: b.withDuration > 0 ? Math.round(b.totalMs / b.withDuration) : null,
3299
+ byStatus: b.byStatus
3300
+ })).sort((a, b) => b.total - a.total) };
3301
+ }
1277
3302
  if (action === "failed") return { failed: list };
1278
3303
  if (action === "list") return { entries: list };
1279
3304
  throw new Error(`unknown network action: ${action}`);
1280
3305
  }
3306
+ /** 只读工具集合:stripReads 保存时剔除(browser_cookies 仅 list 动作算只读)。 */
3307
+ const READONLY_TOOLS = /* @__PURE__ */ new Set([
3308
+ "browser_snapshot",
3309
+ "browser_elements",
3310
+ "browser_eval",
3311
+ "browser_network",
3312
+ "browser_status"
3313
+ ]);
3314
+ function isReadonlyStep(step) {
3315
+ if (READONLY_TOOLS.has(step.tool)) return true;
3316
+ if (step.tool === "browser_cookies") return (step.args?.action ?? "list") === "list";
3317
+ return false;
3318
+ }
3319
+ /** 使用 args.selector 定位的工具集合(自愈与录制指纹的作用范围)。 */
3320
+ const SELECTOR_STEP_TOOLS = /* @__PURE__ */ new Set([
3321
+ "browser_click",
3322
+ "browser_type",
3323
+ "browser_press",
3324
+ "browser_select",
3325
+ "browser_upload"
3326
+ ]);
3327
+ /**
3328
+ * 采集选择器指向元素的指纹(页内 evaluate;找不到元素返回 null)。
3329
+ * 指纹是回放自愈的降级候选来源:testid / role+name / text / id / tag+text。
3330
+ */
3331
+ async function captureSelectorHint(page, selector) {
3332
+ return page.locator(selector).first().evaluate((el) => {
3333
+ const tag = el.tagName.toLowerCase();
3334
+ const type = (el.getAttribute("type") ?? "").toLowerCase();
3335
+ const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
3336
+ const implicit = tag === "a" && el.hasAttribute("href") ? "link" : tag === "button" || tag === "summary" || tag === "input" && [
3337
+ "button",
3338
+ "submit",
3339
+ "reset",
3340
+ "image"
3341
+ ].includes(type) ? "button" : tag === "textarea" || tag === "input" && ![
3342
+ "hidden",
3343
+ "checkbox",
3344
+ "radio",
3345
+ "file"
3346
+ ].includes(type) ? "textbox" : tag === "select" ? "combobox" : tag === "input" && type === "checkbox" ? "checkbox" : tag === "input" && type === "radio" ? "radio" : "";
3347
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60);
3348
+ const name = (el.getAttribute("aria-label") ?? "").trim() || (el.getAttribute("placeholder") ?? "").trim() || text;
3349
+ return {
3350
+ testid: el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? void 0,
3351
+ id: el.id !== "" ? el.id : void 0,
3352
+ tag,
3353
+ role: (roleAttr || implicit) !== "" ? roleAttr || implicit : void 0,
3354
+ name: name !== "" ? name : void 0,
3355
+ text: text !== "" ? text : void 0
3356
+ };
3357
+ }).catch(() => null);
3358
+ }
3359
+ /** 由指纹生成自愈降级候选(按优先级排序;导出供测试)。 */
3360
+ function healCandidates(hint) {
3361
+ const out = [];
3362
+ const q = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
3363
+ if (hint.testid !== void 0 && hint.testid !== "") out.push({
3364
+ via: "testid",
3365
+ selector: `css=[data-testid="${q(hint.testid)}"]`
3366
+ });
3367
+ if (hint.role !== void 0 && hint.role !== "" && hint.name !== void 0 && hint.name !== "") out.push({
3368
+ via: "role+name",
3369
+ selector: `role=${hint.role}[name="${q(hint.name)}"]`
3370
+ });
3371
+ if (hint.text !== void 0 && hint.text !== "") out.push({
3372
+ via: "text",
3373
+ selector: `text="${q(hint.text)}"`
3374
+ });
3375
+ if (hint.id !== void 0 && hint.id !== "") out.push({
3376
+ via: "id",
3377
+ selector: `css=[id="${q(hint.id)}"]`
3378
+ });
3379
+ if (hint.tag !== void 0 && hint.tag !== "" && hint.text !== void 0 && hint.text !== "") out.push({
3380
+ via: "tag+text",
3381
+ selector: `css=${hint.tag}:has-text("${q(hint.text)}")`
3382
+ });
3383
+ return out;
3384
+ }
1281
3385
  /** 录制管理:start / stop / save / list / delete。 */
1282
3386
  async function recordTool(args) {
1283
3387
  const action = args.action ?? "start";
@@ -1299,19 +3403,47 @@ async function recordTool(args) {
1299
3403
  if (isRecording()) throw new Error("录制未结束:先 browser_record stop");
1300
3404
  const name = String(args.name ?? "");
1301
3405
  if (!name) throw new Error("save 需要 name");
1302
- const steps = testLogOf().reverse().slice(-100).map((c) => ({
3406
+ const recorded = lastRecordingOf();
3407
+ const source = recorded !== null ? "recording" : "recent-log";
3408
+ const rawSteps = recorded !== null ? recorded.map((c) => ({
1303
3409
  tool: c.tool,
1304
- args: c.args ?? {}
3410
+ args: c.args ?? {},
3411
+ ...c.meta !== void 0 ? { meta: c.meta } : {}
3412
+ })) : testLogOf().reverse().slice(-100).map((c) => ({
3413
+ tool: c.tool,
3414
+ args: c.args ?? {},
3415
+ ...c.meta !== void 0 ? { meta: c.meta } : {}
1305
3416
  }));
3417
+ const steps = args.stripReads === true ? rawSteps.filter((s) => !isReadonlyStep(s)) : rawSteps;
3418
+ const stripped = rawSteps.length - steps.length;
3419
+ let varsApplied = 0;
3420
+ if (args.vars !== void 0 && typeof args.vars === "object" && args.vars !== null) for (const [varName, path] of Object.entries(args.vars)) {
3421
+ const m = /^#(\d+)\.args\.([\w.]+)$/.exec(String(path));
3422
+ if (m === null) throw new Error(`vars.${varName}: 路径格式应为 "#步骤号.args.字段"(如 "#3.args.text"),实际: ${String(path)}`);
3423
+ const step = steps[Number(m[1]) - 1];
3424
+ if (step === void 0) throw new Error(`vars.${varName}: 步骤 #${m[1]} 不存在(共 ${steps.length} 步)`);
3425
+ setPath(step.args, m[2], `{{${varName}}}`);
3426
+ varsApplied++;
3427
+ }
1306
3428
  savedRecordings.set(name, {
1307
3429
  steps,
1308
3430
  savedAt: Date.now()
1309
3431
  });
1310
3432
  const file = saveRecordingFile(name, steps);
3433
+ const warnings = [];
3434
+ steps.forEach((s, i) => {
3435
+ if (s.tool !== "browser_upload") return;
3436
+ const p = s.args?.path;
3437
+ if (typeof p === "string" && p.length > 0 && !existsSync(p)) warnings.push(`#${i + 1} browser_upload: 文件不存在 ${p},回放此步骤将失败`);
3438
+ });
1311
3439
  return {
1312
3440
  saved: name,
1313
3441
  steps: steps.length,
3442
+ source,
1314
3443
  file,
3444
+ ...stripped > 0 ? { stripped } : {},
3445
+ ...varsApplied > 0 ? { varsApplied } : {},
3446
+ ...warnings.length > 0 ? { warnings } : {},
1315
3447
  hint: "已持久化到磁盘,重启 DSH 后仍可回放"
1316
3448
  };
1317
3449
  }
@@ -1326,11 +3458,12 @@ async function recordTool(args) {
1326
3458
  }
1327
3459
  throw new Error(`unknown record action: ${action}`);
1328
3460
  }
1329
- /** 回放执行器(步骤 → 工具调用),由 register.ts 注入实现 map。 */
1330
- async function runReplay(steps, impls, failFast) {
3461
+ /** 回放执行器(步骤 → 工具调用),由 register.ts 注入实现 map。支持选择器自愈。 */
3462
+ async function runReplay(steps, impls, failFast, opts = {}) {
1331
3463
  const results = [];
1332
3464
  let stoppedAt = null;
1333
3465
  let passed = 0;
3466
+ let healedCount = 0;
1334
3467
  for (let i = 0; i < steps.length; i++) {
1335
3468
  const step = steps[i];
1336
3469
  const impl = impls[step.tool];
@@ -1347,24 +3480,82 @@ async function runReplay(steps, impls, failFast) {
1347
3480
  }
1348
3481
  continue;
1349
3482
  }
3483
+ const hint = step.meta?.hint;
3484
+ if (!(opts.healing === true && hint !== void 0 && SELECTOR_STEP_TOOLS.has(step.tool) && typeof step.args?.selector === "string")) {
3485
+ try {
3486
+ await impl(step.args ?? {});
3487
+ passed++;
3488
+ results.push({
3489
+ index: i,
3490
+ tool: step.tool,
3491
+ ok: true
3492
+ });
3493
+ } catch (e) {
3494
+ results.push({
3495
+ index: i,
3496
+ tool: step.tool,
3497
+ ok: false,
3498
+ error: e instanceof Error ? e.message : String(e)
3499
+ });
3500
+ if (failFast) {
3501
+ stoppedAt = i;
3502
+ break;
3503
+ }
3504
+ }
3505
+ continue;
3506
+ }
3507
+ const healTimeout = opts.healTimeoutMs ?? 3e3;
3508
+ const quickArgs = {
3509
+ ...step.args,
3510
+ timeoutMs: Math.min(Number(step.args.timeoutMs ?? Number.POSITIVE_INFINITY) || Number.POSITIVE_INFINITY, healTimeout)
3511
+ };
1350
3512
  try {
1351
- await impl(step.args ?? {});
3513
+ await impl(quickArgs);
1352
3514
  passed++;
1353
3515
  results.push({
1354
3516
  index: i,
1355
3517
  tool: step.tool,
1356
3518
  ok: true
1357
3519
  });
1358
- } catch (e) {
1359
- results.push({
1360
- index: i,
1361
- tool: step.tool,
1362
- ok: false,
1363
- error: e instanceof Error ? e.message : String(e)
1364
- });
1365
- if (failFast) {
1366
- stoppedAt = i;
1367
- break;
3520
+ continue;
3521
+ } catch (e0) {
3522
+ const tried = [];
3523
+ let healed = null;
3524
+ for (const candidate of healCandidates(hint)) {
3525
+ tried.push(`${candidate.via}: ${candidate.selector}`);
3526
+ try {
3527
+ await impl({
3528
+ ...step.args,
3529
+ selector: candidate.selector,
3530
+ timeoutMs: healTimeout
3531
+ });
3532
+ healed = candidate;
3533
+ break;
3534
+ } catch {}
3535
+ }
3536
+ if (healed !== null) {
3537
+ passed++;
3538
+ healedCount++;
3539
+ results.push({
3540
+ index: i,
3541
+ tool: step.tool,
3542
+ ok: true,
3543
+ healed: true,
3544
+ via: healed.via,
3545
+ resolved: healed.selector
3546
+ });
3547
+ } else {
3548
+ const base = e0 instanceof Error ? e0.message : String(e0);
3549
+ results.push({
3550
+ index: i,
3551
+ tool: step.tool,
3552
+ ok: false,
3553
+ error: `${base}${tried.length > 0 ? `; 自愈已尝试: ${tried.join(" | ")}` : ""}`
3554
+ });
3555
+ if (failFast) {
3556
+ stoppedAt = i;
3557
+ break;
3558
+ }
1368
3559
  }
1369
3560
  }
1370
3561
  }
@@ -1372,12 +3563,263 @@ async function runReplay(steps, impls, failFast) {
1372
3563
  total: steps.length,
1373
3564
  passed,
1374
3565
  failed: results.length - passed,
1375
- stoppedAt,
3566
+ healedCount,
3567
+ ...stoppedAt !== null ? { stoppedAt } : {},
1376
3568
  results
1377
3569
  };
1378
3570
  }
1379
- /** 回放入口:name(已保存录制)或 steps(数组)二选一。 */
1380
- async function replayTool(args, impls) {
3571
+ /**
3572
+ * 页内抽取(自包含函数,page.evaluate 序列化注入):
3573
+ * attr 缺省取可见文本(innerText/textContent),否则 getAttribute(attr);
3574
+ * multiple 收集所有命中;fields 嵌套时在每个命中元素内递归抽取。
3575
+ */
3576
+ function extractFieldsInPage(fields) {
3577
+ const val = (el, attr) => {
3578
+ if (el === null) return null;
3579
+ if (attr !== void 0 && attr !== "" && attr !== "text" && attr !== "textContent") return el.getAttribute(attr);
3580
+ return (el.innerText || el.textContent || "").trim();
3581
+ };
3582
+ const run = (scope, spec) => {
3583
+ const out = {};
3584
+ for (const f of spec) {
3585
+ const els = Array.from(scope.querySelectorAll(f.selector));
3586
+ if (f.multiple === true) {
3587
+ if (Array.isArray(f.fields) && f.fields.length > 0) out[f.name] = els.map((el) => run(el, f.fields));
3588
+ else out[f.name] = els.map((el) => val(el, f.attr));
3589
+ } else {
3590
+ const el = els[0] ?? null;
3591
+ if (el !== null && Array.isArray(f.fields) && f.fields.length > 0) out[f.name] = run(el, f.fields);
3592
+ else out[f.name] = val(el, f.attr);
3593
+ }
3594
+ }
3595
+ return out;
3596
+ };
3597
+ return run(document, fields);
3598
+ }
3599
+ /**
3600
+ * 把单页抽取结果规整为行数组:恰好一个顶层 multiple 字段时,其数组即行
3601
+ * (行内嵌套对象为行,标量包装成 { [name]: value });否则整页算一行。
3602
+ */
3603
+ function rowsOfExtract(result, fields) {
3604
+ const multi = fields.filter((f) => f.multiple === true);
3605
+ if (multi.length === 1) {
3606
+ const name = multi[0].name;
3607
+ return (Array.isArray(result[name]) ? result[name] : []).map((item) => item !== null && typeof item === "object" ? item : { [name]: item });
3608
+ }
3609
+ return [result];
3610
+ }
3611
+ /** 工作区内落盘路径解析(拒绝穿越到工作区之外)。 */
3612
+ function workspaceFilePath(cwd, file) {
3613
+ const p = isAbsolute(file) ? resolve(file) : resolve(cwd, file);
3614
+ const root = resolve(cwd);
3615
+ if (p !== root && !p.startsWith(root + "/") && !p.startsWith(root + "\\")) throw new Error(`文件路径越出工作区: ${file}`);
3616
+ return p;
3617
+ }
3618
+ /**
3619
+ * 规则化抽取:fields 定义抽取结构,next 选择器驱动翻页(maxPages 上限),
3620
+ * 行数超 maxRows 截断;format/file 控制序列化与落盘(json/csv/jsonl/markdown)。
3621
+ */
3622
+ async function extractTool(config, args, exec) {
3623
+ const page = await getPage(config);
3624
+ const fields = args.fields;
3625
+ if (!Array.isArray(fields) || fields.length === 0) throw new Error("extract 需要 fields:[{ name, selector, attr?, multiple?, fields? }]");
3626
+ for (const f of fields) if (typeof f?.name !== "string" || f.name === "" || typeof f?.selector !== "string" || f.selector === "") throw new Error("fields 每项需要非空 name 与 selector");
3627
+ const maxPages = Math.min(Math.max(Number(args.maxPages ?? 1) || 1, 1), 100);
3628
+ const maxRows = Math.min(Math.max(Number(args.maxRows ?? config.maxRows ?? 1e3) || 1e3, 1), 1e5);
3629
+ const waitAfterMs = Math.min(Math.max(Number(args.waitAfterMs ?? 800) || 0, 0), 1e4);
3630
+ const rows = [];
3631
+ let pages = 0;
3632
+ for (;;) {
3633
+ const result = await page.evaluate(extractFieldsInPage, fields);
3634
+ rows.push(...rowsOfExtract(result, fields));
3635
+ pages++;
3636
+ if (rows.length >= maxRows) break;
3637
+ const nextSel = typeof args.next === "string" && args.next !== "" ? args.next : null;
3638
+ if (nextSel === null || pages >= maxPages) break;
3639
+ const next = page.locator(nextSel).first();
3640
+ if (await next.count().catch(() => 0) === 0) break;
3641
+ if (!await next.evaluate((el) => {
3642
+ const style = getComputedStyle(el);
3643
+ const rect = el.getBoundingClientRect();
3644
+ const visible = style.visibility !== "hidden" && style.display !== "none" && (rect.width > 0 || rect.height > 0);
3645
+ const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true" || el.classList.contains("disabled") || el.classList.contains("ant-pagination-disabled");
3646
+ return visible && !disabled;
3647
+ }).catch(() => false)) break;
3648
+ await next.click();
3649
+ await page.waitForLoadState("domcontentloaded").catch(() => {});
3650
+ if (waitAfterMs > 0) await page.waitForTimeout(waitAfterMs);
3651
+ }
3652
+ const truncated = rows.length > maxRows;
3653
+ const finalRows = truncated ? rows.slice(0, maxRows) : rows;
3654
+ const format = typeof args.format === "string" && args.format !== "" ? args.format : (typeof args.file === "string" ? formatFromFile(args.file) : null) ?? "json";
3655
+ if (![
3656
+ "json",
3657
+ "csv",
3658
+ "jsonl",
3659
+ "markdown"
3660
+ ].includes(format)) throw new Error(`未知 format: ${format}(支持 json / csv / jsonl / markdown)`);
3661
+ let file;
3662
+ let bytes;
3663
+ if (typeof args.file === "string" && args.file !== "") {
3664
+ file = workspaceFilePath(workspaceBaseDir(config, exec), args.file);
3665
+ const content = serializeRows(finalRows, format);
3666
+ mkdirSync(dirname(file), { recursive: true });
3667
+ writeFileSync(file, content, "utf8");
3668
+ bytes = Buffer.byteLength(content, "utf8");
3669
+ }
3670
+ return {
3671
+ rows: finalRows,
3672
+ count: finalRows.length,
3673
+ pages,
3674
+ format,
3675
+ ...truncated ? { truncated: true } : { truncated: false },
3676
+ ...file !== void 0 ? {
3677
+ file,
3678
+ bytes
3679
+ } : {},
3680
+ ...await pageIdentity(page)
3681
+ };
3682
+ }
3683
+ /** 批处理内禁止嵌套的工具(防 batch→replay→batch 递归与录制自引用)。 */
3684
+ const BATCH_FORBIDDEN = /* @__PURE__ */ new Set([
3685
+ "browser_batch",
3686
+ "browser_replay",
3687
+ "browser_record"
3688
+ ]);
3689
+ /**
3690
+ * 回归报告:list 列出最近运行,show 看单条详情,
3691
+ * export 聚合生成 Markdown/HTML 报告落盘工作区。
3692
+ */
3693
+ async function reportTool(config, args, exec) {
3694
+ const action = args.action ?? "list";
3695
+ if (action === "list") return { runs: listRuns(Math.min(Math.max(Number(args.limit ?? 20) || 20, 1), 200)).map((r) => ({
3696
+ runId: r.runId,
3697
+ ts: r.ts,
3698
+ kind: r.kind,
3699
+ source: r.source,
3700
+ total: r.total,
3701
+ passed: r.passed,
3702
+ failed: r.failed,
3703
+ healedCount: r.healedCount,
3704
+ durationMs: r.durationMs,
3705
+ ...r.row !== void 0 ? { row: r.row } : {}
3706
+ })) };
3707
+ if (action === "show") {
3708
+ const run = typeof args.runId === "string" && args.runId !== "" ? getRun(args.runId) : latestRun();
3709
+ if (run === null) throw new Error(typeof args.runId === "string" ? `无此运行记录: ${args.runId}` : "暂无运行记录(先跑一次 browser_replay)");
3710
+ return { run };
3711
+ }
3712
+ if (action === "export") {
3713
+ const format = args.format === "html" ? "html" : "markdown";
3714
+ const source = typeof args.source === "string" && args.source !== "" ? args.source : void 0;
3715
+ const runs = listRuns(Math.min(Math.max(Number(args.limit ?? 50) || 50, 1), 1e3)).filter((r) => source === void 0 || r.source === source);
3716
+ if (runs.length === 0) throw new Error(source !== void 0 ? `来源 "${source}" 暂无运行记录` : "暂无运行记录(先跑一次 browser_replay)");
3717
+ const content = format === "html" ? renderHtmlReport(runs, source) : renderMarkdownReport(runs, source);
3718
+ const file = workspaceFilePath(workspaceBaseDir(config, exec), typeof args.file === "string" && args.file !== "" ? args.file : `reports/regression-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.${format === "html" ? "html" : "md"}`);
3719
+ mkdirSync(dirname(file), { recursive: true });
3720
+ writeFileSync(file, content, "utf8");
3721
+ return {
3722
+ file,
3723
+ bytes: Buffer.byteLength(content, "utf8"),
3724
+ runs: runs.length,
3725
+ format
3726
+ };
3727
+ }
3728
+ throw new Error(`unknown report action: ${action}`);
3729
+ }
3730
+ /**
3731
+ * 动作批处理:一次调用顺序执行多个浏览器步骤(内核复用 runReplay),
3732
+ * 减少「点击→等待→输入」类固定小序列的工具往返。步骤内禁止嵌套
3733
+ * batch/replay/record;批整体在操作轨迹里记为一条 browser_batch。
3734
+ */
3735
+ async function batchTool(args, impls, config) {
3736
+ const steps = args.steps;
3737
+ if (!Array.isArray(steps) || steps.length === 0) throw new Error("batch 需要非空 steps 数组:[{ tool, args }]");
3738
+ const guarded = { ...impls };
3739
+ for (const name of BATCH_FORBIDDEN) guarded[name] = async () => {
3740
+ throw new Error(`不允许嵌套: batch 步骤不能调用 ${name}`);
3741
+ };
3742
+ const failFast = args.failFast !== false;
3743
+ const opts = {
3744
+ healing: config?.selectorHealing !== false,
3745
+ healTimeoutMs: config?.healTimeoutMs ?? 3e3
3746
+ };
3747
+ const t0 = Date.now();
3748
+ const result = await runReplay(steps, guarded, failFast, opts);
3749
+ noteRun({
3750
+ kind: "batch",
3751
+ source: "batch",
3752
+ total: result.total,
3753
+ passed: result.passed,
3754
+ failed: result.failed,
3755
+ healedCount: result.healedCount,
3756
+ durationMs: Date.now() - t0,
3757
+ results: result.results
3758
+ });
3759
+ const stoppedAt = result.stoppedAt;
3760
+ return {
3761
+ ...result,
3762
+ summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${stoppedAt !== void 0 ? `(停在步骤 ${stoppedAt})` : ""}` : ""}${result.healedCount > 0 ? `,${result.healedCount} 步自愈` : ""}`
3763
+ };
3764
+ }
3765
+ /** 设置嵌套路径的值(路径段按 . 拆分,供 record save 的 vars 占位替换)。 */
3766
+ function setPath(obj, path, value) {
3767
+ const segs = path.split(".");
3768
+ let cur = obj;
3769
+ for (let i = 0; i < segs.length - 1; i++) {
3770
+ const next = cur[segs[i]];
3771
+ if (next === null || typeof next !== "object") throw new Error(`路径不存在: ${path}`);
3772
+ cur = next;
3773
+ }
3774
+ cur[segs[segs.length - 1]] = value;
3775
+ }
3776
+ /** 收集步骤中所有 {{var}} 占位符名(导出供测试)。 */
3777
+ function findPlaceholders(steps) {
3778
+ const found = /* @__PURE__ */ new Set();
3779
+ const walk = (v) => {
3780
+ if (typeof v === "string") for (const m of v.matchAll(/\{\{([\w-]+)\}\}/g)) found.add(m[1]);
3781
+ else if (Array.isArray(v)) v.forEach(walk);
3782
+ else if (v !== null && typeof v === "object") Object.values(v).forEach(walk);
3783
+ };
3784
+ steps.forEach((s) => walk(s.args));
3785
+ return [...found];
3786
+ }
3787
+ /**
3788
+ * 深拷贝步骤并替换字符串叶子里的 {{var}} 占位符(导出供测试)。
3789
+ * vars 缺失的占位符保留原样(由调用方先 findPlaceholders 校验)。
3790
+ */
3791
+ function substituteVars(steps, vars) {
3792
+ const walk = (v) => {
3793
+ if (typeof v === "string") return v.replace(/\{\{([\w-]+)\}\}/g, (whole, name) => name in vars ? String(vars[name]) : whole);
3794
+ if (Array.isArray(v)) return v.map(walk);
3795
+ if (v !== null && typeof v === "object") {
3796
+ const out = {};
3797
+ for (const [k, val] of Object.entries(v)) out[k] = walk(val);
3798
+ return out;
3799
+ }
3800
+ return v;
3801
+ };
3802
+ return steps.map((s) => ({
3803
+ ...s,
3804
+ args: walk(s.args)
3805
+ }));
3806
+ }
3807
+ /** 运行结果落一条运行记录(runs.jsonl);失败静默,不阻断主流程。 */
3808
+ function noteRun(record) {
3809
+ try {
3810
+ const ts = Date.now();
3811
+ appendRun({
3812
+ runId: makeRunId(ts),
3813
+ ts,
3814
+ ...record
3815
+ });
3816
+ } catch {}
3817
+ }
3818
+ /**
3819
+ * 回放入口:name(已保存录制)或 steps(数组)二选一。
3820
+ * vars 注入 {{占位符}};data/dataFile 数据集模式逐行运行并聚合。
3821
+ */
3822
+ async function replayTool(args, impls, config, exec) {
1381
3823
  let steps = [];
1382
3824
  let source = "steps";
1383
3825
  if (typeof args.name === "string" && args.name) {
@@ -1389,11 +3831,100 @@ async function replayTool(args, impls) {
1389
3831
  else throw new Error("replay 需要 name(已保存录制)或 steps(步骤数组)");
1390
3832
  if (steps.length === 0) throw new Error("步骤为空");
1391
3833
  const failFast = args.failFast !== false;
1392
- const result = await runReplay(steps, impls, failFast);
3834
+ const opts = {
3835
+ healing: config?.selectorHealing !== false,
3836
+ healTimeoutMs: config?.healTimeoutMs ?? 3e3
3837
+ };
3838
+ let rows = null;
3839
+ if (Array.isArray(args.data)) rows = args.data;
3840
+ else if (typeof args.dataFile === "string" && args.dataFile !== "") {
3841
+ const file = workspaceFilePath(workspaceBaseDir(config ?? {}, exec), args.dataFile);
3842
+ if (!existsSync(file)) throw new Error(`dataFile 不存在: ${file}`);
3843
+ rows = parseCsv(readFileSync(file, "utf8"));
3844
+ }
3845
+ if (rows !== null) {
3846
+ if (rows.length === 0) throw new Error("数据集为空(0 行)");
3847
+ const placeholders = findPlaceholders(steps);
3848
+ const runs = [];
3849
+ let okRows = 0;
3850
+ for (let i = 0; i < rows.length; i++) {
3851
+ const rowVars = {
3852
+ ...args.vars,
3853
+ ...rows[i]
3854
+ };
3855
+ const missing = placeholders.filter((p) => !(p in rowVars));
3856
+ const t0 = Date.now();
3857
+ if (missing.length > 0) {
3858
+ runs.push({
3859
+ row: i,
3860
+ total: steps.length,
3861
+ passed: 0,
3862
+ failed: steps.length,
3863
+ healedCount: 0,
3864
+ results: [],
3865
+ error: `缺少变量: ${missing.join(", ")}`
3866
+ });
3867
+ noteRun({
3868
+ kind: "replay",
3869
+ source,
3870
+ total: steps.length,
3871
+ passed: 0,
3872
+ failed: steps.length,
3873
+ healedCount: 0,
3874
+ durationMs: Date.now() - t0,
3875
+ row: i,
3876
+ results: []
3877
+ });
3878
+ continue;
3879
+ }
3880
+ const result = await runReplay(substituteVars(steps, rowVars), impls, failFast, opts);
3881
+ result.stoppedAt;
3882
+ runs.push({
3883
+ row: i,
3884
+ ...result
3885
+ });
3886
+ if (result.failed === 0) okRows++;
3887
+ noteRun({
3888
+ kind: "replay",
3889
+ source,
3890
+ total: result.total,
3891
+ passed: result.passed,
3892
+ failed: result.failed,
3893
+ healedCount: result.healedCount,
3894
+ durationMs: Date.now() - t0,
3895
+ row: i,
3896
+ results: result.results
3897
+ });
3898
+ }
3899
+ return {
3900
+ source,
3901
+ rows: rows.length,
3902
+ passedRows: okRows,
3903
+ failedRows: rows.length - okRows,
3904
+ runs,
3905
+ summary: `数据集 ${okRows}/${rows.length} 行通过${okRows < rows.length ? `,${rows.length - okRows} 行失败` : ""}`
3906
+ };
3907
+ }
3908
+ const vars = args.vars ?? {};
3909
+ const missing = findPlaceholders(steps).filter((p) => !(p in vars));
3910
+ if (missing.length > 0) throw new Error(`缺少变量: ${missing.join(", ")}(录制含 {{占位符}},请用 vars 提供)`);
3911
+ const t0 = Date.now();
3912
+ const result = await runReplay(substituteVars(steps, vars), impls, failFast, opts);
3913
+ const stoppedAt = result.stoppedAt;
3914
+ noteRun({
3915
+ kind: "replay",
3916
+ source,
3917
+ total: result.total,
3918
+ passed: result.passed,
3919
+ failed: result.failed,
3920
+ healedCount: result.healedCount,
3921
+ durationMs: Date.now() - t0,
3922
+ results: result.results
3923
+ });
1393
3924
  return {
1394
3925
  source,
1395
3926
  ...result,
1396
- summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${result.stoppedAt !== null ? `(停在步骤 ${result.stoppedAt})` : ""}` : ""}`
3927
+ summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${stoppedAt !== void 0 ? `(停在步骤 ${stoppedAt})` : ""}` : ""}${result.healedCount > 0 ? `,${result.healedCount} 步自愈(建议修复录制选择器)` : ""}`
1397
3928
  };
1398
3929
  }
1399
3930
  //#endregion
@@ -1401,6 +3932,7 @@ async function replayTool(args, impls) {
1401
3932
  function registerTools(ctx, config) {
1402
3933
  const tools = ctx.get("tools");
1403
3934
  if (tools === void 0) return;
3935
+ setToolsLogger((m) => ctx.logger?.info?.(m));
1404
3936
  const timeout = (ms) => ms ?? config.timeoutMs;
1405
3937
  /** 参数净化:大字段(script/fields 等)截断,避免操作日志膨胀。 */
1406
3938
  function sanitizeArgs(args) {
@@ -1412,21 +3944,83 @@ function registerTools(ctx, config) {
1412
3944
  }
1413
3945
  return out;
1414
3946
  }
3947
+ /** 会触发真实 DOM 事件的 agent 工具:执行前后打点,混合录制时用于区分人手与工具回声。 */
3948
+ const AGENT_DOM_TOOLS = /* @__PURE__ */ new Set([
3949
+ "browser_click",
3950
+ "browser_type",
3951
+ "browser_select",
3952
+ "browser_press",
3953
+ "browser_upload",
3954
+ "browser_form",
3955
+ "browser_eval"
3956
+ ]);
3957
+ /** 操作预告:这些工具执行前把目标元素在实时画面上高亮(异步不阻塞)。 */
3958
+ const PREVIEW_TOOLS = /* @__PURE__ */ new Set([
3959
+ "browser_click",
3960
+ "browser_type",
3961
+ "browser_select",
3962
+ "browser_press"
3963
+ ]);
3964
+ /**
3965
+ * toolset: "core" 的高频核心集:浏览/定位/操作/等待/断言/人机协作。
3966
+ * 低频的录制/回放/报告/抽取/表单回放/profile/context 等只在 "all" 注册。
3967
+ */
3968
+ const CORE_TOOLS = /* @__PURE__ */ new Set([
3969
+ "browser_open",
3970
+ "browser_snapshot",
3971
+ "browser_find",
3972
+ "browser_elements",
3973
+ "browser_click",
3974
+ "browser_type",
3975
+ "browser_press",
3976
+ "browser_select",
3977
+ "browser_eval",
3978
+ "browser_batch",
3979
+ "browser_wait",
3980
+ "browser_wait_for",
3981
+ "browser_assert",
3982
+ "browser_screenshot",
3983
+ "browser_wait_for_login",
3984
+ "browser_pause_for_human",
3985
+ "browser_confirm",
3986
+ "browser_network",
3987
+ "browser_tabs",
3988
+ "browser_download",
3989
+ "browser_upload",
3990
+ "browser_status",
3991
+ "browser_doctor",
3992
+ "browser_close"
3993
+ ]);
1415
3994
  /** 打点包装:每次工具调用记录操作轨迹(测试报告 / 录制数据源)。 */
1416
3995
  function register(def) {
3996
+ if (config.toolset === "core" && !CORE_TOOLS.has(def.name)) return;
1417
3997
  const original = def.execute;
1418
3998
  def.execute = async (args, exec) => {
1419
3999
  const t0 = Date.now();
1420
4000
  const sanitized = sanitizeArgs(args);
4001
+ const domTool = AGENT_DOM_TOOLS.has(def.name);
4002
+ if (domTool) noteAgentAction();
4003
+ if (PREVIEW_TOOLS.has(def.name)) {
4004
+ const page = activePage();
4005
+ previewHighlight(page, typeof args?.selector === "string" && args.selector ? args.selector : typeof args?.ref === "string" && args.ref && page !== null ? pageRef(page, args.ref)?.selector ?? "" : "", def.name.replace("browser_", ""));
4006
+ }
1421
4007
  try {
1422
4008
  const value = await original(args, exec);
1423
- recordCall({
4009
+ if (domTool) noteAgentAction();
4010
+ const call = recordCall({
1424
4011
  tool: def.name,
1425
4012
  args: sanitized,
1426
4013
  ok: true,
1427
4014
  ms: Date.now() - t0,
1428
4015
  ts: Date.now()
1429
4016
  });
4017
+ if (isRecording() && SELECTOR_STEP_TOOLS.has(def.name) && typeof args?.selector === "string") {
4018
+ const page = activePage();
4019
+ if (page !== null) {
4020
+ const hintResult = await captureSelectorHint(page, String(args.selector));
4021
+ if (hintResult !== null) call.meta = { hint: hintResult };
4022
+ }
4023
+ }
1430
4024
  return value;
1431
4025
  } catch (error) {
1432
4026
  recordCall({
@@ -1449,6 +4043,7 @@ function registerTools(ctx, config) {
1449
4043
  browser_click: (a) => clickTool(config, a),
1450
4044
  browser_type: (a) => typeTool(config, a),
1451
4045
  browser_press: (a) => pressTool(config, a),
4046
+ browser_select: (a) => selectTool(config, a),
1452
4047
  browser_eval: (a) => evalTool(config, a),
1453
4048
  browser_screenshot: (a) => screenshotTool(config, a),
1454
4049
  browser_wait: (a) => waitTool(config, a),
@@ -1466,11 +4061,18 @@ function registerTools(ctx, config) {
1466
4061
  browser_profile: (a) => profileTool(config, a),
1467
4062
  browser_elements: (a) => elementsTool(config, a),
1468
4063
  browser_wait_for_login: (a) => waitForLoginTool(config, a),
4064
+ browser_pause_for_human: (a) => pauseForHumanTool(config, a),
4065
+ browser_confirm: (a) => confirmTool(config, a),
4066
+ browser_find: (a) => findTool(config, a),
4067
+ browser_doctor: (a) => doctorTool(config, a, void 0),
4068
+ browser_context: (a) => contextTool(config, a),
1469
4069
  browser_wait_for: (a) => waitForTool(config, a),
1470
4070
  browser_assert: (a) => assertTool(config, a, void 0),
1471
4071
  browser_network: (a) => networkTool(a),
1472
4072
  browser_record: (a) => recordTool(a),
1473
- browser_replay: (a) => replayTool(a, stepImpls)
4073
+ browser_replay: (a) => replayTool(a, stepImpls, config),
4074
+ browser_batch: (a) => batchTool(a, stepImpls, config),
4075
+ browser_extract: (a) => extractTool(config, a)
1474
4076
  };
1475
4077
  register(definePlainTool({
1476
4078
  name: "browser_open",
@@ -1520,17 +4122,34 @@ function registerTools(ctx, config) {
1520
4122
  }));
1521
4123
  register(definePlainTool({
1522
4124
  name: "browser_snapshot",
1523
- description: "读取当前页面的可访问文本、链接清单与输入框数量(不截图)。maxTextChars 默认 20000。链接列表用于构造点击选择器。",
4125
+ description: "读取当前页面快照(不截图)。mode:'text'(默认)返回压缩后的可见文本+链接清单,适合读内容;'aria' 返回可访问性树,可交互元素带 [ref=eN] 短引用,后续 browser_click/browser_type/browser_select/browser_press 可直接传 ref 代替 CSS 选择器(导航或重新快照后旧 ref 失效)。maxTextChars 默认 20000。scope:可选 CSS 选择器,只对命中的容器区域提取(text/aria 两种模式都生效,scope 未命中报错)。since:增量模式,传上次快照返回的 contentVersion——内容未变返回 { unchanged: true },变了只返回变化的行(diff: true, changedFrom/removed 标注变化区),表格翻页/局部刷新后重读 token 开销可降一个量级;since 过期则回落全量并标 stale。",
1524
4126
  parameters: {
1525
4127
  type: "object",
1526
4128
  properties: {
4129
+ mode: {
4130
+ type: "string",
4131
+ enum: ["text", "aria"],
4132
+ description: "快照模式,默认 text。"
4133
+ },
4134
+ scope: {
4135
+ type: "string",
4136
+ description: "可选:限定提取区域的 CSS 选择器(如 '#main')。"
4137
+ },
4138
+ since: {
4139
+ type: "integer",
4140
+ description: "可选:上次快照的 contentVersion,只返回变化内容。"
4141
+ },
1527
4142
  maxTextChars: {
1528
4143
  type: "integer",
1529
4144
  description: "文本截断上限。"
1530
4145
  },
1531
4146
  maxLinks: {
1532
4147
  type: "integer",
1533
- description: "链接数量上限。"
4148
+ description: "链接数量上限(text 模式)。"
4149
+ },
4150
+ maxRefs: {
4151
+ type: "integer",
4152
+ description: "ref 分配上限(aria 模式,默认 200)。"
1534
4153
  }
1535
4154
  },
1536
4155
  additionalProperties: false
@@ -1542,29 +4161,43 @@ function registerTools(ctx, config) {
1542
4161
  url: { type: "string" },
1543
4162
  title: { type: "string" },
1544
4163
  text: { type: "string" },
4164
+ tree: { type: "string" },
1545
4165
  truncated: { type: "boolean" },
1546
4166
  links: {
1547
4167
  type: "array",
1548
4168
  items: { type: "object" }
1549
4169
  },
1550
- inputs: { type: "integer" }
4170
+ inputs: { type: "integer" },
4171
+ refs: { type: "integer" },
4172
+ scope: { type: "string" },
4173
+ contentVersion: { type: "integer" },
4174
+ unchanged: { type: "boolean" },
4175
+ diff: { type: "boolean" },
4176
+ changedFrom: { type: "integer" },
4177
+ removed: { type: "integer" },
4178
+ stale: { type: "boolean" }
1551
4179
  },
1552
4180
  required: [
1553
4181
  "url",
1554
4182
  "title",
1555
- "text",
1556
- "truncated",
1557
- "links",
1558
- "inputs"
4183
+ "truncated"
1559
4184
  ]
1560
4185
  },
1561
- render: (_args, value) => `URL: ${value.url}\nTitle: ${value.title}\nInputs: ${value.inputs}\n--- text ---\n${value.text}`,
4186
+ render: (_args, value) => {
4187
+ if (value.unchanged === true) return `页面未变化(版本 v${value.contentVersion}): ${value.url}`;
4188
+ if (value.diff === true) {
4189
+ const body = value.tree !== void 0 ? value.tree : value.text;
4190
+ return `内容变化(v${value.contentVersion},第 ${value.changedFrom} 行起,删除 ${value.removed} 行):\n${body}`;
4191
+ }
4192
+ if (value.tree !== void 0) return `URL: ${value.url}\nTitle: ${value.title}\nRefs: ${value.refs}\nVersion: v${value.contentVersion}${value.stale === true ? "(stale)" : ""}\n--- aria tree ---\n${value.tree}`;
4193
+ return `URL: ${value.url}\nTitle: ${value.title}\nInputs: ${value.inputs}\nVersion: v${value.contentVersion}${value.stale === true ? "(stale)" : ""}\n--- text ---\n${value.text}`;
4194
+ },
1562
4195
  timeoutMs: timeout(),
1563
4196
  execute: (args) => snapshotTool(config, args)
1564
4197
  }));
1565
4198
  register(definePlainTool({
1566
4199
  name: "browser_click",
1567
- description: "点击页面上匹配 CSS 选择器的元素(来自 browser_snapshot 的链接/表单分析)。",
4200
+ description: "点击页面元素:selector(CSS)或 ref(来自 browser_snapshot mode:'aria')二选一。",
1568
4201
  parameters: {
1569
4202
  type: "object",
1570
4203
  properties: {
@@ -1572,9 +4205,12 @@ function registerTools(ctx, config) {
1572
4205
  type: "string",
1573
4206
  description: "CSS 选择器。"
1574
4207
  },
4208
+ ref: {
4209
+ type: "string",
4210
+ description: "aria 快照的 ref(如 'e3');导航后失效。"
4211
+ },
1575
4212
  timeoutMs: { type: "integer" }
1576
4213
  },
1577
- required: ["selector"],
1578
4214
  additionalProperties: false
1579
4215
  },
1580
4216
  outputSchema: {
@@ -1597,18 +4233,22 @@ function registerTools(ctx, config) {
1597
4233
  }));
1598
4234
  register(definePlainTool({
1599
4235
  name: "browser_type",
1600
- description: "向 CSS 选择器指向的输入框输入文本。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车。",
4236
+ description: "向输入框输入文本:selector(CSS)或 ref 二选一。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车。",
1601
4237
  parameters: {
1602
4238
  type: "object",
1603
4239
  properties: {
1604
4240
  selector: { type: "string" },
4241
+ ref: {
4242
+ type: "string",
4243
+ description: "aria 快照的 ref(如 'e2')。"
4244
+ },
1605
4245
  text: { type: "string" },
1606
4246
  clear: { type: "boolean" },
1607
4247
  submit: { type: "boolean" },
1608
4248
  delayMs: { type: "integer" },
1609
4249
  timeoutMs: { type: "integer" }
1610
4250
  },
1611
- required: ["selector", "text"],
4251
+ required: ["text"],
1612
4252
  additionalProperties: false
1613
4253
  },
1614
4254
  outputSchema: {
@@ -1635,7 +4275,7 @@ function registerTools(ctx, config) {
1635
4275
  }));
1636
4276
  register(definePlainTool({
1637
4277
  name: "browser_press",
1638
- description: "按键:有 selector 时先聚焦该元素再按键(如 'Enter'、'Tab'、'Escape'、'Control+a'),否则在页面级按键。",
4278
+ description: "按键:有 selector 或 ref 时先聚焦该元素再按键(如 'Enter'、'Tab'、'Escape'、'Control+a'),否则在页面级按键。",
1639
4279
  parameters: {
1640
4280
  type: "object",
1641
4281
  properties: {
@@ -1644,6 +4284,10 @@ function registerTools(ctx, config) {
1644
4284
  description: "按键名(Playwright 键盘键名)。"
1645
4285
  },
1646
4286
  selector: { type: "string" },
4287
+ ref: {
4288
+ type: "string",
4289
+ description: "aria 快照的 ref。"
4290
+ },
1647
4291
  timeoutMs: { type: "integer" }
1648
4292
  },
1649
4293
  required: ["key"],
@@ -1667,6 +4311,53 @@ function registerTools(ctx, config) {
1667
4311
  timeoutMs: timeout(),
1668
4312
  execute: (args) => pressTool(config, args)
1669
4313
  }));
4314
+ register(definePlainTool({
4315
+ name: "browser_select",
4316
+ description: "下拉选择原语:原生 <select> 与组件库下拉(antd / element-plus / ARIA listbox)通用。selector 或 ref 指向下拉触发框,option 为选项文本(默认包含匹配,exact=true 精确)。找不到选项时报错并列出可见选项,无需再 browser_eval 探查。",
4317
+ parameters: {
4318
+ type: "object",
4319
+ properties: {
4320
+ selector: {
4321
+ type: "string",
4322
+ description: "下拉触发框的 CSS 选择器(如 #rc_select_1)。"
4323
+ },
4324
+ ref: {
4325
+ type: "string",
4326
+ description: "aria 快照的 ref。"
4327
+ },
4328
+ option: {
4329
+ type: "string",
4330
+ description: "要选择的选项文本。"
4331
+ },
4332
+ exact: {
4333
+ type: "boolean",
4334
+ description: "精确匹配,默认 false(包含匹配)。"
4335
+ },
4336
+ timeoutMs: { type: "integer" }
4337
+ },
4338
+ required: ["option"],
4339
+ additionalProperties: false
4340
+ },
4341
+ outputSchema: {
4342
+ type: "object",
4343
+ additionalProperties: false,
4344
+ properties: {
4345
+ selected: { type: "string" },
4346
+ selector: { type: "string" },
4347
+ url: { type: "string" },
4348
+ title: { type: "string" }
4349
+ },
4350
+ required: [
4351
+ "selected",
4352
+ "selector",
4353
+ "url",
4354
+ "title"
4355
+ ]
4356
+ },
4357
+ render: (_args, value) => `Selected "${value.selected}" in ${value.selector}`,
4358
+ timeoutMs: timeout(),
4359
+ execute: (args) => selectTool(config, args)
4360
+ }));
1670
4361
  register(definePlainTool({
1671
4362
  name: "browser_eval",
1672
4363
  description: "在页面上下文执行一段 JavaScript。表达式直接求值(如 'document.title');含换行/分号的语句会被包进 async IIFE,可用 return 返回。结果必须是可 JSON 序列化的值。",
@@ -1691,13 +4382,19 @@ function registerTools(ctx, config) {
1691
4382
  }));
1692
4383
  register(definePlainTool({
1693
4384
  name: "browser_screenshot",
1694
- description: "把当前页面截图保存为 PNG 文件(默认存到工作区 .dsh-browser/screenshots/),返回文件路径。纯文本模型看不到图,但用户可以在实时画面面板查看。",
4385
+ description: "把当前页面截图保存为 PNG 文件(默认存到工作区 .dsh-browser/screenshots/),返回文件路径。传 baseline=<名字> 时存为视觉回归基线(.dsh-browser/baselines/,不受清理策略约束),供 browser_assert 的 visual 断言比对。纯文本模型看不到图,但用户可以在实时画面面板查看。",
1695
4386
  parameters: {
1696
4387
  type: "object",
1697
- properties: { name: {
1698
- type: "string",
1699
- description: "文件名(不含扩展名也会自动补 .png)。"
1700
- } },
4388
+ properties: {
4389
+ name: {
4390
+ type: "string",
4391
+ description: "文件名(不含扩展名也会自动补 .png)。"
4392
+ },
4393
+ baseline: {
4394
+ type: "string",
4395
+ description: "存为视觉回归基线(基线名)。"
4396
+ }
4397
+ },
1701
4398
  additionalProperties: false
1702
4399
  },
1703
4400
  outputSchema: {
@@ -1707,7 +4404,8 @@ function registerTools(ctx, config) {
1707
4404
  path: { type: "string" },
1708
4405
  bytes: { type: "integer" },
1709
4406
  width: { type: "integer" },
1710
- height: { type: "integer" }
4407
+ height: { type: "integer" },
4408
+ baseline: { type: "string" }
1711
4409
  },
1712
4410
  required: [
1713
4411
  "path",
@@ -1909,13 +4607,23 @@ function registerTools(ctx, config) {
1909
4607
  }));
1910
4608
  register(definePlainTool({
1911
4609
  name: "browser_download",
1912
- description: "获取页面上最近触发的下载(如点击下载链接后),保存到工作区 .dsh-browser/downloads/ 并返回路径。index 可选,默认最近一次;浏览器会话关闭后下载记录丢失。",
4610
+ description: "获取页面上最近触发的下载(如点击下载链接后),保存到工作区 .dsh-browser/downloads/ 并返回路径。index 可选,默认最近一次;wait=true 时先轮询等待新下载出现(以调用时刻为基线,默认等 10 秒),避免 browser_wait 盲等;浏览器会话关闭后下载记录丢失。",
1913
4611
  parameters: {
1914
4612
  type: "object",
1915
- properties: { index: {
1916
- type: "integer",
1917
- description: "下载记录序号(0 起,默认最近)。"
1918
- } },
4613
+ properties: {
4614
+ index: {
4615
+ type: "integer",
4616
+ description: "下载记录序号(0 起,默认最近)。"
4617
+ },
4618
+ wait: {
4619
+ type: "boolean",
4620
+ description: "等待新下载出现再取,默认 false。"
4621
+ },
4622
+ timeoutMs: {
4623
+ type: "integer",
4624
+ description: "wait 超时毫秒,默认 10000,上限 60000。"
4625
+ }
4626
+ },
1919
4627
  additionalProperties: false
1920
4628
  },
1921
4629
  outputSchema: {
@@ -2176,32 +4884,325 @@ function registerTools(ctx, config) {
2176
4884
  items: { type: "object" }
2177
4885
  }
2178
4886
  },
2179
- required: ["current", "profiles"]
4887
+ required: ["current", "profiles"]
4888
+ },
4889
+ render: (args, value) => {
4890
+ if (args.action === "use") return `Switched to profile "${value.current}"`;
4891
+ return value.profiles.map((p) => `${p.name === value.current ? "▶" : " "} ${p.name}${p.open ? ` (open, ${p.tabs} tabs${p.persistent ? ", persistent" : ""})` : " (closed)"}${p.userDataDir ? ` → ${p.userDataDir}` : ""}`).join("\n") || "(no profiles configured)";
4892
+ },
4893
+ timeoutMs: timeout(),
4894
+ execute: (args) => profileTool(config, args)
4895
+ }));
4896
+ register(definePlainTool({
4897
+ name: "browser_wait_for_login",
4898
+ description: "等待人工登录完成。agent 在登录页调用本工具后,提示用户在浏览器窗口中完成登录(有头模式 headless:false 时用户可直接操作;配 userDataDir 登录态会持久化)。成功条件三选一:successSelector(登录后出现的元素,如 '#avatar')或 successUrl(登录后 URL 正则,如 'https://\\\\.app\\\\.example\\\\.com')都不给则 URL 发生变化即视为完成。轮询间隔 2 秒,超时返回 loggedIn:false。",
4899
+ parameters: {
4900
+ type: "object",
4901
+ properties: {
4902
+ timeoutMs: {
4903
+ type: "integer",
4904
+ description: "等待上限(毫秒,默认 120000,上限 600000)。"
4905
+ },
4906
+ successSelector: {
4907
+ type: "string",
4908
+ description: "登录成功后应出现的 CSS 选择器。"
4909
+ },
4910
+ successUrl: {
4911
+ type: "string",
4912
+ description: "登录成功后 URL 应匹配的正则。"
4913
+ }
4914
+ },
4915
+ additionalProperties: false
4916
+ },
4917
+ outputSchema: {
4918
+ type: "object",
4919
+ additionalProperties: false,
4920
+ properties: {
4921
+ loggedIn: { type: "boolean" },
4922
+ url: { type: "string" },
4923
+ title: { type: "string" }
4924
+ },
4925
+ required: [
4926
+ "loggedIn",
4927
+ "url",
4928
+ "title"
4929
+ ]
4930
+ },
4931
+ render: (_args, value) => value.loggedIn ? `登录完成: ${value.url}` : `等待登录超时,仍在: ${value.url}`,
4932
+ timeoutMs: timeout(),
4933
+ execute: (args) => waitForLoginTool(config, args)
4934
+ }));
4935
+ register(definePlainTool({
4936
+ name: "browser_pause_for_human",
4937
+ description: "人工接管等待:agent 把控制权暂时交还给用户,适用于验证码/短信确认/二次复核/敏感步骤人工兜底等场景。调用后提示(instruction)会显示在界面右下角实时画面面板,等待用户在浏览器窗口中完成操作。完成条件三选一:successSelector(完成后出现的元素)、successUrl(完成后 URL 匹配的正则),都不给则 URL 或页面内容发生变化即视为完成。轮询间隔 2 秒;超时不抛错,返回 completed:false,agent 可再次调用或改走其他路径。",
4938
+ parameters: {
4939
+ type: "object",
4940
+ properties: {
4941
+ instruction: {
4942
+ type: "string",
4943
+ description: "给用户看的提示语,如「请在浏览器中输入短信验证码」。"
4944
+ },
4945
+ timeoutMs: {
4946
+ type: "integer",
4947
+ description: "等待上限(毫秒,默认 300000,下限 1000,上限 600000)。"
4948
+ },
4949
+ successSelector: {
4950
+ type: "string",
4951
+ description: "完成后应出现的 CSS 选择器。"
4952
+ },
4953
+ successUrl: {
4954
+ type: "string",
4955
+ description: "完成后 URL 应匹配的正则。"
4956
+ }
4957
+ },
4958
+ required: ["instruction"],
4959
+ additionalProperties: false
4960
+ },
4961
+ outputSchema: {
4962
+ type: "object",
4963
+ additionalProperties: false,
4964
+ properties: {
4965
+ completed: { type: "boolean" },
4966
+ status: {
4967
+ type: "string",
4968
+ enum: ["completed", "timedOut"]
4969
+ },
4970
+ instruction: { type: "string" },
4971
+ detail: { type: "string" },
4972
+ waitedMs: { type: "integer" },
4973
+ url: { type: "string" },
4974
+ title: { type: "string" }
4975
+ },
4976
+ required: [
4977
+ "completed",
4978
+ "status",
4979
+ "instruction",
4980
+ "detail",
4981
+ "waitedMs",
4982
+ "url",
4983
+ "title"
4984
+ ]
4985
+ },
4986
+ render: (_args, value) => value.completed ? `人工操作完成(${value.waitedMs}ms): ${value.url}` : `等待人工操作超时(${value.waitedMs}ms): ${value.instruction} — ${value.detail}`,
4987
+ timeoutMs: timeout(6e5),
4988
+ execute: (args) => pauseForHumanTool(config, args)
4989
+ }));
4990
+ register(definePlainTool({
4991
+ name: "browser_confirm",
4992
+ description: "敏感操作审批:在执行提交/删除/支付等不可逆或影响线上数据的动作前调用,审批请求(含 reason 与将执行的操作)会推送到界面右下角实时画面面板,等待用户点「允许/拒绝」。用户允许时返回 decision:'allowed';用户拒绝或等待超时(默认 60s,fail-closed)**抛错**,live 调用与 replay/batch 步骤统一按失败处理。confirmMode:'manual' 时 systemPrompt 会要求所有敏感动作前必须先过本工具。",
4993
+ parameters: {
4994
+ type: "object",
4995
+ properties: {
4996
+ reason: {
4997
+ type: "string",
4998
+ description: "给用户看的审批理由:要执行什么操作、影响是什么。"
4999
+ },
5000
+ tool: {
5001
+ type: "string",
5002
+ description: "可选:即将执行的工具名,如 'browser_click'。"
5003
+ },
5004
+ args: {
5005
+ type: "object",
5006
+ description: "可选:即将执行的工具参数(如 { selector: '#submit' }),供面板展示。"
5007
+ },
5008
+ timeoutMs: {
5009
+ type: "integer",
5010
+ description: "等待决策上限(毫秒,默认取配置 confirmTimeoutMs=60000,下限 1000,上限 600000)。"
5011
+ }
5012
+ },
5013
+ required: ["reason"],
5014
+ additionalProperties: false
5015
+ },
5016
+ outputSchema: {
5017
+ type: "object",
5018
+ additionalProperties: false,
5019
+ properties: {
5020
+ decision: {
5021
+ type: "string",
5022
+ enum: ["allowed"]
5023
+ },
5024
+ id: { type: "string" },
5025
+ reason: { type: "string" },
5026
+ waitedMs: { type: "integer" },
5027
+ detail: { type: "string" }
5028
+ },
5029
+ required: [
5030
+ "decision",
5031
+ "id",
5032
+ "reason",
5033
+ "waitedMs",
5034
+ "detail"
5035
+ ]
5036
+ },
5037
+ render: (_args, value) => `用户允许执行(${value.waitedMs}ms): ${value.reason}`,
5038
+ timeoutMs: timeout(6e5),
5039
+ execute: (args) => confirmTool(config, args)
5040
+ }));
5041
+ register(definePlainTool({
5042
+ name: "browser_find",
5043
+ description: "按名称/id/data-testid 语义定位页面元素,只返回命中的 ref + 区域上下文,不输出全量 aria 树 —— 在大页面定位单个操作目标时比 browser_snapshot { mode: \"aria\" } 省 95% 以上 token。返回的 ref 可直接传给 browser_click / browser_type / browser_select / browser_press。默认包含匹配(query 是名称子串,忽略大小写;exact:true 精确匹配);scope 限定查找容器;多命中全部返回并附 ancestors 区域链供二次定位;零命中不抛错,返回最相近的 suggestions 候选。",
5044
+ parameters: {
5045
+ type: "object",
5046
+ properties: {
5047
+ query: {
5048
+ type: "string",
5049
+ description: "要找的元素名称(如「提交」「登录」),或元素 id / data-testid。"
5050
+ },
5051
+ exact: {
5052
+ type: "boolean",
5053
+ description: "true 时名称需完全相等(默认 false 包含匹配)。"
5054
+ },
5055
+ scope: {
5056
+ type: "string",
5057
+ description: "可选:限定查找的容器 CSS 选择器(如 '#login-form')。"
5058
+ }
5059
+ },
5060
+ required: ["query"],
5061
+ additionalProperties: false
5062
+ },
5063
+ outputSchema: {
5064
+ type: "object",
5065
+ additionalProperties: false,
5066
+ properties: {
5067
+ found: { type: "integer" },
5068
+ matches: {
5069
+ type: "array",
5070
+ items: {
5071
+ type: "object",
5072
+ properties: {
5073
+ ref: { type: "string" },
5074
+ role: { type: "string" },
5075
+ name: { type: "string" },
5076
+ ancestors: {
5077
+ type: "array",
5078
+ items: { type: "string" }
5079
+ }
5080
+ },
5081
+ required: [
5082
+ "ref",
5083
+ "role",
5084
+ "name",
5085
+ "ancestors"
5086
+ ],
5087
+ additionalProperties: false
5088
+ }
5089
+ },
5090
+ suggestions: {
5091
+ type: "array",
5092
+ items: {
5093
+ type: "object",
5094
+ properties: {
5095
+ role: { type: "string" },
5096
+ name: { type: "string" },
5097
+ score: { type: "integer" }
5098
+ },
5099
+ required: [
5100
+ "role",
5101
+ "name",
5102
+ "score"
5103
+ ],
5104
+ additionalProperties: false
5105
+ }
5106
+ },
5107
+ truncated: { type: "boolean" },
5108
+ scanned: { type: "integer" },
5109
+ url: { type: "string" },
5110
+ title: { type: "string" }
5111
+ },
5112
+ required: [
5113
+ "found",
5114
+ "matches",
5115
+ "scanned",
5116
+ "url",
5117
+ "title"
5118
+ ]
5119
+ },
5120
+ render: (args, value) => {
5121
+ const hits = value.matches.map((m) => `${m.ref}=${m.role} "${m.name}"`).join(", ");
5122
+ if (value.found > 0) return `找到 ${value.found} 个「${args.query}」: ${hits}${value.found > 10 ? " …" : ""}`;
5123
+ const sugg = (value.suggestions ?? []).map((s) => `${s.role} "${s.name}"`).join("、");
5124
+ return `未找到「${args.query}」(扫描 ${value.scanned} 个元素)${sugg ? `;相近候选: ${sugg}` : ""}`;
5125
+ },
5126
+ timeoutMs: timeout(),
5127
+ execute: (args) => findTool(config, args)
5128
+ }));
5129
+ register(definePlainTool({
5130
+ name: "browser_doctor",
5131
+ description: "浏览器环境健康诊断(不启动浏览器):检查可执行文件/channel、playwright-core 可用性、userDataDir 与截图/下载/基线目录写权限、工作区基准目录解析来源、当前会话状态、面板 API 可达性。环境异常(文件落错位置/浏览器起不来)时先跑本工具定位,每项失败检查附修复建议。",
5132
+ parameters: {
5133
+ type: "object",
5134
+ properties: {},
5135
+ additionalProperties: false
5136
+ },
5137
+ outputSchema: {
5138
+ type: "object",
5139
+ additionalProperties: false,
5140
+ properties: {
5141
+ checks: {
5142
+ type: "array",
5143
+ items: {
5144
+ type: "object",
5145
+ properties: {
5146
+ name: { type: "string" },
5147
+ ok: { type: "boolean" },
5148
+ detail: { type: "string" },
5149
+ advice: { type: "string" }
5150
+ },
5151
+ required: [
5152
+ "name",
5153
+ "ok",
5154
+ "detail"
5155
+ ],
5156
+ additionalProperties: false
5157
+ }
5158
+ },
5159
+ summary: {
5160
+ type: "object",
5161
+ properties: {
5162
+ ok: { type: "boolean" },
5163
+ passed: { type: "integer" },
5164
+ failed: { type: "integer" }
5165
+ },
5166
+ required: [
5167
+ "ok",
5168
+ "passed",
5169
+ "failed"
5170
+ ],
5171
+ additionalProperties: false
5172
+ }
5173
+ },
5174
+ required: ["checks", "summary"]
2180
5175
  },
2181
- render: (args, value) => {
2182
- if (args.action === "use") return `Switched to profile "${value.current}"`;
2183
- return value.profiles.map((p) => `${p.name === value.current ? "▶" : " "} ${p.name}${p.open ? ` (open, ${p.tabs} tabs${p.persistent ? ", persistent" : ""})` : " (closed)"}${p.userDataDir ? ` → ${p.userDataDir}` : ""}`).join("\n") || "(no profiles configured)";
5176
+ render: (_args, value) => {
5177
+ const lines = value.checks.map((c) => `${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}${c.advice ? `\n 建议: ${c.advice}` : ""}`);
5178
+ return `诊断 ${value.summary.ok ? "通过" : "发现问题"}(${value.summary.passed}/${value.checks.length})\n${lines.join("\n")}`;
2184
5179
  },
2185
- timeoutMs: timeout(),
2186
- execute: (args) => profileTool(config, args)
5180
+ timeoutMs: timeout(15e3),
5181
+ execute: (args, exec) => doctorTool(config, args, exec)
2187
5182
  }));
2188
5183
  register(definePlainTool({
2189
- name: "browser_wait_for_login",
2190
- description: "等待人工登录完成。agent 在登录页调用本工具后,提示用户在浏览器窗口中完成登录(有头模式 headless:false 时用户可直接操作;配 userDataDir 登录态会持久化)。成功条件三选一:successSelector(登录后出现的元素,如 '#avatar')或 successUrl(登录后 URL 正则,如 'https://\\\\.app\\\\.example\\\\.com')都不给则 URL 发生变化即视为完成。轮询间隔 2 秒,超时返回 loggedIn:false。",
5184
+ name: "browser_context",
5185
+ description: "命名隔离上下文:同一浏览器进程内 cookies/storage 完全隔离的\"标签组\"。action:new(name 可省略自动编号,可选 url 直接打开)/ list / switch(name,激活该组页面,组内页全关则在原 context 新开,登录态仍在)/ close(name,整组关闭)。适合多账号并行验证、需要干净会话的抓取。页注册进普通标签表,所有浏览器工具直接可用;browser_tabs 也能看到/切换这些页。持久模式(userDataDir)不支持,请改用 profiles。",
2191
5186
  parameters: {
2192
5187
  type: "object",
2193
5188
  properties: {
2194
- timeoutMs: {
2195
- type: "integer",
2196
- description: "等待上限(毫秒,默认 120000,上限 600000)。"
5189
+ action: {
5190
+ type: "string",
5191
+ enum: [
5192
+ "list",
5193
+ "new",
5194
+ "switch",
5195
+ "close"
5196
+ ],
5197
+ description: "默认 list。"
2197
5198
  },
2198
- successSelector: {
5199
+ name: {
2199
5200
  type: "string",
2200
- description: "登录成功后应出现的 CSS 选择器。"
5201
+ description: "上下文名(new 自动编号时可省略;switch/close 必填)。"
2201
5202
  },
2202
- successUrl: {
5203
+ url: {
2203
5204
  type: "string",
2204
- description: "登录成功后 URL 应匹配的正则。"
5205
+ description: "new 时可选:新页直接打开的 URL。"
2205
5206
  }
2206
5207
  },
2207
5208
  additionalProperties: false
@@ -2210,23 +5211,49 @@ function registerTools(ctx, config) {
2210
5211
  type: "object",
2211
5212
  additionalProperties: false,
2212
5213
  properties: {
2213
- loggedIn: { type: "boolean" },
5214
+ contexts: {
5215
+ type: "array",
5216
+ items: {
5217
+ type: "object",
5218
+ properties: {
5219
+ name: { type: "string" },
5220
+ pages: { type: "integer" },
5221
+ active: { type: "boolean" },
5222
+ createdAt: { type: "integer" }
5223
+ },
5224
+ required: [
5225
+ "name",
5226
+ "pages",
5227
+ "active",
5228
+ "createdAt"
5229
+ ],
5230
+ additionalProperties: false
5231
+ }
5232
+ },
5233
+ profile: { type: "string" },
5234
+ created: { type: "string" },
5235
+ navigated: { type: "string" },
5236
+ switched: { type: "string" },
5237
+ closed: { type: "string" },
5238
+ remainingTabs: { type: "integer" },
5239
+ pages: { type: "integer" },
2214
5240
  url: { type: "string" },
2215
5241
  title: { type: "string" }
2216
5242
  },
2217
- required: [
2218
- "loggedIn",
2219
- "url",
2220
- "title"
2221
- ]
5243
+ required: []
5244
+ },
5245
+ render: (args, value) => {
5246
+ if (args.action === "new" || value.created) return `新建隔离上下文 "${value.created}"${value.navigated ? ` → ${value.navigated}` : ""}`;
5247
+ if (args.action === "switch" || value.switched) return `切换到上下文 "${value.switched}": ${value.url}`;
5248
+ if (args.action === "close" || value.closed) return `关闭上下文 "${value.closed}"(剩余 ${value.remainingTabs} 标签)`;
5249
+ return value.contexts.map((c) => `${c.active ? "▶" : " "} ${c.name}(${c.pages} 页)`).join("\n") || "(无隔离上下文;browser_context { action: \"new\" } 创建)";
2222
5250
  },
2223
- render: (_args, value) => value.loggedIn ? `登录完成: ${value.url}` : `等待登录超时,仍在: ${value.url}`,
2224
5251
  timeoutMs: timeout(),
2225
- execute: (args) => waitForLoginTool(config, args)
5252
+ execute: (args) => contextTool(config, args)
2226
5253
  }));
2227
5254
  register(definePlainTool({
2228
5255
  name: "browser_elements",
2229
- description: "列出当前页面可交互元素(input/textarea/select/button/链接)的结构化清单:类型、name/id、placeholder、可见文本、可直接用于 browser_click/browser_type 的 CSS 选择器。比 browser_snapshot 更适合定位表单。",
5256
+ description: "列出当前页面可交互元素(input/textarea/select/button/链接)的结构化清单:类型、name/id、关联 label(aria-label/label[for]/form-item)、placeholder、可见文本、可直接用于 browser_click/browser_type/browser_select 的 CSS 选择器。页面有展开的下拉时,dropdownOptions 会列出可见选项。比 browser_snapshot 更适合定位表单。",
2230
5257
  parameters: {
2231
5258
  type: "object",
2232
5259
  properties: { max: {
@@ -2243,11 +5270,23 @@ function registerTools(ctx, config) {
2243
5270
  elements: {
2244
5271
  type: "array",
2245
5272
  items: { type: "object" }
5273
+ },
5274
+ dropdownOptions: {
5275
+ type: "array",
5276
+ items: { type: "string" }
2246
5277
  }
2247
5278
  },
2248
- required: ["count", "elements"]
5279
+ required: [
5280
+ "count",
5281
+ "elements",
5282
+ "dropdownOptions"
5283
+ ]
5284
+ },
5285
+ render: (_args, value) => {
5286
+ const lines = (value.elements ?? []).map((e) => `<${e.tag}${e.type ? ` type=${e.type}` : ""}>${e.name ? ` name=${e.name}` : ""}${e.id ? ` id=${e.id}` : ""}${e.label ? ` label="${e.label}"` : ""}${e.placeholder ? ` ph="${e.placeholder}"` : ""}${e.text ? ` "${e.text.slice(0, 40)}"` : ""}${e.selector ? ` → ${e.selector}` : ""}`);
5287
+ if ((value.dropdownOptions ?? []).length > 0) lines.push(`--- 展开的下拉选项 ---`, ...value.dropdownOptions.map((o) => ` ${o}`));
5288
+ return lines.join("\n") || "(no interactive elements)";
2249
5289
  },
2250
- render: (_args, value) => (value.elements ?? []).map((e) => `<${e.tag}${e.type ? ` type=${e.type}` : ""}>${e.name ? ` name=${e.name}` : ""}${e.id ? ` id=${e.id}` : ""}${e.placeholder ? ` ph="${e.placeholder}"` : ""}${e.text ? ` "${e.text.slice(0, 40)}"` : ""}${e.selector ? ` → ${e.selector}` : ""}`).join("\n") || "(no interactive elements)",
2251
5290
  timeoutMs: timeout(),
2252
5291
  execute: (args) => elementsTool(config, args)
2253
5292
  }));
@@ -2293,13 +5332,17 @@ function registerTools(ctx, config) {
2293
5332
  }));
2294
5333
  register(definePlainTool({
2295
5334
  name: "browser_assert",
2296
- description: "结构化断言(测试核心):condition 在 timeoutMs 内成立则通过(条件格式同 browser_wait_for)。失败时自动截图存证据目录(assert-fail-*.png)并抛错,错误消息含原因与截图路径。适合断言登录成功、关键元素出现、接口返回、文本正确。",
5335
+ description: "结构化断言(测试核心):condition 在 timeoutMs 内成立则通过(条件格式同 browser_wait_for,另支持 ref)。失败时自动截图存证据目录(assert-fail-*.png)并抛错,错误消息含原因与截图路径。另支持视觉回归:传 visual={baseline, threshold?, maxDiffRatio?, mask?} 与基线截图做像素比对(browser_screenshot 的 baseline 建立基线;mask 为忽略的选择器数组,如时钟/广告位),失败时产出当前截图与红标 diff 图。适合断言登录成功、关键元素出现、接口返回、文本正确、页面无视觉回归。",
2297
5336
  parameters: {
2298
5337
  type: "object",
2299
5338
  properties: {
2300
5339
  condition: {
2301
5340
  type: "object",
2302
- description: "断言条件(五选一,同 browser_wait_for)。"
5341
+ description: "断言条件(同 browser_wait_for,另支持 {ref, state})。"
5342
+ },
5343
+ visual: {
5344
+ type: "object",
5345
+ description: "视觉断言:{ baseline: 基线名, threshold?: 像素亮度阈值(默认0.1), maxDiffRatio?: 允许的差异像素比例(默认0.005), mask?: 忽略区域选择器数组 }。"
2303
5346
  },
2304
5347
  timeoutMs: {
2305
5348
  type: "integer",
@@ -2310,7 +5353,6 @@ function registerTools(ctx, config) {
2310
5353
  description: "轮询间隔毫秒,默认 300。"
2311
5354
  }
2312
5355
  },
2313
- required: ["condition"],
2314
5356
  additionalProperties: false
2315
5357
  },
2316
5358
  outputSchema: {
@@ -2319,7 +5361,8 @@ function registerTools(ctx, config) {
2319
5361
  properties: {
2320
5362
  passed: { type: "boolean" },
2321
5363
  url: { type: "string" },
2322
- title: { type: "string" }
5364
+ title: { type: "string" },
5365
+ visual: { type: "object" }
2323
5366
  },
2324
5367
  required: [
2325
5368
  "passed",
@@ -2333,7 +5376,7 @@ function registerTools(ctx, config) {
2333
5376
  }));
2334
5377
  register(definePlainTool({
2335
5378
  name: "browser_network",
2336
- description: "网络记录查询(自动记录页面 XHR/fetch 接口请求,含请求/响应体截断存储,每 profile 上限 500 条)。action:list 列出(可按 urlSubstr/method/status 过滤,limit 默认 50);failed 只列失败/4xx5xx;wait 等待某个 url 正则匹配的接口出现(带 timeoutMs);clear 清空记录。适合断言接口出入参、排查 4xx/5xx。",
5379
+ description: "网络记录查询(自动记录页面 XHR/fetch 接口请求,含请求/响应体截断存储,每 profile 上限 500 条)。action:list 列出(可按 urlSubstr/method/status 过滤,limit 默认 50);failed 只列失败/4xx5xx;wait 等待某个 url 正则匹配的接口出现(带 timeoutMs,命中返回 waitMs);stats 按 host 聚合计数/失败数/平均耗时/状态码分布;clear 清空记录。适合断言接口出入参、排查 4xx/5xx。",
2337
5380
  parameters: {
2338
5381
  type: "object",
2339
5382
  properties: {
@@ -2343,6 +5386,7 @@ function registerTools(ctx, config) {
2343
5386
  "list",
2344
5387
  "failed",
2345
5388
  "wait",
5389
+ "stats",
2346
5390
  "clear"
2347
5391
  ],
2348
5392
  description: "默认 list。"
@@ -2386,15 +5430,20 @@ function registerTools(ctx, config) {
2386
5430
  type: "array",
2387
5431
  items: { type: "object" }
2388
5432
  },
5433
+ stats: {
5434
+ type: "array",
5435
+ items: { type: "object" }
5436
+ },
2389
5437
  matched: { type: "object" },
5438
+ waitMs: { type: "integer" },
2390
5439
  cleared: { type: "boolean" }
2391
5440
  },
2392
5441
  required: []
2393
5442
  },
2394
5443
  render: (_args, value) => {
2395
- const list = value.entries ?? value.failed ?? (value.matched ? [value.matched] : []);
2396
5444
  if (value.cleared) return "网络记录已清空";
2397
- return list.map((e) => {
5445
+ if (value.stats) return value.stats.map((s) => `${s.host} 共${s.total} 失败${s.failed} 均耗时${s.avgMs ?? "-"}ms ${JSON.stringify(s.byStatus)}`).join("\n") || "(无记录)";
5446
+ return (value.entries ?? value.failed ?? (value.matched ? [value.matched] : [])).map((e) => {
2398
5447
  const head = `${e.method} ${e.url} → ${e.failed ? "FAILED" : e.ok ? e.status : e.status + " !"}${e.durationMs !== void 0 ? ` ${e.durationMs}ms` : ""}`;
2399
5448
  const reqBody = e.postData ? `\n req: ${String(e.postData).slice(0, 160)}` : "";
2400
5449
  const resBody = e.body ? `\n res: ${String(e.body).slice(0, 240)}` : "";
@@ -2406,7 +5455,7 @@ function registerTools(ctx, config) {
2406
5455
  }));
2407
5456
  register(definePlainTool({
2408
5457
  name: "browser_record",
2409
- description: "操作录制(回归测试数据源):start 开始录制(后续浏览器工具调用自动记录),stop 结束并返回步数,save <name> 保存最近 100 次调用为命名录制,list/delete 管理。配合 browser_replay 一键回放。",
5458
+ description: "操作录制(回归测试数据源):start 开始录制(后续浏览器工具调用自动记录,selector 步骤附带元素指纹),stop 结束并返回步数,save <name> 保存(start→stop 的录制 buffer 优先,否则取最近 100 次调用;stripReads=true 剔除只读探查步骤;vars 可把指定步骤字段抽成 {{占位符}} 实现参数化回放;upload 引用的本地文件缺失时返回 warnings),list/delete 管理。配合 browser_replay 一键回放。",
2410
5459
  parameters: {
2411
5460
  type: "object",
2412
5461
  properties: {
@@ -2424,6 +5473,14 @@ function registerTools(ctx, config) {
2424
5473
  name: {
2425
5474
  type: "string",
2426
5475
  description: "save/delete 时的录制名。"
5476
+ },
5477
+ stripReads: {
5478
+ type: "boolean",
5479
+ description: "save 时剔除只读步骤(snapshot/elements/eval/network/status/cookies list),默认 false。"
5480
+ },
5481
+ vars: {
5482
+ type: "object",
5483
+ description: "save 时参数化:{ 变量名: \"#步骤号.args.字段\" }(步骤号 1 起),该字段值替换为 {{变量名}} 占位符;replay 时用 vars 传入真实值。"
2427
5484
  }
2428
5485
  },
2429
5486
  additionalProperties: false
@@ -2434,7 +5491,15 @@ function registerTools(ctx, config) {
2434
5491
  properties: {
2435
5492
  recording: { type: "boolean" },
2436
5493
  steps: { type: "integer" },
5494
+ stripped: { type: "integer" },
5495
+ varsApplied: { type: "integer" },
2437
5496
  saved: { type: "string" },
5497
+ source: { type: "string" },
5498
+ file: { type: "string" },
5499
+ warnings: {
5500
+ type: "array",
5501
+ items: { type: "string" }
5502
+ },
2438
5503
  recordings: {
2439
5504
  type: "array",
2440
5505
  items: { type: "object" }
@@ -2448,7 +5513,12 @@ function registerTools(ctx, config) {
2448
5513
  render: (args, value) => {
2449
5514
  if (args.action === "start") return "开始录制(后续浏览器调用将记录)";
2450
5515
  if (args.action === "stop") return `录制结束,共 ${value.steps} 步(browser_record save <name> 保存)`;
2451
- if (args.action === "save") return `已保存录制 "${value.saved}"(${value.steps} 步)`;
5516
+ if (args.action === "save") {
5517
+ const warn = (value.warnings ?? []).length > 0 ? `\n⚠️ ${value.warnings.join("\n⚠️ ")}` : "";
5518
+ const stripped = typeof value.stripped === "number" ? `(已剔除 ${value.stripped} 个只读步骤)` : "";
5519
+ const vars = typeof value.varsApplied === "number" ? `(参数化 ${value.varsApplied} 个变量)` : "";
5520
+ return `已保存录制 "${value.saved}"(${value.steps} 步,来源: ${value.source === "recording" ? "start→stop 录制" : "最近操作轨迹"})${stripped}${vars}${warn}`;
5521
+ }
2452
5522
  if (args.action === "delete") return value.deleted ? `已删除 "${value.name}"` : `无此录制 "${value.name}"`;
2453
5523
  return (value.recordings ?? []).map((r) => `"${r.name}"(${r.steps} 步): ${r.preview}`).join("\n") || "(无录制)";
2454
5524
  },
@@ -2457,7 +5527,7 @@ function registerTools(ctx, config) {
2457
5527
  }));
2458
5528
  register(definePlainTool({
2459
5529
  name: "browser_replay",
2460
- description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。回放中的调用会记入操作轨迹。",
5530
+ description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。选择器自愈:录制时保存了元素指纹的步骤,原 selector 失败时自动按 testid→role+name→text→id 降级重试并在结果中标 healed(建议事后修复录制选择器)。参数化:录制含 {{占位符}} 时用 vars 注入;data 传对象数组或 dataFile 传 CSV 路径(首行为变量名)可逐行数据驱动回放。运行结果自动记入回归历史(browser_report 可导出报告)。",
2461
5531
  parameters: {
2462
5532
  type: "object",
2463
5533
  properties: {
@@ -2465,6 +5535,76 @@ function registerTools(ctx, config) {
2465
5535
  type: "string",
2466
5536
  description: "已保存录制名(与 steps 二选一)。"
2467
5537
  },
5538
+ steps: {
5539
+ type: "array",
5540
+ items: { type: "object" },
5541
+ description: "步骤数组 [{tool, args}](可含 meta.hint 指纹)。"
5542
+ },
5543
+ failFast: {
5544
+ type: "boolean",
5545
+ description: "遇错即停,默认 true(data 模式为行内 failFast,不中断后续行)。"
5546
+ },
5547
+ vars: {
5548
+ type: "object",
5549
+ description: "{{占位符}} 的注入值,如 { username: \"a@x.com\" }。"
5550
+ },
5551
+ data: {
5552
+ type: "array",
5553
+ items: { type: "object" },
5554
+ description: "数据集:每行一个对象,逐行回放一次。"
5555
+ },
5556
+ dataFile: {
5557
+ type: "string",
5558
+ description: "数据集 CSV 文件(工作区相对路径,首行为变量名)。"
5559
+ }
5560
+ },
5561
+ additionalProperties: false
5562
+ },
5563
+ outputSchema: {
5564
+ type: "object",
5565
+ additionalProperties: false,
5566
+ properties: {
5567
+ source: { type: "string" },
5568
+ total: { type: "integer" },
5569
+ passed: { type: "integer" },
5570
+ failed: { type: "integer" },
5571
+ healedCount: { type: "integer" },
5572
+ stoppedAt: { type: "integer" },
5573
+ results: {
5574
+ type: "array",
5575
+ items: { type: "object" }
5576
+ },
5577
+ summary: { type: "string" },
5578
+ rows: { type: "integer" },
5579
+ passedRows: { type: "integer" },
5580
+ failedRows: { type: "integer" },
5581
+ runs: {
5582
+ type: "array",
5583
+ items: { type: "object" }
5584
+ }
5585
+ },
5586
+ required: [
5587
+ "source",
5588
+ "total",
5589
+ "passed",
5590
+ "failed",
5591
+ "healedCount",
5592
+ "results",
5593
+ "summary"
5594
+ ]
5595
+ },
5596
+ render: (_args, value) => {
5597
+ return value.runs !== void 0 ? `${value.summary}\n${value.runs.map((r) => `${(r.failed ?? 0) === 0 ? "✓" : "✗"} 行${r.row}${r.error ? ` — ${String(r.error).slice(0, 100)}` : ` ${r.passed}/${r.total}`}`).join("\n")}` : `${value.summary}\n${(value.results ?? []).map((r) => `${r.ok ? "✓" : "✗"} #${r.index} ${r.tool}${r.healed ? ` [自愈:${r.via}]` : ""}${r.error ? ` — ${String(r.error).slice(0, 120)}` : ""}`).join("\n")}`;
5598
+ },
5599
+ timeoutMs: timeout(),
5600
+ execute: (args, exec) => replayTool(args, stepImpls, config, exec)
5601
+ }));
5602
+ register(definePlainTool({
5603
+ name: "browser_batch",
5604
+ description: "动作批处理:一次调用顺序执行多个浏览器步骤 [{tool, args}](内核同 browser_replay),减少「点击→等待→输入→回车」类固定小序列的往返。failFast 默认 true(遇错停);步骤内禁止嵌套 browser_batch/browser_replay/browser_record;批整体在操作轨迹/录制里记为单条步骤(可录制、可回放)。",
5605
+ parameters: {
5606
+ type: "object",
5607
+ properties: {
2468
5608
  steps: {
2469
5609
  type: "array",
2470
5610
  items: { type: "object" },
@@ -2475,16 +5615,17 @@ function registerTools(ctx, config) {
2475
5615
  description: "遇错即停,默认 true。"
2476
5616
  }
2477
5617
  },
5618
+ required: ["steps"],
2478
5619
  additionalProperties: false
2479
5620
  },
2480
5621
  outputSchema: {
2481
5622
  type: "object",
2482
5623
  additionalProperties: false,
2483
5624
  properties: {
2484
- source: { type: "string" },
2485
5625
  total: { type: "integer" },
2486
5626
  passed: { type: "integer" },
2487
5627
  failed: { type: "integer" },
5628
+ healedCount: { type: "integer" },
2488
5629
  stoppedAt: { type: "integer" },
2489
5630
  results: {
2490
5631
  type: "array",
@@ -2493,17 +5634,155 @@ function registerTools(ctx, config) {
2493
5634
  summary: { type: "string" }
2494
5635
  },
2495
5636
  required: [
2496
- "source",
2497
5637
  "total",
2498
5638
  "passed",
2499
5639
  "failed",
5640
+ "healedCount",
2500
5641
  "results",
2501
5642
  "summary"
2502
5643
  ]
2503
5644
  },
2504
5645
  render: (_args, value) => `${value.summary}\n${(value.results ?? []).map((r) => `${r.ok ? "✓" : "✗"} #${r.index} ${r.tool}${r.error ? ` — ${String(r.error).slice(0, 120)}` : ""}`).join("\n")}`,
2505
5646
  timeoutMs: timeout(),
2506
- execute: (args) => replayTool(args, stepImpls)
5647
+ execute: (args) => batchTool(args, stepImpls)
5648
+ }));
5649
+ register(definePlainTool({
5650
+ name: "browser_extract",
5651
+ description: "规则化结构化抽取(网站信息获取):fields 定义抽取结构 [{name, selector, attr?, multiple?, fields?}](attr 缺省取可见文本,可指定 href/src 等属性;multiple=true 收集所有命中;fields 嵌套可在行内抽子字段);next 传翻页选择器自动翻页聚合(maxPages 上限,next 消失/禁用即停);format 支持 json/csv/jsonl/markdown,file 落盘到工作区(自动按扩展名推断格式,拒绝路径穿越)。语义化抽取(如「总结要点」)不在此工具范围——用 browser_snapshot 读文本后交 jev_extract。",
5652
+ parameters: {
5653
+ type: "object",
5654
+ properties: {
5655
+ fields: {
5656
+ type: "array",
5657
+ items: { type: "object" },
5658
+ description: "抽取字段定义 [{name, selector, attr?, multiple?, fields?}]。"
5659
+ },
5660
+ next: {
5661
+ type: "string",
5662
+ description: "翻页「下一页」选择器(可选)。"
5663
+ },
5664
+ maxPages: {
5665
+ type: "integer",
5666
+ description: "翻页上限,默认 1,上限 100。"
5667
+ },
5668
+ waitAfterMs: {
5669
+ type: "integer",
5670
+ description: "翻页后等待毫秒,默认 800。"
5671
+ },
5672
+ maxRows: {
5673
+ type: "integer",
5674
+ description: "聚合行数上限,默认 1000。"
5675
+ },
5676
+ format: {
5677
+ type: "string",
5678
+ enum: [
5679
+ "json",
5680
+ "csv",
5681
+ "jsonl",
5682
+ "markdown"
5683
+ ],
5684
+ description: "序列化格式(默认 json;file 有扩展名时自动推断)。"
5685
+ },
5686
+ file: {
5687
+ type: "string",
5688
+ description: "落盘路径(工作区相对或绝对,限工作区内)。"
5689
+ }
5690
+ },
5691
+ required: ["fields"],
5692
+ additionalProperties: false
5693
+ },
5694
+ outputSchema: {
5695
+ type: "object",
5696
+ additionalProperties: false,
5697
+ properties: {
5698
+ rows: {
5699
+ type: "array",
5700
+ items: { type: "object" }
5701
+ },
5702
+ count: { type: "integer" },
5703
+ pages: { type: "integer" },
5704
+ format: { type: "string" },
5705
+ truncated: { type: "boolean" },
5706
+ file: { type: "string" },
5707
+ bytes: { type: "integer" },
5708
+ url: { type: "string" },
5709
+ title: { type: "string" }
5710
+ },
5711
+ required: [
5712
+ "rows",
5713
+ "count",
5714
+ "pages",
5715
+ "format",
5716
+ "truncated",
5717
+ "url",
5718
+ "title"
5719
+ ]
5720
+ },
5721
+ render: (_args, value) => `抽取 ${value.count} 行 / ${value.pages} 页${value.truncated ? "(已截断)" : ""}${value.file ? ` → ${value.file} (${value.bytes} bytes)` : ""}`,
5722
+ timeoutMs: timeout(),
5723
+ execute: (args, exec) => extractTool(config, args, exec)
5724
+ }));
5725
+ register(definePlainTool({
5726
+ name: "browser_report",
5727
+ description: "回归运行报告:每次 browser_replay / browser_batch 的结果自动记入运行历史。action=list 列出最近运行;show 查看单条详情(runId 省略取最近一条);export 聚合生成报告落盘工作区(format=markdown|html,含运行趋势、失败步骤 Top、自愈步骤清单,source 可只看某个录制)。配合 DSH 的 schedule 能力可实现定时回归+定期报告。",
5728
+ parameters: {
5729
+ type: "object",
5730
+ properties: {
5731
+ action: {
5732
+ type: "string",
5733
+ enum: [
5734
+ "list",
5735
+ "show",
5736
+ "export"
5737
+ ],
5738
+ description: "默认 list。"
5739
+ },
5740
+ runId: {
5741
+ type: "string",
5742
+ description: "show 时的运行 id(省略取最近一条)。"
5743
+ },
5744
+ format: {
5745
+ type: "string",
5746
+ enum: ["markdown", "html"],
5747
+ description: "export 格式,默认 markdown。"
5748
+ },
5749
+ source: {
5750
+ type: "string",
5751
+ description: "export 时只聚合该来源(录制名)的运行。"
5752
+ },
5753
+ file: {
5754
+ type: "string",
5755
+ description: "export 落盘路径(工作区内,默认 reports/regression-<日期>.md)。"
5756
+ },
5757
+ limit: {
5758
+ type: "integer",
5759
+ description: "list/export 聚合的运行条数上限,list 默认 20、export 默认 50。"
5760
+ }
5761
+ },
5762
+ additionalProperties: false
5763
+ },
5764
+ outputSchema: {
5765
+ type: "object",
5766
+ additionalProperties: false,
5767
+ properties: {
5768
+ runs: {},
5769
+ run: { type: "object" },
5770
+ file: { type: "string" },
5771
+ bytes: { type: "integer" },
5772
+ format: { type: "string" }
5773
+ },
5774
+ required: []
5775
+ },
5776
+ render: (args, value) => {
5777
+ if (args.action === "export") return `已导出 ${value.runs} 次运行 → ${value.file} (${value.bytes} bytes, ${value.format})`;
5778
+ if (args.action === "show") {
5779
+ const r = value.run ?? {};
5780
+ return `${r.source}${r.row !== void 0 ? ` 行${r.row}` : ""}: ${r.passed}/${r.total} 通过,自愈 ${r.healedCount},runId=${r.runId}`;
5781
+ }
5782
+ return (value.runs ?? []).map((r) => `${r.failed === 0 ? "✓" : "✗"} ${new Date(r.ts).toLocaleString("zh-CN", { hour12: false })} ${r.source}${r.row !== void 0 ? ` 行${r.row}` : ""}: ${r.passed}/${r.total}${r.healedCount > 0 ? ` (自愈${r.healedCount})` : ""}`).join("\n") || "(暂无运行记录)";
5783
+ },
5784
+ timeoutMs: timeout(),
5785
+ execute: (args, exec) => reportTool(config, args, exec)
2507
5786
  }));
2508
5787
  }
2509
5788
  //#endregion
@@ -2518,7 +5797,10 @@ function registerBrowserApi(ctx, config, base) {
2518
5797
  handler: async (req, res) => {
2519
5798
  const path = urlPath(req);
2520
5799
  if (path === `${base}/status`) {
2521
- sendJson(res, 200, await statusTool());
5800
+ sendJson(res, 200, {
5801
+ ...await statusTool(),
5802
+ pause: currentPause()
5803
+ });
2522
5804
  return;
2523
5805
  }
2524
5806
  if (path === `${base}/history`) {
@@ -2535,6 +5817,48 @@ function registerBrowserApi(ctx, config, base) {
2535
5817
  });
2536
5818
  return;
2537
5819
  }
5820
+ if (path === `${base}/runs/latest`) {
5821
+ sendJson(res, 200, { run: latestRun() });
5822
+ return;
5823
+ }
5824
+ if (path === `${base}/runs/report`) {
5825
+ let source = "";
5826
+ try {
5827
+ source = new URL(req.url ?? "/", "http://x").searchParams.get("source") ?? "";
5828
+ } catch {}
5829
+ if (source === "") source = latestRun()?.source ?? "";
5830
+ const html = renderHtmlReport(listRuns(50).filter((r) => source === "" ? true : r.source === source), source === "" ? void 0 : source);
5831
+ res.writeHead(200, {
5832
+ "content-type": "text/html; charset=utf-8",
5833
+ "cache-control": "no-store",
5834
+ "content-length": Buffer.byteLength(html)
5835
+ });
5836
+ res.end(html);
5837
+ return;
5838
+ }
5839
+ if (path === `${base}/pick`) {
5840
+ if ((req.method ?? "GET") === "POST") {
5841
+ const page = activePage();
5842
+ if (page === null || page.isClosed()) {
5843
+ sendText(res, 409, "browser not open");
5844
+ return;
5845
+ }
5846
+ try {
5847
+ await page.evaluate(pickElementInjector);
5848
+ sendJson(res, 200, { started: true });
5849
+ } catch (error) {
5850
+ sendText(res, 500, `pick start failed: ${error instanceof Error ? error.message : String(error)}`);
5851
+ }
5852
+ return;
5853
+ }
5854
+ const page = activePage();
5855
+ if (page === null || page.isClosed()) {
5856
+ sendJson(res, 200, { pick: null });
5857
+ return;
5858
+ }
5859
+ sendJson(res, 200, { pick: await takePickResult(page) });
5860
+ return;
5861
+ }
2538
5862
  if (path === `${base}/recordings`) {
2539
5863
  if ((req.method ?? "GET") === "POST") {
2540
5864
  const body = await readJsonBody(req).catch(() => void 0);
@@ -2565,6 +5889,25 @@ function registerBrowserApi(ctx, config, base) {
2565
5889
  sendJson(res, 200, detail);
2566
5890
  return;
2567
5891
  }
5892
+ if (path === `${base}/confirm`) {
5893
+ if ((req.method ?? "GET") === "POST") {
5894
+ const body = await readJsonBody(req).catch(() => void 0);
5895
+ const id = typeof body?.id === "string" ? body.id : "";
5896
+ const decision = body?.decision === "allowed" || body?.decision === "denied" ? body.decision : void 0;
5897
+ if (!id || decision === void 0) {
5898
+ sendText(res, 400, "id and decision (allowed|denied) required");
5899
+ return;
5900
+ }
5901
+ sendJson(res, 200, {
5902
+ resolved: resolveConfirm(id, decision),
5903
+ id,
5904
+ decision
5905
+ });
5906
+ return;
5907
+ }
5908
+ sendJson(res, 200, { confirm: getPendingConfirm() });
5909
+ return;
5910
+ }
2568
5911
  if (path === `${base}/screenshot`) {
2569
5912
  const page = activePage();
2570
5913
  if (page === null || page.isClosed()) {
@@ -2861,6 +6204,7 @@ const SCHEMA = {
2861
6204
  profiles: { type: "any" },
2862
6205
  screenshotDir: { type: "string" },
2863
6206
  downloadDir: { type: "string" },
6207
+ workspaceDir: { type: "string" },
2864
6208
  screenshotMaxAgeDays: {
2865
6209
  type: "number",
2866
6210
  min: 0
@@ -2882,6 +6226,32 @@ const SCHEMA = {
2882
6226
  timeoutMs: {
2883
6227
  type: "number",
2884
6228
  min: 1
6229
+ },
6230
+ baselineDir: { type: "string" },
6231
+ selectorHealing: { type: "boolean" },
6232
+ healTimeoutMs: {
6233
+ type: "number",
6234
+ min: 100
6235
+ },
6236
+ maxRefs: {
6237
+ type: "number",
6238
+ min: 1
6239
+ },
6240
+ maxRows: {
6241
+ type: "number",
6242
+ min: 1
6243
+ },
6244
+ confirmMode: {
6245
+ type: "string",
6246
+ enum: ["off", "manual"]
6247
+ },
6248
+ confirmTimeoutMs: {
6249
+ type: "number",
6250
+ min: 1e3
6251
+ },
6252
+ toolset: {
6253
+ type: "string",
6254
+ enum: ["core", "all"]
2885
6255
  }
2886
6256
  };
2887
6257
  const DEFAULT_CONFIG = {
@@ -2892,13 +6262,22 @@ const DEFAULT_CONFIG = {
2892
6262
  profiles: {},
2893
6263
  screenshotDir: ".dsh-browser/screenshots",
2894
6264
  downloadDir: ".dsh-browser/downloads",
6265
+ workspaceDir: "",
2895
6266
  screenshotMaxAgeDays: 7,
2896
6267
  screenshotMaxCount: 200,
2897
6268
  basePath: "/browser",
2898
6269
  recordBodies: true,
2899
6270
  maxTextChars: 2e4,
2900
6271
  maxLinks: 50,
2901
- timeoutMs: 3e4
6272
+ timeoutMs: 3e4,
6273
+ baselineDir: ".dsh-browser/baselines",
6274
+ selectorHealing: true,
6275
+ healTimeoutMs: 3e3,
6276
+ maxRefs: 200,
6277
+ maxRows: 1e3,
6278
+ confirmMode: "off",
6279
+ confirmTimeoutMs: 6e4,
6280
+ toolset: "all"
2902
6281
  };
2903
6282
  function apply(ctx, rawConfig) {
2904
6283
  const resolved = resolveConfig("dsh-browser-view", SCHEMA, DEFAULT_CONFIG, rawConfig);
@@ -2908,16 +6287,41 @@ function apply(ctx, rawConfig) {
2908
6287
  const loadedRecordings = loadRecordings();
2909
6288
  if (loadedRecordings > 0) ctx.logger?.info?.(`dsh-browser-view: loaded ${loadedRecordings} saved recording(s)`);
2910
6289
  registerTools(ctx, config);
2911
- const sectionDispose = ctx.get("systemPrompt")?.section?.({
6290
+ const systemPrompt = ctx.get("systemPrompt");
6291
+ const confirmGuidance = config.confirmMode === "manual" ? "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。" : "";
6292
+ const sectionDispose = systemPrompt?.section?.({
2912
6293
  name: "tool:browser",
2913
6294
  order: 200,
2914
- text: "浏览器工具操作一个共享浏览器会话,支持多标签页与多配置文件。打开页面后先用 browser_snapshot 读文本与链接、browser_elements 读表单结构,再决定 browser_click / browser_type / browser_form;标签管理用 browser_tabs;下载用 browser_download;登录态用 browser_cookies。做自动化测试时:用 browser_wait_for 显式等待页面稳定/元素出现,用 browser_assert 断言(失败自动截图),用 browser_network 检查接口调用与 4xx/5xx,跑完一轮可用 browser_record save 保存操作序列、browser_replay 回放做回归。遇到需要登录的网站:先 browser_open 打开登录页,告知用户请在浏览器窗口中完成登录,再调用 browser_wait_for_login 等待(可给 successSelector/successUrl);配置 userDataDir 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。"
6295
+ text: (config.toolset === "core" ? "浏览器工具操作一个共享浏览器会话(toolset=core:已精简工具集)。打开页面后用 browser_snapshot 读内容(scope 限定区域、since 增量重读省 token),定位操作元素优先 browser_find(query 传名称,返回 ref);点击/输入/按键/下拉用 browser_click/browser_type/browser_press/browser_select(selector 或 ref);固定小序列用 browser_batch;等待用 browser_wait/browser_wait_for,断言用 browser_assert;接口检查用 browser_network;标签 browser_tabs;下载/上传 browser_download/browser_upload。需要人工:登录用 browser_wait_for_login,验证码/二次确认用 browser_pause_for_human,敏感动作(提交/删除/支付)前用 browser_confirm。环境异常先跑 browser_doctor 诊断。录制/回放/报告/抽取/profile/隔离 context 等工具当前未注册(toolset=core),需要时把配置改为 toolset: all 并重启 DSH。用户可通过界面右下角面板的实时画面查看浏览器。" : "浏览器工具操作一个共享浏览器会话,支持多标签页与多配置文件。打开页面后先用 browser_snapshot 读文本与链接、browser_elements 读表单结构(含关联 label 与展开的下拉选项),再决定 browser_click / browser_type / browser_form;定位操作元素优先用 browser_find(query 传名称,只返回命中 ref+区域上下文,大页面最省 token;零命中会给相近候选),需要全页结构时再用 browser_snapshot { mode: \"aria\" }(可交互元素带 ref=eN,click/type/select/press 直接传 ref 代替 CSS 选择器);下拉选择(原生 select 与 antd/element 组件库)用 browser_select 传 selector/ref + option 文本一步完成;固定小序列(点击→等待→输入→提交)用 browser_batch 一次执行减少往返;标签管理用 browser_tabs;下载用 browser_download(wait=true 可等新下载);登录态用 browser_cookies。做自动化测试时:用 browser_wait_for 显式等待页面稳定/元素出现,用 browser_assert 断言(失败自动截图;visual={baseline} 可做视觉回归,先用 browser_screenshot { baseline } 建基线),用 browser_network 检查接口调用与 4xx/5xx,跑完一轮可用 browser_record save 保存操作序列、browser_replay 回放做回归(选择器失效自动按指纹自愈;vars/data 支持参数化与数据驱动)。每次 replay/batch 都记入运行历史,browser_report list/export 可查看与导出回归报告。抓取结构化数据用 browser_extract(fields 定义 CSS 字段,可翻页,可导出 CSV/JSONL)。遇到需要登录的网站:先 browser_open 打开登录页,告知用户请在浏览器窗口中完成登录,再调用 browser_wait_for_login 等待(可给 successSelector/successUrl);验证码/二次确认等更广的「人补一步」场景用 browser_pause_for_human(提示语会显示在实时画面面板);配置 userDataDir 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。") + confirmGuidance
2915
6296
  });
2916
6297
  if (sectionDispose !== void 0) ctx.effect(() => sectionDispose);
2917
6298
  registerBrowserApi(ctx, config, base);
2918
6299
  const webServer = ctx.get("webServer");
2919
6300
  if (webServer !== void 0) {
2920
6301
  applyEmbed(ctx, webServer, base);
6302
+ const port = webServer.port;
6303
+ setDoctorProbe(port !== void 0 ? async (basePath) => {
6304
+ try {
6305
+ const res = await fetch(`http://127.0.0.1:${port}${basePath}/status`, {
6306
+ signal: AbortSignal.timeout(2e3),
6307
+ cache: "no-store"
6308
+ });
6309
+ return res.ok ? {
6310
+ ok: true,
6311
+ detail: `面板 API 可达(/status → 200,端口 ${port})`
6312
+ } : {
6313
+ ok: false,
6314
+ detail: `面板 API 异常(/status → ${res.status})`,
6315
+ advice: "查看 DSH 日志中 webServer 的注册错误"
6316
+ };
6317
+ } catch (e) {
6318
+ return {
6319
+ ok: false,
6320
+ detail: `本机 127.0.0.1:${port} 请求失败: ${e instanceof Error ? e.message : String(e)}`,
6321
+ advice: "确认 DSH web 端口未被防火墙/代理拦截"
6322
+ };
6323
+ }
6324
+ } : null);
2921
6325
  const disposeConfig = webServer.register({
2922
6326
  kind: "exact",
2923
6327
  path: "/__dsh-browser-view__/config",