@opentiny/next-sdk 0.4.1 → 0.4.2
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/dist/{SimulatorMask-BHVXyogh-iNUb4dyF.js → SimulatorMask-BHVXyogh-CARX3Rff.js} +1 -1
- package/dist/core.js +1 -1
- package/dist/{index-CXK8CcKq.js → index-R_HIbfUX.js} +1698 -1170
- package/dist/index.d.ts +11 -0
- package/dist/index.js +69 -50
- package/dist/{initialize-builtin-WebMCP-rALfqS-o.js → initialize-builtin-WebMCP-JaoKwVlm.js} +355 -346
- package/dist/page-tools/a11y/build.d.ts +3 -4
- package/dist/page-tools/a11y/config.d.ts +96 -0
- package/dist/page-tools/a11y/constants.d.ts +2 -0
- package/dist/page-tools/a11y/search.d.ts +1 -1
- package/dist/page-tools/a11y/types.d.ts +13 -22
- package/dist/page-tools/a11y/utils.d.ts +32 -11
- package/dist/page-tools/a11y/vnode.d.ts +14 -11
- package/dist/page-tools/a11y-tree.d.ts +1 -0
- package/dist/page-tools/configs/console-cloud.d.ts +6 -0
- package/dist/page-tools/constants.d.ts +5 -17
- package/dist/page-tools/context.d.ts +12 -0
- package/dist/page-tools/handlers/searchTree.d.ts +2 -1
- package/dist/page-tools/page-agent-highlight/index.d.ts +12 -0
- package/dist/page-tools/page-agent-mask/SimulatorMask.d.ts +2 -0
- package/dist/page-tools/page-agent-tool-event.d.ts +26 -0
- package/dist/page-tools/schema.d.ts +1 -0
- package/dist/page-tools/tool-config.d.ts +50 -0
- package/dist/runtime.d.ts +4 -1
- package/dist/runtime.js +37 -49
- package/dist/vitest.config.d.ts +2 -0
- package/index.ts +35 -0
- package/package.json +21 -16
- package/page-tools/a11y/build.ts +38 -34
- package/page-tools/a11y/config.ts +465 -0
- package/page-tools/a11y/constants.ts +21 -0
- package/page-tools/a11y/search.ts +1 -3
- package/page-tools/a11y/types.ts +14 -17
- package/page-tools/a11y/utils.ts +129 -156
- package/page-tools/a11y/vnode.ts +277 -67
- package/page-tools/a11y-tree.ts +1 -0
- package/page-tools/bridge.ts +22 -2
- package/page-tools/configs/console-cloud.ts +172 -0
- package/page-tools/constants.ts +5 -65
- package/page-tools/context.ts +38 -1
- package/page-tools/handlers/click.ts +6 -2
- package/page-tools/handlers/fill.ts +10 -5
- package/page-tools/handlers/searchTree.ts +7 -10
- package/page-tools/handlers/select.ts +10 -5
- package/page-tools/page-agent-highlight/index.ts +66 -19
- package/page-tools/page-agent-mask/SimulatorMask.ts +42 -22
- package/page-tools/page-agent-mask/hauwei.svg +25 -0
- package/page-tools/page-agent-prompt.md +1 -1
- package/page-tools/page-agent-tool-event.ts +113 -0
- package/page-tools/page-agent-tool.ts +85 -53
- package/page-tools/schema.ts +1 -0
- package/page-tools/tool-config.ts +100 -0
- package/page-tools/utils/dom.ts +6 -3
- package/runtime.ts +37 -6
- package/test/page-tools/a11y/build.test.ts +638 -0
- package/test/page-tools/a11y/config.test.ts +370 -0
- package/test/page-tools/configs/console-cloud.test.ts +168 -0
- package/test/page-tools/page-agent-highlight.test.ts +110 -0
- package/test/page-tools/page-agent-tool-dispatch.test.ts +208 -0
- package/test/page-tools/page-agent-tool.test.ts +102 -0
- package/test/page-tools/tool-config.test.ts +112 -0
- package/test/page-tools/utils/dom.test.ts +122 -0
- package/vite.config.runtime.ts +1 -1
- package/vitest.config.ts +10 -0
package/page-tools/a11y/vnode.ts
CHANGED
|
@@ -5,20 +5,52 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { computeAccessibleName } from 'dom-accessibility-api'
|
|
8
|
-
import {
|
|
9
|
-
import type { VNode, RefMap,
|
|
10
|
-
import {
|
|
8
|
+
import { isTabbable } from 'tabbable'
|
|
9
|
+
import type { VNode, RefMap, A11yTreeShapeOptions } from './types'
|
|
10
|
+
import type { ResolvedA11yConfig } from './config'
|
|
11
|
+
import { resolveA11yInfo } from './config'
|
|
12
|
+
import {
|
|
13
|
+
isHidden,
|
|
14
|
+
isNonContentElement,
|
|
15
|
+
collectDescendantText,
|
|
16
|
+
collectTitleLabel,
|
|
17
|
+
getComposedChildren,
|
|
18
|
+
hasOwnPointerCursor,
|
|
19
|
+
} from './utils'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 语义上即为可操作控件的 ARIA 角色。
|
|
23
|
+
* jsdom 下部分带 tabindex 的自定义控件 isTabbable 可能为 false,
|
|
24
|
+
* 若不强制分配 ref,Static-Lift 会把它们误判为静态分支并吸收进父 name。
|
|
25
|
+
*/
|
|
26
|
+
const INTERACTIVE_ROLES = new Set([
|
|
27
|
+
'button',
|
|
28
|
+
'link',
|
|
29
|
+
'textbox',
|
|
30
|
+
'searchbox',
|
|
31
|
+
'combobox',
|
|
32
|
+
'listbox',
|
|
33
|
+
'checkbox',
|
|
34
|
+
'radio',
|
|
35
|
+
'switch',
|
|
36
|
+
'slider',
|
|
37
|
+
'spinbutton',
|
|
38
|
+
'menuitem',
|
|
39
|
+
'menuitemcheckbox',
|
|
40
|
+
'menuitemradio',
|
|
41
|
+
'tab',
|
|
42
|
+
'treeitem',
|
|
43
|
+
])
|
|
11
44
|
|
|
12
45
|
/**
|
|
13
46
|
* 递归将 DOM 元素转换为 VNode 中间表示
|
|
14
47
|
* @param el 当前 DOM 元素
|
|
15
48
|
* @param refCounter 引用计数器(使用对象引用避免全局可变状态)
|
|
16
49
|
* @param refMap ref 索引 → 元素映射
|
|
17
|
-
* @param blacklistSet
|
|
18
|
-
* @param whitelistSet
|
|
19
|
-
* @param
|
|
20
|
-
* @param
|
|
21
|
-
* @param warningSelectors 警告选择器
|
|
50
|
+
* @param blacklistSet 已解析的黑名单元素集合
|
|
51
|
+
* @param whitelistSet 已解析的白名单元素集合
|
|
52
|
+
* @param config 已与默认值合并的统一无障碍配置
|
|
53
|
+
* @param hoverPointerSet 只在 :hover/:focus/:active 下声明 cursor:pointer 的元素集合(静止态读不到手势,靠样式表扫描兜底)
|
|
22
54
|
*/
|
|
23
55
|
export function buildVNode(
|
|
24
56
|
el: Element,
|
|
@@ -26,22 +58,26 @@ export function buildVNode(
|
|
|
26
58
|
refMap: RefMap,
|
|
27
59
|
blacklistSet: Set<Element>,
|
|
28
60
|
whitelistSet: Set<Element>,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
warningSelectors?: string | string[],
|
|
32
|
-
/** 祖先节点是否已是可交互节点(已分配 ref)。为 true 时,纯 CSS 继承的 cursor=pointer 不再额外分配 ref */
|
|
33
|
-
ancestorIsInteractive = false,
|
|
61
|
+
config: ResolvedA11yConfig,
|
|
62
|
+
hoverPointerSet: Set<Element> = new Set(),
|
|
34
63
|
): VNode | null {
|
|
35
|
-
|
|
64
|
+
// style/script 等非内容节点:不进树(避免 @font-face base64 等被当成 name)
|
|
65
|
+
if (isNonContentElement(el) || isHidden(el) || blacklistSet.has(el)) return null
|
|
66
|
+
|
|
67
|
+
const { role, tokens } = resolveA11yInfo(el, config)
|
|
36
68
|
|
|
37
|
-
const role = inferRole(el)
|
|
38
|
-
const tokens = getStateTokens(el, exposedAttributes, errorSelectors, warningSelectors)
|
|
39
|
-
|
|
40
69
|
let name = computeAccessibleName(el as HTMLElement)
|
|
41
70
|
const isTrulyInteractive = isTabbable(el as HTMLElement)
|
|
71
|
+
// computed cursor 为 pointer(含继承)——仅用于「无文本时是否值得兜底收集名字」等宽松场景
|
|
42
72
|
const isVisuallyClickable = tokens.includes('cursor=pointer')
|
|
43
|
-
//
|
|
44
|
-
|
|
73
|
+
// 真正的"可点击边界元素"判定(用于是否分配 ref):
|
|
74
|
+
// 1) 元素自身在静止态声明了 cursor:pointer(父级不是 pointer),排除 CSS 继承传染;
|
|
75
|
+
// 2) 或元素命中了 :hover/:focus/:active 下声明 cursor:pointer 的规则(hoverPointerSet)——
|
|
76
|
+
// 大量卡片只在 hover 时才显示手势,静止态 getComputedStyle 读到 auto,需靠样式表扫描兜底。
|
|
77
|
+
const hasClickableCursor = hasOwnPointerCursor(el) || hoverPointerSet.has(el)
|
|
78
|
+
// 白名单:仅由显式 whitelist 配置驱动,与 exposedAttributes(属性 token 输出)解耦,
|
|
79
|
+
// 避免把 data-qa-id 等追踪属性误当成「强制可交互」信号污染无障碍树。
|
|
80
|
+
const isWhitelisted = whitelistSet.has(el)
|
|
45
81
|
// <label for="..."> 原生可点击:浏览器将点击转发到关联的表单控件(checkbox/radio 等)
|
|
46
82
|
// Angular/React 自定义组件常隐藏原生 input,仅暴露 label 文本和自定义 skin
|
|
47
83
|
const isLabelFor = el.tagName.toLowerCase() === 'label' && el.hasAttribute('for')
|
|
@@ -54,12 +90,27 @@ export function buildVNode(
|
|
|
54
90
|
const isDropdown = tag === 'select' || role === 'combobox' || role === 'listbox'
|
|
55
91
|
if (!isDropdown) {
|
|
56
92
|
const isInteractiveTag = ['button', 'a', 'input', 'textarea', 'li', 'label'].includes(tag)
|
|
57
|
-
if (isTrulyInteractive || isWhitelisted || isVisuallyClickable || isInteractiveTag || role === 'listitem' || role === 'option') {
|
|
58
|
-
name = collectDescendantText(el)
|
|
93
|
+
if (isTrulyInteractive || isWhitelisted || isVisuallyClickable || hasClickableCursor || isInteractiveTag || role === 'listitem' || role === 'option') {
|
|
94
|
+
name = collectDescendantText(el, config)
|
|
59
95
|
}
|
|
60
96
|
}
|
|
61
97
|
}
|
|
62
98
|
|
|
99
|
+
// 图标按钮常见:可点击容器本身无文本,title/aria 挂在子节点上(如控制台服务列表按钮)
|
|
100
|
+
if (!name.trim() && (isTrulyInteractive || isWhitelisted || isVisuallyClickable || hasClickableCursor || role === 'button')) {
|
|
101
|
+
name = collectTitleLabel(el)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 结构性交互角色(tab/menuitem 等)的标签文字常落在可聚焦子节点内,
|
|
105
|
+
// collectDescendantText 会因“遇交互子节点即停”而拿不到 name;
|
|
106
|
+
// 此处用「跳过 style/script」的文本收集,禁止 textContent 把内联 CSS/base64 吸进来
|
|
107
|
+
if (
|
|
108
|
+
!name.trim() &&
|
|
109
|
+
['tab', 'menuitem', 'option', 'treeitem', 'button'].includes(role)
|
|
110
|
+
) {
|
|
111
|
+
name = collectVisiblePlainText(el)
|
|
112
|
+
}
|
|
113
|
+
|
|
63
114
|
// 最终兜底:非交互元素(如错误提示 ti-error-msg、状态信息)的纯文本捕获。
|
|
64
115
|
// 仅当元素没有有价值的子元素时触发,避免与子节点文本重复:
|
|
65
116
|
// - 叶子 span(只有文本节点)→ 捕获文本作为 name
|
|
@@ -67,35 +118,43 @@ export function buildVNode(
|
|
|
67
118
|
if (!name.trim()) {
|
|
68
119
|
const hasElementChildren = getComposedChildren(el).length > 0
|
|
69
120
|
if (!hasElementChildren) {
|
|
70
|
-
const text = collectDescendantText(el)
|
|
121
|
+
const text = collectDescendantText(el, config)
|
|
71
122
|
if (text) {
|
|
72
123
|
name = text
|
|
73
124
|
}
|
|
74
125
|
}
|
|
75
126
|
}
|
|
76
127
|
|
|
77
|
-
//
|
|
78
|
-
// cursor
|
|
79
|
-
//
|
|
80
|
-
//
|
|
128
|
+
// cursor:pointer 是业界公认的"视觉可点击"通用信号(browser-use 等 DOM 提取器均以此为核心兜底判据)。
|
|
129
|
+
// 但 CSS 的 cursor 会向子孙继承,若直接以 computed cursor 判定,可点击容器(<a>、卡片等)会把
|
|
130
|
+
// ref "传染"给所有子孙,导致高亮泛滥。因此改用 hasClickableCursor(自身声明手势 + hover 态手势)来
|
|
131
|
+
// 定位真正的可点击边界元素:这样既能覆盖 <div class="card-wrapper" (click)=...> 这类无语义、无
|
|
132
|
+
// tabindex 的自定义可点击卡片(包括只在 hover 时显示手势的卡片),又能自然排除仅靠继承拿到 pointer
|
|
133
|
+
// 的内部子孙,无需再依赖祖先传播标记。
|
|
81
134
|
//
|
|
82
|
-
// 例外:<a>/<button>/<input>
|
|
83
|
-
//
|
|
135
|
+
// 例外:<a>/<button>/<input> 等语义性交互标签始终强制分配 ref,因为它们在 HTML 语义上
|
|
136
|
+
// 就是独立的操作单元,即使外层容器同样可点击也应各自暴露。
|
|
84
137
|
const tagName = el.tagName.toLowerCase()
|
|
85
138
|
const isSemanticInteractiveTag =
|
|
86
139
|
(tagName === 'a' && el.hasAttribute('href')) ||
|
|
87
140
|
(['button', 'input', 'select', 'textarea'].includes(tagName) && !el.hasAttribute('disabled'))
|
|
88
141
|
|
|
89
|
-
// 虽然有些元素有 tabindex="0" (isTrulyInteractive),但如果它是 generic
|
|
142
|
+
// 虽然有些元素有 tabindex="0" (isTrulyInteractive),但如果它是 generic 且没有可点击手势,
|
|
90
143
|
// 往往是开发者加的结构化 focus 容器(如 tp-card),而非真正的可交互按钮,我们在此过滤掉它们。
|
|
91
|
-
const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !
|
|
144
|
+
const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !hasClickableCursor)
|
|
145
|
+
|
|
146
|
+
const isDisabled = tokens.includes('disabled')
|
|
92
147
|
|
|
93
148
|
const interactive =
|
|
94
|
-
isMeaningfullyInteractive ||
|
|
149
|
+
(!isDisabled && isMeaningfullyInteractive) ||
|
|
95
150
|
isWhitelisted ||
|
|
96
151
|
isLabelFor ||
|
|
97
152
|
isSemanticInteractiveTag ||
|
|
98
|
-
(!
|
|
153
|
+
(!isDisabled && INTERACTIVE_ROLES.has(role)) ||
|
|
154
|
+
// hasClickableCursor 已排除 CSS 继承(hasOwnPointerCursor)并覆盖 hover 态手势,
|
|
155
|
+
// 无需再要求 role≠generic 或 name≠''——否则无文本的图标按钮(tp-icon.common-icon 等)
|
|
156
|
+
// 虽是真正的可点击边界,仍会因 generic+空名被漏判。
|
|
157
|
+
(!isDisabled && hasClickableCursor)
|
|
99
158
|
|
|
100
159
|
let ref: number | undefined
|
|
101
160
|
if (interactive) {
|
|
@@ -112,16 +171,107 @@ export function buildVNode(
|
|
|
112
171
|
refMap,
|
|
113
172
|
blacklistSet,
|
|
114
173
|
whitelistSet,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
warningSelectors,
|
|
118
|
-
// 将当前节点的交互性向下传递,子节点据此决定是否抑制 cursor=pointer
|
|
119
|
-
interactive || ancestorIsInteractive,
|
|
174
|
+
config,
|
|
175
|
+
hoverPointerSet,
|
|
120
176
|
)
|
|
121
177
|
if (childVNode) children.push(childVNode)
|
|
122
178
|
}
|
|
123
179
|
|
|
124
|
-
|
|
180
|
+
const vnode: VNode = { role, name, tokens, ref, el: el as HTMLElement, children }
|
|
181
|
+
|
|
182
|
+
// 有可交互子孙时:清空 AccName 内容汇总,仅保留自身声明名。
|
|
183
|
+
// 静态子树文案的上提(Static-Lift)改由 serializeVNode 负责,避免与交互文案混进父 name。
|
|
184
|
+
if (hasInteractiveDescendant(vnode)) {
|
|
185
|
+
vnode.name = getOwnDeclaredName(el as HTMLElement)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return vnode
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 仅取元素自身声明的可访问名,不含内容汇总(name from contents)。
|
|
193
|
+
*/
|
|
194
|
+
function getOwnDeclaredName(el: HTMLElement): string {
|
|
195
|
+
if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) {
|
|
196
|
+
return computeAccessibleName(el).trim()
|
|
197
|
+
}
|
|
198
|
+
return ''
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 子节点自身无 ref,且子树无可交互子孙 → 纯静态分支(可上提文案) */
|
|
202
|
+
function isStaticBranch(vnode: VNode): boolean {
|
|
203
|
+
return vnode.ref === undefined && !hasInteractiveDescendant(vnode)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 收集元素可见纯文本,跳过 style/script 等非内容子树。
|
|
208
|
+
* 禁止使用 el.textContent:它会包含内联 @font-face base64,导致 token 爆炸。
|
|
209
|
+
*/
|
|
210
|
+
function collectVisiblePlainText(el: Element): string {
|
|
211
|
+
return collectDescendantText(el).replace(/\s+/g, ' ').trim()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 收集纯静态分支的展示文案(保字用)。
|
|
216
|
+
* 优先 name,其次递归子节点,最后可见纯文本兜底(不含 style/script)。
|
|
217
|
+
*/
|
|
218
|
+
function getStaticDisplayText(vnode: VNode): string {
|
|
219
|
+
const childJoined =
|
|
220
|
+
vnode.children.length > 0
|
|
221
|
+
? vnode.children
|
|
222
|
+
.map(getStaticDisplayText)
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
.join(' ')
|
|
225
|
+
.replace(/\s+/g, ' ')
|
|
226
|
+
.trim()
|
|
227
|
+
: ''
|
|
228
|
+
const plain = vnode.children.length === 0 ? collectVisiblePlainText(vnode.el) : ''
|
|
229
|
+
const named = vnode.name.trim()
|
|
230
|
+
|
|
231
|
+
const parts: string[] = []
|
|
232
|
+
if (named && !isNoiseAccessibleName(named)) parts.push(named)
|
|
233
|
+
if (childJoined && !isNoiseAccessibleName(childJoined)) {
|
|
234
|
+
const normNamed = named.replace(/\s+/g, ' ').trim()
|
|
235
|
+
const normChild = childJoined.replace(/\s+/g, ' ').trim()
|
|
236
|
+
// 声明名与子孙文案不同则合并保留,避免仅有 aria-label 时丢掉静态说明
|
|
237
|
+
if (!normNamed || (normChild !== normNamed && !normNamed.includes(normChild))) {
|
|
238
|
+
parts.push(childJoined)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (parts.length === 0 && plain && !isNoiseAccessibleName(plain)) parts.push(plain)
|
|
242
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 名称若像 CSS/字体资源噪声(常见于误吸收的 <style> 内容),视为无效,避免输出到 YAML。
|
|
247
|
+
*/
|
|
248
|
+
function isNoiseAccessibleName(name: string): boolean {
|
|
249
|
+
const n = name.trim()
|
|
250
|
+
if (!n) return false
|
|
251
|
+
if (/@font-face\b/i.test(n)) return true
|
|
252
|
+
if (/data:(?:font|application|image)\//i.test(n)) return true
|
|
253
|
+
if (/base64,[A-Za-z0-9+/=]{80,}/.test(n)) return true
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 静态子节点可否省略:仅当其文案已由父 outputName 完整承载(去重且不丢字)。
|
|
259
|
+
* - 父使用自身声明名:仅当子文案与声明名完全相同才可省略
|
|
260
|
+
* - 父使用上提拼接名:子文案作为片段已包含在父名中才可省略
|
|
261
|
+
*/
|
|
262
|
+
function canOmitStaticChild(
|
|
263
|
+
child: VNode,
|
|
264
|
+
parentOutputName: string,
|
|
265
|
+
parentUsesOwnDeclaredName: boolean,
|
|
266
|
+
): boolean {
|
|
267
|
+
const text = getStaticDisplayText(child)
|
|
268
|
+
if (!text || !parentOutputName.trim()) return false
|
|
269
|
+
const parentNorm = parentOutputName.replace(/\s+/g, ' ').trim()
|
|
270
|
+
const textNorm = text.replace(/\s+/g, ' ').trim()
|
|
271
|
+
if (parentUsesOwnDeclaredName) {
|
|
272
|
+
return textNorm === parentNorm
|
|
273
|
+
}
|
|
274
|
+
return parentNorm.includes(textNorm)
|
|
125
275
|
}
|
|
126
276
|
|
|
127
277
|
/**
|
|
@@ -164,7 +314,7 @@ function findSingleRefDescendant(vnode: VNode): VNode | null {
|
|
|
164
314
|
* - 去掉 generic/list/listitem 等纯布局噪音(无 name 时穿透)
|
|
165
315
|
* - 保留有 name 的 listitem(有 name 时保留,兼顾内容理解场景)
|
|
166
316
|
*/
|
|
167
|
-
export function shouldPassThrough(vnode: VNode, opts:
|
|
317
|
+
export function shouldPassThrough(vnode: VNode, opts: A11yTreeShapeOptions): boolean {
|
|
168
318
|
if (!opts.pruneUnnamed) return false
|
|
169
319
|
// preserveRoles 中的角色强制保留
|
|
170
320
|
if (opts.preserveRoles.includes(vnode.role)) return false
|
|
@@ -176,54 +326,114 @@ export function shouldPassThrough(vnode: VNode, opts: Required<A11yTreeOptions>)
|
|
|
176
326
|
return true
|
|
177
327
|
}
|
|
178
328
|
|
|
329
|
+
/**
|
|
330
|
+
* 将节点 name 转义为 YAML 引号内文本;空名返回空串(调用方决定是否拼引号)。
|
|
331
|
+
*/
|
|
332
|
+
function formatNameAttr(name: string): string {
|
|
333
|
+
const safe = name.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"').trim()
|
|
334
|
+
return safe ? ` "${safe}"` : ''
|
|
335
|
+
}
|
|
336
|
+
|
|
179
337
|
/**
|
|
180
338
|
* 将 VNode 序列化为 YAML 行数组
|
|
181
339
|
* 穿透节点时,子节点在当前 depth 平铺输出(不增加缩进层级)
|
|
340
|
+
*
|
|
341
|
+
* 文本折叠规则(Static-Lift + Interactive-Keep,保字优先):
|
|
342
|
+
* 1. 子树全静态 → 省略子节点,文字保留在父节点
|
|
343
|
+
* 2. 混合子树 → 静态文案上提到父 name(成功才省略静态子节点);交互分支递归保留;
|
|
344
|
+
* 禁止把交互文案并入父 name;上提失败则静态子节点独立输出,绝不丢字
|
|
182
345
|
*/
|
|
183
346
|
export function serializeVNode(
|
|
184
347
|
vnode: VNode,
|
|
185
348
|
depth: number,
|
|
186
|
-
opts:
|
|
349
|
+
opts: A11yTreeShapeOptions,
|
|
187
350
|
): string[] {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
// 同时过滤掉整棵子树都无价値的空容器,避免输出无意义的嵌套
|
|
191
|
-
return vnode.children
|
|
192
|
-
.filter(c => hasValue(c))
|
|
193
|
-
.flatMap(c => serializeVNode(c, depth, opts))
|
|
194
|
-
}
|
|
195
|
-
|
|
351
|
+
const hasInteractive = hasInteractiveDescendant(vnode)
|
|
352
|
+
const forceKeepStructure = opts.preserveRoles.includes(vnode.role)
|
|
196
353
|
const indent = ' '.repeat(depth)
|
|
197
354
|
const refStr = vnode.ref !== undefined ? ` #${vnode.ref}` : ''
|
|
198
355
|
const safeTokens = vnode.tokens.map(t => t.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"'))
|
|
199
356
|
const tokenStr = safeTokens.length > 0 ? ` [${safeTokens.join(' ')}]` : ''
|
|
200
357
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return [`${indent}- ${vnode.role}${refStr}${tokenStr}${nameStr}`]
|
|
358
|
+
// 规则 1:子树无可交互节点,且无需强制保留结构 → 折叠为一行(文字不丢)
|
|
359
|
+
if (!hasInteractive && !forceKeepStructure) {
|
|
360
|
+
if (shouldPassThrough(vnode, opts)) {
|
|
361
|
+
return vnode.children
|
|
362
|
+
.filter(c => hasValue(c))
|
|
363
|
+
.flatMap(c => serializeVNode(c, depth, opts))
|
|
208
364
|
}
|
|
365
|
+
let displayName = vnode.name.trim()
|
|
366
|
+
if (!displayName && vnode.children.length > 0) {
|
|
367
|
+
displayName = collectVisiblePlainText(vnode.el)
|
|
368
|
+
}
|
|
369
|
+
if (isNoiseAccessibleName(displayName)) displayName = ''
|
|
370
|
+
return [`${indent}- ${vnode.role}${refStr}${tokenStr}${formatNameAttr(displayName)}`]
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// preserveRoles 且无交互:保留结构,父级去掉 content name,子节点照常输出
|
|
374
|
+
if (!hasInteractive && forceKeepStructure) {
|
|
375
|
+
const outputName = getOwnDeclaredName(vnode.el)
|
|
376
|
+
const line = `${indent}- ${vnode.role}${refStr}${tokenStr}${formatNameAttr(outputName)}`
|
|
377
|
+
const childLines = vnode.children.flatMap(c => serializeVNode(c, depth + 1, opts))
|
|
378
|
+
return [line, ...childLines]
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── 混合子树:Static-Lift + Interactive-Keep ──
|
|
382
|
+
const ownDeclaredName = getOwnDeclaredName(vnode.el)
|
|
383
|
+
const parentUsesOwnDeclaredName = ownDeclaredName !== ''
|
|
384
|
+
const staticChildren = vnode.children.filter(isStaticBranch)
|
|
385
|
+
const liftedStaticName = staticChildren
|
|
386
|
+
.map(getStaticDisplayText)
|
|
387
|
+
.filter(Boolean)
|
|
388
|
+
.join(' ')
|
|
389
|
+
.replace(/\s+/g, ' ')
|
|
390
|
+
.trim()
|
|
391
|
+
|
|
392
|
+
// 父 name:声明名优先;否则上提静态子树文案(绝不吸收交互文案)
|
|
393
|
+
let outputName = parentUsesOwnDeclaredName ? ownDeclaredName : liftedStaticName
|
|
394
|
+
if (isNoiseAccessibleName(outputName)) outputName = ''
|
|
395
|
+
// vnode.name(声明名)若被污染同样丢弃
|
|
396
|
+
if (!parentUsesOwnDeclaredName && isNoiseAccessibleName(vnode.name)) {
|
|
397
|
+
outputName = liftedStaticName && !isNoiseAccessibleName(liftedStaticName) ? liftedStaticName : ''
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const willEmitParent =
|
|
401
|
+
vnode.ref !== undefined ||
|
|
402
|
+
outputName !== '' ||
|
|
403
|
+
forceKeepStructure ||
|
|
404
|
+
!opts.pruneUnnamed
|
|
405
|
+
|
|
406
|
+
// 父节点不会输出时必须穿透,且不得省略静态子节点(保字)
|
|
407
|
+
if (!willEmitParent) {
|
|
408
|
+
return vnode.children
|
|
409
|
+
.filter(c => hasValue(c))
|
|
410
|
+
.flatMap(c => serializeVNode(c, depth, opts))
|
|
411
|
+
}
|
|
209
412
|
|
|
413
|
+
if (vnode.ref !== undefined) {
|
|
210
414
|
// 子树中唯一一个 ref 节点且是无子 ref 的 generic 时,将其 name 提升到父节点,省略子节点输出。
|
|
211
|
-
// 场景:`listitem #22 [active]` 内部只有 `generic #23 "总览"`,
|
|
212
|
-
// 点击意义相同时,就展示为一行 `- listitem #22 [active] "总览"`。
|
|
213
415
|
const singleChild = findSingleRefDescendant(vnode)
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
416
|
+
if (
|
|
417
|
+
singleChild &&
|
|
418
|
+
singleChild.role === 'generic' &&
|
|
419
|
+
!hasInteractiveDescendant(singleChild) &&
|
|
420
|
+
staticChildren.length === 0
|
|
421
|
+
) {
|
|
422
|
+
const mergedName = outputName.trim() || singleChild.name.trim()
|
|
423
|
+
// 合并输出时使用子节点 ref,避免 refMap 中的 #N 在 YAML 里丢失
|
|
424
|
+
const mergedRefStr =
|
|
425
|
+
singleChild.ref !== undefined ? ` #${singleChild.ref}` : refStr
|
|
426
|
+
return [`${indent}- ${vnode.role}${mergedRefStr}${tokenStr}${formatNameAttr(mergedName)}`]
|
|
220
427
|
}
|
|
221
428
|
}
|
|
222
429
|
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
430
|
+
// 仅省略「文案已由父 outputName 承载」的静态子节点;其余(含全部交互分支)按原序输出
|
|
431
|
+
const childrenToEmit = vnode.children.filter(c => {
|
|
432
|
+
if (!isStaticBranch(c)) return true
|
|
433
|
+
return !canOmitStaticChild(c, outputName, parentUsesOwnDeclaredName)
|
|
434
|
+
})
|
|
226
435
|
|
|
227
|
-
const
|
|
436
|
+
const line = `${indent}- ${vnode.role}${refStr}${tokenStr}${formatNameAttr(outputName)}`
|
|
437
|
+
const childLines = childrenToEmit.flatMap(c => serializeVNode(c, depth + 1, opts))
|
|
228
438
|
return [line, ...childLines]
|
|
229
439
|
}
|
package/page-tools/a11y-tree.ts
CHANGED
package/page-tools/bridge.ts
CHANGED
|
@@ -613,14 +613,34 @@ export function registerPageTool(options: RegisterPageToolByHandlersOptions): ()
|
|
|
613
613
|
// 供 content.ts 通过 window.__nextSdkRegisteredTools() 跨沙箱查询完整工具定义
|
|
614
614
|
const _registeredTools = new Map<string, any>()
|
|
615
615
|
|
|
616
|
+
/** 浏览器全局对象上可能挂载的 modelContext 接口 */
|
|
617
|
+
interface ModelContextHost {
|
|
618
|
+
modelContext?: BridgeableModelContext
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** 可被 bridge 拦截的原生 modelContext 接口 */
|
|
622
|
+
interface BridgeableModelContext {
|
|
623
|
+
registerTool?(config: unknown, options?: unknown): unknown
|
|
624
|
+
unregisterTool?(name: string): unknown
|
|
625
|
+
__isNextSdkBridgeSetup?: boolean
|
|
626
|
+
}
|
|
627
|
+
|
|
616
628
|
export function setupModelContextBridge() {
|
|
617
629
|
if (typeof document === 'undefined') return
|
|
618
|
-
const doc = document as
|
|
619
|
-
const
|
|
630
|
+
const doc = document as unknown as ModelContextHost
|
|
631
|
+
const nav = typeof navigator !== 'undefined' ? (navigator as unknown as ModelContextHost) : undefined
|
|
632
|
+
|
|
633
|
+
// 兼容 document.modelContext 与 navigator.modelContext(不同浏览器/polyfill 挂载位置不同)
|
|
634
|
+
const nativeCtx = doc.modelContext || nav?.modelContext
|
|
620
635
|
|
|
621
636
|
// 如果不存在或者已经被拦截过,则不处理
|
|
622
637
|
if (!nativeCtx || nativeCtx.__isNextSdkBridgeSetup) return
|
|
623
638
|
|
|
639
|
+
// 若 modelContext 仅存在于 navigator 上,同步挂载到 document 以保持向后兼容
|
|
640
|
+
if (!doc.modelContext && nav?.modelContext) {
|
|
641
|
+
doc.modelContext = nav.modelContext
|
|
642
|
+
}
|
|
643
|
+
|
|
624
644
|
// 挂载全局查询函数,供 content script(隔离沙箱)访问
|
|
625
645
|
// 注意:必须强制覆盖,不检查是否已存在。
|
|
626
646
|
// init-webmcp.js 的 fallback polyfill 会提前注册一个基于内联 Map 的版本,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 云控制台(consoleCloud)专用 PageAgentToolOptions。
|
|
3
|
+
*
|
|
4
|
+
* 控制台基于 Tiny3(ti3-*)+ Angular,大量自定义组件缺少 role / aria-*:
|
|
5
|
+
* - Tab:`.ti3-tab-li` 无 role=tab,选中靠 `.ti3-tab-active`
|
|
6
|
+
* - 下拉:`.ti3-select-dominator-container` / `.selected-label` 无 combobox 语义
|
|
7
|
+
* - 图标按钮:仅有 `cf-uba` / 子节点 `title`,无 aria-label
|
|
8
|
+
* - 服务列表侧栏、区域选择等用 class 表达选中态
|
|
9
|
+
*
|
|
10
|
+
* 书写约定:优先 selector;仅当需要计算样式等复杂判断时再用 match。
|
|
11
|
+
* (roles 的 selector 为自身 matches;states 的 selector 为 closest)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defineA11yConfig } from '../a11y/config'
|
|
15
|
+
import type { PageAgentToolOptions } from '../tool-config'
|
|
16
|
+
|
|
17
|
+
/** 云控制台 page-agent-tool 预设配置 */
|
|
18
|
+
export const consoleCloudPageAgentToolOptions: PageAgentToolOptions = {
|
|
19
|
+
enableHighlight: false,
|
|
20
|
+
a11yConfig: defineA11yConfig({
|
|
21
|
+
roles: [
|
|
22
|
+
// Tiny3 Tabs:真正可聚焦/可点击的是内部 .ti3-tabs-text(tabindex=0)
|
|
23
|
+
{ role: 'tablist', selector: 'ul.ti3-tabs' },
|
|
24
|
+
{ role: 'tab', selector: '.ti3-tabs-text' },
|
|
25
|
+
{ role: 'tabpanel', selector: ['ti-tab.ti3-tab-pane', '.ti3-tab-pane'] },
|
|
26
|
+
|
|
27
|
+
// 自定义下拉 / 区域选择触发器
|
|
28
|
+
{
|
|
29
|
+
role: 'combobox',
|
|
30
|
+
selector: [
|
|
31
|
+
'.ti3-select-dominator-container',
|
|
32
|
+
'.selected-label',
|
|
33
|
+
'.sort-select',
|
|
34
|
+
'.service-select',
|
|
35
|
+
'.region-select',
|
|
36
|
+
'[cf-uba="searchableRegion..open"]'
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// 图标型 / 无语义按钮
|
|
41
|
+
{
|
|
42
|
+
role: 'button',
|
|
43
|
+
selector: [
|
|
44
|
+
'.modules-service-list-menu-service-icon-container',
|
|
45
|
+
'.ti3-btn-only-icon-noborder',
|
|
46
|
+
'.ti3-select-dominator-dropdown-btn',
|
|
47
|
+
'.modules-right-sidebar-icon-item-wrapper',
|
|
48
|
+
'.modules-right-sidebar-hide-state-plugin',
|
|
49
|
+
'.components-service-list-container-service-list-input-service-close',
|
|
50
|
+
'.modules-searchable-region-region-panel-close',
|
|
51
|
+
'.modules-searchable-region-region-panel-search-btn',
|
|
52
|
+
'[cf-uba="rightSidebar..hideBtn"]',
|
|
53
|
+
// Tiny3 图标组件:帮助中心固定/全屏/关闭等(ti-icon / tp-icon 无原生 button 语义)
|
|
54
|
+
'.ti-global-help-panel-header-icon',
|
|
55
|
+
'ti-icon[name]',
|
|
56
|
+
'tp-icon[name]',
|
|
57
|
+
// 无 name 的箭头/操作图标(如 latest news 区 common-icon 展开箭头)
|
|
58
|
+
'tp-icon.common-icon',
|
|
59
|
+
'ti-icon.common-icon',
|
|
60
|
+
'ti-icon.ti3-icon-full-screen',
|
|
61
|
+
'ti-icon.ti3-icon-close',
|
|
62
|
+
'tp-icon.ti3-icon-full-screen',
|
|
63
|
+
'tp-icon.ti3-icon-close'
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// 服务列表侧栏分类 / 顶栏菜单项
|
|
68
|
+
{
|
|
69
|
+
role: 'menuitem',
|
|
70
|
+
selector: [
|
|
71
|
+
'.components-service-list-left-box-sidebar-visit-panel > li',
|
|
72
|
+
'.modules-menus-menu-item',
|
|
73
|
+
'.components-menu-wrapper-menu-text'
|
|
74
|
+
]
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// 区域筛选项 / 下拉选项
|
|
78
|
+
{
|
|
79
|
+
role: 'option',
|
|
80
|
+
selector: [
|
|
81
|
+
'.region-selector-item',
|
|
82
|
+
'.modules-searchable-region-project-item-region-item',
|
|
83
|
+
'.modules-searchable-region-region-panel-recent-region-item',
|
|
84
|
+
'.ti3-multiselect-box-cell'
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
states: {
|
|
89
|
+
// 选中态:挂在容器 class 上,用 selector + closest 即可命中内部交互节点
|
|
90
|
+
selected: [
|
|
91
|
+
{ selector: 'li.ti3-tab-li.ti3-tab-active' },
|
|
92
|
+
// Tiny3 按钮组(计费模式等):ti3-active 在 .ti3-btn-item-container 上,不在 button 上
|
|
93
|
+
{ selector: '.ti3-btn-item-container.ti3-active' },
|
|
94
|
+
{ selector: '.components-service-list-left-box-active' },
|
|
95
|
+
{
|
|
96
|
+
selector: [
|
|
97
|
+
'.modules-searchable-region-project-item-region-item-selected',
|
|
98
|
+
'.components-search-content-search-select-search-select-selected'
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
hasPopup: [
|
|
103
|
+
{
|
|
104
|
+
selector: [
|
|
105
|
+
'.ti3-select-dominator-container',
|
|
106
|
+
'.selected-label',
|
|
107
|
+
'.sort-select',
|
|
108
|
+
'.service-select',
|
|
109
|
+
'.region-select',
|
|
110
|
+
'[cf-uba="searchableRegion..open"]',
|
|
111
|
+
'[cf-uba="serviceList..open"]',
|
|
112
|
+
'[cf-uba="userinfo"]'
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
disabled: [{ selector: '.ti3-disabled' }],
|
|
117
|
+
// 展开态:需结合计算样式,选择器表达不了「可见」
|
|
118
|
+
expanded: [
|
|
119
|
+
{
|
|
120
|
+
match: (el) => {
|
|
121
|
+
try {
|
|
122
|
+
if (
|
|
123
|
+
!el.matches(
|
|
124
|
+
'.modules-layout-module-sidebar-panel, .modules-searchable-region-region-panel, .ti3-dropdown-container'
|
|
125
|
+
)
|
|
126
|
+
) {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
const style = window.getComputedStyle(el as HTMLElement)
|
|
130
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
|
|
131
|
+
} catch {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
// exposedAttributes 输出属性 token;带 name 的 ti-icon/tp-icon 可交互性由 roles/whitelist 中 ti-icon[name] 等规则判定。
|
|
139
|
+
exposedAttributes: ['cf-uba', 'data-qa-id', 'name'],
|
|
140
|
+
whitelist: [
|
|
141
|
+
'.modules-service-list-menu-service-icon-container',
|
|
142
|
+
'.ti3-tabs-text',
|
|
143
|
+
'[cf-uba="cloudShell"]',
|
|
144
|
+
'[cf-uba="messageBox"]',
|
|
145
|
+
'[cf-uba="helpFeedback"]',
|
|
146
|
+
'[cf-uba="globalMessage"]',
|
|
147
|
+
'[cf-uba="rightSidebar..hideBtn"]',
|
|
148
|
+
// 帮助中心等区域的 ti-icon / tp-icon 图标按钮
|
|
149
|
+
'.ti-global-help-panel-header-icon',
|
|
150
|
+
'ti-icon[name]',
|
|
151
|
+
'tp-icon[name]',
|
|
152
|
+
'tp-icon.common-icon',
|
|
153
|
+
'ti-icon.common-icon',
|
|
154
|
+
'ti-icon.ti3-icon-full-screen'
|
|
155
|
+
],
|
|
156
|
+
blacklist: ['noscript', 'pan-gu', '#J_header'],
|
|
157
|
+
dialogSelectors: [
|
|
158
|
+
'[class*="ti3-modal"]',
|
|
159
|
+
'[class*="ti3-message-box"]',
|
|
160
|
+
'[class*="drawer"]',
|
|
161
|
+
'.modules-layout-module-sidebar-panel'
|
|
162
|
+
]
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 判断当前页面是否应使用云控制台(consoleCloud)预设 */
|
|
167
|
+
export function isConsoleCloudHost(
|
|
168
|
+
hostname: string = typeof location !== 'undefined' ? location.hostname : ''
|
|
169
|
+
): boolean {
|
|
170
|
+
// 要求 console 前为域名边界(开头或 `.`),避免误匹配 xconsole.huaweicloud.com
|
|
171
|
+
return /(^|\.)console\.huaweicloud\.com$/i.test(hostname)
|
|
172
|
+
}
|