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