browser-opt 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/browser-opt/index.d.ts +9 -0
  2. package/dist/browser-opt/index.d.ts.map +1 -0
  3. package/dist/browser-opt/index.js +7 -0
  4. package/dist/browser-opt/index.js.map +1 -0
  5. package/dist/browser-opt/runner/actions/index.d.ts +13 -0
  6. package/dist/browser-opt/runner/actions/index.d.ts.map +1 -0
  7. package/dist/browser-opt/runner/actions/index.js +44 -0
  8. package/dist/browser-opt/runner/actions/index.js.map +1 -0
  9. package/dist/browser-opt/runner/actions/utils/click-action.d.ts +10 -0
  10. package/dist/browser-opt/runner/actions/utils/click-action.d.ts.map +1 -0
  11. package/dist/browser-opt/runner/actions/utils/click-action.js +17 -0
  12. package/dist/browser-opt/runner/actions/utils/click-action.js.map +1 -0
  13. package/dist/browser-opt/runner/actions/utils/date-action.d.ts +21 -0
  14. package/dist/browser-opt/runner/actions/utils/date-action.d.ts.map +1 -0
  15. package/dist/browser-opt/runner/actions/utils/date-action.js +464 -0
  16. package/dist/browser-opt/runner/actions/utils/date-action.js.map +1 -0
  17. package/dist/browser-opt/runner/actions/utils/dom-action.d.ts +8 -0
  18. package/dist/browser-opt/runner/actions/utils/dom-action.d.ts.map +1 -0
  19. package/dist/browser-opt/runner/actions/utils/dom-action.js +167 -0
  20. package/dist/browser-opt/runner/actions/utils/dom-action.js.map +1 -0
  21. package/dist/browser-opt/runner/actions/utils/fill-action.d.ts +10 -0
  22. package/dist/browser-opt/runner/actions/utils/fill-action.d.ts.map +1 -0
  23. package/dist/browser-opt/runner/actions/utils/fill-action.js +20 -0
  24. package/dist/browser-opt/runner/actions/utils/fill-action.js.map +1 -0
  25. package/dist/browser-opt/runner/actions/utils/handoff-action.d.ts +10 -0
  26. package/dist/browser-opt/runner/actions/utils/handoff-action.d.ts.map +1 -0
  27. package/dist/browser-opt/runner/actions/utils/handoff-action.js +6 -0
  28. package/dist/browser-opt/runner/actions/utils/handoff-action.js.map +1 -0
  29. package/dist/browser-opt/runner/actions/utils/open-action.d.ts +10 -0
  30. package/dist/browser-opt/runner/actions/utils/open-action.d.ts.map +1 -0
  31. package/dist/browser-opt/runner/actions/utils/open-action.js +9 -0
  32. package/dist/browser-opt/runner/actions/utils/open-action.js.map +1 -0
  33. package/dist/browser-opt/runner/actions/utils/select-option-action.d.ts +17 -0
  34. package/dist/browser-opt/runner/actions/utils/select-option-action.d.ts.map +1 -0
  35. package/dist/browser-opt/runner/actions/utils/select-option-action.js +532 -0
  36. package/dist/browser-opt/runner/actions/utils/select-option-action.js.map +1 -0
  37. package/dist/browser-opt/runner/actions/utils/upload-action.d.ts +7 -0
  38. package/dist/browser-opt/runner/actions/utils/upload-action.d.ts.map +1 -0
  39. package/dist/browser-opt/runner/actions/utils/upload-action.js +173 -0
  40. package/dist/browser-opt/runner/actions/utils/upload-action.js.map +1 -0
  41. package/dist/browser-opt/runner/evidence.d.ts +18 -0
  42. package/dist/browser-opt/runner/evidence.d.ts.map +1 -0
  43. package/dist/browser-opt/runner/evidence.js +116 -0
  44. package/dist/browser-opt/runner/evidence.js.map +1 -0
  45. package/dist/browser-opt/runner/handoff.d.ts +28 -0
  46. package/dist/browser-opt/runner/handoff.d.ts.map +1 -0
  47. package/dist/browser-opt/runner/handoff.js +132 -0
  48. package/dist/browser-opt/runner/handoff.js.map +1 -0
  49. package/dist/browser-opt/runner/index.d.ts +11 -0
  50. package/dist/browser-opt/runner/index.d.ts.map +1 -0
  51. package/dist/browser-opt/runner/index.js +244 -0
  52. package/dist/browser-opt/runner/index.js.map +1 -0
  53. package/dist/browser-opt/runner/step-executor.d.ts +5 -0
  54. package/dist/browser-opt/runner/step-executor.d.ts.map +1 -0
  55. package/dist/browser-opt/runner/step-executor.js +302 -0
  56. package/dist/browser-opt/runner/step-executor.js.map +1 -0
  57. package/dist/browser-opt/type.d.ts +120 -0
  58. package/dist/browser-opt/type.d.ts.map +1 -0
  59. package/dist/browser-opt/type.js +2 -0
  60. package/dist/browser-opt/type.js.map +1 -0
  61. package/dist/browser-opt/utils.d.ts +48 -0
  62. package/dist/browser-opt/utils.d.ts.map +1 -0
  63. package/dist/browser-opt/utils.js +885 -0
  64. package/dist/browser-opt/utils.js.map +1 -0
  65. package/dist/browser-opt/workflow/index.d.ts +7 -0
  66. package/dist/browser-opt/workflow/index.d.ts.map +1 -0
  67. package/dist/browser-opt/workflow/index.js +6 -0
  68. package/dist/browser-opt/workflow/index.js.map +1 -0
  69. package/dist/browser-opt/workflow/matcher.d.ts +12 -0
  70. package/dist/browser-opt/workflow/matcher.d.ts.map +1 -0
  71. package/dist/browser-opt/workflow/matcher.js +102 -0
  72. package/dist/browser-opt/workflow/matcher.js.map +1 -0
  73. package/dist/browser-opt/workflow/store.d.ts +15 -0
  74. package/dist/browser-opt/workflow/store.d.ts.map +1 -0
  75. package/dist/browser-opt/workflow/store.js +175 -0
  76. package/dist/browser-opt/workflow/store.js.map +1 -0
  77. package/dist/browser-opt/workflow/type.d.ts +43 -0
  78. package/dist/browser-opt/workflow/type.d.ts.map +1 -0
  79. package/dist/browser-opt/workflow/type.js +6 -0
  80. package/dist/browser-opt/workflow/type.js.map +1 -0
  81. package/dist/cli/browser-opt.d.ts +3 -0
  82. package/dist/cli/browser-opt.d.ts.map +1 -0
  83. package/dist/cli/browser-opt.js +11 -0
  84. package/dist/cli/browser-opt.js.map +1 -0
  85. package/dist/cli/commands/browser-opt.d.ts +2 -0
  86. package/dist/cli/commands/browser-opt.d.ts.map +1 -0
  87. package/dist/cli/commands/browser-opt.js +514 -0
  88. package/dist/cli/commands/browser-opt.js.map +1 -0
  89. package/dist/cli/commands/setup.d.ts +10 -0
  90. package/dist/cli/commands/setup.d.ts.map +1 -0
  91. package/dist/cli/commands/setup.js +35 -0
  92. package/dist/cli/commands/setup.js.map +1 -0
  93. package/dist/cli/utils/args.d.ts +17 -0
  94. package/dist/cli/utils/args.d.ts.map +1 -0
  95. package/dist/cli/utils/args.js +74 -0
  96. package/dist/cli/utils/args.js.map +1 -0
  97. package/dist/cli/utils/constants.d.ts +8 -0
  98. package/dist/cli/utils/constants.d.ts.map +1 -0
  99. package/dist/cli/utils/constants.js +18 -0
  100. package/dist/cli/utils/constants.js.map +1 -0
  101. package/dist/cli/utils/output.d.ts +7 -0
  102. package/dist/cli/utils/output.d.ts.map +1 -0
  103. package/dist/cli/utils/output.js +45 -0
  104. package/dist/cli/utils/output.js.map +1 -0
  105. package/package.json +35 -0
  106. package/skills/browser-opt/SKILL.md +249 -0
  107. package/skills/browser-opt/manifest.json +75 -0
@@ -0,0 +1,885 @@
1
+ /**
2
+ * browser-opt 工具函数文件,集中承载自然语言解析、snapshot 文本处理、
3
+ * 节点匹配、验证判断和报告渲染等无状态逻辑,让 runner 只负责执行编排。
4
+ */
5
+ import * as path from 'node:path';
6
+ const DEFAULT_OUTPUT_ROOT = path.join(process.cwd(), '.browser-opt', 'artifacts');
7
+ const URL_RE = /https?:\/\/[^\s。,、,))"'‘’“”]+/i;
8
+ const SELECTABLE_VERB_RE = /选择|选中|勾选|勾上|设置为|设置成|切换为|切换成|切到|改为|改成|调整为|调整成|设为|设成|置为|置成|变更为|变更成|变为|变成|select|check|toggle/i;
9
+ /** 从自然语言描述中提取第一个 URL,作为 browser-opt 的起始页面。 */
10
+ export function extractBrowserOptUrl(text) {
11
+ const match = normalizeBrowserOptFlowText(text).match(URL_RE);
12
+ return match ? match[0] : null;
13
+ }
14
+ /** 把自然语言流程拆成顺序步骤,优先识别编号目标,无法识别时回退为单步骤。 */
15
+ export function splitBrowserOptSteps(text) {
16
+ const lines = normalizeBrowserOptFlowText(text)
17
+ .split('\n')
18
+ .map((line) => line.trim())
19
+ .filter(Boolean);
20
+ const numbered = lines
21
+ .map((line) => line.match(/^(?:目标[::]\s*)?(\d+)[\.)、]\s*(.+)$/))
22
+ .filter((match) => Boolean(match?.[2]))
23
+ .map((match) => match[2].trim())
24
+ .filter(Boolean);
25
+ if (numbered.length > 0) {
26
+ return numbered;
27
+ }
28
+ const compactLines = lines.filter((line) => !/^目标[::]?$/.test(line));
29
+ const looseSteps = compactLines
30
+ .flatMap((line) => line.split(/(?<=[。;;])\s*/))
31
+ .map((line) => line.replace(/[。;;]$/g, '').trim())
32
+ .filter(Boolean)
33
+ .flatMap((line) => splitCompoundSelectableStep(line));
34
+ if (looseSteps.length > 1) {
35
+ const actionable = looseSteps.filter((step) => parseDeterministicAction(step) || isVerificationStep(step));
36
+ return actionable.length > 0 ? actionable : looseSteps;
37
+ }
38
+ const compact = compactLines
39
+ .filter((line) => !/^目标[::]?$/.test(line))
40
+ .join('\n')
41
+ .trim();
42
+ return compact ? [compact] : [];
43
+ }
44
+ /** 将“字段选择 A 和 B”拆成两条独立选择步骤,避免只执行最后一个选项。 */
45
+ function splitCompoundSelectableStep(instruction) {
46
+ const verb = instruction.match(SELECTABLE_VERB_RE);
47
+ const quoted = extractQuotedSegments(instruction);
48
+ if (!verb || quoted.length < 2) {
49
+ return [instruction];
50
+ }
51
+ const field = parseSelectableFieldName(instruction, quoted[0]);
52
+ if (!field) {
53
+ return [instruction];
54
+ }
55
+ const betweenOptions = instruction.slice(quoted[0].index + quoted[0].value.length, quoted[1].index);
56
+ if (!/[和及、,,]/.test(betweenOptions)) {
57
+ return [instruction];
58
+ }
59
+ return quoted.map((segment) => `${field}选择“${segment.value}”`);
60
+ }
61
+ /** 从自然语言步骤中提炼结构化动作,供确定性执行层消费。 */
62
+ export function parseDeterministicAction(instruction) {
63
+ const normalized = cleanInstructionPrefix(normalizeBrowserOptFlowText(instruction));
64
+ const url = normalized.match(URL_RE)?.[0];
65
+ if (url && /访问|打开|open|goto|navigate/i.test(normalized)) {
66
+ return { type: 'open', url };
67
+ }
68
+ if (isInspectInstruction(normalized)) {
69
+ return { type: 'inspect' };
70
+ }
71
+ if (/handoff|人工|手动|操作人员/.test(normalized) && /上传|选择.*(?:图片|文件|封面)/.test(normalized)) {
72
+ return { type: 'handoff', message: normalized };
73
+ }
74
+ if (url && isUploadInstruction(normalized)) {
75
+ return {
76
+ type: 'upload',
77
+ field: parseUploadFieldName(normalized) ?? '文件',
78
+ source: url,
79
+ };
80
+ }
81
+ const fillVerb = normalized.match(/输入|填写|填入|type|fill/i);
82
+ const quoted = extractQuotedSegments(normalized);
83
+ if (fillVerb && quoted.length > 0) {
84
+ const valueSegment = quoted[quoted.length - 1];
85
+ const fieldSegment = quoted.find((segment) => segment.index < (fillVerb.index ?? 0));
86
+ return {
87
+ type: 'fill',
88
+ field: fieldSegment?.value ?? parseFieldName(normalized) ?? '文本',
89
+ value: valueSegment?.value ?? '',
90
+ };
91
+ }
92
+ if (/点击|单击|click|tap|press/i.test(normalized)) {
93
+ const clickTarget = parseClickTarget(normalized);
94
+ const target = clickTarget?.target
95
+ ?? normalized
96
+ .replace(/点击|单击|click|tap|press/gi, '')
97
+ .replace(/[。;,,]/g, '')
98
+ .trim();
99
+ const action = { type: 'click', target: target || '按钮' };
100
+ if (clickTarget?.field) {
101
+ action.field = clickTarget.field;
102
+ }
103
+ return action;
104
+ }
105
+ const selectableTarget = parseSelectableTarget(normalized);
106
+ if (selectableTarget) {
107
+ return {
108
+ type: 'select-option',
109
+ field: selectableTarget.field,
110
+ option: selectableTarget.option,
111
+ };
112
+ }
113
+ const expectedText = parseExpectedText(normalized);
114
+ if (expectedText && isVerificationStep(normalized)) {
115
+ return { type: 'assert-text', text: expectedText };
116
+ }
117
+ return null;
118
+ }
119
+ /** 识别明确要求打开当前页面开发者工具的中英文表达,避免把普通“检查页面”误判为 inspect。 */
120
+ function isInspectInstruction(instruction) {
121
+ if (/^inspect(?:\s+(?:the\s+)?(?:current\s+)?page)?[。.!!]?$/i.test(instruction)) {
122
+ return true;
123
+ }
124
+ return /开发者工具|开发人员工具|(?:chrome\s*)?devtools/i.test(instruction)
125
+ && /打开|启动|调起|唤起|显示|open|launch|start/i.test(instruction);
126
+ }
127
+ /** 将 CLI 或上层封装传入的字面量换行还原,避免 "\\n2." 被当作字段名。 */
128
+ function normalizeBrowserOptFlowText(text) {
129
+ return text.replace(/\\r\\n|\\n|\\r/g, '\n');
130
+ }
131
+ /** 清理步骤前缀中的编号和多余空白,让动作解析只面对业务语义。 */
132
+ function cleanInstructionPrefix(instruction) {
133
+ return instruction
134
+ .replace(/^\s*(?:目标[::]\s*)?\d+[\.)、]\s*/, '')
135
+ .trim();
136
+ }
137
+ /** 识别带远程 URL 的图片/文件上传描述,兼容省略“上传”动词的口语写法。 */
138
+ function isUploadInstruction(instruction) {
139
+ return /上传|upload/i.test(instruction)
140
+ || /(?:图片|文件|封面)\s*(?:来源|地址|链接|url)\s*(?:为|是|:|:)?/i.test(instruction)
141
+ || /(?:来源|地址|链接|url)\s*(?:为|是|:|:)?\s*https?:\/\/.+(?:图片|文件|封面)/i.test(instruction)
142
+ || /(?:使用|用|从)\s*https?:\/\/.+(?:作为|当作|设置为|设为).*(?:图片|文件|封面)/i.test(instruction);
143
+ }
144
+ /** 从上传步骤中提取字段名,优先识别引号中的控件名称。 */
145
+ function parseUploadFieldName(instruction) {
146
+ const quoted = instruction.match(/["“‘']([^"”’']+)["”’']/)?.[1]?.trim();
147
+ if (quoted && !URL_RE.test(quoted)) {
148
+ return quoted;
149
+ }
150
+ const beforeSource = instruction.split(URL_RE)[0]?.trim() ?? instruction;
151
+ const afterSource = instruction.split(URL_RE).slice(1).join(' ').trim();
152
+ const candidates = [
153
+ beforeSource.match(/(?:自动)?上传\s*([^,,。;\n]+?)(?:,|,|图片来源|文件来源|来源|地址|链接|url|$)/i)?.[1],
154
+ beforeSource.match(/^([^,,。;\n]+?)(?:,|,)?\s*(?:图片|文件|封面)\s*(?:来源|地址|链接|url)\s*(?:为|是|:|:)?/i)?.[1],
155
+ beforeSource.match(/(?:为|给|将|把)\s*([^,,。;\n]+?)\s*(?:使用|用|从)?\s*$/i)?.[1],
156
+ afterSource.match(/(?:作为|当作|设置为|设为)\s*([^,,。;\n]+?)(?:图片|文件|封面)?(?:,|,|。|;|$)/i)?.[1],
157
+ ];
158
+ for (const candidate of candidates) {
159
+ const cleaned = cleanUploadFieldName(candidate ?? '');
160
+ if (cleaned) {
161
+ return cleaned;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ /** 清理上传字段名外围的动作词和来源提示,保留页面控件文案。 */
167
+ function cleanUploadFieldName(value) {
168
+ const cleaned = value
169
+ .replace(/^["“‘']|["”’']$/g, '')
170
+ .replace(/^(请|自动|帮我|给我|将|把|为|给)\s*/, '')
171
+ .replace(/(?:的)?(?:图片|文件|封面)?\s*(?:来源|地址|链接|url)\s*(?:为|是|:|:)?$/i, '')
172
+ .replace(/(封面)(?:图片|图)$/i, '$1')
173
+ .replace(/[::,,。;]$/g, '')
174
+ .trim();
175
+ return cleaned || null;
176
+ }
177
+ /** 从“字段输入值”类语句中提取字段名,兼容常见中文口语写法。 */
178
+ function parseFieldName(instruction) {
179
+ const beforeVerb = instruction.match(/^(.+?)(?:输入|填写|填入|type|fill)/i)?.[1]?.trim();
180
+ if (beforeVerb) {
181
+ const cleaned = beforeVerb.replace(/^(在|向|给)\s*/, '').replace(/[::,,。;]$/g, '').trim();
182
+ if (cleaned) {
183
+ return cleaned;
184
+ }
185
+ }
186
+ const afterVerb = instruction.match(/(?:在|向|给)\s*([^,,。;\n]+?)(?:中|里|内)?(?:输入|填写|填入)/i)?.[1]?.trim();
187
+ return afterVerb || null;
188
+ }
189
+ /** 从“字段选择选项”类语句中同时提取字段名和选项,避免把字段误当成选项。 */
190
+ function parseSelectableTarget(instruction) {
191
+ const verb = instruction.match(SELECTABLE_VERB_RE);
192
+ if (!verb) {
193
+ return parseLooseSelectableTarget(instruction);
194
+ }
195
+ const quoted = extractQuotedSegments(instruction);
196
+ if (quoted.length >= 2) {
197
+ return {
198
+ field: quoted[0].value,
199
+ option: quoted[1].value,
200
+ };
201
+ }
202
+ const quotedValueIsField = quoted[0] && quoted[0].index < (verb.index ?? 0);
203
+ const option = quotedValueIsField ? parseUnquotedSelectableOption(instruction) : quoted[0]?.value ?? parseUnquotedSelectableOption(instruction);
204
+ if (!option) {
205
+ return null;
206
+ }
207
+ return {
208
+ field: quotedValueIsField ? cleanSelectableText(quoted[0].value) : parseSelectableFieldName(instruction, quoted[0]) ?? null,
209
+ option,
210
+ };
211
+ }
212
+ /** 提取中英文引号里的片段,并保留位置供字段名解析避开选项片段。 */
213
+ function extractQuotedSegments(instruction) {
214
+ const matches = instruction.matchAll(/["“‘']([^"”’']+)["”’']/g);
215
+ return [...matches]
216
+ .map((match) => ({ value: match[1]?.trim() ?? '', index: match.index ?? 0 }))
217
+ .filter((segment) => Boolean(segment.value));
218
+ }
219
+ /** 从点击语句中提取真实点击目标,多引号场景优先识别“字段下方的入口”这类入口文案。 */
220
+ function parseClickTarget(instruction) {
221
+ const quoted = extractQuotedSegments(instruction);
222
+ if (quoted.length === 0) {
223
+ return null;
224
+ }
225
+ if (quoted.length === 1) {
226
+ return { target: quoted[0].value };
227
+ }
228
+ const lastQuoted = quoted[quoted.length - 1];
229
+ if (!lastQuoted) {
230
+ return quoted[0] ? { target: quoted[0].value } : null;
231
+ }
232
+ const lastQuotedEnd = lastQuoted.index + lastQuoted.value.length + 2;
233
+ const beforeLastQuoted = instruction.slice(0, lastQuoted.index);
234
+ const afterLastQuoted = instruction.slice(lastQuotedEnd);
235
+ if (/(?:下方|上方|左侧|右侧|旁边|附近|内部|里面|里|中|内|显示|文案|占位|字段|区域)的?$/.test(beforeLastQuoted)
236
+ || /^(?:入口|按钮|控件|区域|输入区域|输入框|下拉框|下拉入口|面板|弹窗|选项)/.test(afterLastQuoted)) {
237
+ return { target: lastQuoted.value, field: quoted[0]?.value ?? null };
238
+ }
239
+ return quoted[0] ? { target: quoted[0].value } : null;
240
+ }
241
+ /** 从没有引号的选择语句中提取目标选项,作为口语化步骤的兜底。 */
242
+ function parseUnquotedSelectableOption(instruction) {
243
+ const afterVerb = instruction.match(new RegExp(`(?:${SELECTABLE_VERB_RE.source})\\s*([^,,。;\\n]+)`, 'i'))?.[1]?.trim();
244
+ if (!afterVerb) {
245
+ return null;
246
+ }
247
+ return cleanSelectableText(afterVerb.replace(/^(为|成|到)\s*/, ''));
248
+ }
249
+ /** 从“字段选择选项”类语句中提取字段名,支持单选、多选和开关式配置。 */
250
+ function parseSelectableFieldName(instruction, quotedOption) {
251
+ const beforeVerb = instruction.match(new RegExp(`^(.+?)(?:${SELECTABLE_VERB_RE.source})`, 'i'))?.[1]?.trim();
252
+ if (beforeVerb) {
253
+ const cleaned = cleanSelectableText(beforeVerb.replace(/^(将|把|在|为|给)\s*/, ''));
254
+ if (cleaned) {
255
+ return cleaned;
256
+ }
257
+ }
258
+ const beforeQuotedOption = quotedOption ? instruction.slice(0, quotedOption.index) : instruction;
259
+ const afterVerb = beforeQuotedOption.match(new RegExp(`(?:在|为|给)\\s*([^,,。;\\n]+?)(?:中|里|内)?(?:${SELECTABLE_VERB_RE.source})`, 'i'))?.[1]?.trim();
260
+ return cleanSelectableText(afterVerb ?? '');
261
+ }
262
+ /** 为不完全命中标准动词的选择语句提供保守兜底,尽量提炼出“字段 -> 值”。 */
263
+ function parseLooseSelectableTarget(instruction) {
264
+ const looseVerb = instruction.match(/(.+?)(?:改为|改成|调整为|调整成|设为|设成|置为|置成|变更为|变更成|变为|变成|切到)\s*([^,,。;\n]+)/i);
265
+ if (!looseVerb?.[1] || !looseVerb[2]) {
266
+ return null;
267
+ }
268
+ const field = cleanSelectableText(looseVerb[1].replace(/^(将|把|在|为|给)\s*/, ''));
269
+ const option = cleanSelectableText(looseVerb[2]);
270
+ return field && option ? { field, option } : null;
271
+ }
272
+ /** 清理字段名和选项值外围的语气词、引号和标点,保留真实业务文案。 */
273
+ function cleanSelectableText(value) {
274
+ const cleaned = value
275
+ .replace(/^\s*(?:目标[::]\s*)?\d+[\.)、]\s*/, '')
276
+ .replace(/^["“‘']|["”’']$/g, '')
277
+ .replace(/^(为|成|到)\s*/, '')
278
+ .replace(/[::,,。;]$/g, '')
279
+ .trim();
280
+ return cleaned || null;
281
+ }
282
+ /** 在当前快照中优先查找文本框,再按字段名做最佳匹配。 */
283
+ export function findTextboxRef(snapshot, field) {
284
+ const nodes = getSnapshotNodes(snapshot).filter((node) => isTextboxRole(node.role));
285
+ const matchedRef = findBestNodeRef(nodes, field);
286
+ if (matchedRef || isGenericTextboxField(field)) {
287
+ return matchedRef ?? nodes[0]?.ref ?? null;
288
+ }
289
+ return null;
290
+ }
291
+ /** 在当前快照中查找可点击元素,并按目标文案做最佳匹配。 */
292
+ export function findClickableRef(snapshot, target, field) {
293
+ const nodes = getSnapshotNodes(snapshot).filter((node) => !node.disabled && !isTextboxRole(node.role) && isClickableNode(node));
294
+ if (field) {
295
+ return findFollowingClickableRef(snapshot.text, field, target) ?? findUniqueNodeRef(nodes, target);
296
+ }
297
+ return findBestNodeRef(nodes, target) ?? findFollowingClickableRef(snapshot.text, target);
298
+ }
299
+ /** 查找单选或多选选项,优先点击 label,并在已选中时短路为完成。 */
300
+ export function findSelectableOption(snapshot, field, option) {
301
+ const scoped = findScopedOptionLabel(snapshot.text, field, option);
302
+ if (scoped) {
303
+ return scoped;
304
+ }
305
+ const nodes = getSnapshotNodes(snapshot);
306
+ const switchControl = findScopedSwitchControl(snapshot.text, field, nodes, option);
307
+ if (switchControl) {
308
+ return switchControl;
309
+ }
310
+ const selectable = findBestNode(nodes.filter((node) => isSelectableRole(node.role)), option);
311
+ if (selectable?.checked) {
312
+ return { ref: selectable.ref, alreadySelected: true, role: selectable.role };
313
+ }
314
+ const label = findBestNode(nodes.filter((node) => isClickableLabelRole(node.role)), option);
315
+ if (label) {
316
+ return { ref: label.ref, alreadySelected: false, role: label.role };
317
+ }
318
+ if (selectable && !selectable.disabled) {
319
+ return { ref: selectable.ref, alreadySelected: false, role: selectable.role };
320
+ }
321
+ return { ref: null, alreadySelected: false, role: null };
322
+ }
323
+ /** 查找可展开选择项的字段控件,供下拉选项尚未渲染时先打开选项面板。 */
324
+ export function findSelectableFieldRef(snapshot, field) {
325
+ if (!field) {
326
+ return null;
327
+ }
328
+ const scoped = findScopedSelectableFieldRef(snapshot.text, field);
329
+ if (scoped) {
330
+ return scoped;
331
+ }
332
+ const nodes = getSnapshotNodes(snapshot).filter((node) => isExpandableSelectRole(node.role));
333
+ return findBestNodeRef(nodes, field);
334
+ }
335
+ /** 查找上传控件,只返回快照中可确认的真实文件输入,避免把上传按钮误当成 input。 */
336
+ export function findUploadRef(snapshot, field) {
337
+ const nodes = getSnapshotNodes(snapshot);
338
+ const fileInputs = nodes.filter((node) => isFileInputRole(node.role, node.label));
339
+ return findBestNodeRef(fileInputs, field) ?? fileInputs[0]?.ref ?? null;
340
+ }
341
+ /** 在候选节点集合里做一次精确优先、字符兜底的模糊匹配。 */
342
+ function findBestNodeRef(nodes, target) {
343
+ return findBestNode(nodes, target)?.ref ?? null;
344
+ }
345
+ /** 只有目标文案在可点击节点中唯一出现时才兜底,避免多个“请选择”时误点第一个。 */
346
+ function findUniqueNodeRef(nodes, target) {
347
+ const matches = findMatchingNodes(nodes, target);
348
+ return matches.length === 1 ? matches[0]?.ref ?? null : null;
349
+ }
350
+ /** 在候选节点集合里返回最佳匹配节点,供需要读取选中状态的场景复用。 */
351
+ function findBestNode(nodes, target) {
352
+ return findMatchingNodes(nodes, target)[0] ?? null;
353
+ }
354
+ /** 返回所有匹配目标文案的候选节点,保持 snapshot 原始顺序。 */
355
+ function findMatchingNodes(nodes, target) {
356
+ const normalizedTarget = normalizeMatchText(target);
357
+ if (!normalizedTarget) {
358
+ return [];
359
+ }
360
+ const exact = nodes.filter((node) => {
361
+ const label = normalizeMatchText(node.label);
362
+ if (!label) {
363
+ return false;
364
+ }
365
+ return label.includes(normalizedTarget) || normalizedTarget.includes(label);
366
+ });
367
+ if (exact.length > 0) {
368
+ return exact.sort((a, b) => rankNodeMatch(a, normalizedTarget) - rankNodeMatch(b, normalizedTarget));
369
+ }
370
+ const targetChars = [...normalizedTarget];
371
+ return nodes.filter((node) => {
372
+ const label = normalizeMatchText(node.label);
373
+ if (!label) {
374
+ return false;
375
+ }
376
+ return targetChars.length > 1 && targetChars.every((char) => label.includes(char));
377
+ }).sort((a, b) => rankNodeMatch(a, normalizedTarget) - rankNodeMatch(b, normalizedTarget));
378
+ }
379
+ /** 匹配多个节点时优先选更具体的短标签,避免点到包含整段弹窗文本的大容器。 */
380
+ function rankNodeMatch(node, normalizedTarget) {
381
+ const label = normalizeMatchText(node.label);
382
+ const lengthPenalty = Math.max(0, label.length - normalizedTarget.length);
383
+ const exactBonus = label === normalizedTarget ? -1000 : 0;
384
+ return exactBonus + lengthPenalty;
385
+ }
386
+ /** 把 JSON refs 和 snapshot 文本中的节点信息统一归并成可匹配的节点列表。 */
387
+ function getSnapshotNodes(snapshot) {
388
+ const refs = findObjectProperty(snapshot.output.data, 'refs');
389
+ const nodes = [];
390
+ if (refs) {
391
+ for (const [ref, value] of Object.entries(refs)) {
392
+ if (!value || typeof value !== 'object') {
393
+ continue;
394
+ }
395
+ const role = findFirstStringProperty(value, ['role', 'type']) ?? '';
396
+ const label = findFirstStringProperty(value, ['name', 'label', 'text', 'title', 'placeholder', 'ariaLabel', 'value']) ?? '';
397
+ nodes.push({ ref, role, label });
398
+ }
399
+ }
400
+ const fromText = snapshot.text
401
+ .split('\n')
402
+ .map((line) => parseSnapshotLine(line))
403
+ .filter((node) => Boolean(node));
404
+ return mergeSnapshotNodes([...nodes, ...fromText]);
405
+ }
406
+ /** 从 snapshot 的单行文本中解析出一个节点定义,作为 refs 缺失时的兜底来源。 */
407
+ function parseSnapshotLine(line) {
408
+ const match = line.match(/-\s*([A-Za-z]+)\s+"([^"]*)"\s+\[([^\]]+)\]/);
409
+ const ref = match?.[3]?.match(/(?:^|,\s*)ref=([^,\]\s]+)/)?.[1];
410
+ if (match?.[1] && ref) {
411
+ return {
412
+ role: match[1],
413
+ label: match[2] ?? '',
414
+ ref,
415
+ clickable: /(?:^|,\s*)clickable(?:,|$)/.test(match[3]) || /\sclickable(?:\s|$|\[)/.test(line),
416
+ checked: /(?:^|,\s*)checked=true(?:,|$)/.test(match[3]),
417
+ disabled: /(?:^|,\s*)disabled(?:,|$)/.test(match[3]),
418
+ };
419
+ }
420
+ return null;
421
+ }
422
+ /** 文案本身不可点时,在其后续兄弟或子节点中寻找第一个可点击元素。 */
423
+ function findFollowingClickableRef(text, anchorTarget, followingTarget) {
424
+ const normalizedAnchorTarget = normalizeMatchText(anchorTarget);
425
+ if (!normalizedAnchorTarget) {
426
+ return null;
427
+ }
428
+ const lines = parseSnapshotTextLines(text);
429
+ for (const line of lines) {
430
+ const label = normalizeMatchText(line.node.label);
431
+ if (!label || !label.includes(normalizedAnchorTarget)) {
432
+ continue;
433
+ }
434
+ const following = findFirstFollowingClickableLine(lines, line, followingTarget);
435
+ if (following) {
436
+ return following.node.ref;
437
+ }
438
+ }
439
+ return null;
440
+ }
441
+ /** 解析带缩进的 snapshot 行,供局部邻近匹配保持在同一结构块内。 */
442
+ function parseSnapshotTextLines(text) {
443
+ return text
444
+ .split('\n')
445
+ .map((line, index) => {
446
+ const node = parseSnapshotLine(line);
447
+ if (!node) {
448
+ return null;
449
+ }
450
+ return {
451
+ index,
452
+ indent: line.match(/^\s*/)?.[0].length ?? 0,
453
+ node,
454
+ };
455
+ })
456
+ .filter((line) => Boolean(line));
457
+ }
458
+ /** 从目标文案之后按文本顺序查找同级或子级 clickable,越过目标结构块后停止。 */
459
+ function findFirstFollowingClickableLine(lines, anchor, followingTarget) {
460
+ const normalizedFollowingTarget = normalizeMatchText(followingTarget ?? '');
461
+ for (const line of lines) {
462
+ if (line.index <= anchor.index) {
463
+ continue;
464
+ }
465
+ if (line.indent < anchor.indent) {
466
+ break;
467
+ }
468
+ if (isClickableNode(line.node) && !isTextboxRole(line.node.role) && matchesOptionalTarget(line.node, normalizedFollowingTarget)) {
469
+ return line;
470
+ }
471
+ if (line.indent === anchor.indent) {
472
+ break;
473
+ }
474
+ }
475
+ return null;
476
+ }
477
+ /** 带上下文点击时,需要后续可点击节点匹配入口文案;未指定入口时接受第一个。 */
478
+ function matchesOptionalTarget(node, normalizedTarget) {
479
+ if (!normalizedTarget) {
480
+ return true;
481
+ }
482
+ const label = normalizeMatchText(node.label);
483
+ return Boolean(label) && (label.includes(normalizedTarget) || normalizedTarget.includes(label));
484
+ }
485
+ /** 以 ref 合并节点,保留 JSON refs 的基础信息和文本快照里的状态属性。 */
486
+ function mergeSnapshotNodes(nodes) {
487
+ const merged = new Map();
488
+ for (const node of nodes) {
489
+ const current = merged.get(node.ref);
490
+ if (!current) {
491
+ merged.set(node.ref, node);
492
+ continue;
493
+ }
494
+ merged.set(node.ref, {
495
+ ref: node.ref,
496
+ role: current.role || node.role,
497
+ label: current.label || node.label,
498
+ clickable: current.clickable ?? node.clickable,
499
+ checked: current.checked ?? node.checked,
500
+ disabled: current.disabled ?? node.disabled,
501
+ });
502
+ }
503
+ return [...merged.values()];
504
+ }
505
+ /** 判断某个角色是否可以视为文本输入控件。 */
506
+ function isTextboxRole(role) {
507
+ return /textbox|input|searchbox|combobox|textarea/i.test(role);
508
+ }
509
+ /** 判断节点是否可直接点击,refs 缺少 clickable 时按常见交互角色保守推断。 */
510
+ function isClickableNode(node) {
511
+ return Boolean(node.clickable) || /button|link|menuitem|tab|option|radio|checkbox|switch|labeltext|label/i.test(node.role);
512
+ }
513
+ /** 识别 radio、checkbox 等可选择控件。 */
514
+ function isSelectableRole(role) {
515
+ return /radio|checkbox|switch|option/i.test(role);
516
+ }
517
+ /** 识别可展开的选择字段,如 select、combobox 以及常见伪下拉按钮。 */
518
+ function isExpandableSelectRole(role) {
519
+ return /combobox|select|listbox|button/i.test(role);
520
+ }
521
+ /** 单独识别 switch,便于按布尔目标状态切换。 */
522
+ function isSwitchRole(role) {
523
+ return /switch/i.test(role);
524
+ }
525
+ /** 识别常见 UI 库中包裹 radio/checkbox 的可点击 label。 */
526
+ function isClickableLabelRole(role) {
527
+ return /labeltext|label/i.test(role);
528
+ }
529
+ /** 只有用户没有给出具体字段名时,才允许回退到页面上的第一个输入框。 */
530
+ function isGenericTextboxField(field) {
531
+ return /^(文本|内容|输入框|搜索框|textbox|input|search)$/i.test(field.trim());
532
+ }
533
+ /** 识别 snapshot 中可能代表文件上传的输入控件。 */
534
+ function isFileInputRole(role, label) {
535
+ return /file/i.test(role) || (/input/i.test(role) && /上传|upload|选择文件|choose\s+file/i.test(label));
536
+ }
537
+ /** 归一化待匹配文本,减少空格和中英文标点对匹配结果的干扰。 */
538
+ function normalizeMatchText(value) {
539
+ return value.toLowerCase().replace(/\s+/g, '').replace(/[::,,。;"'‘’“”]/g, '').trim();
540
+ }
541
+ /** 识别“是/否、开/关”这类布尔型目标值,供 switch 切换场景复用。 */
542
+ function isBooleanLikeOption(option) {
543
+ return /^(是|否|开|关|开启|关闭|打开|启用|停用|true|false|yes|no|on|off)$/i.test(option.trim());
544
+ }
545
+ /** 将自然语言里的开关目标状态归一化为 checked 布尔值。 */
546
+ function inferDesiredChecked(option) {
547
+ return /^(是|开|开启|打开|启用|true|yes|on)$/i.test(option.trim());
548
+ }
549
+ /** 基于文本快照顺序,在同一组选项附近寻找目标选项,弥补 refs 缺少字段名的问题。 */
550
+ function findScopedOptionLabel(text, field, option) {
551
+ const lines = text.split('\n');
552
+ const normalizedField = normalizeMatchText(field ?? '');
553
+ const normalizedOption = normalizeMatchText(option);
554
+ const fieldIndex = normalizedField
555
+ ? lines.findIndex((line) => normalizeMatchText(line).includes(normalizedField))
556
+ : -1;
557
+ const startIndex = fieldIndex >= 0 ? fieldIndex + 1 : 0;
558
+ const endIndex = fieldIndex >= 0 ? findSelectableScopeEnd(lines, startIndex) : lines.length;
559
+ for (let index = startIndex; index < endIndex; index += 1) {
560
+ const parsed = parseSnapshotLine(lines[index]);
561
+ if (!parsed) {
562
+ continue;
563
+ }
564
+ if (isClickableLabelRole(parsed.role) && normalizeMatchText(parsed.label).includes(normalizedOption)) {
565
+ const child = parseSnapshotLine(lines[index + 1] ?? '');
566
+ if (child?.disabled && !child.checked) {
567
+ index += 1;
568
+ continue;
569
+ }
570
+ return {
571
+ ref: child && !child.disabled ? child.ref : parsed.ref,
572
+ alreadySelected: child?.checked ?? false,
573
+ role: child?.role ?? parsed.role,
574
+ };
575
+ }
576
+ if (isSelectableRole(parsed.role) && normalizeMatchText(parsed.label).includes(normalizedOption)) {
577
+ if (parsed.disabled && !parsed.checked) {
578
+ continue;
579
+ }
580
+ return {
581
+ ref: parsed.ref,
582
+ alreadySelected: parsed.checked ?? false,
583
+ role: parsed.role,
584
+ };
585
+ }
586
+ }
587
+ return null;
588
+ }
589
+ /** 针对单个 switch 控件,按目标状态判断是否需要点击,而不是要求页面出现同名选项文案。 */
590
+ function findScopedSwitchControl(text, field, nodes, option) {
591
+ if (!field || !isBooleanLikeOption(option)) {
592
+ return null;
593
+ }
594
+ const lines = text.split('\n');
595
+ const normalizedField = normalizeMatchText(field);
596
+ const fieldIndex = normalizedField
597
+ ? lines.findIndex((line) => normalizeMatchText(line).includes(normalizedField))
598
+ : -1;
599
+ if (fieldIndex < 0) {
600
+ return null;
601
+ }
602
+ const desiredChecked = inferDesiredChecked(option);
603
+ const endIndex = findSelectableScopeEnd(lines, fieldIndex + 1);
604
+ for (let index = fieldIndex; index < endIndex; index += 1) {
605
+ const parsed = parseSnapshotLine(lines[index]);
606
+ if (!parsed || !isSwitchRole(parsed.role)) {
607
+ continue;
608
+ }
609
+ const merged = nodes.find((node) => node.ref === parsed.ref) ?? parsed;
610
+ return {
611
+ ref: merged.ref,
612
+ alreadySelected: merged.checked === desiredChecked,
613
+ role: merged.role,
614
+ };
615
+ }
616
+ return null;
617
+ }
618
+ /** 在字段文案后面的短范围内寻找下拉控件,适配字段名和控件值分离的表单布局。 */
619
+ function findScopedSelectableFieldRef(text, field) {
620
+ const lines = text.split('\n');
621
+ const normalizedField = normalizeMatchText(field);
622
+ const fieldIndex = lines.findIndex((line) => normalizeMatchText(line).includes(normalizedField));
623
+ if (fieldIndex < 0) {
624
+ return null;
625
+ }
626
+ for (let index = fieldIndex + 1; index < Math.min(lines.length, fieldIndex + 6); index += 1) {
627
+ const parsed = parseSnapshotLine(lines[index]);
628
+ if (parsed && isExpandableSelectRole(parsed.role) && !parsed.disabled) {
629
+ return parsed.ref;
630
+ }
631
+ }
632
+ return null;
633
+ }
634
+ /** 字段命中后只在紧邻的可选择控件区域内查找,遇到下一个非选项节点即停止。 */
635
+ function findSelectableScopeEnd(lines, startIndex) {
636
+ let hasSeenOption = false;
637
+ for (let index = startIndex; index < lines.length; index += 1) {
638
+ const parsed = parseSnapshotLine(lines[index]);
639
+ if (!parsed) {
640
+ continue;
641
+ }
642
+ if (isClickableLabelRole(parsed.role) || isSelectableRole(parsed.role)) {
643
+ hasSeenOption = true;
644
+ continue;
645
+ }
646
+ if (hasSeenOption) {
647
+ return index;
648
+ }
649
+ }
650
+ return lines.length;
651
+ }
652
+ /** 在未知深度的对象里递归查找首个可用字符串字段。 */
653
+ function findFirstStringProperty(value, keys) {
654
+ if (!value || typeof value !== 'object') {
655
+ return null;
656
+ }
657
+ const record = value;
658
+ for (const key of keys) {
659
+ if (typeof record[key] === 'string') {
660
+ return record[key];
661
+ }
662
+ }
663
+ for (const entry of Object.values(record)) {
664
+ const found = findFirstStringProperty(entry, keys);
665
+ if (found) {
666
+ return found;
667
+ }
668
+ }
669
+ return null;
670
+ }
671
+ /** 判断一条自然语言步骤是否主要承担验证职责。 */
672
+ export function isVerificationStep(instruction) {
673
+ return /验证|断言|检查|verify|assert|expect|should|包含|存在|至少\s*\d+/i.test(instruction);
674
+ }
675
+ /** 对步骤执行后的页面状态做验证,生成可直接写入报告的结果说明。 */
676
+ export function verifyStep(instruction, snapshot) {
677
+ if (!isVerificationStep(instruction)) {
678
+ return { passed: true, message: '非验证步骤,已完成动作并重新 snapshot。' };
679
+ }
680
+ const atLeastCount = parseAtLeastCount(instruction);
681
+ if (atLeastCount !== null) {
682
+ const passed = snapshot.nodeCount >= atLeastCount;
683
+ return {
684
+ passed,
685
+ message: passed
686
+ ? `元素数量 ${snapshot.nodeCount} >= ${atLeastCount}`
687
+ : `元素数量 ${snapshot.nodeCount} < ${atLeastCount}`,
688
+ };
689
+ }
690
+ const expectedText = parseExpectedText(instruction);
691
+ if (expectedText) {
692
+ const passed = normalizedIncludes(snapshot.text, expectedText);
693
+ return {
694
+ passed,
695
+ message: passed
696
+ ? `页面包含文本:${expectedText}`
697
+ : `页面未包含文本:${expectedText}`,
698
+ };
699
+ }
700
+ if (/存在|visible|exists?|出现/i.test(instruction)) {
701
+ return snapshot.nodeCount > 0
702
+ ? { passed: true, message: `存在可交互元素,数量:${snapshot.nodeCount}` }
703
+ : { passed: false, message: '未发现可交互元素。' };
704
+ }
705
+ return snapshot.text.trim()
706
+ ? { passed: true, message: '验证步骤已执行,页面 snapshot 非空。' }
707
+ : { passed: false, message: '验证步骤执行后页面 snapshot 为空。' };
708
+ }
709
+ /** 从自然语言断言中提取“至少 N 个元素”这类数量约束。 */
710
+ function parseAtLeastCount(instruction) {
711
+ const match = instruction.match(/(?:至少|不少于|>=|at\s+least)\s*(\d+)/i);
712
+ return match?.[1] ? Number(match[1]) : null;
713
+ }
714
+ /** 从自然语言断言中提取预期文本,兼容引号和“包含/显示”类说法。 */
715
+ function parseExpectedText(instruction) {
716
+ const quoted = extractQuotedSegments(instruction);
717
+ const lastQuoted = quoted[quoted.length - 1];
718
+ if (lastQuoted) {
719
+ return lastQuoted.value;
720
+ }
721
+ const contains = instruction.match(/(?:包含|看到|显示|contains?|include[s]?)\s*([^。;\n]+)/i);
722
+ if (contains?.[1]) {
723
+ return contains[1]
724
+ .replace(/至少\s*\d+.*$/, '')
725
+ .replace(/^["“‘']|["”’']$/g, '')
726
+ .trim();
727
+ }
728
+ return null;
729
+ }
730
+ /** 用大小写不敏感的方式比较文本包含关系。 */
731
+ function normalizedIncludes(source, expected) {
732
+ return source.toLowerCase().includes(expected.toLowerCase());
733
+ }
734
+ /** 优先从 JSON 结构提取 snapshot 文本,提取不到时回退到原始输出。 */
735
+ export function snapshotText(output) {
736
+ if (typeof output.data === 'object' && output.data !== null) {
737
+ const snapshot = findStringProperty(output.data, 'snapshot');
738
+ if (snapshot) {
739
+ return snapshot;
740
+ }
741
+ }
742
+ return output.raw;
743
+ }
744
+ /** 在任意嵌套对象中递归查找指定 key 对应的字符串值。 */
745
+ function findStringProperty(value, key) {
746
+ if (!value || typeof value !== 'object') {
747
+ return null;
748
+ }
749
+ const record = value;
750
+ if (typeof record[key] === 'string') {
751
+ return record[key];
752
+ }
753
+ for (const entry of Object.values(record)) {
754
+ const found = findStringProperty(entry, key);
755
+ if (found) {
756
+ return found;
757
+ }
758
+ }
759
+ return null;
760
+ }
761
+ /** 统计快照中的可引用节点数量,兼容 refs 和文本两种来源。 */
762
+ export function countSnapshotNodes(value) {
763
+ if (!value || typeof value !== 'object') {
764
+ return 0;
765
+ }
766
+ const record = value;
767
+ const refs = findObjectProperty(record, 'refs');
768
+ if (refs) {
769
+ return Object.keys(refs).length;
770
+ }
771
+ const snapshot = findStringProperty(record, 'snapshot');
772
+ if (snapshot) {
773
+ return snapshot.split('\n').filter((line) => /\[ref=|@[a-z]\d+/i.test(line)).length;
774
+ }
775
+ return 0;
776
+ }
777
+ /** 在任意嵌套对象中递归查找指定 key 对应的对象值。 */
778
+ function findObjectProperty(value, key) {
779
+ if (!value || typeof value !== 'object') {
780
+ return null;
781
+ }
782
+ const record = value;
783
+ if (record[key] && typeof record[key] === 'object' && !Array.isArray(record[key])) {
784
+ return record[key];
785
+ }
786
+ for (const entry of Object.values(record)) {
787
+ const found = findObjectProperty(entry, key);
788
+ if (found) {
789
+ return found;
790
+ }
791
+ }
792
+ return null;
793
+ }
794
+ /** 把 chat 的 JSON 结果整理成适合写进日志和报告的单段文本。 */
795
+ export function summarizeJsonResult(result) {
796
+ if (result.data !== null) {
797
+ return JSON.stringify(result.data);
798
+ }
799
+ return result.raw.trim();
800
+ }
801
+ /** 为日志生成一段简短的页面状态摘要,避免整份 snapshot 直接灌入日志。 */
802
+ export function summarizeSnapshot(snapshot) {
803
+ const text = snapshot.text.replace(/\s+/g, ' ').trim();
804
+ const preview = text.length > 160 ? `${text.slice(0, 160)}...` : text;
805
+ return `nodes=${snapshot.nodeCount}; text="${preview}"`;
806
+ }
807
+ /** 生成本次运行的输出目录,确保时间戳与流程名可以共同区分不同执行。 */
808
+ export function resolveOutputDir(flow, outputRoot, startedAt) {
809
+ const root = outputRoot ? path.resolve(outputRoot) : DEFAULT_OUTPUT_ROOT;
810
+ const timestamp = startedAt.toISOString().replace(/[:.]/g, '-');
811
+ return path.join(root, `${timestamp}-${slugify(flow)}`);
812
+ }
813
+ /** 把自然语言流程压缩成适合作为目录名的短 slug。 */
814
+ function slugify(value) {
815
+ const ascii = value
816
+ .replace(URL_RE, '')
817
+ .toLowerCase()
818
+ .replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, '-')
819
+ .replace(/^-+|-+$/g, '')
820
+ .slice(0, 40);
821
+ return ascii || 'browser-opt-flow';
822
+ }
823
+ /** 统一步骤级展示状态,避免人工接管在报告明细里继续显示成普通失败。 */
824
+ export function formatBrowserOptStepStatus(step) {
825
+ if (step.handoffTriggered) {
826
+ return 'HANDOFF';
827
+ }
828
+ return step.passed ? 'PASS' : 'FAIL';
829
+ }
830
+ /** 汇总普通失败步骤,供报告和 CLI 在流程结束后集中展示失败原因。 */
831
+ export function collectFailedBrowserOptSteps(report) {
832
+ return report.steps.filter((step) => !step.passed && !step.handoffTriggered);
833
+ }
834
+ /** 把执行报告渲染成 Markdown,方便直接在本地阅读和附带截图证据。 */
835
+ export function renderMarkdownReport(report) {
836
+ const failedSteps = collectFailedBrowserOptSteps(report);
837
+ const lines = [
838
+ `# Browser Opt Report: ${report.status}`,
839
+ '',
840
+ `- URL: ${report.url}`,
841
+ `- Started: ${report.startedAt}`,
842
+ `- Duration: ${report.durationMs}ms`,
843
+ `- Evidence directory: ${report.outputDir}`,
844
+ `- Log file: ${report.logPath}`,
845
+ '',
846
+ '## Evidence Screenshots',
847
+ ...report.screenshots.map((screenshot) => `- ${screenshot}`),
848
+ '',
849
+ '## Failed Steps',
850
+ ...(failedSteps.length > 0
851
+ ? failedSteps.map((step) => `- ${step.index}. ${step.instruction}: ${step.error ?? step.verification ?? '未知原因'}`)
852
+ : ['- n/a']),
853
+ '',
854
+ '## Steps',
855
+ ];
856
+ for (const step of report.steps) {
857
+ lines.push('', `### ${formatBrowserOptStepStatus(step)} ${step.index}. ${step.instruction}`, `- Attempts: ${step.attempts}`, `- Before screenshot: ${step.beforeScreenshotPath}`, `- After screenshot: ${step.afterScreenshotPath}`, `- Verification: ${step.verification ?? 'n/a'}`, `- Error: ${step.error ?? 'n/a'}`, '', '```text', ...step.logs, '```');
858
+ }
859
+ lines.push('', '## Detailed Logs', '```text', ...report.logs, '```', '');
860
+ return lines.join('\n');
861
+ }
862
+ /** 返回 browser-opt 的通用输入模板,供 CLI 报错和首轮使用提示复用。 */
863
+ export function browserOptTemplate() {
864
+ return `通用测试模板:
865
+ 你是一个专业的自动化测试 Agent 执行以下测试用例:
866
+
867
+ 网站:{URL}
868
+ 测试用例:{描述,如 "用户注册流程"}
869
+
870
+ 预期结果:
871
+ 1. {步骤1}
872
+ 2. {步骤2}
873
+ ...
874
+
875
+ 自然语言流程示例:
876
+ 测试 https://example.com 的搜索功能。
877
+
878
+ 目标:
879
+ 1. 打开首页。
880
+ 2. 在搜索框输入 "agent-browser"。
881
+ 3. 点击搜索按钮。
882
+ 4. 验证搜索结果页面是否包含至少 3 个结果项。
883
+ 5. 点击第一个结果,验证跳转正确。`;
884
+ }
885
+ //# sourceMappingURL=utils.js.map