dsh-browser-view 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -6
- package/lib/client.js +1 -1
- package/lib/index.js +922 -515
- package/lib/types/browser/recordings.d.ts +6 -0
- 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 +130 -1
- package/lib/types/browser/types.d.ts +24 -2
- package/lib/types/browser/walker.d.ts +30 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -191,6 +191,198 @@ 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 roleOf(el) {
|
|
247
|
+
const explicit = el.getAttribute("role");
|
|
248
|
+
if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
|
|
249
|
+
switch (el.tagName.toLowerCase()) {
|
|
250
|
+
case "a": return el.hasAttribute("href") ? "link" : "";
|
|
251
|
+
case "button": return "button";
|
|
252
|
+
case "input": {
|
|
253
|
+
const t = (el.getAttribute("type") ?? "text").toLowerCase();
|
|
254
|
+
if (t === "checkbox") return "checkbox";
|
|
255
|
+
if (t === "radio") return "radio";
|
|
256
|
+
if (t === "range") return "slider";
|
|
257
|
+
if (t === "number") return "spinbutton";
|
|
258
|
+
if (t === "search") return "searchbox";
|
|
259
|
+
if ([
|
|
260
|
+
"button",
|
|
261
|
+
"submit",
|
|
262
|
+
"reset",
|
|
263
|
+
"image"
|
|
264
|
+
].includes(t)) return "button";
|
|
265
|
+
if (["hidden", "file"].includes(t)) return "";
|
|
266
|
+
return "textbox";
|
|
267
|
+
}
|
|
268
|
+
case "textarea": return "textbox";
|
|
269
|
+
case "select": return "combobox";
|
|
270
|
+
case "nav": return "navigation";
|
|
271
|
+
case "main": return "main";
|
|
272
|
+
case "header": return "banner";
|
|
273
|
+
case "footer": return "contentinfo";
|
|
274
|
+
case "form": return "form";
|
|
275
|
+
case "h1":
|
|
276
|
+
case "h2":
|
|
277
|
+
case "h3":
|
|
278
|
+
case "h4":
|
|
279
|
+
case "h5":
|
|
280
|
+
case "h6": return "heading";
|
|
281
|
+
case "ul":
|
|
282
|
+
case "ol": return "list";
|
|
283
|
+
case "li": return "listitem";
|
|
284
|
+
case "table": return "table";
|
|
285
|
+
case "dialog": return "dialog";
|
|
286
|
+
case "summary": return "button";
|
|
287
|
+
default: return "";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function nameOf(el, role) {
|
|
291
|
+
const aria = el.getAttribute("aria-label");
|
|
292
|
+
if (aria && aria.trim()) return aria.trim().slice(0, 60);
|
|
293
|
+
const labelledby = el.getAttribute("aria-labelledby");
|
|
294
|
+
if (labelledby) {
|
|
295
|
+
const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
|
|
296
|
+
if (t) return t.slice(0, 60);
|
|
297
|
+
}
|
|
298
|
+
if (el.id) {
|
|
299
|
+
const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
|
|
300
|
+
if (t) return t.slice(0, 60);
|
|
301
|
+
}
|
|
302
|
+
if ([
|
|
303
|
+
"textbox",
|
|
304
|
+
"searchbox",
|
|
305
|
+
"combobox",
|
|
306
|
+
"spinbutton"
|
|
307
|
+
].includes(role)) {
|
|
308
|
+
const ph = el.getAttribute("placeholder");
|
|
309
|
+
if (ph && ph.trim()) return ph.trim().slice(0, 60);
|
|
310
|
+
}
|
|
311
|
+
const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
|
|
312
|
+
if (text) return text.slice(0, 60);
|
|
313
|
+
const title = el.getAttribute("title");
|
|
314
|
+
if (title && title.trim()) return title.trim().slice(0, 60);
|
|
315
|
+
return "";
|
|
316
|
+
}
|
|
317
|
+
/** 稳定 CSS 路径:#id → [data-testid] → nth-of-type 链(≤6 层)。 */
|
|
318
|
+
function cssPath(el) {
|
|
319
|
+
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
320
|
+
const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
|
|
321
|
+
if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
|
|
322
|
+
const parts = [];
|
|
323
|
+
let cur = el;
|
|
324
|
+
while (cur && cur !== document.body && parts.length < 6) {
|
|
325
|
+
const tag = cur.tagName.toLowerCase();
|
|
326
|
+
const parent = cur.parentElement;
|
|
327
|
+
if (!parent) {
|
|
328
|
+
parts.unshift(tag);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
332
|
+
const idx = sameTag.indexOf(cur) + 1;
|
|
333
|
+
parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
|
|
334
|
+
cur = parent;
|
|
335
|
+
}
|
|
336
|
+
return parts.join(" > ");
|
|
337
|
+
}
|
|
338
|
+
function isVisible(el) {
|
|
339
|
+
if (el.getAttribute("aria-hidden") === "true") return false;
|
|
340
|
+
const rect = el.getBoundingClientRect();
|
|
341
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
342
|
+
const style = getComputedStyle(el);
|
|
343
|
+
return style.visibility !== "hidden" && style.display !== "none";
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* 统一元素描述结构(F5 顺带统一):aria 快照 ref、find 匹配、
|
|
347
|
+
* 拾取指纹(pick hint)、录制回声(recorder payload)共用同一份字段语义。
|
|
348
|
+
*/
|
|
349
|
+
function describeElement(el) {
|
|
350
|
+
const role = roleOf(el);
|
|
351
|
+
const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? "";
|
|
352
|
+
return {
|
|
353
|
+
role,
|
|
354
|
+
name: nameOf(el, role),
|
|
355
|
+
selector: cssPath(el),
|
|
356
|
+
testid,
|
|
357
|
+
id: el.id ?? "",
|
|
358
|
+
tag: el.tagName.toLowerCase(),
|
|
359
|
+
text: (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60),
|
|
360
|
+
visible: isVisible(el)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
INTERACTIVE,
|
|
365
|
+
STRUCTURAL,
|
|
366
|
+
SKIP_TAGS,
|
|
367
|
+
roleOf,
|
|
368
|
+
nameOf,
|
|
369
|
+
cssPath,
|
|
370
|
+
isVisible,
|
|
371
|
+
describeElement
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/** walker 核心的序列化源码(模块加载期取一次;tsdown/vitest 均原样保留)。 */
|
|
375
|
+
const CORE_SRC = __walkerCore.toString();
|
|
376
|
+
/**
|
|
377
|
+
* 把消费方 entry(自包含函数,签名 (payload, w: WalkerApi) => R)拼装成
|
|
378
|
+
* 可被 page.evaluate 序列化的独立函数 (payload: P) => R。
|
|
379
|
+
* 拼装发生在模块加载期(new Function 一次性),调用期零开销。
|
|
380
|
+
*/
|
|
381
|
+
function injectWalker(entry) {
|
|
382
|
+
const src = `"use strict"; return function (payload) { const w = (${CORE_SRC})(); return (${entry.toString()})(payload, w); };`;
|
|
383
|
+
return new Function(src)();
|
|
384
|
+
}
|
|
385
|
+
//#endregion
|
|
194
386
|
//#region lib/types/browser/session.js
|
|
195
387
|
/** 当前活动 profile 名(默认 "default")。 */
|
|
196
388
|
let currentProfile = "default";
|
|
@@ -228,6 +420,35 @@ function snapshotCacheOf(page) {
|
|
|
228
420
|
function setSnapshotCache(page, cache) {
|
|
229
421
|
snapshotCaches.set(page, cache);
|
|
230
422
|
}
|
|
423
|
+
/** 环形容量钳制(1..20,非法值回落默认 5)。导出供测试。 */
|
|
424
|
+
function snapshotCapacity(k) {
|
|
425
|
+
const n = Math.floor(Number(k));
|
|
426
|
+
if (!Number.isFinite(n)) return 5;
|
|
427
|
+
return Math.min(Math.max(n, 1), 20);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* 追加一个新版本并淘汰超限的最旧版本(纯逻辑,导出供测试)。
|
|
431
|
+
* cache 为 undefined 时从 version 1 开始;返回新缓存(不修改入参)。
|
|
432
|
+
*/
|
|
433
|
+
function pushSnapshotVersion(cache, content, capacity) {
|
|
434
|
+
const version = (cache?.version ?? 0) + 1;
|
|
435
|
+
const k = snapshotCapacity(capacity);
|
|
436
|
+
const history = [...cache?.history ?? [], {
|
|
437
|
+
version,
|
|
438
|
+
text: content.text,
|
|
439
|
+
aria: content.aria
|
|
440
|
+
}];
|
|
441
|
+
if (history.length > k) history.splice(0, history.length - k);
|
|
442
|
+
return {
|
|
443
|
+
version,
|
|
444
|
+
history
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/** 按版本号取历史条目;不在环形窗口内(更旧/未知)返回 undefined。 */
|
|
448
|
+
function snapshotVersionEntry(cache, version) {
|
|
449
|
+
if (cache === void 0) return void 0;
|
|
450
|
+
return cache.history[cache.history.length - 1]?.version === version ? cache.history[cache.history.length - 1] : cache.history.find((e) => e.version === version);
|
|
451
|
+
}
|
|
231
452
|
/** 已保存的录制脚本:name → 步骤数组。 */
|
|
232
453
|
const savedRecordings = /* @__PURE__ */ new Map();
|
|
233
454
|
/** 当前录制中的 buffer(非空 = 录制中)。 */
|
|
@@ -300,6 +521,19 @@ function resolveConfirm(id, decision) {
|
|
|
300
521
|
pendingConfirm.decision = decision;
|
|
301
522
|
return true;
|
|
302
523
|
}
|
|
524
|
+
let toolInFlight = 0;
|
|
525
|
+
/** live 工具调用开始(register 包装层调用)。 */
|
|
526
|
+
function noteToolStart() {
|
|
527
|
+
toolInFlight++;
|
|
528
|
+
}
|
|
529
|
+
/** live 工具调用结束(成功/失败都算结束)。 */
|
|
530
|
+
function noteToolEnd() {
|
|
531
|
+
if (toolInFlight > 0) toolInFlight--;
|
|
532
|
+
}
|
|
533
|
+
/** 当前在途的 live 工具调用数(0 = agent 空闲)。 */
|
|
534
|
+
function activeToolCalls() {
|
|
535
|
+
return toolInFlight;
|
|
536
|
+
}
|
|
303
537
|
/** window 上的回传 binding 名(须为合法 JS 标识符)。 */
|
|
304
538
|
const HUMAN_BINDING = "__dshBrowserHumanEvent";
|
|
305
539
|
/** 最近一次 agent DOM 操作(点击/输入/选择)的时间戳;用于区分人手与工具回声。 */
|
|
@@ -390,33 +624,16 @@ function handleHumanEvent(payload) {
|
|
|
390
624
|
* 页内注入的人工操作监听(addInitScript 序列化;每次导航自动重装)。
|
|
391
625
|
* 监听常驻但 node 侧只在录制中生效,关闭录制等价于"移除"。
|
|
392
626
|
* 只上报可交互元素上的 click 与表单控件的 change,避免逐键噪音。
|
|
627
|
+
* F5 收敛:cssPath 等元素描述来自 walker.ts 单一来源(构建期拼装注入),
|
|
628
|
+
* 不再持有内联副本;payload 字段语义与 v0.3 完全一致。
|
|
393
629
|
*/
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
630
|
+
const installHumanRecorder = injectWalker((_payload, w) => {
|
|
631
|
+
const cssPath = w.cssPath;
|
|
632
|
+
const w2 = window;
|
|
633
|
+
if (w2.__dshHumanRecorder === true) return;
|
|
634
|
+
w2.__dshHumanRecorder = true;
|
|
398
635
|
const BINDING = "__dshBrowserHumanEvent";
|
|
399
636
|
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
637
|
function payloadOf(el, kind, value) {
|
|
421
638
|
const aria = el.getAttribute("aria-label");
|
|
422
639
|
return {
|
|
@@ -433,7 +650,7 @@ function installHumanRecorder() {
|
|
|
433
650
|
}
|
|
434
651
|
function send(payload) {
|
|
435
652
|
try {
|
|
436
|
-
const fn =
|
|
653
|
+
const fn = w2[BINDING];
|
|
437
654
|
if (typeof fn === "function") Promise.resolve(fn(payload)).catch(() => {});
|
|
438
655
|
} catch {}
|
|
439
656
|
}
|
|
@@ -455,7 +672,7 @@ function installHumanRecorder() {
|
|
|
455
672
|
send(payloadOf(t, "input", t.value));
|
|
456
673
|
}
|
|
457
674
|
}, true);
|
|
458
|
-
}
|
|
675
|
+
});
|
|
459
676
|
const networkByProfile = /* @__PURE__ */ new Map();
|
|
460
677
|
function networkOf(name) {
|
|
461
678
|
let list = networkByProfile.get(name);
|
|
@@ -772,12 +989,16 @@ async function closeBrowser() {
|
|
|
772
989
|
profileStates.clear();
|
|
773
990
|
downloadsByProfile.clear();
|
|
774
991
|
}
|
|
775
|
-
/** Best-effort current page identity, safe when nothing is open. */
|
|
992
|
+
/** Best-effort current page identity, safe when nothing is open.title 有界防页面忙时挂起。 */
|
|
776
993
|
async function pageIdentity(page) {
|
|
777
994
|
try {
|
|
995
|
+
const title = await new Promise((resolve, reject) => {
|
|
996
|
+
const t = setTimeout(() => reject(/* @__PURE__ */ new Error("title timeout")), 2e3);
|
|
997
|
+
page.title().then(resolve, reject).finally(() => clearTimeout(t));
|
|
998
|
+
}).catch(() => "");
|
|
778
999
|
return {
|
|
779
1000
|
url: page.url(),
|
|
780
|
-
title
|
|
1001
|
+
title
|
|
781
1002
|
};
|
|
782
1003
|
} catch {
|
|
783
1004
|
return {
|
|
@@ -904,6 +1125,33 @@ function deleteRecordingFile(name) {
|
|
|
904
1125
|
return false;
|
|
905
1126
|
}
|
|
906
1127
|
}
|
|
1128
|
+
/**
|
|
1129
|
+
* 磁盘热重载:录制文件被外部编辑(手修 JSON)后,同会话内 browser_replay 仍用内存旧缓存。
|
|
1130
|
+
* 比较文件 mtime 与内存 savedAt(留 2s 余量容忍写入延迟),更新过则重新加载进内存表。
|
|
1131
|
+
* 返回是否发生了重载;文件不存在或损坏时静默保留内存版。
|
|
1132
|
+
*/
|
|
1133
|
+
function reloadRecordingIfChanged(name) {
|
|
1134
|
+
const mem = savedRecordings.get(name);
|
|
1135
|
+
const file = join(recordingsDir(), `${safeName(name)}.json`);
|
|
1136
|
+
let mtime;
|
|
1137
|
+
try {
|
|
1138
|
+
mtime = statSync(file).mtimeMs;
|
|
1139
|
+
} catch {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
if (mem !== void 0 && mtime <= mem.savedAt + 2e3) return false;
|
|
1143
|
+
try {
|
|
1144
|
+
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
1145
|
+
if (data !== null && typeof data === "object" && Array.isArray(data.steps)) {
|
|
1146
|
+
savedRecordings.set(name, {
|
|
1147
|
+
steps: data.steps,
|
|
1148
|
+
savedAt: mtime
|
|
1149
|
+
});
|
|
1150
|
+
return true;
|
|
1151
|
+
}
|
|
1152
|
+
} catch {}
|
|
1153
|
+
return false;
|
|
1154
|
+
}
|
|
907
1155
|
/** 录制列表(面板 / browser_record list 共用)。 */
|
|
908
1156
|
function listRecordings() {
|
|
909
1157
|
return [...savedRecordings.entries()].map(([name, r]) => ({
|
|
@@ -1162,7 +1410,7 @@ function renderMarkdownReport(runs, source) {
|
|
|
1162
1410
|
"| 时间 | 来源 | 通过/总数 | 失败 | 自愈 | 耗时 |",
|
|
1163
1411
|
"| --- | --- | --- | --- | --- | --- |"
|
|
1164
1412
|
];
|
|
1165
|
-
for (const r of runs) lines.push(`| ${fmtTs(r.ts)} | ${r.source}${r.row !== void 0 ? ` (行${r.row})` : ""} | ${r.passed}/${r.total} | ${r.failed} | ${r.healedCount} | ${(r.durationMs / 1e3).toFixed(1)}s |`);
|
|
1413
|
+
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 |`);
|
|
1166
1414
|
const { failures, healed } = analyze(runs);
|
|
1167
1415
|
lines.push("", "## 失败步骤 Top", "");
|
|
1168
1416
|
if (failures.length === 0) lines.push("全部通过 🎉");
|
|
@@ -1183,7 +1431,7 @@ function renderHtmlReport(runs, source) {
|
|
|
1183
1431
|
const { failures, healed } = analyze(runs);
|
|
1184
1432
|
const rowHtml = runs.map((r) => {
|
|
1185
1433
|
const ok = r.failed === 0;
|
|
1186
|
-
return `<tr><td>${esc(fmtTs(r.ts))}</td><td>${esc(r.source)}${r.row !== void 0 ? ` (行${r.row})` : ""}</td><td class="${ok ? "ok" : "bad"}">${r.passed}/${r.total}</td><td>${r.failed}</td><td>${r.healedCount}</td><td>${(r.durationMs / 1e3).toFixed(1)}s</td></tr>`;
|
|
1434
|
+
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>`;
|
|
1187
1435
|
}).join("\n");
|
|
1188
1436
|
const failRows = failures.length === 0 ? `<tr><td colspan="4" class="ok">全部通过 🎉</td></tr>` : failures.map((f) => `<tr><td>${f.count}</td><td>${esc(f.source)}</td><td>${esc(f.tool)}</td><td>${esc(f.error.slice(0, 200))}</td></tr>`).join("\n");
|
|
1189
1437
|
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");
|
|
@@ -1274,160 +1522,14 @@ function compressText(text) {
|
|
|
1274
1522
|
return out.join("\n");
|
|
1275
1523
|
}
|
|
1276
1524
|
/**
|
|
1277
|
-
* 页内 ARIA 树构建(
|
|
1525
|
+
* 页内 ARIA 树构建(walker 单一来源:roleOf/nameOf/cssPath/isVisible 与
|
|
1526
|
+
* INTERACTIVE/STRUCTURAL 集合来自 walker.ts,构建期拼装注入)。
|
|
1278
1527
|
* 遍历 DOM:结构角色(navigation/main/form/table/heading…)直接打印;
|
|
1279
1528
|
* 可交互元素分配 ref=e1..eN 并附稳定 CSS 路径;无语义容器(div/span)穿透。
|
|
1280
1529
|
* 输出形如 `- button "登录" [ref=e3]` 的缩进树,供纯文本模型定位操作目标。
|
|
1281
1530
|
*/
|
|
1282
|
-
|
|
1283
|
-
const INTERACTIVE
|
|
1284
|
-
"link",
|
|
1285
|
-
"button",
|
|
1286
|
-
"textbox",
|
|
1287
|
-
"checkbox",
|
|
1288
|
-
"radio",
|
|
1289
|
-
"combobox",
|
|
1290
|
-
"listbox",
|
|
1291
|
-
"searchbox",
|
|
1292
|
-
"slider",
|
|
1293
|
-
"spinbutton",
|
|
1294
|
-
"switch",
|
|
1295
|
-
"tab",
|
|
1296
|
-
"menuitem",
|
|
1297
|
-
"option",
|
|
1298
|
-
"treeitem"
|
|
1299
|
-
]);
|
|
1300
|
-
const STRUCTURAL = /* @__PURE__ */ new Set([
|
|
1301
|
-
"navigation",
|
|
1302
|
-
"main",
|
|
1303
|
-
"banner",
|
|
1304
|
-
"contentinfo",
|
|
1305
|
-
"form",
|
|
1306
|
-
"search",
|
|
1307
|
-
"complementary",
|
|
1308
|
-
"region",
|
|
1309
|
-
"dialog",
|
|
1310
|
-
"alert",
|
|
1311
|
-
"table",
|
|
1312
|
-
"list",
|
|
1313
|
-
"heading",
|
|
1314
|
-
"toolbar",
|
|
1315
|
-
"menu",
|
|
1316
|
-
"menubar",
|
|
1317
|
-
"tablist",
|
|
1318
|
-
"tree",
|
|
1319
|
-
"grid"
|
|
1320
|
-
]);
|
|
1321
|
-
const SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
1322
|
-
"script",
|
|
1323
|
-
"style",
|
|
1324
|
-
"template",
|
|
1325
|
-
"noscript",
|
|
1326
|
-
"svg",
|
|
1327
|
-
"path",
|
|
1328
|
-
"link",
|
|
1329
|
-
"meta",
|
|
1330
|
-
"br"
|
|
1331
|
-
]);
|
|
1332
|
-
function roleOf(el) {
|
|
1333
|
-
const explicit = el.getAttribute("role");
|
|
1334
|
-
if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
|
|
1335
|
-
switch (el.tagName.toLowerCase()) {
|
|
1336
|
-
case "a": return el.hasAttribute("href") ? "link" : "";
|
|
1337
|
-
case "button": return "button";
|
|
1338
|
-
case "input": {
|
|
1339
|
-
const t = (el.getAttribute("type") ?? "text").toLowerCase();
|
|
1340
|
-
if (t === "checkbox") return "checkbox";
|
|
1341
|
-
if (t === "radio") return "radio";
|
|
1342
|
-
if (t === "range") return "slider";
|
|
1343
|
-
if (t === "number") return "spinbutton";
|
|
1344
|
-
if (t === "search") return "searchbox";
|
|
1345
|
-
if ([
|
|
1346
|
-
"button",
|
|
1347
|
-
"submit",
|
|
1348
|
-
"reset",
|
|
1349
|
-
"image"
|
|
1350
|
-
].includes(t)) return "button";
|
|
1351
|
-
if (["hidden", "file"].includes(t)) return "";
|
|
1352
|
-
return "textbox";
|
|
1353
|
-
}
|
|
1354
|
-
case "textarea": return "textbox";
|
|
1355
|
-
case "select": return "combobox";
|
|
1356
|
-
case "nav": return "navigation";
|
|
1357
|
-
case "main": return "main";
|
|
1358
|
-
case "header": return "banner";
|
|
1359
|
-
case "footer": return "contentinfo";
|
|
1360
|
-
case "form": return "form";
|
|
1361
|
-
case "h1":
|
|
1362
|
-
case "h2":
|
|
1363
|
-
case "h3":
|
|
1364
|
-
case "h4":
|
|
1365
|
-
case "h5":
|
|
1366
|
-
case "h6": return "heading";
|
|
1367
|
-
case "ul":
|
|
1368
|
-
case "ol": return "list";
|
|
1369
|
-
case "li": return "listitem";
|
|
1370
|
-
case "table": return "table";
|
|
1371
|
-
case "dialog": return "dialog";
|
|
1372
|
-
case "summary": return "button";
|
|
1373
|
-
default: return "";
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
function nameOf(el, role) {
|
|
1377
|
-
const aria = el.getAttribute("aria-label");
|
|
1378
|
-
if (aria && aria.trim()) return aria.trim().slice(0, 60);
|
|
1379
|
-
const labelledby = el.getAttribute("aria-labelledby");
|
|
1380
|
-
if (labelledby) {
|
|
1381
|
-
const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
|
|
1382
|
-
if (t) return t.slice(0, 60);
|
|
1383
|
-
}
|
|
1384
|
-
if (el.id) {
|
|
1385
|
-
const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
|
|
1386
|
-
if (t) return t.slice(0, 60);
|
|
1387
|
-
}
|
|
1388
|
-
if ([
|
|
1389
|
-
"textbox",
|
|
1390
|
-
"searchbox",
|
|
1391
|
-
"combobox",
|
|
1392
|
-
"spinbutton"
|
|
1393
|
-
].includes(role)) {
|
|
1394
|
-
const ph = el.getAttribute("placeholder");
|
|
1395
|
-
if (ph && ph.trim()) return ph.trim().slice(0, 60);
|
|
1396
|
-
}
|
|
1397
|
-
const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
|
|
1398
|
-
if (text) return text.slice(0, 60);
|
|
1399
|
-
const title = el.getAttribute("title");
|
|
1400
|
-
if (title && title.trim()) return title.trim().slice(0, 60);
|
|
1401
|
-
return "";
|
|
1402
|
-
}
|
|
1403
|
-
/** 稳定 CSS 路径:#id → [data-testid] → nth-of-type 链(≤6 层)。 */
|
|
1404
|
-
function cssPath(el) {
|
|
1405
|
-
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
1406
|
-
const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
|
|
1407
|
-
if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
|
|
1408
|
-
const parts = [];
|
|
1409
|
-
let cur = el;
|
|
1410
|
-
while (cur && cur !== document.body && parts.length < 6) {
|
|
1411
|
-
const tag = cur.tagName.toLowerCase();
|
|
1412
|
-
const parent = cur.parentElement;
|
|
1413
|
-
if (!parent) {
|
|
1414
|
-
parts.unshift(tag);
|
|
1415
|
-
break;
|
|
1416
|
-
}
|
|
1417
|
-
const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
1418
|
-
const idx = sameTag.indexOf(cur) + 1;
|
|
1419
|
-
parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
|
|
1420
|
-
cur = parent;
|
|
1421
|
-
}
|
|
1422
|
-
return parts.join(" > ");
|
|
1423
|
-
}
|
|
1424
|
-
function isVisible(el) {
|
|
1425
|
-
if (el.getAttribute("aria-hidden") === "true") return false;
|
|
1426
|
-
const rect = el.getBoundingClientRect();
|
|
1427
|
-
if (rect.width === 0 && rect.height === 0) return false;
|
|
1428
|
-
const style = getComputedStyle(el);
|
|
1429
|
-
return style.visibility !== "hidden" && style.display !== "none";
|
|
1430
|
-
}
|
|
1531
|
+
const buildAriaTree = injectWalker((payload, w) => {
|
|
1532
|
+
const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible } = w;
|
|
1431
1533
|
const lines = [];
|
|
1432
1534
|
const refs = [];
|
|
1433
1535
|
let truncated = false;
|
|
@@ -1437,190 +1539,60 @@ function buildAriaTree(payload) {
|
|
|
1437
1539
|
truncated = true;
|
|
1438
1540
|
return;
|
|
1439
1541
|
}
|
|
1440
|
-
const role = roleOf(el);
|
|
1441
|
-
if (INTERACTIVE.has(role)) {
|
|
1442
|
-
if (!isVisible(el)) return;
|
|
1443
|
-
const name = nameOf(el, role);
|
|
1444
|
-
const ref = refs.length < payload.maxRefs ? `e${refs.length + 1}` : "";
|
|
1445
|
-
if (ref !== "") refs.push({
|
|
1446
|
-
ref,
|
|
1447
|
-
role,
|
|
1448
|
-
name,
|
|
1449
|
-
selector: cssPath(el)
|
|
1450
|
-
});
|
|
1451
|
-
const expanded = el.getAttribute("aria-expanded");
|
|
1452
|
-
const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
|
|
1453
|
-
lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${ref !== "" ? ` [ref=${ref}]` : ""}${expanded !== null ? ` [expanded=${expanded}]` : ""}${disabled ? " [disabled]" : ""}`);
|
|
1454
|
-
return;
|
|
1455
|
-
}
|
|
1456
|
-
if (STRUCTURAL.has(role)) {
|
|
1457
|
-
if (!isVisible(el)) return;
|
|
1458
|
-
const name = nameOf(el, role);
|
|
1459
|
-
const level = role === "heading" ? ` [level=${el.tagName[1] ?? "1"}]` : "";
|
|
1460
|
-
lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${level}`);
|
|
1461
|
-
for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth + 1);
|
|
1462
|
-
return;
|
|
1463
|
-
}
|
|
1464
|
-
for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth);
|
|
1465
|
-
}
|
|
1466
|
-
if (document.body === null) return {
|
|
1467
|
-
tree: "",
|
|
1468
|
-
refs,
|
|
1469
|
-
truncated: false,
|
|
1470
|
-
scopeMissing: false
|
|
1471
|
-
};
|
|
1472
|
-
const root = payload.scope ? document.querySelector(payload.scope) : document.body;
|
|
1473
|
-
if (root === null) return {
|
|
1474
|
-
tree: "",
|
|
1475
|
-
refs,
|
|
1476
|
-
truncated: false,
|
|
1477
|
-
scopeMissing: true
|
|
1478
|
-
};
|
|
1479
|
-
walk(root, 0);
|
|
1480
|
-
return {
|
|
1481
|
-
tree: lines.join("\n"),
|
|
1482
|
-
refs,
|
|
1483
|
-
truncated,
|
|
1484
|
-
scopeMissing: false
|
|
1485
|
-
};
|
|
1486
|
-
}
|
|
1487
|
-
/**
|
|
1488
|
-
* 页内语义查找:只收集匹配 query 的可交互元素(不构建全量树)。
|
|
1489
|
-
* role/name/cssPath/isVisible 与 buildAriaTree 保持同一套语义(此处内联副本,
|
|
1490
|
-
* evaluate 序列化不允许闭包引用);改动任一处时同步另一处。
|
|
1491
|
-
*/
|
|
1492
|
-
function findInteractive(payload) {
|
|
1493
|
-
const INTERACTIVE = /* @__PURE__ */ new Set([
|
|
1494
|
-
"link",
|
|
1495
|
-
"button",
|
|
1496
|
-
"textbox",
|
|
1497
|
-
"checkbox",
|
|
1498
|
-
"radio",
|
|
1499
|
-
"combobox",
|
|
1500
|
-
"listbox",
|
|
1501
|
-
"searchbox",
|
|
1502
|
-
"slider",
|
|
1503
|
-
"spinbutton",
|
|
1504
|
-
"switch",
|
|
1505
|
-
"tab",
|
|
1506
|
-
"menuitem",
|
|
1507
|
-
"option",
|
|
1508
|
-
"treeitem"
|
|
1509
|
-
]);
|
|
1510
|
-
const STRUCTURAL = /* @__PURE__ */ new Set([
|
|
1511
|
-
"navigation",
|
|
1512
|
-
"main",
|
|
1513
|
-
"banner",
|
|
1514
|
-
"contentinfo",
|
|
1515
|
-
"form",
|
|
1516
|
-
"search",
|
|
1517
|
-
"complementary",
|
|
1518
|
-
"region",
|
|
1519
|
-
"dialog",
|
|
1520
|
-
"alert",
|
|
1521
|
-
"table",
|
|
1522
|
-
"list",
|
|
1523
|
-
"heading",
|
|
1524
|
-
"toolbar",
|
|
1525
|
-
"menu",
|
|
1526
|
-
"menubar",
|
|
1527
|
-
"tablist",
|
|
1528
|
-
"tree",
|
|
1529
|
-
"grid"
|
|
1530
|
-
]);
|
|
1531
|
-
const SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
1532
|
-
"script",
|
|
1533
|
-
"style",
|
|
1534
|
-
"template",
|
|
1535
|
-
"noscript",
|
|
1536
|
-
"svg",
|
|
1537
|
-
"path",
|
|
1538
|
-
"link",
|
|
1539
|
-
"meta",
|
|
1540
|
-
"br"
|
|
1541
|
-
]);
|
|
1542
|
-
function roleOf(el) {
|
|
1543
|
-
const explicit = el.getAttribute("role");
|
|
1544
|
-
if (explicit) return explicit.trim().split(/\s+/)[0] ?? "";
|
|
1545
|
-
switch (el.tagName.toLowerCase()) {
|
|
1546
|
-
case "a": return el.hasAttribute("href") ? "link" : "";
|
|
1547
|
-
case "button": return "button";
|
|
1548
|
-
case "input": {
|
|
1549
|
-
const t = (el.getAttribute("type") ?? "text").toLowerCase();
|
|
1550
|
-
if (t === "checkbox") return "checkbox";
|
|
1551
|
-
if (t === "radio") return "radio";
|
|
1552
|
-
if (t === "range") return "slider";
|
|
1553
|
-
if (t === "number") return "spinbutton";
|
|
1554
|
-
if (t === "search") return "searchbox";
|
|
1555
|
-
if ([
|
|
1556
|
-
"button",
|
|
1557
|
-
"submit",
|
|
1558
|
-
"reset",
|
|
1559
|
-
"image"
|
|
1560
|
-
].includes(t)) return "button";
|
|
1561
|
-
if (["hidden", "file"].includes(t)) return "";
|
|
1562
|
-
return "textbox";
|
|
1563
|
-
}
|
|
1564
|
-
case "textarea": return "textbox";
|
|
1565
|
-
case "select": return "combobox";
|
|
1566
|
-
case "summary": return "button";
|
|
1567
|
-
default: return "";
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
function nameOf(el, role) {
|
|
1571
|
-
const aria = el.getAttribute("aria-label");
|
|
1572
|
-
if (aria && aria.trim()) return aria.trim().slice(0, 60);
|
|
1573
|
-
const labelledby = el.getAttribute("aria-labelledby");
|
|
1574
|
-
if (labelledby) {
|
|
1575
|
-
const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
|
|
1576
|
-
if (t) return t.slice(0, 60);
|
|
1577
|
-
}
|
|
1578
|
-
if (el.id) {
|
|
1579
|
-
const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
|
|
1580
|
-
if (t) return t.slice(0, 60);
|
|
1581
|
-
}
|
|
1582
|
-
if ([
|
|
1583
|
-
"textbox",
|
|
1584
|
-
"searchbox",
|
|
1585
|
-
"combobox",
|
|
1586
|
-
"spinbutton"
|
|
1587
|
-
].includes(role)) {
|
|
1588
|
-
const ph = el.getAttribute("placeholder");
|
|
1589
|
-
if (ph && ph.trim()) return ph.trim().slice(0, 60);
|
|
1590
|
-
}
|
|
1591
|
-
const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
|
|
1592
|
-
if (text) return text.slice(0, 60);
|
|
1593
|
-
const title = el.getAttribute("title");
|
|
1594
|
-
if (title && title.trim()) return title.trim().slice(0, 60);
|
|
1595
|
-
return "";
|
|
1596
|
-
}
|
|
1597
|
-
function cssPath(el) {
|
|
1598
|
-
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
1599
|
-
const testid = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id");
|
|
1600
|
-
if (testid) return `[data-testid="${testid.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"]`;
|
|
1601
|
-
const parts = [];
|
|
1602
|
-
let cur = el;
|
|
1603
|
-
while (cur && cur !== document.body && parts.length < 6) {
|
|
1604
|
-
const tag = cur.tagName.toLowerCase();
|
|
1605
|
-
const parent = cur.parentElement;
|
|
1606
|
-
if (!parent) {
|
|
1607
|
-
parts.unshift(tag);
|
|
1608
|
-
break;
|
|
1609
|
-
}
|
|
1610
|
-
const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
1611
|
-
const idx = sameTag.indexOf(cur) + 1;
|
|
1612
|
-
parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
|
|
1613
|
-
cur = parent;
|
|
1614
|
-
}
|
|
1615
|
-
return parts.join(" > ");
|
|
1616
|
-
}
|
|
1617
|
-
function isVisible(el) {
|
|
1618
|
-
if (el.getAttribute("aria-hidden") === "true") return false;
|
|
1619
|
-
const rect = el.getBoundingClientRect();
|
|
1620
|
-
if (rect.width === 0 && rect.height === 0) return false;
|
|
1621
|
-
const style = getComputedStyle(el);
|
|
1622
|
-
return style.visibility !== "hidden" && style.display !== "none";
|
|
1542
|
+
const role = roleOf(el);
|
|
1543
|
+
if (INTERACTIVE.has(role)) {
|
|
1544
|
+
if (!isVisible(el)) return;
|
|
1545
|
+
const name = nameOf(el, role);
|
|
1546
|
+
const ref = refs.length < payload.maxRefs ? `e${refs.length + 1}` : "";
|
|
1547
|
+
if (ref !== "") refs.push({
|
|
1548
|
+
ref,
|
|
1549
|
+
role,
|
|
1550
|
+
name,
|
|
1551
|
+
selector: cssPath(el)
|
|
1552
|
+
});
|
|
1553
|
+
const expanded = el.getAttribute("aria-expanded");
|
|
1554
|
+
const disabled = el.disabled === true || el.getAttribute("aria-disabled") === "true";
|
|
1555
|
+
lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${ref !== "" ? ` [ref=${ref}]` : ""}${expanded !== null ? ` [expanded=${expanded}]` : ""}${disabled ? " [disabled]" : ""}`);
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
if (STRUCTURAL.has(role)) {
|
|
1559
|
+
if (!isVisible(el)) return;
|
|
1560
|
+
const name = nameOf(el, role);
|
|
1561
|
+
const level = role === "heading" ? ` [level=${el.tagName[1] ?? "1"}]` : "";
|
|
1562
|
+
lines.push(`${" ".repeat(depth)}- ${role}${name !== "" ? ` "${name.replace(/"/g, "'")}"` : ""}${level}`);
|
|
1563
|
+
for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth + 1);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
for (const c of Array.from(el.children)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) walk(c, depth);
|
|
1623
1567
|
}
|
|
1568
|
+
if (document.body === null) return {
|
|
1569
|
+
tree: "",
|
|
1570
|
+
refs,
|
|
1571
|
+
truncated: false,
|
|
1572
|
+
scopeMissing: false
|
|
1573
|
+
};
|
|
1574
|
+
const root = payload.scope ? document.querySelector(payload.scope) : document.body;
|
|
1575
|
+
if (root === null) return {
|
|
1576
|
+
tree: "",
|
|
1577
|
+
refs,
|
|
1578
|
+
truncated: false,
|
|
1579
|
+
scopeMissing: true
|
|
1580
|
+
};
|
|
1581
|
+
walk(root, 0);
|
|
1582
|
+
return {
|
|
1583
|
+
tree: lines.join("\n"),
|
|
1584
|
+
refs,
|
|
1585
|
+
truncated,
|
|
1586
|
+
scopeMissing: false
|
|
1587
|
+
};
|
|
1588
|
+
});
|
|
1589
|
+
/**
|
|
1590
|
+
* 页内语义查找:只收集匹配 query 的可交互元素(不构建全量树)。
|
|
1591
|
+
* role/name/cssPath/isVisible 与 buildAriaTree 同一来源(walker.ts 单一来源,
|
|
1592
|
+
* 构建期拼装注入),语义天然一致,不再需要手工同步副本。
|
|
1593
|
+
*/
|
|
1594
|
+
const findInteractive = injectWalker((payload, w) => {
|
|
1595
|
+
const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible } = w;
|
|
1624
1596
|
/** 最近的结构化祖先(区域上下文,帮助区分多命中)。 */
|
|
1625
1597
|
function ancestorsOf(el) {
|
|
1626
1598
|
const out = [];
|
|
@@ -1679,7 +1651,7 @@ function findInteractive(payload) {
|
|
|
1679
1651
|
candidates,
|
|
1680
1652
|
scanned
|
|
1681
1653
|
};
|
|
1682
|
-
}
|
|
1654
|
+
});
|
|
1683
1655
|
/** 最长公共子串长度(小写比较;零依赖,导出供测试)。 */
|
|
1684
1656
|
function lcsScore(a, b) {
|
|
1685
1657
|
const x = a.toLowerCase();
|
|
@@ -1812,7 +1784,9 @@ async function snapshotTool(config, args) {
|
|
|
1812
1784
|
title: await page.title()
|
|
1813
1785
|
};
|
|
1814
1786
|
const cache = snapshotCacheOf(page);
|
|
1815
|
-
const
|
|
1787
|
+
const capacity = config.snapshotHistory ?? 5;
|
|
1788
|
+
const base = since !== void 0 ? snapshotVersionEntry(cache, since) : void 0;
|
|
1789
|
+
const canDiff = base !== void 0;
|
|
1816
1790
|
let content;
|
|
1817
1791
|
let extra = {};
|
|
1818
1792
|
let walkerTruncated = false;
|
|
@@ -1858,7 +1832,7 @@ async function snapshotTool(config, args) {
|
|
|
1858
1832
|
};
|
|
1859
1833
|
}
|
|
1860
1834
|
const key = ariaMode ? "aria" : "text";
|
|
1861
|
-
if (canDiff &&
|
|
1835
|
+
if (canDiff && base[key] === content) return {
|
|
1862
1836
|
unchanged: true,
|
|
1863
1837
|
contentVersion: cache.version,
|
|
1864
1838
|
...scope !== "" ? { scope } : {},
|
|
@@ -1866,22 +1840,23 @@ async function snapshotTool(config, args) {
|
|
|
1866
1840
|
...extra
|
|
1867
1841
|
};
|
|
1868
1842
|
if (canDiff) {
|
|
1869
|
-
const d = diffLines(
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
text: ariaMode ?
|
|
1873
|
-
aria: ariaMode ? content :
|
|
1874
|
-
});
|
|
1843
|
+
const d = diffLines(base[key], content);
|
|
1844
|
+
const latest = cache.history[cache.history.length - 1];
|
|
1845
|
+
const next = pushSnapshotVersion(cache, {
|
|
1846
|
+
text: ariaMode ? latest.text : content,
|
|
1847
|
+
aria: ariaMode ? content : latest.aria
|
|
1848
|
+
}, capacity);
|
|
1849
|
+
setSnapshotCache(page, next);
|
|
1875
1850
|
if (d.unchanged) return {
|
|
1876
1851
|
unchanged: true,
|
|
1877
|
-
contentVersion:
|
|
1852
|
+
contentVersion: next.version,
|
|
1878
1853
|
...scope !== "" ? { scope } : {},
|
|
1879
1854
|
...identity,
|
|
1880
1855
|
...extra
|
|
1881
1856
|
};
|
|
1882
1857
|
return {
|
|
1883
1858
|
diff: true,
|
|
1884
|
-
contentVersion:
|
|
1859
|
+
contentVersion: next.version,
|
|
1885
1860
|
changedFrom: d.changedFrom,
|
|
1886
1861
|
removed: d.removed,
|
|
1887
1862
|
...scope !== "" ? { scope } : {},
|
|
@@ -1891,13 +1866,14 @@ async function snapshotTool(config, args) {
|
|
|
1891
1866
|
...extra
|
|
1892
1867
|
};
|
|
1893
1868
|
}
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
text: ariaMode ?
|
|
1897
|
-
aria: ariaMode ? content :
|
|
1898
|
-
});
|
|
1869
|
+
const latestEntry = cache?.history[cache.history.length - 1];
|
|
1870
|
+
const next = pushSnapshotVersion(cache, {
|
|
1871
|
+
text: ariaMode ? latestEntry?.text ?? "" : content,
|
|
1872
|
+
aria: ariaMode ? content : latestEntry?.aria ?? ""
|
|
1873
|
+
}, capacity);
|
|
1874
|
+
setSnapshotCache(page, next);
|
|
1899
1875
|
return {
|
|
1900
|
-
contentVersion:
|
|
1876
|
+
contentVersion: next.version,
|
|
1901
1877
|
...since !== void 0 ? { stale: true } : {},
|
|
1902
1878
|
...scope !== "" ? { scope } : {},
|
|
1903
1879
|
[ariaMode ? "tree" : "text"]: content.slice(0, maxText),
|
|
@@ -1917,8 +1893,8 @@ async function targetLocator(page, args) {
|
|
|
1917
1893
|
const entry = pageRef(page, ref);
|
|
1918
1894
|
if (entry === void 0) throw new Error(`未知 ref: ${ref}(页面导航或重新快照后旧 ref 失效;请先 browser_snapshot { mode: "aria" })`);
|
|
1919
1895
|
const byPath = page.locator(entry.selector).first();
|
|
1920
|
-
if (await byPath.count().catch(() => 0) > 0) {
|
|
1921
|
-
if (await byPath.evaluate((el, want) => {
|
|
1896
|
+
if (await withTimeout(byPath.count(), 3e3, "解析 ref 路径").catch(() => 0) > 0) {
|
|
1897
|
+
if (await withTimeout(byPath.evaluate((el, want) => {
|
|
1922
1898
|
const tag = el.tagName.toLowerCase();
|
|
1923
1899
|
const type = (el.getAttribute("type") ?? "").toLowerCase();
|
|
1924
1900
|
const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
|
|
@@ -1940,10 +1916,10 @@ async function targetLocator(page, args) {
|
|
|
1940
1916
|
}, {
|
|
1941
1917
|
role: entry.role,
|
|
1942
1918
|
name: entry.name
|
|
1943
|
-
}).catch(() => false)) return byPath;
|
|
1919
|
+
}), 3e3, "校验 ref 元素").catch(() => false)) return byPath;
|
|
1944
1920
|
}
|
|
1945
1921
|
const byRole = entry.name !== "" ? page.getByRole(entry.role, { name: entry.name }).first() : page.getByRole(entry.role).first();
|
|
1946
|
-
if (await byRole.count().catch(() => 0) > 0) return byRole;
|
|
1922
|
+
if (await withTimeout(byRole.count(), 3e3, "解析 ref role 降级").catch(() => 0) > 0) return byRole;
|
|
1947
1923
|
throw new Error(`ref ${ref} 已失效(元素不存在或内容已变化);请重新 browser_snapshot { mode: "aria" }`);
|
|
1948
1924
|
}
|
|
1949
1925
|
if (args.selector === void 0) throw new Error("需要 selector 或 ref 之一");
|
|
@@ -1951,7 +1927,21 @@ async function targetLocator(page, args) {
|
|
|
1951
1927
|
}
|
|
1952
1928
|
async function clickTool(config, args) {
|
|
1953
1929
|
const page = await getPage(config);
|
|
1954
|
-
|
|
1930
|
+
const target = await targetLocator(page, args);
|
|
1931
|
+
const label = String(args.ref ?? args.selector ?? "");
|
|
1932
|
+
try {
|
|
1933
|
+
await target.click({ timeout: args.timeoutMs ?? config.timeoutMs });
|
|
1934
|
+
} catch (e) {
|
|
1935
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1936
|
+
let diag = "";
|
|
1937
|
+
try {
|
|
1938
|
+
if (await target.count() === 0) diag = "元素未匹配(selector 可能写错或元素未渲染)";
|
|
1939
|
+
else diag = await target.first().isVisible() ? "元素可见但不可交互(被遮挡/禁用?);若页面刚发生跳转,点击可能实际已生效" : "元素存在但不可见";
|
|
1940
|
+
} catch {
|
|
1941
|
+
diag = "元素已从页面移除(若页面刚发生跳转,点击可能实际已生效)";
|
|
1942
|
+
}
|
|
1943
|
+
throw new Error(`click 失败: ${label} — ${diag};原始错误: ${msg}`);
|
|
1944
|
+
}
|
|
1955
1945
|
await page.waitForLoadState("domcontentloaded").catch(() => {});
|
|
1956
1946
|
return {
|
|
1957
1947
|
clicked: String(args.ref ?? args.selector),
|
|
@@ -1991,6 +1981,18 @@ const DROPDOWN_OPTION_SELECTOR = [
|
|
|
1991
1981
|
".el-select-dropdown__item",
|
|
1992
1982
|
"[role='listbox'] [role='option']"
|
|
1993
1983
|
].join(", ");
|
|
1984
|
+
/** 给本身无超时的 Playwright 调用(evaluate/allTextContents)加有界保护,防页面主线程忙时无限挂起。 */
|
|
1985
|
+
async function withTimeout(p, ms, what) {
|
|
1986
|
+
let timer;
|
|
1987
|
+
const guard = new Promise((_, reject) => {
|
|
1988
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${what} 超时(${ms}ms;页面主线程可能正忙)`)), ms);
|
|
1989
|
+
});
|
|
1990
|
+
try {
|
|
1991
|
+
return await Promise.race([p, guard]);
|
|
1992
|
+
} finally {
|
|
1993
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1994
1996
|
/**
|
|
1995
1997
|
* 下拉选择原语:原生 <select> 走 selectOption;组件库下拉(antd/element/listbox)
|
|
1996
1998
|
* 走「点击触发框 → 等待可见选项 → 按文本点击」。找不到选项时抛出错误并附当前可见
|
|
@@ -2004,7 +2006,8 @@ async function selectTool(config, args) {
|
|
|
2004
2006
|
if (!want) throw new Error("select 需要 option(选项文本)");
|
|
2005
2007
|
const exact = args.exact === true;
|
|
2006
2008
|
const timeout = args.timeoutMs ?? config.timeoutMs;
|
|
2007
|
-
|
|
2009
|
+
const probe = Math.min(timeout, 5e3);
|
|
2010
|
+
if (await withTimeout(target.evaluate((el) => el.tagName.toLowerCase()), probe, "读取目标元素").catch(() => "") === "select") try {
|
|
2008
2011
|
await target.selectOption({ label: want }, { timeout });
|
|
2009
2012
|
return {
|
|
2010
2013
|
selected: want,
|
|
@@ -2012,23 +2015,35 @@ async function selectTool(config, args) {
|
|
|
2012
2015
|
...await pageIdentity(page)
|
|
2013
2016
|
};
|
|
2014
2017
|
} catch {
|
|
2015
|
-
const options = await target.locator("option").allTextContents();
|
|
2018
|
+
const options = await withTimeout(target.locator("option").allTextContents(), probe, "读取原生下拉选项").catch(() => []);
|
|
2016
2019
|
throw new Error(`原生下拉无选项 "${want}";可用选项: ${options.map((t) => t.trim()).join(" / ")}`);
|
|
2017
2020
|
}
|
|
2018
|
-
|
|
2021
|
+
const clickTimeout = Math.min(timeout, 1e4);
|
|
2022
|
+
try {
|
|
2023
|
+
await target.click({ timeout: clickTimeout });
|
|
2024
|
+
} catch (e0) {
|
|
2025
|
+
try {
|
|
2026
|
+
await target.click({
|
|
2027
|
+
timeout: 2e3,
|
|
2028
|
+
force: true
|
|
2029
|
+
});
|
|
2030
|
+
} catch {
|
|
2031
|
+
throw new Error(`下拉触发框点击失败(元素未出现/被遮挡/不可交互): ${label};原始错误: ${e0 instanceof Error ? e0.message : String(e0)}。可尝试 selector 指向上层触发容器(如 .ant-select),或用 browser_snapshot { mode: "aria" } 拿 ref`);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2019
2034
|
const options = page.locator(DROPDOWN_OPTION_SELECTOR);
|
|
2020
2035
|
await options.first().waitFor({
|
|
2021
2036
|
state: "visible",
|
|
2022
2037
|
timeout: Math.min(timeout, 5e3)
|
|
2023
2038
|
}).catch(() => {});
|
|
2024
|
-
const texts = (await options.allTextContents()).map((t) => t.trim()).filter(Boolean);
|
|
2039
|
+
const texts = (await withTimeout(options.allTextContents(), probe, "读取下拉选项")).map((t) => t.trim()).filter(Boolean);
|
|
2025
2040
|
if (texts.length === 0) throw new Error(`点击 ${label} 后未出现下拉选项(非原生 select,也不是已知的组件库下拉)`);
|
|
2026
2041
|
const index = texts.findIndex((t) => exact ? t === want : t.includes(want));
|
|
2027
2042
|
if (index === -1) {
|
|
2028
2043
|
await page.keyboard.press("Escape").catch(() => {});
|
|
2029
2044
|
throw new Error(`下拉选项中无 "${want}";可见选项: ${texts.join(" / ")}`);
|
|
2030
2045
|
}
|
|
2031
|
-
await options.nth(index).click();
|
|
2046
|
+
await options.nth(index).click({ timeout: probe });
|
|
2032
2047
|
return {
|
|
2033
2048
|
selected: texts[index],
|
|
2034
2049
|
selector: label,
|
|
@@ -3028,6 +3043,69 @@ async function confirmTool(config, args) {
|
|
|
3028
3043
|
detail
|
|
3029
3044
|
};
|
|
3030
3045
|
}
|
|
3046
|
+
/** auto 模式默认敏感文本模式(提交/删除/支付类)。 */
|
|
3047
|
+
const DEFAULT_SENSITIVE_PATTERNS = [
|
|
3048
|
+
"提交",
|
|
3049
|
+
"删除",
|
|
3050
|
+
"支付"
|
|
3051
|
+
];
|
|
3052
|
+
/** auto 模式默认参与判定的工具(已决:只拦 browser_click,type 误报率高)。 */
|
|
3053
|
+
const DEFAULT_SENSITIVE_TOOLS = ["browser_click"];
|
|
3054
|
+
/** 敏感判定的页内目标描述提取(walker 统一元素描述,构建期拼装注入)。 */
|
|
3055
|
+
const describeTarget = injectWalker((sel, w) => {
|
|
3056
|
+
const el = document.querySelector(sel);
|
|
3057
|
+
return el === null ? null : w.describeElement(el);
|
|
3058
|
+
});
|
|
3059
|
+
/**
|
|
3060
|
+
* 纯逻辑:元素描述命中了哪些敏感模式(role/name/text/id/testid/tag 拼接后
|
|
3061
|
+
* 子串匹配,大小写不敏感;空白模式忽略)。返回命中的模式列表(空 = 放行)。
|
|
3062
|
+
* 导出供测试。
|
|
3063
|
+
*/
|
|
3064
|
+
function sensitiveHits(desc, patterns) {
|
|
3065
|
+
if (desc === null) return [];
|
|
3066
|
+
const hay = [
|
|
3067
|
+
desc.role,
|
|
3068
|
+
desc.name,
|
|
3069
|
+
desc.text,
|
|
3070
|
+
desc.id,
|
|
3071
|
+
desc.testid,
|
|
3072
|
+
desc.tag
|
|
3073
|
+
].filter((s) => typeof s === "string").join(" ").toLowerCase();
|
|
3074
|
+
if (hay === "") return [];
|
|
3075
|
+
return patterns.filter((p) => typeof p === "string" && p.trim() !== "" && hay.includes(p.trim().toLowerCase()));
|
|
3076
|
+
}
|
|
3077
|
+
/**
|
|
3078
|
+
* auto 模式审批闸:命中敏感模式时经 confirmTool 弹审批等待用户决策,
|
|
3079
|
+
* 拒绝/超时抛错(调用方该步失败),允许则返回继续执行原工具。
|
|
3080
|
+
* 不满足前置条件(非 auto / 工具不在 sensitiveTools / 无模式 / 无活动页 /
|
|
3081
|
+
* 无目标 selector / 页内取描述失败)一律直接放行,由原工具自行处理错误。
|
|
3082
|
+
* page 参数仅供测试注入;缺省取当前活动页。
|
|
3083
|
+
*/
|
|
3084
|
+
async function confirmGate(config, tool, args, page) {
|
|
3085
|
+
if (config.confirmMode !== "auto") return;
|
|
3086
|
+
if (tool === "browser_confirm") return;
|
|
3087
|
+
if (!(config.sensitiveTools ?? DEFAULT_SENSITIVE_TOOLS).includes(tool)) return;
|
|
3088
|
+
const patterns = config.sensitivePatterns ?? DEFAULT_SENSITIVE_PATTERNS;
|
|
3089
|
+
if (patterns.length === 0) return;
|
|
3090
|
+
const active = page !== void 0 ? page : activePage();
|
|
3091
|
+
if (active === null || active === void 0) return;
|
|
3092
|
+
const sel = typeof args?.selector === "string" && args.selector ? args.selector : typeof args?.ref === "string" && args.ref ? pageRef(active, args.ref)?.selector ?? "" : "";
|
|
3093
|
+
if (sel === "") return;
|
|
3094
|
+
let desc = null;
|
|
3095
|
+
try {
|
|
3096
|
+
desc = await active.evaluate(describeTarget, sel);
|
|
3097
|
+
} catch {
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
3100
|
+
const hits = sensitiveHits(desc, patterns);
|
|
3101
|
+
if (hits.length === 0) return;
|
|
3102
|
+
const label = desc !== null && (desc.name || desc.text) ? `(${desc.role !== "" ? `${desc.role} ` : ""}"${desc.name || desc.text}")` : "";
|
|
3103
|
+
await confirmTool(config, {
|
|
3104
|
+
reason: `confirmMode:auto 硬拦截:${tool} 目标元素命中敏感模式 [${hits.join(", ")}] ${label}`.trim(),
|
|
3105
|
+
tool,
|
|
3106
|
+
args
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3031
3109
|
/** 断言:条件在 timeoutMs 内成立则通过,否则失败并自动截图存证据。visual 断言走像素比对。 */
|
|
3032
3110
|
async function assertTool(config, args, exec) {
|
|
3033
3111
|
const page = await getPage(config);
|
|
@@ -3283,37 +3361,29 @@ const SELECTOR_STEP_TOOLS = /* @__PURE__ */ new Set([
|
|
|
3283
3361
|
"browser_select",
|
|
3284
3362
|
"browser_upload"
|
|
3285
3363
|
]);
|
|
3364
|
+
/** 拾取指纹的页内提取(walker 统一元素描述结构;构建期拼装注入)。 */
|
|
3365
|
+
const pickElementInjector = injectWalker((el, w) => {
|
|
3366
|
+
const d = w.describeElement(el);
|
|
3367
|
+
return {
|
|
3368
|
+
testid: d.testid !== "" ? d.testid : void 0,
|
|
3369
|
+
id: d.id !== "" ? d.id : void 0,
|
|
3370
|
+
tag: d.tag,
|
|
3371
|
+
role: d.role !== "" ? d.role : void 0,
|
|
3372
|
+
name: d.name !== "" ? d.name : void 0,
|
|
3373
|
+
text: d.text !== "" ? d.text : void 0
|
|
3374
|
+
};
|
|
3375
|
+
});
|
|
3286
3376
|
/**
|
|
3287
3377
|
* 采集选择器指向元素的指纹(页内 evaluate;找不到元素返回 null)。
|
|
3288
3378
|
* 指纹是回放自愈的降级候选来源:testid / role+name / text / id / tag+text。
|
|
3379
|
+
* F5 收敛:元素描述(role/name/text)改用 walker 单一来源
|
|
3380
|
+
* (describeElement),与 aria 快照/find/录制回声共用同一套语义;相比旧
|
|
3381
|
+
* 内联副本,role 推断更完整(slider/spinbutton/searchbox/heading 等),
|
|
3382
|
+
* name 补齐 labelledby/label[for]/title 兜底 —— 指纹只作自愈降级候选,
|
|
3383
|
+
* 语义增强只会让自愈命中率更高,不改变任何主链路行为。
|
|
3289
3384
|
*/
|
|
3290
3385
|
async function captureSelectorHint(page, selector) {
|
|
3291
|
-
return page.locator(selector).first().evaluate((
|
|
3292
|
-
const tag = el.tagName.toLowerCase();
|
|
3293
|
-
const type = (el.getAttribute("type") ?? "").toLowerCase();
|
|
3294
|
-
const roleAttr = (el.getAttribute("role") ?? "").trim().split(/\s+/)[0] ?? "";
|
|
3295
|
-
const implicit = tag === "a" && el.hasAttribute("href") ? "link" : tag === "button" || tag === "summary" || tag === "input" && [
|
|
3296
|
-
"button",
|
|
3297
|
-
"submit",
|
|
3298
|
-
"reset",
|
|
3299
|
-
"image"
|
|
3300
|
-
].includes(type) ? "button" : tag === "textarea" || tag === "input" && ![
|
|
3301
|
-
"hidden",
|
|
3302
|
-
"checkbox",
|
|
3303
|
-
"radio",
|
|
3304
|
-
"file"
|
|
3305
|
-
].includes(type) ? "textbox" : tag === "select" ? "combobox" : tag === "input" && type === "checkbox" ? "checkbox" : tag === "input" && type === "radio" ? "radio" : "";
|
|
3306
|
-
const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60);
|
|
3307
|
-
const name = (el.getAttribute("aria-label") ?? "").trim() || (el.getAttribute("placeholder") ?? "").trim() || text;
|
|
3308
|
-
return {
|
|
3309
|
-
testid: el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? void 0,
|
|
3310
|
-
id: el.id !== "" ? el.id : void 0,
|
|
3311
|
-
tag,
|
|
3312
|
-
role: (roleAttr || implicit) !== "" ? roleAttr || implicit : void 0,
|
|
3313
|
-
name: name !== "" ? name : void 0,
|
|
3314
|
-
text: text !== "" ? text : void 0
|
|
3315
|
-
};
|
|
3316
|
-
}).catch(() => null);
|
|
3386
|
+
return page.locator(selector).first().evaluate(pickElementInjector).catch(() => null);
|
|
3317
3387
|
}
|
|
3318
3388
|
/** 由指纹生成自愈降级候选(按优先级排序;导出供测试)。 */
|
|
3319
3389
|
function healCandidates(hint) {
|
|
@@ -3436,6 +3506,7 @@ async function runReplay(steps, impls, failFast, opts = {}) {
|
|
|
3436
3506
|
let stoppedAt = null;
|
|
3437
3507
|
let passed = 0;
|
|
3438
3508
|
let healedCount = 0;
|
|
3509
|
+
let retryAttempted = 0;
|
|
3439
3510
|
for (let i = 0; i < steps.length; i++) {
|
|
3440
3511
|
const step = steps[i];
|
|
3441
3512
|
const impl = impls[step.tool];
|
|
@@ -3453,81 +3524,107 @@ async function runReplay(steps, impls, failFast, opts = {}) {
|
|
|
3453
3524
|
continue;
|
|
3454
3525
|
}
|
|
3455
3526
|
const hint = step.meta?.hint;
|
|
3456
|
-
|
|
3457
|
-
|
|
3527
|
+
const canHeal = opts.healing === true && hint !== void 0 && SELECTOR_STEP_TOOLS.has(step.tool) && typeof step.args?.selector === "string";
|
|
3528
|
+
/** 单次完整尝试(含自愈链);返回该次尝试的 result 行(带内部 healed 标记)。 */
|
|
3529
|
+
const attempt = async () => {
|
|
3530
|
+
if (!canHeal) try {
|
|
3458
3531
|
await impl(step.args ?? {});
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3532
|
+
return {
|
|
3533
|
+
ok: true,
|
|
3534
|
+
result: {
|
|
3535
|
+
index: i,
|
|
3536
|
+
tool: step.tool,
|
|
3537
|
+
ok: true
|
|
3538
|
+
}
|
|
3539
|
+
};
|
|
3465
3540
|
} catch (e) {
|
|
3466
|
-
|
|
3467
|
-
index: i,
|
|
3468
|
-
tool: step.tool,
|
|
3541
|
+
return {
|
|
3469
3542
|
ok: false,
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
continue;
|
|
3478
|
-
}
|
|
3479
|
-
const healTimeout = opts.healTimeoutMs ?? 3e3;
|
|
3480
|
-
const quickArgs = {
|
|
3481
|
-
...step.args,
|
|
3482
|
-
timeoutMs: Math.min(Number(step.args.timeoutMs ?? Number.POSITIVE_INFINITY) || Number.POSITIVE_INFINITY, healTimeout)
|
|
3483
|
-
};
|
|
3484
|
-
try {
|
|
3485
|
-
await impl(quickArgs);
|
|
3486
|
-
passed++;
|
|
3487
|
-
results.push({
|
|
3488
|
-
index: i,
|
|
3489
|
-
tool: step.tool,
|
|
3490
|
-
ok: true
|
|
3491
|
-
});
|
|
3492
|
-
continue;
|
|
3493
|
-
} catch (e0) {
|
|
3494
|
-
const tried = [];
|
|
3495
|
-
let healed = null;
|
|
3496
|
-
for (const candidate of healCandidates(hint)) {
|
|
3497
|
-
tried.push(`${candidate.via}: ${candidate.selector}`);
|
|
3498
|
-
try {
|
|
3499
|
-
await impl({
|
|
3500
|
-
...step.args,
|
|
3501
|
-
selector: candidate.selector,
|
|
3502
|
-
timeoutMs: healTimeout
|
|
3503
|
-
});
|
|
3504
|
-
healed = candidate;
|
|
3505
|
-
break;
|
|
3506
|
-
} catch {}
|
|
3543
|
+
result: {
|
|
3544
|
+
index: i,
|
|
3545
|
+
tool: step.tool,
|
|
3546
|
+
ok: false,
|
|
3547
|
+
error: e instanceof Error ? e.message : String(e)
|
|
3548
|
+
}
|
|
3549
|
+
};
|
|
3507
3550
|
}
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3551
|
+
const healTimeout = opts.healTimeoutMs ?? 3e3;
|
|
3552
|
+
const quickArgs = {
|
|
3553
|
+
...step.args,
|
|
3554
|
+
timeoutMs: Math.min(Number(step.args.timeoutMs ?? Number.POSITIVE_INFINITY) || Number.POSITIVE_INFINITY, healTimeout)
|
|
3555
|
+
};
|
|
3556
|
+
try {
|
|
3557
|
+
await impl(quickArgs);
|
|
3558
|
+
return {
|
|
3514
3559
|
ok: true,
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3560
|
+
result: {
|
|
3561
|
+
index: i,
|
|
3562
|
+
tool: step.tool,
|
|
3563
|
+
ok: true
|
|
3564
|
+
}
|
|
3565
|
+
};
|
|
3566
|
+
} catch (e0) {
|
|
3567
|
+
const tried = [];
|
|
3568
|
+
let healed = null;
|
|
3569
|
+
for (const candidate of healCandidates(hint)) {
|
|
3570
|
+
tried.push(`${candidate.via}: ${candidate.selector}`);
|
|
3571
|
+
try {
|
|
3572
|
+
await impl({
|
|
3573
|
+
...step.args,
|
|
3574
|
+
selector: candidate.selector,
|
|
3575
|
+
timeoutMs: healTimeout
|
|
3576
|
+
});
|
|
3577
|
+
healed = candidate;
|
|
3578
|
+
break;
|
|
3579
|
+
} catch {}
|
|
3580
|
+
}
|
|
3581
|
+
if (healed !== null) return {
|
|
3582
|
+
ok: true,
|
|
3583
|
+
result: {
|
|
3584
|
+
index: i,
|
|
3585
|
+
tool: step.tool,
|
|
3586
|
+
ok: true,
|
|
3587
|
+
healed: true,
|
|
3588
|
+
via: healed.via,
|
|
3589
|
+
resolved: healed.selector
|
|
3590
|
+
}
|
|
3591
|
+
};
|
|
3520
3592
|
const base = e0 instanceof Error ? e0.message : String(e0);
|
|
3521
|
-
|
|
3522
|
-
index: i,
|
|
3523
|
-
tool: step.tool,
|
|
3593
|
+
return {
|
|
3524
3594
|
ok: false,
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3595
|
+
result: {
|
|
3596
|
+
index: i,
|
|
3597
|
+
tool: step.tool,
|
|
3598
|
+
ok: false,
|
|
3599
|
+
error: `${base}${tried.length > 0 ? `; 自愈已尝试: ${tried.join(" | ")}` : ""}`
|
|
3600
|
+
}
|
|
3601
|
+
};
|
|
3602
|
+
}
|
|
3603
|
+
};
|
|
3604
|
+
let outcome = await attempt();
|
|
3605
|
+
let recovered = false;
|
|
3606
|
+
if (!outcome.ok && (opts.retryFailed ?? 0) > 0) for (let n = 1; n <= (opts.retryFailed ?? 0); n++) {
|
|
3607
|
+
await sleep(500 * n);
|
|
3608
|
+
retryAttempted++;
|
|
3609
|
+
const again = await attempt();
|
|
3610
|
+
outcome = again;
|
|
3611
|
+
if (again.ok) {
|
|
3612
|
+
recovered = true;
|
|
3613
|
+
break;
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
if (outcome.ok) {
|
|
3617
|
+
passed++;
|
|
3618
|
+
if (outcome.result.healed === true) healedCount++;
|
|
3619
|
+
results.push(recovered ? {
|
|
3620
|
+
...outcome.result,
|
|
3621
|
+
recovered: true
|
|
3622
|
+
} : outcome.result);
|
|
3623
|
+
} else {
|
|
3624
|
+
results.push(outcome.result);
|
|
3625
|
+
if (failFast) {
|
|
3626
|
+
stoppedAt = i;
|
|
3627
|
+
break;
|
|
3531
3628
|
}
|
|
3532
3629
|
}
|
|
3533
3630
|
}
|
|
@@ -3536,6 +3633,10 @@ async function runReplay(steps, impls, failFast, opts = {}) {
|
|
|
3536
3633
|
passed,
|
|
3537
3634
|
failed: results.length - passed,
|
|
3538
3635
|
healedCount,
|
|
3636
|
+
...retryAttempted > 0 ? { retries: {
|
|
3637
|
+
attempted: retryAttempted,
|
|
3638
|
+
recovered: results.filter((r) => r.recovered === true).length
|
|
3639
|
+
} } : {},
|
|
3539
3640
|
...stoppedAt !== null ? { stoppedAt } : {},
|
|
3540
3641
|
results
|
|
3541
3642
|
};
|
|
@@ -3587,14 +3688,176 @@ function workspaceFilePath(cwd, file) {
|
|
|
3587
3688
|
if (p !== root && !p.startsWith(root + "/") && !p.startsWith(root + "\\")) throw new Error(`文件路径越出工作区: ${file}`);
|
|
3588
3689
|
return p;
|
|
3589
3690
|
}
|
|
3691
|
+
/** suggest 的页内结构探测(injectWalker 注入;walker 提供 cssPath)。 */
|
|
3692
|
+
const suggestFieldsInPage = injectWalker((payload, w) => {
|
|
3693
|
+
const root = payload.scope !== "" ? document.querySelector(payload.scope) : document.body;
|
|
3694
|
+
if (root === null) return {
|
|
3695
|
+
ok: false,
|
|
3696
|
+
reason: `scope 未命中: 页面中没有匹配 "${payload.scope}" 的元素`
|
|
3697
|
+
};
|
|
3698
|
+
const cellOf = (el) => {
|
|
3699
|
+
const a = el.querySelector("a[href]");
|
|
3700
|
+
const img = el.querySelector("img[src]");
|
|
3701
|
+
return {
|
|
3702
|
+
text: (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60),
|
|
3703
|
+
...a !== null ? { href: a.getAttribute("href") ?? "" } : {},
|
|
3704
|
+
...img !== null ? { src: img.getAttribute("src") ?? "" } : {}
|
|
3705
|
+
};
|
|
3706
|
+
};
|
|
3707
|
+
const rows = Array.from(root.querySelectorAll("table tr"));
|
|
3708
|
+
if (rows.length >= 2) {
|
|
3709
|
+
const first = rows[0];
|
|
3710
|
+
let headerTexts = [];
|
|
3711
|
+
let record = null;
|
|
3712
|
+
if (first.querySelector("th") !== null) {
|
|
3713
|
+
headerTexts = Array.from(first.querySelectorAll("th, td")).map((c) => (c.innerText || c.textContent || "").trim());
|
|
3714
|
+
record = rows[1] ?? null;
|
|
3715
|
+
} else record = first;
|
|
3716
|
+
if (record !== null) {
|
|
3717
|
+
const cells = Array.from(record.querySelectorAll("td, th")).map(cellOf);
|
|
3718
|
+
if (cells.length > 0) return {
|
|
3719
|
+
ok: true,
|
|
3720
|
+
kind: "table",
|
|
3721
|
+
rowSelector: "table tr",
|
|
3722
|
+
headerTexts,
|
|
3723
|
+
cells
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
const list = root.querySelector("ul, ol");
|
|
3728
|
+
if (list !== null) {
|
|
3729
|
+
const lis = Array.from(list.children).filter((c) => c.tagName === "LI");
|
|
3730
|
+
if (lis.length >= 2) {
|
|
3731
|
+
const li = lis[0];
|
|
3732
|
+
return {
|
|
3733
|
+
ok: true,
|
|
3734
|
+
kind: "list",
|
|
3735
|
+
rowSelector: `${w.cssPath(list)} > li`,
|
|
3736
|
+
item: cellOf(li)
|
|
3737
|
+
};
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
return {
|
|
3741
|
+
ok: false,
|
|
3742
|
+
reason: "未发现规律结构(无 ≥2 行的表格,也无 ≥2 项的列表);请用 browser_elements/browser_snapshot 查看结构后手写 fields",
|
|
3743
|
+
overview: {
|
|
3744
|
+
links: root.querySelectorAll("a").length,
|
|
3745
|
+
images: root.querySelectorAll("img").length,
|
|
3746
|
+
buttons: root.querySelectorAll("button, [role='button']").length,
|
|
3747
|
+
headings: root.querySelectorAll("h1, h2, h3, h4").length,
|
|
3748
|
+
rows: rows.length,
|
|
3749
|
+
textChars: (root.innerText || "").length
|
|
3750
|
+
}
|
|
3751
|
+
};
|
|
3752
|
+
});
|
|
3753
|
+
/** 字段名净化:空白→下划线,限长 30,空回落 col_N。导出供测试。 */
|
|
3754
|
+
function suggestFieldName(header, index) {
|
|
3755
|
+
const h = (header ?? "").trim().replace(/\s+/g, "_");
|
|
3756
|
+
return h !== "" ? h.slice(0, 30) : `col_${index + 1}`;
|
|
3757
|
+
}
|
|
3758
|
+
/**
|
|
3759
|
+
* 纯逻辑:由表头与首行单元格特征推断表格 fields(单元格文本/链接 href/图片 src)。
|
|
3760
|
+
* 返回可直接放进 rows.fields 的嵌套字段数组。导出供测试。
|
|
3761
|
+
*/
|
|
3762
|
+
function fieldsFromCells(headerTexts, cells) {
|
|
3763
|
+
const out = [];
|
|
3764
|
+
cells.forEach((c, i) => {
|
|
3765
|
+
const name = suggestFieldName(headerTexts[i], i);
|
|
3766
|
+
const sel = `td:nth-of-type(${i + 1})`;
|
|
3767
|
+
if (c.src !== void 0) out.push({
|
|
3768
|
+
name: `${name}_src`,
|
|
3769
|
+
selector: `${sel} img`,
|
|
3770
|
+
attr: "src"
|
|
3771
|
+
});
|
|
3772
|
+
if (c.href !== void 0) out.push({
|
|
3773
|
+
name: `${name}_href`,
|
|
3774
|
+
selector: `${sel} a`,
|
|
3775
|
+
attr: "href"
|
|
3776
|
+
});
|
|
3777
|
+
out.push({
|
|
3778
|
+
name,
|
|
3779
|
+
selector: sel
|
|
3780
|
+
});
|
|
3781
|
+
});
|
|
3782
|
+
return out;
|
|
3783
|
+
}
|
|
3784
|
+
/**
|
|
3785
|
+
* 纯逻辑:由首条卡片信息推断列表 fields(项文本/链接/图片)。
|
|
3786
|
+
* 文本取第一个子元素(项自身无法用 querySelector 自选)。导出供测试。
|
|
3787
|
+
*/
|
|
3788
|
+
function fieldsFromCard(item) {
|
|
3789
|
+
const out = [];
|
|
3790
|
+
if (item.src !== void 0) out.push({
|
|
3791
|
+
name: "image",
|
|
3792
|
+
selector: "img",
|
|
3793
|
+
attr: "src"
|
|
3794
|
+
});
|
|
3795
|
+
if (item.href !== void 0) out.push({
|
|
3796
|
+
name: "link",
|
|
3797
|
+
selector: "a",
|
|
3798
|
+
attr: "href"
|
|
3799
|
+
});
|
|
3800
|
+
out.push({
|
|
3801
|
+
name: "text",
|
|
3802
|
+
selector: ":scope > *:nth-child(1)"
|
|
3803
|
+
});
|
|
3804
|
+
return out;
|
|
3805
|
+
}
|
|
3806
|
+
/** 纯逻辑:由 fields 与单元格样本拼出首行样例(与抽取输出同构;按 td 序号对位)。导出供测试。 */
|
|
3807
|
+
function sampleOfFields(fields, cells) {
|
|
3808
|
+
const out = {};
|
|
3809
|
+
for (const f of fields) {
|
|
3810
|
+
const m = /td:nth-of-type\((\d+)\)/.exec(f.selector);
|
|
3811
|
+
const c = cells[m !== null ? Number(m[1]) - 1 : 0];
|
|
3812
|
+
if (f.attr === "href") out[f.name] = c?.href ?? null;
|
|
3813
|
+
else if (f.attr === "src") out[f.name] = c?.src ?? null;
|
|
3814
|
+
else out[f.name] = c?.text ?? null;
|
|
3815
|
+
}
|
|
3816
|
+
return out;
|
|
3817
|
+
}
|
|
3590
3818
|
/**
|
|
3591
|
-
*
|
|
3592
|
-
*
|
|
3819
|
+
* suggest 汇总(纯逻辑):由页内分析结果组装「可直接交给抽取模式」的顶层 fields
|
|
3820
|
+
* (单个 multiple rows 字段 + 行内嵌套字段)与样本。推断失败原样返回。导出供测试。
|
|
3593
3821
|
*/
|
|
3822
|
+
function suggestWrap(analysis) {
|
|
3823
|
+
if (!analysis.ok || analysis.kind === void 0 || analysis.rowSelector === void 0) return {
|
|
3824
|
+
suggested: false,
|
|
3825
|
+
reason: analysis.reason ?? "结构不足以推断字段",
|
|
3826
|
+
...analysis.overview !== void 0 ? { overview: analysis.overview } : {}
|
|
3827
|
+
};
|
|
3828
|
+
const rowFields = analysis.kind === "table" ? fieldsFromCells(analysis.headerTexts ?? [], analysis.cells ?? []) : fieldsFromCard(analysis.item ?? { text: "" });
|
|
3829
|
+
if (rowFields.length === 0) return {
|
|
3830
|
+
suggested: false,
|
|
3831
|
+
reason: "首条记录内未发现可抽取内容(文本/链接/图片)",
|
|
3832
|
+
...analysis.overview !== void 0 ? { overview: analysis.overview } : {}
|
|
3833
|
+
};
|
|
3834
|
+
const sample = sampleOfFields(rowFields, analysis.kind === "table" ? analysis.cells ?? [] : [analysis.item ?? { text: "" }]);
|
|
3835
|
+
return {
|
|
3836
|
+
suggested: true,
|
|
3837
|
+
kind: analysis.kind,
|
|
3838
|
+
rowSelector: analysis.rowSelector,
|
|
3839
|
+
...analysis.headerTexts !== void 0 ? { headerTexts: analysis.headerTexts } : {},
|
|
3840
|
+
fields: [{
|
|
3841
|
+
name: "rows",
|
|
3842
|
+
selector: analysis.rowSelector,
|
|
3843
|
+
multiple: true,
|
|
3844
|
+
fields: rowFields
|
|
3845
|
+
}],
|
|
3846
|
+
sample
|
|
3847
|
+
};
|
|
3848
|
+
}
|
|
3594
3849
|
async function extractTool(config, args, exec) {
|
|
3595
3850
|
const page = await getPage(config);
|
|
3851
|
+
if (args.action === "suggest") {
|
|
3852
|
+
const scope = typeof args.scope === "string" && args.scope !== "" ? args.scope : "";
|
|
3853
|
+
return {
|
|
3854
|
+
action: "suggest",
|
|
3855
|
+
...suggestWrap(await page.evaluate(suggestFieldsInPage, { scope })),
|
|
3856
|
+
...await pageIdentity(page)
|
|
3857
|
+
};
|
|
3858
|
+
}
|
|
3596
3859
|
const fields = args.fields;
|
|
3597
|
-
if (!Array.isArray(fields) || fields.length === 0) throw new Error("extract 需要 fields:[{ name, selector, attr?, multiple?, fields? }]");
|
|
3860
|
+
if (!Array.isArray(fields) || fields.length === 0) throw new Error("extract 需要 fields:[{ name, selector, attr?, multiple?, fields? }] 或 action: \"suggest\" 推断字段");
|
|
3598
3861
|
for (const f of fields) if (typeof f?.name !== "string" || f.name === "" || typeof f?.selector !== "string" || f.selector === "") throw new Error("fields 每项需要非空 name 与 selector");
|
|
3599
3862
|
const maxPages = Math.min(Math.max(Number(args.maxPages ?? 1) || 1, 1), 100);
|
|
3600
3863
|
const maxRows = Math.min(Math.max(Number(args.maxRows ?? config.maxRows ?? 1e3) || 1e3, 1), 1e5);
|
|
@@ -3795,17 +4058,29 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3795
4058
|
let steps = [];
|
|
3796
4059
|
let source = "steps";
|
|
3797
4060
|
if (typeof args.name === "string" && args.name) {
|
|
4061
|
+
const reloaded = reloadRecordingIfChanged(args.name);
|
|
3798
4062
|
const saved = savedRecordings.get(args.name);
|
|
3799
4063
|
if (saved === void 0) throw new Error(`未找到录制: ${args.name}`);
|
|
3800
4064
|
steps = saved.steps;
|
|
3801
4065
|
source = args.name;
|
|
4066
|
+
if (reloaded) source = `${args.name}(已从磁盘重载)`;
|
|
3802
4067
|
} else if (Array.isArray(args.steps)) steps = args.steps;
|
|
3803
4068
|
else throw new Error("replay 需要 name(已保存录制)或 steps(步骤数组)");
|
|
3804
4069
|
if (steps.length === 0) throw new Error("步骤为空");
|
|
3805
4070
|
const failFast = args.failFast !== false;
|
|
4071
|
+
const retryFailed = Math.min(Math.max(Math.floor(Number(args.retryFailed ?? 0)) || 0, 0), 10);
|
|
4072
|
+
const retryOf = typeof args.retryOf === "string" && args.retryOf !== "" ? args.retryOf : void 0;
|
|
4073
|
+
const runMeta = { ...retryFailed > 0 || retryOf !== void 0 ? {
|
|
4074
|
+
...retryFailed > 0 ? { retries: {
|
|
4075
|
+
attempted: 0,
|
|
4076
|
+
recovered: 0
|
|
4077
|
+
} } : {},
|
|
4078
|
+
...retryOf !== void 0 ? { retryOf } : {}
|
|
4079
|
+
} : {} };
|
|
3806
4080
|
const opts = {
|
|
3807
4081
|
healing: config?.selectorHealing !== false,
|
|
3808
|
-
healTimeoutMs: config?.healTimeoutMs ?? 3e3
|
|
4082
|
+
healTimeoutMs: config?.healTimeoutMs ?? 3e3,
|
|
4083
|
+
...retryFailed > 0 ? { retryFailed } : {}
|
|
3809
4084
|
};
|
|
3810
4085
|
let rows = null;
|
|
3811
4086
|
if (Array.isArray(args.data)) rows = args.data;
|
|
@@ -3845,7 +4120,8 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3845
4120
|
healedCount: 0,
|
|
3846
4121
|
durationMs: Date.now() - t0,
|
|
3847
4122
|
row: i,
|
|
3848
|
-
results: []
|
|
4123
|
+
results: [],
|
|
4124
|
+
...runMeta
|
|
3849
4125
|
});
|
|
3850
4126
|
continue;
|
|
3851
4127
|
}
|
|
@@ -3865,7 +4141,9 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3865
4141
|
healedCount: result.healedCount,
|
|
3866
4142
|
durationMs: Date.now() - t0,
|
|
3867
4143
|
row: i,
|
|
3868
|
-
results: result.results
|
|
4144
|
+
results: result.results,
|
|
4145
|
+
...result.retries ?? runMeta.retries,
|
|
4146
|
+
...runMeta.retryOf !== void 0 ? { retryOf: runMeta.retryOf } : {}
|
|
3869
4147
|
});
|
|
3870
4148
|
}
|
|
3871
4149
|
return {
|
|
@@ -3883,6 +4161,7 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3883
4161
|
const t0 = Date.now();
|
|
3884
4162
|
const result = await runReplay(substituteVars(steps, vars), impls, failFast, opts);
|
|
3885
4163
|
const stoppedAt = result.stoppedAt;
|
|
4164
|
+
const retries = result.retries;
|
|
3886
4165
|
noteRun({
|
|
3887
4166
|
kind: "replay",
|
|
3888
4167
|
source,
|
|
@@ -3891,14 +4170,42 @@ async function replayTool(args, impls, config, exec) {
|
|
|
3891
4170
|
failed: result.failed,
|
|
3892
4171
|
healedCount: result.healedCount,
|
|
3893
4172
|
durationMs: Date.now() - t0,
|
|
3894
|
-
results: result.results
|
|
4173
|
+
results: result.results,
|
|
4174
|
+
...retries !== void 0 ? { retries } : {},
|
|
4175
|
+
...retryOf !== void 0 ? { retryOf } : {}
|
|
3895
4176
|
});
|
|
3896
4177
|
return {
|
|
3897
4178
|
source,
|
|
3898
4179
|
...result,
|
|
3899
|
-
summary: `${result.passed}/${result.total} 通过${result.failed > 0 ? `, ${result.failed} 失败${stoppedAt !== void 0 ? `(停在步骤 ${stoppedAt})` : ""}` : ""}${result.healedCount > 0 ? `,${result.healedCount} 步自愈(建议修复录制选择器)` : ""}`
|
|
4180
|
+
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}` : ""}`
|
|
3900
4181
|
};
|
|
3901
4182
|
}
|
|
4183
|
+
let rerunHandler = null;
|
|
4184
|
+
let rerunInFlight = false;
|
|
4185
|
+
/** registerTools 注册重跑入口(stepImpls 同路径);web-only 进程保持 null。 */
|
|
4186
|
+
function setRerunHandler(fn) {
|
|
4187
|
+
rerunHandler = fn;
|
|
4188
|
+
}
|
|
4189
|
+
/** 当前可用的重跑入口(null = 本进程没有 tools 服务,无法重跑)。 */
|
|
4190
|
+
function getRerunHandler() {
|
|
4191
|
+
return rerunHandler;
|
|
4192
|
+
}
|
|
4193
|
+
/** 重跑是否正在执行(端点互斥;导出供测试)。 */
|
|
4194
|
+
function rerunBusy() {
|
|
4195
|
+
return rerunInFlight;
|
|
4196
|
+
}
|
|
4197
|
+
/** 执行一次面板重跑(busy 检查 + 互斥标记;返回 replayTool 结果)。 */
|
|
4198
|
+
async function runPanelRerun(req) {
|
|
4199
|
+
if (rerunInFlight) throw Object.assign(/* @__PURE__ */ new Error("重跑进行中"), { status: 409 });
|
|
4200
|
+
rerunInFlight = true;
|
|
4201
|
+
try {
|
|
4202
|
+
const handler = rerunHandler;
|
|
4203
|
+
if (handler === null) throw Object.assign(/* @__PURE__ */ new Error("本进程无回放能力(tools 服务未注册)"), { status: 503 });
|
|
4204
|
+
return await handler(req);
|
|
4205
|
+
} finally {
|
|
4206
|
+
rerunInFlight = false;
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
3902
4209
|
//#endregion
|
|
3903
4210
|
//#region lib/types/browser/register.js
|
|
3904
4211
|
function registerTools(ctx, config) {
|
|
@@ -3929,6 +4236,15 @@ function registerTools(ctx, config) {
|
|
|
3929
4236
|
"browser_press"
|
|
3930
4237
|
]);
|
|
3931
4238
|
/**
|
|
4239
|
+
* auto 审批闸(confirmMode:"auto" 硬约束):live 调用在 register 包装层拦,
|
|
4240
|
+
* replay/batch 在 stepImpls 外再包一层(默认同样拦截;confirmInReplay: false
|
|
4241
|
+
* 可关,README 注明风险)。未命中模式时闸内只花一次页内 evaluate。
|
|
4242
|
+
*/
|
|
4243
|
+
const replayGate = (tool, fn) => async (a) => {
|
|
4244
|
+
if (config.confirmMode === "auto" && config.confirmInReplay !== false) await confirmGate(config, tool, a);
|
|
4245
|
+
return fn(a);
|
|
4246
|
+
};
|
|
4247
|
+
/**
|
|
3932
4248
|
* toolset: "core" 的高频核心集:浏览/定位/操作/等待/断言/人机协作。
|
|
3933
4249
|
* 低频的录制/回放/报告/抽取/表单回放/profile/context 等只在 "all" 注册。
|
|
3934
4250
|
*/
|
|
@@ -3964,8 +4280,10 @@ function registerTools(ctx, config) {
|
|
|
3964
4280
|
const original = def.execute;
|
|
3965
4281
|
def.execute = async (args, exec) => {
|
|
3966
4282
|
const t0 = Date.now();
|
|
4283
|
+
noteToolStart();
|
|
3967
4284
|
const domTool = AGENT_DOM_TOOLS.has(def.name);
|
|
3968
4285
|
if (domTool) noteAgentAction();
|
|
4286
|
+
if (config.confirmMode === "auto") await confirmGate(config, def.name, args);
|
|
3969
4287
|
if (PREVIEW_TOOLS.has(def.name)) {
|
|
3970
4288
|
const page = activePage();
|
|
3971
4289
|
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_", ""));
|
|
@@ -3998,6 +4316,8 @@ function registerTools(ctx, config) {
|
|
|
3998
4316
|
error: error instanceof Error ? error.message : String(error)
|
|
3999
4317
|
});
|
|
4000
4318
|
throw error;
|
|
4319
|
+
} finally {
|
|
4320
|
+
noteToolEnd();
|
|
4001
4321
|
}
|
|
4002
4322
|
};
|
|
4003
4323
|
tools.register(def);
|
|
@@ -4040,6 +4360,14 @@ function registerTools(ctx, config) {
|
|
|
4040
4360
|
browser_batch: (a) => batchTool(a, stepImpls, config),
|
|
4041
4361
|
browser_extract: (a) => extractTool(config, a)
|
|
4042
4362
|
};
|
|
4363
|
+
for (const name of Object.keys(stepImpls)) {
|
|
4364
|
+
const fn = stepImpls[name];
|
|
4365
|
+
stepImpls[name] = replayGate(name, fn);
|
|
4366
|
+
}
|
|
4367
|
+
setRerunHandler((req) => replayTool({
|
|
4368
|
+
name: req.source,
|
|
4369
|
+
...req.retryOf !== void 0 ? { retryOf: req.retryOf } : {}
|
|
4370
|
+
}, stepImpls, config));
|
|
4043
4371
|
register(definePlainTool({
|
|
4044
4372
|
name: "browser_open",
|
|
4045
4373
|
description: "在共享浏览器中打开一个 URL。之后可用 browser_snapshot 读取页面内容,用 browser_click / browser_type / browser_eval 操作页面,用 browser_screenshot 截图。",
|
|
@@ -4088,7 +4416,7 @@ function registerTools(ctx, config) {
|
|
|
4088
4416
|
}));
|
|
4089
4417
|
register(definePlainTool({
|
|
4090
4418
|
name: "browser_snapshot",
|
|
4091
|
-
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
|
|
4419
|
+
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。",
|
|
4092
4420
|
parameters: {
|
|
4093
4421
|
type: "object",
|
|
4094
4422
|
properties: {
|
|
@@ -4103,7 +4431,7 @@ function registerTools(ctx, config) {
|
|
|
4103
4431
|
},
|
|
4104
4432
|
since: {
|
|
4105
4433
|
type: "integer",
|
|
4106
|
-
description: "
|
|
4434
|
+
description: "可选:之前任一次快照的 contentVersion(历史窗口内任意版本),只返回相对该版本的变化内容。"
|
|
4107
4435
|
},
|
|
4108
4436
|
maxTextChars: {
|
|
4109
4437
|
type: "integer",
|
|
@@ -4901,7 +5229,7 @@ function registerTools(ctx, config) {
|
|
|
4901
5229
|
]
|
|
4902
5230
|
},
|
|
4903
5231
|
render: (_args, value) => value.loggedIn ? `登录完成: ${value.url}` : `等待登录超时,仍在: ${value.url}`,
|
|
4904
|
-
timeoutMs: timeout(),
|
|
5232
|
+
timeoutMs: timeout(6e5),
|
|
4905
5233
|
execute: (args) => waitForLoginTool(config, args)
|
|
4906
5234
|
}));
|
|
4907
5235
|
register(definePlainTool({
|
|
@@ -5507,7 +5835,7 @@ function registerTools(ctx, config) {
|
|
|
5507
5835
|
}));
|
|
5508
5836
|
register(definePlainTool({
|
|
5509
5837
|
name: "browser_replay",
|
|
5510
|
-
description: "回放录制或步骤序列(回归测试):name 指定已保存录制(browser_record save),或直接传 steps 数组(每步 {tool, args})。failFast 默认 true(遇错停止);返回每步结果与汇总。选择器自愈:录制时保存了元素指纹的步骤,原 selector 失败时自动按 testid→role+name→text→id 降级重试并在结果中标 healed(建议事后修复录制选择器)。参数化:录制含 {{占位符}} 时用 vars 注入;data 传对象数组或 dataFile 传 CSV 路径(首行为变量名)
|
|
5838
|
+
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 可导出报告)。",
|
|
5511
5839
|
parameters: {
|
|
5512
5840
|
type: "object",
|
|
5513
5841
|
properties: {
|
|
@@ -5536,6 +5864,10 @@ function registerTools(ctx, config) {
|
|
|
5536
5864
|
dataFile: {
|
|
5537
5865
|
type: "string",
|
|
5538
5866
|
description: "数据集 CSV 文件(工作区相对路径,首行为变量名)。"
|
|
5867
|
+
},
|
|
5868
|
+
retryFailed: {
|
|
5869
|
+
type: "integer",
|
|
5870
|
+
description: "失败步骤自动重试次数(0-10,默认 0;500ms×n 线性退避,重试耗尽才停)。偶发超时/网络抖动类失败的兜底。"
|
|
5539
5871
|
}
|
|
5540
5872
|
},
|
|
5541
5873
|
additionalProperties: false
|
|
@@ -5549,6 +5881,10 @@ function registerTools(ctx, config) {
|
|
|
5549
5881
|
passed: { type: "integer" },
|
|
5550
5882
|
failed: { type: "integer" },
|
|
5551
5883
|
healedCount: { type: "integer" },
|
|
5884
|
+
retries: {
|
|
5885
|
+
type: "object",
|
|
5886
|
+
description: "自动重试统计 { attempted, recovered }(仅 retryFailed>0 时返回)。"
|
|
5887
|
+
},
|
|
5552
5888
|
stoppedAt: { type: "integer" },
|
|
5553
5889
|
results: {
|
|
5554
5890
|
type: "array",
|
|
@@ -5574,9 +5910,9 @@ function registerTools(ctx, config) {
|
|
|
5574
5910
|
]
|
|
5575
5911
|
},
|
|
5576
5912
|
render: (_args, value) => {
|
|
5577
|
-
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")}`;
|
|
5913
|
+
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")}`;
|
|
5578
5914
|
},
|
|
5579
|
-
timeoutMs: timeout(),
|
|
5915
|
+
timeoutMs: timeout(6e5),
|
|
5580
5916
|
execute: (args, exec) => replayTool(args, stepImpls, config, exec)
|
|
5581
5917
|
}));
|
|
5582
5918
|
register(definePlainTool({
|
|
@@ -5623,19 +5959,28 @@ function registerTools(ctx, config) {
|
|
|
5623
5959
|
]
|
|
5624
5960
|
},
|
|
5625
5961
|
render: (_args, value) => `${value.summary}\n${(value.results ?? []).map((r) => `${r.ok ? "✓" : "✗"} #${r.index} ${r.tool}${r.error ? ` — ${String(r.error).slice(0, 120)}` : ""}`).join("\n")}`,
|
|
5626
|
-
timeoutMs: timeout(),
|
|
5962
|
+
timeoutMs: timeout(6e5),
|
|
5627
5963
|
execute: (args) => batchTool(args, stepImpls)
|
|
5628
5964
|
}));
|
|
5629
5965
|
register(definePlainTool({
|
|
5630
5966
|
name: "browser_extract",
|
|
5631
|
-
description: "规则化结构化抽取(网站信息获取):fields 定义抽取结构 [{name, selector, attr?, multiple?, fields?}](attr 缺省取可见文本,可指定 href/src 等属性;multiple=true 收集所有命中;fields 嵌套可在行内抽子字段);next 传翻页选择器自动翻页聚合(maxPages 上限,next 消失/禁用即停);format 支持 json/csv/jsonl/markdown,file 落盘到工作区(自动按扩展名推断格式,拒绝路径穿越)
|
|
5967
|
+
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。",
|
|
5632
5968
|
parameters: {
|
|
5633
5969
|
type: "object",
|
|
5634
5970
|
properties: {
|
|
5971
|
+
action: {
|
|
5972
|
+
type: "string",
|
|
5973
|
+
enum: ["suggest"],
|
|
5974
|
+
description: "suggest:自动推断字段结构,返回建议 fields + 样本(此模式不传 fields)。"
|
|
5975
|
+
},
|
|
5976
|
+
scope: {
|
|
5977
|
+
type: "string",
|
|
5978
|
+
description: "suggest 模式的容器选择器(缺省整页;表格/列表探测在此范围内进行)。"
|
|
5979
|
+
},
|
|
5635
5980
|
fields: {
|
|
5636
5981
|
type: "array",
|
|
5637
5982
|
items: { type: "object" },
|
|
5638
|
-
description: "抽取字段定义 [{name, selector, attr?, multiple?, fields?}]。"
|
|
5983
|
+
description: "抽取字段定义 [{name, selector, attr?, multiple?, fields?}](action:suggest 时不传)。"
|
|
5639
5984
|
},
|
|
5640
5985
|
next: {
|
|
5641
5986
|
type: "string",
|
|
@@ -5668,13 +6013,28 @@ function registerTools(ctx, config) {
|
|
|
5668
6013
|
description: "落盘路径(工作区相对或绝对,限工作区内)。"
|
|
5669
6014
|
}
|
|
5670
6015
|
},
|
|
5671
|
-
required: [
|
|
6016
|
+
required: [],
|
|
5672
6017
|
additionalProperties: false
|
|
5673
6018
|
},
|
|
5674
6019
|
outputSchema: {
|
|
5675
6020
|
type: "object",
|
|
5676
6021
|
additionalProperties: false,
|
|
5677
6022
|
properties: {
|
|
6023
|
+
action: { type: "string" },
|
|
6024
|
+
suggested: { type: "boolean" },
|
|
6025
|
+
kind: { type: "string" },
|
|
6026
|
+
rowSelector: { type: "string" },
|
|
6027
|
+
headerTexts: {
|
|
6028
|
+
type: "array",
|
|
6029
|
+
items: { type: "string" }
|
|
6030
|
+
},
|
|
6031
|
+
fields: {
|
|
6032
|
+
type: "array",
|
|
6033
|
+
items: { type: "object" }
|
|
6034
|
+
},
|
|
6035
|
+
sample: { type: "object" },
|
|
6036
|
+
reason: { type: "string" },
|
|
6037
|
+
overview: { type: "object" },
|
|
5678
6038
|
rows: {
|
|
5679
6039
|
type: "array",
|
|
5680
6040
|
items: { type: "object" }
|
|
@@ -5688,17 +6048,9 @@ function registerTools(ctx, config) {
|
|
|
5688
6048
|
url: { type: "string" },
|
|
5689
6049
|
title: { type: "string" }
|
|
5690
6050
|
},
|
|
5691
|
-
required: [
|
|
5692
|
-
"rows",
|
|
5693
|
-
"count",
|
|
5694
|
-
"pages",
|
|
5695
|
-
"format",
|
|
5696
|
-
"truncated",
|
|
5697
|
-
"url",
|
|
5698
|
-
"title"
|
|
5699
|
-
]
|
|
6051
|
+
required: ["url", "title"]
|
|
5700
6052
|
},
|
|
5701
|
-
render: (_args, value) => `抽取 ${value.count} 行 / ${value.pages} 页${value.truncated ? "(已截断)" : ""}${value.file ? ` → ${value.file} (${value.bytes} bytes)` : ""}`,
|
|
6053
|
+
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)` : ""}`,
|
|
5702
6054
|
timeoutMs: timeout(),
|
|
5703
6055
|
execute: (args, exec) => extractTool(config, args, exec)
|
|
5704
6056
|
}));
|
|
@@ -5811,7 +6163,42 @@ function registerBrowserApi(ctx, config, base) {
|
|
|
5811
6163
|
return;
|
|
5812
6164
|
}
|
|
5813
6165
|
if (path === `${base}/runs/latest`) {
|
|
5814
|
-
sendJson(res, 200, {
|
|
6166
|
+
sendJson(res, 200, {
|
|
6167
|
+
run: latestRun(),
|
|
6168
|
+
busy: activeToolCalls() > 0 || rerunBusy()
|
|
6169
|
+
});
|
|
6170
|
+
return;
|
|
6171
|
+
}
|
|
6172
|
+
if (path === `${base}/runs/rerun`) {
|
|
6173
|
+
if ((req.method ?? "GET") !== "POST") {
|
|
6174
|
+
sendText(res, 405, "POST only");
|
|
6175
|
+
return;
|
|
6176
|
+
}
|
|
6177
|
+
if (activeToolCalls() > 0 || rerunBusy()) {
|
|
6178
|
+
sendJson(res, 409, { error: "agent 忙碌(有工具调用或回放在进行),稍后重试" });
|
|
6179
|
+
return;
|
|
6180
|
+
}
|
|
6181
|
+
if (getRerunHandler() === null) {
|
|
6182
|
+
sendJson(res, 503, { error: "本进程无回放能力(tools 服务未注册)" });
|
|
6183
|
+
return;
|
|
6184
|
+
}
|
|
6185
|
+
const body = await readJsonBody(req).catch(() => void 0);
|
|
6186
|
+
const source = typeof body?.source === "string" ? body.source.trim() : "";
|
|
6187
|
+
if (!source) {
|
|
6188
|
+
sendJson(res, 400, "source required");
|
|
6189
|
+
return;
|
|
6190
|
+
}
|
|
6191
|
+
try {
|
|
6192
|
+
sendJson(res, 200, {
|
|
6193
|
+
ok: true,
|
|
6194
|
+
result: await runPanelRerun({
|
|
6195
|
+
source,
|
|
6196
|
+
...typeof body?.retryOf === "string" && body.retryOf !== "" ? { retryOf: body.retryOf } : {}
|
|
6197
|
+
})
|
|
6198
|
+
});
|
|
6199
|
+
} catch (e) {
|
|
6200
|
+
sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
6201
|
+
}
|
|
5815
6202
|
return;
|
|
5816
6203
|
}
|
|
5817
6204
|
if (path === `${base}/runs/report`) {
|
|
@@ -6211,14 +6598,26 @@ const SCHEMA = {
|
|
|
6211
6598
|
type: "number",
|
|
6212
6599
|
min: 1
|
|
6213
6600
|
},
|
|
6601
|
+
snapshotHistory: {
|
|
6602
|
+
type: "number",
|
|
6603
|
+
min: 1,
|
|
6604
|
+
max: 20
|
|
6605
|
+
},
|
|
6214
6606
|
confirmMode: {
|
|
6215
6607
|
type: "string",
|
|
6216
|
-
enum: [
|
|
6608
|
+
enum: [
|
|
6609
|
+
"off",
|
|
6610
|
+
"manual",
|
|
6611
|
+
"auto"
|
|
6612
|
+
]
|
|
6217
6613
|
},
|
|
6218
6614
|
confirmTimeoutMs: {
|
|
6219
6615
|
type: "number",
|
|
6220
6616
|
min: 1e3
|
|
6221
6617
|
},
|
|
6618
|
+
sensitivePatterns: { type: "string[]" },
|
|
6619
|
+
sensitiveTools: { type: "string[]" },
|
|
6620
|
+
confirmInReplay: { type: "boolean" },
|
|
6222
6621
|
toolset: {
|
|
6223
6622
|
type: "string",
|
|
6224
6623
|
enum: ["core", "all"]
|
|
@@ -6245,8 +6644,16 @@ const DEFAULT_CONFIG = {
|
|
|
6245
6644
|
healTimeoutMs: 3e3,
|
|
6246
6645
|
maxRefs: 200,
|
|
6247
6646
|
maxRows: 1e3,
|
|
6647
|
+
snapshotHistory: 5,
|
|
6248
6648
|
confirmMode: "off",
|
|
6249
6649
|
confirmTimeoutMs: 6e4,
|
|
6650
|
+
sensitivePatterns: [
|
|
6651
|
+
"提交",
|
|
6652
|
+
"删除",
|
|
6653
|
+
"支付"
|
|
6654
|
+
],
|
|
6655
|
+
sensitiveTools: ["browser_click"],
|
|
6656
|
+
confirmInReplay: true,
|
|
6250
6657
|
toolset: "all"
|
|
6251
6658
|
};
|
|
6252
6659
|
function apply(ctx, rawConfig) {
|
|
@@ -6258,7 +6665,7 @@ function apply(ctx, rawConfig) {
|
|
|
6258
6665
|
if (loadedRecordings > 0) ctx.logger?.info?.(`dsh-browser-view: loaded ${loadedRecordings} saved recording(s)`);
|
|
6259
6666
|
registerTools(ctx, config);
|
|
6260
6667
|
const systemPrompt = ctx.get("systemPrompt");
|
|
6261
|
-
const confirmGuidance = config.confirmMode === "manual" ? "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。" : "";
|
|
6668
|
+
const confirmGuidance = config.confirmMode === "manual" ? "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。" : config.confirmMode === "auto" ? "本环境 confirmMode 为 auto:宿主层已在敏感动作(命中敏感模式的目标元素)前强制弹审批卡,agent 无法绕过;审批被拒绝或超时的动作不可换路径重试,应向用户说明并等待指示。" : "";
|
|
6262
6669
|
const sectionDispose = systemPrompt?.section?.({
|
|
6263
6670
|
name: "tool:browser",
|
|
6264
6671
|
order: 200,
|