@yule-h2o/web_auto_flow 0.0.5

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/index.js ADDED
@@ -0,0 +1,2491 @@
1
+ import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { Stagehand } from '@browserbasehq/stagehand';
4
+ import { chromium } from 'playwright-core';
5
+ import { pathToFileURL } from 'url';
6
+
7
+ // ============================================================
8
+ // Rule Engine — 选择器工具
9
+ // ============================================================
10
+ /**
11
+ * 标准化选择器:对常见简写做展开,确保跨浏览器兼容。
12
+ * - `#id` 保持不变
13
+ * - `.class` 保持不变
14
+ * - `tag` 保持不变
15
+ * - 含空格的复合选择器保持不变
16
+ */
17
+ function normalizeSelector(selector) {
18
+ return selector.trim();
19
+ }
20
+ /**
21
+ * 生成描述性的选择器标签(用于报告)
22
+ */
23
+ function selectorLabel(selector) {
24
+ const s = normalizeSelector(selector);
25
+ if (s.startsWith("#"))
26
+ return `#${s.slice(1)}`;
27
+ if (s.startsWith("."))
28
+ return `.${s.slice(1)}`;
29
+ if (s.startsWith("[") && s.includes("=")) {
30
+ const match = s.match(/\[(\w+)=["']?(.+?)["']?\]/);
31
+ if (match)
32
+ return `[${match[1]}="${match[2]}"]`;
33
+ }
34
+ if (s.includes(" ")) {
35
+ const parts = s.split(" ");
36
+ return parts.map((p) => selectorLabel(p)).join(" > ");
37
+ }
38
+ return s.length > 36 ? s.slice(0, 33) + "..." : s;
39
+ }
40
+ /**
41
+ * 安全获取 locator,捕获 Playwright 语法错误
42
+ */
43
+ function safeLocator(page, selector) {
44
+ try {
45
+ return page.locator(normalizeSelector(selector));
46
+ }
47
+ catch {
48
+ throw new Error(`无效选择器: "${selector}"`);
49
+ }
50
+ }
51
+
52
+ // ============================================================
53
+ // Rule Engine — 元素断言器
54
+ // ============================================================
55
+ async function assertElement(page, selector, options = {}) {
56
+ const label = selectorLabel(selector);
57
+ const start = Date.now();
58
+ const checks = [];
59
+ const failures = [];
60
+ try {
61
+ const locator = safeLocator(page, selector);
62
+ const timeout = options.timeout ?? 5000;
63
+ if (options.visible === true) {
64
+ const visible = await locator.isVisible({ timeout });
65
+ if (!visible)
66
+ failures.push(`元素 "${label}" 应可见但不可见`);
67
+ checks.push(`可见: ${visible ? "✓" : "✗"}`);
68
+ }
69
+ if (options.hidden === true) {
70
+ const hidden = await locator.isHidden({ timeout });
71
+ if (!hidden)
72
+ failures.push(`元素 "${label}" 应隐藏但可见`);
73
+ checks.push(`隐藏: ${hidden ? "✓" : "✗"}`);
74
+ }
75
+ if (options.editable === true) {
76
+ let editable;
77
+ try {
78
+ editable = await locator.isEditable({ timeout });
79
+ }
80
+ catch {
81
+ try {
82
+ editable = await page.$eval(selector, (el) => {
83
+ if (el.disabled || el.readOnly)
84
+ return false;
85
+ const tag = el.tagName.toLowerCase();
86
+ if (tag === "input") {
87
+ const type = (el.getAttribute("type") || "text").toLowerCase();
88
+ const nonEditable = ["checkbox", "radio", "button", "submit", "reset", "image", "file", "hidden"];
89
+ return !nonEditable.includes(type);
90
+ }
91
+ return tag === "textarea" || tag === "select" || el.isContentEditable;
92
+ });
93
+ }
94
+ catch {
95
+ editable = false;
96
+ }
97
+ }
98
+ if (!editable)
99
+ failures.push(`元素 "${label}" 应可编辑但不可编辑`);
100
+ checks.push(`可编辑: ${editable ? "✓" : "✗"}`);
101
+ }
102
+ if (options.enabled === true) {
103
+ const enabled = await locator.isEnabled({ timeout });
104
+ if (!enabled)
105
+ failures.push(`元素 "${label}" 应启用但已禁用`);
106
+ checks.push(`启用: ${enabled ? "✓" : "✗"}`);
107
+ }
108
+ if (options.disabled === true) {
109
+ const disabled = await locator.isDisabled({ timeout });
110
+ if (!disabled)
111
+ failures.push(`元素 "${label}" 应禁用但已启用`);
112
+ checks.push(`禁用: ${disabled ? "✓" : "✗"}`);
113
+ }
114
+ if (options.count !== undefined || options.minCount !== undefined || options.maxCount !== undefined) {
115
+ const count = await locator.count();
116
+ if (options.count !== undefined && count !== options.count)
117
+ failures.push(`元素 "${label}" 数量应为 ${options.count},实际 ${count}`);
118
+ if (options.minCount !== undefined && count < options.minCount)
119
+ failures.push(`元素 "${label}" 数量应 >= ${options.minCount},实际 ${count}`);
120
+ if (options.maxCount !== undefined && count > options.maxCount)
121
+ failures.push(`元素 "${label}" 数量应 <= ${options.maxCount},实际 ${count}`);
122
+ checks.push(`数量: ${count}`);
123
+ }
124
+ const hasImplicitCheck = options.visible !== undefined || options.hidden !== undefined ||
125
+ options.enabled !== undefined || options.disabled !== undefined ||
126
+ options.editable !== undefined;
127
+ const hasCountCheck = options.count !== undefined || options.minCount !== undefined || options.maxCount !== undefined;
128
+ if (hasImplicitCheck && !hasCountCheck) {
129
+ const count = await locator.count();
130
+ if (count === 0)
131
+ failures.push(`找不到匹配元素: "${label}"`);
132
+ checks.push(`存在: ${count > 0 ? "✓" : "✗"}`);
133
+ }
134
+ const passed = failures.length === 0;
135
+ const duration = Date.now() - start;
136
+ return {
137
+ passed, type: "element", name: `元素断言: ${label}`,
138
+ message: passed ? `元素 "${label}" 全部检查通过 [${checks.join(", ")}]` : failures.join("; "),
139
+ duration, details: passed ? checks.join(", ") : undefined,
140
+ };
141
+ }
142
+ catch (err) {
143
+ return {
144
+ passed: false, type: "element", name: `元素断言: ${label}`,
145
+ message: `元素 "${label}" 检查异常: ${err.message}`,
146
+ duration: Date.now() - start, details: err.stack,
147
+ };
148
+ }
149
+ }
150
+
151
+ // ============================================================
152
+ // Rule Engine — 内容断言器(文本 & 属性)
153
+ // ============================================================
154
+ /**
155
+ * 断言元素的文本内容
156
+ */
157
+ async function assertText(page, selector, expected, options = {}) {
158
+ const label = selectorLabel(selector);
159
+ const start = Date.now();
160
+ const timeout = options.timeout ?? 5000;
161
+ try {
162
+ const locator = safeLocator(page, selector);
163
+ // 先确保可见(用 page.waitForSelector 兼容 Stagehand)
164
+ try {
165
+ await page.waitForSelector(selector, { state: "visible", timeout });
166
+ }
167
+ catch {
168
+ // waitForSelector 也可能不可用,静默回退
169
+ }
170
+ // 获取文本
171
+ const rawText = options.useInnerText
172
+ ? await locator.innerText({ timeout })
173
+ : await locator.textContent({ timeout });
174
+ const text = (rawText ?? "").trim();
175
+ let passed = false;
176
+ let expectedStr = "";
177
+ if (expected instanceof RegExp) {
178
+ const flags = options.ignoreCase && !expected.flags.includes("i")
179
+ ? new RegExp(expected.source, expected.flags + "i")
180
+ : expected;
181
+ passed = flags.test(text);
182
+ expectedStr = `/${expected.source}/${expected.flags}`;
183
+ }
184
+ else if (options.regex) {
185
+ const flags = options.ignoreCase ? "i" : "";
186
+ const re = new RegExp(expected, flags);
187
+ passed = re.test(text);
188
+ expectedStr = `/${expected}/${flags}`;
189
+ }
190
+ else if (options.exact !== false) {
191
+ // 默认精确匹配
192
+ if (options.ignoreCase) {
193
+ passed = text.toLowerCase() === expected.toLowerCase();
194
+ }
195
+ else {
196
+ passed = text === expected;
197
+ }
198
+ expectedStr = `"${expected}"${options.ignoreCase ? " (忽略大小写)" : ""}`;
199
+ }
200
+ else {
201
+ // 包含匹配
202
+ if (options.ignoreCase) {
203
+ passed = text.toLowerCase().includes(expected.toLowerCase());
204
+ }
205
+ else {
206
+ passed = text.includes(expected);
207
+ }
208
+ expectedStr = `包含 "${expected}"${options.ignoreCase ? " (忽略大小写)" : ""}`;
209
+ }
210
+ const actualDisplay = text.length > 80 ? text.slice(0, 77) + "..." : text;
211
+ const duration = Date.now() - start;
212
+ return {
213
+ passed,
214
+ type: "text",
215
+ name: `文本断言: ${label}`,
216
+ message: passed
217
+ ? `"${label}" 文本匹配: ${expectedStr}`
218
+ : `"${label}" 文本不匹配: 期望 ${expectedStr},实际 "${actualDisplay}"`,
219
+ duration,
220
+ details: `期望: ${expectedStr}\n实际: "${text}"`,
221
+ };
222
+ }
223
+ catch (err) {
224
+ return {
225
+ passed: false,
226
+ type: "text",
227
+ name: `文本断言: ${label}`,
228
+ message: `"${label}" 文本检查异常: ${err.message}`,
229
+ duration: Date.now() - start,
230
+ details: err.stack,
231
+ };
232
+ }
233
+ }
234
+ /**
235
+ * 断言元素的 HTML 属性值
236
+ */
237
+ async function assertAttribute(page, selector, attribute, expected, _options = {}) {
238
+ const label = selectorLabel(selector);
239
+ const start = Date.now();
240
+ try {
241
+ // 用 page.evaluate 兼容 Stagehand(locator.getAttribute 不被代理)
242
+ const escapedAttr = attribute.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
243
+ const value = await page.evaluate(`
244
+ (() => {
245
+ const el = document.querySelector(${JSON.stringify(selector)});
246
+ return el ? el.getAttribute(${JSON.stringify(escapedAttr)}) : null;
247
+ })()
248
+ `);
249
+ let passed;
250
+ let expectedStr;
251
+ if (expected instanceof RegExp) {
252
+ passed = expected.test(value ?? "");
253
+ expectedStr = `/${expected.source}/${expected.flags}`;
254
+ }
255
+ else {
256
+ passed = value === expected;
257
+ expectedStr = `"${expected}"`;
258
+ }
259
+ const duration = Date.now() - start;
260
+ return {
261
+ passed,
262
+ type: "attribute",
263
+ name: `属性断言: ${label}[${attribute}]`,
264
+ message: passed
265
+ ? `"${label}" [${attribute}] 匹配: ${expectedStr}`
266
+ : `"${label}" [${attribute}] 不匹配: 期望 ${expectedStr},实际 "${value ?? "null"}"`,
267
+ duration,
268
+ details: `属性: ${attribute}\n期望: ${expectedStr}\n实际: "${value ?? "null"}"`,
269
+ };
270
+ }
271
+ catch (err) {
272
+ return {
273
+ passed: false,
274
+ type: "attribute",
275
+ name: `属性断言: ${label}[${attribute}]`,
276
+ message: `"${label}" [${attribute}] 检查异常: ${err.message}`,
277
+ duration: Date.now() - start,
278
+ details: err.stack,
279
+ };
280
+ }
281
+ }
282
+
283
+ // ============================================================
284
+ // Rule Engine — 导航断言器(URL / Title / Meta)
285
+ // ============================================================
286
+ /**
287
+ * 断⾔当前页面 URL
288
+ */
289
+ async function assertUrl(page, expected, options = {}) {
290
+ const start = Date.now();
291
+ const timeout = options.timeout ?? 10000;
292
+ let expectedStr = "";
293
+ try {
294
+ // 如果指定了 after 动作(点击选择器),先执行
295
+ if (options.after) {
296
+ const trimmed = options.after.trim();
297
+ if (trimmed.startsWith("click ")) {
298
+ const clickSelector = trimmed.slice(6).trim();
299
+ await page.locator(clickSelector).click({ timeout });
300
+ await page.waitForURL(expected instanceof RegExp ? expected : new RegExp(expected), { timeout });
301
+ }
302
+ }
303
+ const currentUrl = page.url();
304
+ let passed;
305
+ if (expected instanceof RegExp) {
306
+ passed = expected.test(currentUrl);
307
+ expectedStr = `/${expected.source}/${expected.flags}`;
308
+ }
309
+ else {
310
+ // 支持部分匹配:如果 expected 不以 / 开头,做包含匹配
311
+ if (expected.startsWith("/") || expected.startsWith("http")) {
312
+ passed = currentUrl === expected || currentUrl.endsWith(expected);
313
+ }
314
+ else {
315
+ passed = currentUrl.includes(expected);
316
+ }
317
+ expectedStr = `"${expected}"`;
318
+ }
319
+ const duration = Date.now() - start;
320
+ return {
321
+ passed,
322
+ type: "navigation",
323
+ name: "URL 断言",
324
+ message: passed
325
+ ? `URL 匹配: ${expectedStr}`
326
+ : `URL 不匹配: 期望 ${expectedStr},实际 "${currentUrl}"`,
327
+ duration,
328
+ details: `期望: ${expectedStr}\n实际: "${currentUrl}"`,
329
+ };
330
+ }
331
+ catch (err) {
332
+ return {
333
+ passed: false,
334
+ type: "navigation",
335
+ name: "URL 断言",
336
+ message: `URL 检查异常: ${err.message}`,
337
+ duration: Date.now() - start,
338
+ details: err.stack,
339
+ };
340
+ }
341
+ }
342
+ /**
343
+ * 断言页面标题
344
+ */
345
+ async function assertTitle(page, expected, _options = {}) {
346
+ const start = Date.now();
347
+ try {
348
+ const title = await page.title();
349
+ let passed;
350
+ let expectedStr;
351
+ if (expected instanceof RegExp) {
352
+ passed = expected.test(title);
353
+ expectedStr = `/${expected.source}/${expected.flags}`;
354
+ }
355
+ else {
356
+ passed = title.trim() === expected.trim();
357
+ expectedStr = `"${expected}"`;
358
+ }
359
+ const duration = Date.now() - start;
360
+ return {
361
+ passed,
362
+ type: "navigation",
363
+ name: "Title 断言",
364
+ message: passed
365
+ ? `页面标题匹配: ${expectedStr}`
366
+ : `页面标题不匹配: 期望 ${expectedStr},实际 "${title}"`,
367
+ duration,
368
+ details: `期望标题: ${expectedStr}\n实际标题: "${title}"`,
369
+ };
370
+ }
371
+ catch (err) {
372
+ return {
373
+ passed: false,
374
+ type: "navigation",
375
+ name: "Title 断言",
376
+ message: `Title 检查异常: ${err.message}`,
377
+ duration: Date.now() - start,
378
+ details: err.stack,
379
+ };
380
+ }
381
+ }
382
+ /**
383
+ * 断言页面 <meta> 标签内容
384
+ */
385
+ async function assertMeta(page, metaName, expected, _options = {}) {
386
+ const start = Date.now();
387
+ try {
388
+ // 用 page.evaluate 兼容 Stagehand(locator.getAttribute 不被代理)
389
+ let content = null;
390
+ try {
391
+ content = await page.evaluate(`
392
+ (() => {
393
+ const el = document.querySelector('meta[name="${metaName}"]');
394
+ return el ? el.getAttribute("content") : null;
395
+ })()
396
+ `);
397
+ }
398
+ catch {
399
+ // 静默回退
400
+ }
401
+ // 再尝试 property 属性 (OpenGraph)
402
+ if (content === null || content === undefined) {
403
+ try {
404
+ content = await page.evaluate(`
405
+ (() => {
406
+ const el = document.querySelector('meta[property="${metaName}"]');
407
+ return el ? el.getAttribute("content") : null;
408
+ })()
409
+ `);
410
+ }
411
+ catch {
412
+ // 静默回退
413
+ }
414
+ }
415
+ let passed;
416
+ let expectedStr;
417
+ if (expected instanceof RegExp) {
418
+ passed = expected.test(content ?? "");
419
+ expectedStr = `/${expected.source}/${expected.flags}`;
420
+ }
421
+ else {
422
+ passed = content === expected;
423
+ expectedStr = `"${expected}"`;
424
+ }
425
+ const duration = Date.now() - start;
426
+ return {
427
+ passed,
428
+ type: "navigation",
429
+ name: `Meta 断言: ${metaName}`,
430
+ message: passed
431
+ ? `Meta "${metaName}" 匹配: ${expectedStr}`
432
+ : `Meta "${metaName}" 不匹配: 期望 ${expectedStr},实际 "${content ?? "null"}"`,
433
+ duration,
434
+ details: `Meta: ${metaName}\n期望: ${expectedStr}\n实际: "${content ?? "null"}"`,
435
+ };
436
+ }
437
+ catch (err) {
438
+ return {
439
+ passed: false,
440
+ type: "navigation",
441
+ name: `Meta 断言: ${metaName}`,
442
+ message: `Meta "${metaName}" 检查异常: ${err.message}`,
443
+ duration: Date.now() - start,
444
+ details: err.stack,
445
+ };
446
+ }
447
+ }
448
+
449
+ // ============================================================
450
+ // Rule Engine — 网络断言器(状态码 / 响应体 / 响应头)
451
+ // ============================================================
452
+ /**
453
+ * 断言某个请求的 HTTP 状态码
454
+ *
455
+ * 支持两种模式:
456
+ * 1. 等待模式:传入 url 字符串,拦截匹配的请求并验证
457
+ * 2. 被动模式:若已通过其他方式捕获 response,可直接传入验证
458
+ */
459
+ async function assertStatusCode(page, urlOrPattern, expectedStatus, options = {}) {
460
+ const start = Date.now();
461
+ const timeout = options.timeout ?? 15000;
462
+ try {
463
+ // 等待匹配的响应
464
+ const response = await page.waitForResponse((res) => {
465
+ if (urlOrPattern instanceof RegExp) {
466
+ return urlOrPattern.test(res.url());
467
+ }
468
+ return res.url().includes(urlOrPattern);
469
+ }, { timeout });
470
+ const actualStatus = response.status();
471
+ const passed = actualStatus === expectedStatus;
472
+ const duration = Date.now() - start;
473
+ const shortUrl = response.url().length > 80
474
+ ? response.url().slice(0, 77) + "..."
475
+ : response.url();
476
+ const result = {
477
+ passed,
478
+ type: "network",
479
+ name: `状态码断言: ${typeof urlOrPattern === "string" ? urlOrPattern : urlOrPattern.source}`,
480
+ message: passed
481
+ ? `HTTP ${actualStatus} — ${shortUrl}`
482
+ : `HTTP 状态码不匹配: 期望 ${expectedStatus},实际 ${actualStatus} — ${shortUrl}`,
483
+ duration,
484
+ details: `URL: ${response.url()}\n期望状态码: ${expectedStatus}\n实际状态码: ${actualStatus}`,
485
+ };
486
+ // 可选的响应体验证
487
+ if (passed && options.schema) {
488
+ try {
489
+ const body = await response.json();
490
+ const schemaErrors = validateSchema(body, options.schema);
491
+ if (schemaErrors.length > 0) {
492
+ result.passed = false;
493
+ result.message += ` | JSON Schema 校验失败: ${schemaErrors.join(", ")}`;
494
+ result.details += `\nSchema 错误: ${schemaErrors.join("; ")}`;
495
+ }
496
+ }
497
+ catch {
498
+ result.passed = false;
499
+ result.message += " | 响应体非有效 JSON";
500
+ }
501
+ }
502
+ // 可选的响应头验证
503
+ if (passed && options.headers) {
504
+ const respHeaders = response.headers();
505
+ const lowerHeaders = {};
506
+ for (const [k, v] of Object.entries(respHeaders)) {
507
+ lowerHeaders[k.toLowerCase()] = v;
508
+ }
509
+ const headerErrors = [];
510
+ for (const [key, expectedVal] of Object.entries(options.headers)) {
511
+ const actual = lowerHeaders[key.toLowerCase()];
512
+ if (actual !== expectedVal) {
513
+ headerErrors.push(`header "${key}": 期望 "${expectedVal}",实际 "${actual ?? "缺失"}"`);
514
+ }
515
+ }
516
+ if (headerErrors.length > 0) {
517
+ result.passed = false;
518
+ result.message += ` | 响应头校验失败`;
519
+ }
520
+ }
521
+ // 可选的响应体文本包含
522
+ if (passed && options.contains) {
523
+ const body = await response.text();
524
+ if (!body.includes(options.contains)) {
525
+ result.passed = false;
526
+ result.message += ` | 响应体不包含 "${options.contains}"`;
527
+ }
528
+ }
529
+ return result;
530
+ }
531
+ catch (err) {
532
+ return {
533
+ passed: false,
534
+ type: "network",
535
+ name: `状态码断言: ${typeof urlOrPattern === "string" ? urlOrPattern : urlOrPattern.source}`,
536
+ message: `网络请求异常: ${err.message}`,
537
+ duration: Date.now() - start,
538
+ details: err.stack,
539
+ };
540
+ }
541
+ }
542
+ // -----------------------------------------------------------
543
+ // 简易 JSON Schema 校验
544
+ // -----------------------------------------------------------
545
+ function validateSchema(data, schema, path = "$") {
546
+ const errors = [];
547
+ for (const [key, expectedType] of Object.entries(schema)) {
548
+ const value = data?.[key];
549
+ const fullPath = `${path}.${key}`;
550
+ if (value === undefined) {
551
+ if (expectedType !== "optional") {
552
+ errors.push(`${fullPath}: 缺失`);
553
+ }
554
+ continue;
555
+ }
556
+ if (typeof expectedType === "string") {
557
+ if (expectedType === "array" && !Array.isArray(value)) {
558
+ errors.push(`${fullPath}: 期望 array,实际 ${typeof value}`);
559
+ }
560
+ else if (expectedType !== "array" && typeof value !== expectedType && expectedType !== "any") {
561
+ errors.push(`${fullPath}: 期望 ${expectedType},实际 ${typeof value}`);
562
+ }
563
+ }
564
+ else if (typeof expectedType === "object" && !Array.isArray(expectedType)) {
565
+ // 嵌套 schema
566
+ if (typeof value === "object" && value !== null) {
567
+ errors.push(...validateSchema(value, expectedType, fullPath));
568
+ }
569
+ else {
570
+ errors.push(`${fullPath}: 期望 object,实际 ${typeof value}`);
571
+ }
572
+ }
573
+ }
574
+ return errors;
575
+ }
576
+
577
+ // ============================================================
578
+ // Rule Engine — 性能断言器(FCP / LCP / TTI / CLS 等 Web Vitals)
579
+ // ============================================================
580
+ const METRIC_LABELS = {
581
+ FCP: "First Contentful Paint",
582
+ LCP: "Largest Contentful Paint",
583
+ TTI: "Time to Interactive",
584
+ CLS: "Cumulative Layout Shift",
585
+ TBT: "Total Blocking Time",
586
+ FID: "First Input Delay",
587
+ load: "页面加载时间",
588
+ dns: "DNS 解析",
589
+ tls: "TLS 握手",
590
+ ttfb: "Time to First Byte",
591
+ dom: "DOM 解析完成",
592
+ custom: "自定义指标",
593
+ };
594
+ /**
595
+ * 从页面提取 Web Vitals 指标(基于 Performance API)
596
+ */
597
+ async function assertPerformance(page, metric, options = {},
598
+ /** 自定义指标时传入具体数值 */
599
+ customValue) {
600
+ const start = Date.now();
601
+ const metricLabel = METRIC_LABELS[metric];
602
+ try {
603
+ let value;
604
+ if (metric === "custom" && customValue !== undefined) {
605
+ value = customValue;
606
+ }
607
+ else {
608
+ // 自包含的 evaluate 字符串(不能引用外部 TS 函数,Stagehand 要求)
609
+ value = await page.evaluate(`
610
+ (() => {
611
+ const m = ${JSON.stringify(metric)};
612
+ const navEntries = performance.getEntriesByType("navigation");
613
+ const nav = navEntries.length > 0 ? navEntries[0] : null;
614
+ const paintEntries = performance.getEntriesByType("paint");
615
+ const getFcp = () => {
616
+ const e = paintEntries.find(p => p.name === "first-contentful-paint");
617
+ return e ? e.startTime : -1;
618
+ };
619
+ switch (m) {
620
+ case "FCP": return Math.round(getFcp());
621
+ case "LCP": {
622
+ const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
623
+ if (lcpEntries.length > 0) return Math.round(lcpEntries[lcpEntries.length - 1].startTime);
624
+ return nav ? Math.round(nav.loadEventEnd) : -1;
625
+ }
626
+ case "TTI": return nav ? Math.round(nav.domInteractive) : -1;
627
+ case "CLS": return -1;
628
+ case "TBT": {
629
+ if (nav) {
630
+ const fcp = getFcp();
631
+ return Math.round(Math.max(0, nav.domInteractive - (fcp > 0 ? fcp : 0)));
632
+ }
633
+ return -1;
634
+ }
635
+ case "FID": return -1;
636
+ case "load": return nav ? Math.round(nav.loadEventEnd) : -1;
637
+ case "dns": return nav ? Math.round(nav.domainLookupEnd - nav.domainLookupStart) : -1;
638
+ case "tls": return nav ? Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0) : -1;
639
+ case "ttfb": return nav ? Math.round(nav.responseStart - nav.requestStart) : -1;
640
+ case "dom": return nav ? Math.round(nav.domContentLoadedEventEnd) : -1;
641
+ default: return -1;
642
+ }
643
+ })()
644
+ `);
645
+ }
646
+ // 四舍五入到整数
647
+ value = Math.round(value);
648
+ // 阈值比较
649
+ let passed = true;
650
+ const thresholdMsgs = [];
651
+ if (options.lt !== undefined) {
652
+ if (!(value < options.lt)) {
653
+ passed = false;
654
+ thresholdMsgs.push(`${value}ms >= ${options.lt}ms`);
655
+ }
656
+ }
657
+ if (options.lte !== undefined) {
658
+ if (!(value <= options.lte)) {
659
+ passed = false;
660
+ thresholdMsgs.push(`${value}ms > ${options.lte}ms`);
661
+ }
662
+ }
663
+ if (options.gt !== undefined) {
664
+ if (!(value > options.gt)) {
665
+ passed = false;
666
+ thresholdMsgs.push(`${value}ms <= ${options.gt}ms`);
667
+ }
668
+ }
669
+ if (options.gte !== undefined) {
670
+ if (!(value >= options.gte)) {
671
+ passed = false;
672
+ thresholdMsgs.push(`${value}ms < ${options.gte}ms`);
673
+ }
674
+ }
675
+ if (options.between) {
676
+ const [lo, hi] = options.between;
677
+ if (!(value >= lo && value <= hi)) {
678
+ passed = false;
679
+ thresholdMsgs.push(`${value}ms 不在 [${lo}, ${hi}] 范围内`);
680
+ }
681
+ }
682
+ const duration = Date.now() - start;
683
+ // 如果没有设置阈值,则仅做报告,总是通过
684
+ const hasThreshold = options.lt !== undefined ||
685
+ options.lte !== undefined ||
686
+ options.gt !== undefined ||
687
+ options.gte !== undefined ||
688
+ options.between !== undefined;
689
+ return {
690
+ passed: hasThreshold ? passed : true,
691
+ type: "performance",
692
+ name: `性能断言: ${metric}`,
693
+ message: passed
694
+ ? `${metricLabel}: ${value}ms ✓`
695
+ : `${metricLabel}: ${value}ms — ${thresholdMsgs.join(", ")}`,
696
+ duration,
697
+ details: hasThreshold
698
+ ? `指标: ${metric} (${metricLabel})\n值: ${value}ms\n阈值: ${JSON.stringify(options)}`
699
+ : `指标: ${metric} (${metricLabel})\n值: ${value}ms (无阈值,仅报告)`,
700
+ };
701
+ }
702
+ catch (err) {
703
+ return {
704
+ passed: false,
705
+ type: "performance",
706
+ name: `性能断言: ${metric}`,
707
+ message: `${metricLabel} 提取异常: ${err.message}`,
708
+ duration: Date.now() - start,
709
+ details: err.stack,
710
+ };
711
+ }
712
+ }
713
+
714
+ // ============================================================
715
+ // Rule Engine — 可访问性断言器(a11y)
716
+ // ============================================================
717
+ /**
718
+ * 断言元素的可访问性:
719
+ * - 图片是否有 alt 文本
720
+ * - 表单控件是否有关联 label
721
+ * - ARIA role 是否正确
722
+ * - 颜色对比度(基础检查)
723
+ */
724
+ async function assertAccessibility(page, selector, options = {}) {
725
+ const label = selectorLabel(selector);
726
+ const start = Date.now();
727
+ const timeout = options.timeout ?? 5000;
728
+ const failures = [];
729
+ const checks = [];
730
+ try {
731
+ const locator = safeLocator(page, selector);
732
+ // --- alt 文本检查 ---
733
+ if (options.altText !== undefined) {
734
+ const actualAlt = await locator.getAttribute("alt", { timeout });
735
+ let passed = false;
736
+ if (options.altText instanceof RegExp) {
737
+ passed = options.altText.test(actualAlt ?? "");
738
+ }
739
+ else {
740
+ passed = actualAlt === options.altText;
741
+ }
742
+ if (!passed) {
743
+ failures.push(`"${label}" alt 文本不匹配: 期望 "${options.altText}",实际 "${actualAlt ?? "缺失"}"`);
744
+ }
745
+ checks.push(`alt: ${actualAlt ?? "缺失"}`);
746
+ }
747
+ // --- label 关联检查 ---
748
+ if (options.hasLabel) {
749
+ // 检查是否通过 id-label 关联或包裹在 label 中
750
+ const hasLabel = await locator.evaluate((el) => {
751
+ const id = el.getAttribute("id");
752
+ if (id) {
753
+ const labelFor = document.querySelector(`label[for="${id}"]`);
754
+ if (labelFor)
755
+ return true;
756
+ }
757
+ // 检查是否被 label 包裹
758
+ const parentLabel = el.closest("label");
759
+ if (parentLabel)
760
+ return true;
761
+ // 检查 aria-label / aria-labelledby
762
+ if (el.getAttribute("aria-label") || el.getAttribute("aria-labelledby"))
763
+ return true;
764
+ return false;
765
+ }).catch(() => false);
766
+ if (!hasLabel) {
767
+ failures.push(`"${label}" 缺少关联的 label`);
768
+ }
769
+ checks.push(`label: ${hasLabel ? "✓" : "✗"}`);
770
+ }
771
+ // --- ARIA role 检查 ---
772
+ if (options.role) {
773
+ const actualRole = await locator.getAttribute("role", { timeout }).catch(() => null);
774
+ if (actualRole !== options.role) {
775
+ failures.push(`"${label}" role 不匹配: 期望 "${options.role}",实际 "${actualRole ?? "缺失"}"`);
776
+ }
777
+ checks.push(`role: ${actualRole ?? "缺失"}`);
778
+ }
779
+ // --- 颜色对比度基础检查 ---(用 page.$eval 兼容 Stagehand)
780
+ if (options.minContrast !== undefined) {
781
+ const minRatio = options.minContrast;
782
+ let contrastOk = false;
783
+ try {
784
+ contrastOk = await page.$eval(selector, (el, ratio) => {
785
+ const style = window.getComputedStyle(el);
786
+ const color = style.color;
787
+ const bg = style.backgroundColor;
788
+ const parseRgb = (rgb) => {
789
+ const m = rgb.match(/[\d.]+/g);
790
+ if (!m || m.length < 3)
791
+ return null;
792
+ return [Number(m[0]), Number(m[1]), Number(m[2])];
793
+ };
794
+ const fg = parseRgb(color);
795
+ const bgParsed = parseRgb(bg);
796
+ if (!fg || !bgParsed)
797
+ return false;
798
+ const luminance = (r, g, b) => {
799
+ const toLinear = (c) => {
800
+ c /= 255;
801
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
802
+ };
803
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
804
+ };
805
+ const l1 = luminance(fg[0], fg[1], fg[2]);
806
+ const l2 = luminance(bgParsed[0], bgParsed[1], bgParsed[2]);
807
+ const contrastRatio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
808
+ return contrastRatio >= ratio;
809
+ }, minRatio);
810
+ }
811
+ catch {
812
+ contrastOk = false;
813
+ }
814
+ if (!contrastOk) {
815
+ failures.push(`"${label}" 颜色对比度不满足 ${options.minContrast}:1 要求`);
816
+ }
817
+ checks.push(`对比度 >= ${options.minContrast}:1: ${contrastOk ? "✓" : "✗"}`);
818
+ }
819
+ const passed = failures.length === 0;
820
+ const duration = Date.now() - start;
821
+ return {
822
+ passed,
823
+ type: "accessibility",
824
+ name: `可访问性断言: ${label}`,
825
+ message: passed
826
+ ? `"${label}" 可访问性通过 [${checks.join(", ")}]`
827
+ : failures.join("; "),
828
+ duration,
829
+ details: `元素: "${label}"\n检查项:\n${checks.map((c) => ` ${c}`).join("\n")}`,
830
+ };
831
+ }
832
+ catch (err) {
833
+ return {
834
+ passed: false,
835
+ type: "accessibility",
836
+ name: `可访问性断言: ${label}`,
837
+ message: `"${label}" 可访问性检查异常: ${err.message}`,
838
+ duration: Date.now() - start,
839
+ details: err.stack,
840
+ };
841
+ }
842
+ }
843
+
844
+ // ============================================================
845
+ // Rule Engine — RuleTest 主类(Fluent API)
846
+ // ============================================================
847
+ class RuleTest {
848
+ constructor(name, options = {}) {
849
+ this.tasks = [];
850
+ this.name = name;
851
+ this.options = {
852
+ timeout: options.timeout ?? 10000,
853
+ screenshotOnFailure: options.screenshotOnFailure ?? true,
854
+ bailOnFailure: options.bailOnFailure ?? false,
855
+ retryCount: options.retryCount ?? 0,
856
+ };
857
+ }
858
+ // ==========================================================
859
+ // Builder API — 链式断言
860
+ // ==========================================================
861
+ /** 断言元素存在性/可见性/可编辑性/数量 */
862
+ assertElement(selector, opts = {}) {
863
+ this.tasks.push({
864
+ type: "element",
865
+ name: `元素断言: ${selector}`,
866
+ execute: (page) => assertElement(page, selector, opts),
867
+ });
868
+ return this;
869
+ }
870
+ /** 断言元素文本内容 */
871
+ assertText(selector, expected, opts = {}) {
872
+ this.tasks.push({
873
+ type: "text",
874
+ name: `文本断言: ${selector}`,
875
+ execute: (page) => assertText(page, selector, expected, opts),
876
+ });
877
+ return this;
878
+ }
879
+ /** 断言元素 HTML 属性值 */
880
+ assertAttribute(selector, attribute, expected, opts = {}) {
881
+ this.tasks.push({
882
+ type: "attribute",
883
+ name: `属性断言: ${selector}[${attribute}]`,
884
+ execute: (page) => assertAttribute(page, selector, attribute, expected, opts),
885
+ });
886
+ return this;
887
+ }
888
+ /** 断言当前页面 URL */
889
+ assertUrl(expected, opts = {}) {
890
+ this.tasks.push({
891
+ type: "navigation",
892
+ name: "URL 断言",
893
+ execute: (page) => assertUrl(page, expected, opts),
894
+ });
895
+ return this;
896
+ }
897
+ /** 断言页面 <title> */
898
+ assertTitle(expected, opts = {}) {
899
+ this.tasks.push({
900
+ type: "navigation",
901
+ name: "Title 断言",
902
+ execute: (page) => assertTitle(page, expected, opts),
903
+ });
904
+ return this;
905
+ }
906
+ /** 断言页面 <meta> 标签 */
907
+ assertMeta(metaName, expected, opts = {}) {
908
+ this.tasks.push({
909
+ type: "navigation",
910
+ name: `Meta 断言: ${metaName}`,
911
+ execute: (page) => assertMeta(page, metaName, expected, opts),
912
+ });
913
+ return this;
914
+ }
915
+ /** 断言 HTTP 状态码(可附加响应体/响应头校验) */
916
+ assertStatusCode(urlOrPattern, expectedStatus, opts = {}) {
917
+ this.tasks.push({
918
+ type: "network",
919
+ name: `状态码断言: ${urlOrPattern}`,
920
+ execute: (page) => assertStatusCode(page, urlOrPattern, expectedStatus, opts),
921
+ });
922
+ return this;
923
+ }
924
+ /** 断言 Web 性能指标 */
925
+ assertPerformance(metric, opts = {}, customValue) {
926
+ this.tasks.push({
927
+ type: "performance",
928
+ name: `性能断言: ${metric}`,
929
+ execute: (page) => assertPerformance(page, metric, opts, customValue),
930
+ });
931
+ return this;
932
+ }
933
+ /** 断言可访问性(alt / label / role / 对比度) */
934
+ assertAccessibility(selector, opts = {}) {
935
+ this.tasks.push({
936
+ type: "accessibility",
937
+ name: `可访问性断言: ${selector}`,
938
+ execute: (page) => assertAccessibility(page, selector, opts),
939
+ });
940
+ return this;
941
+ }
942
+ /** 自定义断言:传入任意异步校验函数 */
943
+ assertCustom(label, fn) {
944
+ this.tasks.push({
945
+ type: "custom",
946
+ name: label,
947
+ execute: async (page) => {
948
+ const result = await fn(page);
949
+ return { ...result, type: "custom" };
950
+ },
951
+ });
952
+ return this;
953
+ }
954
+ // ==========================================================
955
+ // 执行
956
+ // ==========================================================
957
+ /**
958
+ * 运行全部断言,返回结果
959
+ */
960
+ async run(page) {
961
+ const startTime = Date.now();
962
+ const results = [];
963
+ for (const task of this.tasks) {
964
+ let lastResult = null;
965
+ // 支持重试
966
+ const attempts = 1 + this.options.retryCount;
967
+ for (let attempt = 1; attempt <= attempts; attempt++) {
968
+ const result = await task.execute(page);
969
+ lastResult = result;
970
+ if (result.passed)
971
+ break;
972
+ // 如果不是最后一次尝试,打印重试日志
973
+ if (attempt < attempts) {
974
+ console.warn(`[RuleTest] "${task.name}" 第 ${attempt}/${attempts} 次尝试失败,即将重试...`);
975
+ }
976
+ }
977
+ // 确保一定有结果
978
+ const finalResult = lastResult;
979
+ // 失败时截图
980
+ if (!finalResult.passed && this.options.screenshotOnFailure) {
981
+ try {
982
+ const buf = await page.screenshot();
983
+ finalResult.screenshot = buf.toString("base64");
984
+ }
985
+ catch {
986
+ // 截图失败不阻断流程
987
+ }
988
+ }
989
+ results.push(finalResult);
990
+ // bailOnFailure:遇错即停
991
+ if (!finalResult.passed && this.options.bailOnFailure) {
992
+ console.warn(`[RuleTest] bailOnFailure 触发,跳过剩余 ${this.tasks.length - results.length} 个断言`);
993
+ break;
994
+ }
995
+ }
996
+ const passedCount = results.filter((r) => r.passed).length;
997
+ const totalDuration = Date.now() - startTime;
998
+ return {
999
+ name: this.name,
1000
+ assertions: results,
1001
+ hasFailure: passedCount < results.length,
1002
+ passRate: results.length > 0 ? passedCount / results.length : 1,
1003
+ totalDuration,
1004
+ };
1005
+ }
1006
+ }
1007
+
1008
+ // ============================================================
1009
+ // AI Engine — LLM Client(OpenAI 兼容 API)
1010
+ // ============================================================
1011
+ /**
1012
+ * 根据模型名推断 Base URL
1013
+ */
1014
+ function inferBaseURL(model) {
1015
+ const lower = model.toLowerCase();
1016
+ if (lower.includes("deepseek"))
1017
+ return "https://api.deepseek.com/v1";
1018
+ if (lower.includes("openai") || lower.includes("gpt"))
1019
+ return "https://api.openai.com/v1";
1020
+ if (lower.includes("anthropic") || lower.includes("claude"))
1021
+ return "https://api.anthropic.com/v1";
1022
+ // 默认走 DeepSeek(项目已在使用)
1023
+ return "https://api.deepseek.com/v1";
1024
+ }
1025
+ /**
1026
+ * 根据模型名推断 API Key 环境变量
1027
+ */
1028
+ function inferApiKey(model) {
1029
+ const lower = model.toLowerCase();
1030
+ if (lower.includes("deepseek"))
1031
+ return process.env.DEEPSEEK_API_KEY || "";
1032
+ if (lower.includes("openai") || lower.includes("gpt"))
1033
+ return process.env.OPENAI_API_KEY || "";
1034
+ if (lower.includes("anthropic") || lower.includes("claude"))
1035
+ return process.env.ANTHROPIC_API_KEY || "";
1036
+ return process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || "";
1037
+ }
1038
+ /**
1039
+ * 简易 OpenAI 兼容 LLM 客户端
1040
+ *
1041
+ * 支持 DeepSeek、OpenAI、及任何 OpenAI 兼容 API。
1042
+ * 自动从模型名推断 endpoint 和 API Key。
1043
+ */
1044
+ class LLMClient {
1045
+ constructor(config) {
1046
+ this.totalTokens = 0;
1047
+ this.config = {
1048
+ model: config.model,
1049
+ baseURL: config.baseURL || inferBaseURL(config.model),
1050
+ apiKey: config.apiKey || inferApiKey(config.model),
1051
+ temperature: config.temperature ?? 0.1,
1052
+ maxTokens: config.maxTokens ?? 1024,
1053
+ timeout: config.timeout ?? 30000,
1054
+ };
1055
+ }
1056
+ /** 获取累计 token 消耗 */
1057
+ getTotalTokens() {
1058
+ return this.totalTokens;
1059
+ }
1060
+ /** 发送 Chat Completion 请求 */
1061
+ async chat(messages) {
1062
+ const url = `${this.config.baseURL}/chat/completions`;
1063
+ const controller = new AbortController();
1064
+ const timer = setTimeout(() => controller.abort(), this.config.timeout);
1065
+ try {
1066
+ const res = await fetch(url, {
1067
+ method: "POST",
1068
+ headers: {
1069
+ "Content-Type": "application/json",
1070
+ "Authorization": `Bearer ${this.config.apiKey}`,
1071
+ },
1072
+ body: JSON.stringify({
1073
+ model: this.config.model,
1074
+ messages,
1075
+ temperature: this.config.temperature,
1076
+ max_tokens: this.config.maxTokens,
1077
+ }),
1078
+ signal: controller.signal,
1079
+ });
1080
+ if (!res.ok) {
1081
+ const body = await res.text().catch(() => "");
1082
+ throw new Error(`LLM API ${res.status}: ${body.slice(0, 200)}`);
1083
+ }
1084
+ const data = await res.json();
1085
+ const content = data.choices?.[0]?.message?.content ?? "";
1086
+ const usage = data.usage;
1087
+ if (usage?.totalTokens) {
1088
+ this.totalTokens += usage.totalTokens;
1089
+ }
1090
+ return {
1091
+ content: content.trim(),
1092
+ usage: usage ? {
1093
+ promptTokens: usage.prompt_tokens || usage.promptTokens || 0,
1094
+ completionTokens: usage.completion_tokens || usage.completionTokens || 0,
1095
+ totalTokens: usage.total_tokens || usage.totalTokens || 0,
1096
+ } : undefined,
1097
+ };
1098
+ }
1099
+ finally {
1100
+ clearTimeout(timer);
1101
+ }
1102
+ }
1103
+ /** 单轮对话快捷方法 */
1104
+ async complete(systemPrompt, userMessage) {
1105
+ return this.chat([
1106
+ { role: "system", content: systemPrompt },
1107
+ { role: "user", content: userMessage },
1108
+ ]);
1109
+ }
1110
+ /** 判断类快捷方法 — 返回布尔值 */
1111
+ async judge(systemPrompt, userMessage, expectedAnswer = "YES") {
1112
+ const fullPrompt = `${systemPrompt}\n\nReply with ONLY "YES" or "NO" followed by a brief reason.`;
1113
+ const res = await this.complete(fullPrompt, userMessage);
1114
+ const raw = res.content;
1115
+ const upper = raw.toUpperCase().trim();
1116
+ const passed = upper.startsWith(expectedAnswer.toUpperCase());
1117
+ const reason = raw.replace(/^(YES|NO)\s*-?\s*/i, "").trim();
1118
+ return { passed, reason, raw };
1119
+ }
1120
+ }
1121
+
1122
+ // ============================================================
1123
+ // AI Engine — Prompt 模板(内联,打包安全)
1124
+ // ============================================================
1125
+ const SENTIMENT_PROMPT = `You are a sentiment analyzer for UI testing. Given a piece of UI text, classify its emotional tone.
1126
+
1127
+ Categories:
1128
+ - positive: encouraging, friendly, warm, optimistic, praising, welcoming
1129
+ - negative: angry, frustrated, blaming, cold, dismissive, alarming
1130
+ - neutral: factual, informational, neutral instructions
1131
+
1132
+ Reply with ONLY one word: "positive", "negative", or "neutral". Then a brief reason.`;
1133
+ const EXTRACTION_PROMPT = `You are a data extraction tool for UI testing. Extract structured information from the given HTML or text content.
1134
+
1135
+ Rules:
1136
+ - Return ONLY valid JSON, no markdown, no explanation
1137
+ - Use the exact field names provided in the schema
1138
+ - If a field cannot be found, use null
1139
+ - For numbers, return the numeric value (not strings)`;
1140
+ const ANOMALY_PROMPT = `You are an anomaly detector for UI testing. Analyze the given page content and determine if the page is in an error or unexpected state.
1141
+
1142
+ Anomalies to detect:
1143
+ - Blank/white page with no meaningful content
1144
+ - Error messages (e.g., "500 Internal Server Error", "Something went wrong", "Page not found")
1145
+ - Loading failures (e.g., infinite spinner, "Loading..." stuck forever)
1146
+ - Broken elements (e.g., missing images, unstyled content)
1147
+ - Unexpected redirects to error pages
1148
+
1149
+ Reply with ONLY "YES" if an anomaly is detected, or "NO" if the page looks normal.
1150
+ Then a brief reason.`;
1151
+
1152
+ // ============================================================
1153
+ // AI Engine — 情感断言器
1154
+ // ============================================================
1155
+ /**
1156
+ * 断言页面元素的文本情感倾向
1157
+ */
1158
+ async function assertSentiment(page, llm, selector, options) {
1159
+ const start = Date.now();
1160
+ try {
1161
+ // 从页面提取文本
1162
+ const text = await page.locator(selector).first().textContent().catch(() => null);
1163
+ if (!text || !text.trim()) {
1164
+ return {
1165
+ passed: false,
1166
+ type: "sentiment",
1167
+ name: `情感断言: ${selector}`,
1168
+ message: `无法提取 "${selector}" 的文本内容`,
1169
+ duration: Date.now() - start,
1170
+ };
1171
+ }
1172
+ const trimmed = text.trim().slice(0, 2000); // 限制长度
1173
+ // 调用 LLM 判断情感
1174
+ const userMsg = `Analyze the sentiment of this UI text:\n\n"${trimmed}"\n\nExpected sentiment: ${options.expected}${options.criteria ? `\nCustom criteria: ${options.criteria}` : ""}`;
1175
+ const res = await llm.complete(SENTIMENT_PROMPT, userMsg);
1176
+ const label = res.content.toLowerCase().trim();
1177
+ const passed = label.startsWith(options.expected.toLowerCase());
1178
+ const duration = Date.now() - start;
1179
+ return {
1180
+ passed,
1181
+ type: "sentiment",
1182
+ name: `情感断言: ${selector}`,
1183
+ message: passed
1184
+ ? `"${selector}" 情感为 ${options.expected} ✓`
1185
+ : `"${selector}" 情感不匹配: 期望 ${options.expected},实际 ${label}`,
1186
+ duration,
1187
+ details: `文本: "${trimmed.slice(0, 200)}"\nLLM 判断: ${res.content}`,
1188
+ rawResponse: res.content,
1189
+ };
1190
+ }
1191
+ catch (err) {
1192
+ return {
1193
+ passed: false,
1194
+ type: "sentiment",
1195
+ name: `情感断言: ${selector}`,
1196
+ message: `情感分析异常: ${err.message}`,
1197
+ duration: Date.now() - start,
1198
+ details: err.stack,
1199
+ };
1200
+ }
1201
+ }
1202
+
1203
+ // ============================================================
1204
+ // AI Engine — 结构化提取断言器
1205
+ // ============================================================
1206
+ /**
1207
+ * 从页面元素中提取结构化数据,并校验是否符合 Schema
1208
+ */
1209
+ async function assertExtraction(page, llm, selector, options) {
1210
+ const start = Date.now();
1211
+ try {
1212
+ // 从页面提取 HTML/文本
1213
+ const text = await page.locator(selector).first().textContent().catch(() => null);
1214
+ if (!text || !text.trim()) {
1215
+ return {
1216
+ passed: false,
1217
+ type: "extract",
1218
+ name: `提取断言: ${selector}`,
1219
+ message: `无法提取 "${selector}" 的内容`,
1220
+ duration: Date.now() - start,
1221
+ };
1222
+ }
1223
+ const schemaStr = JSON.stringify(options.schema, null, 2);
1224
+ const instruction = options.instruction || "Extract the requested fields from the content.";
1225
+ const userMsg = [
1226
+ instruction,
1227
+ `\nExpected schema:\n${schemaStr}`,
1228
+ `\nContent:\n${text.slice(0, 3000)}`,
1229
+ ].join("\n");
1230
+ const res = await llm.complete(EXTRACTION_PROMPT, userMsg);
1231
+ // 尝试解析 JSON
1232
+ let parsed = null;
1233
+ let parseError = "";
1234
+ try {
1235
+ // 去掉可能的 markdown 包裹
1236
+ const jsonStr = res.content
1237
+ .replace(/^```json?\s*/i, "")
1238
+ .replace(/\s*```$/i, "")
1239
+ .trim();
1240
+ parsed = JSON.parse(jsonStr);
1241
+ }
1242
+ catch {
1243
+ parseError = `JSON 解析失败,原始响应: ${res.content.slice(0, 200)}`;
1244
+ }
1245
+ const duration = Date.now() - start;
1246
+ if (!parsed) {
1247
+ return {
1248
+ passed: false,
1249
+ type: "extract",
1250
+ name: `提取断言: ${selector}`,
1251
+ message: parseError,
1252
+ duration,
1253
+ rawResponse: res.content,
1254
+ details: parseError,
1255
+ };
1256
+ }
1257
+ // 校验 Schema 字段
1258
+ const missingFields = [];
1259
+ const typeErrors = [];
1260
+ for (const [key, expectedType] of Object.entries(options.schema)) {
1261
+ if (!(key in parsed)) {
1262
+ missingFields.push(key);
1263
+ continue;
1264
+ }
1265
+ const val = parsed[key];
1266
+ if (expectedType === "number" && typeof val !== "number") {
1267
+ typeErrors.push(`${key}: 期望 number,实际 ${typeof val}`);
1268
+ }
1269
+ else if (expectedType === "string" && typeof val !== "string") {
1270
+ typeErrors.push(`${key}: 期望 string,实际 ${typeof val}`);
1271
+ }
1272
+ else if (expectedType === "boolean" && typeof val !== "boolean") {
1273
+ typeErrors.push(`${key}: 期望 boolean,实际 ${typeof val}`);
1274
+ }
1275
+ else if (expectedType === "array" && !Array.isArray(val)) {
1276
+ typeErrors.push(`${key}: 期望 array,实际 ${typeof val}`);
1277
+ }
1278
+ }
1279
+ const passed = missingFields.length === 0 && typeErrors.length === 0;
1280
+ const errors = [...missingFields.map((f) => `缺少字段: ${f}`), ...typeErrors];
1281
+ return {
1282
+ passed,
1283
+ type: "extract",
1284
+ name: `提取断言: ${selector}`,
1285
+ message: passed
1286
+ ? `"${selector}" 结构化提取成功`
1287
+ : `"${selector}" 提取校验失败: ${errors.join("; ")}`,
1288
+ duration,
1289
+ details: `提取结果:\n${JSON.stringify(parsed, null, 2)}`,
1290
+ rawResponse: res.content,
1291
+ };
1292
+ }
1293
+ catch (err) {
1294
+ return {
1295
+ passed: false,
1296
+ type: "extract",
1297
+ name: `提取断言: ${selector}`,
1298
+ message: `提取异常: ${err.message}`,
1299
+ duration: Date.now() - start,
1300
+ details: err.stack,
1301
+ };
1302
+ }
1303
+ }
1304
+
1305
+ // ============================================================
1306
+ // AI Engine — 内容质量断言器
1307
+ // ============================================================
1308
+ const QUALITY_PROMPT = `You are a content quality evaluator for UI testing. Analyze the given text against the specified quality criteria. Reply with ONLY "YES" if all criteria are met, or "NO" if any criterion fails. Then list which criteria failed.`;
1309
+ /**
1310
+ * 断言页面内容质量(长度、关键词、语气、禁用词)
1311
+ */
1312
+ async function assertQuality(page, llm, selector, options) {
1313
+ const start = Date.now();
1314
+ try {
1315
+ const text = await page.locator(selector).first().textContent().catch(() => null);
1316
+ if (!text || !text.trim()) {
1317
+ return {
1318
+ passed: false,
1319
+ type: "quality",
1320
+ name: `质量断言: ${selector}`,
1321
+ message: `无法提取 "${selector}" 的文本内容`,
1322
+ duration: Date.now() - start,
1323
+ };
1324
+ }
1325
+ const trimmed = text.trim();
1326
+ const checks = [];
1327
+ const failures = [];
1328
+ // --- 规则层检查(不需要 LLM) ---
1329
+ // 长度
1330
+ if (options.minLength !== undefined) {
1331
+ const ok = trimmed.length >= options.minLength;
1332
+ checks.push(`长度 >= ${options.minLength}: ${ok ? "✓" : "✗"} (${trimmed.length})`);
1333
+ if (!ok)
1334
+ failures.push(`文本长度 ${trimmed.length} < ${options.minLength}`);
1335
+ }
1336
+ // 关键词
1337
+ if (options.containsKeywords) {
1338
+ for (const kw of options.containsKeywords) {
1339
+ const ok = trimmed.toLowerCase().includes(kw.toLowerCase());
1340
+ checks.push(`关键词 "${kw}": ${ok ? "✓" : "✗"}`);
1341
+ if (!ok)
1342
+ failures.push(`缺少关键词: "${kw}"`);
1343
+ }
1344
+ }
1345
+ // 禁用词
1346
+ if (options.forbiddenWords) {
1347
+ for (const fw of options.forbiddenWords) {
1348
+ const found = trimmed.toLowerCase().includes(fw.toLowerCase());
1349
+ checks.push(`禁用词 "${fw}": ${found ? "✗" : "✓"}`);
1350
+ if (found)
1351
+ failures.push(`包含禁用词: "${fw}"`);
1352
+ }
1353
+ }
1354
+ // --- LLM 层检查(语气+语法) ---
1355
+ if (options.tone || options.checkGrammar) {
1356
+ const criteriaParts = [];
1357
+ if (options.tone)
1358
+ criteriaParts.push(`Tone must be: ${options.tone}`);
1359
+ if (options.checkGrammar)
1360
+ criteriaParts.push("No spelling or grammar errors");
1361
+ const criteria = criteriaParts.join(". ");
1362
+ const userMsg = `Evaluate this text:\n\n"${trimmed.slice(0, 2000)}"\n\nCriteria: ${criteria}`;
1363
+ const { passed: llmOk, reason } = await llm.judge(QUALITY_PROMPT, userMsg);
1364
+ if (options.tone) {
1365
+ checks.push(`语气 "${options.tone}": ${llmOk ? "✓" : "✗"}`);
1366
+ if (!llmOk)
1367
+ failures.push(`语气不符合 "${options.tone}": ${reason}`);
1368
+ }
1369
+ if (options.checkGrammar && !llmOk) {
1370
+ failures.push(`语法/拼写问题: ${reason}`);
1371
+ }
1372
+ }
1373
+ const passed = failures.length === 0;
1374
+ const duration = Date.now() - start;
1375
+ return {
1376
+ passed,
1377
+ type: "quality",
1378
+ name: `质量断言: ${selector}`,
1379
+ message: passed
1380
+ ? `"${selector}" 内容质量通过 [${checks.join(", ")}]`
1381
+ : failures.join("; "),
1382
+ duration,
1383
+ details: `文本: "${trimmed.slice(0, 300)}"\n检查项:\n${checks.map((c) => ` ${c}`).join("\n")}`,
1384
+ };
1385
+ }
1386
+ catch (err) {
1387
+ return {
1388
+ passed: false,
1389
+ type: "quality",
1390
+ name: `质量断言: ${selector}`,
1391
+ message: `质量检查异常: ${err.message}`,
1392
+ duration: Date.now() - start,
1393
+ details: err.stack,
1394
+ };
1395
+ }
1396
+ }
1397
+
1398
+ // ============================================================
1399
+ // AI Engine — 异常检测断言器
1400
+ // ============================================================
1401
+ /**
1402
+ * 全局异常检测 — 检查页面是否处于错误/空白/加载失败等非预期状态
1403
+ */
1404
+ async function assertAnomaly(page, llm, options = {}) {
1405
+ const start = Date.now();
1406
+ const { checkBlank = true, checkError = true, checkLoading = true, } = options;
1407
+ try {
1408
+ // 收集页面信息
1409
+ const url = page.url();
1410
+ const title = await page.title().catch(() => "");
1411
+ const bodyText = await page.locator("body").textContent().catch(() => "") ?? "";
1412
+ // 规则层快速检查
1413
+ const quickFailures = [];
1414
+ if (checkBlank && bodyText.trim().length < 10) {
1415
+ quickFailures.push("页面几乎为空(可能白屏)");
1416
+ }
1417
+ if (checkError) {
1418
+ const lowerText = bodyText.toLowerCase();
1419
+ const errorPatterns = [
1420
+ "500", "internal server error", "something went wrong",
1421
+ "page not found", "404", "access denied", "403",
1422
+ ];
1423
+ for (const pattern of errorPatterns) {
1424
+ if (lowerText.includes(pattern)) {
1425
+ quickFailures.push(`检测到错误提示: "${pattern}"`);
1426
+ break;
1427
+ }
1428
+ }
1429
+ }
1430
+ if (checkLoading) {
1431
+ const lowerText = bodyText.toLowerCase();
1432
+ if (bodyText.trim().length > 0 && bodyText.trim().length < 50) {
1433
+ const loadingWords = ["loading", "加载", "please wait", "spinner"];
1434
+ for (const w of loadingWords) {
1435
+ if (lowerText.includes(w)) {
1436
+ quickFailures.push(`页面可能卡在加载状态: "${w}"`);
1437
+ break;
1438
+ }
1439
+ }
1440
+ }
1441
+ }
1442
+ // 如果规则层已发现异常,直接返回
1443
+ if (quickFailures.length > 0) {
1444
+ const duration = Date.now() - start;
1445
+ return {
1446
+ passed: false,
1447
+ type: "anomaly",
1448
+ name: "异常检测",
1449
+ message: quickFailures.join("; "),
1450
+ duration,
1451
+ details: `URL: ${url}\nTitle: "${title}"\nBody 长度: ${bodyText.length}`,
1452
+ };
1453
+ }
1454
+ // LLM 层深度检查
1455
+ const userMsg = [
1456
+ `URL: ${url}`,
1457
+ `Title: "${title}"`,
1458
+ `Body text (first 2000 chars):`,
1459
+ bodyText.slice(0, 2000),
1460
+ ].join("\n");
1461
+ const { passed, reason, raw } = await llm.judge(ANOMALY_PROMPT, userMsg, "NO");
1462
+ const duration = Date.now() - start;
1463
+ return {
1464
+ passed,
1465
+ type: "anomaly",
1466
+ name: "异常检测",
1467
+ message: passed
1468
+ ? "页面状态正常 ✓"
1469
+ : `检测到异常: ${reason}`,
1470
+ duration,
1471
+ details: `URL: ${url}\nTitle: "${title}"\nBody 长度: ${bodyText.length}\nLLM: ${raw}`,
1472
+ rawResponse: raw,
1473
+ };
1474
+ }
1475
+ catch (err) {
1476
+ return {
1477
+ passed: false,
1478
+ type: "anomaly",
1479
+ name: "异常检测",
1480
+ message: `异常检测失败: ${err.message}`,
1481
+ duration: Date.now() - start,
1482
+ details: err.stack,
1483
+ };
1484
+ }
1485
+ }
1486
+
1487
+ // ============================================================
1488
+ // AI Engine — AITest 主类(Fluent API)
1489
+ // ============================================================
1490
+ class AITest {
1491
+ constructor(name, options) {
1492
+ this.tasks = [];
1493
+ this.name = name;
1494
+ this.stagehand = options.stagehand;
1495
+ const llmConfig = options.llm ?? {
1496
+ model: process.env.AI_MODEL || "deepseek/deepseek-v4-flash",
1497
+ };
1498
+ this.llm = new LLMClient(llmConfig);
1499
+ this.options = {
1500
+ screenshotOnFailure: options.screenshotOnFailure ?? true,
1501
+ bailOnFailure: options.bailOnFailure ?? false,
1502
+ };
1503
+ }
1504
+ // ==========================================================
1505
+ // Builder API — 链式方法
1506
+ // ==========================================================
1507
+ /** 自然语言交互:AI 自动找元素并操作(填充/点击/选择等) */
1508
+ act(instruction) {
1509
+ this.tasks.push({
1510
+ type: "act",
1511
+ name: `Act: ${instruction}`,
1512
+ execute: async (_page) => {
1513
+ const start = Date.now();
1514
+ try {
1515
+ await this.stagehand.act(instruction);
1516
+ return {
1517
+ passed: true,
1518
+ type: "act",
1519
+ name: `Act: ${instruction}`,
1520
+ message: `✓ ${instruction}`,
1521
+ duration: Date.now() - start,
1522
+ };
1523
+ }
1524
+ catch (err) {
1525
+ return {
1526
+ passed: false,
1527
+ type: "act",
1528
+ name: `Act: ${instruction}`,
1529
+ message: `✗ ${instruction}: ${err.message}`,
1530
+ duration: Date.now() - start,
1531
+ details: err.stack,
1532
+ };
1533
+ }
1534
+ },
1535
+ });
1536
+ return this;
1537
+ }
1538
+ /** 结构化提取:从页面元素提取数据并校验 Schema */
1539
+ extract(selector, schema, instruction) {
1540
+ this.tasks.push({
1541
+ type: "extract",
1542
+ name: `提取: ${selector}`,
1543
+ execute: (page) => assertExtraction(page, this.llm, selector, {
1544
+ schema,
1545
+ instruction,
1546
+ }),
1547
+ });
1548
+ return this;
1549
+ }
1550
+ /** 情感断言:判断文本情感是否符合预期 */
1551
+ assertSentiment(selector, expected, criteria) {
1552
+ this.tasks.push({
1553
+ type: "sentiment",
1554
+ name: `情感: ${selector}`,
1555
+ execute: (page) => assertSentiment(page, this.llm, selector, {
1556
+ expected,
1557
+ criteria,
1558
+ }),
1559
+ });
1560
+ return this;
1561
+ }
1562
+ /** 内容质量断言:检查长度、关键词、语气、禁用词等 */
1563
+ assertQuality(selector, opts) {
1564
+ this.tasks.push({
1565
+ type: "quality",
1566
+ name: `质量: ${selector}`,
1567
+ execute: (page) => assertQuality(page, this.llm, selector, opts),
1568
+ });
1569
+ return this;
1570
+ }
1571
+ /** 异常检测:检查页面是否处于错误/空白/加载失败等非预期状态 */
1572
+ detectAnomaly(opts = {}) {
1573
+ this.tasks.push({
1574
+ type: "anomaly",
1575
+ name: "异常检测",
1576
+ execute: (page) => assertAnomaly(page, this.llm, opts),
1577
+ });
1578
+ return this;
1579
+ }
1580
+ /** 自定义断言 */
1581
+ assertCustom(label, fn) {
1582
+ this.tasks.push({
1583
+ type: "custom",
1584
+ name: label,
1585
+ execute: async (page) => {
1586
+ const result = await fn(page, this.llm, this.stagehand);
1587
+ return { ...result, type: "custom" };
1588
+ },
1589
+ });
1590
+ return this;
1591
+ }
1592
+ // ==========================================================
1593
+ // 执行
1594
+ // ==========================================================
1595
+ async run(page) {
1596
+ const startTime = Date.now();
1597
+ const results = [];
1598
+ let totalTokens = 0;
1599
+ for (const task of this.tasks) {
1600
+ const result = await task.execute(page, this.stagehand);
1601
+ // 失败时截图
1602
+ if (!result.passed && this.options.screenshotOnFailure) {
1603
+ try {
1604
+ const buf = await page.screenshot();
1605
+ result.screenshot = buf.toString("base64");
1606
+ }
1607
+ catch {
1608
+ // 截图失败不阻断
1609
+ }
1610
+ }
1611
+ results.push(result);
1612
+ // bailOnFailure
1613
+ if (!result.passed && this.options.bailOnFailure) {
1614
+ console.warn(`[AITest] bailOnFailure 触发,跳过剩余 ${this.tasks.length - results.length} 个任务`);
1615
+ break;
1616
+ }
1617
+ }
1618
+ totalTokens = this.llm.getTotalTokens();
1619
+ const passedCount = results.filter((r) => r.passed).length;
1620
+ const totalDuration = Date.now() - startTime;
1621
+ return {
1622
+ name: this.name,
1623
+ assertions: results,
1624
+ hasFailure: passedCount < results.length,
1625
+ passRate: results.length > 0 ? passedCount / results.length : 1,
1626
+ totalDuration,
1627
+ estimatedTokens: totalTokens,
1628
+ };
1629
+ }
1630
+ }
1631
+
1632
+ // ============================================================
1633
+ // Share — 报告聚合器
1634
+ // ============================================================
1635
+ class Reporter {
1636
+ constructor(planName) {
1637
+ this.screenshots = [];
1638
+ this.planName = planName;
1639
+ this.startedAt = new Date().toISOString();
1640
+ }
1641
+ /** 构建报告 */
1642
+ build(ruleResults, aiResults, actionTotal, actionFailed) {
1643
+ const ruleLayer = this.buildRuleLayer(ruleResults);
1644
+ const aiLayer = aiResults.length === 0 && actionTotal === 0
1645
+ ? null
1646
+ : this.buildAILayer(aiResults);
1647
+ // 汇集失败项
1648
+ const failures = [];
1649
+ for (const r of ruleResults) {
1650
+ for (const a of r.assertions.filter((a) => !a.passed)) {
1651
+ failures.push({
1652
+ layer: "rule",
1653
+ testName: r.name,
1654
+ assertionName: a.name,
1655
+ message: a.message,
1656
+ details: a.details,
1657
+ });
1658
+ }
1659
+ }
1660
+ for (const r of aiResults) {
1661
+ for (const a of r.assertions.filter((a) => !a.passed)) {
1662
+ failures.push({
1663
+ layer: "ai",
1664
+ testName: r.name,
1665
+ assertionName: a.name,
1666
+ message: a.message,
1667
+ details: a.details,
1668
+ });
1669
+ }
1670
+ }
1671
+ // 收集截图
1672
+ const allResults = [
1673
+ ...ruleResults.flatMap((r) => r.assertions),
1674
+ ...aiResults.flatMap((r) => r.assertions),
1675
+ ];
1676
+ for (const a of allResults) {
1677
+ if ("screenshot" in a && a.screenshot) {
1678
+ this.screenshots.push(a.screenshot);
1679
+ }
1680
+ }
1681
+ const totalPassed = ruleLayer.passed + (aiLayer?.passed ?? 0);
1682
+ const totalFailed = ruleLayer.failed + (aiLayer?.failed ?? 0);
1683
+ const totalSkipped = ruleLayer.skipped + (aiLayer?.skipped ?? 0);
1684
+ const totalDuration = ruleLayer.duration + (aiLayer?.duration ?? 0);
1685
+ // 估算成本:每 1M token ≈ DeepSeek $0.28 (输入) + $1.10 (输出)
1686
+ const totalTokens = aiResults.reduce((sum, r) => sum + r.estimatedTokens, 0);
1687
+ const costEstimate = totalTokens > 0
1688
+ ? Math.round(totalTokens / 1000 * 0.001 * 10000) / 10000 // 粗略估算
1689
+ : 0;
1690
+ return {
1691
+ planName: this.planName,
1692
+ startedAt: this.startedAt,
1693
+ summary: {
1694
+ total: totalPassed + totalFailed + totalSkipped,
1695
+ passed: totalPassed,
1696
+ failed: totalFailed,
1697
+ skipped: totalSkipped,
1698
+ duration: totalDuration,
1699
+ costEstimate,
1700
+ },
1701
+ layers: {
1702
+ rule: ruleLayer,
1703
+ ai: aiLayer,
1704
+ actions: { total: actionTotal, failed: actionFailed },
1705
+ },
1706
+ failures,
1707
+ artifacts: {
1708
+ screenshots: this.screenshots.map((_, i) => `screenshot-${i + 1}.png`),
1709
+ },
1710
+ };
1711
+ }
1712
+ buildRuleLayer(results) {
1713
+ const allAssertions = results.flatMap((r) => r.assertions);
1714
+ const passed = allAssertions.filter((a) => a.passed).length;
1715
+ const failed = allAssertions.filter((a) => !a.passed).length;
1716
+ const totalDuration = results.reduce((sum, r) => sum + r.totalDuration, 0);
1717
+ return {
1718
+ passed,
1719
+ failed,
1720
+ skipped: 0,
1721
+ total: allAssertions.length,
1722
+ passRate: allAssertions.length > 0 ? passed / allAssertions.length : 1,
1723
+ duration: totalDuration,
1724
+ };
1725
+ }
1726
+ buildAILayer(results) {
1727
+ const allAssertions = results.flatMap((r) => r.assertions);
1728
+ const passed = allAssertions.filter((a) => a.passed).length;
1729
+ const failed = allAssertions.filter((a) => !a.passed).length;
1730
+ const totalDuration = results.reduce((sum, r) => sum + r.totalDuration, 0);
1731
+ return {
1732
+ passed,
1733
+ failed,
1734
+ skipped: 0,
1735
+ total: allAssertions.length,
1736
+ passRate: allAssertions.length > 0 ? passed / allAssertions.length : 1,
1737
+ duration: totalDuration,
1738
+ };
1739
+ }
1740
+ }
1741
+
1742
+ // ============================================================
1743
+ // Share — HTML 报告生成器
1744
+ // ============================================================
1745
+ function writeHtmlReport(report, outputDir) {
1746
+ const path = resolve(outputDir, "report.html");
1747
+ writeFileSync(path, buildHtml(report));
1748
+ return path;
1749
+ }
1750
+ function buildHtml(report) {
1751
+ const { planName, startedAt, layers, failures } = report;
1752
+ const wallDuration = report.wallDuration ?? report.summary.duration;
1753
+ const rule = layers.rule;
1754
+ const ai = layers.ai;
1755
+ const act = layers.actions;
1756
+ // 格式化时间
1757
+ const formattedTime = formatTime(startedAt);
1758
+ const durationStr = formatDuration(wallDuration);
1759
+ return `<!DOCTYPE html>
1760
+ <html lang="zh">
1761
+ <head>
1762
+ <meta charset="UTF-8">
1763
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1764
+ <title>${esc(planName)} — 测试报告</title>
1765
+ <style>
1766
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1767
+ body { font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1a1a2e; background: #f0f2f5; }
1768
+ .wrap { max-width: 1024px; margin: 0 auto; padding: 24px 16px; }
1769
+
1770
+ h1 { font-size: 22px; margin-bottom: 2px; }
1771
+ .time { color: #999; font-size: 12px; margin-bottom: 20px; }
1772
+
1773
+ /* stat row */
1774
+ .stat-row { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
1775
+ .stat-card { flex: 1; min-width: 140px; background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.05); }
1776
+ .stat-card .title { font-size: 12px; color: #999; margin-bottom: 6px; }
1777
+ .stat-card .body { display: flex; align-items: baseline; gap: 8px; }
1778
+ .stat-card .val { font-size: 30px; font-weight: 700; }
1779
+ .stat-card .meta { font-size: 12px; color: #bbb; }
1780
+ .v-pass { color: #10b981; }
1781
+ .v-fail { color: #ef4444; }
1782
+ .v-info { color: #6366f1; }
1783
+
1784
+ /* section */
1785
+ .section { background: #fff; border-radius: 10px; padding: 20px; margin-bottom: 14px; box-shadow: 0 1px 4px rgba(0,0,0,.05); }
1786
+ .section h2 { font-size: 15px; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
1787
+ .bar-wrap { height: 6px; background: #e5e7eb; border-radius: 3px; margin-bottom: 10px; overflow: hidden; }
1788
+ .bar-inner { height: 100%; border-radius: 3px; transition: width .3s; }
1789
+ .bar-pass { background: #10b981; }
1790
+ .bar-fail { background: #ef4444; }
1791
+
1792
+ /* failures */
1793
+ .fail-item { background: #fef2f2; border-radius: 6px; padding: 10px 14px; margin-bottom: 8px; }
1794
+ .fail-item .layer { font-size: 11px; color: #991b1b; font-weight: 600; margin-bottom: 3px; }
1795
+ .fail-item .msg { font-size: 13px; }
1796
+ .fail-item .detail { font-size: 12px; color: #999; margin-top: 4px; white-space: pre-wrap; word-break: break-all; }
1797
+
1798
+ /* screenshots */
1799
+ .shots { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
1800
+ .shots figure { cursor: pointer; transition: transform .15s; }
1801
+ .shots figure:hover { transform: scale(1.02); }
1802
+ .shots img { width: 100%; border-radius: 6px; border: 1px solid #e5e7eb; display: block; }
1803
+ .shots figcaption { font-size: 11px; color: #999; text-align: center; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1804
+
1805
+ /* lightbox */
1806
+ .lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.88); z-index: 999; align-items: center; justify-content: center; }
1807
+ .lightbox.show { display: flex; }
1808
+ .lightbox img { max-width: 92vw; max-height: 92vh; border-radius: 6px; cursor: zoom-out; }
1809
+ .lightbox .close { position: absolute; top: 16px; right: 24px; color: #fff; font-size: 32px; cursor: pointer; line-height: 1; }
1810
+ .lightbox .caption { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); color: #ccc; font-size: 13px; }
1811
+ .lightbox .nav { position: absolute; top: 50%; transform: translateY(-50%); color: #fff; font-size: 40px; cursor: pointer; padding: 16px; user-select: none; }
1812
+ .lightbox .prev { left: 8px; }
1813
+ .lightbox .next { right: 8px; }
1814
+ </style>
1815
+ </head>
1816
+ <body>
1817
+
1818
+ <div class="wrap">
1819
+ <h1>📊 ${esc(planName)}</h1>
1820
+ <div class="time">${formattedTime}</div>
1821
+
1822
+ <div class="stat-row">
1823
+ <div class="stat-card">
1824
+ <div class="title">📏 Rule 断言</div>
1825
+ <div class="body">
1826
+ <span class="val v-pass">${rule.passed}</span><span class="meta">通过</span>
1827
+ <span class="val v-fail">${rule.failed}</span><span class="meta">失败</span>
1828
+ </div>
1829
+ </div>
1830
+ ${ai ? `
1831
+ <div class="stat-card">
1832
+ <div class="title">🤖 AI 断言</div>
1833
+ <div class="body">
1834
+ <span class="val v-pass">${ai.passed}</span><span class="meta">通过</span>
1835
+ <span class="val v-fail">${ai.failed}</span><span class="meta">失败</span>
1836
+ </div>
1837
+ </div>` : ""}
1838
+ <div class="stat-card">
1839
+ <div class="title">🖱️ 操作</div>
1840
+ <div class="body">
1841
+ <span class="val v-pass">${act.total - act.failed}</span><span class="meta">成功</span>
1842
+ <span class="val v-fail">${act.failed}</span><span class="meta">失败</span>
1843
+ </div>
1844
+ </div>
1845
+ <div class="stat-card">
1846
+ <div class="title">⏱️ 总耗时</div>
1847
+ <div class="body"><span class="val v-info">${durationStr}</span></div>
1848
+ </div>
1849
+ </div>
1850
+
1851
+ ${buildLayerSection("📏 Rule Engine", rule)}
1852
+ ${buildActionSection("🖱️ 操作详情", act)}
1853
+ ${ai ? buildLayerSection("🤖 AI Engine", ai) : ""}
1854
+ ${buildFailuresSection(failures)}
1855
+ ${buildScreenshotsSection(report)}
1856
+ </div>
1857
+
1858
+ <div class="lightbox" id="lightbox" onclick="closeLightbox()">
1859
+ <span class="close">&times;</span>
1860
+ <span class="nav prev" onclick="event.stopPropagation();navLightbox(-1)">‹</span>
1861
+ <img id="lb-img" src="" alt="" onclick="event.stopPropagation()">
1862
+ <span class="nav next" onclick="event.stopPropagation();navLightbox(1)">›</span>
1863
+ <div class="caption" id="lb-caption"></div>
1864
+ </div>
1865
+
1866
+ <script>
1867
+ var lbImages = [];
1868
+ var lbIndex = 0;
1869
+
1870
+ document.querySelectorAll('.shots img').forEach(function(img, idx) {
1871
+ lbImages.push({ src: img.src, alt: (img.nextElementSibling || {}).textContent || img.alt });
1872
+ img.addEventListener('click', function(e) { e.stopPropagation(); openLightbox(idx); });
1873
+ img.title = '点击放大 · 键盘 ← → 切换 · Esc 关闭';
1874
+ });
1875
+
1876
+ function openLightbox(idx) {
1877
+ lbIndex = idx;
1878
+ document.getElementById('lb-img').src = lbImages[idx].src;
1879
+ document.getElementById('lb-caption').textContent = lbImages[idx].alt;
1880
+ document.getElementById('lightbox').classList.add('show');
1881
+ document.body.style.overflow = 'hidden';
1882
+ }
1883
+ function closeLightbox() {
1884
+ document.getElementById('lightbox').classList.remove('show');
1885
+ document.body.style.overflow = '';
1886
+ }
1887
+ function navLightbox(dir) {
1888
+ lbIndex = (lbIndex + dir + lbImages.length) % lbImages.length;
1889
+ document.getElementById('lb-img').src = lbImages[lbIndex].src;
1890
+ document.getElementById('lb-caption').textContent = lbImages[lbIndex].alt;
1891
+ }
1892
+ document.addEventListener('keydown', function(e) {
1893
+ if (!document.getElementById('lightbox').classList.contains('show')) return;
1894
+ if (e.key === 'Escape') closeLightbox();
1895
+ if (e.key === 'ArrowLeft') navLightbox(-1);
1896
+ if (e.key === 'ArrowRight') navLightbox(1);
1897
+ });
1898
+ </script>
1899
+ </body>
1900
+ </html>`;
1901
+ }
1902
+ function buildLayerSection(title, layer) {
1903
+ const passPct = (layer.passRate * 100).toFixed(0);
1904
+ const isAllPass = layer.failed === 0;
1905
+ return `
1906
+ <div class="section">
1907
+ <h2><span>${isAllPass ? "✅" : "❌"}</span> ${title}</h2>
1908
+ <div class="bar-wrap">
1909
+ <div class="bar-inner bar-${isAllPass ? "pass" : "fail"}" style="width:${passPct}%"></div>
1910
+ </div>
1911
+ <p style="font-size:13px;color:#999">
1912
+ 通过率 ${passPct}% &nbsp;|&nbsp; ${layer.passed}/${layer.total} 通过 &nbsp;|&nbsp; ${layer.duration}ms
1913
+ </p>
1914
+ </div>`;
1915
+ }
1916
+ function buildActionSection(title, actions) {
1917
+ if (actions.total === 0)
1918
+ return "";
1919
+ const ok = actions.total - actions.failed;
1920
+ const passPct = actions.total > 0 ? (ok / actions.total * 100).toFixed(0) : "0";
1921
+ const isAllPass = actions.failed === 0;
1922
+ return `
1923
+ <div class="section">
1924
+ <h2><span>${isAllPass ? "✅" : "⚠️"}</span> ${title}</h2>
1925
+ <div class="bar-wrap">
1926
+ <div class="bar-inner bar-${isAllPass ? "pass" : "fail"}" style="width:${passPct}%"></div>
1927
+ </div>
1928
+ <p style="font-size:13px;color:#999">
1929
+ 共 ${actions.total} 次 &nbsp;|&nbsp; 成功 ${ok} &nbsp;|&nbsp; 失败 ${actions.failed}
1930
+ </p>
1931
+ </div>`;
1932
+ }
1933
+ function buildFailuresSection(failures) {
1934
+ if (failures.length === 0)
1935
+ return "";
1936
+ const items = failures.map((f) => `
1937
+ <div class="fail-item">
1938
+ <div class="layer">${f.layer === "rule" ? "📏 Rule" : f.layer === "ai" ? "🤖 AI" : "🖱️ Action"} | ${esc(f.testName)}</div>
1939
+ <div class="msg">${esc(f.message)}</div>
1940
+ ${f.details ? `<div class="detail">${esc(f.details)}</div>` : ""}
1941
+ </div>`).join("");
1942
+ return `
1943
+ <div class="section">
1944
+ <h2><span>❌</span> 失败详情 (${failures.length})</h2>
1945
+ ${items}
1946
+ </div>`;
1947
+ }
1948
+ function buildScreenshotsSection(report) {
1949
+ const paths = report.artifacts.screenshots ?? [];
1950
+ if (paths.length === 0)
1951
+ return "";
1952
+ const images = paths.map((p) => {
1953
+ const name = p.replace(/^.*[\\/]/, "");
1954
+ try {
1955
+ if (existsSync(p)) {
1956
+ const buf = readFileSync(p);
1957
+ const b64 = buf.toString("base64");
1958
+ const ext = p.split(".").pop()?.toLowerCase() ?? "png";
1959
+ const mime = ext === "jpg" ? "jpeg" : ext;
1960
+ return `<figure><img src="data:image/${mime};base64,${b64}" alt="${esc(name)}"><figcaption>${esc(name)}</figcaption></figure>`;
1961
+ }
1962
+ }
1963
+ catch { /* skip */ }
1964
+ return `<figure><figcaption>${esc(name)} (无法加载)</figcaption></figure>`;
1965
+ }).join("");
1966
+ return `
1967
+ <div class="section">
1968
+ <h2><span>📸</span> 截图</h2>
1969
+ <div class="shots">${images}</div>
1970
+ </div>`;
1971
+ }
1972
+ function esc(s) {
1973
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1974
+ }
1975
+ function formatTime(iso) {
1976
+ try {
1977
+ const d = new Date(iso);
1978
+ const pad = (n) => String(n).padStart(2, "0");
1979
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
1980
+ }
1981
+ catch {
1982
+ return iso;
1983
+ }
1984
+ }
1985
+ function formatDuration(ms) {
1986
+ if (ms < 1000)
1987
+ return `${ms}ms`;
1988
+ const sec = Math.floor(ms / 1000);
1989
+ if (sec < 60)
1990
+ return `${sec}s`;
1991
+ const min = Math.floor(sec / 60);
1992
+ const remain = sec % 60;
1993
+ return remain > 0 ? `${min}m ${remain}s` : `${min}m`;
1994
+ }
1995
+
1996
+ // ============================================================
1997
+ // Share — Composition API(Vue3 风格组合式测试)
1998
+ // ============================================================
1999
+ // -----------------------------------------------------------
2000
+ // Thenable 包装 — await 时自动执行
2001
+ // -----------------------------------------------------------
2002
+ /**
2003
+ * 可自动执行的 RuleTest
2004
+ *
2005
+ * 用法:
2006
+ * await check("名称").element("input").performance("FCP")
2007
+ * // ↑ 链式构建 ↑
2008
+ * // ↑ await 触发 .run(page)
2009
+ */
2010
+ class AutoRuleTest extends RuleTest {
2011
+ constructor(name, page, onResult) {
2012
+ super(name);
2013
+ // 简写别名 — 直接委托给父类方法
2014
+ this.element = this.assertElement;
2015
+ this.text = this.assertText;
2016
+ this.attribute = this.assertAttribute;
2017
+ this.url = this.assertUrl;
2018
+ this.title = this.assertTitle;
2019
+ this.meta = this.assertMeta;
2020
+ this.status = this.assertStatusCode;
2021
+ this.performance = this.assertPerformance;
2022
+ this.accessibility = this.assertAccessibility;
2023
+ this.custom = this.assertCustom;
2024
+ this._page = page;
2025
+ this._onResult = onResult;
2026
+ }
2027
+ then(onfulfilled, onrejected) {
2028
+ return this.run(this._page)
2029
+ .then((r) => {
2030
+ this._onResult(r);
2031
+ return r;
2032
+ })
2033
+ .then(onfulfilled, onrejected);
2034
+ }
2035
+ }
2036
+ /**
2037
+ * 可自动执行的 AITest
2038
+ *
2039
+ * 用法:
2040
+ * await inspect("名称").anomaly().sentiment(".msg", "positive")
2041
+ */
2042
+ class AutoAITest extends AITest {
2043
+ constructor(name, page, onResult, stagehand) {
2044
+ super(name, { stagehand });
2045
+ // 简写别名
2046
+ this.anomaly = this.detectAnomaly;
2047
+ this.sentiment = this.assertSentiment;
2048
+ this.quality = this.assertQuality;
2049
+ this._page = page;
2050
+ this._onResult = onResult;
2051
+ }
2052
+ then(onfulfilled, onrejected) {
2053
+ return this.run(this._page)
2054
+ .then((r) => {
2055
+ this._onResult(r);
2056
+ return r;
2057
+ })
2058
+ .then(onfulfilled, onrejected);
2059
+ }
2060
+ }
2061
+ // -----------------------------------------------------------
2062
+ // createTestContext — 创建组合式上下文
2063
+ // -----------------------------------------------------------
2064
+ function createTestContext(page, options) {
2065
+ const name = options?.planName ?? "Test Plan";
2066
+ const ruleResults = [];
2067
+ const aiResults = [];
2068
+ let actionTotal = 0;
2069
+ let actionFailed = 0;
2070
+ let screenshotCount = 0;
2071
+ const screenshotPaths = [];
2072
+ const outputDir = options?.outputDir ?? resolve(process.cwd(), "logs");
2073
+ const ctx = {
2074
+ name,
2075
+ page,
2076
+ stagehand: options?.stagehand,
2077
+ // ======== 核心 API ========
2078
+ has: async (selector) => {
2079
+ try {
2080
+ return (await page.locator(selector).count()) > 0;
2081
+ }
2082
+ catch {
2083
+ return false;
2084
+ }
2085
+ },
2086
+ which: async (selectors) => {
2087
+ const counts = await Promise.all(selectors.map((s) => page
2088
+ .locator(s)
2089
+ .count()
2090
+ .catch(() => 0)));
2091
+ return counts.findIndex((c) => c > 0);
2092
+ },
2093
+ click: async (selector, nth) => {
2094
+ try {
2095
+ const items = page.locator(selector);
2096
+ const count = await items.count();
2097
+ if (count === 0)
2098
+ return; // 元素不存在,静默跳过
2099
+ const visible = [];
2100
+ for (let i = 0; i < count; i++) {
2101
+ if (await items
2102
+ .nth(i)
2103
+ .isVisible()
2104
+ .catch(() => false)) {
2105
+ visible.push(i);
2106
+ }
2107
+ }
2108
+ const target = nth ?? 0;
2109
+ if (visible.length === 0)
2110
+ throw new Error(`所有 ${count} 个匹配元素都不可见`);
2111
+ if (target >= visible.length)
2112
+ throw new Error(`只有 ${visible.length} 个可见元素,无法取第 ${target} 个`);
2113
+ actionTotal++;
2114
+ await items.nth(visible[target]).click();
2115
+ console.log(` ✓ click ${selector}${nth !== undefined ? `[${nth}]` : ""}\n`);
2116
+ }
2117
+ catch (err) {
2118
+ actionFailed++;
2119
+ console.error(` ✗ click ${selector}: ${err.message} [${page.url()}]\n`);
2120
+ }
2121
+ },
2122
+ fill: async (selector, value) => {
2123
+ actionTotal++;
2124
+ try {
2125
+ await page.locator(selector).first().fill(value);
2126
+ console.log(` ✓ fill ${selector}\n`);
2127
+ }
2128
+ catch (err) {
2129
+ actionFailed++;
2130
+ console.error(` ✗ fill ${selector}: ${err.message} [${page.url()}]\n`);
2131
+ }
2132
+ },
2133
+ screenshot: async (label) => {
2134
+ const dir = resolve(outputDir, "screenshots");
2135
+ mkdirSync(dir, { recursive: true });
2136
+ const step = String(screenshotCount++).padStart(2, "0");
2137
+ const name = `${step}-${label.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, "_")}.png`;
2138
+ const filepath = resolve(dir, name);
2139
+ await page.screenshot({ path: filepath, fullPage: true });
2140
+ screenshotPaths.push(filepath);
2141
+ console.log(`📸 ${name}`);
2142
+ },
2143
+ sleep: async (ms) => {
2144
+ await page.waitForTimeout(ms);
2145
+ },
2146
+ ai: async (instruction) => {
2147
+ const aiTest = new AutoAITest("AI", page, (r) => {
2148
+ aiResults.push(r);
2149
+ console.log(`🤖 ${r.assertions.map((a) => a.message).join("; ")}\n`);
2150
+ }, options?.stagehand);
2151
+ aiTest.act(instruction);
2152
+ aiTest.anomaly();
2153
+ await aiTest;
2154
+ },
2155
+ wait: async (selector, timeout = 10000) => {
2156
+ await page.locator(selector).first().waitFor({ state: "visible", timeout }).catch(() => { });
2157
+ },
2158
+ // ======== 进阶 API ========
2159
+ check: (testName) => {
2160
+ return new AutoRuleTest(testName, page, (r) => {
2161
+ ruleResults.push(r);
2162
+ printCheckResult(testName, r);
2163
+ });
2164
+ },
2165
+ task: async (label, fn, aiFallback) => {
2166
+ actionTotal++;
2167
+ let via = "";
2168
+ try {
2169
+ await fn(page);
2170
+ via = aiFallback ? "原生 (免费)" : "";
2171
+ console.log(` ✓ ${via}\n`);
2172
+ return true;
2173
+ }
2174
+ catch (nativeErr) {
2175
+ if (!aiFallback) {
2176
+ actionFailed++;
2177
+ console.error(` ✗ 失败: ${nativeErr.message}\n`);
2178
+ return false;
2179
+ }
2180
+ console.log(` → 原生失败,降级 AI...`);
2181
+ try {
2182
+ const aiTest = new AutoAITest(label, page, (r) => {
2183
+ aiResults.push(r);
2184
+ printInspectResult(label, r);
2185
+ }, options?.stagehand);
2186
+ aiTest.act(aiFallback);
2187
+ await aiTest;
2188
+ console.log(` ✓ AI 兜底成功\n`);
2189
+ return false;
2190
+ }
2191
+ catch (aiErr) {
2192
+ actionFailed++;
2193
+ console.error(` ✗ AI 也失败: ${aiErr.message}\n`);
2194
+ return false;
2195
+ }
2196
+ }
2197
+ },
2198
+ inspect: (testName) => {
2199
+ return new AutoAITest(testName, page, (r) => {
2200
+ aiResults.push(r);
2201
+ printInspectResult(testName, r);
2202
+ }, options?.stagehand);
2203
+ },
2204
+ network: () => {
2205
+ const netCalls = [];
2206
+ // 用原生 Playwright page.on("response") 实时收集
2207
+ page.on("response", async (response) => {
2208
+ try {
2209
+ const body = await response.text();
2210
+ netCalls.push({
2211
+ url: response.url(),
2212
+ status: response.status(),
2213
+ headers: response.headers(),
2214
+ body,
2215
+ timestamp: Date.now(),
2216
+ });
2217
+ }
2218
+ catch {
2219
+ netCalls.push({
2220
+ url: response.url(),
2221
+ status: response.status(),
2222
+ headers: response.headers(),
2223
+ body: "",
2224
+ timestamp: Date.now(),
2225
+ });
2226
+ }
2227
+ });
2228
+ return {
2229
+ getCalls: () => [...netCalls],
2230
+ filter(urlPattern) {
2231
+ return netCalls.filter((c) => typeof urlPattern === "string"
2232
+ ? c.url.includes(urlPattern)
2233
+ : urlPattern.test(c.url));
2234
+ },
2235
+ assertCall(urlPattern, opts = {}) {
2236
+ const matched = netCalls.filter((c) => typeof urlPattern === "string"
2237
+ ? c.url.includes(urlPattern)
2238
+ : urlPattern.test(c.url));
2239
+ if (matched.length === 0) {
2240
+ return { passed: false, message: `未捕获到: ${urlPattern}` };
2241
+ }
2242
+ const call = matched[matched.length - 1];
2243
+ if (opts.status !== undefined && call.status !== opts.status) {
2244
+ return {
2245
+ passed: false,
2246
+ message: `状态码期望 ${opts.status},实际 ${call.status}`,
2247
+ };
2248
+ }
2249
+ if (opts.bodyContains && !call.body.includes(opts.bodyContains)) {
2250
+ return {
2251
+ passed: false,
2252
+ message: `响应体不包含 "${opts.bodyContains}"`,
2253
+ };
2254
+ }
2255
+ if (opts.bodySchema) {
2256
+ try {
2257
+ const json = JSON.parse(call.body);
2258
+ for (const [key, type] of Object.entries(opts.bodySchema)) {
2259
+ if (!(key in json))
2260
+ return { passed: false, message: `缺少字段: ${key}` };
2261
+ if (type !== "any" && typeof json[key] !== type)
2262
+ return {
2263
+ passed: false,
2264
+ message: `${key}: 期望 ${type},实际 ${typeof json[key]}`,
2265
+ };
2266
+ }
2267
+ }
2268
+ catch {
2269
+ return { passed: false, message: "响应体非有效 JSON" };
2270
+ }
2271
+ }
2272
+ const shortUrl = call.url.length > 60 ? call.url.slice(0, 57) + "..." : call.url;
2273
+ return { passed: true, message: `HTTP ${call.status} — ${shortUrl}` };
2274
+ },
2275
+ };
2276
+ },
2277
+ };
2278
+ // 挂载 finalize 方法
2279
+ ctx._finalize = () => {
2280
+ const reporter = new Reporter(name);
2281
+ const report = reporter.build(ruleResults, aiResults, actionTotal, actionFailed);
2282
+ report.artifacts.screenshots = screenshotPaths;
2283
+ return report;
2284
+ };
2285
+ return ctx;
2286
+ }
2287
+ /**
2288
+ * 定义测试计划
2289
+ *
2290
+ * @example
2291
+ * ```ts
2292
+ * export default definePlan("PWA 登录", async ({ page, has, click, fill, screenshot, check, ai }) => {
2293
+ * await page.goto("https://example.com/login");
2294
+ *
2295
+ * await check("登录页元素")
2296
+ * .element("input", { minCount: 2 })
2297
+ * .performance("FCP", { lt: 3000 });
2298
+ *
2299
+ * await fill('input[type="email"]', "test@test.com");
2300
+ * await click('[type="submit"]');
2301
+ * await screenshot("登录后");
2302
+ *
2303
+ * await check("登录结果")
2304
+ * .url("/dashboard");
2305
+ * });
2306
+ * ```
2307
+ */
2308
+ function definePlan(name, setup) {
2309
+ return {
2310
+ name,
2311
+ run: async (page, runOptions) => {
2312
+ console.log(`\n🚀 开始执行计划: ${name}\n`);
2313
+ const ctx = createTestContext(page, {
2314
+ planName: name,
2315
+ stagehand: runOptions?.stagehand,
2316
+ outputDir: runOptions?.outputDir,
2317
+ });
2318
+ const startTime = Date.now();
2319
+ await setup(ctx);
2320
+ const report = ctx._finalize();
2321
+ report.startedAt = new Date(startTime).toISOString();
2322
+ report.wallDuration = Date.now() - startTime;
2323
+ printReport(report);
2324
+ // 写 JSON 报告
2325
+ const out = runOptions?.outputDir ?? resolve(process.cwd(), "logs");
2326
+ const jsonPath = resolve(out, "report.json");
2327
+ writeFileSync(jsonPath, JSON.stringify(report, null, 2));
2328
+ // 写 HTML 报告
2329
+ const htmlPath = writeHtmlReport(report, out);
2330
+ console.log(`📄 报告已保存: ${jsonPath}`);
2331
+ console.log(`📄 报告已保存: ${htmlPath}\n`);
2332
+ return report;
2333
+ },
2334
+ };
2335
+ }
2336
+ // -----------------------------------------------------------
2337
+ // 打印工具
2338
+ // -----------------------------------------------------------
2339
+ function printCheckResult(name, r) {
2340
+ const icon = r.hasFailure ? "❌" : "✅";
2341
+ console.log(`🔧 ${name}`);
2342
+ console.log(` ${icon} ${(r.passRate * 100).toFixed(0)}% | ${r.totalDuration}ms`);
2343
+ for (const a of r.assertions) {
2344
+ console.log(` ${a.passed ? "✓" : "✗"} ${a.message} (${a.duration}ms)`);
2345
+ }
2346
+ console.log("");
2347
+ }
2348
+ function printInspectResult(name, r) {
2349
+ const icon = r.hasFailure ? "❌" : "✅";
2350
+ console.log(`🤖 ${name}`);
2351
+ console.log(` ${icon} ${(r.passRate * 100).toFixed(0)}% | ${r.totalDuration}ms | Token: ${r.estimatedTokens}`);
2352
+ for (const a of r.assertions) {
2353
+ console.log(` ${a.passed ? "✓" : "✗"} ${a.message} (${a.duration}ms)`);
2354
+ }
2355
+ console.log("");
2356
+ }
2357
+ function printReport(report) {
2358
+ const { summary, layers } = report;
2359
+ console.log(`\n${"=".repeat(50)}`);
2360
+ console.log(` 📊 ${report.planName}`);
2361
+ console.log(` 总断言: ${summary.total} | 通过: ${summary.passed} | 失败: ${summary.failed}`);
2362
+ console.log(` 耗时: ${summary.duration}ms | AI 成本: $${summary.costEstimate.toFixed(4)}`);
2363
+ if (layers.actions.total > 0) {
2364
+ console.log(` 操作: ${layers.actions.total} 次 (失败 ${layers.actions.failed})`);
2365
+ }
2366
+ console.log(`${"=".repeat(50)}\n`);
2367
+ }
2368
+ /** 判断当前文件是否作为入口直接运行 */
2369
+ function isMain(metaUrl) {
2370
+ const entry = process.argv[1]?.replace(/\\/g, "/") ?? "";
2371
+ const self = metaUrl.replace("file:///", "").replace(/\\/g, "/");
2372
+ return entry === self || entry.endsWith("/" + self.split("/").pop());
2373
+ }
2374
+
2375
+ // ============================================================
2376
+ // 用户配置 — h2o.config.ts
2377
+ // ============================================================
2378
+ /** 定义配置(纯类型助手,不做运行时处理) */
2379
+ function defineConfig(config) {
2380
+ return config;
2381
+ }
2382
+ // -----------------------------------------------------------
2383
+ // 配置解析 — 优先级:用户配置 > 环境变量 > 默认值
2384
+ // -----------------------------------------------------------
2385
+ // 从用户项目根目录加载(process.cwd())
2386
+ const CONFIG_FILES = [
2387
+ "h2o.config.ts",
2388
+ "h2o.config.js",
2389
+ "h2o.config.mjs",
2390
+ ];
2391
+ let _cached = null;
2392
+ async function loadUserConfig() {
2393
+ if (_cached)
2394
+ return _cached;
2395
+ let userConfig = {};
2396
+ // 尝试加载用户配置文件(从 process.cwd() 解析绝对路径)
2397
+ for (const file of CONFIG_FILES) {
2398
+ try {
2399
+ const absPath = resolve(process.cwd(), file);
2400
+ const mod = await import(pathToFileURL(absPath).href);
2401
+ userConfig = mod.default ?? mod;
2402
+ break;
2403
+ }
2404
+ catch { /* file not found, try next */ }
2405
+ }
2406
+ // 合并默认值
2407
+ _cached = {
2408
+ ai: {
2409
+ apiKey: userConfig.ai?.apiKey ?? process.env.DEEPSEEK_API_KEY ?? process.env.OPENAI_API_KEY,
2410
+ model: userConfig.ai?.model ?? process.env.AI_MODEL ?? "deepseek/deepseek-v4-flash",
2411
+ timeout: userConfig.ai?.timeout ?? (Number(process.env.AI_TIMEOUT) || 30000),
2412
+ temperature: userConfig.ai?.temperature ?? 0.1,
2413
+ maxTokens: userConfig.ai?.maxTokens ?? 1024,
2414
+ },
2415
+ output: {
2416
+ dir: userConfig.output?.dir ?? process.env.OUTPUT_DIR ?? "./logs",
2417
+ },
2418
+ browser: {
2419
+ headless: userConfig.browser?.headless ?? process.env.HEADLESS === "true",
2420
+ viewport: userConfig.browser?.viewport ?? {
2421
+ width: Number(process.env.VIEWPORT_WIDTH) || 1280,
2422
+ height: Number(process.env.VIEWPORT_HEIGHT) || 720,
2423
+ },
2424
+ },
2425
+ };
2426
+ return _cached;
2427
+ }
2428
+
2429
+ // ============================================================
2430
+ // Share — 浏览器启动器(Stagehand + Playwright 集成)
2431
+ // ============================================================
2432
+ const CHROME_PREFS = {
2433
+ credentials_enable_service: false,
2434
+ profile: { password_manager_enabled: false, password_manager_leak_detection: false },
2435
+ safebrowsing: { enabled: false },
2436
+ };
2437
+ function createChromeProfile(baseDir) {
2438
+ const dir = resolve(baseDir, "chrome-profile");
2439
+ const prefsDir = resolve(dir, "Default");
2440
+ mkdirSync(prefsDir, { recursive: true });
2441
+ writeFileSync(resolve(prefsDir, "Preferences"), JSON.stringify(CHROME_PREFS));
2442
+ return dir;
2443
+ }
2444
+ async function createBrowser(baseDir, options = {}) {
2445
+ const config = await loadUserConfig();
2446
+ const userDataDir = createChromeProfile(baseDir);
2447
+ // 从 config 注入 API Key 到环境变量(Stagehand 从 env 读取)
2448
+ if (config.ai?.apiKey) {
2449
+ const model = (options.stagehand?.model ?? config.ai?.model ?? "").toLowerCase();
2450
+ if (model.includes("deepseek"))
2451
+ process.env.DEEPSEEK_API_KEY = config.ai.apiKey;
2452
+ else if (model.includes("openai") || model.includes("gpt"))
2453
+ process.env.OPENAI_API_KEY = config.ai.apiKey;
2454
+ else if (model.includes("anthropic") || model.includes("claude"))
2455
+ process.env.ANTHROPIC_API_KEY = config.ai.apiKey;
2456
+ }
2457
+ const stagehand = new Stagehand({
2458
+ env: "LOCAL",
2459
+ model: options.stagehand?.model ?? config.ai?.model ?? "deepseek/deepseek-v4-flash",
2460
+ localBrowserLaunchOptions: {
2461
+ headless: options.stagehand?.headless ?? config.browser?.headless ?? false,
2462
+ userDataDir,
2463
+ },
2464
+ });
2465
+ await stagehand.init();
2466
+ const browser = await chromium.connectOverCDP({
2467
+ wsEndpoint: stagehand.connectURL(),
2468
+ });
2469
+ const pwContext = browser.contexts()[0];
2470
+ const pwPage = pwContext.pages()[0];
2471
+ if (config.browser?.viewport) {
2472
+ await pwPage.setViewportSize(config.browser.viewport);
2473
+ }
2474
+ return { stagehand, page: pwPage };
2475
+ }
2476
+ async function runPlan(plan, options) {
2477
+ const config = await loadUserConfig();
2478
+ const outDir = config.output?.dir ?? "./logs";
2479
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
2480
+ const runDir = resolve(process.cwd(), outDir, `run-${ts}`);
2481
+ mkdirSync(runDir, { recursive: true });
2482
+ console.log(`📁 ${runDir}\n`);
2483
+ console.log(`🚀 启动浏览器...\n`);
2484
+ const { stagehand, page } = await createBrowser(runDir, options);
2485
+ const report = await plan.run(page, { stagehand, outputDir: runDir });
2486
+ await stagehand.close();
2487
+ console.log("✅ 全部测试完成");
2488
+ return report;
2489
+ }
2490
+
2491
+ export { createBrowser, createChromeProfile, createTestContext, defineConfig, definePlan, isMain, runPlan, writeHtmlReport };