dsh-browser-view 0.1.1 → 0.2.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 { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, 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,45 @@ 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
+ }
166
220
  /** 已保存的录制脚本:name → 步骤数组。 */
167
221
  const savedRecordings = /* @__PURE__ */ new Map();
168
222
  /** 当前录制中的 buffer(非空 = 录制中)。 */
169
223
  let recordingSteps = null;
224
+ /** 最近一次 start→stop 的录制结果(save 优先使用;下次 start 时覆盖)。 */
225
+ let lastRecording = null;
170
226
  /** 操作轨迹(全局,记录 currentProfile;上限 500 条)。 */
171
227
  const testLog = [];
172
228
  function isRecording() {
173
229
  return recordingSteps !== null;
174
230
  }
175
231
  function startRecording() {
232
+ lastRecording = null;
176
233
  recordingSteps = [];
177
234
  }
178
235
  function stopRecording() {
179
236
  const steps = recordingSteps ?? [];
180
237
  recordingSteps = null;
238
+ lastRecording = steps;
181
239
  return steps;
182
240
  }
183
- /** 操作轨迹追加(同时写入录制 buffer,排除 record/replay 自身)。 */
241
+ /** 最近一次完整录制(start→stop)的 buffer;从未录制过返回 null。 */
242
+ function lastRecordingOf() {
243
+ return lastRecording;
244
+ }
245
+ /** 操作轨迹追加(同时写入录制 buffer,排除 record/replay 自身)。返回存入的调用对象(供调用方补 meta)。 */
184
246
  function recordCall(call) {
185
247
  testLog.push(call);
186
248
  if (testLog.length > 500) testLog.shift();
@@ -188,6 +250,7 @@ function recordCall(call) {
188
250
  recordingSteps.push(call);
189
251
  if (recordingSteps.length > 500) recordingSteps.shift();
190
252
  }
253
+ return call;
191
254
  }
192
255
  /** 操作轨迹(按时间倒序,最新在前)。 */
193
256
  function testLogOf() {
@@ -286,6 +349,7 @@ function attachPage(page, config) {
286
349
  const profile = currentProfile;
287
350
  page.on("framenavigated", (frame) => {
288
351
  if (frame !== page.mainFrame()) return;
352
+ clearPageRefs(page);
289
353
  const url = frame.url();
290
354
  if (!url || url === "about:blank") return;
291
355
  recordNav(profile, url);
@@ -600,13 +664,14 @@ function loadRecordings() {
600
664
  } catch {}
601
665
  return loaded;
602
666
  }
603
- /** 写一个录制到磁盘(原子替换);返回文件路径。 */
667
+ /** 写一个录制到磁盘(原子替换);返回文件路径。formatVersion 2 起步骤可带 meta(选择器指纹)与 {{var}} 占位符。 */
604
668
  function saveRecordingFile(name, steps) {
605
669
  const dir = recordingsDir();
606
670
  mkdirSync(dir, { recursive: true });
607
671
  const file = join(dir, `${safeName(name)}.json`);
608
672
  const tmp = `${file}.tmp`;
609
673
  writeFileSync(tmp, JSON.stringify({
674
+ formatVersion: 2,
610
675
  steps,
611
676
  savedAt: Date.now()
612
677
  }, null, 2));
@@ -648,7 +713,308 @@ function deleteRecording(name) {
648
713
  return existed;
649
714
  }
650
715
  //#endregion
716
+ //#region lib/types/browser/extract.js
717
+ /** 把一行里的值转成 CSV 单元格文本(引号双写转义,对象 JSON 化)。 */
718
+ function csvCell(value) {
719
+ if (value === null || value === void 0) return "";
720
+ const s = typeof value === "string" ? value : JSON.stringify(value);
721
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
722
+ }
723
+ /** 行的表头:所有行 key 的并集(按首次出现顺序)。 */
724
+ function headerOf(rows) {
725
+ const keys = [];
726
+ for (const row of rows) for (const k of Object.keys(row)) if (!keys.includes(k)) keys.push(k);
727
+ return keys;
728
+ }
729
+ function toCsv(rows) {
730
+ const header = headerOf(rows);
731
+ const lines = [header.map(csvCell).join(",")];
732
+ for (const row of rows) lines.push(header.map((k) => csvCell(row[k])).join(","));
733
+ return lines.join("\n") + "\n";
734
+ }
735
+ function toJsonl(rows) {
736
+ return rows.map((r) => JSON.stringify(r)).join("\n") + (rows.length > 0 ? "\n" : "");
737
+ }
738
+ function toMarkdownTable(rows) {
739
+ const header = headerOf(rows);
740
+ if (header.length === 0) return "(空)\n";
741
+ const cell = (v) => {
742
+ if (v === null || v === void 0) return "";
743
+ return (typeof v === "string" ? v : JSON.stringify(v)).replace(/\|/g, "\\|").replace(/\n/g, " ");
744
+ };
745
+ return [
746
+ `| ${header.join(" | ")} |`,
747
+ `| ${header.map(() => "---").join(" | ")} |`,
748
+ ...rows.map((r) => `| ${header.map((k) => cell(r[k])).join(" | ")} |`)
749
+ ].join("\n") + "\n";
750
+ }
751
+ /** 按格式序列化抽取结果(json 为 pretty JSON 字符串)。 */
752
+ function serializeRows(rows, format) {
753
+ if (format === "csv") return toCsv(rows);
754
+ if (format === "jsonl") return toJsonl(rows);
755
+ if (format === "markdown") return toMarkdownTable(rows);
756
+ return JSON.stringify(rows, null, 2) + "\n";
757
+ }
758
+ /** 由文件扩展名推断序列化格式(显式 format 优先)。 */
759
+ function formatFromFile(file) {
760
+ if (/\.csv$/i.test(file)) return "csv";
761
+ if (/\.jsonl$/i.test(file)) return "jsonl";
762
+ if (/\.(md|markdown)$/i.test(file)) return "markdown";
763
+ if (/\.json$/i.test(file)) return "json";
764
+ return null;
765
+ }
766
+ /**
767
+ * 解析 CSV 文本为行对象数组(首行为表头;支持引号包裹、双引号转义、
768
+ * 逗号/换行内嵌)。供 browser_replay 的 dataFile 使用。
769
+ */
770
+ function parseCsv(text) {
771
+ const rows = [];
772
+ let cur = [];
773
+ let field = "";
774
+ let inQuotes = false;
775
+ let i = 0;
776
+ const pushField = () => {
777
+ cur.push(field);
778
+ field = "";
779
+ };
780
+ const pushRow = () => {
781
+ pushField();
782
+ rows.push(cur);
783
+ cur = [];
784
+ };
785
+ while (i < text.length) {
786
+ const ch = text[i];
787
+ if (inQuotes) {
788
+ if (ch === "\"") {
789
+ if (text[i + 1] === "\"") {
790
+ field += "\"";
791
+ i += 2;
792
+ continue;
793
+ }
794
+ inQuotes = false;
795
+ i++;
796
+ continue;
797
+ }
798
+ field += ch;
799
+ i++;
800
+ continue;
801
+ }
802
+ if (ch === "\"") {
803
+ inQuotes = true;
804
+ i++;
805
+ continue;
806
+ }
807
+ if (ch === ",") {
808
+ pushField();
809
+ i++;
810
+ continue;
811
+ }
812
+ if (ch === "\r") {
813
+ i++;
814
+ continue;
815
+ }
816
+ if (ch === "\n") {
817
+ pushRow();
818
+ i++;
819
+ continue;
820
+ }
821
+ field += ch;
822
+ i++;
823
+ }
824
+ if (field !== "" || cur.length > 0) pushRow();
825
+ const header = rows[0]?.map((h) => h.trim()) ?? [];
826
+ const out = [];
827
+ for (let r = 1; r < rows.length; r++) {
828
+ const cells = rows[r];
829
+ if (cells.length === 1 && cells[0] === "") continue;
830
+ const obj = {};
831
+ header.forEach((h, c) => {
832
+ obj[h] = cells[c] ?? "";
833
+ });
834
+ out.push(obj);
835
+ }
836
+ return out;
837
+ }
838
+ //#endregion
839
+ //#region lib/types/browser/reports.js
840
+ /** 报告存储目录($DSH_HOME/.dsh-browser/reports)。 */
841
+ function reportsDir() {
842
+ const home = process.env.DSH_HOME || join(homedir(), ".dsh");
843
+ return join(home, ".dsh-browser", "reports");
844
+ }
845
+ function runsFile() {
846
+ return join(reportsDir(), "runs.jsonl");
847
+ }
848
+ /** 生成 runId:YYYYMMDD-HHmmss-随机4位。 */
849
+ function makeRunId(ts) {
850
+ const d = new Date(ts);
851
+ const pad = (n) => String(n).padStart(2, "0");
852
+ 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)}`;
853
+ }
854
+ const MAX_LINES = 1e3;
855
+ const MAX_BYTES = 5242880;
856
+ /** 追加一条运行记录;超限时截断旧行(原子替换)。 */
857
+ function appendRun(record) {
858
+ const dir = reportsDir();
859
+ mkdirSync(dir, { recursive: true });
860
+ const file = runsFile();
861
+ appendFileSync(file, JSON.stringify(record) + "\n", "utf8");
862
+ let size = 0;
863
+ try {
864
+ size = statSync(file).size;
865
+ } catch {
866
+ return;
867
+ }
868
+ if (size <= MAX_BYTES) {
869
+ try {
870
+ const lines = readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "");
871
+ if (lines.length <= MAX_LINES) return;
872
+ const kept = lines.slice(-1e3).join("\n") + "\n";
873
+ const tmp = `${file}.tmp`;
874
+ writeFileSync(tmp, kept, "utf8");
875
+ renameSync(tmp, file);
876
+ } catch {}
877
+ return;
878
+ }
879
+ try {
880
+ const kept = readFileSync(file, "utf8").split("\n").filter((l) => l.trim() !== "").slice(-1e3).join("\n") + "\n";
881
+ const tmp = `${file}.tmp`;
882
+ writeFileSync(tmp, kept, "utf8");
883
+ renameSync(tmp, file);
884
+ } catch {}
885
+ }
886
+ /** 读取全部运行记录(按时间倒序);坏行静默跳过。 */
887
+ function listRuns(limit = 50) {
888
+ let lines = [];
889
+ try {
890
+ lines = readFileSync(runsFile(), "utf8").split("\n").filter((l) => l.trim() !== "");
891
+ } catch {
892
+ return [];
893
+ }
894
+ const runs = [];
895
+ for (let i = lines.length - 1; i >= 0 && runs.length < limit; i--) try {
896
+ const r = JSON.parse(lines[i]);
897
+ if (r !== null && typeof r === "object" && typeof r.runId === "string") runs.push(r);
898
+ } catch {}
899
+ return runs;
900
+ }
901
+ /** 最近一次运行(无记录返回 null)。 */
902
+ function latestRun() {
903
+ return listRuns(1)[0] ?? null;
904
+ }
905
+ /** 按 runId 取单条记录。 */
906
+ function getRun(runId) {
907
+ return listRuns(MAX_LINES).find((r) => r.runId === runId) ?? null;
908
+ }
909
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
910
+ const fmtTs = (ts) => new Date(ts).toLocaleString("zh-CN", { hour12: false });
911
+ /** 聚合统计:失败步骤 TopN 与自愈步骤清单(两个渲染器共用)。 */
912
+ function analyze(runs) {
913
+ const failMap = /* @__PURE__ */ new Map();
914
+ const healed = [];
915
+ for (const run of runs) for (const step of run.results) if (!step.ok) {
916
+ const key = `${run.source}|${step.tool}|${step.error ?? ""}`;
917
+ const cur = failMap.get(key);
918
+ if (cur !== void 0) cur.count++;
919
+ else failMap.set(key, {
920
+ source: run.source,
921
+ tool: step.tool,
922
+ error: step.error ?? "",
923
+ count: 1
924
+ });
925
+ } else if (step.healed === true) healed.push({
926
+ ts: run.ts,
927
+ source: run.source,
928
+ tool: step.tool,
929
+ via: step.via ?? "",
930
+ resolved: step.resolved ?? ""
931
+ });
932
+ return {
933
+ failures: [...failMap.values()].sort((a, b) => b.count - a.count).slice(0, 10),
934
+ healed
935
+ };
936
+ }
937
+ /** 生成 Markdown 回归报告。 */
938
+ function renderMarkdownReport(runs, source) {
939
+ const lines = [
940
+ `# ${source !== void 0 && source !== "" ? `回归报告 — ${source}` : "回归报告 — 全部来源"}`,
941
+ "",
942
+ `> 生成时间:${fmtTs(Date.now())} · 共 ${runs.length} 次运行`,
943
+ "",
944
+ "## 运行趋势(最新在前)",
945
+ "",
946
+ "| 时间 | 来源 | 通过/总数 | 失败 | 自愈 | 耗时 |",
947
+ "| --- | --- | --- | --- | --- | --- |"
948
+ ];
949
+ 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 |`);
950
+ const { failures, healed } = analyze(runs);
951
+ lines.push("", "## 失败步骤 Top", "");
952
+ if (failures.length === 0) lines.push("全部通过 🎉");
953
+ else {
954
+ lines.push("| 次数 | 来源 | 步骤 | 错误 |", "| --- | --- | --- | --- |");
955
+ for (const f of failures) lines.push(`| ${f.count} | ${f.source} | ${f.tool} | ${f.error.slice(0, 120).replace(/\|/g, "\\|").replace(/\n/g, " ")} |`);
956
+ }
957
+ if (healed.length > 0) {
958
+ lines.push("", "## 自愈步骤(建议修复录制中的选择器)", "", "| 时间 | 来源 | 步骤 | 自愈方式 | 新选择器 |", "| --- | --- | --- | --- | --- |");
959
+ for (const h of healed.slice(0, 20)) lines.push(`| ${fmtTs(h.ts)} | ${h.source} | ${h.tool} | ${h.via} | \`${h.resolved}\` |`);
960
+ }
961
+ lines.push("");
962
+ return lines.join("\n");
963
+ }
964
+ /** 生成自包含 HTML 回归报告(内联样式,无外部资源)。 */
965
+ function renderHtmlReport(runs, source) {
966
+ const title = source !== void 0 && source !== "" ? `回归报告 — ${source}` : "回归报告 — 全部来源";
967
+ const { failures, healed } = analyze(runs);
968
+ const rowHtml = runs.map((r) => {
969
+ const ok = r.failed === 0;
970
+ 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>`;
971
+ }).join("\n");
972
+ 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");
973
+ 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");
974
+ return `<!doctype html>
975
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>${esc(title)}</title>
976
+ <style>
977
+ body{font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;max-width:960px;margin:24px auto;padding:0 16px;color:#1f2933}
978
+ h1{font-size:20px}h2{font-size:16px;margin-top:28px}
979
+ table{border-collapse:collapse;width:100%;font-size:13px}
980
+ th,td{border:1px solid #d9e2ec;padding:6px 10px;text-align:left;vertical-align:top}
981
+ th{background:#f0f4f8}.ok{color:#0f9d58;font-weight:600}.bad{color:#d64545;font-weight:600}
982
+ code{background:#f0f4f8;padding:1px 4px;border-radius:4px;font-size:12px}
983
+ .meta{color:#7b8794;font-size:13px}
984
+ </style></head><body>
985
+ <h1>${esc(title)}</h1>
986
+ <p class="meta">生成时间:${esc(fmtTs(Date.now()))} · 共 ${runs.length} 次运行</p>
987
+ <h2>运行趋势(最新在前)</h2>
988
+ <table><thead><tr><th>时间</th><th>来源</th><th>通过/总数</th><th>失败</th><th>自愈</th><th>耗时</th></tr></thead>
989
+ <tbody>${rowHtml}</tbody></table>
990
+ <h2>失败步骤 Top</h2>
991
+ <table><thead><tr><th>次数</th><th>来源</th><th>步骤</th><th>错误</th></tr></thead>
992
+ <tbody>${failRows}</tbody></table>
993
+ ${healRows !== "" ? `<h2>自愈步骤(建议修复录制中的选择器)</h2>
994
+ <table><thead><tr><th>时间</th><th>来源</th><th>步骤</th><th>自愈方式</th><th>新选择器</th></tr></thead>
995
+ <tbody>${healRows}</tbody></table>` : ""}
996
+ </body></html>
997
+ `;
998
+ }
999
+ //#endregion
651
1000
  //#region lib/types/browser/tools.js
1001
+ /** 读取 PNG 文件头(IHDR)中的宽高,不解码像素(导出供测试)。 */
1002
+ function pngSize(buf) {
1003
+ if (buf.length < 24 || buf.readUInt32BE(0) !== 2303741511) throw new Error("not a PNG file");
1004
+ return {
1005
+ width: buf.readUInt32BE(16),
1006
+ height: buf.readUInt32BE(20)
1007
+ };
1008
+ }
1009
+ /** 按缩放比例换算 mask 矩形(导出供测试;与页内比对共用同一套坐标语义)。 */
1010
+ function scaleRects(rects, scale) {
1011
+ return rects.map((r) => ({
1012
+ x: Math.round(r.x * scale),
1013
+ y: Math.round(r.y * scale),
1014
+ width: Math.round(r.width * scale),
1015
+ height: Math.round(r.height * scale)
1016
+ }));
1017
+ }
652
1018
  async function openTool(config, args) {
653
1019
  const page = await getPage(config);
654
1020
  await page.goto(String(args.url), {
@@ -661,53 +1027,338 @@ async function openTool(config, args) {
661
1027
  status: "ok"
662
1028
  };
663
1029
  }
1030
+ /**
1031
+ * 快照文本压缩(导出供测试):
1032
+ * - 去掉首尾空白行;
1033
+ * - 连续空白行折叠为 1 行;
1034
+ * - 连续重复的非空行(表格空单元格/占位行产生的噪音)折叠为「首行 + …(重复 N 行)」。
1035
+ * 只折叠完全相同的相邻行,不重排、不删改非重复内容。
1036
+ */
1037
+ function compressText(text) {
1038
+ const lines = text.split("\n");
1039
+ const out = [];
1040
+ let blankRun = 0;
1041
+ for (let i = 0; i < lines.length; i++) {
1042
+ const trimmed = lines[i].trim();
1043
+ if (trimmed === "") {
1044
+ blankRun++;
1045
+ if (blankRun === 1 && out.length > 0) out.push("");
1046
+ continue;
1047
+ }
1048
+ blankRun = 0;
1049
+ let dup = 0;
1050
+ while (i + dup + 1 < lines.length && lines[i + dup + 1].trim() === trimmed) dup++;
1051
+ out.push(lines[i]);
1052
+ if (dup > 0) {
1053
+ out.push(`…(重复 ${dup} 行)`);
1054
+ i += dup;
1055
+ }
1056
+ }
1057
+ while (out.length > 0 && out[out.length - 1] === "") out.pop();
1058
+ return out.join("\n");
1059
+ }
1060
+ /**
1061
+ * 页内 ARIA 树构建(自包含函数,page.evaluate 序列化注入;禁止引用模块级变量)。
1062
+ * 遍历 DOM:结构角色(navigation/main/form/table/heading…)直接打印;
1063
+ * 可交互元素分配 ref=e1..eN 并附稳定 CSS 路径;无语义容器(div/span)穿透。
1064
+ * 输出形如 `- button "登录" [ref=e3]` 的缩进树,供纯文本模型定位操作目标。
1065
+ */
1066
+ function buildAriaTree(maxRefs) {
1067
+ const INTERACTIVE = /* @__PURE__ */ new Set([
1068
+ "link",
1069
+ "button",
1070
+ "textbox",
1071
+ "checkbox",
1072
+ "radio",
1073
+ "combobox",
1074
+ "listbox",
1075
+ "searchbox",
1076
+ "slider",
1077
+ "spinbutton",
1078
+ "switch",
1079
+ "tab",
1080
+ "menuitem",
1081
+ "option",
1082
+ "treeitem"
1083
+ ]);
1084
+ const STRUCTURAL = /* @__PURE__ */ new Set([
1085
+ "navigation",
1086
+ "main",
1087
+ "banner",
1088
+ "contentinfo",
1089
+ "form",
1090
+ "search",
1091
+ "complementary",
1092
+ "region",
1093
+ "dialog",
1094
+ "alert",
1095
+ "table",
1096
+ "list",
1097
+ "heading",
1098
+ "toolbar",
1099
+ "menu",
1100
+ "menubar",
1101
+ "tablist",
1102
+ "tree",
1103
+ "grid"
1104
+ ]);
1105
+ const SKIP_TAGS = /* @__PURE__ */ new Set([
1106
+ "script",
1107
+ "style",
1108
+ "template",
1109
+ "noscript",
1110
+ "svg",
1111
+ "path",
1112
+ "link",
1113
+ "meta",
1114
+ "br"
1115
+ ]);
1116
+ function roleOf(el) {
1117
+ const explicit = el.getAttribute("role");
1118
+ if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
1119
+ switch (el.tagName.toLowerCase()) {
1120
+ case "a": return el.hasAttribute("href") ? "link" : "";
1121
+ case "button": return "button";
1122
+ case "input": {
1123
+ const t = (el.getAttribute("type") ?? "text").toLowerCase();
1124
+ if (t === "checkbox") return "checkbox";
1125
+ if (t === "radio") return "radio";
1126
+ if (t === "range") return "slider";
1127
+ if (t === "number") return "spinbutton";
1128
+ if (t === "search") return "searchbox";
1129
+ if ([
1130
+ "button",
1131
+ "submit",
1132
+ "reset",
1133
+ "image"
1134
+ ].includes(t)) return "button";
1135
+ if (["hidden", "file"].includes(t)) return "";
1136
+ return "textbox";
1137
+ }
1138
+ case "textarea": return "textbox";
1139
+ case "select": return "combobox";
1140
+ case "nav": return "navigation";
1141
+ case "main": return "main";
1142
+ case "header": return "banner";
1143
+ case "footer": return "contentinfo";
1144
+ case "form": return "form";
1145
+ case "h1":
1146
+ case "h2":
1147
+ case "h3":
1148
+ case "h4":
1149
+ case "h5":
1150
+ case "h6": return "heading";
1151
+ case "ul":
1152
+ case "ol": return "list";
1153
+ case "li": return "listitem";
1154
+ case "table": return "table";
1155
+ case "dialog": return "dialog";
1156
+ case "summary": return "button";
1157
+ default: return "";
1158
+ }
1159
+ }
1160
+ function nameOf(el, role) {
1161
+ const aria = el.getAttribute("aria-label");
1162
+ if (aria && aria.trim()) return aria.trim().slice(0, 60);
1163
+ const labelledby = el.getAttribute("aria-labelledby");
1164
+ if (labelledby) {
1165
+ const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
1166
+ if (t) return t.slice(0, 60);
1167
+ }
1168
+ if (el.id) {
1169
+ const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
1170
+ if (t) return t.slice(0, 60);
1171
+ }
1172
+ if ([
1173
+ "textbox",
1174
+ "searchbox",
1175
+ "combobox",
1176
+ "spinbutton"
1177
+ ].includes(role)) {
1178
+ const ph = el.getAttribute("placeholder");
1179
+ if (ph && ph.trim()) return ph.trim().slice(0, 60);
1180
+ }
1181
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
1182
+ if (text) return text.slice(0, 60);
1183
+ const title = el.getAttribute("title");
1184
+ if (title && title.trim()) return title.trim().slice(0, 60);
1185
+ return "";
1186
+ }
1187
+ /** 稳定 CSS 路径:#id → [data-testid] → nth-of-type 链(≤6 层)。 */
1188
+ function cssPath(el) {
1189
+ if (el.id) return `#${CSS.escape(el.id)}`;
1190
+ const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
1191
+ if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
1192
+ const parts = [];
1193
+ let cur = el;
1194
+ while (cur && cur !== document.body && parts.length < 6) {
1195
+ const tag = cur.tagName.toLowerCase();
1196
+ const parent = cur.parentElement;
1197
+ if (!parent) {
1198
+ parts.unshift(tag);
1199
+ break;
1200
+ }
1201
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
1202
+ const idx = sameTag.indexOf(cur) + 1;
1203
+ parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
1204
+ cur = parent;
1205
+ }
1206
+ return parts.join(" > ");
1207
+ }
1208
+ function isVisible(el) {
1209
+ if (el.getAttribute("aria-hidden") === "true") return false;
1210
+ const rect = el.getBoundingClientRect();
1211
+ if (rect.width === 0 && rect.height === 0) return false;
1212
+ const style = getComputedStyle(el);
1213
+ return style.visibility !== "hidden" && style.display !== "none";
1214
+ }
1215
+ const lines = [];
1216
+ const refs = [];
1217
+ let truncated = false;
1218
+ function walk(el, depth) {
1219
+ if (truncated) return;
1220
+ if (lines.length >= 400 || depth > 10) {
1221
+ truncated = true;
1222
+ return;
1223
+ }
1224
+ const role = roleOf(el);
1225
+ if (INTERACTIVE.has(role)) {
1226
+ if (!isVisible(el)) return;
1227
+ const name = nameOf(el, role);
1228
+ const ref = refs.length < maxRefs ? `e${refs.length + 1}` : "";
1229
+ if (ref !== "") refs.push({
1230
+ ref,
1231
+ role,
1232
+ name,
1233
+ selector: cssPath(el)
1234
+ });
1235
+ const expanded = el.getAttribute("aria-expanded");
1236
+ const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
1237
+ lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${ref !== "" ? ` [ref=${ref}]` : ""}${expanded !== null ? ` [expanded=${expanded}]` : ""}${disabled ? " [disabled]" : ""}`);
1238
+ return;
1239
+ }
1240
+ if (STRUCTURAL.has(role)) {
1241
+ if (!isVisible(el)) return;
1242
+ const name = nameOf(el, role);
1243
+ const level = role === "heading" ? ` [level=${el.tagName[1] ?? "1"}]` : "";
1244
+ lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${level}`);
1245
+ for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth + 1);
1246
+ return;
1247
+ }
1248
+ for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth);
1249
+ }
1250
+ if (document.body) walk(document.body, 0);
1251
+ return {
1252
+ tree: lines.join("\n"),
1253
+ refs,
1254
+ truncated
1255
+ };
1256
+ }
664
1257
  async function snapshotTool(config, args) {
665
1258
  const page = await getPage(config);
666
1259
  const maxText = args.maxTextChars ?? config.maxTextChars;
1260
+ if (args.mode === "aria") {
1261
+ const maxRefs = Math.min(Math.max(Number(args.maxRefs ?? config.maxRefs ?? 200) || 200, 1), 500);
1262
+ const data = await page.evaluate(buildAriaTree, maxRefs);
1263
+ setPageRefs(page, new Map(data.refs.map((r) => [r.ref, {
1264
+ role: r.role,
1265
+ name: r.name,
1266
+ selector: r.selector
1267
+ }])));
1268
+ return {
1269
+ url: page.url(),
1270
+ title: await page.title(),
1271
+ tree: data.tree.slice(0, maxText),
1272
+ truncated: data.truncated || data.tree.length > maxText,
1273
+ refs: data.refs.length
1274
+ };
1275
+ }
667
1276
  const maxLinks = args.maxLinks ?? config.maxLinks;
668
1277
  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;
675
1278
  return {
676
- text: text.slice(0, maxT),
677
- truncated: text.length > maxT,
678
- links,
679
- inputs
1279
+ text: document.body ? document.body.innerText : "",
1280
+ links: Array.from(document.querySelectorAll("a")).slice(0, maxL).map((a) => ({
1281
+ text: (a.innerText || a.textContent || "").trim().slice(0, 200),
1282
+ href: a.href
1283
+ })),
1284
+ inputs: document.querySelectorAll("input, textarea, select").length
680
1285
  };
681
1286
  }, [maxText, maxLinks]);
1287
+ const compressed = compressText(data.text);
682
1288
  return {
683
1289
  url: page.url(),
684
1290
  title: await page.title(),
685
- ...data
1291
+ text: compressed.slice(0, maxText),
1292
+ truncated: compressed.length > maxText,
1293
+ links: data.links,
1294
+ inputs: data.inputs
686
1295
  };
687
1296
  }
1297
+ /**
1298
+ * 解析点击/输入目标为 Locator:selector 直译;ref 走注册表——
1299
+ * 先试快照时的 CSS 路径并校验 role/name 仍一致(防 ref 漂移误点),
1300
+ * 不一致则降级 getByRole(role, {name}),均失败报「ref 已失效」。
1301
+ */
1302
+ async function targetLocator(page, args) {
1303
+ if (args.ref !== void 0 && args.ref !== "") {
1304
+ const ref = String(args.ref);
1305
+ const entry = pageRef(page, ref);
1306
+ if (entry === void 0) throw new Error(`未知 ref: ${ref}(页面导航或重新快照后旧 ref 失效;请先 browser_snapshot { mode: "aria" })`);
1307
+ const byPath = page.locator(entry.selector).first();
1308
+ if (await byPath.count().catch(() => 0) > 0) {
1309
+ if (await byPath.evaluate((el, want) => {
1310
+ const tag = el.tagName.toLowerCase();
1311
+ const type = (el.getAttribute("type") ?? "").toLowerCase();
1312
+ const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
1313
+ const roleOk = want.role === "" || roleAttr === want.role || want.role === "link" && tag === "a" || want.role === "button" && (tag === "button" || tag === "summary" || tag === "input" && [
1314
+ "button",
1315
+ "submit",
1316
+ "reset",
1317
+ "image"
1318
+ ].includes(type)) || ["textbox", "searchbox"].includes(want.role) && (tag === "textarea" || tag === "input" && ![
1319
+ "hidden",
1320
+ "checkbox",
1321
+ "radio"
1322
+ ].includes(type)) || want.role === "combobox" && tag === "select" || want.role === "checkbox" && tag === "input" && type === "checkbox" || want.role === "radio" && tag === "input" && type === "radio";
1323
+ const aria = el.getAttribute("aria-label") ?? "";
1324
+ const ph = el.getAttribute("placeholder") ?? "";
1325
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
1326
+ const nameOk = want.name === "" || aria.includes(want.name) || ph.includes(want.name) || text.includes(want.name);
1327
+ return roleOk && nameOk;
1328
+ }, {
1329
+ role: entry.role,
1330
+ name: entry.name
1331
+ }).catch(() => false)) return byPath;
1332
+ }
1333
+ const byRole = entry.name !== "" ? page.getByRole(entry.role, { name: entry.name }).first() : page.getByRole(entry.role).first();
1334
+ if (await byRole.count().catch(() => 0) > 0) return byRole;
1335
+ throw new Error(`ref ${ref} 已失效(元素不存在或内容已变化);请重新 browser_snapshot { mode: "aria" }`);
1336
+ }
1337
+ if (args.selector === void 0) throw new Error("需要 selector 或 ref 之一");
1338
+ return page.locator(String(args.selector)).first();
1339
+ }
688
1340
  async function clickTool(config, args) {
689
1341
  const page = await getPage(config);
690
- const selector = String(args.selector);
691
- await page.click(selector, { timeout: args.timeoutMs ?? config.timeoutMs });
1342
+ await (await targetLocator(page, args)).click({ timeout: args.timeoutMs ?? config.timeoutMs });
692
1343
  await page.waitForLoadState("domcontentloaded").catch(() => {});
693
1344
  return {
694
- clicked: selector,
1345
+ clicked: String(args.ref ?? args.selector),
695
1346
  ...await pageIdentity(page)
696
1347
  };
697
1348
  }
698
1349
  async function typeTool(config, args) {
699
1350
  const page = await getPage(config);
700
- const selector = String(args.selector);
1351
+ const target = await targetLocator(page, args);
701
1352
  const text = String(args.text ?? "");
702
- if (args.clear === true) await page.fill(selector, text, { timeout: args.timeoutMs ?? config.timeoutMs });
1353
+ if (args.clear === true) await target.fill(text, { timeout: args.timeoutMs ?? config.timeoutMs });
703
1354
  else {
704
- await page.click(selector, { timeout: args.timeoutMs ?? config.timeoutMs });
1355
+ await target.click({ timeout: args.timeoutMs ?? config.timeoutMs });
705
1356
  await page.keyboard.type(text, { delay: args.delayMs ?? 0 });
706
1357
  }
707
1358
  if (args.submit === true) await page.keyboard.press("Enter");
708
1359
  return {
709
1360
  typed: true,
710
- selector,
1361
+ selector: String(args.ref ?? args.selector),
711
1362
  submit: args.submit === true,
712
1363
  ...await pageIdentity(page)
713
1364
  };
@@ -715,13 +1366,63 @@ async function typeTool(config, args) {
715
1366
  async function pressTool(config, args) {
716
1367
  const page = await getPage(config);
717
1368
  const key = String(args.key);
718
- if (args.selector) await page.press(String(args.selector), key, { timeout: args.timeoutMs ?? config.timeoutMs });
1369
+ if (args.selector !== void 0 || args.ref !== void 0) await (await targetLocator(page, args)).press(key, { timeout: args.timeoutMs ?? config.timeoutMs });
719
1370
  else await page.keyboard.press(key);
720
1371
  return {
721
1372
  pressed: key,
722
1373
  ...await pageIdentity(page)
723
1374
  };
724
1375
  }
1376
+ /** 组件库下拉框的可见选项选择器并集(antd / element-plus / 通用 ARIA listbox)。 */
1377
+ const DROPDOWN_OPTION_SELECTOR = [
1378
+ ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option",
1379
+ ".el-select-dropdown__item",
1380
+ "[role='listbox'] [role='option']"
1381
+ ].join(", ");
1382
+ /**
1383
+ * 下拉选择原语:原生 <select> 走 selectOption;组件库下拉(antd/element/listbox)
1384
+ * 走「点击触发框 → 等待可见选项 → 按文本点击」。找不到选项时抛出错误并附当前可见
1385
+ * 选项清单,agent 可直接据此修正,无需再手写 eval 探查。
1386
+ */
1387
+ async function selectTool(config, args) {
1388
+ const page = await getPage(config);
1389
+ const target = await targetLocator(page, args);
1390
+ const label = String(args.ref ?? args.selector);
1391
+ const want = String(args.option ?? "");
1392
+ if (!want) throw new Error("select 需要 option(选项文本)");
1393
+ const exact = args.exact === true;
1394
+ const timeout = args.timeoutMs ?? config.timeoutMs;
1395
+ if (await target.evaluate((el) => el.tagName.toLowerCase()).catch(() => "") === "select") try {
1396
+ await target.selectOption({ label: want }, { timeout });
1397
+ return {
1398
+ selected: want,
1399
+ selector: label,
1400
+ ...await pageIdentity(page)
1401
+ };
1402
+ } catch {
1403
+ const options = await target.locator("option").allTextContents();
1404
+ throw new Error(`原生下拉无选项 "${want}";可用选项: ${options.map((t) => t.trim()).join(" / ")}`);
1405
+ }
1406
+ await target.click({ timeout });
1407
+ const options = page.locator(DROPDOWN_OPTION_SELECTOR);
1408
+ await options.first().waitFor({
1409
+ state: "visible",
1410
+ timeout: Math.min(timeout, 5e3)
1411
+ }).catch(() => {});
1412
+ const texts = (await options.allTextContents()).map((t) => t.trim()).filter(Boolean);
1413
+ if (texts.length === 0) throw new Error(`点击 ${label} 后未出现下拉选项(非原生 select,也不是已知的组件库下拉)`);
1414
+ const index = texts.findIndex((t) => exact ? t === want : t.includes(want));
1415
+ if (index === -1) {
1416
+ await page.keyboard.press("Escape").catch(() => {});
1417
+ throw new Error(`下拉选项中无 "${want}";可见选项: ${texts.join(" / ")}`);
1418
+ }
1419
+ await options.nth(index).click();
1420
+ return {
1421
+ selected: texts[index],
1422
+ selector: label,
1423
+ ...await pageIdentity(page)
1424
+ };
1425
+ }
725
1426
  async function evalTool(config, args) {
726
1427
  const page = await getPage(config);
727
1428
  const script = String(args.script ?? "");
@@ -737,12 +1438,29 @@ async function evalTool(config, args) {
737
1438
  }
738
1439
  async function screenshotTool(config, args, exec) {
739
1440
  const page = await getPage(config);
740
- const cwd = workspaceCwd(exec);
1441
+ const cwd = workspaceBaseDir(config, exec);
1442
+ if (typeof args.baseline === "string" && args.baseline !== "") {
1443
+ const baselineName = safeName(args.baseline);
1444
+ const baselineDir = config.baselineDir ?? ".dsh-browser/baselines";
1445
+ const dir = isAbsolute(baselineDir) ? baselineDir : resolve(cwd, baselineDir);
1446
+ mkdirSync(dir, { recursive: true });
1447
+ const filePath = join(dir, `${baselineName}.png`);
1448
+ const buffer = await page.screenshot({ type: "png" });
1449
+ writeFileSync(filePath, buffer);
1450
+ const size = pngSize(buffer);
1451
+ return {
1452
+ path: filePath,
1453
+ bytes: buffer.length,
1454
+ width: size.width,
1455
+ height: size.height,
1456
+ baseline: baselineName
1457
+ };
1458
+ }
741
1459
  const screenshotDir = config.screenshotDir ?? ".dsh-browser/screenshots";
742
1460
  const dir = isAbsolute(screenshotDir) ? screenshotDir : resolve(cwd, screenshotDir);
743
1461
  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`;
1462
+ const safeName$1 = String(args.name ?? `shot-${Date.now()}`).replace(/[^A-Za-z0-9._-]/g, "_");
1463
+ const fileName = safeName$1.endsWith(".png") ? safeName$1 : `${safeName$1}.png`;
746
1464
  const filePath = join(dir, fileName);
747
1465
  const buffer = await page.screenshot({ type: "png" });
748
1466
  writeFileSync(filePath, buffer);
@@ -885,12 +1603,22 @@ function resolveTabId(args) {
885
1603
  throw new Error("no tab id/index given and no active tab");
886
1604
  }
887
1605
  async function downloadTool(config, args, exec) {
888
- const recentDownloads = downloadsOf(currentProfile);
1606
+ let recentDownloads = downloadsOf(currentProfile);
1607
+ if (args.wait === true) {
1608
+ const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 1e4) || 1e4, 1e3), 6e4);
1609
+ const baseline = recentDownloads.length;
1610
+ const deadline = Date.now() + timeoutMs;
1611
+ while (recentDownloads.length <= baseline && Date.now() < deadline) {
1612
+ await new Promise((r) => setTimeout(r, 300));
1613
+ recentDownloads = downloadsOf(currentProfile);
1614
+ }
1615
+ if (recentDownloads.length <= baseline) throw new Error(`等待下载超时(${timeoutMs}ms):未检测到新下载,请先在页面触发下载动作`);
1616
+ }
889
1617
  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
1618
  const index = args.index !== void 0 ? Number(args.index) : recentDownloads.length - 1;
891
1619
  const entry = recentDownloads[index];
892
1620
  if (entry === void 0) throw new Error(`no download at index ${index}`);
893
- const cwd = workspaceCwd(exec);
1621
+ const cwd = workspaceBaseDir(config, exec);
894
1622
  const downloadDir = config.downloadDir ?? ".dsh-browser/downloads";
895
1623
  const dir = isAbsolute(downloadDir) ? downloadDir : resolve(cwd, downloadDir);
896
1624
  mkdirSync(dir, { recursive: true });
@@ -907,7 +1635,7 @@ async function downloadTool(config, args, exec) {
907
1635
  }
908
1636
  async function uploadTool(config, args, exec) {
909
1637
  const page = await getPage(config);
910
- const cwd = workspaceCwd(exec);
1638
+ const cwd = workspaceBaseDir(config, exec);
911
1639
  const filePath = isAbsolute(args.path) ? String(args.path) : resolve(cwd, String(args.path));
912
1640
  let stat;
913
1641
  try {
@@ -1044,7 +1772,21 @@ async function profileTool(config, args) {
1044
1772
  async function elementsTool(config, args) {
1045
1773
  const page = await getPage(config);
1046
1774
  const max = Math.min(Math.max(Number(args.max ?? 60) || 60, 1), 200);
1047
- const elements = await page.evaluate((maxN) => {
1775
+ const data = await page.evaluate(([maxN, optionSelector]) => {
1776
+ /** 提取元素的关联 label:aria-label → label[for] → form-item 标签(antd/element)。 */
1777
+ function labelOf(el) {
1778
+ const aria = el.getAttribute("aria-label");
1779
+ if (aria) return aria.trim();
1780
+ if (el.id) {
1781
+ const text = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
1782
+ if (text) return text;
1783
+ }
1784
+ const antLabel = el.closest(".ant-form-item")?.querySelector(".ant-form-item-label")?.textContent?.trim();
1785
+ if (antLabel) return antLabel.replace(/[*::]\s*$/, "");
1786
+ const elLabel = el.closest(".el-form-item")?.querySelector(".el-form-item__label")?.textContent?.trim();
1787
+ if (elLabel) return elLabel.replace(/[*::]\s*$/, "");
1788
+ return "";
1789
+ }
1048
1790
  const out = [];
1049
1791
  const seen = /* @__PURE__ */ new Set();
1050
1792
  const nodes = Array.from(document.querySelectorAll("input, textarea, select, button, a[href]"));
@@ -1057,6 +1799,7 @@ async function elementsTool(config, args) {
1057
1799
  type: el.type ?? "",
1058
1800
  name: el.name ?? "",
1059
1801
  id: el.id ?? "",
1802
+ label: labelOf(el),
1060
1803
  placeholder: el.placeholder ?? "",
1061
1804
  ariaLabel: el.getAttribute("aria-label") ?? "",
1062
1805
  text: (el.innerText || el.textContent || "").trim().slice(0, 80),
@@ -1068,11 +1811,15 @@ async function elementsTool(config, args) {
1068
1811
  seen.add(key);
1069
1812
  out.push(info);
1070
1813
  }
1071
- return out;
1072
- }, max);
1814
+ return {
1815
+ elements: out,
1816
+ dropdownOptions: Array.from(document.querySelectorAll(optionSelector)).map((el) => (el.textContent ?? "").trim()).filter(Boolean).slice(0, 50)
1817
+ };
1818
+ }, [max, DROPDOWN_OPTION_SELECTOR]);
1073
1819
  return {
1074
- count: elements.length,
1075
- elements
1820
+ count: data.elements.length,
1821
+ elements: data.elements,
1822
+ dropdownOptions: data.dropdownOptions
1076
1823
  };
1077
1824
  }
1078
1825
  async function closeTool() {
@@ -1081,6 +1828,21 @@ async function closeTool() {
1081
1828
  }
1082
1829
  /** 单次检查:返回 { ok, detail }。不抛错,失败时 detail 给出原因。 */
1083
1830
  async function checkOnce(page, cond) {
1831
+ if (cond.ref !== void 0) {
1832
+ const state = String(cond.state ?? "visible");
1833
+ try {
1834
+ await (await targetLocator(page, { ref: cond.ref })).waitFor({
1835
+ state,
1836
+ timeout: 120
1837
+ });
1838
+ return { ok: true };
1839
+ } catch {
1840
+ return {
1841
+ ok: false,
1842
+ detail: `ref ${String(cond.ref)} 未达到 ${state}`
1843
+ };
1844
+ }
1845
+ }
1084
1846
  if (cond.selector !== void 0) {
1085
1847
  const locator = page.locator(String(cond.selector));
1086
1848
  const state = String(cond.state ?? "visible");
@@ -1210,9 +1972,10 @@ async function waitForLoginTool(config, args) {
1210
1972
  ...await pageIdentity(page)
1211
1973
  };
1212
1974
  }
1213
- /** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。 */
1975
+ /** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。visual 断言走像素比对。 */
1214
1976
  async function assertTool(config, args, exec) {
1215
1977
  const page = await getPage(config);
1978
+ if (args.visual !== void 0 && typeof args.visual === "object") return visualAssert(config, args.visual, page, exec);
1216
1979
  const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 5e3) || 5e3, 100), 6e4);
1217
1980
  const intervalMs = Math.max(Number(args.intervalMs ?? 300) || 300, 50);
1218
1981
  const result = await pollCondition(page, args.condition, timeoutMs, intervalMs);
@@ -1220,7 +1983,7 @@ async function assertTool(config, args, exec) {
1220
1983
  passed: true,
1221
1984
  ...await pageIdentity(page)
1222
1985
  };
1223
- const cwd = workspaceCwd(exec);
1986
+ const cwd = workspaceBaseDir(config, exec);
1224
1987
  const screenshotDir = config.screenshotDir ?? ".dsh-browser/screenshots";
1225
1988
  const dir = isAbsolute(screenshotDir) ? screenshotDir : resolve(cwd, screenshotDir);
1226
1989
  mkdirSync(dir, { recursive: true });
@@ -1236,6 +1999,129 @@ async function assertTool(config, args, exec) {
1236
1999
  } catch {}
1237
2000
  throw new Error(`断言失败: ${result.detail ?? "条件未满足"}; 失败截图: ${filePath}`);
1238
2001
  }
2002
+ /**
2003
+ * 页内像素比对(自包含函数,page.evaluate 序列化注入):
2004
+ * 两张图按传入的 w/h 重绘,mask 区域填白后逐像素比较
2005
+ * (加权亮度差 / 255 > threshold 计为差异像素),差异像素在 diff 图上标红。
2006
+ * 注意:公式与测试里的 luminanceDelta 语义保持一致(0.2126/0.7152/0.0722)。
2007
+ */
2008
+ async function compareImagesInPage(payload) {
2009
+ const load = (src) => new Promise((res, rej) => {
2010
+ const img = new Image();
2011
+ img.onload = () => res(img);
2012
+ img.onerror = () => rej(/* @__PURE__ */ new Error("image decode failed"));
2013
+ img.src = src;
2014
+ });
2015
+ const [a, b] = await Promise.all([load(payload.baseline), load(payload.current)]);
2016
+ const { width: w, height: h } = payload;
2017
+ const draw = (img) => {
2018
+ const c = document.createElement("canvas");
2019
+ c.width = w;
2020
+ c.height = h;
2021
+ const ctx = c.getContext("2d");
2022
+ ctx.drawImage(img, 0, 0, w, h);
2023
+ return ctx;
2024
+ };
2025
+ const ctxA = draw(a);
2026
+ const ctxB = draw(b);
2027
+ for (const r of payload.maskRects) for (const ctx of [ctxA, ctxB]) {
2028
+ ctx.fillStyle = "#ffffff";
2029
+ ctx.fillRect(r.x, r.y, r.width, r.height);
2030
+ }
2031
+ const da = ctxA.getImageData(0, 0, w, h).data;
2032
+ const db = ctxB.getImageData(0, 0, w, h).data;
2033
+ const diffCanvas = document.createElement("canvas");
2034
+ diffCanvas.width = w;
2035
+ diffCanvas.height = h;
2036
+ const dctx = diffCanvas.getContext("2d");
2037
+ dctx.drawImage(b, 0, 0, w, h);
2038
+ const dd = dctx.getImageData(0, 0, w, h);
2039
+ let diff = 0;
2040
+ 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) {
2041
+ diff++;
2042
+ dd.data[i] = 255;
2043
+ dd.data[i + 1] = 0;
2044
+ dd.data[i + 2] = 0;
2045
+ dd.data[i + 3] = 255;
2046
+ }
2047
+ const total = w * h;
2048
+ return {
2049
+ diffPixels: diff,
2050
+ totalPixels: total,
2051
+ diffRatio: total > 0 ? diff / total : 0,
2052
+ diffImage: diff > 0 ? diffCanvas.toDataURL("image/png") : null
2053
+ };
2054
+ }
2055
+ /**
2056
+ * 视觉回归断言:与基线截图比对。基线缺失时报错提示先建基线;
2057
+ * diffRatio 超过 maxDiffRatio 时落盘当前截图与 diff 图并抛错。
2058
+ */
2059
+ async function visualAssert(config, visual, page, exec) {
2060
+ const baselineName = safeName(String(visual.baseline ?? ""));
2061
+ if (baselineName === "") throw new Error("visual 断言需要 baseline(基线名)");
2062
+ const cwd = workspaceBaseDir(config, exec);
2063
+ const baselineDir = config.baselineDir ?? ".dsh-browser/baselines";
2064
+ const dir = isAbsolute(baselineDir) ? baselineDir : resolve(cwd, baselineDir);
2065
+ const baselinePath = join(dir, `${baselineName}.png`);
2066
+ if (!existsSync(baselinePath)) throw new Error(`视觉基线不存在: ${baselinePath};先用 browser_screenshot { baseline: "${baselineName}" } 建立基线`);
2067
+ const baselineBuf = readFileSync(baselinePath);
2068
+ const currentBuf = await page.screenshot({ type: "png" });
2069
+ const baseSize = pngSize(baselineBuf);
2070
+ const curSize = pngSize(currentBuf);
2071
+ const screenshotDir = config.screenshotDir ?? ".dsh-browser/screenshots";
2072
+ const shotDir = isAbsolute(screenshotDir) ? screenshotDir : resolve(cwd, screenshotDir);
2073
+ const saveEvidence = (suffix, data) => {
2074
+ mkdirSync(shotDir, { recursive: true });
2075
+ const p = join(shotDir, `visual-${safeName(baselineName)}-${suffix}-${Date.now()}.png`);
2076
+ writeFileSync(p, typeof data === "string" ? Buffer.from(data.split(",")[1] ?? "", "base64") : data);
2077
+ noteScreenshotDir(shotDir);
2078
+ return p;
2079
+ };
2080
+ if (baseSize.width !== curSize.width || baseSize.height !== curSize.height) {
2081
+ const currentPath = saveEvidence("current", currentBuf);
2082
+ throw new Error(`视觉回归失败: 尺寸不一致(基线 ${baseSize.width}x${baseSize.height},当前 ${curSize.width}x${curSize.height}); 当前截图: ${currentPath}`);
2083
+ }
2084
+ const maskSelectors = Array.isArray(visual.mask) ? visual.mask.map(String) : [];
2085
+ const rawRects = [];
2086
+ for (const sel of maskSelectors) {
2087
+ const rects = await page.locator(sel).evaluateAll((els) => els.map((el) => {
2088
+ const r = el.getBoundingClientRect();
2089
+ return {
2090
+ x: r.x,
2091
+ y: r.y,
2092
+ width: r.width,
2093
+ height: r.height
2094
+ };
2095
+ })).catch(() => []);
2096
+ rawRects.push(...rects);
2097
+ }
2098
+ const scale = Math.min(1, 1e3 / Math.max(curSize.width, curSize.height));
2099
+ const w = Math.max(1, Math.round(curSize.width * scale));
2100
+ const h = Math.max(1, Math.round(curSize.height * scale));
2101
+ const result = await page.evaluate(compareImagesInPage, {
2102
+ baseline: `data:image/png;base64,${baselineBuf.toString("base64")}`,
2103
+ current: `data:image/png;base64,${currentBuf.toString("base64")}`,
2104
+ threshold: Math.min(Math.max(Number(visual.threshold ?? .1) || .1, 0), 1),
2105
+ maskRects: scaleRects(rawRects, scale),
2106
+ width: w,
2107
+ height: h
2108
+ });
2109
+ const maxDiffRatio = Math.min(Math.max(Number(visual.maxDiffRatio ?? .005) || 0, 0), 1);
2110
+ if (result.diffRatio > maxDiffRatio) {
2111
+ const currentPath = saveEvidence("current", currentBuf);
2112
+ const diffPath = result.diffImage !== null ? saveEvidence("diff", result.diffImage) : "(无 diff 图)";
2113
+ throw new Error(`视觉回归失败: diffRatio ${(result.diffRatio * 100).toFixed(3)}%(${result.diffPixels}/${result.totalPixels} 像素)> 阈值 ${(maxDiffRatio * 100).toFixed(3)}%; 当前截图: ${currentPath}; diff 图: ${diffPath}`);
2114
+ }
2115
+ return {
2116
+ passed: true,
2117
+ visual: {
2118
+ baseline: baselineName,
2119
+ diffRatio: result.diffRatio,
2120
+ diffPixels: result.diffPixels
2121
+ },
2122
+ ...await pageIdentity(page)
2123
+ };
2124
+ }
1239
2125
  function matchNetwork(entry, f) {
1240
2126
  if (f.urlSubstr && !entry.url.includes(String(f.urlSubstr))) return false;
1241
2127
  if (f.method && entry.method.toUpperCase() !== String(f.method).toUpperCase()) return false;
@@ -1251,13 +2137,14 @@ async function networkTool(args) {
1251
2137
  clearNetwork(currentProfile);
1252
2138
  return {
1253
2139
  cleared: true,
1254
- entries: 0
2140
+ entries: []
1255
2141
  };
1256
2142
  }
1257
2143
  if (action === "wait") {
1258
2144
  if (!args.url) throw new Error("wait 需要 url(正则字符串)");
1259
2145
  const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 1e4) || 1e4, 1e3), 6e4);
1260
- const deadline = Date.now() + timeoutMs;
2146
+ const startedAt = Date.now();
2147
+ const deadline = startedAt + timeoutMs;
1261
2148
  while (Date.now() < deadline) {
1262
2149
  const hit = [...entries].reverse().find((e) => {
1263
2150
  if (!new RegExp(String(args.url)).test(e.url)) return false;
@@ -1266,18 +2153,138 @@ async function networkTool(args) {
1266
2153
  });
1267
2154
  if (hit !== void 0) return {
1268
2155
  matched: hit,
1269
- waitMs: Date.now() + timeoutMs - deadline - (Date.now() + timeoutMs - deadline) + 0
2156
+ waitMs: Date.now() - startedAt
1270
2157
  };
1271
2158
  await new Promise((r) => setTimeout(r, 300));
1272
2159
  }
1273
- throw new Error(`等待接口超时(${timeoutMs}ms): ${args.url}`);
2160
+ const recent = entries.slice(-5).map((e) => `${e.method} ${e.url} → ${e.failed ? "FAILED" : e.status ?? "?"}`);
2161
+ throw new Error(`等待接口超时(${timeoutMs}ms): ${args.url}\n` + (recent.length > 0 ? `最近已见请求:\n ${recent.join("\n ")}` : "期间无任何接口请求记录"));
1274
2162
  }
1275
2163
  const filter = action === "failed" ? { failedOnly: true } : args;
1276
2164
  const list = [...entries].reverse().filter((e) => matchNetwork(e, filter)).slice(0, Math.min(Math.max(Number(args.limit ?? 50) || 50, 1), 200));
2165
+ if (action === "stats") {
2166
+ const byHost = /* @__PURE__ */ new Map();
2167
+ for (const e of entries) {
2168
+ let host;
2169
+ try {
2170
+ host = new URL(e.url).host;
2171
+ } catch {
2172
+ host = "(invalid-url)";
2173
+ }
2174
+ let bucket = byHost.get(host);
2175
+ if (bucket === void 0) {
2176
+ bucket = {
2177
+ host,
2178
+ total: 0,
2179
+ failed: 0,
2180
+ totalMs: 0,
2181
+ withDuration: 0,
2182
+ byStatus: {}
2183
+ };
2184
+ byHost.set(host, bucket);
2185
+ }
2186
+ bucket.total++;
2187
+ if (e.failed === true || e.status !== void 0 && e.status >= 400) bucket.failed++;
2188
+ if (e.durationMs !== void 0) {
2189
+ bucket.totalMs += e.durationMs;
2190
+ bucket.withDuration++;
2191
+ }
2192
+ if (e.status !== void 0) {
2193
+ const key = String(e.status);
2194
+ bucket.byStatus[key] = (bucket.byStatus[key] ?? 0) + 1;
2195
+ }
2196
+ }
2197
+ return { stats: [...byHost.values()].map((b) => ({
2198
+ host: b.host,
2199
+ total: b.total,
2200
+ failed: b.failed,
2201
+ avgMs: b.withDuration > 0 ? Math.round(b.totalMs / b.withDuration) : null,
2202
+ byStatus: b.byStatus
2203
+ })).sort((a, b) => b.total - a.total) };
2204
+ }
1277
2205
  if (action === "failed") return { failed: list };
1278
2206
  if (action === "list") return { entries: list };
1279
2207
  throw new Error(`unknown network action: ${action}`);
1280
2208
  }
2209
+ /** 只读工具集合:stripReads 保存时剔除(browser_cookies 仅 list 动作算只读)。 */
2210
+ const READONLY_TOOLS = /* @__PURE__ */ new Set([
2211
+ "browser_snapshot",
2212
+ "browser_elements",
2213
+ "browser_eval",
2214
+ "browser_network",
2215
+ "browser_status"
2216
+ ]);
2217
+ function isReadonlyStep(step) {
2218
+ if (READONLY_TOOLS.has(step.tool)) return true;
2219
+ if (step.tool === "browser_cookies") return (step.args?.action ?? "list") === "list";
2220
+ return false;
2221
+ }
2222
+ /** 使用 args.selector 定位的工具集合(自愈与录制指纹的作用范围)。 */
2223
+ const SELECTOR_STEP_TOOLS = /* @__PURE__ */ new Set([
2224
+ "browser_click",
2225
+ "browser_type",
2226
+ "browser_press",
2227
+ "browser_select",
2228
+ "browser_upload"
2229
+ ]);
2230
+ /**
2231
+ * 采集选择器指向元素的指纹(页内 evaluate;找不到元素返回 null)。
2232
+ * 指纹是回放自愈的降级候选来源:testid / role+name / text / id / tag+text。
2233
+ */
2234
+ async function captureSelectorHint(page, selector) {
2235
+ return page.locator(selector).first().evaluate((el) => {
2236
+ const tag = el.tagName.toLowerCase();
2237
+ const type = (el.getAttribute("type") ?? "").toLowerCase();
2238
+ const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
2239
+ const implicit = tag === "a" && el.hasAttribute("href") ? "link" : tag === "button" || tag === "summary" || tag === "input" && [
2240
+ "button",
2241
+ "submit",
2242
+ "reset",
2243
+ "image"
2244
+ ].includes(type) ? "button" : tag === "textarea" || tag === "input" && ![
2245
+ "hidden",
2246
+ "checkbox",
2247
+ "radio",
2248
+ "file"
2249
+ ].includes(type) ? "textbox" : tag === "select" ? "combobox" : tag === "input" && type === "checkbox" ? "checkbox" : tag === "input" && type === "radio" ? "radio" : "";
2250
+ const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60);
2251
+ const name = (el.getAttribute("aria-label") ?? "").trim() || (el.getAttribute("placeholder") ?? "").trim() || text;
2252
+ return {
2253
+ testid: el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? void 0,
2254
+ id: el.id !== "" ? el.id : void 0,
2255
+ tag,
2256
+ role: (roleAttr || implicit) !== "" ? roleAttr || implicit : void 0,
2257
+ name: name !== "" ? name : void 0,
2258
+ text: text !== "" ? text : void 0
2259
+ };
2260
+ }).catch(() => null);
2261
+ }
2262
+ /** 由指纹生成自愈降级候选(按优先级排序;导出供测试)。 */
2263
+ function healCandidates(hint) {
2264
+ const out = [];
2265
+ const q = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
2266
+ if (hint.testid !== void 0 && hint.testid !== "") out.push({
2267
+ via: "testid",
2268
+ selector: `css=[data-testid="${q(hint.testid)}"]`
2269
+ });
2270
+ if (hint.role !== void 0 && hint.role !== "" && hint.name !== void 0 && hint.name !== "") out.push({
2271
+ via: "role+name",
2272
+ selector: `role=${hint.role}[name="${q(hint.name)}"]`
2273
+ });
2274
+ if (hint.text !== void 0 && hint.text !== "") out.push({
2275
+ via: "text",
2276
+ selector: `text="${q(hint.text)}"`
2277
+ });
2278
+ if (hint.id !== void 0 && hint.id !== "") out.push({
2279
+ via: "id",
2280
+ selector: `css=[id="${q(hint.id)}"]`
2281
+ });
2282
+ if (hint.tag !== void 0 && hint.tag !== "" && hint.text !== void 0 && hint.text !== "") out.push({
2283
+ via: "tag+text",
2284
+ selector: `css=${hint.tag}:has-text("${q(hint.text)}")`
2285
+ });
2286
+ return out;
2287
+ }
1281
2288
  /** 录制管理:start / stop / save / list / delete。 */
1282
2289
  async function recordTool(args) {
1283
2290
  const action = args.action ?? "start";
@@ -1299,19 +2306,47 @@ async function recordTool(args) {
1299
2306
  if (isRecording()) throw new Error("录制未结束:先 browser_record stop");
1300
2307
  const name = String(args.name ?? "");
1301
2308
  if (!name) throw new Error("save 需要 name");
1302
- const steps = testLogOf().reverse().slice(-100).map((c) => ({
2309
+ const recorded = lastRecordingOf();
2310
+ const source = recorded !== null ? "recording" : "recent-log";
2311
+ const rawSteps = recorded !== null ? recorded.map((c) => ({
1303
2312
  tool: c.tool,
1304
- args: c.args ?? {}
2313
+ args: c.args ?? {},
2314
+ ...c.meta !== void 0 ? { meta: c.meta } : {}
2315
+ })) : testLogOf().reverse().slice(-100).map((c) => ({
2316
+ tool: c.tool,
2317
+ args: c.args ?? {},
2318
+ ...c.meta !== void 0 ? { meta: c.meta } : {}
1305
2319
  }));
2320
+ const steps = args.stripReads === true ? rawSteps.filter((s) => !isReadonlyStep(s)) : rawSteps;
2321
+ const stripped = rawSteps.length - steps.length;
2322
+ let varsApplied = 0;
2323
+ if (args.vars !== void 0 && typeof args.vars === "object" && args.vars !== null) for (const [varName, path] of Object.entries(args.vars)) {
2324
+ const m = /^#(\d+)\.args\.([\w.]+)$/.exec(String(path));
2325
+ if (m === null) throw new Error(`vars.${varName}: 路径格式应为 "#步骤号.args.字段"(如 "#3.args.text"),实际: ${String(path)}`);
2326
+ const step = steps[Number(m[1]) - 1];
2327
+ if (step === void 0) throw new Error(`vars.${varName}: 步骤 #${m[1]} 不存在(共 ${steps.length} 步)`);
2328
+ setPath(step.args, m[2], `{{${varName}}}`);
2329
+ varsApplied++;
2330
+ }
1306
2331
  savedRecordings.set(name, {
1307
2332
  steps,
1308
2333
  savedAt: Date.now()
1309
2334
  });
1310
2335
  const file = saveRecordingFile(name, steps);
2336
+ const warnings = [];
2337
+ steps.forEach((s, i) => {
2338
+ if (s.tool !== "browser_upload") return;
2339
+ const p = s.args?.path;
2340
+ if (typeof p === "string" && p.length > 0 && !existsSync(p)) warnings.push(`#${i + 1} browser_upload: 文件不存在 ${p},回放此步骤将失败`);
2341
+ });
1311
2342
  return {
1312
2343
  saved: name,
1313
2344
  steps: steps.length,
2345
+ source,
1314
2346
  file,
2347
+ ...stripped > 0 ? { stripped } : {},
2348
+ ...varsApplied > 0 ? { varsApplied } : {},
2349
+ ...warnings.length > 0 ? { warnings } : {},
1315
2350
  hint: "已持久化到磁盘,重启 DSH 后仍可回放"
1316
2351
  };
1317
2352
  }
@@ -1326,11 +2361,12 @@ async function recordTool(args) {
1326
2361
  }
1327
2362
  throw new Error(`unknown record action: ${action}`);
1328
2363
  }
1329
- /** 回放执行器(步骤 → 工具调用),由 register.ts 注入实现 map。 */
1330
- async function runReplay(steps, impls, failFast) {
2364
+ /** 回放执行器(步骤 → 工具调用),由 register.ts 注入实现 map。支持选择器自愈。 */
2365
+ async function runReplay(steps, impls, failFast, opts = {}) {
1331
2366
  const results = [];
1332
2367
  let stoppedAt = null;
1333
2368
  let passed = 0;
2369
+ let healedCount = 0;
1334
2370
  for (let i = 0; i < steps.length; i++) {
1335
2371
  const step = steps[i];
1336
2372
  const impl = impls[step.tool];
@@ -1347,24 +2383,82 @@ async function runReplay(steps, impls, failFast) {
1347
2383
  }
1348
2384
  continue;
1349
2385
  }
2386
+ const hint = step.meta?.hint;
2387
+ if (!(opts.healing === true && hint !== void 0 && SELECTOR_STEP_TOOLS.has(step.tool) && typeof step.args?.selector === "string")) {
2388
+ try {
2389
+ await impl(step.args ?? {});
2390
+ passed++;
2391
+ results.push({
2392
+ index: i,
2393
+ tool: step.tool,
2394
+ ok: true
2395
+ });
2396
+ } catch (e) {
2397
+ results.push({
2398
+ index: i,
2399
+ tool: step.tool,
2400
+ ok: false,
2401
+ error: e instanceof Error ? e.message : String(e)
2402
+ });
2403
+ if (failFast) {
2404
+ stoppedAt = i;
2405
+ break;
2406
+ }
2407
+ }
2408
+ continue;
2409
+ }
2410
+ const healTimeout = opts.healTimeoutMs ?? 3e3;
2411
+ const quickArgs = {
2412
+ ...step.args,
2413
+ timeoutMs: Math.min(Number(step.args.timeoutMs ?? Number.POSITIVE_INFINITY) || Number.POSITIVE_INFINITY, healTimeout)
2414
+ };
1350
2415
  try {
1351
- await impl(step.args ?? {});
2416
+ await impl(quickArgs);
1352
2417
  passed++;
1353
2418
  results.push({
1354
2419
  index: i,
1355
2420
  tool: step.tool,
1356
2421
  ok: true
1357
2422
  });
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;
2423
+ continue;
2424
+ } catch (e0) {
2425
+ const tried = [];
2426
+ let healed = null;
2427
+ for (const candidate of healCandidates(hint)) {
2428
+ tried.push(`${candidate.via}: ${candidate.selector}`);
2429
+ try {
2430
+ await impl({
2431
+ ...step.args,
2432
+ selector: candidate.selector,
2433
+ timeoutMs: healTimeout
2434
+ });
2435
+ healed = candidate;
2436
+ break;
2437
+ } catch {}
2438
+ }
2439
+ if (healed !== null) {
2440
+ passed++;
2441
+ healedCount++;
2442
+ results.push({
2443
+ index: i,
2444
+ tool: step.tool,
2445
+ ok: true,
2446
+ healed: true,
2447
+ via: healed.via,
2448
+ resolved: healed.selector
2449
+ });
2450
+ } else {
2451
+ const base = e0 instanceof Error ? e0.message : String(e0);
2452
+ results.push({
2453
+ index: i,
2454
+ tool: step.tool,
2455
+ ok: false,
2456
+ error: `${base}${tried.length > 0 ? `; 自愈已尝试: ${tried.join(" | ")}` : ""}`
2457
+ });
2458
+ if (failFast) {
2459
+ stoppedAt = i;
2460
+ break;
2461
+ }
1368
2462
  }
1369
2463
  }
1370
2464
  }
@@ -1372,12 +2466,263 @@ async function runReplay(steps, impls, failFast) {
1372
2466
  total: steps.length,
1373
2467
  passed,
1374
2468
  failed: results.length - passed,
1375
- stoppedAt,
2469
+ healedCount,
2470
+ ...stoppedAt !== null ? { stoppedAt } : {},
1376
2471
  results
1377
2472
  };
1378
2473
  }
1379
- /** 回放入口:name(已保存录制)或 steps(数组)二选一。 */
1380
- async function replayTool(args, impls) {
2474
+ /**
2475
+ * 页内抽取(自包含函数,page.evaluate 序列化注入):
2476
+ * attr 缺省取可见文本(innerText/textContent),否则 getAttribute(attr);
2477
+ * multiple 收集所有命中;fields 嵌套时在每个命中元素内递归抽取。
2478
+ */
2479
+ function extractFieldsInPage(fields) {
2480
+ const val = (el, attr) => {
2481
+ if (el === null) return null;
2482
+ if (attr !== void 0 && attr !== "" && attr !== "text" && attr !== "textContent") return el.getAttribute(attr);
2483
+ return (el.innerText || el.textContent || "").trim();
2484
+ };
2485
+ const run = (scope, spec) => {
2486
+ const out = {};
2487
+ for (const f of spec) {
2488
+ const els = Array.from(scope.querySelectorAll(f.selector));
2489
+ if (f.multiple === true) {
2490
+ if (Array.isArray(f.fields) && f.fields.length > 0) out[f.name] = els.map((el) => run(el, f.fields));
2491
+ else out[f.name] = els.map((el) => val(el, f.attr));
2492
+ } else {
2493
+ const el = els[0] ?? null;
2494
+ if (el !== null && Array.isArray(f.fields) && f.fields.length > 0) out[f.name] = run(el, f.fields);
2495
+ else out[f.name] = val(el, f.attr);
2496
+ }
2497
+ }
2498
+ return out;
2499
+ };
2500
+ return run(document, fields);
2501
+ }
2502
+ /**
2503
+ * 把单页抽取结果规整为行数组:恰好一个顶层 multiple 字段时,其数组即行
2504
+ * (行内嵌套对象为行,标量包装成 { [name]: value });否则整页算一行。
2505
+ */
2506
+ function rowsOfExtract(result, fields) {
2507
+ const multi = fields.filter((f) => f.multiple === true);
2508
+ if (multi.length === 1) {
2509
+ const name = multi[0].name;
2510
+ return (Array.isArray(result[name]) ? result[name] : []).map((item) => item !== null && typeof item === "object" ? item : { [name]: item });
2511
+ }
2512
+ return [result];
2513
+ }
2514
+ /** 工作区内落盘路径解析(拒绝穿越到工作区之外)。 */
2515
+ function workspaceFilePath(cwd, file) {
2516
+ const p = isAbsolute(file) ? resolve(file) : resolve(cwd, file);
2517
+ const root = resolve(cwd);
2518
+ if (p !== root && !p.startsWith(root + "/") && !p.startsWith(root + "\\")) throw new Error(`文件路径越出工作区: ${file}`);
2519
+ return p;
2520
+ }
2521
+ /**
2522
+ * 规则化抽取:fields 定义抽取结构,next 选择器驱动翻页(maxPages 上限),
2523
+ * 行数超 maxRows 截断;format/file 控制序列化与落盘(json/csv/jsonl/markdown)。
2524
+ */
2525
+ async function extractTool(config, args, exec) {
2526
+ const page = await getPage(config);
2527
+ const fields = args.fields;
2528
+ if (!Array.isArray(fields) || fields.length === 0) throw new Error("extract 需要 fields:[{ name, selector, attr?, multiple?, fields? }]");
2529
+ for (const f of fields) if (typeof f?.name !== "string" || f.name === "" || typeof f?.selector !== "string" || f.selector === "") throw new Error("fields 每项需要非空 name 与 selector");
2530
+ const maxPages = Math.min(Math.max(Number(args.maxPages ?? 1) || 1, 1), 100);
2531
+ const maxRows = Math.min(Math.max(Number(args.maxRows ?? config.maxRows ?? 1e3) || 1e3, 1), 1e5);
2532
+ const waitAfterMs = Math.min(Math.max(Number(args.waitAfterMs ?? 800) || 0, 0), 1e4);
2533
+ const rows = [];
2534
+ let pages = 0;
2535
+ for (;;) {
2536
+ const result = await page.evaluate(extractFieldsInPage, fields);
2537
+ rows.push(...rowsOfExtract(result, fields));
2538
+ pages++;
2539
+ if (rows.length >= maxRows) break;
2540
+ const nextSel = typeof args.next === "string" && args.next !== "" ? args.next : null;
2541
+ if (nextSel === null || pages >= maxPages) break;
2542
+ const next = page.locator(nextSel).first();
2543
+ if (await next.count().catch(() => 0) === 0) break;
2544
+ if (!await next.evaluate((el) => {
2545
+ const style = getComputedStyle(el);
2546
+ const rect = el.getBoundingClientRect();
2547
+ const visible = style.visibility !== "hidden" && style.display !== "none" && (rect.width > 0 || rect.height > 0);
2548
+ const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true" || el.classList.contains("disabled") || el.classList.contains("ant-pagination-disabled");
2549
+ return visible && !disabled;
2550
+ }).catch(() => false)) break;
2551
+ await next.click();
2552
+ await page.waitForLoadState("domcontentloaded").catch(() => {});
2553
+ if (waitAfterMs > 0) await page.waitForTimeout(waitAfterMs);
2554
+ }
2555
+ const truncated = rows.length > maxRows;
2556
+ const finalRows = truncated ? rows.slice(0, maxRows) : rows;
2557
+ const format = typeof args.format === "string" && args.format !== "" ? args.format : (typeof args.file === "string" ? formatFromFile(args.file) : null) ?? "json";
2558
+ if (![
2559
+ "json",
2560
+ "csv",
2561
+ "jsonl",
2562
+ "markdown"
2563
+ ].includes(format)) throw new Error(`未知 format: ${format}(支持 json / csv / jsonl / markdown)`);
2564
+ let file;
2565
+ let bytes;
2566
+ if (typeof args.file === "string" && args.file !== "") {
2567
+ file = workspaceFilePath(workspaceBaseDir(config, exec), args.file);
2568
+ const content = serializeRows(finalRows, format);
2569
+ mkdirSync(dirname(file), { recursive: true });
2570
+ writeFileSync(file, content, "utf8");
2571
+ bytes = Buffer.byteLength(content, "utf8");
2572
+ }
2573
+ return {
2574
+ rows: finalRows,
2575
+ count: finalRows.length,
2576
+ pages,
2577
+ format,
2578
+ ...truncated ? { truncated: true } : { truncated: false },
2579
+ ...file !== void 0 ? {
2580
+ file,
2581
+ bytes
2582
+ } : {},
2583
+ ...await pageIdentity(page)
2584
+ };
2585
+ }
2586
+ /** 批处理内禁止嵌套的工具(防 batch→replay→batch 递归与录制自引用)。 */
2587
+ const BATCH_FORBIDDEN = /* @__PURE__ */ new Set([
2588
+ "browser_batch",
2589
+ "browser_replay",
2590
+ "browser_record"
2591
+ ]);
2592
+ /**
2593
+ * 回归报告:list 列出最近运行,show 看单条详情,
2594
+ * export 聚合生成 Markdown/HTML 报告落盘工作区。
2595
+ */
2596
+ async function reportTool(config, args, exec) {
2597
+ const action = args.action ?? "list";
2598
+ if (action === "list") return { runs: listRuns(Math.min(Math.max(Number(args.limit ?? 20) || 20, 1), 200)).map((r) => ({
2599
+ runId: r.runId,
2600
+ ts: r.ts,
2601
+ kind: r.kind,
2602
+ source: r.source,
2603
+ total: r.total,
2604
+ passed: r.passed,
2605
+ failed: r.failed,
2606
+ healedCount: r.healedCount,
2607
+ durationMs: r.durationMs,
2608
+ ...r.row !== void 0 ? { row: r.row } : {}
2609
+ })) };
2610
+ if (action === "show") {
2611
+ const run = typeof args.runId === "string" && args.runId !== "" ? getRun(args.runId) : latestRun();
2612
+ if (run === null) throw new Error(typeof args.runId === "string" ? `无此运行记录: ${args.runId}` : "暂无运行记录(先跑一次 browser_replay)");
2613
+ return { run };
2614
+ }
2615
+ if (action === "export") {
2616
+ const format = args.format === "html" ? "html" : "markdown";
2617
+ const source = typeof args.source === "string" && args.source !== "" ? args.source : void 0;
2618
+ const runs = listRuns(Math.min(Math.max(Number(args.limit ?? 50) || 50, 1), 1e3)).filter((r) => source === void 0 || r.source === source);
2619
+ if (runs.length === 0) throw new Error(source !== void 0 ? `来源 "${source}" 暂无运行记录` : "暂无运行记录(先跑一次 browser_replay)");
2620
+ const content = format === "html" ? renderHtmlReport(runs, source) : renderMarkdownReport(runs, source);
2621
+ 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"}`);
2622
+ mkdirSync(dirname(file), { recursive: true });
2623
+ writeFileSync(file, content, "utf8");
2624
+ return {
2625
+ file,
2626
+ bytes: Buffer.byteLength(content, "utf8"),
2627
+ runs: runs.length,
2628
+ format
2629
+ };
2630
+ }
2631
+ throw new Error(`unknown report action: ${action}`);
2632
+ }
2633
+ /**
2634
+ * 动作批处理:一次调用顺序执行多个浏览器步骤(内核复用 runReplay),
2635
+ * 减少「点击→等待→输入」类固定小序列的工具往返。步骤内禁止嵌套
2636
+ * batch/replay/record;批整体在操作轨迹里记为一条 browser_batch。
2637
+ */
2638
+ async function batchTool(args, impls, config) {
2639
+ const steps = args.steps;
2640
+ if (!Array.isArray(steps) || steps.length === 0) throw new Error("batch 需要非空 steps 数组:[{ tool, args }]");
2641
+ const guarded = { ...impls };
2642
+ for (const name of BATCH_FORBIDDEN) guarded[name] = async () => {
2643
+ throw new Error(`不允许嵌套: batch 步骤不能调用 ${name}`);
2644
+ };
2645
+ const failFast = args.failFast !== false;
2646
+ const opts = {
2647
+ healing: config?.selectorHealing !== false,
2648
+ healTimeoutMs: config?.healTimeoutMs ?? 3e3
2649
+ };
2650
+ const t0 = Date.now();
2651
+ const result = await runReplay(steps, guarded, failFast, opts);
2652
+ noteRun({
2653
+ kind: "batch",
2654
+ source: "batch",
2655
+ total: result.total,
2656
+ passed: result.passed,
2657
+ failed: result.failed,
2658
+ healedCount: result.healedCount,
2659
+ durationMs: Date.now() - t0,
2660
+ results: result.results
2661
+ });
2662
+ const stoppedAt = result.stoppedAt;
2663
+ return {
2664
+ ...result,
2665
+ summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${stoppedAt !== void 0 ? `(停在步骤 ${stoppedAt})` : ""}` : ""}${result.healedCount > 0 ? `,${result.healedCount} 步自愈` : ""}`
2666
+ };
2667
+ }
2668
+ /** 设置嵌套路径的值(路径段按 . 拆分,供 record save 的 vars 占位替换)。 */
2669
+ function setPath(obj, path, value) {
2670
+ const segs = path.split(".");
2671
+ let cur = obj;
2672
+ for (let i = 0; i < segs.length - 1; i++) {
2673
+ const next = cur[segs[i]];
2674
+ if (next === null || typeof next !== "object") throw new Error(`路径不存在: ${path}`);
2675
+ cur = next;
2676
+ }
2677
+ cur[segs[segs.length - 1]] = value;
2678
+ }
2679
+ /** 收集步骤中所有 {{var}} 占位符名(导出供测试)。 */
2680
+ function findPlaceholders(steps) {
2681
+ const found = /* @__PURE__ */ new Set();
2682
+ const walk = (v) => {
2683
+ if (typeof v === "string") for (const m of v.matchAll(/\{\{([\w-]+)\}\}/g)) found.add(m[1]);
2684
+ else if (Array.isArray(v)) v.forEach(walk);
2685
+ else if (v !== null && typeof v === "object") Object.values(v).forEach(walk);
2686
+ };
2687
+ steps.forEach((s) => walk(s.args));
2688
+ return [...found];
2689
+ }
2690
+ /**
2691
+ * 深拷贝步骤并替换字符串叶子里的 {{var}} 占位符(导出供测试)。
2692
+ * vars 缺失的占位符保留原样(由调用方先 findPlaceholders 校验)。
2693
+ */
2694
+ function substituteVars(steps, vars) {
2695
+ const walk = (v) => {
2696
+ if (typeof v === "string") return v.replace(/\{\{([\w-]+)\}\}/g, (whole, name) => name in vars ? String(vars[name]) : whole);
2697
+ if (Array.isArray(v)) return v.map(walk);
2698
+ if (v !== null && typeof v === "object") {
2699
+ const out = {};
2700
+ for (const [k, val] of Object.entries(v)) out[k] = walk(val);
2701
+ return out;
2702
+ }
2703
+ return v;
2704
+ };
2705
+ return steps.map((s) => ({
2706
+ ...s,
2707
+ args: walk(s.args)
2708
+ }));
2709
+ }
2710
+ /** 运行结果落一条运行记录(runs.jsonl);失败静默,不阻断主流程。 */
2711
+ function noteRun(record) {
2712
+ try {
2713
+ const ts = Date.now();
2714
+ appendRun({
2715
+ runId: makeRunId(ts),
2716
+ ts,
2717
+ ...record
2718
+ });
2719
+ } catch {}
2720
+ }
2721
+ /**
2722
+ * 回放入口:name(已保存录制)或 steps(数组)二选一。
2723
+ * vars 注入 {{占位符}};data/dataFile 数据集模式逐行运行并聚合。
2724
+ */
2725
+ async function replayTool(args, impls, config, exec) {
1381
2726
  let steps = [];
1382
2727
  let source = "steps";
1383
2728
  if (typeof args.name === "string" && args.name) {
@@ -1389,11 +2734,100 @@ async function replayTool(args, impls) {
1389
2734
  else throw new Error("replay 需要 name(已保存录制)或 steps(步骤数组)");
1390
2735
  if (steps.length === 0) throw new Error("步骤为空");
1391
2736
  const failFast = args.failFast !== false;
1392
- const result = await runReplay(steps, impls, failFast);
2737
+ const opts = {
2738
+ healing: config?.selectorHealing !== false,
2739
+ healTimeoutMs: config?.healTimeoutMs ?? 3e3
2740
+ };
2741
+ let rows = null;
2742
+ if (Array.isArray(args.data)) rows = args.data;
2743
+ else if (typeof args.dataFile === "string" && args.dataFile !== "") {
2744
+ const file = workspaceFilePath(workspaceBaseDir(config ?? {}, exec), args.dataFile);
2745
+ if (!existsSync(file)) throw new Error(`dataFile 不存在: ${file}`);
2746
+ rows = parseCsv(readFileSync(file, "utf8"));
2747
+ }
2748
+ if (rows !== null) {
2749
+ if (rows.length === 0) throw new Error("数据集为空(0 行)");
2750
+ const placeholders = findPlaceholders(steps);
2751
+ const runs = [];
2752
+ let okRows = 0;
2753
+ for (let i = 0; i < rows.length; i++) {
2754
+ const rowVars = {
2755
+ ...args.vars,
2756
+ ...rows[i]
2757
+ };
2758
+ const missing = placeholders.filter((p) => !(p in rowVars));
2759
+ const t0 = Date.now();
2760
+ if (missing.length > 0) {
2761
+ runs.push({
2762
+ row: i,
2763
+ total: steps.length,
2764
+ passed: 0,
2765
+ failed: steps.length,
2766
+ healedCount: 0,
2767
+ results: [],
2768
+ error: `缺少变量: ${missing.join(", ")}`
2769
+ });
2770
+ noteRun({
2771
+ kind: "replay",
2772
+ source,
2773
+ total: steps.length,
2774
+ passed: 0,
2775
+ failed: steps.length,
2776
+ healedCount: 0,
2777
+ durationMs: Date.now() - t0,
2778
+ row: i,
2779
+ results: []
2780
+ });
2781
+ continue;
2782
+ }
2783
+ const result = await runReplay(substituteVars(steps, rowVars), impls, failFast, opts);
2784
+ result.stoppedAt;
2785
+ runs.push({
2786
+ row: i,
2787
+ ...result
2788
+ });
2789
+ if (result.failed === 0) okRows++;
2790
+ noteRun({
2791
+ kind: "replay",
2792
+ source,
2793
+ total: result.total,
2794
+ passed: result.passed,
2795
+ failed: result.failed,
2796
+ healedCount: result.healedCount,
2797
+ durationMs: Date.now() - t0,
2798
+ row: i,
2799
+ results: result.results
2800
+ });
2801
+ }
2802
+ return {
2803
+ source,
2804
+ rows: rows.length,
2805
+ passedRows: okRows,
2806
+ failedRows: rows.length - okRows,
2807
+ runs,
2808
+ summary: `数据集 ${okRows}/${rows.length} 行通过${okRows < rows.length ? `,${rows.length - okRows} 行失败` : ""}`
2809
+ };
2810
+ }
2811
+ const vars = args.vars ?? {};
2812
+ const missing = findPlaceholders(steps).filter((p) => !(p in vars));
2813
+ if (missing.length > 0) throw new Error(`缺少变量: ${missing.join(", ")}(录制含 {{占位符}},请用 vars 提供)`);
2814
+ const t0 = Date.now();
2815
+ const result = await runReplay(substituteVars(steps, vars), impls, failFast, opts);
2816
+ const stoppedAt = result.stoppedAt;
2817
+ noteRun({
2818
+ kind: "replay",
2819
+ source,
2820
+ total: result.total,
2821
+ passed: result.passed,
2822
+ failed: result.failed,
2823
+ healedCount: result.healedCount,
2824
+ durationMs: Date.now() - t0,
2825
+ results: result.results
2826
+ });
1393
2827
  return {
1394
2828
  source,
1395
2829
  ...result,
1396
- summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${result.stoppedAt !== null ? `(停在步骤 ${result.stoppedAt})` : ""}` : ""}`
2830
+ summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${stoppedAt !== void 0 ? `(停在步骤 ${stoppedAt})` : ""}` : ""}${result.healedCount > 0 ? `,${result.healedCount} 步自愈(建议修复录制选择器)` : ""}`
1397
2831
  };
1398
2832
  }
1399
2833
  //#endregion
@@ -1401,6 +2835,7 @@ async function replayTool(args, impls) {
1401
2835
  function registerTools(ctx, config) {
1402
2836
  const tools = ctx.get("tools");
1403
2837
  if (tools === void 0) return;
2838
+ setToolsLogger((m) => ctx.logger?.info?.(m));
1404
2839
  const timeout = (ms) => ms ?? config.timeoutMs;
1405
2840
  /** 参数净化:大字段(script/fields 等)截断,避免操作日志膨胀。 */
1406
2841
  function sanitizeArgs(args) {
@@ -1420,13 +2855,20 @@ function registerTools(ctx, config) {
1420
2855
  const sanitized = sanitizeArgs(args);
1421
2856
  try {
1422
2857
  const value = await original(args, exec);
1423
- recordCall({
2858
+ const call = recordCall({
1424
2859
  tool: def.name,
1425
2860
  args: sanitized,
1426
2861
  ok: true,
1427
2862
  ms: Date.now() - t0,
1428
2863
  ts: Date.now()
1429
2864
  });
2865
+ if (isRecording() && SELECTOR_STEP_TOOLS.has(def.name) && typeof args?.selector === "string") {
2866
+ const page = activePage();
2867
+ if (page !== null) {
2868
+ const hintResult = await captureSelectorHint(page, String(args.selector));
2869
+ if (hintResult !== null) call.meta = { hint: hintResult };
2870
+ }
2871
+ }
1430
2872
  return value;
1431
2873
  } catch (error) {
1432
2874
  recordCall({
@@ -1449,6 +2891,7 @@ function registerTools(ctx, config) {
1449
2891
  browser_click: (a) => clickTool(config, a),
1450
2892
  browser_type: (a) => typeTool(config, a),
1451
2893
  browser_press: (a) => pressTool(config, a),
2894
+ browser_select: (a) => selectTool(config, a),
1452
2895
  browser_eval: (a) => evalTool(config, a),
1453
2896
  browser_screenshot: (a) => screenshotTool(config, a),
1454
2897
  browser_wait: (a) => waitTool(config, a),
@@ -1470,7 +2913,9 @@ function registerTools(ctx, config) {
1470
2913
  browser_assert: (a) => assertTool(config, a, void 0),
1471
2914
  browser_network: (a) => networkTool(a),
1472
2915
  browser_record: (a) => recordTool(a),
1473
- browser_replay: (a) => replayTool(a, stepImpls)
2916
+ browser_replay: (a) => replayTool(a, stepImpls, config),
2917
+ browser_batch: (a) => batchTool(a, stepImpls, config),
2918
+ browser_extract: (a) => extractTool(config, a)
1474
2919
  };
1475
2920
  register(definePlainTool({
1476
2921
  name: "browser_open",
@@ -1520,17 +2965,26 @@ function registerTools(ctx, config) {
1520
2965
  }));
1521
2966
  register(definePlainTool({
1522
2967
  name: "browser_snapshot",
1523
- description: "读取当前页面的可访问文本、链接清单与输入框数量(不截图)。maxTextChars 默认 20000。链接列表用于构造点击选择器。",
2968
+ description: "读取当前页面快照(不截图)。mode:'text'(默认)返回压缩后的可见文本+链接清单,适合读内容;'aria' 返回可访问性树,可交互元素带 [ref=eN] 短引用,后续 browser_click/browser_type/browser_select/browser_press 可直接传 ref 代替 CSS 选择器(导航或重新快照后旧 ref 失效)。maxTextChars 默认 20000。",
1524
2969
  parameters: {
1525
2970
  type: "object",
1526
2971
  properties: {
2972
+ mode: {
2973
+ type: "string",
2974
+ enum: ["text", "aria"],
2975
+ description: "快照模式,默认 text。"
2976
+ },
1527
2977
  maxTextChars: {
1528
2978
  type: "integer",
1529
2979
  description: "文本截断上限。"
1530
2980
  },
1531
2981
  maxLinks: {
1532
2982
  type: "integer",
1533
- description: "链接数量上限。"
2983
+ description: "链接数量上限(text 模式)。"
2984
+ },
2985
+ maxRefs: {
2986
+ type: "integer",
2987
+ description: "ref 分配上限(aria 模式,默认 200)。"
1534
2988
  }
1535
2989
  },
1536
2990
  additionalProperties: false
@@ -1547,24 +3001,26 @@ function registerTools(ctx, config) {
1547
3001
  type: "array",
1548
3002
  items: { type: "object" }
1549
3003
  },
1550
- inputs: { type: "integer" }
3004
+ inputs: { type: "integer" },
3005
+ tree: { type: "string" },
3006
+ refs: { type: "integer" }
1551
3007
  },
1552
3008
  required: [
1553
3009
  "url",
1554
3010
  "title",
1555
- "text",
1556
- "truncated",
1557
- "links",
1558
- "inputs"
3011
+ "truncated"
1559
3012
  ]
1560
3013
  },
1561
- render: (_args, value) => `URL: ${value.url}\nTitle: ${value.title}\nInputs: ${value.inputs}\n--- text ---\n${value.text}`,
3014
+ render: (_args, value) => {
3015
+ if (value.tree !== void 0) return `URL: ${value.url}\nTitle: ${value.title}\nRefs: ${value.refs}\n--- aria tree ---\n${value.tree}`;
3016
+ return `URL: ${value.url}\nTitle: ${value.title}\nInputs: ${value.inputs}\n--- text ---\n${value.text}`;
3017
+ },
1562
3018
  timeoutMs: timeout(),
1563
3019
  execute: (args) => snapshotTool(config, args)
1564
3020
  }));
1565
3021
  register(definePlainTool({
1566
3022
  name: "browser_click",
1567
- description: "点击页面上匹配 CSS 选择器的元素(来自 browser_snapshot 的链接/表单分析)。",
3023
+ description: "点击页面元素:selector(CSS)或 ref(来自 browser_snapshot mode:'aria')二选一。",
1568
3024
  parameters: {
1569
3025
  type: "object",
1570
3026
  properties: {
@@ -1572,9 +3028,12 @@ function registerTools(ctx, config) {
1572
3028
  type: "string",
1573
3029
  description: "CSS 选择器。"
1574
3030
  },
3031
+ ref: {
3032
+ type: "string",
3033
+ description: "aria 快照的 ref(如 'e3');导航后失效。"
3034
+ },
1575
3035
  timeoutMs: { type: "integer" }
1576
3036
  },
1577
- required: ["selector"],
1578
3037
  additionalProperties: false
1579
3038
  },
1580
3039
  outputSchema: {
@@ -1597,18 +3056,22 @@ function registerTools(ctx, config) {
1597
3056
  }));
1598
3057
  register(definePlainTool({
1599
3058
  name: "browser_type",
1600
- description: "向 CSS 选择器指向的输入框输入文本。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车。",
3059
+ description: "向输入框输入文本:selector(CSS)或 ref 二选一。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车。",
1601
3060
  parameters: {
1602
3061
  type: "object",
1603
3062
  properties: {
1604
3063
  selector: { type: "string" },
3064
+ ref: {
3065
+ type: "string",
3066
+ description: "aria 快照的 ref(如 'e2')。"
3067
+ },
1605
3068
  text: { type: "string" },
1606
3069
  clear: { type: "boolean" },
1607
3070
  submit: { type: "boolean" },
1608
3071
  delayMs: { type: "integer" },
1609
3072
  timeoutMs: { type: "integer" }
1610
3073
  },
1611
- required: ["selector", "text"],
3074
+ required: ["text"],
1612
3075
  additionalProperties: false
1613
3076
  },
1614
3077
  outputSchema: {
@@ -1635,7 +3098,7 @@ function registerTools(ctx, config) {
1635
3098
  }));
1636
3099
  register(definePlainTool({
1637
3100
  name: "browser_press",
1638
- description: "按键:有 selector 时先聚焦该元素再按键(如 'Enter'、'Tab'、'Escape'、'Control+a'),否则在页面级按键。",
3101
+ description: "按键:有 selector 或 ref 时先聚焦该元素再按键(如 'Enter'、'Tab'、'Escape'、'Control+a'),否则在页面级按键。",
1639
3102
  parameters: {
1640
3103
  type: "object",
1641
3104
  properties: {
@@ -1644,6 +3107,10 @@ function registerTools(ctx, config) {
1644
3107
  description: "按键名(Playwright 键盘键名)。"
1645
3108
  },
1646
3109
  selector: { type: "string" },
3110
+ ref: {
3111
+ type: "string",
3112
+ description: "aria 快照的 ref。"
3113
+ },
1647
3114
  timeoutMs: { type: "integer" }
1648
3115
  },
1649
3116
  required: ["key"],
@@ -1667,6 +3134,53 @@ function registerTools(ctx, config) {
1667
3134
  timeoutMs: timeout(),
1668
3135
  execute: (args) => pressTool(config, args)
1669
3136
  }));
3137
+ register(definePlainTool({
3138
+ name: "browser_select",
3139
+ description: "下拉选择原语:原生 <select> 与组件库下拉(antd / element-plus / ARIA listbox)通用。selector 或 ref 指向下拉触发框,option 为选项文本(默认包含匹配,exact=true 精确)。找不到选项时报错并列出可见选项,无需再 browser_eval 探查。",
3140
+ parameters: {
3141
+ type: "object",
3142
+ properties: {
3143
+ selector: {
3144
+ type: "string",
3145
+ description: "下拉触发框的 CSS 选择器(如 #rc_select_1)。"
3146
+ },
3147
+ ref: {
3148
+ type: "string",
3149
+ description: "aria 快照的 ref。"
3150
+ },
3151
+ option: {
3152
+ type: "string",
3153
+ description: "要选择的选项文本。"
3154
+ },
3155
+ exact: {
3156
+ type: "boolean",
3157
+ description: "精确匹配,默认 false(包含匹配)。"
3158
+ },
3159
+ timeoutMs: { type: "integer" }
3160
+ },
3161
+ required: ["option"],
3162
+ additionalProperties: false
3163
+ },
3164
+ outputSchema: {
3165
+ type: "object",
3166
+ additionalProperties: false,
3167
+ properties: {
3168
+ selected: { type: "string" },
3169
+ selector: { type: "string" },
3170
+ url: { type: "string" },
3171
+ title: { type: "string" }
3172
+ },
3173
+ required: [
3174
+ "selected",
3175
+ "selector",
3176
+ "url",
3177
+ "title"
3178
+ ]
3179
+ },
3180
+ render: (_args, value) => `Selected "${value.selected}" in ${value.selector}`,
3181
+ timeoutMs: timeout(),
3182
+ execute: (args) => selectTool(config, args)
3183
+ }));
1670
3184
  register(definePlainTool({
1671
3185
  name: "browser_eval",
1672
3186
  description: "在页面上下文执行一段 JavaScript。表达式直接求值(如 'document.title');含换行/分号的语句会被包进 async IIFE,可用 return 返回。结果必须是可 JSON 序列化的值。",
@@ -1691,13 +3205,19 @@ function registerTools(ctx, config) {
1691
3205
  }));
1692
3206
  register(definePlainTool({
1693
3207
  name: "browser_screenshot",
1694
- description: "把当前页面截图保存为 PNG 文件(默认存到工作区 .dsh-browser/screenshots/),返回文件路径。纯文本模型看不到图,但用户可以在实时画面面板查看。",
3208
+ description: "把当前页面截图保存为 PNG 文件(默认存到工作区 .dsh-browser/screenshots/),返回文件路径。传 baseline=<名字> 时存为视觉回归基线(.dsh-browser/baselines/,不受清理策略约束),供 browser_assert 的 visual 断言比对。纯文本模型看不到图,但用户可以在实时画面面板查看。",
1695
3209
  parameters: {
1696
3210
  type: "object",
1697
- properties: { name: {
1698
- type: "string",
1699
- description: "文件名(不含扩展名也会自动补 .png)。"
1700
- } },
3211
+ properties: {
3212
+ name: {
3213
+ type: "string",
3214
+ description: "文件名(不含扩展名也会自动补 .png)。"
3215
+ },
3216
+ baseline: {
3217
+ type: "string",
3218
+ description: "存为视觉回归基线(基线名)。"
3219
+ }
3220
+ },
1701
3221
  additionalProperties: false
1702
3222
  },
1703
3223
  outputSchema: {
@@ -1707,7 +3227,8 @@ function registerTools(ctx, config) {
1707
3227
  path: { type: "string" },
1708
3228
  bytes: { type: "integer" },
1709
3229
  width: { type: "integer" },
1710
- height: { type: "integer" }
3230
+ height: { type: "integer" },
3231
+ baseline: { type: "string" }
1711
3232
  },
1712
3233
  required: [
1713
3234
  "path",
@@ -1909,13 +3430,23 @@ function registerTools(ctx, config) {
1909
3430
  }));
1910
3431
  register(definePlainTool({
1911
3432
  name: "browser_download",
1912
- description: "获取页面上最近触发的下载(如点击下载链接后),保存到工作区 .dsh-browser/downloads/ 并返回路径。index 可选,默认最近一次;浏览器会话关闭后下载记录丢失。",
3433
+ description: "获取页面上最近触发的下载(如点击下载链接后),保存到工作区 .dsh-browser/downloads/ 并返回路径。index 可选,默认最近一次;wait=true 时先轮询等待新下载出现(以调用时刻为基线,默认等 10 秒),避免 browser_wait 盲等;浏览器会话关闭后下载记录丢失。",
1913
3434
  parameters: {
1914
3435
  type: "object",
1915
- properties: { index: {
1916
- type: "integer",
1917
- description: "下载记录序号(0 起,默认最近)。"
1918
- } },
3436
+ properties: {
3437
+ index: {
3438
+ type: "integer",
3439
+ description: "下载记录序号(0 起,默认最近)。"
3440
+ },
3441
+ wait: {
3442
+ type: "boolean",
3443
+ description: "等待新下载出现再取,默认 false。"
3444
+ },
3445
+ timeoutMs: {
3446
+ type: "integer",
3447
+ description: "wait 超时毫秒,默认 10000,上限 60000。"
3448
+ }
3449
+ },
1919
3450
  additionalProperties: false
1920
3451
  },
1921
3452
  outputSchema: {
@@ -2226,7 +3757,7 @@ function registerTools(ctx, config) {
2226
3757
  }));
2227
3758
  register(definePlainTool({
2228
3759
  name: "browser_elements",
2229
- description: "列出当前页面可交互元素(input/textarea/select/button/链接)的结构化清单:类型、name/id、placeholder、可见文本、可直接用于 browser_click/browser_type 的 CSS 选择器。比 browser_snapshot 更适合定位表单。",
3760
+ 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
3761
  parameters: {
2231
3762
  type: "object",
2232
3763
  properties: { max: {
@@ -2243,11 +3774,23 @@ function registerTools(ctx, config) {
2243
3774
  elements: {
2244
3775
  type: "array",
2245
3776
  items: { type: "object" }
3777
+ },
3778
+ dropdownOptions: {
3779
+ type: "array",
3780
+ items: { type: "string" }
2246
3781
  }
2247
3782
  },
2248
- required: ["count", "elements"]
3783
+ required: [
3784
+ "count",
3785
+ "elements",
3786
+ "dropdownOptions"
3787
+ ]
3788
+ },
3789
+ render: (_args, value) => {
3790
+ 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}` : ""}`);
3791
+ if ((value.dropdownOptions ?? []).length > 0) lines.push(`--- 展开的下拉选项 ---`, ...value.dropdownOptions.map((o) => ` ${o}`));
3792
+ return lines.join("\n") || "(no interactive elements)";
2249
3793
  },
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
3794
  timeoutMs: timeout(),
2252
3795
  execute: (args) => elementsTool(config, args)
2253
3796
  }));
@@ -2293,13 +3836,17 @@ function registerTools(ctx, config) {
2293
3836
  }));
2294
3837
  register(definePlainTool({
2295
3838
  name: "browser_assert",
2296
- description: "结构化断言(测试核心):condition 在 timeoutMs 内成立则通过(条件格式同 browser_wait_for)。失败时自动截图存证据目录(assert-fail-*.png)并抛错,错误消息含原因与截图路径。适合断言登录成功、关键元素出现、接口返回、文本正确。",
3839
+ description: "结构化断言(测试核心):condition 在 timeoutMs 内成立则通过(条件格式同 browser_wait_for,另支持 ref)。失败时自动截图存证据目录(assert-fail-*.png)并抛错,错误消息含原因与截图路径。另支持视觉回归:传 visual={baseline, threshold?, maxDiffRatio?, mask?} 与基线截图做像素比对(browser_screenshot 的 baseline 建立基线;mask 为忽略的选择器数组,如时钟/广告位),失败时产出当前截图与红标 diff 图。适合断言登录成功、关键元素出现、接口返回、文本正确、页面无视觉回归。",
2297
3840
  parameters: {
2298
3841
  type: "object",
2299
3842
  properties: {
2300
3843
  condition: {
2301
3844
  type: "object",
2302
- description: "断言条件(五选一,同 browser_wait_for)。"
3845
+ description: "断言条件(同 browser_wait_for,另支持 {ref, state})。"
3846
+ },
3847
+ visual: {
3848
+ type: "object",
3849
+ description: "视觉断言:{ baseline: 基线名, threshold?: 像素亮度阈值(默认0.1), maxDiffRatio?: 允许的差异像素比例(默认0.005), mask?: 忽略区域选择器数组 }。"
2303
3850
  },
2304
3851
  timeoutMs: {
2305
3852
  type: "integer",
@@ -2310,7 +3857,6 @@ function registerTools(ctx, config) {
2310
3857
  description: "轮询间隔毫秒,默认 300。"
2311
3858
  }
2312
3859
  },
2313
- required: ["condition"],
2314
3860
  additionalProperties: false
2315
3861
  },
2316
3862
  outputSchema: {
@@ -2319,7 +3865,8 @@ function registerTools(ctx, config) {
2319
3865
  properties: {
2320
3866
  passed: { type: "boolean" },
2321
3867
  url: { type: "string" },
2322
- title: { type: "string" }
3868
+ title: { type: "string" },
3869
+ visual: { type: "object" }
2323
3870
  },
2324
3871
  required: [
2325
3872
  "passed",
@@ -2333,7 +3880,7 @@ function registerTools(ctx, config) {
2333
3880
  }));
2334
3881
  register(definePlainTool({
2335
3882
  name: "browser_network",
2336
- description: "网络记录查询(自动记录页面 XHR/fetch 接口请求,含请求/响应体截断存储,每 profile 上限 500 条)。action:list 列出(可按 urlSubstr/method/status 过滤,limit 默认 50);failed 只列失败/4xx5xx;wait 等待某个 url 正则匹配的接口出现(带 timeoutMs);clear 清空记录。适合断言接口出入参、排查 4xx/5xx。",
3883
+ description: "网络记录查询(自动记录页面 XHR/fetch 接口请求,含请求/响应体截断存储,每 profile 上限 500 条)。action:list 列出(可按 urlSubstr/method/status 过滤,limit 默认 50);failed 只列失败/4xx5xx;wait 等待某个 url 正则匹配的接口出现(带 timeoutMs,命中返回 waitMs);stats 按 host 聚合计数/失败数/平均耗时/状态码分布;clear 清空记录。适合断言接口出入参、排查 4xx/5xx。",
2337
3884
  parameters: {
2338
3885
  type: "object",
2339
3886
  properties: {
@@ -2343,6 +3890,7 @@ function registerTools(ctx, config) {
2343
3890
  "list",
2344
3891
  "failed",
2345
3892
  "wait",
3893
+ "stats",
2346
3894
  "clear"
2347
3895
  ],
2348
3896
  description: "默认 list。"
@@ -2386,15 +3934,20 @@ function registerTools(ctx, config) {
2386
3934
  type: "array",
2387
3935
  items: { type: "object" }
2388
3936
  },
3937
+ stats: {
3938
+ type: "array",
3939
+ items: { type: "object" }
3940
+ },
2389
3941
  matched: { type: "object" },
3942
+ waitMs: { type: "integer" },
2390
3943
  cleared: { type: "boolean" }
2391
3944
  },
2392
3945
  required: []
2393
3946
  },
2394
3947
  render: (_args, value) => {
2395
- const list = value.entries ?? value.failed ?? (value.matched ? [value.matched] : []);
2396
3948
  if (value.cleared) return "网络记录已清空";
2397
- return list.map((e) => {
3949
+ if (value.stats) return value.stats.map((s) => `${s.host} 共${s.total} 失败${s.failed} 均耗时${s.avgMs ?? "-"}ms ${JSON.stringify(s.byStatus)}`).join("\n") || "(无记录)";
3950
+ return (value.entries ?? value.failed ?? (value.matched ? [value.matched] : [])).map((e) => {
2398
3951
  const head = `${e.method} ${e.url} → ${e.failed ? "FAILED" : e.ok ? e.status : e.status + " !"}${e.durationMs !== void 0 ? ` ${e.durationMs}ms` : ""}`;
2399
3952
  const reqBody = e.postData ? `\n req: ${String(e.postData).slice(0, 160)}` : "";
2400
3953
  const resBody = e.body ? `\n res: ${String(e.body).slice(0, 240)}` : "";
@@ -2406,7 +3959,7 @@ function registerTools(ctx, config) {
2406
3959
  }));
2407
3960
  register(definePlainTool({
2408
3961
  name: "browser_record",
2409
- description: "操作录制(回归测试数据源):start 开始录制(后续浏览器工具调用自动记录),stop 结束并返回步数,save <name> 保存最近 100 次调用为命名录制,list/delete 管理。配合 browser_replay 一键回放。",
3962
+ description: "操作录制(回归测试数据源):start 开始录制(后续浏览器工具调用自动记录,selector 步骤附带元素指纹),stop 结束并返回步数,save <name> 保存(start→stop 的录制 buffer 优先,否则取最近 100 次调用;stripReads=true 剔除只读探查步骤;vars 可把指定步骤字段抽成 {{占位符}} 实现参数化回放;upload 引用的本地文件缺失时返回 warnings),list/delete 管理。配合 browser_replay 一键回放。",
2410
3963
  parameters: {
2411
3964
  type: "object",
2412
3965
  properties: {
@@ -2424,6 +3977,14 @@ function registerTools(ctx, config) {
2424
3977
  name: {
2425
3978
  type: "string",
2426
3979
  description: "save/delete 时的录制名。"
3980
+ },
3981
+ stripReads: {
3982
+ type: "boolean",
3983
+ description: "save 时剔除只读步骤(snapshot/elements/eval/network/status/cookies list),默认 false。"
3984
+ },
3985
+ vars: {
3986
+ type: "object",
3987
+ description: "save 时参数化:{ 变量名: \"#步骤号.args.字段\" }(步骤号 1 起),该字段值替换为 {{变量名}} 占位符;replay 时用 vars 传入真实值。"
2427
3988
  }
2428
3989
  },
2429
3990
  additionalProperties: false
@@ -2434,7 +3995,15 @@ function registerTools(ctx, config) {
2434
3995
  properties: {
2435
3996
  recording: { type: "boolean" },
2436
3997
  steps: { type: "integer" },
3998
+ stripped: { type: "integer" },
3999
+ varsApplied: { type: "integer" },
2437
4000
  saved: { type: "string" },
4001
+ source: { type: "string" },
4002
+ file: { type: "string" },
4003
+ warnings: {
4004
+ type: "array",
4005
+ items: { type: "string" }
4006
+ },
2438
4007
  recordings: {
2439
4008
  type: "array",
2440
4009
  items: { type: "object" }
@@ -2448,7 +4017,12 @@ function registerTools(ctx, config) {
2448
4017
  render: (args, value) => {
2449
4018
  if (args.action === "start") return "开始录制(后续浏览器调用将记录)";
2450
4019
  if (args.action === "stop") return `录制结束,共 ${value.steps} 步(browser_record save <name> 保存)`;
2451
- if (args.action === "save") return `已保存录制 "${value.saved}"(${value.steps} 步)`;
4020
+ if (args.action === "save") {
4021
+ const warn = (value.warnings ?? []).length > 0 ? `\n⚠️ ${value.warnings.join("\n⚠️ ")}` : "";
4022
+ const stripped = typeof value.stripped === "number" ? `(已剔除 ${value.stripped} 个只读步骤)` : "";
4023
+ const vars = typeof value.varsApplied === "number" ? `(参数化 ${value.varsApplied} 个变量)` : "";
4024
+ return `已保存录制 "${value.saved}"(${value.steps} 步,来源: ${value.source === "recording" ? "start→stop 录制" : "最近操作轨迹"})${stripped}${vars}${warn}`;
4025
+ }
2452
4026
  if (args.action === "delete") return value.deleted ? `已删除 "${value.name}"` : `无此录制 "${value.name}"`;
2453
4027
  return (value.recordings ?? []).map((r) => `"${r.name}"(${r.steps} 步): ${r.preview}`).join("\n") || "(无录制)";
2454
4028
  },
@@ -2457,7 +4031,7 @@ function registerTools(ctx, config) {
2457
4031
  }));
2458
4032
  register(definePlainTool({
2459
4033
  name: "browser_replay",
2460
- description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。回放中的调用会记入操作轨迹。",
4034
+ description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。选择器自愈:录制时保存了元素指纹的步骤,原 selector 失败时自动按 testid→role+name→text→id 降级重试并在结果中标 healed(建议事后修复录制选择器)。参数化:录制含 {{占位符}} 时用 vars 注入;data 传对象数组或 dataFile 传 CSV 路径(首行为变量名)可逐行数据驱动回放。运行结果自动记入回归历史(browser_report 可导出报告)。",
2461
4035
  parameters: {
2462
4036
  type: "object",
2463
4037
  properties: {
@@ -2465,6 +4039,76 @@ function registerTools(ctx, config) {
2465
4039
  type: "string",
2466
4040
  description: "已保存录制名(与 steps 二选一)。"
2467
4041
  },
4042
+ steps: {
4043
+ type: "array",
4044
+ items: { type: "object" },
4045
+ description: "步骤数组 [{tool, args}](可含 meta.hint 指纹)。"
4046
+ },
4047
+ failFast: {
4048
+ type: "boolean",
4049
+ description: "遇错即停,默认 true(data 模式为行内 failFast,不中断后续行)。"
4050
+ },
4051
+ vars: {
4052
+ type: "object",
4053
+ description: "{{占位符}} 的注入值,如 { username: \"a@x.com\" }。"
4054
+ },
4055
+ data: {
4056
+ type: "array",
4057
+ items: { type: "object" },
4058
+ description: "数据集:每行一个对象,逐行回放一次。"
4059
+ },
4060
+ dataFile: {
4061
+ type: "string",
4062
+ description: "数据集 CSV 文件(工作区相对路径,首行为变量名)。"
4063
+ }
4064
+ },
4065
+ additionalProperties: false
4066
+ },
4067
+ outputSchema: {
4068
+ type: "object",
4069
+ additionalProperties: false,
4070
+ properties: {
4071
+ source: { type: "string" },
4072
+ total: { type: "integer" },
4073
+ passed: { type: "integer" },
4074
+ failed: { type: "integer" },
4075
+ healedCount: { type: "integer" },
4076
+ stoppedAt: { type: "integer" },
4077
+ results: {
4078
+ type: "array",
4079
+ items: { type: "object" }
4080
+ },
4081
+ summary: { type: "string" },
4082
+ rows: { type: "integer" },
4083
+ passedRows: { type: "integer" },
4084
+ failedRows: { type: "integer" },
4085
+ runs: {
4086
+ type: "array",
4087
+ items: { type: "object" }
4088
+ }
4089
+ },
4090
+ required: [
4091
+ "source",
4092
+ "total",
4093
+ "passed",
4094
+ "failed",
4095
+ "healedCount",
4096
+ "results",
4097
+ "summary"
4098
+ ]
4099
+ },
4100
+ render: (_args, value) => {
4101
+ 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")}`;
4102
+ },
4103
+ timeoutMs: timeout(),
4104
+ execute: (args, exec) => replayTool(args, stepImpls, config, exec)
4105
+ }));
4106
+ register(definePlainTool({
4107
+ name: "browser_batch",
4108
+ description: "动作批处理:一次调用顺序执行多个浏览器步骤 [{tool, args}](内核同 browser_replay),减少「点击→等待→输入→回车」类固定小序列的往返。failFast 默认 true(遇错停);步骤内禁止嵌套 browser_batch/browser_replay/browser_record;批整体在操作轨迹/录制里记为单条步骤(可录制、可回放)。",
4109
+ parameters: {
4110
+ type: "object",
4111
+ properties: {
2468
4112
  steps: {
2469
4113
  type: "array",
2470
4114
  items: { type: "object" },
@@ -2475,16 +4119,17 @@ function registerTools(ctx, config) {
2475
4119
  description: "遇错即停,默认 true。"
2476
4120
  }
2477
4121
  },
4122
+ required: ["steps"],
2478
4123
  additionalProperties: false
2479
4124
  },
2480
4125
  outputSchema: {
2481
4126
  type: "object",
2482
4127
  additionalProperties: false,
2483
4128
  properties: {
2484
- source: { type: "string" },
2485
4129
  total: { type: "integer" },
2486
4130
  passed: { type: "integer" },
2487
4131
  failed: { type: "integer" },
4132
+ healedCount: { type: "integer" },
2488
4133
  stoppedAt: { type: "integer" },
2489
4134
  results: {
2490
4135
  type: "array",
@@ -2493,17 +4138,155 @@ function registerTools(ctx, config) {
2493
4138
  summary: { type: "string" }
2494
4139
  },
2495
4140
  required: [
2496
- "source",
2497
4141
  "total",
2498
4142
  "passed",
2499
4143
  "failed",
4144
+ "healedCount",
2500
4145
  "results",
2501
4146
  "summary"
2502
4147
  ]
2503
4148
  },
2504
4149
  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
4150
  timeoutMs: timeout(),
2506
- execute: (args) => replayTool(args, stepImpls)
4151
+ execute: (args) => batchTool(args, stepImpls)
4152
+ }));
4153
+ register(definePlainTool({
4154
+ name: "browser_extract",
4155
+ 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。",
4156
+ parameters: {
4157
+ type: "object",
4158
+ properties: {
4159
+ fields: {
4160
+ type: "array",
4161
+ items: { type: "object" },
4162
+ description: "抽取字段定义 [{name, selector, attr?, multiple?, fields?}]。"
4163
+ },
4164
+ next: {
4165
+ type: "string",
4166
+ description: "翻页「下一页」选择器(可选)。"
4167
+ },
4168
+ maxPages: {
4169
+ type: "integer",
4170
+ description: "翻页上限,默认 1,上限 100。"
4171
+ },
4172
+ waitAfterMs: {
4173
+ type: "integer",
4174
+ description: "翻页后等待毫秒,默认 800。"
4175
+ },
4176
+ maxRows: {
4177
+ type: "integer",
4178
+ description: "聚合行数上限,默认 1000。"
4179
+ },
4180
+ format: {
4181
+ type: "string",
4182
+ enum: [
4183
+ "json",
4184
+ "csv",
4185
+ "jsonl",
4186
+ "markdown"
4187
+ ],
4188
+ description: "序列化格式(默认 json;file 有扩展名时自动推断)。"
4189
+ },
4190
+ file: {
4191
+ type: "string",
4192
+ description: "落盘路径(工作区相对或绝对,限工作区内)。"
4193
+ }
4194
+ },
4195
+ required: ["fields"],
4196
+ additionalProperties: false
4197
+ },
4198
+ outputSchema: {
4199
+ type: "object",
4200
+ additionalProperties: false,
4201
+ properties: {
4202
+ rows: {
4203
+ type: "array",
4204
+ items: { type: "object" }
4205
+ },
4206
+ count: { type: "integer" },
4207
+ pages: { type: "integer" },
4208
+ format: { type: "string" },
4209
+ truncated: { type: "boolean" },
4210
+ file: { type: "string" },
4211
+ bytes: { type: "integer" },
4212
+ url: { type: "string" },
4213
+ title: { type: "string" }
4214
+ },
4215
+ required: [
4216
+ "rows",
4217
+ "count",
4218
+ "pages",
4219
+ "format",
4220
+ "truncated",
4221
+ "url",
4222
+ "title"
4223
+ ]
4224
+ },
4225
+ render: (_args, value) => `抽取 ${value.count} 行 / ${value.pages} 页${value.truncated ? "(已截断)" : ""}${value.file ? ` → ${value.file} (${value.bytes} bytes)` : ""}`,
4226
+ timeoutMs: timeout(),
4227
+ execute: (args, exec) => extractTool(config, args, exec)
4228
+ }));
4229
+ register(definePlainTool({
4230
+ name: "browser_report",
4231
+ description: "回归运行报告:每次 browser_replay / browser_batch 的结果自动记入运行历史。action=list 列出最近运行;show 查看单条详情(runId 省略取最近一条);export 聚合生成报告落盘工作区(format=markdown|html,含运行趋势、失败步骤 Top、自愈步骤清单,source 可只看某个录制)。配合 DSH 的 schedule 能力可实现定时回归+定期报告。",
4232
+ parameters: {
4233
+ type: "object",
4234
+ properties: {
4235
+ action: {
4236
+ type: "string",
4237
+ enum: [
4238
+ "list",
4239
+ "show",
4240
+ "export"
4241
+ ],
4242
+ description: "默认 list。"
4243
+ },
4244
+ runId: {
4245
+ type: "string",
4246
+ description: "show 时的运行 id(省略取最近一条)。"
4247
+ },
4248
+ format: {
4249
+ type: "string",
4250
+ enum: ["markdown", "html"],
4251
+ description: "export 格式,默认 markdown。"
4252
+ },
4253
+ source: {
4254
+ type: "string",
4255
+ description: "export 时只聚合该来源(录制名)的运行。"
4256
+ },
4257
+ file: {
4258
+ type: "string",
4259
+ description: "export 落盘路径(工作区内,默认 reports/regression-<日期>.md)。"
4260
+ },
4261
+ limit: {
4262
+ type: "integer",
4263
+ description: "list/export 聚合的运行条数上限,list 默认 20、export 默认 50。"
4264
+ }
4265
+ },
4266
+ additionalProperties: false
4267
+ },
4268
+ outputSchema: {
4269
+ type: "object",
4270
+ additionalProperties: false,
4271
+ properties: {
4272
+ runs: {},
4273
+ run: { type: "object" },
4274
+ file: { type: "string" },
4275
+ bytes: { type: "integer" },
4276
+ format: { type: "string" }
4277
+ },
4278
+ required: []
4279
+ },
4280
+ render: (args, value) => {
4281
+ if (args.action === "export") return `已导出 ${value.runs} 次运行 → ${value.file} (${value.bytes} bytes, ${value.format})`;
4282
+ if (args.action === "show") {
4283
+ const r = value.run ?? {};
4284
+ return `${r.source}${r.row !== void 0 ? ` 行${r.row}` : ""}: ${r.passed}/${r.total} 通过,自愈 ${r.healedCount},runId=${r.runId}`;
4285
+ }
4286
+ 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") || "(暂无运行记录)";
4287
+ },
4288
+ timeoutMs: timeout(),
4289
+ execute: (args, exec) => reportTool(config, args, exec)
2507
4290
  }));
2508
4291
  }
2509
4292
  //#endregion
@@ -2535,6 +4318,10 @@ function registerBrowserApi(ctx, config, base) {
2535
4318
  });
2536
4319
  return;
2537
4320
  }
4321
+ if (path === `${base}/runs/latest`) {
4322
+ sendJson(res, 200, { run: latestRun() });
4323
+ return;
4324
+ }
2538
4325
  if (path === `${base}/recordings`) {
2539
4326
  if ((req.method ?? "GET") === "POST") {
2540
4327
  const body = await readJsonBody(req).catch(() => void 0);
@@ -2758,7 +4545,7 @@ function embedJs(base) {
2758
4545
  root.appendChild(modal);
2759
4546
  }
2760
4547
  function mount() {
2761
- if (root !== null) return;
4548
+ if (root !== null || document.getElementById("dsh-browser-client-overlay")) return;
2762
4549
  css();
2763
4550
  btn = el("button", null); btn.id = "dsh-browser-fab"; btn.title = "dsh-browser";
2764
4551
  btn.textContent = "🌐";
@@ -2861,6 +4648,7 @@ const SCHEMA = {
2861
4648
  profiles: { type: "any" },
2862
4649
  screenshotDir: { type: "string" },
2863
4650
  downloadDir: { type: "string" },
4651
+ workspaceDir: { type: "string" },
2864
4652
  screenshotMaxAgeDays: {
2865
4653
  type: "number",
2866
4654
  min: 0
@@ -2882,6 +4670,20 @@ const SCHEMA = {
2882
4670
  timeoutMs: {
2883
4671
  type: "number",
2884
4672
  min: 1
4673
+ },
4674
+ baselineDir: { type: "string" },
4675
+ selectorHealing: { type: "boolean" },
4676
+ healTimeoutMs: {
4677
+ type: "number",
4678
+ min: 100
4679
+ },
4680
+ maxRefs: {
4681
+ type: "number",
4682
+ min: 1
4683
+ },
4684
+ maxRows: {
4685
+ type: "number",
4686
+ min: 1
2885
4687
  }
2886
4688
  };
2887
4689
  const DEFAULT_CONFIG = {
@@ -2892,13 +4694,19 @@ const DEFAULT_CONFIG = {
2892
4694
  profiles: {},
2893
4695
  screenshotDir: ".dsh-browser/screenshots",
2894
4696
  downloadDir: ".dsh-browser/downloads",
4697
+ workspaceDir: "",
2895
4698
  screenshotMaxAgeDays: 7,
2896
4699
  screenshotMaxCount: 200,
2897
4700
  basePath: "/browser",
2898
4701
  recordBodies: true,
2899
4702
  maxTextChars: 2e4,
2900
4703
  maxLinks: 50,
2901
- timeoutMs: 3e4
4704
+ timeoutMs: 3e4,
4705
+ baselineDir: ".dsh-browser/baselines",
4706
+ selectorHealing: true,
4707
+ healTimeoutMs: 3e3,
4708
+ maxRefs: 200,
4709
+ maxRows: 1e3
2902
4710
  };
2903
4711
  function apply(ctx, rawConfig) {
2904
4712
  const resolved = resolveConfig("dsh-browser-view", SCHEMA, DEFAULT_CONFIG, rawConfig);
@@ -2911,7 +4719,7 @@ function apply(ctx, rawConfig) {
2911
4719
  const sectionDispose = ctx.get("systemPrompt")?.section?.({
2912
4720
  name: "tool:browser",
2913
4721
  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 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。"
4722
+ text: "浏览器工具操作一个共享浏览器会话,支持多标签页与多配置文件。打开页面后先用 browser_snapshot 读文本与链接、browser_elements 读表单结构(含关联 label 与展开的下拉选项),再决定 browser_click / browser_type / browser_form;定位操作元素优先用 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);配置 userDataDir 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。"
2915
4723
  });
2916
4724
  if (sectionDispose !== void 0) ctx.effect(() => sectionDispose);
2917
4725
  registerBrowserApi(ctx, config, base);