dsh-browser-view 0.3.4 → 0.4.1
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 +19 -6
- package/lib/client.js +1 -1
- package/lib/index.js +1081 -516
- package/lib/types/browser/reports.d.ts +7 -0
- package/lib/types/browser/session.d.ts +35 -3
- package/lib/types/browser/tools.d.ts +143 -2
- package/lib/types/browser/types.d.ts +30 -2
- package/lib/types/browser/walker.d.ts +42 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -191,6 +191,350 @@ function genericCard(kind, title, rawInput) {
|
|
|
191
191
|
};
|
|
192
192
|
}
|
|
193
193
|
//#endregion
|
|
194
|
+
//#region lib/types/browser/walker.js
|
|
195
|
+
/** 页内 walker 核心(自包含;勿引用模块级变量、勿导入任何模块)。 */
|
|
196
|
+
function __walkerCore() {
|
|
197
|
+
const INTERACTIVE = /* @__PURE__ */ new Set([
|
|
198
|
+
"link",
|
|
199
|
+
"button",
|
|
200
|
+
"textbox",
|
|
201
|
+
"checkbox",
|
|
202
|
+
"radio",
|
|
203
|
+
"combobox",
|
|
204
|
+
"listbox",
|
|
205
|
+
"searchbox",
|
|
206
|
+
"slider",
|
|
207
|
+
"spinbutton",
|
|
208
|
+
"switch",
|
|
209
|
+
"tab",
|
|
210
|
+
"menuitem",
|
|
211
|
+
"option",
|
|
212
|
+
"treeitem"
|
|
213
|
+
]);
|
|
214
|
+
const STRUCTURAL = /* @__PURE__ */ new Set([
|
|
215
|
+
"navigation",
|
|
216
|
+
"main",
|
|
217
|
+
"banner",
|
|
218
|
+
"contentinfo",
|
|
219
|
+
"form",
|
|
220
|
+
"search",
|
|
221
|
+
"complementary",
|
|
222
|
+
"region",
|
|
223
|
+
"dialog",
|
|
224
|
+
"alert",
|
|
225
|
+
"table",
|
|
226
|
+
"list",
|
|
227
|
+
"heading",
|
|
228
|
+
"toolbar",
|
|
229
|
+
"menu",
|
|
230
|
+
"menubar",
|
|
231
|
+
"tablist",
|
|
232
|
+
"tree",
|
|
233
|
+
"grid"
|
|
234
|
+
]);
|
|
235
|
+
const SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
236
|
+
"script",
|
|
237
|
+
"style",
|
|
238
|
+
"template",
|
|
239
|
+
"noscript",
|
|
240
|
+
"svg",
|
|
241
|
+
"path",
|
|
242
|
+
"link",
|
|
243
|
+
"meta",
|
|
244
|
+
"br"
|
|
245
|
+
]);
|
|
246
|
+
function safeEscape(str) {
|
|
247
|
+
return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(str) : str;
|
|
248
|
+
}
|
|
249
|
+
function matches(el, sel) {
|
|
250
|
+
try {
|
|
251
|
+
return typeof el.matches === "function" && el.matches(sel);
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function closest(el, sel) {
|
|
257
|
+
try {
|
|
258
|
+
return typeof el.closest === "function" ? el.closest(sel) : null;
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 运行时组件元数据与源码坐标反射:
|
|
265
|
+
* 支持 @xiaou66/vite-plugin-vue-mcp-next 与 vite-plugin-vue-inspector 的 data-v-inspector,
|
|
266
|
+
* 以及 Vue 3 挂载在 DOM 上的 __vueParentComponent 实例。
|
|
267
|
+
*/
|
|
268
|
+
function resolveVueAndInspector(el) {
|
|
269
|
+
let inspector = el.getAttribute("data-v-inspector") || el.getAttribute("data-v-inspect") || el.getAttribute("data-inspector-file") || "";
|
|
270
|
+
let cur = el.parentElement;
|
|
271
|
+
let depth = 0;
|
|
272
|
+
while (!inspector && cur && depth < 2) {
|
|
273
|
+
inspector = cur.getAttribute("data-v-inspector") || cur.getAttribute("data-v-inspect") || cur.getAttribute("data-inspector-file") || "";
|
|
274
|
+
cur = cur.parentElement;
|
|
275
|
+
depth += 1;
|
|
276
|
+
}
|
|
277
|
+
let componentName = "";
|
|
278
|
+
let props = {};
|
|
279
|
+
const vcomp = el.__vueParentComponent;
|
|
280
|
+
if (vcomp && typeof vcomp === "object") {
|
|
281
|
+
componentName = vcomp.type?.name || vcomp.type?.__name || "";
|
|
282
|
+
props = vcomp.props || {};
|
|
283
|
+
}
|
|
284
|
+
if (!componentName && el.parentElement) {
|
|
285
|
+
const pvcomp = el.parentElement.__vueParentComponent;
|
|
286
|
+
if (pvcomp && typeof pvcomp === "object") {
|
|
287
|
+
componentName = pvcomp.type?.name || pvcomp.type?.__name || "";
|
|
288
|
+
if (Object.keys(props).length === 0) props = pvcomp.props || {};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
componentName,
|
|
293
|
+
inspector,
|
|
294
|
+
props
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* 可扩展组件库适配体系:
|
|
299
|
+
* 抹平 Ant Design Vue、Element Plus、Naive UI、Arco Design 等组件库的外层包装与非原生标签。
|
|
300
|
+
*/
|
|
301
|
+
function resolveComponentSemantics(el) {
|
|
302
|
+
const { componentName, inspector, props } = resolveVueAndInspector(el);
|
|
303
|
+
const className = typeof el.className === "string" ? el.className : "";
|
|
304
|
+
if (className.includes("ant-") || /^A[A-Z]/.test(componentName) || !!closest(el, ".ant-form-item")) {
|
|
305
|
+
const formLabel = closest(el, ".ant-form-item")?.querySelector(".ant-form-item-label")?.textContent?.trim() || "";
|
|
306
|
+
let role = "";
|
|
307
|
+
if (matches(el, ".ant-select, .ant-cascader, .ant-tree-select") || componentName === "ASelect" || componentName === "ACascader") role = "combobox";
|
|
308
|
+
else if (matches(el, ".ant-picker") || componentName === "ADatePicker" || componentName === "ATimePicker") role = "textbox";
|
|
309
|
+
else if (matches(el, ".ant-input-number") || componentName === "AInputNumber") role = "spinbutton";
|
|
310
|
+
else if (matches(el, ".ant-switch") || componentName === "ASwitch") role = "switch";
|
|
311
|
+
else if (matches(el, ".ant-btn") || componentName === "AButton") role = "button";
|
|
312
|
+
const ph = String(props.placeholder || el.querySelector(".ant-select-selection-placeholder")?.textContent?.trim() || el.getAttribute("placeholder") || "");
|
|
313
|
+
return {
|
|
314
|
+
library: "ant-design-vue",
|
|
315
|
+
component: componentName || (role === "combobox" ? "ASelect" : ""),
|
|
316
|
+
sourceFile: inspector,
|
|
317
|
+
role,
|
|
318
|
+
formLabel,
|
|
319
|
+
placeholder: ph
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (className.includes("el-") || /^El[A-Z]/.test(componentName) || !!closest(el, ".el-form-item")) {
|
|
323
|
+
const formLabel = closest(el, ".el-form-item")?.querySelector(".el-form-item__label")?.textContent?.trim() || "";
|
|
324
|
+
let role = "";
|
|
325
|
+
if (matches(el, ".el-select, .el-cascader") || componentName === "ElSelect" || componentName === "ElCascader") role = "combobox";
|
|
326
|
+
else if (matches(el, ".el-date-editor") || componentName === "ElDatePicker") role = "textbox";
|
|
327
|
+
else if (matches(el, ".el-input-number") || componentName === "ElInputNumber") role = "spinbutton";
|
|
328
|
+
else if (matches(el, ".el-switch") || componentName === "ElSwitch") role = "switch";
|
|
329
|
+
else if (matches(el, ".el-button") || componentName === "ElButton") role = "button";
|
|
330
|
+
const ph = String(props.placeholder || el.querySelector(".el-select__placeholder")?.textContent?.trim() || el.getAttribute("placeholder") || "");
|
|
331
|
+
return {
|
|
332
|
+
library: "element-plus",
|
|
333
|
+
component: componentName || (role === "combobox" ? "ElSelect" : ""),
|
|
334
|
+
sourceFile: inspector,
|
|
335
|
+
role,
|
|
336
|
+
formLabel,
|
|
337
|
+
placeholder: ph
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (className.includes("n-") || /^N[A-Z]/.test(componentName) || !!closest(el, ".n-form-item")) {
|
|
341
|
+
const formLabel = closest(el, ".n-form-item")?.querySelector(".n-form-item-label")?.textContent?.trim() || "";
|
|
342
|
+
let role = "";
|
|
343
|
+
if (matches(el, ".n-select") || componentName === "NSelect") role = "combobox";
|
|
344
|
+
else if (matches(el, ".n-date-picker") || componentName === "NDatePicker") role = "textbox";
|
|
345
|
+
else if (matches(el, ".n-switch") || componentName === "NSwitch") role = "switch";
|
|
346
|
+
else if (matches(el, ".n-button") || componentName === "NButton") role = "button";
|
|
347
|
+
const ph = String(props.placeholder || el.getAttribute("placeholder") || "");
|
|
348
|
+
return {
|
|
349
|
+
library: "naive-ui",
|
|
350
|
+
component: componentName || "",
|
|
351
|
+
sourceFile: inspector,
|
|
352
|
+
role,
|
|
353
|
+
formLabel,
|
|
354
|
+
placeholder: ph
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (className.includes("arco-") || !!closest(el, ".arco-form-item")) {
|
|
358
|
+
const formLabel = closest(el, ".arco-form-item")?.querySelector(".arco-form-item-label")?.textContent?.trim() || "";
|
|
359
|
+
let role = "";
|
|
360
|
+
if (matches(el, ".arco-select") || componentName.includes("Select")) role = "combobox";
|
|
361
|
+
else if (matches(el, ".arco-picker")) role = "textbox";
|
|
362
|
+
else if (matches(el, ".arco-btn")) role = "button";
|
|
363
|
+
const ph = String(props.placeholder || el.getAttribute("placeholder") || "");
|
|
364
|
+
return {
|
|
365
|
+
library: "arco-design",
|
|
366
|
+
component: componentName || "",
|
|
367
|
+
sourceFile: inspector,
|
|
368
|
+
role,
|
|
369
|
+
formLabel,
|
|
370
|
+
placeholder: ph
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (componentName || inspector) return {
|
|
374
|
+
library: "custom-vue",
|
|
375
|
+
component: componentName,
|
|
376
|
+
sourceFile: inspector,
|
|
377
|
+
role: "",
|
|
378
|
+
formLabel: "",
|
|
379
|
+
placeholder: String(props.placeholder || "")
|
|
380
|
+
};
|
|
381
|
+
return {
|
|
382
|
+
library: "",
|
|
383
|
+
component: "",
|
|
384
|
+
sourceFile: "",
|
|
385
|
+
role: "",
|
|
386
|
+
formLabel: "",
|
|
387
|
+
placeholder: ""
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function roleOf(el) {
|
|
391
|
+
const explicit = el.getAttribute("role");
|
|
392
|
+
if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
|
|
393
|
+
switch (el.tagName.toLowerCase()) {
|
|
394
|
+
case "a": return el.hasAttribute("href") ? "link" : "";
|
|
395
|
+
case "button": return "button";
|
|
396
|
+
case "input": {
|
|
397
|
+
const t = (el.getAttribute("type") ?? "text").toLowerCase();
|
|
398
|
+
if (t === "checkbox") return "checkbox";
|
|
399
|
+
if (t === "radio") return "radio";
|
|
400
|
+
if (t === "range") return "slider";
|
|
401
|
+
if (t === "number") return "spinbutton";
|
|
402
|
+
if (t === "search") return "searchbox";
|
|
403
|
+
if ([
|
|
404
|
+
"button",
|
|
405
|
+
"submit",
|
|
406
|
+
"reset",
|
|
407
|
+
"image"
|
|
408
|
+
].includes(t)) return "button";
|
|
409
|
+
if (["hidden", "file"].includes(t)) return "";
|
|
410
|
+
return "textbox";
|
|
411
|
+
}
|
|
412
|
+
case "textarea": return "textbox";
|
|
413
|
+
case "select": return "combobox";
|
|
414
|
+
case "nav": return "navigation";
|
|
415
|
+
case "main": return "main";
|
|
416
|
+
case "header": return "banner";
|
|
417
|
+
case "footer": return "contentinfo";
|
|
418
|
+
case "form": return "form";
|
|
419
|
+
case "h1":
|
|
420
|
+
case "h2":
|
|
421
|
+
case "h3":
|
|
422
|
+
case "h4":
|
|
423
|
+
case "h5":
|
|
424
|
+
case "h6": return "heading";
|
|
425
|
+
case "ul":
|
|
426
|
+
case "ol": return "list";
|
|
427
|
+
case "li": return "listitem";
|
|
428
|
+
case "table": return "table";
|
|
429
|
+
case "dialog": return "dialog";
|
|
430
|
+
case "summary": return "button";
|
|
431
|
+
default: return resolveComponentSemantics(el).role;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function nameOf(el, role) {
|
|
435
|
+
const sem = resolveComponentSemantics(el);
|
|
436
|
+
const aria = el.getAttribute("aria-label");
|
|
437
|
+
if (aria && aria.trim()) return aria.trim().slice(0, 60);
|
|
438
|
+
const labelledby = el.getAttribute("aria-labelledby");
|
|
439
|
+
if (labelledby) {
|
|
440
|
+
const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
|
|
441
|
+
if (t) return t.slice(0, 60);
|
|
442
|
+
}
|
|
443
|
+
if (el.id) {
|
|
444
|
+
const t = document.querySelector(`label[for="${safeEscape(el.id)}"]`)?.textContent?.trim();
|
|
445
|
+
if (t) return t.slice(0, 60);
|
|
446
|
+
}
|
|
447
|
+
if (sem.formLabel) return sem.formLabel.slice(0, 60);
|
|
448
|
+
if ([
|
|
449
|
+
"textbox",
|
|
450
|
+
"searchbox",
|
|
451
|
+
"combobox",
|
|
452
|
+
"spinbutton"
|
|
453
|
+
].includes(role)) {
|
|
454
|
+
const ph = el.getAttribute("placeholder") || sem.placeholder;
|
|
455
|
+
if (ph && ph.trim()) return ph.trim().slice(0, 60);
|
|
456
|
+
}
|
|
457
|
+
const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
|
|
458
|
+
if (text) return text.slice(0, 60);
|
|
459
|
+
const title = el.getAttribute("title");
|
|
460
|
+
if (title && title.trim()) return title.trim().slice(0, 60);
|
|
461
|
+
return "";
|
|
462
|
+
}
|
|
463
|
+
/** 稳定 CSS 路径:#id → [data-testid] → nth-of-type 链(≤6 层)。 */
|
|
464
|
+
function cssPath(el) {
|
|
465
|
+
if (el.id) return `#${safeEscape(el.id)}`;
|
|
466
|
+
const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
|
|
467
|
+
if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
|
|
468
|
+
const parts = [];
|
|
469
|
+
let cur = el;
|
|
470
|
+
while (cur && cur !== document.body && parts.length < 6) {
|
|
471
|
+
const tag = cur.tagName.toLowerCase();
|
|
472
|
+
const parent = cur.parentElement;
|
|
473
|
+
if (!parent) {
|
|
474
|
+
parts.unshift(tag);
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
478
|
+
const idx = sameTag.indexOf(cur) + 1;
|
|
479
|
+
parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
|
|
480
|
+
cur = parent;
|
|
481
|
+
}
|
|
482
|
+
return parts.join(" > ");
|
|
483
|
+
}
|
|
484
|
+
function isVisible(el) {
|
|
485
|
+
if (el.getAttribute("aria-hidden") === "true") return false;
|
|
486
|
+
const rect = el.getBoundingClientRect();
|
|
487
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
488
|
+
const style = getComputedStyle(el);
|
|
489
|
+
return style.visibility !== "hidden" && style.display !== "none";
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* 统一元素描述结构(F5 顺带统一):aria 快照 ref、find 匹配、
|
|
493
|
+
* 拾取指纹(pick hint)、录制回声(recorder payload)共用同一份字段语义。
|
|
494
|
+
*/
|
|
495
|
+
function describeElement(el) {
|
|
496
|
+
const sem = resolveComponentSemantics(el);
|
|
497
|
+
const role = roleOf(el);
|
|
498
|
+
const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? "";
|
|
499
|
+
return {
|
|
500
|
+
role,
|
|
501
|
+
name: nameOf(el, role),
|
|
502
|
+
selector: cssPath(el),
|
|
503
|
+
testid,
|
|
504
|
+
id: el.id ?? "",
|
|
505
|
+
tag: el.tagName.toLowerCase(),
|
|
506
|
+
text: (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60),
|
|
507
|
+
visible: isVisible(el),
|
|
508
|
+
...sem.library ? { library: sem.library } : {},
|
|
509
|
+
...sem.component ? { component: sem.component } : {},
|
|
510
|
+
...sem.sourceFile ? { sourceFile: sem.sourceFile } : {},
|
|
511
|
+
...sem.formLabel ? { formLabel: sem.formLabel } : {}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
INTERACTIVE,
|
|
516
|
+
STRUCTURAL,
|
|
517
|
+
SKIP_TAGS,
|
|
518
|
+
roleOf,
|
|
519
|
+
nameOf,
|
|
520
|
+
cssPath,
|
|
521
|
+
isVisible,
|
|
522
|
+
describeElement,
|
|
523
|
+
resolveComponentSemantics
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/** walker 核心的序列化源码(模块加载期取一次;tsdown/vitest 均原样保留)。 */
|
|
527
|
+
const CORE_SRC = __walkerCore.toString();
|
|
528
|
+
/**
|
|
529
|
+
* 把消费方 entry(自包含函数,签名 (payload, w: WalkerApi) => R)拼装成
|
|
530
|
+
* 可被 page.evaluate 序列化的独立函数 (payload: P) => R。
|
|
531
|
+
* 拼装发生在模块加载期(new Function 一次性),调用期零开销。
|
|
532
|
+
*/
|
|
533
|
+
function injectWalker(entry) {
|
|
534
|
+
const src = `"use strict"; return function (payload) { const w = (${CORE_SRC})(); return (${entry.toString()})(payload, w); };`;
|
|
535
|
+
return new Function(src)();
|
|
536
|
+
}
|
|
537
|
+
//#endregion
|
|
194
538
|
//#region lib/types/browser/session.js
|
|
195
539
|
/** 当前活动 profile 名(默认 "default")。 */
|
|
196
540
|
let currentProfile = "default";
|
|
@@ -228,6 +572,35 @@ function snapshotCacheOf(page) {
|
|
|
228
572
|
function setSnapshotCache(page, cache) {
|
|
229
573
|
snapshotCaches.set(page, cache);
|
|
230
574
|
}
|
|
575
|
+
/** 环形容量钳制(1..20,非法值回落默认 5)。导出供测试。 */
|
|
576
|
+
function snapshotCapacity(k) {
|
|
577
|
+
const n = Math.floor(Number(k));
|
|
578
|
+
if (!Number.isFinite(n)) return 5;
|
|
579
|
+
return Math.min(Math.max(n, 1), 20);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* 追加一个新版本并淘汰超限的最旧版本(纯逻辑,导出供测试)。
|
|
583
|
+
* cache 为 undefined 时从 version 1 开始;返回新缓存(不修改入参)。
|
|
584
|
+
*/
|
|
585
|
+
function pushSnapshotVersion(cache, content, capacity) {
|
|
586
|
+
const version = (cache?.version ?? 0) + 1;
|
|
587
|
+
const k = snapshotCapacity(capacity);
|
|
588
|
+
const history = [...cache?.history ?? [], {
|
|
589
|
+
version,
|
|
590
|
+
text: content.text,
|
|
591
|
+
aria: content.aria
|
|
592
|
+
}];
|
|
593
|
+
if (history.length > k) history.splice(0, history.length - k);
|
|
594
|
+
return {
|
|
595
|
+
version,
|
|
596
|
+
history
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/** 按版本号取历史条目;不在环形窗口内(更旧/未知)返回 undefined。 */
|
|
600
|
+
function snapshotVersionEntry(cache, version) {
|
|
601
|
+
if (cache === void 0) return void 0;
|
|
602
|
+
return cache.history[cache.history.length - 1]?.version === version ? cache.history[cache.history.length - 1] : cache.history.find((e) => e.version === version);
|
|
603
|
+
}
|
|
231
604
|
/** 已保存的录制脚本:name → 步骤数组。 */
|
|
232
605
|
const savedRecordings = /* @__PURE__ */ new Map();
|
|
233
606
|
/** 当前录制中的 buffer(非空 = 录制中)。 */
|
|
@@ -300,6 +673,19 @@ function resolveConfirm(id, decision) {
|
|
|
300
673
|
pendingConfirm.decision = decision;
|
|
301
674
|
return true;
|
|
302
675
|
}
|
|
676
|
+
let toolInFlight = 0;
|
|
677
|
+
/** live 工具调用开始(register 包装层调用)。 */
|
|
678
|
+
function noteToolStart() {
|
|
679
|
+
toolInFlight++;
|
|
680
|
+
}
|
|
681
|
+
/** live 工具调用结束(成功/失败都算结束)。 */
|
|
682
|
+
function noteToolEnd() {
|
|
683
|
+
if (toolInFlight > 0) toolInFlight--;
|
|
684
|
+
}
|
|
685
|
+
/** 当前在途的 live 工具调用数(0 = agent 空闲)。 */
|
|
686
|
+
function activeToolCalls() {
|
|
687
|
+
return toolInFlight;
|
|
688
|
+
}
|
|
303
689
|
/** window 上的回传 binding 名(须为合法 JS 标识符)。 */
|
|
304
690
|
const HUMAN_BINDING = "__dshBrowserHumanEvent";
|
|
305
691
|
/** 最近一次 agent DOM 操作(点击/输入/选择)的时间戳;用于区分人手与工具回声。 */
|
|
@@ -390,33 +776,16 @@ function handleHumanEvent(payload) {
|
|
|
390
776
|
* 页内注入的人工操作监听(addInitScript 序列化;每次导航自动重装)。
|
|
391
777
|
* 监听常驻但 node 侧只在录制中生效,关闭录制等价于"移除"。
|
|
392
778
|
* 只上报可交互元素上的 click 与表单控件的 change,避免逐键噪音。
|
|
779
|
+
* F5 收敛:cssPath 等元素描述来自 walker.ts 单一来源(构建期拼装注入),
|
|
780
|
+
* 不再持有内联副本;payload 字段语义与 v0.3 完全一致。
|
|
393
781
|
*/
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
782
|
+
const installHumanRecorder = injectWalker((_payload, w) => {
|
|
783
|
+
const cssPath = w.cssPath;
|
|
784
|
+
const w2 = window;
|
|
785
|
+
if (w2.__dshHumanRecorder === true) return;
|
|
786
|
+
w2.__dshHumanRecorder = true;
|
|
398
787
|
const BINDING = "__dshBrowserHumanEvent";
|
|
399
788
|
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
789
|
function payloadOf(el, kind, value) {
|
|
421
790
|
const aria = el.getAttribute("aria-label");
|
|
422
791
|
return {
|
|
@@ -433,7 +802,7 @@ function installHumanRecorder() {
|
|
|
433
802
|
}
|
|
434
803
|
function send(payload) {
|
|
435
804
|
try {
|
|
436
|
-
const fn =
|
|
805
|
+
const fn = w2[BINDING];
|
|
437
806
|
if (typeof fn === "function") Promise.resolve(fn(payload)).catch(() => {});
|
|
438
807
|
} catch {}
|
|
439
808
|
}
|
|
@@ -455,7 +824,7 @@ function installHumanRecorder() {
|
|
|
455
824
|
send(payloadOf(t, "input", t.value));
|
|
456
825
|
}
|
|
457
826
|
}, true);
|
|
458
|
-
}
|
|
827
|
+
});
|
|
459
828
|
const networkByProfile = /* @__PURE__ */ new Map();
|
|
460
829
|
function networkOf(name) {
|
|
461
830
|
let list = networkByProfile.get(name);
|
|
@@ -772,12 +1141,16 @@ async function closeBrowser() {
|
|
|
772
1141
|
profileStates.clear();
|
|
773
1142
|
downloadsByProfile.clear();
|
|
774
1143
|
}
|
|
775
|
-
/** Best-effort current page identity, safe when nothing is open. */
|
|
1144
|
+
/** Best-effort current page identity, safe when nothing is open.title 有界防页面忙时挂起。 */
|
|
776
1145
|
async function pageIdentity(page) {
|
|
777
1146
|
try {
|
|
1147
|
+
const title = await new Promise((resolve, reject) => {
|
|
1148
|
+
const t = setTimeout(() => reject(/* @__PURE__ */ new Error("title timeout")), 2e3);
|
|
1149
|
+
page.title().then(resolve, reject).finally(() => clearTimeout(t));
|
|
1150
|
+
}).catch(() => "");
|
|
778
1151
|
return {
|
|
779
1152
|
url: page.url(),
|
|
780
|
-
title
|
|
1153
|
+
title
|
|
781
1154
|
};
|
|
782
1155
|
} catch {
|
|
783
1156
|
return {
|
|
@@ -1189,7 +1562,7 @@ function renderMarkdownReport(runs, source) {
|
|
|
1189
1562
|
"| 时间 | 来源 | 通过/总数 | 失败 | 自愈 | 耗时 |",
|
|
1190
1563
|
"| --- | --- | --- | --- | --- | --- |"
|
|
1191
1564
|
];
|
|
1192
|
-
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 |`);
|
|
1565
|
+
for (const r of runs) lines.push(`| ${fmtTs(r.ts)} | ${r.source}${r.row !== void 0 ? ` (行${r.row})` : ""}${r.retries !== void 0 ? ` (重试挽回 ${r.retries.recovered}/${r.retries.attempted})` : ""}${r.retryOf !== void 0 ? ` ↻${r.retryOf}` : ""} | ${r.passed}/${r.total} | ${r.failed} | ${r.healedCount} | ${(r.durationMs / 1e3).toFixed(1)}s |`);
|
|
1193
1566
|
const { failures, healed } = analyze(runs);
|
|
1194
1567
|
lines.push("", "## 失败步骤 Top", "");
|
|
1195
1568
|
if (failures.length === 0) lines.push("全部通过 🎉");
|
|
@@ -1210,7 +1583,7 @@ function renderHtmlReport(runs, source) {
|
|
|
1210
1583
|
const { failures, healed } = analyze(runs);
|
|
1211
1584
|
const rowHtml = runs.map((r) => {
|
|
1212
1585
|
const ok = r.failed === 0;
|
|
1213
|
-
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>`;
|
|
1586
|
+
return `<tr><td>${esc(fmtTs(r.ts))}</td><td>${esc(r.source)}${r.row !== void 0 ? ` (行${r.row})` : ""}${r.retries !== void 0 ? ` <span class="meta">(重试挽回 ${r.retries.recovered}/${r.retries.attempted})</span>` : ""}${r.retryOf !== void 0 ? ` <span class="meta" title="重跑自 ${esc(r.retryOf)}">↻</span>` : ""}</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>`;
|
|
1214
1587
|
}).join("\n");
|
|
1215
1588
|
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");
|
|
1216
1589
|
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");
|
|
@@ -1301,160 +1674,14 @@ function compressText(text) {
|
|
|
1301
1674
|
return out.join("\n");
|
|
1302
1675
|
}
|
|
1303
1676
|
/**
|
|
1304
|
-
* 页内 ARIA 树构建(
|
|
1677
|
+
* 页内 ARIA 树构建(walker 单一来源:roleOf/nameOf/cssPath/isVisible 与
|
|
1678
|
+
* INTERACTIVE/STRUCTURAL 集合来自 walker.ts,构建期拼装注入)。
|
|
1305
1679
|
* 遍历 DOM:结构角色(navigation/main/form/table/heading…)直接打印;
|
|
1306
1680
|
* 可交互元素分配 ref=e1..eN 并附稳定 CSS 路径;无语义容器(div/span)穿透。
|
|
1307
1681
|
* 输出形如 `- button "登录" [ref=e3]` 的缩进树,供纯文本模型定位操作目标。
|
|
1308
1682
|
*/
|
|
1309
|
-
|
|
1310
|
-
const INTERACTIVE
|
|
1311
|
-
"link",
|
|
1312
|
-
"button",
|
|
1313
|
-
"textbox",
|
|
1314
|
-
"checkbox",
|
|
1315
|
-
"radio",
|
|
1316
|
-
"combobox",
|
|
1317
|
-
"listbox",
|
|
1318
|
-
"searchbox",
|
|
1319
|
-
"slider",
|
|
1320
|
-
"spinbutton",
|
|
1321
|
-
"switch",
|
|
1322
|
-
"tab",
|
|
1323
|
-
"menuitem",
|
|
1324
|
-
"option",
|
|
1325
|
-
"treeitem"
|
|
1326
|
-
]);
|
|
1327
|
-
const STRUCTURAL = /* @__PURE__ */ new Set([
|
|
1328
|
-
"navigation",
|
|
1329
|
-
"main",
|
|
1330
|
-
"banner",
|
|
1331
|
-
"contentinfo",
|
|
1332
|
-
"form",
|
|
1333
|
-
"search",
|
|
1334
|
-
"complementary",
|
|
1335
|
-
"region",
|
|
1336
|
-
"dialog",
|
|
1337
|
-
"alert",
|
|
1338
|
-
"table",
|
|
1339
|
-
"list",
|
|
1340
|
-
"heading",
|
|
1341
|
-
"toolbar",
|
|
1342
|
-
"menu",
|
|
1343
|
-
"menubar",
|
|
1344
|
-
"tablist",
|
|
1345
|
-
"tree",
|
|
1346
|
-
"grid"
|
|
1347
|
-
]);
|
|
1348
|
-
const SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
1349
|
-
"script",
|
|
1350
|
-
"style",
|
|
1351
|
-
"template",
|
|
1352
|
-
"noscript",
|
|
1353
|
-
"svg",
|
|
1354
|
-
"path",
|
|
1355
|
-
"link",
|
|
1356
|
-
"meta",
|
|
1357
|
-
"br"
|
|
1358
|
-
]);
|
|
1359
|
-
function roleOf(el) {
|
|
1360
|
-
const explicit = el.getAttribute("role");
|
|
1361
|
-
if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
|
|
1362
|
-
switch (el.tagName.toLowerCase()) {
|
|
1363
|
-
case "a": return el.hasAttribute("href") ? "link" : "";
|
|
1364
|
-
case "button": return "button";
|
|
1365
|
-
case "input": {
|
|
1366
|
-
const t = (el.getAttribute("type") ?? "text").toLowerCase();
|
|
1367
|
-
if (t === "checkbox") return "checkbox";
|
|
1368
|
-
if (t === "radio") return "radio";
|
|
1369
|
-
if (t === "range") return "slider";
|
|
1370
|
-
if (t === "number") return "spinbutton";
|
|
1371
|
-
if (t === "search") return "searchbox";
|
|
1372
|
-
if ([
|
|
1373
|
-
"button",
|
|
1374
|
-
"submit",
|
|
1375
|
-
"reset",
|
|
1376
|
-
"image"
|
|
1377
|
-
].includes(t)) return "button";
|
|
1378
|
-
if (["hidden", "file"].includes(t)) return "";
|
|
1379
|
-
return "textbox";
|
|
1380
|
-
}
|
|
1381
|
-
case "textarea": return "textbox";
|
|
1382
|
-
case "select": return "combobox";
|
|
1383
|
-
case "nav": return "navigation";
|
|
1384
|
-
case "main": return "main";
|
|
1385
|
-
case "header": return "banner";
|
|
1386
|
-
case "footer": return "contentinfo";
|
|
1387
|
-
case "form": return "form";
|
|
1388
|
-
case "h1":
|
|
1389
|
-
case "h2":
|
|
1390
|
-
case "h3":
|
|
1391
|
-
case "h4":
|
|
1392
|
-
case "h5":
|
|
1393
|
-
case "h6": return "heading";
|
|
1394
|
-
case "ul":
|
|
1395
|
-
case "ol": return "list";
|
|
1396
|
-
case "li": return "listitem";
|
|
1397
|
-
case "table": return "table";
|
|
1398
|
-
case "dialog": return "dialog";
|
|
1399
|
-
case "summary": return "button";
|
|
1400
|
-
default: return "";
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
function nameOf(el, role) {
|
|
1404
|
-
const aria = el.getAttribute("aria-label");
|
|
1405
|
-
if (aria && aria.trim()) return aria.trim().slice(0, 60);
|
|
1406
|
-
const labelledby = el.getAttribute("aria-labelledby");
|
|
1407
|
-
if (labelledby) {
|
|
1408
|
-
const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
|
|
1409
|
-
if (t) return t.slice(0, 60);
|
|
1410
|
-
}
|
|
1411
|
-
if (el.id) {
|
|
1412
|
-
const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
|
|
1413
|
-
if (t) return t.slice(0, 60);
|
|
1414
|
-
}
|
|
1415
|
-
if ([
|
|
1416
|
-
"textbox",
|
|
1417
|
-
"searchbox",
|
|
1418
|
-
"combobox",
|
|
1419
|
-
"spinbutton"
|
|
1420
|
-
].includes(role)) {
|
|
1421
|
-
const ph = el.getAttribute("placeholder");
|
|
1422
|
-
if (ph && ph.trim()) return ph.trim().slice(0, 60);
|
|
1423
|
-
}
|
|
1424
|
-
const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
|
|
1425
|
-
if (text) return text.slice(0, 60);
|
|
1426
|
-
const title = el.getAttribute("title");
|
|
1427
|
-
if (title && title.trim()) return title.trim().slice(0, 60);
|
|
1428
|
-
return "";
|
|
1429
|
-
}
|
|
1430
|
-
/** 稳定 CSS 路径:#id → [data-testid] → nth-of-type 链(≤6 层)。 */
|
|
1431
|
-
function cssPath(el) {
|
|
1432
|
-
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
1433
|
-
const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
|
|
1434
|
-
if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
|
|
1435
|
-
const parts = [];
|
|
1436
|
-
let cur = el;
|
|
1437
|
-
while (cur && cur !== document.body && parts.length < 6) {
|
|
1438
|
-
const tag = cur.tagName.toLowerCase();
|
|
1439
|
-
const parent = cur.parentElement;
|
|
1440
|
-
if (!parent) {
|
|
1441
|
-
parts.unshift(tag);
|
|
1442
|
-
break;
|
|
1443
|
-
}
|
|
1444
|
-
const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
1445
|
-
const idx = sameTag.indexOf(cur) + 1;
|
|
1446
|
-
parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
|
|
1447
|
-
cur = parent;
|
|
1448
|
-
}
|
|
1449
|
-
return parts.join(" > ");
|
|
1450
|
-
}
|
|
1451
|
-
function isVisible(el) {
|
|
1452
|
-
if (el.getAttribute("aria-hidden") === "true") return false;
|
|
1453
|
-
const rect = el.getBoundingClientRect();
|
|
1454
|
-
if (rect.width === 0 && rect.height === 0) return false;
|
|
1455
|
-
const style = getComputedStyle(el);
|
|
1456
|
-
return style.visibility !== "hidden" && style.display !== "none";
|
|
1457
|
-
}
|
|
1683
|
+
const buildAriaTree = injectWalker((payload, w) => {
|
|
1684
|
+
const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible } = w;
|
|
1458
1685
|
const lines = [];
|
|
1459
1686
|
const refs = [];
|
|
1460
1687
|
let truncated = false;
|
|
@@ -1468,186 +1695,62 @@ function buildAriaTree(payload) {
|
|
|
1468
1695
|
if (INTERACTIVE.has(role)) {
|
|
1469
1696
|
if (!isVisible(el)) return;
|
|
1470
1697
|
const name = nameOf(el, role);
|
|
1698
|
+
const sem = w.resolveComponentSemantics(el);
|
|
1471
1699
|
const ref = refs.length < payload.maxRefs ? `e${refs.length + 1}` : "";
|
|
1472
1700
|
if (ref !== "") refs.push({
|
|
1473
1701
|
ref,
|
|
1474
1702
|
role,
|
|
1475
1703
|
name,
|
|
1476
|
-
selector: cssPath(el)
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${level}`);
|
|
1488
|
-
for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth + 1);
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1491
|
-
for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth);
|
|
1492
|
-
}
|
|
1493
|
-
if (document.body === null) return {
|
|
1494
|
-
tree: "",
|
|
1495
|
-
refs,
|
|
1496
|
-
truncated: false,
|
|
1497
|
-
scopeMissing: false
|
|
1498
|
-
};
|
|
1499
|
-
const root = payload.scope ? document.querySelector(payload.scope) : document.body;
|
|
1500
|
-
if (root === null) return {
|
|
1501
|
-
tree: "",
|
|
1502
|
-
refs,
|
|
1503
|
-
truncated: false,
|
|
1504
|
-
scopeMissing: true
|
|
1505
|
-
};
|
|
1506
|
-
walk(root, 0);
|
|
1507
|
-
return {
|
|
1508
|
-
tree: lines.join("\n"),
|
|
1509
|
-
refs,
|
|
1510
|
-
truncated,
|
|
1511
|
-
scopeMissing: false
|
|
1512
|
-
};
|
|
1513
|
-
}
|
|
1514
|
-
/**
|
|
1515
|
-
* 页内语义查找:只收集匹配 query 的可交互元素(不构建全量树)。
|
|
1516
|
-
* role/name/cssPath/isVisible 与 buildAriaTree 保持同一套语义(此处内联副本,
|
|
1517
|
-
* evaluate 序列化不允许闭包引用);改动任一处时同步另一处。
|
|
1518
|
-
*/
|
|
1519
|
-
function findInteractive(payload) {
|
|
1520
|
-
const INTERACTIVE = /* @__PURE__ */ new Set([
|
|
1521
|
-
"link",
|
|
1522
|
-
"button",
|
|
1523
|
-
"textbox",
|
|
1524
|
-
"checkbox",
|
|
1525
|
-
"radio",
|
|
1526
|
-
"combobox",
|
|
1527
|
-
"listbox",
|
|
1528
|
-
"searchbox",
|
|
1529
|
-
"slider",
|
|
1530
|
-
"spinbutton",
|
|
1531
|
-
"switch",
|
|
1532
|
-
"tab",
|
|
1533
|
-
"menuitem",
|
|
1534
|
-
"option",
|
|
1535
|
-
"treeitem"
|
|
1536
|
-
]);
|
|
1537
|
-
const STRUCTURAL = /* @__PURE__ */ new Set([
|
|
1538
|
-
"navigation",
|
|
1539
|
-
"main",
|
|
1540
|
-
"banner",
|
|
1541
|
-
"contentinfo",
|
|
1542
|
-
"form",
|
|
1543
|
-
"search",
|
|
1544
|
-
"complementary",
|
|
1545
|
-
"region",
|
|
1546
|
-
"dialog",
|
|
1547
|
-
"alert",
|
|
1548
|
-
"table",
|
|
1549
|
-
"list",
|
|
1550
|
-
"heading",
|
|
1551
|
-
"toolbar",
|
|
1552
|
-
"menu",
|
|
1553
|
-
"menubar",
|
|
1554
|
-
"tablist",
|
|
1555
|
-
"tree",
|
|
1556
|
-
"grid"
|
|
1557
|
-
]);
|
|
1558
|
-
const SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
1559
|
-
"script",
|
|
1560
|
-
"style",
|
|
1561
|
-
"template",
|
|
1562
|
-
"noscript",
|
|
1563
|
-
"svg",
|
|
1564
|
-
"path",
|
|
1565
|
-
"link",
|
|
1566
|
-
"meta",
|
|
1567
|
-
"br"
|
|
1568
|
-
]);
|
|
1569
|
-
function roleOf(el) {
|
|
1570
|
-
const explicit = el.getAttribute("role");
|
|
1571
|
-
if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
|
|
1572
|
-
switch (el.tagName.toLowerCase()) {
|
|
1573
|
-
case "a": return el.hasAttribute("href") ? "link" : "";
|
|
1574
|
-
case "button": return "button";
|
|
1575
|
-
case "input": {
|
|
1576
|
-
const t = (el.getAttribute("type") ?? "text").toLowerCase();
|
|
1577
|
-
if (t === "checkbox") return "checkbox";
|
|
1578
|
-
if (t === "radio") return "radio";
|
|
1579
|
-
if (t === "range") return "slider";
|
|
1580
|
-
if (t === "number") return "spinbutton";
|
|
1581
|
-
if (t === "search") return "searchbox";
|
|
1582
|
-
if ([
|
|
1583
|
-
"button",
|
|
1584
|
-
"submit",
|
|
1585
|
-
"reset",
|
|
1586
|
-
"image"
|
|
1587
|
-
].includes(t)) return "button";
|
|
1588
|
-
if (["hidden", "file"].includes(t)) return "";
|
|
1589
|
-
return "textbox";
|
|
1590
|
-
}
|
|
1591
|
-
case "textarea": return "textbox";
|
|
1592
|
-
case "select": return "combobox";
|
|
1593
|
-
case "summary": return "button";
|
|
1594
|
-
default: return "";
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
|
-
function nameOf(el, role) {
|
|
1598
|
-
const aria = el.getAttribute("aria-label");
|
|
1599
|
-
if (aria && aria.trim()) return aria.trim().slice(0, 60);
|
|
1600
|
-
const labelledby = el.getAttribute("aria-labelledby");
|
|
1601
|
-
if (labelledby) {
|
|
1602
|
-
const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
|
|
1603
|
-
if (t) return t.slice(0, 60);
|
|
1604
|
-
}
|
|
1605
|
-
if (el.id) {
|
|
1606
|
-
const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
|
|
1607
|
-
if (t) return t.slice(0, 60);
|
|
1608
|
-
}
|
|
1609
|
-
if ([
|
|
1610
|
-
"textbox",
|
|
1611
|
-
"searchbox",
|
|
1612
|
-
"combobox",
|
|
1613
|
-
"spinbutton"
|
|
1614
|
-
].includes(role)) {
|
|
1615
|
-
const ph = el.getAttribute("placeholder");
|
|
1616
|
-
if (ph && ph.trim()) return ph.trim().slice(0, 60);
|
|
1704
|
+
selector: cssPath(el),
|
|
1705
|
+
...sem.library ? { library: sem.library } : {},
|
|
1706
|
+
...sem.component ? { component: sem.component } : {},
|
|
1707
|
+
...sem.sourceFile ? { sourceFile: sem.sourceFile } : {}
|
|
1708
|
+
});
|
|
1709
|
+
const expanded = el.getAttribute("aria-expanded");
|
|
1710
|
+
const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
|
|
1711
|
+
const compLabel = sem.component ? ` [comp=${sem.component}]` : "";
|
|
1712
|
+
const srcLabel = sem.sourceFile ? ` [src=${sem.sourceFile.split("/").pop()}]` : "";
|
|
1713
|
+
lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${ref !== "" ? ` [ref=${ref}]` : ""}${compLabel}${srcLabel}${expanded !== null ? ` [expanded=${expanded}]` : ""}${disabled ? " [disabled]" : ""}`);
|
|
1714
|
+
return;
|
|
1617
1715
|
}
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
1626
|
-
const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
|
|
1627
|
-
if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
|
|
1628
|
-
const parts = [];
|
|
1629
|
-
let cur = el;
|
|
1630
|
-
while (cur && cur !== document.body && parts.length < 6) {
|
|
1631
|
-
const tag = cur.tagName.toLowerCase();
|
|
1632
|
-
const parent = cur.parentElement;
|
|
1633
|
-
if (!parent) {
|
|
1634
|
-
parts.unshift(tag);
|
|
1635
|
-
break;
|
|
1636
|
-
}
|
|
1637
|
-
const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
1638
|
-
const idx = sameTag.indexOf(cur) + 1;
|
|
1639
|
-
parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
|
|
1640
|
-
cur = parent;
|
|
1716
|
+
if (STRUCTURAL.has(role)) {
|
|
1717
|
+
if (!isVisible(el)) return;
|
|
1718
|
+
const name = nameOf(el, role);
|
|
1719
|
+
const level = role === "heading" ? ` [level=${el.tagName[1] ?? "1"}]` : "";
|
|
1720
|
+
lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${level}`);
|
|
1721
|
+
for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth + 1);
|
|
1722
|
+
return;
|
|
1641
1723
|
}
|
|
1642
|
-
|
|
1643
|
-
}
|
|
1644
|
-
function isVisible(el) {
|
|
1645
|
-
if (el.getAttribute("aria-hidden") === "true") return false;
|
|
1646
|
-
const rect = el.getBoundingClientRect();
|
|
1647
|
-
if (rect.width === 0 && rect.height === 0) return false;
|
|
1648
|
-
const style = getComputedStyle(el);
|
|
1649
|
-
return style.visibility !== "hidden" && style.display !== "none";
|
|
1724
|
+
for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth);
|
|
1650
1725
|
}
|
|
1726
|
+
if (document.body === null) return {
|
|
1727
|
+
tree: "",
|
|
1728
|
+
refs,
|
|
1729
|
+
truncated: false,
|
|
1730
|
+
scopeMissing: false
|
|
1731
|
+
};
|
|
1732
|
+
const root = payload.scope ? document.querySelector(payload.scope) : document.body;
|
|
1733
|
+
if (root === null) return {
|
|
1734
|
+
tree: "",
|
|
1735
|
+
refs,
|
|
1736
|
+
truncated: false,
|
|
1737
|
+
scopeMissing: true
|
|
1738
|
+
};
|
|
1739
|
+
walk(root, 0);
|
|
1740
|
+
return {
|
|
1741
|
+
tree: lines.join("\n"),
|
|
1742
|
+
refs,
|
|
1743
|
+
truncated,
|
|
1744
|
+
scopeMissing: false
|
|
1745
|
+
};
|
|
1746
|
+
});
|
|
1747
|
+
/**
|
|
1748
|
+
* 页内语义查找:只收集匹配 query 的可交互元素(不构建全量树)。
|
|
1749
|
+
* role/name/cssPath/isVisible 与 buildAriaTree 同一来源(walker.ts 单一来源,
|
|
1750
|
+
* 构建期拼装注入),语义天然一致,不再需要手工同步副本。
|
|
1751
|
+
*/
|
|
1752
|
+
const findInteractive = injectWalker((payload, w) => {
|
|
1753
|
+
const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible, resolveComponentSemantics } = w;
|
|
1651
1754
|
/** 最近的结构化祖先(区域上下文,帮助区分多命中)。 */
|
|
1652
1755
|
function ancestorsOf(el) {
|
|
1653
1756
|
const out = [];
|
|
@@ -1662,7 +1765,10 @@ function findInteractive(payload) {
|
|
|
1662
1765
|
}
|
|
1663
1766
|
return out;
|
|
1664
1767
|
}
|
|
1665
|
-
const q = payload.query.trim().toLowerCase();
|
|
1768
|
+
const q = (payload.query || "").trim().toLowerCase();
|
|
1769
|
+
const filterComp = (payload.component || "").trim().toLowerCase();
|
|
1770
|
+
const filterLib = (payload.library || "").trim().toLowerCase();
|
|
1771
|
+
const filterFile = (payload.file || "").trim().toLowerCase();
|
|
1666
1772
|
const matches = [];
|
|
1667
1773
|
const candidates = [];
|
|
1668
1774
|
let scanned = 0;
|
|
@@ -1679,13 +1785,30 @@ function findInteractive(payload) {
|
|
|
1679
1785
|
if (INTERACTIVE.has(role)) {
|
|
1680
1786
|
if (!isVisible(el)) continue;
|
|
1681
1787
|
const name = nameOf(el, role);
|
|
1788
|
+
const sem = resolveComponentSemantics(el);
|
|
1682
1789
|
const idOrTestid = el.id || el.getAttribute("data-testid") || "";
|
|
1683
|
-
let
|
|
1684
|
-
if (
|
|
1685
|
-
const
|
|
1686
|
-
|
|
1790
|
+
let filterPass = true;
|
|
1791
|
+
if (filterComp) {
|
|
1792
|
+
const comp = (sem.component || "").toLowerCase();
|
|
1793
|
+
if (filterComp.startsWith("*")) filterPass = comp.endsWith(filterComp.slice(1));
|
|
1794
|
+
else if (filterComp.endsWith("*")) filterPass = comp.startsWith(filterComp.slice(0, -1));
|
|
1795
|
+
else filterPass = comp === filterComp;
|
|
1687
1796
|
}
|
|
1688
|
-
if (
|
|
1797
|
+
if (filterPass && filterLib) filterPass = (sem.library || "").toLowerCase() === filterLib;
|
|
1798
|
+
if (filterPass && filterFile) filterPass = (sem.sourceFile || "").toLowerCase().includes(filterFile);
|
|
1799
|
+
if (!filterPass) continue;
|
|
1800
|
+
let hit = false;
|
|
1801
|
+
if (q) {
|
|
1802
|
+
if (name !== "") {
|
|
1803
|
+
const n = name.toLowerCase();
|
|
1804
|
+
hit = payload.exact ? n === q : n.includes(q);
|
|
1805
|
+
}
|
|
1806
|
+
if (!hit && idOrTestid !== "" && idOrTestid.toLowerCase() === q) hit = true;
|
|
1807
|
+
if (!hit && sem.formLabel && sem.formLabel.toLowerCase().includes(q)) hit = true;
|
|
1808
|
+
if (!hit && sem.placeholder && sem.placeholder.toLowerCase().includes(q)) hit = true;
|
|
1809
|
+
if (!hit && sem.component && sem.component.toLowerCase() === q) hit = true;
|
|
1810
|
+
if (!hit && sem.sourceFile && sem.sourceFile.toLowerCase().includes(q)) hit = true;
|
|
1811
|
+
} else hit = true;
|
|
1689
1812
|
scanned += 1;
|
|
1690
1813
|
if (name !== "" && candidates.length < 50) candidates.push({
|
|
1691
1814
|
role,
|
|
@@ -1695,7 +1818,11 @@ function findInteractive(payload) {
|
|
|
1695
1818
|
role,
|
|
1696
1819
|
name,
|
|
1697
1820
|
selector: cssPath(el),
|
|
1698
|
-
ancestors: ancestorsOf(el)
|
|
1821
|
+
ancestors: ancestorsOf(el),
|
|
1822
|
+
...sem.library ? { library: sem.library } : {},
|
|
1823
|
+
...sem.component ? { component: sem.component } : {},
|
|
1824
|
+
...sem.sourceFile ? { sourceFile: sem.sourceFile } : {},
|
|
1825
|
+
...sem.formLabel ? { formLabel: sem.formLabel } : {}
|
|
1699
1826
|
});
|
|
1700
1827
|
continue;
|
|
1701
1828
|
}
|
|
@@ -1706,7 +1833,7 @@ function findInteractive(payload) {
|
|
|
1706
1833
|
candidates,
|
|
1707
1834
|
scanned
|
|
1708
1835
|
};
|
|
1709
|
-
}
|
|
1836
|
+
});
|
|
1710
1837
|
/** 最长公共子串长度(小写比较;零依赖,导出供测试)。 */
|
|
1711
1838
|
function lcsScore(a, b) {
|
|
1712
1839
|
const x = a.toLowerCase();
|
|
@@ -1761,7 +1888,10 @@ function assignRefs(existing, matches, maxRefs) {
|
|
|
1761
1888
|
merged.set(ref, {
|
|
1762
1889
|
role: m.role,
|
|
1763
1890
|
name: m.name,
|
|
1764
|
-
selector: m.selector
|
|
1891
|
+
selector: m.selector,
|
|
1892
|
+
...m.library ? { library: m.library } : {},
|
|
1893
|
+
...m.component ? { component: m.component } : {},
|
|
1894
|
+
...m.sourceFile ? { sourceFile: m.sourceFile } : {}
|
|
1765
1895
|
});
|
|
1766
1896
|
withRefs.push({
|
|
1767
1897
|
ref,
|
|
@@ -1775,32 +1905,42 @@ function assignRefs(existing, matches, maxRefs) {
|
|
|
1775
1905
|
};
|
|
1776
1906
|
}
|
|
1777
1907
|
/**
|
|
1778
|
-
* 语义元素定位:按名称(或 id/data-testid)在可交互元素中查找,只返回命中的
|
|
1908
|
+
* 语义元素定位:按名称(或 id/data-testid/组件名/源码文件)在可交互元素中查找,只返回命中的
|
|
1779
1909
|
* ref + 区域上下文,不输出全量 aria 树 —— 大页面定位单个目标时 token 开销
|
|
1780
1910
|
* 远小于 browser_snapshot { mode: "aria" }。返回的 ref 可直接用于
|
|
1781
1911
|
* click/type/select/press。零命中不抛错,附最相近候选供二次定位。
|
|
1782
1912
|
*/
|
|
1783
1913
|
async function findTool(config, args) {
|
|
1784
1914
|
const query = typeof args.query === "string" ? args.query.trim() : "";
|
|
1785
|
-
|
|
1915
|
+
const component = typeof args.component === "string" ? args.component.trim() : "";
|
|
1916
|
+
const library = typeof args.library === "string" ? args.library.trim() : "";
|
|
1917
|
+
const file = typeof args.file === "string" ? args.file.trim() : "";
|
|
1918
|
+
if (!query && !component && !file) throw new Error("缺少定位参数: 请提供 query(元素名称/id/data-testid), 或 component(如 ASelect/ElSelect), 或 file(源码文件名)");
|
|
1786
1919
|
const page = await getPage(config);
|
|
1787
1920
|
const maxRefs = Math.min(Math.max(Number(args.maxRefs ?? config.maxRefs ?? 200) || 200, 1), 500);
|
|
1788
1921
|
const data = await page.evaluate(findInteractive, {
|
|
1789
1922
|
query,
|
|
1790
1923
|
exact: args.exact === true,
|
|
1791
1924
|
scope: typeof args.scope === "string" && args.scope ? args.scope : "",
|
|
1792
|
-
limit: 20
|
|
1925
|
+
limit: 20,
|
|
1926
|
+
component,
|
|
1927
|
+
library,
|
|
1928
|
+
file
|
|
1793
1929
|
});
|
|
1794
1930
|
const { withRefs, merged, truncated } = assignRefs(pageRefsOf(page), data.matches, maxRefs);
|
|
1795
1931
|
setPageRefs(page, merged);
|
|
1796
|
-
const suggestions = withRefs.length === 0 ? topSuggestions(query, data.candidates) : [];
|
|
1932
|
+
const suggestions = withRefs.length === 0 ? topSuggestions(query || component || file, data.candidates) : [];
|
|
1797
1933
|
return {
|
|
1798
1934
|
found: withRefs.length,
|
|
1799
|
-
matches: withRefs.slice(0, 10).map(({ ref, role, name, ancestors }) => ({
|
|
1935
|
+
matches: withRefs.slice(0, 10).map(({ ref, role, name, ancestors, library, component, sourceFile, formLabel }) => ({
|
|
1800
1936
|
ref,
|
|
1801
1937
|
role,
|
|
1802
1938
|
name,
|
|
1803
|
-
ancestors
|
|
1939
|
+
ancestors,
|
|
1940
|
+
...library ? { library } : {},
|
|
1941
|
+
...component ? { component } : {},
|
|
1942
|
+
...sourceFile ? { sourceFile } : {},
|
|
1943
|
+
...formLabel ? { formLabel } : {}
|
|
1804
1944
|
})),
|
|
1805
1945
|
...suggestions.length > 0 ? { suggestions } : {},
|
|
1806
1946
|
...truncated ? { truncated } : {},
|
|
@@ -1839,7 +1979,9 @@ async function snapshotTool(config, args) {
|
|
|
1839
1979
|
title: await page.title()
|
|
1840
1980
|
};
|
|
1841
1981
|
const cache = snapshotCacheOf(page);
|
|
1842
|
-
const
|
|
1982
|
+
const capacity = config.snapshotHistory ?? 5;
|
|
1983
|
+
const base = since !== void 0 ? snapshotVersionEntry(cache, since) : void 0;
|
|
1984
|
+
const canDiff = base !== void 0;
|
|
1843
1985
|
let content;
|
|
1844
1986
|
let extra = {};
|
|
1845
1987
|
let walkerTruncated = false;
|
|
@@ -1885,7 +2027,7 @@ async function snapshotTool(config, args) {
|
|
|
1885
2027
|
};
|
|
1886
2028
|
}
|
|
1887
2029
|
const key = ariaMode ? "aria" : "text";
|
|
1888
|
-
if (canDiff &&
|
|
2030
|
+
if (canDiff && base[key] === content) return {
|
|
1889
2031
|
unchanged: true,
|
|
1890
2032
|
contentVersion: cache.version,
|
|
1891
2033
|
...scope !== "" ? { scope } : {},
|
|
@@ -1893,22 +2035,23 @@ async function snapshotTool(config, args) {
|
|
|
1893
2035
|
...extra
|
|
1894
2036
|
};
|
|
1895
2037
|
if (canDiff) {
|
|
1896
|
-
const d = diffLines(
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
text: ariaMode ?
|
|
1900
|
-
aria: ariaMode ? content :
|
|
1901
|
-
});
|
|
2038
|
+
const d = diffLines(base[key], content);
|
|
2039
|
+
const latest = cache.history[cache.history.length - 1];
|
|
2040
|
+
const next = pushSnapshotVersion(cache, {
|
|
2041
|
+
text: ariaMode ? latest.text : content,
|
|
2042
|
+
aria: ariaMode ? content : latest.aria
|
|
2043
|
+
}, capacity);
|
|
2044
|
+
setSnapshotCache(page, next);
|
|
1902
2045
|
if (d.unchanged) return {
|
|
1903
2046
|
unchanged: true,
|
|
1904
|
-
contentVersion:
|
|
2047
|
+
contentVersion: next.version,
|
|
1905
2048
|
...scope !== "" ? { scope } : {},
|
|
1906
2049
|
...identity,
|
|
1907
2050
|
...extra
|
|
1908
2051
|
};
|
|
1909
2052
|
return {
|
|
1910
2053
|
diff: true,
|
|
1911
|
-
contentVersion:
|
|
2054
|
+
contentVersion: next.version,
|
|
1912
2055
|
changedFrom: d.changedFrom,
|
|
1913
2056
|
removed: d.removed,
|
|
1914
2057
|
...scope !== "" ? { scope } : {},
|
|
@@ -1918,13 +2061,14 @@ async function snapshotTool(config, args) {
|
|
|
1918
2061
|
...extra
|
|
1919
2062
|
};
|
|
1920
2063
|
}
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
text: ariaMode ?
|
|
1924
|
-
aria: ariaMode ? content :
|
|
1925
|
-
});
|
|
2064
|
+
const latestEntry = cache?.history[cache.history.length - 1];
|
|
2065
|
+
const next = pushSnapshotVersion(cache, {
|
|
2066
|
+
text: ariaMode ? latestEntry?.text ?? "" : content,
|
|
2067
|
+
aria: ariaMode ? content : latestEntry?.aria ?? ""
|
|
2068
|
+
}, capacity);
|
|
2069
|
+
setSnapshotCache(page, next);
|
|
1926
2070
|
return {
|
|
1927
|
-
contentVersion:
|
|
2071
|
+
contentVersion: next.version,
|
|
1928
2072
|
...since !== void 0 ? { stale: true } : {},
|
|
1929
2073
|
...scope !== "" ? { scope } : {},
|
|
1930
2074
|
[ariaMode ? "tree" : "text"]: content.slice(0, maxText),
|
|
@@ -1944,8 +2088,8 @@ async function targetLocator(page, args) {
|
|
|
1944
2088
|
const entry = pageRef(page, ref);
|
|
1945
2089
|
if (entry === void 0) throw new Error(`未知 ref: ${ref}(页面导航或重新快照后旧 ref 失效;请先 browser_snapshot { mode: "aria" })`);
|
|
1946
2090
|
const byPath = page.locator(entry.selector).first();
|
|
1947
|
-
if (await byPath.count().catch(() => 0) > 0) {
|
|
1948
|
-
if (await byPath.evaluate((el, want) => {
|
|
2091
|
+
if (await withTimeout(byPath.count(), 3e3, "解析 ref 路径").catch(() => 0) > 0) {
|
|
2092
|
+
if (await withTimeout(byPath.evaluate((el, want) => {
|
|
1949
2093
|
const tag = el.tagName.toLowerCase();
|
|
1950
2094
|
const type = (el.getAttribute("type") ?? "").toLowerCase();
|
|
1951
2095
|
const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
|
|
@@ -1967,10 +2111,10 @@ async function targetLocator(page, args) {
|
|
|
1967
2111
|
}, {
|
|
1968
2112
|
role: entry.role,
|
|
1969
2113
|
name: entry.name
|
|
1970
|
-
}).catch(() => false)) return byPath;
|
|
2114
|
+
}), 3e3, "校验 ref 元素").catch(() => false)) return byPath;
|
|
1971
2115
|
}
|
|
1972
2116
|
const byRole = entry.name !== "" ? page.getByRole(entry.role, { name: entry.name }).first() : page.getByRole(entry.role).first();
|
|
1973
|
-
if (await byRole.count().catch(() => 0) > 0) return byRole;
|
|
2117
|
+
if (await withTimeout(byRole.count(), 3e3, "解析 ref role 降级").catch(() => 0) > 0) return byRole;
|
|
1974
2118
|
throw new Error(`ref ${ref} 已失效(元素不存在或内容已变化);请重新 browser_snapshot { mode: "aria" }`);
|
|
1975
2119
|
}
|
|
1976
2120
|
if (args.selector === void 0) throw new Error("需要 selector 或 ref 之一");
|
|
@@ -2026,12 +2170,26 @@ async function pressTool(config, args) {
|
|
|
2026
2170
|
...await pageIdentity(page)
|
|
2027
2171
|
};
|
|
2028
2172
|
}
|
|
2029
|
-
/** 组件库下拉框的可见选项选择器并集(antd / element-plus / 通用 ARIA listbox)。 */
|
|
2173
|
+
/** 组件库下拉框的可见选项选择器并集(antd / element-plus / naive-ui / arco-design / 通用 ARIA listbox)。 */
|
|
2030
2174
|
const DROPDOWN_OPTION_SELECTOR = [
|
|
2031
2175
|
".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option",
|
|
2032
2176
|
".el-select-dropdown__item",
|
|
2177
|
+
".n-base-select-option",
|
|
2178
|
+
".arco-select-option",
|
|
2033
2179
|
"[role='listbox'] [role='option']"
|
|
2034
2180
|
].join(", ");
|
|
2181
|
+
/** 给本身无超时的 Playwright 调用(evaluate/allTextContents)加有界保护,防页面主线程忙时无限挂起。 */
|
|
2182
|
+
async function withTimeout(p, ms, what) {
|
|
2183
|
+
let timer;
|
|
2184
|
+
const guard = new Promise((_, reject) => {
|
|
2185
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${what} 超时(${ms}ms;页面主线程可能正忙)`)), ms);
|
|
2186
|
+
});
|
|
2187
|
+
try {
|
|
2188
|
+
return await Promise.race([p, guard]);
|
|
2189
|
+
} finally {
|
|
2190
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2035
2193
|
/**
|
|
2036
2194
|
* 下拉选择原语:原生 <select> 走 selectOption;组件库下拉(antd/element/listbox)
|
|
2037
2195
|
* 走「点击触发框 → 等待可见选项 → 按文本点击」。找不到选项时抛出错误并附当前可见
|
|
@@ -2045,7 +2203,8 @@ async function selectTool(config, args) {
|
|
|
2045
2203
|
if (!want) throw new Error("select 需要 option(选项文本)");
|
|
2046
2204
|
const exact = args.exact === true;
|
|
2047
2205
|
const timeout = args.timeoutMs ?? config.timeoutMs;
|
|
2048
|
-
|
|
2206
|
+
const probe = Math.min(timeout, 5e3);
|
|
2207
|
+
if (await withTimeout(target.evaluate((el) => el.tagName.toLowerCase()), probe, "读取目标元素").catch(() => "") === "select") try {
|
|
2049
2208
|
await target.selectOption({ label: want }, { timeout });
|
|
2050
2209
|
return {
|
|
2051
2210
|
selected: want,
|
|
@@ -2053,7 +2212,7 @@ async function selectTool(config, args) {
|
|
|
2053
2212
|
...await pageIdentity(page)
|
|
2054
2213
|
};
|
|
2055
2214
|
} catch {
|
|
2056
|
-
const options = await target.locator("option").allTextContents();
|
|
2215
|
+
const options = await withTimeout(target.locator("option").allTextContents(), probe, "读取原生下拉选项").catch(() => []);
|
|
2057
2216
|
throw new Error(`原生下拉无选项 "${want}";可用选项: ${options.map((t) => t.trim()).join(" / ")}`);
|
|
2058
2217
|
}
|
|
2059
2218
|
const clickTimeout = Math.min(timeout, 1e4);
|
|
@@ -2074,14 +2233,14 @@ async function selectTool(config, args) {
|
|
|
2074
2233
|
state: "visible",
|
|
2075
2234
|
timeout: Math.min(timeout, 5e3)
|
|
2076
2235
|
}).catch(() => {});
|
|
2077
|
-
const texts = (await options.allTextContents()).map((t) => t.trim()).filter(Boolean);
|
|
2236
|
+
const texts = (await withTimeout(options.allTextContents(), probe, "读取下拉选项")).map((t) => t.trim()).filter(Boolean);
|
|
2078
2237
|
if (texts.length === 0) throw new Error(`点击 ${label} 后未出现下拉选项(非原生 select,也不是已知的组件库下拉)`);
|
|
2079
2238
|
const index = texts.findIndex((t) => exact ? t === want : t.includes(want));
|
|
2080
2239
|
if (index === -1) {
|
|
2081
2240
|
await page.keyboard.press("Escape").catch(() => {});
|
|
2082
2241
|
throw new Error(`下拉选项中无 "${want}";可见选项: ${texts.join(" / ")}`);
|
|
2083
2242
|
}
|
|
2084
|
-
await options.nth(index).click();
|
|
2243
|
+
await options.nth(index).click({ timeout: probe });
|
|
2085
2244
|
return {
|
|
2086
2245
|
selected: texts[index],
|
|
2087
2246
|
selector: label,
|
|
@@ -3081,6 +3240,69 @@ async function confirmTool(config, args) {
|
|
|
3081
3240
|
detail
|
|
3082
3241
|
};
|
|
3083
3242
|
}
|
|
3243
|
+
/** auto 模式默认敏感文本模式(提交/删除/支付类)。 */
|
|
3244
|
+
const DEFAULT_SENSITIVE_PATTERNS = [
|
|
3245
|
+
"提交",
|
|
3246
|
+
"删除",
|
|
3247
|
+
"支付"
|
|
3248
|
+
];
|
|
3249
|
+
/** auto 模式默认参与判定的工具(已决:只拦 browser_click,type 误报率高)。 */
|
|
3250
|
+
const DEFAULT_SENSITIVE_TOOLS = ["browser_click"];
|
|
3251
|
+
/** 敏感判定的页内目标描述提取(walker 统一元素描述,构建期拼装注入)。 */
|
|
3252
|
+
const describeTarget = injectWalker((sel, w) => {
|
|
3253
|
+
const el = document.querySelector(sel);
|
|
3254
|
+
return el === null ? null : w.describeElement(el);
|
|
3255
|
+
});
|
|
3256
|
+
/**
|
|
3257
|
+
* 纯逻辑:元素描述命中了哪些敏感模式(role/name/text/id/testid/tag 拼接后
|
|
3258
|
+
* 子串匹配,大小写不敏感;空白模式忽略)。返回命中的模式列表(空 = 放行)。
|
|
3259
|
+
* 导出供测试。
|
|
3260
|
+
*/
|
|
3261
|
+
function sensitiveHits(desc, patterns) {
|
|
3262
|
+
if (desc === null) return [];
|
|
3263
|
+
const hay = [
|
|
3264
|
+
desc.role,
|
|
3265
|
+
desc.name,
|
|
3266
|
+
desc.text,
|
|
3267
|
+
desc.id,
|
|
3268
|
+
desc.testid,
|
|
3269
|
+
desc.tag
|
|
3270
|
+
].filter((s) => typeof s === "string").join(" ").toLowerCase();
|
|
3271
|
+
if (hay === "") return [];
|
|
3272
|
+
return patterns.filter((p) => typeof p === "string" && p.trim() !== "" && hay.includes(p.trim().toLowerCase()));
|
|
3273
|
+
}
|
|
3274
|
+
/**
|
|
3275
|
+
* auto 模式审批闸:命中敏感模式时经 confirmTool 弹审批等待用户决策,
|
|
3276
|
+
* 拒绝/超时抛错(调用方该步失败),允许则返回继续执行原工具。
|
|
3277
|
+
* 不满足前置条件(非 auto / 工具不在 sensitiveTools / 无模式 / 无活动页 /
|
|
3278
|
+
* 无目标 selector / 页内取描述失败)一律直接放行,由原工具自行处理错误。
|
|
3279
|
+
* page 参数仅供测试注入;缺省取当前活动页。
|
|
3280
|
+
*/
|
|
3281
|
+
async function confirmGate(config, tool, args, page) {
|
|
3282
|
+
if (config.confirmMode !== "auto") return;
|
|
3283
|
+
if (tool === "browser_confirm") return;
|
|
3284
|
+
if (!(config.sensitiveTools ?? DEFAULT_SENSITIVE_TOOLS).includes(tool)) return;
|
|
3285
|
+
const patterns = config.sensitivePatterns ?? DEFAULT_SENSITIVE_PATTERNS;
|
|
3286
|
+
if (patterns.length === 0) return;
|
|
3287
|
+
const active = page !== void 0 ? page : activePage();
|
|
3288
|
+
if (active === null || active === void 0) return;
|
|
3289
|
+
const sel = typeof args?.selector === "string" && args.selector ? args.selector : typeof args?.ref === "string" && args.ref ? pageRef(active, args.ref)?.selector ?? "" : "";
|
|
3290
|
+
if (sel === "") return;
|
|
3291
|
+
let desc = null;
|
|
3292
|
+
try {
|
|
3293
|
+
desc = await active.evaluate(describeTarget, sel);
|
|
3294
|
+
} catch {
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
const hits = sensitiveHits(desc, patterns);
|
|
3298
|
+
if (hits.length === 0) return;
|
|
3299
|
+
const label = desc !== null && (desc.name || desc.text) ? `(${desc.role !== "" ? `${desc.role} ` : ""}"${desc.name || desc.text}")` : "";
|
|
3300
|
+
await confirmTool(config, {
|
|
3301
|
+
reason: `confirmMode:auto 硬拦截:${tool} 目标元素命中敏感模式 [${hits.join(", ")}] ${label}`.trim(),
|
|
3302
|
+
tool,
|
|
3303
|
+
args
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3084
3306
|
/** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。visual 断言走像素比对。 */
|
|
3085
3307
|
async function assertTool(config, args, exec) {
|
|
3086
3308
|
const page = await getPage(config);
|
|
@@ -3336,37 +3558,29 @@ const SELECTOR_STEP_TOOLS = /* @__PURE__ */ new Set([
|
|
|
3336
3558
|
"browser_select",
|
|
3337
3559
|
"browser_upload"
|
|
3338
3560
|
]);
|
|
3561
|
+
/** 拾取指纹的页内提取(walker 统一元素描述结构;构建期拼装注入)。 */
|
|
3562
|
+
const pickElementInjector = injectWalker((el, w) => {
|
|
3563
|
+
const d = w.describeElement(el);
|
|
3564
|
+
return {
|
|
3565
|
+
testid: d.testid !== "" ? d.testid : void 0,
|
|
3566
|
+
id: d.id !== "" ? d.id : void 0,
|
|
3567
|
+
tag: d.tag,
|
|
3568
|
+
role: d.role !== "" ? d.role : void 0,
|
|
3569
|
+
name: d.name !== "" ? d.name : void 0,
|
|
3570
|
+
text: d.text !== "" ? d.text : void 0
|
|
3571
|
+
};
|
|
3572
|
+
});
|
|
3339
3573
|
/**
|
|
3340
3574
|
* 采集选择器指向元素的指纹(页内 evaluate;找不到元素返回 null)。
|
|
3341
3575
|
* 指纹是回放自愈的降级候选来源:testid / role+name / text / id / tag+text。
|
|
3576
|
+
* F5 收敛:元素描述(role/name/text)改用 walker 单一来源
|
|
3577
|
+
* (describeElement),与 aria 快照/find/录制回声共用同一套语义;相比旧
|
|
3578
|
+
* 内联副本,role 推断更完整(slider/spinbutton/searchbox/heading 等),
|
|
3579
|
+
* name 补齐 labelledby/label[for]/title 兜底 —— 指纹只作自愈降级候选,
|
|
3580
|
+
* 语义增强只会让自愈命中率更高,不改变任何主链路行为。
|
|
3342
3581
|
*/
|
|
3343
3582
|
async function captureSelectorHint(page, selector) {
|
|
3344
|
-
return page.locator(selector).first().evaluate((
|
|
3345
|
-
const tag = el.tagName.toLowerCase();
|
|
3346
|
-
const type = (el.getAttribute("type") ?? "").toLowerCase();
|
|
3347
|
-
const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
|
|
3348
|
-
const implicit = tag === "a" && el.hasAttribute("href") ? "link" : tag === "button" || tag === "summary" || tag === "input" && [
|
|
3349
|
-
"button",
|
|
3350
|
-
"submit",
|
|
3351
|
-
"reset",
|
|
3352
|
-
"image"
|
|
3353
|
-
].includes(type) ? "button" : tag === "textarea" || tag === "input" && ![
|
|
3354
|
-
"hidden",
|
|
3355
|
-
"checkbox",
|
|
3356
|
-
"radio",
|
|
3357
|
-
"file"
|
|
3358
|
-
].includes(type) ? "textbox" : tag === "select" ? "combobox" : tag === "input" && type === "checkbox" ? "checkbox" : tag === "input" && type === "radio" ? "radio" : "";
|
|
3359
|
-
const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60);
|
|
3360
|
-
const name = (el.getAttribute("aria-label") ?? "").trim() || (el.getAttribute("placeholder") ?? "").trim() || text;
|
|
3361
|
-
return {
|
|
3362
|
-
testid: el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? void 0,
|
|
3363
|
-
id: el.id !== "" ? el.id : void 0,
|
|
3364
|
-
tag,
|
|
3365
|
-
role: (roleAttr || implicit) !== "" ? roleAttr || implicit : void 0,
|
|
3366
|
-
name: name !== "" ? name : void 0,
|
|
3367
|
-
text: text !== "" ? text : void 0
|
|
3368
|
-
};
|
|
3369
|
-
}).catch(() => null);
|
|
3583
|
+
return page.locator(selector).first().evaluate(pickElementInjector).catch(() => null);
|
|
3370
3584
|
}
|
|
3371
3585
|
/** 由指纹生成自愈降级候选(按优先级排序;导出供测试)。 */
|
|
3372
3586
|
function healCandidates(hint) {
|
|
@@ -3489,6 +3703,7 @@ async function runReplay(steps, impls, failFast, opts = {}) {
|
|
|
3489
3703
|
let stoppedAt = null;
|
|
3490
3704
|
let passed = 0;
|
|
3491
3705
|
let healedCount = 0;
|
|
3706
|
+
let retryAttempted = 0;
|
|
3492
3707
|
for (let i = 0; i < steps.length; i++) {
|
|
3493
3708
|
const step = steps[i];
|
|
3494
3709
|
const impl = impls[step.tool];
|
|
@@ -3506,81 +3721,107 @@ async function runReplay(steps, impls, failFast, opts = {}) {
|
|
|
3506
3721
|
continue;
|
|
3507
3722
|
}
|
|
3508
3723
|
const hint = step.meta?.hint;
|
|
3509
|
-
|
|
3510
|
-
|
|
3724
|
+
const canHeal = opts.healing === true && hint !== void 0 && SELECTOR_STEP_TOOLS.has(step.tool) && typeof step.args?.selector === "string";
|
|
3725
|
+
/** 单次完整尝试(含自愈链);返回该次尝试的 result 行(带内部 healed 标记)。 */
|
|
3726
|
+
const attempt = async () => {
|
|
3727
|
+
if (!canHeal) try {
|
|
3511
3728
|
await impl(step.args ?? {});
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3729
|
+
return {
|
|
3730
|
+
ok: true,
|
|
3731
|
+
result: {
|
|
3732
|
+
index: i,
|
|
3733
|
+
tool: step.tool,
|
|
3734
|
+
ok: true
|
|
3735
|
+
}
|
|
3736
|
+
};
|
|
3518
3737
|
} catch (e) {
|
|
3519
|
-
|
|
3520
|
-
index: i,
|
|
3521
|
-
tool: step.tool,
|
|
3738
|
+
return {
|
|
3522
3739
|
ok: false,
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
continue;
|
|
3531
|
-
}
|
|
3532
|
-
const healTimeout = opts.healTimeoutMs ?? 3e3;
|
|
3533
|
-
const quickArgs = {
|
|
3534
|
-
...step.args,
|
|
3535
|
-
timeoutMs: Math.min(Number(step.args.timeoutMs ?? Number.POSITIVE_INFINITY) || Number.POSITIVE_INFINITY, healTimeout)
|
|
3536
|
-
};
|
|
3537
|
-
try {
|
|
3538
|
-
await impl(quickArgs);
|
|
3539
|
-
passed++;
|
|
3540
|
-
results.push({
|
|
3541
|
-
index: i,
|
|
3542
|
-
tool: step.tool,
|
|
3543
|
-
ok: true
|
|
3544
|
-
});
|
|
3545
|
-
continue;
|
|
3546
|
-
} catch (e0) {
|
|
3547
|
-
const tried = [];
|
|
3548
|
-
let healed = null;
|
|
3549
|
-
for (const candidate of healCandidates(hint)) {
|
|
3550
|
-
tried.push(`${candidate.via}: ${candidate.selector}`);
|
|
3551
|
-
try {
|
|
3552
|
-
await impl({
|
|
3553
|
-
...step.args,
|
|
3554
|
-
selector: candidate.selector,
|
|
3555
|
-
timeoutMs: healTimeout
|
|
3556
|
-
});
|
|
3557
|
-
healed = candidate;
|
|
3558
|
-
break;
|
|
3559
|
-
} catch {}
|
|
3740
|
+
result: {
|
|
3741
|
+
index: i,
|
|
3742
|
+
tool: step.tool,
|
|
3743
|
+
ok: false,
|
|
3744
|
+
error: e instanceof Error ? e.message : String(e)
|
|
3745
|
+
}
|
|
3746
|
+
};
|
|
3560
3747
|
}
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3748
|
+
const healTimeout = opts.healTimeoutMs ?? 3e3;
|
|
3749
|
+
const quickArgs = {
|
|
3750
|
+
...step.args,
|
|
3751
|
+
timeoutMs: Math.min(Number(step.args.timeoutMs ?? Number.POSITIVE_INFINITY) || Number.POSITIVE_INFINITY, healTimeout)
|
|
3752
|
+
};
|
|
3753
|
+
try {
|
|
3754
|
+
await impl(quickArgs);
|
|
3755
|
+
return {
|
|
3567
3756
|
ok: true,
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3757
|
+
result: {
|
|
3758
|
+
index: i,
|
|
3759
|
+
tool: step.tool,
|
|
3760
|
+
ok: true
|
|
3761
|
+
}
|
|
3762
|
+
};
|
|
3763
|
+
} catch (e0) {
|
|
3764
|
+
const tried = [];
|
|
3765
|
+
let healed = null;
|
|
3766
|
+
for (const candidate of healCandidates(hint)) {
|
|
3767
|
+
tried.push(`${candidate.via}: ${candidate.selector}`);
|
|
3768
|
+
try {
|
|
3769
|
+
await impl({
|
|
3770
|
+
...step.args,
|
|
3771
|
+
selector: candidate.selector,
|
|
3772
|
+
timeoutMs: healTimeout
|
|
3773
|
+
});
|
|
3774
|
+
healed = candidate;
|
|
3775
|
+
break;
|
|
3776
|
+
} catch {}
|
|
3777
|
+
}
|
|
3778
|
+
if (healed !== null) return {
|
|
3779
|
+
ok: true,
|
|
3780
|
+
result: {
|
|
3781
|
+
index: i,
|
|
3782
|
+
tool: step.tool,
|
|
3783
|
+
ok: true,
|
|
3784
|
+
healed: true,
|
|
3785
|
+
via: healed.via,
|
|
3786
|
+
resolved: healed.selector
|
|
3787
|
+
}
|
|
3788
|
+
};
|
|
3573
3789
|
const base = e0 instanceof Error ? e0.message : String(e0);
|
|
3574
|
-
|
|
3575
|
-
index: i,
|
|
3576
|
-
tool: step.tool,
|
|
3790
|
+
return {
|
|
3577
3791
|
ok: false,
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3792
|
+
result: {
|
|
3793
|
+
index: i,
|
|
3794
|
+
tool: step.tool,
|
|
3795
|
+
ok: false,
|
|
3796
|
+
error: `${base}${tried.length > 0 ? `; 自愈已尝试: ${tried.join(" | ")}` : ""}`
|
|
3797
|
+
}
|
|
3798
|
+
};
|
|
3799
|
+
}
|
|
3800
|
+
};
|
|
3801
|
+
let outcome = await attempt();
|
|
3802
|
+
let recovered = false;
|
|
3803
|
+
if (!outcome.ok && (opts.retryFailed ?? 0) > 0) for (let n = 1; n <= (opts.retryFailed ?? 0); n++) {
|
|
3804
|
+
await sleep(500 * n);
|
|
3805
|
+
retryAttempted++;
|
|
3806
|
+
const again = await attempt();
|
|
3807
|
+
outcome = again;
|
|
3808
|
+
if (again.ok) {
|
|
3809
|
+
recovered = true;
|
|
3810
|
+
break;
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
if (outcome.ok) {
|
|
3814
|
+
passed++;
|
|
3815
|
+
if (outcome.result.healed === true) healedCount++;
|
|
3816
|
+
results.push(recovered ? {
|
|
3817
|
+
...outcome.result,
|
|
3818
|
+
recovered: true
|
|
3819
|
+
} : outcome.result);
|
|
3820
|
+
} else {
|
|
3821
|
+
results.push(outcome.result);
|
|
3822
|
+
if (failFast) {
|
|
3823
|
+
stoppedAt = i;
|
|
3824
|
+
break;
|
|
3584
3825
|
}
|
|
3585
3826
|
}
|
|
3586
3827
|
}
|
|
@@ -3589,6 +3830,10 @@ async function runReplay(steps, impls, failFast, opts = {}) {
|
|
|
3589
3830
|
passed,
|
|
3590
3831
|
failed: results.length - passed,
|
|
3591
3832
|
healedCount,
|
|
3833
|
+
...retryAttempted > 0 ? { retries: {
|
|
3834
|
+
attempted: retryAttempted,
|
|
3835
|
+
recovered: results.filter((r) => r.recovered === true).length
|
|
3836
|
+
} } : {},
|
|
3592
3837
|
...stoppedAt !== null ? { stoppedAt } : {},
|
|
3593
3838
|
results
|
|
3594
3839
|
};
|
|
@@ -3640,14 +3885,176 @@ function workspaceFilePath(cwd, file) {
|
|
|
3640
3885
|
if (p !== root && !p.startsWith(root + "/") && !p.startsWith(root + "\\")) throw new Error(`文件路径越出工作区: ${file}`);
|
|
3641
3886
|
return p;
|
|
3642
3887
|
}
|
|
3888
|
+
/** suggest 的页内结构探测(injectWalker 注入;walker 提供 cssPath)。 */
|
|
3889
|
+
const suggestFieldsInPage = injectWalker((payload, w) => {
|
|
3890
|
+
const root = payload.scope !== "" ? document.querySelector(payload.scope) : document.body;
|
|
3891
|
+
if (root === null) return {
|
|
3892
|
+
ok: false,
|
|
3893
|
+
reason: `scope 未命中: 页面中没有匹配 "${payload.scope}" 的元素`
|
|
3894
|
+
};
|
|
3895
|
+
const cellOf = (el) => {
|
|
3896
|
+
const a = el.querySelector("a[href]");
|
|
3897
|
+
const img = el.querySelector("img[src]");
|
|
3898
|
+
return {
|
|
3899
|
+
text: (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60),
|
|
3900
|
+
...a !== null ? { href: a.getAttribute("href") ?? "" } : {},
|
|
3901
|
+
...img !== null ? { src: img.getAttribute("src") ?? "" } : {}
|
|
3902
|
+
};
|
|
3903
|
+
};
|
|
3904
|
+
const rows = Array.from(root.querySelectorAll("table tr"));
|
|
3905
|
+
if (rows.length >= 2) {
|
|
3906
|
+
const first = rows[0];
|
|
3907
|
+
let headerTexts = [];
|
|
3908
|
+
let record = null;
|
|
3909
|
+
if (first.querySelector("th") !== null) {
|
|
3910
|
+
headerTexts = Array.from(first.querySelectorAll("th, td")).map((c) => (c.innerText || c.textContent || "").trim());
|
|
3911
|
+
record = rows[1] ?? null;
|
|
3912
|
+
} else record = first;
|
|
3913
|
+
if (record !== null) {
|
|
3914
|
+
const cells = Array.from(record.querySelectorAll("td, th")).map(cellOf);
|
|
3915
|
+
if (cells.length > 0) return {
|
|
3916
|
+
ok: true,
|
|
3917
|
+
kind: "table",
|
|
3918
|
+
rowSelector: "table tr",
|
|
3919
|
+
headerTexts,
|
|
3920
|
+
cells
|
|
3921
|
+
};
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
const list = root.querySelector("ul, ol");
|
|
3925
|
+
if (list !== null) {
|
|
3926
|
+
const lis = Array.from(list.children).filter((c) => c.tagName === "LI");
|
|
3927
|
+
if (lis.length >= 2) {
|
|
3928
|
+
const li = lis[0];
|
|
3929
|
+
return {
|
|
3930
|
+
ok: true,
|
|
3931
|
+
kind: "list",
|
|
3932
|
+
rowSelector: `${w.cssPath(list)} > li`,
|
|
3933
|
+
item: cellOf(li)
|
|
3934
|
+
};
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
return {
|
|
3938
|
+
ok: false,
|
|
3939
|
+
reason: "未发现规律结构(无 ≥2 行的表格,也无 ≥2 项的列表);请用 browser_elements/browser_snapshot 查看结构后手写 fields",
|
|
3940
|
+
overview: {
|
|
3941
|
+
links: root.querySelectorAll("a").length,
|
|
3942
|
+
images: root.querySelectorAll("img").length,
|
|
3943
|
+
buttons: root.querySelectorAll("button, [role='button']").length,
|
|
3944
|
+
headings: root.querySelectorAll("h1, h2, h3, h4").length,
|
|
3945
|
+
rows: rows.length,
|
|
3946
|
+
textChars: (root.innerText || "").length
|
|
3947
|
+
}
|
|
3948
|
+
};
|
|
3949
|
+
});
|
|
3950
|
+
/** 字段名净化:空白→下划线,限长 30,空回落 col_N。导出供测试。 */
|
|
3951
|
+
function suggestFieldName(header, index) {
|
|
3952
|
+
const h = (header ?? "").trim().replace(/\s+/g, "_");
|
|
3953
|
+
return h !== "" ? h.slice(0, 30) : `col_${index + 1}`;
|
|
3954
|
+
}
|
|
3955
|
+
/**
|
|
3956
|
+
* 纯逻辑:由表头与首行单元格特征推断表格 fields(单元格文本/链接 href/图片 src)。
|
|
3957
|
+
* 返回可直接放进 rows.fields 的嵌套字段数组。导出供测试。
|
|
3958
|
+
*/
|
|
3959
|
+
function fieldsFromCells(headerTexts, cells) {
|
|
3960
|
+
const out = [];
|
|
3961
|
+
cells.forEach((c, i) => {
|
|
3962
|
+
const name = suggestFieldName(headerTexts[i], i);
|
|
3963
|
+
const sel = `td:nth-of-type(${i + 1})`;
|
|
3964
|
+
if (c.src !== void 0) out.push({
|
|
3965
|
+
name: `${name}_src`,
|
|
3966
|
+
selector: `${sel} img`,
|
|
3967
|
+
attr: "src"
|
|
3968
|
+
});
|
|
3969
|
+
if (c.href !== void 0) out.push({
|
|
3970
|
+
name: `${name}_href`,
|
|
3971
|
+
selector: `${sel} a`,
|
|
3972
|
+
attr: "href"
|
|
3973
|
+
});
|
|
3974
|
+
out.push({
|
|
3975
|
+
name,
|
|
3976
|
+
selector: sel
|
|
3977
|
+
});
|
|
3978
|
+
});
|
|
3979
|
+
return out;
|
|
3980
|
+
}
|
|
3981
|
+
/**
|
|
3982
|
+
* 纯逻辑:由首条卡片信息推断列表 fields(项文本/链接/图片)。
|
|
3983
|
+
* 文本取第一个子元素(项自身无法用 querySelector 自选)。导出供测试。
|
|
3984
|
+
*/
|
|
3985
|
+
function fieldsFromCard(item) {
|
|
3986
|
+
const out = [];
|
|
3987
|
+
if (item.src !== void 0) out.push({
|
|
3988
|
+
name: "image",
|
|
3989
|
+
selector: "img",
|
|
3990
|
+
attr: "src"
|
|
3991
|
+
});
|
|
3992
|
+
if (item.href !== void 0) out.push({
|
|
3993
|
+
name: "link",
|
|
3994
|
+
selector: "a",
|
|
3995
|
+
attr: "href"
|
|
3996
|
+
});
|
|
3997
|
+
out.push({
|
|
3998
|
+
name: "text",
|
|
3999
|
+
selector: ":scope > *:nth-child(1)"
|
|
4000
|
+
});
|
|
4001
|
+
return out;
|
|
4002
|
+
}
|
|
4003
|
+
/** 纯逻辑:由 fields 与单元格样本拼出首行样例(与抽取输出同构;按 td 序号对位)。导出供测试。 */
|
|
4004
|
+
function sampleOfFields(fields, cells) {
|
|
4005
|
+
const out = {};
|
|
4006
|
+
for (const f of fields) {
|
|
4007
|
+
const m = /td:nth-of-type\((\d+)\)/.exec(f.selector);
|
|
4008
|
+
const c = cells[m !== null ? Number(m[1]) - 1 : 0];
|
|
4009
|
+
if (f.attr === "href") out[f.name] = c?.href ?? null;
|
|
4010
|
+
else if (f.attr === "src") out[f.name] = c?.src ?? null;
|
|
4011
|
+
else out[f.name] = c?.text ?? null;
|
|
4012
|
+
}
|
|
4013
|
+
return out;
|
|
4014
|
+
}
|
|
3643
4015
|
/**
|
|
3644
|
-
*
|
|
3645
|
-
*
|
|
4016
|
+
* suggest 汇总(纯逻辑):由页内分析结果组装「可直接交给抽取模式」的顶层 fields
|
|
4017
|
+
* (单个 multiple rows 字段 + 行内嵌套字段)与样本。推断失败原样返回。导出供测试。
|
|
3646
4018
|
*/
|
|
4019
|
+
function suggestWrap(analysis) {
|
|
4020
|
+
if (!analysis.ok || analysis.kind === void 0 || analysis.rowSelector === void 0) return {
|
|
4021
|
+
suggested: false,
|
|
4022
|
+
reason: analysis.reason ?? "结构不足以推断字段",
|
|
4023
|
+
...analysis.overview !== void 0 ? { overview: analysis.overview } : {}
|
|
4024
|
+
};
|
|
4025
|
+
const rowFields = analysis.kind === "table" ? fieldsFromCells(analysis.headerTexts ?? [], analysis.cells ?? []) : fieldsFromCard(analysis.item ?? { text: "" });
|
|
4026
|
+
if (rowFields.length === 0) return {
|
|
4027
|
+
suggested: false,
|
|
4028
|
+
reason: "首条记录内未发现可抽取内容(文本/链接/图片)",
|
|
4029
|
+
...analysis.overview !== void 0 ? { overview: analysis.overview } : {}
|
|
4030
|
+
};
|
|
4031
|
+
const sample = sampleOfFields(rowFields, analysis.kind === "table" ? analysis.cells ?? [] : [analysis.item ?? { text: "" }]);
|
|
4032
|
+
return {
|
|
4033
|
+
suggested: true,
|
|
4034
|
+
kind: analysis.kind,
|
|
4035
|
+
rowSelector: analysis.rowSelector,
|
|
4036
|
+
...analysis.headerTexts !== void 0 ? { headerTexts: analysis.headerTexts } : {},
|
|
4037
|
+
fields: [{
|
|
4038
|
+
name: "rows",
|
|
4039
|
+
selector: analysis.rowSelector,
|
|
4040
|
+
multiple: true,
|
|
4041
|
+
fields: rowFields
|
|
4042
|
+
}],
|
|
4043
|
+
sample
|
|
4044
|
+
};
|
|
4045
|
+
}
|
|
3647
4046
|
async function extractTool(config, args, exec) {
|
|
3648
4047
|
const page = await getPage(config);
|
|
4048
|
+
if (args.action === "suggest") {
|
|
4049
|
+
const scope = typeof args.scope === "string" && args.scope !== "" ? args.scope : "";
|
|
4050
|
+
return {
|
|
4051
|
+
action: "suggest",
|
|
4052
|
+
...suggestWrap(await page.evaluate(suggestFieldsInPage, { scope })),
|
|
4053
|
+
...await pageIdentity(page)
|
|
4054
|
+
};
|
|
4055
|
+
}
|
|
3649
4056
|
const fields = args.fields;
|
|
3650
|
-
if (!Array.isArray(fields) || fields.length === 0) throw new Error("extract 需要 fields:[{ name, selector, attr?, multiple?, fields? }]");
|
|
4057
|
+
if (!Array.isArray(fields) || fields.length === 0) throw new Error("extract 需要 fields:[{ name, selector, attr?, multiple?, fields? }] 或 action: \"suggest\" 推断字段");
|
|
3651
4058
|
for (const f of fields) if (typeof f?.name !== "string" || f.name === "" || typeof f?.selector !== "string" || f.selector === "") throw new Error("fields 每项需要非空 name 与 selector");
|
|
3652
4059
|
const maxPages = Math.min(Math.max(Number(args.maxPages ?? 1) || 1, 1), 100);
|
|
3653
4060
|
const maxRows = Math.min(Math.max(Number(args.maxRows ?? config.maxRows ?? 1e3) || 1e3, 1), 1e5);
|
|
@@ -3858,9 +4265,19 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3858
4265
|
else throw new Error("replay 需要 name(已保存录制)或 steps(步骤数组)");
|
|
3859
4266
|
if (steps.length === 0) throw new Error("步骤为空");
|
|
3860
4267
|
const failFast = args.failFast !== false;
|
|
4268
|
+
const retryFailed = Math.min(Math.max(Math.floor(Number(args.retryFailed ?? 0)) || 0, 0), 10);
|
|
4269
|
+
const retryOf = typeof args.retryOf === "string" && args.retryOf !== "" ? args.retryOf : void 0;
|
|
4270
|
+
const runMeta = { ...retryFailed > 0 || retryOf !== void 0 ? {
|
|
4271
|
+
...retryFailed > 0 ? { retries: {
|
|
4272
|
+
attempted: 0,
|
|
4273
|
+
recovered: 0
|
|
4274
|
+
} } : {},
|
|
4275
|
+
...retryOf !== void 0 ? { retryOf } : {}
|
|
4276
|
+
} : {} };
|
|
3861
4277
|
const opts = {
|
|
3862
4278
|
healing: config?.selectorHealing !== false,
|
|
3863
|
-
healTimeoutMs: config?.healTimeoutMs ?? 3e3
|
|
4279
|
+
healTimeoutMs: config?.healTimeoutMs ?? 3e3,
|
|
4280
|
+
...retryFailed > 0 ? { retryFailed } : {}
|
|
3864
4281
|
};
|
|
3865
4282
|
let rows = null;
|
|
3866
4283
|
if (Array.isArray(args.data)) rows = args.data;
|
|
@@ -3900,7 +4317,8 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3900
4317
|
healedCount: 0,
|
|
3901
4318
|
durationMs: Date.now() - t0,
|
|
3902
4319
|
row: i,
|
|
3903
|
-
results: []
|
|
4320
|
+
results: [],
|
|
4321
|
+
...runMeta
|
|
3904
4322
|
});
|
|
3905
4323
|
continue;
|
|
3906
4324
|
}
|
|
@@ -3920,7 +4338,9 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3920
4338
|
healedCount: result.healedCount,
|
|
3921
4339
|
durationMs: Date.now() - t0,
|
|
3922
4340
|
row: i,
|
|
3923
|
-
results: result.results
|
|
4341
|
+
results: result.results,
|
|
4342
|
+
...result.retries ?? runMeta.retries,
|
|
4343
|
+
...runMeta.retryOf !== void 0 ? { retryOf: runMeta.retryOf } : {}
|
|
3924
4344
|
});
|
|
3925
4345
|
}
|
|
3926
4346
|
return {
|
|
@@ -3938,6 +4358,7 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3938
4358
|
const t0 = Date.now();
|
|
3939
4359
|
const result = await runReplay(substituteVars(steps, vars), impls, failFast, opts);
|
|
3940
4360
|
const stoppedAt = result.stoppedAt;
|
|
4361
|
+
const retries = result.retries;
|
|
3941
4362
|
noteRun({
|
|
3942
4363
|
kind: "replay",
|
|
3943
4364
|
source,
|
|
@@ -3946,14 +4367,42 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3946
4367
|
failed: result.failed,
|
|
3947
4368
|
healedCount: result.healedCount,
|
|
3948
4369
|
durationMs: Date.now() - t0,
|
|
3949
|
-
results: result.results
|
|
4370
|
+
results: result.results,
|
|
4371
|
+
...retries !== void 0 ? { retries } : {},
|
|
4372
|
+
...retryOf !== void 0 ? { retryOf } : {}
|
|
3950
4373
|
});
|
|
3951
4374
|
return {
|
|
3952
4375
|
source,
|
|
3953
4376
|
...result,
|
|
3954
|
-
summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${stoppedAt !== void 0 ? `(停在步骤 ${stoppedAt})` : ""}` : ""}${result.healedCount > 0 ? `,${result.healedCount} 步自愈(建议修复录制选择器)` : ""}`
|
|
4377
|
+
summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${stoppedAt !== void 0 ? `(停在步骤 ${stoppedAt})` : ""}` : ""}${result.healedCount > 0 ? `,${result.healedCount} 步自愈(建议修复录制选择器)` : ""}${retries !== void 0 && retries.recovered > 0 ? `,重试挽回 ${retries.recovered}/${retries.attempted}` : ""}`
|
|
3955
4378
|
};
|
|
3956
4379
|
}
|
|
4380
|
+
let rerunHandler = null;
|
|
4381
|
+
let rerunInFlight = false;
|
|
4382
|
+
/** registerTools 注册重跑入口(stepImpls 同路径);web-only 进程保持 null。 */
|
|
4383
|
+
function setRerunHandler(fn) {
|
|
4384
|
+
rerunHandler = fn;
|
|
4385
|
+
}
|
|
4386
|
+
/** 当前可用的重跑入口(null = 本进程没有 tools 服务,无法重跑)。 */
|
|
4387
|
+
function getRerunHandler() {
|
|
4388
|
+
return rerunHandler;
|
|
4389
|
+
}
|
|
4390
|
+
/** 重跑是否正在执行(端点互斥;导出供测试)。 */
|
|
4391
|
+
function rerunBusy() {
|
|
4392
|
+
return rerunInFlight;
|
|
4393
|
+
}
|
|
4394
|
+
/** 执行一次面板重跑(busy 检查 + 互斥标记;返回 replayTool 结果)。 */
|
|
4395
|
+
async function runPanelRerun(req) {
|
|
4396
|
+
if (rerunInFlight) throw Object.assign(/* @__PURE__ */ new Error("重跑进行中"), { status: 409 });
|
|
4397
|
+
rerunInFlight = true;
|
|
4398
|
+
try {
|
|
4399
|
+
const handler = rerunHandler;
|
|
4400
|
+
if (handler === null) throw Object.assign(/* @__PURE__ */ new Error("本进程无回放能力(tools 服务未注册)"), { status: 503 });
|
|
4401
|
+
return await handler(req);
|
|
4402
|
+
} finally {
|
|
4403
|
+
rerunInFlight = false;
|
|
4404
|
+
}
|
|
4405
|
+
}
|
|
3957
4406
|
//#endregion
|
|
3958
4407
|
//#region lib/types/browser/register.js
|
|
3959
4408
|
function registerTools(ctx, config) {
|
|
@@ -3984,6 +4433,15 @@ function registerTools(ctx, config) {
|
|
|
3984
4433
|
"browser_press"
|
|
3985
4434
|
]);
|
|
3986
4435
|
/**
|
|
4436
|
+
* auto 审批闸(confirmMode:"auto" 硬约束):live 调用在 register 包装层拦,
|
|
4437
|
+
* replay/batch 在 stepImpls 外再包一层(默认同样拦截;confirmInReplay: false
|
|
4438
|
+
* 可关,README 注明风险)。未命中模式时闸内只花一次页内 evaluate。
|
|
4439
|
+
*/
|
|
4440
|
+
const replayGate = (tool, fn) => async (a) => {
|
|
4441
|
+
if (config.confirmMode === "auto" && config.confirmInReplay !== false) await confirmGate(config, tool, a);
|
|
4442
|
+
return fn(a);
|
|
4443
|
+
};
|
|
4444
|
+
/**
|
|
3987
4445
|
* toolset: "core" 的高频核心集:浏览/定位/操作/等待/断言/人机协作。
|
|
3988
4446
|
* 低频的录制/回放/报告/抽取/表单回放/profile/context 等只在 "all" 注册。
|
|
3989
4447
|
*/
|
|
@@ -4019,8 +4477,10 @@ function registerTools(ctx, config) {
|
|
|
4019
4477
|
const original = def.execute;
|
|
4020
4478
|
def.execute = async (args, exec) => {
|
|
4021
4479
|
const t0 = Date.now();
|
|
4480
|
+
noteToolStart();
|
|
4022
4481
|
const domTool = AGENT_DOM_TOOLS.has(def.name);
|
|
4023
4482
|
if (domTool) noteAgentAction();
|
|
4483
|
+
if (config.confirmMode === "auto") await confirmGate(config, def.name, args);
|
|
4024
4484
|
if (PREVIEW_TOOLS.has(def.name)) {
|
|
4025
4485
|
const page = activePage();
|
|
4026
4486
|
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_", ""));
|
|
@@ -4053,6 +4513,8 @@ function registerTools(ctx, config) {
|
|
|
4053
4513
|
error: error instanceof Error ? error.message : String(error)
|
|
4054
4514
|
});
|
|
4055
4515
|
throw error;
|
|
4516
|
+
} finally {
|
|
4517
|
+
noteToolEnd();
|
|
4056
4518
|
}
|
|
4057
4519
|
};
|
|
4058
4520
|
tools.register(def);
|
|
@@ -4095,6 +4557,14 @@ function registerTools(ctx, config) {
|
|
|
4095
4557
|
browser_batch: (a) => batchTool(a, stepImpls, config),
|
|
4096
4558
|
browser_extract: (a) => extractTool(config, a)
|
|
4097
4559
|
};
|
|
4560
|
+
for (const name of Object.keys(stepImpls)) {
|
|
4561
|
+
const fn = stepImpls[name];
|
|
4562
|
+
stepImpls[name] = replayGate(name, fn);
|
|
4563
|
+
}
|
|
4564
|
+
setRerunHandler((req) => replayTool({
|
|
4565
|
+
name: req.source,
|
|
4566
|
+
...req.retryOf !== void 0 ? { retryOf: req.retryOf } : {}
|
|
4567
|
+
}, stepImpls, config));
|
|
4098
4568
|
register(definePlainTool({
|
|
4099
4569
|
name: "browser_open",
|
|
4100
4570
|
description: "在共享浏览器中打开一个 URL。之后可用 browser_snapshot 读取页面内容,用 browser_click / browser_type / browser_eval 操作页面,用 browser_screenshot 截图。",
|
|
@@ -4143,7 +4613,7 @@ function registerTools(ctx, config) {
|
|
|
4143
4613
|
}));
|
|
4144
4614
|
register(definePlainTool({
|
|
4145
4615
|
name: "browser_snapshot",
|
|
4146
|
-
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
|
|
4616
|
+
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——历史版本环形缓存(snapshotHistory 默认 5,可配 1-20)窗口内的任意版本都能 diff:内容未变返回 { unchanged: true },变了只返回相对该版本的变化行(diff: true, changedFrom/removed 标注变化区),表格翻页/局部刷新/轮询抓取后重读 token 开销可降一个量级;since 超出窗口(过旧/未知)则回落全量并标 stale。",
|
|
4147
4617
|
parameters: {
|
|
4148
4618
|
type: "object",
|
|
4149
4619
|
properties: {
|
|
@@ -4158,7 +4628,7 @@ function registerTools(ctx, config) {
|
|
|
4158
4628
|
},
|
|
4159
4629
|
since: {
|
|
4160
4630
|
type: "integer",
|
|
4161
|
-
description: "
|
|
4631
|
+
description: "可选:之前任一次快照的 contentVersion(历史窗口内任意版本),只返回相对该版本的变化内容。"
|
|
4162
4632
|
},
|
|
4163
4633
|
maxTextChars: {
|
|
4164
4634
|
type: "integer",
|
|
@@ -5067,7 +5537,7 @@ function registerTools(ctx, config) {
|
|
|
5067
5537
|
}));
|
|
5068
5538
|
register(definePlainTool({
|
|
5069
5539
|
name: "browser_find",
|
|
5070
|
-
description: "按名称/id/data-testid
|
|
5540
|
+
description: "按名称/id/data-testid/组件名/源码文件语义定位页面元素,只返回命中的 ref + 区域上下文,不输出全量 aria 树 —— 在大页面定位单个操作目标时比 browser_snapshot { mode: \"aria\" } 省 95% 以上 token。原生支持 Ant Design Vue / Element Plus / Naive UI 等组件库与 @xiaou66/vite-plugin-vue-mcp-next 源码映射。返回的 ref 可直接传给 browser_click / browser_type / browser_select / browser_press。默认包含匹配(query 是名称子串,忽略大小写;exact:true 精确匹配);scope 限定查找容器;多命中全部返回并附 ancestors 区域链供二次定位;零命中不抛错,返回最相近的 suggestions 候选。",
|
|
5071
5541
|
parameters: {
|
|
5072
5542
|
type: "object",
|
|
5073
5543
|
properties: {
|
|
@@ -5082,6 +5552,18 @@ function registerTools(ctx, config) {
|
|
|
5082
5552
|
scope: {
|
|
5083
5553
|
type: "string",
|
|
5084
5554
|
description: "可选:限定查找的容器 CSS 选择器(如 '#login-form')。"
|
|
5555
|
+
},
|
|
5556
|
+
component: {
|
|
5557
|
+
type: "string",
|
|
5558
|
+
description: "可选:按组件名查找(如 'ASelect', 'ElSelect', '*Select')。"
|
|
5559
|
+
},
|
|
5560
|
+
library: {
|
|
5561
|
+
type: "string",
|
|
5562
|
+
description: "可选:按组件库筛选(如 'ant-design-vue', 'element-plus')。"
|
|
5563
|
+
},
|
|
5564
|
+
file: {
|
|
5565
|
+
type: "string",
|
|
5566
|
+
description: "可选:按 Vue SFC 源码文件名筛选(如 'Login.vue')。"
|
|
5085
5567
|
}
|
|
5086
5568
|
},
|
|
5087
5569
|
required: ["query"],
|
|
@@ -5103,7 +5585,11 @@ function registerTools(ctx, config) {
|
|
|
5103
5585
|
ancestors: {
|
|
5104
5586
|
type: "array",
|
|
5105
5587
|
items: { type: "string" }
|
|
5106
|
-
}
|
|
5588
|
+
},
|
|
5589
|
+
library: { type: "string" },
|
|
5590
|
+
component: { type: "string" },
|
|
5591
|
+
sourceFile: { type: "string" },
|
|
5592
|
+
formLabel: { type: "string" }
|
|
5107
5593
|
},
|
|
5108
5594
|
required: [
|
|
5109
5595
|
"ref",
|
|
@@ -5562,7 +6048,7 @@ function registerTools(ctx, config) {
|
|
|
5562
6048
|
}));
|
|
5563
6049
|
register(definePlainTool({
|
|
5564
6050
|
name: "browser_replay",
|
|
5565
|
-
description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。选择器自愈:录制时保存了元素指纹的步骤,原 selector 失败时自动按 testid→role+name→text→id 降级重试并在结果中标 healed(建议事后修复录制选择器)。参数化:录制含 {{占位符}} 时用 vars 注入;data 传对象数组或 dataFile 传 CSV 路径(首行为变量名)
|
|
6051
|
+
description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。选择器自愈:录制时保存了元素指纹的步骤,原 selector 失败时自动按 testid→role+name→text→id 降级重试并在结果中标 healed(建议事后修复录制选择器)。参数化:录制含 {{占位符}} 时用 vars 注入;data 传对象数组或 dataFile 传 CSV 路径(首行为变量名)可逐行数据驱动回放。retryFailed: N 使失败步骤(自愈链耗尽后)自动重试 N 次(500ms×n 线性退避,重试耗尽才停),返回值 retries 标注 { attempted, recovered }。运行结果自动记入回归历史(browser_report 可导出报告)。",
|
|
5566
6052
|
parameters: {
|
|
5567
6053
|
type: "object",
|
|
5568
6054
|
properties: {
|
|
@@ -5591,6 +6077,10 @@ function registerTools(ctx, config) {
|
|
|
5591
6077
|
dataFile: {
|
|
5592
6078
|
type: "string",
|
|
5593
6079
|
description: "数据集 CSV 文件(工作区相对路径,首行为变量名)。"
|
|
6080
|
+
},
|
|
6081
|
+
retryFailed: {
|
|
6082
|
+
type: "integer",
|
|
6083
|
+
description: "失败步骤自动重试次数(0-10,默认 0;500ms×n 线性退避,重试耗尽才停)。偶发超时/网络抖动类失败的兜底。"
|
|
5594
6084
|
}
|
|
5595
6085
|
},
|
|
5596
6086
|
additionalProperties: false
|
|
@@ -5604,6 +6094,10 @@ function registerTools(ctx, config) {
|
|
|
5604
6094
|
passed: { type: "integer" },
|
|
5605
6095
|
failed: { type: "integer" },
|
|
5606
6096
|
healedCount: { type: "integer" },
|
|
6097
|
+
retries: {
|
|
6098
|
+
type: "object",
|
|
6099
|
+
description: "自动重试统计 { attempted, recovered }(仅 retryFailed>0 时返回)。"
|
|
6100
|
+
},
|
|
5607
6101
|
stoppedAt: { type: "integer" },
|
|
5608
6102
|
results: {
|
|
5609
6103
|
type: "array",
|
|
@@ -5629,7 +6123,7 @@ function registerTools(ctx, config) {
|
|
|
5629
6123
|
]
|
|
5630
6124
|
},
|
|
5631
6125
|
render: (_args, value) => {
|
|
5632
|
-
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")}`;
|
|
6126
|
+
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.recovered ? " [重试挽回]" : ""}${r.error ? ` — ${String(r.error).slice(0, 120)}` : ""}`).join("\n")}`;
|
|
5633
6127
|
},
|
|
5634
6128
|
timeoutMs: timeout(6e5),
|
|
5635
6129
|
execute: (args, exec) => replayTool(args, stepImpls, config, exec)
|
|
@@ -5683,14 +6177,23 @@ function registerTools(ctx, config) {
|
|
|
5683
6177
|
}));
|
|
5684
6178
|
register(definePlainTool({
|
|
5685
6179
|
name: "browser_extract",
|
|
5686
|
-
description: "规则化结构化抽取(网站信息获取):fields 定义抽取结构 [{name, selector, attr?, multiple?, fields?}](attr 缺省取可见文本,可指定 href/src 等属性;multiple=true 收集所有命中;fields 嵌套可在行内抽子字段);next 传翻页选择器自动翻页聚合(maxPages 上限,next 消失/禁用即停);format 支持 json/csv/jsonl/markdown,file 落盘到工作区(自动按扩展名推断格式,拒绝路径穿越)
|
|
6180
|
+
description: "规则化结构化抽取(网站信息获取):fields 定义抽取结构 [{name, selector, attr?, multiple?, fields?}](attr 缺省取可见文本,可指定 href/src 等属性;multiple=true 收集所有命中;fields 嵌套可在行内抽子字段);next 传翻页选择器自动翻页聚合(maxPages 上限,next 消失/禁用即停);format 支持 json/csv/jsonl/markdown,file 落盘到工作区(自动按扩展名推断格式,拒绝路径穿越)。action:\"suggest\"(不传 fields)自动推断字段:从命中容器首条记录(表格首行/列表卡片)推断单元格文本/链接 href/图片 src 结构,返回建议 fields + 首行样本,确认/微调后直接交给抽取模式(往返 3-4 次降到 2 次);推断不出规律结构时返回原因与容器元素概览,不猜。语义化抽取(如「总结要点」)不在此工具范围——用 browser_snapshot 读文本后交 jev_extract。",
|
|
5687
6181
|
parameters: {
|
|
5688
6182
|
type: "object",
|
|
5689
6183
|
properties: {
|
|
6184
|
+
action: {
|
|
6185
|
+
type: "string",
|
|
6186
|
+
enum: ["suggest"],
|
|
6187
|
+
description: "suggest:自动推断字段结构,返回建议 fields + 样本(此模式不传 fields)。"
|
|
6188
|
+
},
|
|
6189
|
+
scope: {
|
|
6190
|
+
type: "string",
|
|
6191
|
+
description: "suggest 模式的容器选择器(缺省整页;表格/列表探测在此范围内进行)。"
|
|
6192
|
+
},
|
|
5690
6193
|
fields: {
|
|
5691
6194
|
type: "array",
|
|
5692
6195
|
items: { type: "object" },
|
|
5693
|
-
description: "抽取字段定义 [{name, selector, attr?, multiple?, fields?}]。"
|
|
6196
|
+
description: "抽取字段定义 [{name, selector, attr?, multiple?, fields?}](action:suggest 时不传)。"
|
|
5694
6197
|
},
|
|
5695
6198
|
next: {
|
|
5696
6199
|
type: "string",
|
|
@@ -5723,13 +6226,28 @@ function registerTools(ctx, config) {
|
|
|
5723
6226
|
description: "落盘路径(工作区相对或绝对,限工作区内)。"
|
|
5724
6227
|
}
|
|
5725
6228
|
},
|
|
5726
|
-
required: [
|
|
6229
|
+
required: [],
|
|
5727
6230
|
additionalProperties: false
|
|
5728
6231
|
},
|
|
5729
6232
|
outputSchema: {
|
|
5730
6233
|
type: "object",
|
|
5731
6234
|
additionalProperties: false,
|
|
5732
6235
|
properties: {
|
|
6236
|
+
action: { type: "string" },
|
|
6237
|
+
suggested: { type: "boolean" },
|
|
6238
|
+
kind: { type: "string" },
|
|
6239
|
+
rowSelector: { type: "string" },
|
|
6240
|
+
headerTexts: {
|
|
6241
|
+
type: "array",
|
|
6242
|
+
items: { type: "string" }
|
|
6243
|
+
},
|
|
6244
|
+
fields: {
|
|
6245
|
+
type: "array",
|
|
6246
|
+
items: { type: "object" }
|
|
6247
|
+
},
|
|
6248
|
+
sample: { type: "object" },
|
|
6249
|
+
reason: { type: "string" },
|
|
6250
|
+
overview: { type: "object" },
|
|
5733
6251
|
rows: {
|
|
5734
6252
|
type: "array",
|
|
5735
6253
|
items: { type: "object" }
|
|
@@ -5743,17 +6261,9 @@ function registerTools(ctx, config) {
|
|
|
5743
6261
|
url: { type: "string" },
|
|
5744
6262
|
title: { type: "string" }
|
|
5745
6263
|
},
|
|
5746
|
-
required: [
|
|
5747
|
-
"rows",
|
|
5748
|
-
"count",
|
|
5749
|
-
"pages",
|
|
5750
|
-
"format",
|
|
5751
|
-
"truncated",
|
|
5752
|
-
"url",
|
|
5753
|
-
"title"
|
|
5754
|
-
]
|
|
6264
|
+
required: ["url", "title"]
|
|
5755
6265
|
},
|
|
5756
|
-
render: (_args, value) => `抽取 ${value.count} 行 / ${value.pages} 页${value.truncated ? "(已截断)" : ""}${value.file ? ` → ${value.file} (${value.bytes} bytes)` : ""}`,
|
|
6266
|
+
render: (_args, value) => value.action === "suggest" ? value.suggested ? `字段建议(${value.kind}):行选择器 ${value.rowSelector},${value.fields[0].fields.length} 个字段;样例 ${JSON.stringify(value.sample).slice(0, 120)}` : `字段建议失败: ${String(value.reason).slice(0, 120)}` : `抽取 ${value.count} 行 / ${value.pages} 页${value.truncated ? "(已截断)" : ""}${value.file ? ` → ${value.file} (${value.bytes} bytes)` : ""}`,
|
|
5757
6267
|
timeoutMs: timeout(),
|
|
5758
6268
|
execute: (args, exec) => extractTool(config, args, exec)
|
|
5759
6269
|
}));
|
|
@@ -5866,7 +6376,42 @@ function registerBrowserApi(ctx, config, base) {
|
|
|
5866
6376
|
return;
|
|
5867
6377
|
}
|
|
5868
6378
|
if (path === `${base}/runs/latest`) {
|
|
5869
|
-
sendJson(res, 200, {
|
|
6379
|
+
sendJson(res, 200, {
|
|
6380
|
+
run: latestRun(),
|
|
6381
|
+
busy: activeToolCalls() > 0 || rerunBusy()
|
|
6382
|
+
});
|
|
6383
|
+
return;
|
|
6384
|
+
}
|
|
6385
|
+
if (path === `${base}/runs/rerun`) {
|
|
6386
|
+
if ((req.method ?? "GET") !== "POST") {
|
|
6387
|
+
sendText(res, 405, "POST only");
|
|
6388
|
+
return;
|
|
6389
|
+
}
|
|
6390
|
+
if (activeToolCalls() > 0 || rerunBusy()) {
|
|
6391
|
+
sendJson(res, 409, { error: "agent 忙碌(有工具调用或回放在进行),稍后重试" });
|
|
6392
|
+
return;
|
|
6393
|
+
}
|
|
6394
|
+
if (getRerunHandler() === null) {
|
|
6395
|
+
sendJson(res, 503, { error: "本进程无回放能力(tools 服务未注册)" });
|
|
6396
|
+
return;
|
|
6397
|
+
}
|
|
6398
|
+
const body = await readJsonBody(req).catch(() => void 0);
|
|
6399
|
+
const source = typeof body?.source === "string" ? body.source.trim() : "";
|
|
6400
|
+
if (!source) {
|
|
6401
|
+
sendJson(res, 400, "source required");
|
|
6402
|
+
return;
|
|
6403
|
+
}
|
|
6404
|
+
try {
|
|
6405
|
+
sendJson(res, 200, {
|
|
6406
|
+
ok: true,
|
|
6407
|
+
result: await runPanelRerun({
|
|
6408
|
+
source,
|
|
6409
|
+
...typeof body?.retryOf === "string" && body.retryOf !== "" ? { retryOf: body.retryOf } : {}
|
|
6410
|
+
})
|
|
6411
|
+
});
|
|
6412
|
+
} catch (e) {
|
|
6413
|
+
sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
6414
|
+
}
|
|
5870
6415
|
return;
|
|
5871
6416
|
}
|
|
5872
6417
|
if (path === `${base}/runs/report`) {
|
|
@@ -6266,14 +6811,26 @@ const SCHEMA = {
|
|
|
6266
6811
|
type: "number",
|
|
6267
6812
|
min: 1
|
|
6268
6813
|
},
|
|
6814
|
+
snapshotHistory: {
|
|
6815
|
+
type: "number",
|
|
6816
|
+
min: 1,
|
|
6817
|
+
max: 20
|
|
6818
|
+
},
|
|
6269
6819
|
confirmMode: {
|
|
6270
6820
|
type: "string",
|
|
6271
|
-
enum: [
|
|
6821
|
+
enum: [
|
|
6822
|
+
"off",
|
|
6823
|
+
"manual",
|
|
6824
|
+
"auto"
|
|
6825
|
+
]
|
|
6272
6826
|
},
|
|
6273
6827
|
confirmTimeoutMs: {
|
|
6274
6828
|
type: "number",
|
|
6275
6829
|
min: 1e3
|
|
6276
6830
|
},
|
|
6831
|
+
sensitivePatterns: { type: "string[]" },
|
|
6832
|
+
sensitiveTools: { type: "string[]" },
|
|
6833
|
+
confirmInReplay: { type: "boolean" },
|
|
6277
6834
|
toolset: {
|
|
6278
6835
|
type: "string",
|
|
6279
6836
|
enum: ["core", "all"]
|
|
@@ -6300,8 +6857,16 @@ const DEFAULT_CONFIG = {
|
|
|
6300
6857
|
healTimeoutMs: 3e3,
|
|
6301
6858
|
maxRefs: 200,
|
|
6302
6859
|
maxRows: 1e3,
|
|
6860
|
+
snapshotHistory: 5,
|
|
6303
6861
|
confirmMode: "off",
|
|
6304
6862
|
confirmTimeoutMs: 6e4,
|
|
6863
|
+
sensitivePatterns: [
|
|
6864
|
+
"提交",
|
|
6865
|
+
"删除",
|
|
6866
|
+
"支付"
|
|
6867
|
+
],
|
|
6868
|
+
sensitiveTools: ["browser_click"],
|
|
6869
|
+
confirmInReplay: true,
|
|
6305
6870
|
toolset: "all"
|
|
6306
6871
|
};
|
|
6307
6872
|
function apply(ctx, rawConfig) {
|
|
@@ -6313,7 +6878,7 @@ function apply(ctx, rawConfig) {
|
|
|
6313
6878
|
if (loadedRecordings > 0) ctx.logger?.info?.(`dsh-browser-view: loaded ${loadedRecordings} saved recording(s)`);
|
|
6314
6879
|
registerTools(ctx, config);
|
|
6315
6880
|
const systemPrompt = ctx.get("systemPrompt");
|
|
6316
|
-
const confirmGuidance = config.confirmMode === "manual" ? "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。" : "";
|
|
6881
|
+
const confirmGuidance = config.confirmMode === "manual" ? "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。" : config.confirmMode === "auto" ? "本环境 confirmMode 为 auto:宿主层已在敏感动作(命中敏感模式的目标元素)前强制弹审批卡,agent 无法绕过;审批被拒绝或超时的动作不可换路径重试,应向用户说明并等待指示。" : "";
|
|
6317
6882
|
const sectionDispose = systemPrompt?.section?.({
|
|
6318
6883
|
name: "tool:browser",
|
|
6319
6884
|
order: 200,
|