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/README.md +109 -15
- package/lib/client.js +1 -1
- package/lib/index.js +1913 -105
- package/lib/types/browser/extract.d.ts +13 -0
- package/lib/types/browser/recordings.d.ts +4 -9
- package/lib/types/browser/reports.d.ts +42 -0
- package/lib/types/browser/session.d.ts +16 -7
- package/lib/types/browser/tools.d.ts +222 -24
- package/lib/types/browser/types.d.ts +36 -0
- package/lib/types/client/index.d.ts +17 -7
- package/lib/types/lib/tools.d.ts +11 -0
- package/lib/types/lib/types.d.ts +2 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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:
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1351
|
+
const target = await targetLocator(page, args);
|
|
701
1352
|
const text = String(args.text ?? "");
|
|
702
|
-
if (args.clear === true) await
|
|
1353
|
+
if (args.clear === true) await target.fill(text, { timeout: args.timeoutMs ?? config.timeoutMs });
|
|
703
1354
|
else {
|
|
704
|
-
await
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
1072
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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()
|
|
2156
|
+
waitMs: Date.now() - startedAt
|
|
1270
2157
|
};
|
|
1271
2158
|
await new Promise((r) => setTimeout(r, 300));
|
|
1272
2159
|
}
|
|
1273
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
2469
|
+
healedCount,
|
|
2470
|
+
...stoppedAt !== null ? { stoppedAt } : {},
|
|
1376
2471
|
results
|
|
1377
2472
|
};
|
|
1378
2473
|
}
|
|
1379
|
-
/**
|
|
1380
|
-
|
|
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
|
|
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} 失败${
|
|
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: "
|
|
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
|
-
"
|
|
1556
|
-
"truncated",
|
|
1557
|
-
"links",
|
|
1558
|
-
"inputs"
|
|
3011
|
+
"truncated"
|
|
1559
3012
|
]
|
|
1560
3013
|
},
|
|
1561
|
-
render: (_args, value) =>
|
|
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: "
|
|
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: "
|
|
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: ["
|
|
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: {
|
|
1698
|
-
|
|
1699
|
-
|
|
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: {
|
|
1916
|
-
|
|
1917
|
-
|
|
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
|
|
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: [
|
|
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: "断言条件(
|
|
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
|
|
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 开始录制(
|
|
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")
|
|
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) =>
|
|
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
|
|
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);
|