@glittering0717/ai-browser 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -20
- package/bin/ai-browser.cjs +1 -1
- package/examples/AGENT_INSTRUCTIONS.md +22 -2
- package/llms-install.md +14 -4
- package/package.json +9 -5
- package/scripts/build_dmg.cjs +220 -0
- package/scripts/marketing/data_snapshot.sh +69 -0
- package/scripts/render_icon.cjs +118 -0
- package/src/main/axExtractor.js +103 -36
- package/src/main/error_log.js +245 -0
- package/src/main/index.js +487 -16
- package/src/main/mcp_server.js +39 -1
- package/src/main/mcp_tools.js +64 -10
- package/src/main/network_monitor.js +86 -23
- package/src/main/page_manager.js +1040 -115
- package/src/main/ws_server.js +127 -16
- package/src/preload/actions.cjs +61 -2
- package/src/preload/bridge.cjs +31 -1
- package/src/preload/extractor.cjs +520 -25
- package/src/renderer/error_banner.html +90 -0
- package/src/renderer/error_page.html +125 -0
- package/src/renderer/tab_bar.html +116 -56
- package/src/shared/act_params.cjs +53 -0
- package/src/shared/auth_wall.cjs +162 -0
- package/src/shared/config.js +8 -2
- package/src/shared/deadline.js +6 -0
- package/src/shared/deep_active.cjs +31 -0
- package/src/shared/error_banner.cjs +43 -0
- package/src/shared/find_ref.cjs +44 -0
- package/src/shared/frame_probe.cjs +112 -0
- package/src/shared/hit_point.cjs +80 -0
- package/src/shared/input_verify.js +47 -0
- package/src/shared/key_dispatch.cjs +37 -0
- package/src/shared/nav_wait.cjs +47 -0
- package/src/shared/protocol.js +15 -0
- package/src/shared/ref_guard.cjs +133 -0
- package/src/shared/screenshot_opts.cjs +130 -0
- package/src/shared/tree_diff.cjs +105 -0
- package/src/shared/version.js +1 -1
- package/tools/coverage_probe.cjs +239 -0
package/src/shared/config.js
CHANGED
|
@@ -12,12 +12,18 @@ const DEFAULT_UA =
|
|
|
12
12
|
export const config = {
|
|
13
13
|
// WebSocket JSON-RPC server + MCP auto-launch probe port
|
|
14
14
|
wsPort: Number(process.env.AI_BROWSER_PORT) || 9223,
|
|
15
|
+
// Loopback-only by default: the JSON-RPC port has NO authentication, so
|
|
16
|
+
// binding 0.0.0.0 would let every host on the local network drive the
|
|
17
|
+
// browser and its logged-in sessions. Set AI_BROWSER_WS_HOST=0.0.0.0 only
|
|
18
|
+
// behind a firewall you trust, and know that the protocol itself is open.
|
|
19
|
+
wsBindHost: process.env.AI_BROWSER_WS_HOST || '127.0.0.1',
|
|
15
20
|
// Electron userData dir — session/cookie persistence survives restart
|
|
16
21
|
userDataDir: process.env.AI_BROWSER_USER_DATA || path.join(os.homedir(), '.ai-browser'),
|
|
17
22
|
// Spoofed UA to mask the automation fingerprint
|
|
18
23
|
userAgent: process.env.AI_BROWSER_UA || DEFAULT_UA,
|
|
19
|
-
// Height of the in-app tab strip below the window title bar
|
|
20
|
-
|
|
24
|
+
// Height of the in-app tab strip below the window title bar.
|
|
25
|
+
// 36px = tab row, 38px = toolbar row (address bar + back/forward/reload/stop).
|
|
26
|
+
tabBarHeight: 36 + 38,
|
|
21
27
|
// The session every tab's BrowserView uses. **Must stay a single source of
|
|
22
28
|
// truth**: session-level hooks (will-download, permission requests) are
|
|
23
29
|
// installed on `session.fromPartition(viewPartition)`. Hooking
|
package/src/shared/deadline.js
CHANGED
|
@@ -78,6 +78,12 @@ export function actionDeadlineMs(action, params = {}) {
|
|
|
78
78
|
} else if (action === 'clear') {
|
|
79
79
|
// clear 要走编辑器状态提交与回读校验(多处 80~150ms 等待)。
|
|
80
80
|
need = Math.max(need, 20000);
|
|
81
|
+
} else if (action === 'paste') {
|
|
82
|
+
// paste 本身不逐字符输入,但大型 HTML 经编辑器管线转换(Draft.js 的
|
|
83
|
+
// DraftPasteProcessor / ProseMirror 的 transformPastedHTML)可能很慢,
|
|
84
|
+
// 之后还有 350ms 等待 + 回读校验。按 payload 大小给余量。
|
|
85
|
+
const payload = Math.max(String(p.html || '').length, String(p.text || '').length);
|
|
86
|
+
need = Math.max(need, 20000 + payload / 4);
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
const env = Number(process.env.AI_BROWSER_ACTION_TIMEOUT_MS);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// src/shared/deep_active.cjs —— 「真正的焦点元素」表达式。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要它:`document.activeElement` 在 Web Component 里只到**宿主**为止。
|
|
5
|
+
// 焦点实际在 `<bili-rich-text>` 的 shadow root 里那个 contenteditable 上,但
|
|
6
|
+
// `document.activeElement` 返回的是 `bili-rich-text` 宿主本身,宿主上没有内容
|
|
7
|
+
// (innerText 为空),于是所有"读回输入内容再和目标比对"的校验全部读到空串。
|
|
8
|
+
//
|
|
9
|
+
// 后果不是"慢"或"看不清",而是**假阴性**:内容明明打进去了,回执却报
|
|
10
|
+
// `Input not verified / actual_chars: 0`。调用方(尤其 agent)看到这个结论会
|
|
11
|
+
// 再打一次,结果是**重复输入**——比直接报错更糟,因为它看起来像"上次没成功"。
|
|
12
|
+
// 实测(2026-10-17,B站 opus 详情页评论框):type 返回失败、actual_chars=0,
|
|
13
|
+
// 而回读 value 是完整的一句话。
|
|
14
|
+
//
|
|
15
|
+
// 修法:沿 `shadowRoot.activeElement` 一路下钻,取最里层的那个。
|
|
16
|
+
// 这条链是浏览器自己维护的焦点路径,不是猜 —— 每一层都是**该树内**的 activeElement。
|
|
17
|
+
//
|
|
18
|
+
// 返回值是一个**页面内可执行的 JS 表达式字符串**(不是函数),因为这些调用点
|
|
19
|
+
// 全部是拼进 `executeJavaScript` 的一行脚本里,统一用表达式才不用改它们的形状。
|
|
20
|
+
function deepActiveExpr() {
|
|
21
|
+
return '(function(){' +
|
|
22
|
+
'var e=null;try{e=document.activeElement}catch(x){return null}' +
|
|
23
|
+
// 防御死循环:shadow 嵌套深度有限,但页面可以自己构造循环引用。
|
|
24
|
+
'var g=0;while(e&&e.shadowRoot&&e.shadowRoot.activeElement&&g++<10){e=e.shadowRoot.activeElement}' +
|
|
25
|
+
'return e})()';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 同源 iframe 里的焦点:顶层 document.activeElement 是 <iframe> 元素本身,
|
|
29
|
+
// 这条链进不去(跨进程更进不去)。调用方要区分"在帧里"时自己判 tagName 即可,
|
|
30
|
+
// 这里不多导出一个口径 —— 两处判断同一件事迟早会不一致。
|
|
31
|
+
module.exports = { deepActiveExpr };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// shared/error_banner.cjs — 主进程异常横幅的文案装配(纯函数,可在 vitest 里直接测)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一个文件:横幅内容有两条硬要求,都值得被钉住 ——
|
|
4
|
+
// ① **一行**。横幅只有 34px 高,异常消息里带换行(多行栈、带 \n 的 Error.message)
|
|
5
|
+
// 会把文字挤没,用户看到一片红却读不到原因。
|
|
6
|
+
// ② **必须带日志路径**。横幅是提醒,不是全部信息:真正的栈在 error.jsonl 里。
|
|
7
|
+
// 只写"出错了"等于把人重新推回"点掉之后无从查证"的老问题。
|
|
8
|
+
//
|
|
9
|
+
// 这些逻辑放在 main/index.js 里就没法测了(那个文件 import electron)。
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const MAX_DETAIL = 220;
|
|
14
|
+
const MAX_LOG_PATH = 120;
|
|
15
|
+
|
|
16
|
+
// 压成一行 + 截断。用省略号而不是硬切,读的人才知道后面还有内容。
|
|
17
|
+
function oneLine(input, max) {
|
|
18
|
+
const s = String(input === undefined || input === null ? '' : input)
|
|
19
|
+
.replace(/\s+/g, ' ')
|
|
20
|
+
.trim();
|
|
21
|
+
if (!s) return '';
|
|
22
|
+
if (s.length <= max) return s;
|
|
23
|
+
return s.slice(0, Math.max(0, max - 1)) + '…';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{detail?: string, logPath?: string, at?: string, count?: number}} input
|
|
28
|
+
* @returns {{title: string, detail: string, log_path: string, at: string, count: number}}
|
|
29
|
+
*/
|
|
30
|
+
function buildBannerPayload(input) {
|
|
31
|
+
const src = input || {};
|
|
32
|
+
return {
|
|
33
|
+
title: 'AI Browser 主进程异常',
|
|
34
|
+
detail: oneLine(src.detail, MAX_DETAIL) || '(没有消息)',
|
|
35
|
+
// 路径也压一行:userData 可能被 AI_BROWSER_USER_DATA 指到很深的目录
|
|
36
|
+
log_path: oneLine(src.logPath, MAX_LOG_PATH),
|
|
37
|
+
at: src.at || new Date().toISOString(),
|
|
38
|
+
// 连续报错时横幅不叠加,只把次数写进角标 —— 一屏红块比一行红更烦人
|
|
39
|
+
count: Number(src.count) > 0 ? Math.floor(Number(src.count)) : 1,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { buildBannerPayload, MAX_DETAIL };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// shared/find_ref.cjs — 按 data-ai-id 找元素,**穿透影子根**。
|
|
3
|
+
//
|
|
4
|
+
// 为什么不能只用 document.querySelector:shadow boundary 是硬边界,顶层查询进不去。
|
|
5
|
+
// Web Component 里的元素(B站 opus 的 <bili-comments>、YouTube 的 <ytd-*>、
|
|
6
|
+
// 各设计系统的 <ui-*>)即使抽取层已经写了 data-ai-id,顶层也查不到 —— 报出来只是
|
|
7
|
+
// "no element with data-ai-id=...",读起来像"元素不存在",实际是"查询路径够不着"。
|
|
8
|
+
// 实测 2026-10-17:B站帖子详情页的评论输入框整个在 bili-comments 的 shadow root 里,
|
|
9
|
+
// 抽取层补了下探(extractor.cjs 的 childrenOf)之后,解析这一侧也必须跟着穿透,
|
|
10
|
+
// 否则就是"树里有、点不动"。
|
|
11
|
+
//
|
|
12
|
+
// 两半各自有存在的理由:
|
|
13
|
+
// findRef —— 运行时函数,给能在页面里直接跑的代码用(preload/actions.cjs 自己
|
|
14
|
+
// 那份等价实现就是它,故意不跨界 require,避免一处路径写错连带
|
|
15
|
+
// 整个 preload 起不来)。
|
|
16
|
+
// findRefExpr —— 字符串表达式,给主进程"拼注入脚本"的地方用。返回自包含表达式,
|
|
17
|
+
// 所以可以原地替换 `document.querySelector(...)`,不需要在每个
|
|
18
|
+
// IIFE 里补前置声明(这样的地方有十几处,漏一处就是一处静默失效)。
|
|
19
|
+
|
|
20
|
+
function findRefExpr(idJsExpr) {
|
|
21
|
+
return "(function(id){var f=function(r){var e=null;"
|
|
22
|
+
+ "try{e=r.querySelector('[data-ai-id=\"'+id+'\"]')}catch(x){e=null}"
|
|
23
|
+
+ "if(e)return e;"
|
|
24
|
+
+ "var a;try{a=r.querySelectorAll('*')}catch(x){return null}"
|
|
25
|
+
+ "for(var i=0;i<a.length;i++){var s=a[i].shadowRoot;if(!s)continue;var h=f(s);if(h)return h}"
|
|
26
|
+
+ "return null};return f(document)})(" + idJsExpr + ")";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 在**某个子树内**穿透影子根找一个后代(按选择器)。
|
|
30
|
+
//
|
|
31
|
+
// 为什么还需要这个:找到编辑器外壳(例如 B站 的 <bili-rich-text> 宿主)之后,
|
|
32
|
+
// 真正的 contenteditable 往往在它自己的 shadow root 里 —— `el.querySelector(
|
|
33
|
+
// '[contenteditable=true]')` 同样不跨边界,于是"清空校验"读到空、把已经清干净
|
|
34
|
+
// 的编辑器判成"没清掉",接着又删一遍。读回口径必须和定位口径一样深。
|
|
35
|
+
function findDescExpr(rootJsExpr, selectorJsExpr) {
|
|
36
|
+
return "(function(root,sel){var f=function(r){var e=null;"
|
|
37
|
+
+ "try{e=r.querySelector(sel)}catch(x){e=null}"
|
|
38
|
+
+ "if(e)return e;"
|
|
39
|
+
+ "var a;try{a=r.querySelectorAll('*')}catch(x){return null}"
|
|
40
|
+
+ "for(var i=0;i<a.length;i++){var s=a[i].shadowRoot;if(!s)continue;var h=f(s);if(h)return h}"
|
|
41
|
+
+ "return null};return f(root)})(" + rootJsExpr + "," + selectorJsExpr + ")";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { findRefExpr, findDescExpr };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/shared/frame_probe.cjs — 跨进程子帧(OOPIF)探测的纯函数部分
|
|
2
|
+
//
|
|
3
|
+
// 背景(B站图文编辑器隐形两小时的事故):站点把富文本编辑器放进跨进程
|
|
4
|
+
// iframe。同源 DOM 通路(preload extractor 的 contentDocument 递归)对
|
|
5
|
+
// OOPIF 恒返回 null,于是那部分内容在默认语义树里**完全不存在** —— 不是
|
|
6
|
+
// "读出来是空的",是连占位都没有。能救场的只有 AX 路径(getFullAXTree
|
|
7
|
+
// 走 CDP,天生跨帧),但调用方无从知道该切过去。
|
|
8
|
+
//
|
|
9
|
+
// 本模块只放纯函数:探测本身在 extractor 里(要 DOM),判定与摘要放这里
|
|
10
|
+
// (要可单测,且 page_manager 的自动路由要用同一把尺)。
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 汇总 extractor 收集到的不可读子帧。
|
|
16
|
+
* @param {Array<{id:string,src:string,width:number,height:number}>} frames
|
|
17
|
+
* @returns {{count:number, visible_count:number, suggestion:string|null}}
|
|
18
|
+
*/
|
|
19
|
+
function summarizeOpaqueFrames(frames) {
|
|
20
|
+
const list = Array.isArray(frames) ? frames.filter((f) => f && typeof f === 'object') : [];
|
|
21
|
+
const visible = list.filter((f) => (f.width || 0) > 0 && (f.height || 0) > 0);
|
|
22
|
+
if (!list.length) {
|
|
23
|
+
return { count: 0, visible_count: 0, suggestion: null };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
count: list.length,
|
|
27
|
+
visible_count: visible.length,
|
|
28
|
+
// 建议文案里必须出现 ax:true —— 那是唯一的补救通路,且此前无任何地方
|
|
29
|
+
// 告诉调用方它存在。
|
|
30
|
+
suggestion: 'page has cross-process iframe(s) whose content is invisible to the default DOM read path — retry with get_tree {ax:true} (CDP Accessibility reads across frames)',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 是否值得为这批帧自动补一次 AX 读取。
|
|
36
|
+
* 判定故意保守:只有"确有可见的不可读帧"才自动路由。不可见的帧(width/height
|
|
37
|
+
* 为 0,通常是埋点/广告帧)不值得让每次 get_tree 都多付一次 CDP 往返。
|
|
38
|
+
*/
|
|
39
|
+
function shouldAutoAx(summary) {
|
|
40
|
+
return !!(summary && typeof summary.visible_count === 'number' && summary.visible_count > 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 自动 AX 读取的结果如何写进 context.frame_hints。
|
|
45
|
+
* 三种结局分开说:合并成功 / 合并没有新内容 / AX 本身失败。调用方需要能
|
|
46
|
+
* 区分"已经尽力了"和"还有一条没走过的路"。
|
|
47
|
+
*/
|
|
48
|
+
function describeAutoAx(outcome) {
|
|
49
|
+
const o = outcome && typeof outcome === 'object' ? outcome : {};
|
|
50
|
+
if (o.merged) {
|
|
51
|
+
return { auto_ax: 'merged', frames_read: Number(o.framesRead) || 0 };
|
|
52
|
+
}
|
|
53
|
+
if (o.error) {
|
|
54
|
+
return { auto_ax: 'failed', reason: String(o.error) };
|
|
55
|
+
}
|
|
56
|
+
return { auto_ax: 'no-new-content' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 把子帧读到的"该填什么 / 错在哪"合进顶层 context。
|
|
61
|
+
*
|
|
62
|
+
* 为什么需要:extractPageContext 只在顶层文档里扫描,而真正要填的字段常常在
|
|
63
|
+
* 跨进程子帧里(B站图文的标题框、掘金编辑器)。于是 required_hints 和
|
|
64
|
+
* validation_errors 会在**最需要它们的那一类页面上**恰好为空 —— 这比没有这个
|
|
65
|
+
* 字段更糟,因为调用方会读成"这页没有必填项"。
|
|
66
|
+
*
|
|
67
|
+
* 纯函数:不读 DOM、不改入参。子帧来源的条目带 frame 字段,调用方能看出它
|
|
68
|
+
* 不是顶层文档说的。
|
|
69
|
+
*/
|
|
70
|
+
function mergeFrameContext(ctx, frames) {
|
|
71
|
+
const base = ctx && typeof ctx === 'object' ? ctx : {};
|
|
72
|
+
const hints = Array.isArray(base.required_hints) ? base.required_hints.slice() : [];
|
|
73
|
+
const ves = Array.isArray(base.validation_errors) ? base.validation_errors.slice() : [];
|
|
74
|
+
const seenHint = new Set(hints);
|
|
75
|
+
const seenVE = new Set(ves.map((e) => (e && e.near ? e.near : '') + '\u0000' + ((e && e.text) || '')));
|
|
76
|
+
let added = 0;
|
|
77
|
+
for (const f of frames || []) {
|
|
78
|
+
const fc = f && f.context;
|
|
79
|
+
if (!fc) continue;
|
|
80
|
+
const where = f.key || f.label || 'subframe';
|
|
81
|
+
for (const h of fc.required_hints || []) {
|
|
82
|
+
const t = String(h || '').trim();
|
|
83
|
+
if (!t || seenHint.has(t)) continue;
|
|
84
|
+
seenHint.add(t);
|
|
85
|
+
hints.push(t);
|
|
86
|
+
added += 1;
|
|
87
|
+
}
|
|
88
|
+
for (const e of fc.validation_errors || []) {
|
|
89
|
+
const text = String((e && e.text) || '').trim();
|
|
90
|
+
if (!text) continue;
|
|
91
|
+
const near = String((e && e.near) || '');
|
|
92
|
+
const key = near + '\u0000' + text;
|
|
93
|
+
if (seenVE.has(key)) continue;
|
|
94
|
+
seenVE.add(key);
|
|
95
|
+
ves.push({ text, near, frame: where });
|
|
96
|
+
added += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!added) return null;
|
|
100
|
+
const out = {};
|
|
101
|
+
if (hints.length) out.required_hints = hints.slice(0, 40);
|
|
102
|
+
if (ves.length) out.validation_errors = ves.slice(0, 30);
|
|
103
|
+
out.frame_context_merged = added;
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
summarizeOpaqueFrames,
|
|
109
|
+
shouldAutoAx,
|
|
110
|
+
describeAutoAx,
|
|
111
|
+
mergeFrameContext,
|
|
112
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// findRefExpr 是这里的地基:定位本身也得能穿透 shadow,否则连 el 都拿不到,
|
|
3
|
+
// 命中判定再对也没用("树里有、点不动")。
|
|
4
|
+
const { findRefExpr } = require('./find_ref.cjs');
|
|
5
|
+
|
|
6
|
+
// shared/hit_point.cjs — "指针落在这个坐标上,到底会命中谁",**穿透影子根**。
|
|
7
|
+
//
|
|
8
|
+
// 为什么不能只用 document.elementFromPoint:它和 querySelector 一样,被 shadow
|
|
9
|
+
// boundary 挡住 —— 对影子内部的元素,它返回的是**宿主**(离目标最近的那个
|
|
10
|
+
// 自定义元素),不是元素本身。
|
|
11
|
+
//
|
|
12
|
+
// 实测证据(2026-10-18,B站 视频页评论区,整块装在 <bili-comments> 的 shadow 里):
|
|
13
|
+
// 树里报的 bounds => {x:733,y:377,w:24,h:24} ← 坐标本身是准的
|
|
14
|
+
// document.elementFromPoint(745,389) => bili-comments ← 宿主
|
|
15
|
+
// containsHit => false
|
|
16
|
+
// 于是"点击前先证明坐标会落在目标上"这道校验对影子内部元素**恒为假**,
|
|
17
|
+
// 点击只能回退到 preload 的 el.click()(非受信合成事件)。这在 CKEditor /
|
|
18
|
+
// ProseMirror / Draft.js 之类只认真实指针序列的编辑器上会直接失效,
|
|
19
|
+
// 而且"回退"这个事实不会出现在返回里 —— 看着成功,机制已经降级了。
|
|
20
|
+
//
|
|
21
|
+
// 修法:命中后逐级下钻。`ShadowRoot` 实现了 `DocumentOrShadowRoot`,所以
|
|
22
|
+
// `shadowRoot.elementFromPoint(x,y)` 是标准 API(不是 hack),坐标与
|
|
23
|
+
// document 版本同为视口坐标。一直钻到没有 shadowRoot 或钻不动为止。
|
|
24
|
+
//
|
|
25
|
+
// 配套的第二处修正:判定"命中即目标"必须用**含 shadow 的包含关系**。
|
|
26
|
+
// `Node.contains()` 不是 shadow-including 的 —— 隔着一层 shadow 它照样返回
|
|
27
|
+
// false(影子树是独立的 node tree)。上溯要写 `parentNode || ShadowRoot.host`。
|
|
28
|
+
//
|
|
29
|
+
// 隔离实验(tools/_oneoff/shadow_hittest.html + shadow_hittest_probe.cjs):
|
|
30
|
+
// A 裸元素 flat=A-plain deep=A-plain 旧判定 true 新判定 true
|
|
31
|
+
// B 一层 shadow flat=DIV(宿主) deep=B-inner 旧判定 false 新判定 true
|
|
32
|
+
// C 两层 shadow flat=DIV(宿主) deep=C-deep 旧判定 false 新判定 true
|
|
33
|
+
// D shadow 内被盖 flat=DIV deep=DIV 旧判定 false 新判定 false ← 阴性仍守住
|
|
34
|
+
// 新判定是旧判定的严格超集:任何原本为真的情形仍为真(命中链是目标的祖先链),
|
|
35
|
+
// 只是把"隔着 shadow 其实命中了"这一整类假阴性补回来。
|
|
36
|
+
|
|
37
|
+
// 下钻层数上限:防御性。真实页面不会嵌套 32 层 shadow,但万一某页自己套娃,
|
|
38
|
+
// 宁可停下返回宿主的命中结果,也不要在页面里转不出来(这个 JS 是注入到页面
|
|
39
|
+
// 上下文里同步跑的,死循环等于页面卡死)。
|
|
40
|
+
const MAX_DEPTH = 32;
|
|
41
|
+
|
|
42
|
+
// 最深命中元素。传进来的 x/y 是**已求值**的 JS 表达式字符串(例如 "745" 或 "c.x")。
|
|
43
|
+
function deepHitExpr(xJs, yJs) {
|
|
44
|
+
return '(function(x,y){var h=null;try{h=document.elementFromPoint(x,y);}catch(e){h=null}' +
|
|
45
|
+
'var g=0;' +
|
|
46
|
+
'while(h&&h.shadowRoot&&g++<' + MAX_DEPTH + '){' +
|
|
47
|
+
'var n=null;try{n=h.shadowRoot.elementFromPoint(x,y);}catch(e){n=null}' +
|
|
48
|
+
'if(!n||n===h)break;h=n;}' +
|
|
49
|
+
'return h;})(' + xJs + ',' + yJs + ')';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 含 shadow 的"anc 是不是 n 的(含自身)祖先"。上溯时遇到 ShadowRoot 就跳到 host。
|
|
53
|
+
function shadowAncestorOfExpr(ancJs, nodeJs) {
|
|
54
|
+
return '(function(anc,n){var c=n,g=0;while(c&&g++<128){if(c===anc)return true;' +
|
|
55
|
+
'c=c.parentNode||c.host||null;}return false;})(' + ancJs + ',' + nodeJs + ')';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 坐标会不会落在 el 上。el 用 data-ai-id 定位(本身已穿透 shadow,见 find_ref.cjs)。
|
|
59
|
+
// 判定:最深命中元素 === el,或 el 是它的含 shadow 祖先。
|
|
60
|
+
// **不能**反过来接受 hit.contains(el):那等于"父元素当然包含子元素",会把
|
|
61
|
+
// "按在了外层容器上"这种失败情形原样放行(拖拽连接点那处踩过,见 page_manager)。
|
|
62
|
+
function hitTestExpr(idJs, xJs, yJs) {
|
|
63
|
+
return '(function(){var el=' + findRefExpr(idJs) + ';if(!el)return false;' +
|
|
64
|
+
'var h=' + deepHitExpr(xJs, yJs) + ';if(!h)return false;' +
|
|
65
|
+
'return ' + shadowAncestorOfExpr('el', 'h') + ';})()';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// "这个点上实际是哪个元素" —— 用于回执/错抓检测。
|
|
69
|
+
// 返回 {el:"TAG cls", id:最近一个带 data-ai-id 的含 shadow 祖先的 id} 或 null。
|
|
70
|
+
// 原来用 e.closest('[data-ai-id]'),同样跨不过 shadow:影子内部的元素 closest
|
|
71
|
+
// 走到影子树顶就停,永远返回 null,于是"抓错节点"的检测被静默跳过。
|
|
72
|
+
function hitInfoExpr(xJs, yJs) {
|
|
73
|
+
return '(function(){var e=' + deepHitExpr(xJs, yJs) + ';if(!e)return null;' +
|
|
74
|
+
'var c=e,id=null,g=0;' +
|
|
75
|
+
'while(c&&g++<128){if(c.getAttribute){var v=c.getAttribute("data-ai-id");if(v){id=v;break;}}' +
|
|
76
|
+
'c=c.parentNode||c.host||null;}' +
|
|
77
|
+
'return {el:(e.tagName||"")+" "+(typeof e.className==="string"?e.className:"").slice(0,60),id:id};})()';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { deepHitExpr, hitTestExpr, hitInfoExpr, shadowAncestorOfExpr, MAX_DEPTH };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// 输入校验用的文本归一化。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:`ui.act {action:'type'|'setContent'}` 走 CDP 受信键入,输完之后要
|
|
4
|
+
// 回读可编辑区内容,确认"字真的打出去了"。回读来的文本和输入的目标文本不会逐字节
|
|
5
|
+
// 相等 —— 富文本编辑器会按自己的排版规则重写内容(最常见的是把一个语义换行渲染成
|
|
6
|
+
// 一串 \n)。直接逐字比对会让**已经成功**的输入被判成失败,回一个
|
|
7
|
+
// `cdp-input-failed`,调用方据此重试或放弃。这属于谎报,是本项目最忌讳的一类问题。
|
|
8
|
+
//
|
|
9
|
+
// 用法:
|
|
10
|
+
// normalizeInputText(s) —— 校验主口径:忽略排版空白 + 压缩连续换行
|
|
11
|
+
// skeletalInputText(s) —— 兜底口径:连换行一起剥,只用于判断"内容有没有落上去"
|
|
12
|
+
//
|
|
13
|
+
// 设计取舍(重要):绝不能简单粗暴地把 \s 全剥掉。那样 "a\nb\nc" 与 "abc" 会等价,
|
|
14
|
+
// 段落结构整个没了还判成功 —— 等于把谎报换成另一种谎报。所以:
|
|
15
|
+
// - 排版空白(空格 / tab)压掉:编辑器加缩进用户看不出来,不算差异;
|
|
16
|
+
// - 换行**参与**比对:段落结构必须保住;
|
|
17
|
+
// - 但连续换行压成一个:分几层由编辑器决定,不该由调用方的文本决定。
|
|
18
|
+
|
|
19
|
+
/** 校验主口径:\r\n 归一 → 压排版空白 → 连续换行压成一个。 */
|
|
20
|
+
export function normalizeInputText(s) {
|
|
21
|
+
return String(s || '')
|
|
22
|
+
.replace(/\r\n?/g, '\n')
|
|
23
|
+
.replace(/[^\S\n]+/g, '')
|
|
24
|
+
.replace(/\n{2,}/g, '\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 兜底口径:在 normalizeInputText 之上再剥掉所有换行,得到纯内容指纹。 */
|
|
28
|
+
export function skeletalInputText(s) {
|
|
29
|
+
return normalizeInputText(s).replace(/\n+/g, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 判定一次输入是否达到了预期。
|
|
34
|
+
* @param {string} expected 调用方想写入的文本
|
|
35
|
+
* @param {string} actual 回读到的可编辑区内容
|
|
36
|
+
* @returns {{ok: boolean, landed: boolean}} ok=按主口径达标;landed=忽略全部空白后内容已到位(排版被重排过)
|
|
37
|
+
*/
|
|
38
|
+
export function verifyInputText(expected, actual) {
|
|
39
|
+
const want = normalizeInputText(expected);
|
|
40
|
+
const got = normalizeInputText(actual);
|
|
41
|
+
// clear(空目标):允许残留空块外壳 —— 浏览器永远会留一个空块承载光标。
|
|
42
|
+
if (!want) return { ok: skeletalInputText(actual) === '', landed: true };
|
|
43
|
+
const ok = got.includes(want);
|
|
44
|
+
// 忽略全部空白后再比一次:相等说明东西其实打进去了、只是被编辑器重新排了版。
|
|
45
|
+
const landed = ok || skeletalInputText(actual) === skeletalInputText(expected);
|
|
46
|
+
return { ok, landed };
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// src/shared/key_dispatch.cjs —— 「这一次按键该发什么 CDP 事件」的纯决策。
|
|
3
|
+
//
|
|
4
|
+
// 抽成纯函数只为一件事:这是"按了等于没按"那类缺陷的唯一入口,必须能单测。
|
|
5
|
+
// 藏在 PageManager 里就只能靠真起一个 Electron 才能验证。
|
|
6
|
+
//
|
|
7
|
+
// 核心事实:CDP 的 `rawKeyDown` **刻意不带 text**,浏览器只派发 keydown、
|
|
8
|
+
// 不产生 keypress。而 Enter 激活聚焦元素(<button> 的 click、<a> 的跳转、
|
|
9
|
+
// 表单的隐式提交)走的正是默认动作 —— 也就是 keypress。
|
|
10
|
+
// 实测(tools/_oneoff/probe_press_enter.cjs,file:// 页面):focus 一个 button
|
|
11
|
+
// 后 ui.act press Enter,页面收到 keydown(e.key==='Enter'、isTrusted=true),
|
|
12
|
+
// 但 onclick **一次都没触发**;同元素 click 正常。
|
|
13
|
+
// Puppeteer 的 keyboard.press('Enter') 有效,是因为它的键表给 Enter 配了
|
|
14
|
+
// text='\r',走的是**带 text 的 keyDown**。
|
|
15
|
+
//
|
|
16
|
+
// 两种语义必须分开,不能全局替换:
|
|
17
|
+
// activate=true → 带 text 的 keyDown(产生默认动作)
|
|
18
|
+
// activate 缺省 → rawKeyDown(+ 可选 char),type 的段间换行依赖它
|
|
19
|
+
// 理由见 page_manager._cdpKey 的注释:type 里"先发不带 text 的 Enter,看编辑器
|
|
20
|
+
// 有没有自己分段,没分才补 char"这个策略不能被改坏。
|
|
21
|
+
function keyDispatchPlan(p, opts) {
|
|
22
|
+
const spec = p || {};
|
|
23
|
+
const activating = !!(opts && opts.activate);
|
|
24
|
+
// 只有 Enter 需要"补"一个默认文本:它的默认动作在 keypress 上。
|
|
25
|
+
// 其它键(Tab/Delete/方向键)的默认动作在 keydown 上,rawKeyDown 已经够。
|
|
26
|
+
let text = '';
|
|
27
|
+
if (activating) {
|
|
28
|
+
if (spec.text === undefined) text = spec.key === 'Enter' ? '\r' : '';
|
|
29
|
+
else text = spec.text || '';
|
|
30
|
+
}
|
|
31
|
+
if (activating && text) {
|
|
32
|
+
return { type: 'keyDown', text, charText: '' };
|
|
33
|
+
}
|
|
34
|
+
return { type: 'rawKeyDown', text: '', charText: spec.text || '' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { keyDispatchPlan };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/shared/nav_wait.cjs — ui.navigate 的完成信号(纯函数)
|
|
2
|
+
//
|
|
3
|
+
// 事故:SPA(尤其带 markdown 高亮的编辑器)永远不 fire `load`,而 navigate 走的是
|
|
4
|
+
// `webContents.loadURL()` —— 它在 did-finish-load 才 resolve。于是导航"成功"的页面
|
|
5
|
+
// 被当成超时:30s 空等,期间 tab 被拖到无响应,之后所有 act/evaluate 连锁超时,
|
|
6
|
+
// agent 据此得出"编辑器坏了"的结论。
|
|
7
|
+
//
|
|
8
|
+
// load 不是可用的完成信号。这里把"等到哪一步"显式化:
|
|
9
|
+
// commit 主帧导航已提交(最快;内容可能还没渲染)
|
|
10
|
+
// domcontentloaded DOM 就绪(默认;SPA 在这一步就能返回)
|
|
11
|
+
// load 连子资源一起等(老行为,只在确实需要时用)
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const LEVELS = ['commit', 'domcontentloaded', 'load'];
|
|
16
|
+
const DEFAULT_LEVEL = 'domcontentloaded';
|
|
17
|
+
|
|
18
|
+
// 同义词归一。刻意不接受 networkidle:它需要网络空闲探测,本实现里没有对应
|
|
19
|
+
// 机制 —— 承诺一个做不到的档位,比只给三个真实档位更糟。
|
|
20
|
+
const ALIASES = {
|
|
21
|
+
commit: 'commit',
|
|
22
|
+
dom: 'domcontentloaded',
|
|
23
|
+
domready: 'domcontentloaded',
|
|
24
|
+
domcontentloaded: 'domcontentloaded',
|
|
25
|
+
interactive: 'domcontentloaded',
|
|
26
|
+
load: 'load',
|
|
27
|
+
full: 'load',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 归一 wait_until。返回 { value } 或 { error }(error 可直接给调用方看)。
|
|
32
|
+
*/
|
|
33
|
+
function normalizeWaitUntil(v) {
|
|
34
|
+
if (v === undefined || v === null || v === '') return { value: DEFAULT_LEVEL };
|
|
35
|
+
const key = String(v).toLowerCase().replace(/[\s_-]/g, '');
|
|
36
|
+
const hit = ALIASES[key];
|
|
37
|
+
if (!hit) {
|
|
38
|
+
return {
|
|
39
|
+
error: 'unknown wait_until: ' + JSON.stringify(String(v))
|
|
40
|
+
+ ' — valid values are ' + LEVELS.join(' | ')
|
|
41
|
+
+ '. Default is domcontentloaded; use load only when you really need subresources.',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { value: hit };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { normalizeWaitUntil, LEVELS, DEFAULT_LEVEL };
|
package/src/shared/protocol.js
CHANGED
|
@@ -59,6 +59,10 @@ export function parseMessage(msg) {
|
|
|
59
59
|
export const VALID_METHODS = [
|
|
60
60
|
'ui.get_tree',
|
|
61
61
|
'ui.act',
|
|
62
|
+
'ui.auth_state',
|
|
63
|
+
// 视口截图(P0-1):操作失败时的第一诊断手段,弥补元素树看不见的
|
|
64
|
+
// 浮层/canvas/遮挡/渲染错位。返回 base64,默认 png。
|
|
65
|
+
'ui.screenshot',
|
|
62
66
|
'ui.navigate',
|
|
63
67
|
'ui.repaint',
|
|
64
68
|
'ui.snapshot',
|
|
@@ -68,6 +72,10 @@ export const VALID_METHODS = [
|
|
|
68
72
|
'ui.subscribe',
|
|
69
73
|
'ui.unsubscribe',
|
|
70
74
|
'ui.downloads',
|
|
75
|
+
// 主进程错误日志(只读)。界面弹过"js 崩溃"原生框、或某个动作莫名失败时,
|
|
76
|
+
// 这里是唯一能查到 stack 与"出错那一刻在调什么"的地方 —— 此前这类异常只活在
|
|
77
|
+
// 那个框里,点掉即永久丢失。
|
|
78
|
+
'ui.errors',
|
|
71
79
|
'ui.get_focused',
|
|
72
80
|
'ui.peek',
|
|
73
81
|
'ui.wait',
|
|
@@ -76,6 +84,13 @@ export const VALID_METHODS = [
|
|
|
76
84
|
'ui.close_tab',
|
|
77
85
|
'ui.list_tabs',
|
|
78
86
|
'ui.set_active_tab',
|
|
87
|
+
// 窗口首次显示的时刻与当时页面状态。白屏类缺陷的根因是"窗口显示了但渲染器
|
|
88
|
+
// 还没画出第一帧",而这只能从窗口侧观测(WS 端口比 showInitialWindow 早开),
|
|
89
|
+
// 所以留一个查询口;见 e2e/nav_ws.cjs NV-A。
|
|
90
|
+
'ui.window_shown',
|
|
91
|
+
// 服务端进程身份(pid + 启动时刻)。调用方据此核对"我连上的是不是我拉起的
|
|
92
|
+
// 那个实例" —— 连错实例时结果毫无意义但不会报错,是最难查的一类。
|
|
93
|
+
'ui.server_info',
|
|
79
94
|
'ui.canvas_configure',
|
|
80
95
|
'ui.canvas_list',
|
|
81
96
|
'ui.canvas_read',
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// src/shared/ref_guard.cjs — ui.act target 的形状体检(纯函数)
|
|
2
|
+
//
|
|
3
|
+
// 坑:ui.act 的 target 是 get_tree 返回的 data-ai-id(如 `e:button-12`、
|
|
4
|
+
// `axf-3-1`,或元素原生 id),**不是按钮上可见的文字**。传 "发布" 这类
|
|
5
|
+
// label 时,最终以 `[data-ai-id="发布"]` 去查,命中不到,回一句
|
|
6
|
+
// "Target not found for ref: 发布" —— 字面没错,但完全没有指出"你传的是
|
|
7
|
+
// 文字不是 id"这个真正原因,调用方容易反复重试同一个错误形状。
|
|
8
|
+
//
|
|
9
|
+
// 这里在入口把明显不像 ref 的形状挡下来,并给可操作的提示。判定从宽:
|
|
10
|
+
// 只拒**几乎不可能是 id** 的输入(含空白字符、超长、以引号/括号开头),
|
|
11
|
+
// 原生 id 一律放行 —— 误拒一个合法 id 比漏放一个 label 更糟。
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const MAX_REF_LEN = 120;
|
|
16
|
+
|
|
17
|
+
// CJK(含全角标点)。ref 里不会出现,文案里必然出现。
|
|
18
|
+
const CJK_RE = /[\u3000-\u303f\u4e00-\u9fff\uff00-\uffef]/;
|
|
19
|
+
// 选择器的"结构符"。要求**至少有一个**,而不是"有空格就算"——
|
|
20
|
+
// 否则 "Publish now" 这种英文按钮文案会被当成后代选择器放行,
|
|
21
|
+
// 那恰恰是这套体检要拦的东西。
|
|
22
|
+
const SELECTOR_MARK_RE = /[.#\[>]|\*/;
|
|
23
|
+
// 选择器里允许出现的字符集合(够覆盖后代/子代/属性/伪类/分组选择器的常见写法)。
|
|
24
|
+
const SELECTOR_CHARS_RE = /^[A-Za-z0-9_\-.#\[\]="'>+~:*()\s]+$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 是否"像一条 CSS 选择器"。
|
|
28
|
+
* 用于 upload:上传控件常常既没有 data-ai-id、也没有可用的原生 id
|
|
29
|
+
* (B站「本地上传」是个纯 div 菜单项,AX 树里根本没有它),只能靠选择器定位。
|
|
30
|
+
* 判定刻意从严:必须**无 CJK + 含结构符 + 字符集合法**,避免把页面文案放进来。
|
|
31
|
+
*/
|
|
32
|
+
function looksLikeCssSelector(t) {
|
|
33
|
+
if (!t) return false;
|
|
34
|
+
if (CJK_RE.test(t)) return false;
|
|
35
|
+
if (!SELECTOR_MARK_RE.test(t)) return false;
|
|
36
|
+
return SELECTOR_CHARS_RE.test(t);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 把复合选择器拆成"逐段"的简单选择器(只按**顶层**复合子拆,属性值/伪类
|
|
41
|
+
* 参数里的空格引号不算)。返回形如 ['.a', '.b', '[type=file]']。
|
|
42
|
+
* 拆的时候丢掉组合子(`>` `+` `~` 与空格一样处理),因为回退只关心"哪几层",
|
|
43
|
+
* 不关心层与层之间是父子还是兄弟 —— 原始写法会由调用方原样保留为第一个候选。
|
|
44
|
+
*/
|
|
45
|
+
function splitCompoundSelector(t) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let quote = null;
|
|
49
|
+
let cur = '';
|
|
50
|
+
for (let i = 0; i < t.length; i++) {
|
|
51
|
+
const ch = t[i];
|
|
52
|
+
if (quote) {
|
|
53
|
+
cur += ch;
|
|
54
|
+
if (ch === '\\') { cur += t[i + 1] || ''; i++; continue; }
|
|
55
|
+
if (ch === quote) quote = null;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
|
|
59
|
+
if (ch === '[' || ch === '(') { depth++; cur += ch; continue; }
|
|
60
|
+
if (ch === ']' || ch === ')') { depth = Math.max(0, depth - 1); cur += ch; continue; }
|
|
61
|
+
if (depth === 0 && /[\s>+~]/.test(ch)) {
|
|
62
|
+
if (cur) { parts.push(cur); cur = ''; }
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
cur += ch;
|
|
66
|
+
}
|
|
67
|
+
if (cur) parts.push(cur);
|
|
68
|
+
return parts;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 选择器的回退候选,**由长到短**:`.a .b .c` → `['.a .b .c', '.a .b', '.a']`。
|
|
73
|
+
*
|
|
74
|
+
* 为什么需要:大量上传组件在"已经有文件"的状态下会把上传区(连同它内部那个
|
|
75
|
+
* 永远隐藏的 `<input type=file>`)整个卸载,只留一个封面预览。此时
|
|
76
|
+
* `.container-coverimage-box .el-upload__input` 查不到任何元素 —— 但它的祖先
|
|
77
|
+
* `.container-coverimage-box` 还在,点它能重新唤出选择器。调用方拿这串候选
|
|
78
|
+
* 逐个试,第一个命中的就是"还能点的那一层"。
|
|
79
|
+
*
|
|
80
|
+
* 非选择器形状(真的 data-ai-id)原样返回单元素数组,不改变既有行为。
|
|
81
|
+
*/
|
|
82
|
+
function selectorAncestors(t) {
|
|
83
|
+
const s = String(t || '').trim();
|
|
84
|
+
if (!s) return [];
|
|
85
|
+
if (!looksLikeCssSelector(s)) return [s];
|
|
86
|
+
const parts = splitCompoundSelector(s);
|
|
87
|
+
if (parts.length <= 1) return [s];
|
|
88
|
+
// 第一个永远是原样的完整选择器(保留 `>` `+` 这些精确组合子),后面才是
|
|
89
|
+
// 逐层退化的版本 —— 优先精确命中,实在没有才放宽成后代匹配。
|
|
90
|
+
const out = [s];
|
|
91
|
+
for (let n = parts.length - 1; n >= 1; n--) {
|
|
92
|
+
const cand = parts.slice(0, n).join(' ');
|
|
93
|
+
if (cand !== s) out.push(cand);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* target 是否"看着像"一个 data-ai-id。
|
|
100
|
+
* @param {string} target
|
|
101
|
+
* @param {{allowSelector?: boolean}} [opts] allowSelector:该动作是否接受 CSS
|
|
102
|
+
* 选择器(目前只有 upload)。开启后选择器形态直接放行,其余判定不变。
|
|
103
|
+
* @returns {string|null} null 表示放行;字符串是拒绝原因(可直接给调用方看)。
|
|
104
|
+
*/
|
|
105
|
+
function refShapeError(target, opts) {
|
|
106
|
+
if (target === undefined || target === null || target === '') return null; // 空由调用方按各动作规则处理
|
|
107
|
+
const t = String(target);
|
|
108
|
+
const allowSelector = !!(opts && opts.allowSelector);
|
|
109
|
+
if (allowSelector && looksLikeCssSelector(t)) return null;
|
|
110
|
+
if (/\s/.test(t)) {
|
|
111
|
+
return 'target looks like visible text, not a data-ai-id: ' + JSON.stringify(t.slice(0, 60))
|
|
112
|
+
+ ' — pass the id from get_tree (e.g. "e:button-12", "axf-3-1", or the element\'s native id), not the label on screen'
|
|
113
|
+
+ (allowSelector ? '; a CSS selector (e.g. ".el-upload__input") is also accepted for upload' : '');
|
|
114
|
+
}
|
|
115
|
+
// 纯 CJK、且不含任何 ASCII 字母/数字/结构符(`:` `-` `_` 等)——不可能是
|
|
116
|
+
// data-ai-id。树里的 ref 要么带结构前缀(`e:button-12` / `axf-3-1`),要么
|
|
117
|
+
// 是站点的原生 id(ASCII)。真实站点上"发布"/"点击发布"这类输入就是按钮
|
|
118
|
+
// 文案,放行只会让它变成一句无方向的 "Target not found"。
|
|
119
|
+
// 混了 ASCII 的一律放行(`e:按钮-1` 可能是合法 id)。
|
|
120
|
+
if (CJK_RE.test(t) && !/[A-Za-z0-9]/.test(t)) {
|
|
121
|
+
return 'target looks like visible text, not a data-ai-id: ' + JSON.stringify(t.slice(0, 60))
|
|
122
|
+
+ ' — pass the id from get_tree (e.g. "e:button-12", "axf-3-1"). If this really is the element\'s native id, locate it with ui.evaluate instead';
|
|
123
|
+
}
|
|
124
|
+
if (t.length > MAX_REF_LEN) {
|
|
125
|
+
return 'target is ' + t.length + ' chars — data-ai-id values are short ids from get_tree, not text';
|
|
126
|
+
}
|
|
127
|
+
if (/^["'`()[\]{}]/.test(t)) {
|
|
128
|
+
return 'target starts with a quote/bracket — pass the bare id from get_tree, not a selector';
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { refShapeError, looksLikeCssSelector, selectorAncestors, splitCompoundSelector, MAX_REF_LEN };
|