@szc-ft/mcp-szcd-client 0.27.1 → 0.27.3

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.
@@ -61,7 +61,7 @@ export class BrowserEngine {
61
61
  */
62
62
  async connect(cdpUrl, options = {}) {
63
63
  const puppeteer = await loadPuppeteer();
64
- this.browser = await puppeteer.connect({ browserURL: cdpUrl });
64
+ this.browser = await puppeteer.connect({ browserURL: cdpUrl, defaultViewport: null });
65
65
  this.mode = "connect";
66
66
  this.ownedBrowser = false;
67
67
 
@@ -145,11 +145,14 @@ export class BrowserEngine {
145
145
  const startTime = Date.now();
146
146
  try {
147
147
  const result = await this._dispatchStep(step, context);
148
+ if (step.type === "apiCapture" || step.type === "api-capture") {
149
+ context.lastApiCapture = result;
150
+ }
148
151
  return {
149
152
  ...result,
150
153
  step: step.type,
151
154
  duration: Date.now() - startTime,
152
- status: "PASS",
155
+ status: result?.passed === false ? "FAIL" : "PASS",
153
156
  };
154
157
  } catch (err) {
155
158
  return {
@@ -178,11 +181,834 @@ export class BrowserEngine {
178
181
  }
179
182
  }
180
183
 
184
+ async observe(options = {}) {
185
+ const targetFrame = await this._resolveObserveFrame(options);
186
+ const selector = options.selector || "body";
187
+ const depth = Number.parseInt(options.depth ?? 5, 10);
188
+ const maxInteractiveElements = Number.parseInt(options.maxInteractiveElements ?? 200, 10);
189
+ const frames = await this._getFrameSummaries();
190
+ const tree = await this._buildDomTree(targetFrame.frame, selector, depth);
191
+ const interactiveElements = await this._collectInteractiveElements(
192
+ targetFrame.frame,
193
+ targetFrame.frameId,
194
+ maxInteractiveElements
195
+ );
196
+ const title = await this.page.title().catch(() => "");
197
+
198
+ return {
199
+ type: "observe",
200
+ url: this.page.url(),
201
+ title,
202
+ timestamp: new Date().toISOString(),
203
+ mode: this.mode,
204
+ target: {
205
+ frameId: targetFrame.frameId,
206
+ frameUrl: targetFrame.frame.url(),
207
+ matchedBy: targetFrame.matchedBy,
208
+ },
209
+ frames,
210
+ tree,
211
+ interactiveElements,
212
+ summary: {
213
+ frames: frames.length,
214
+ interactiveElements: interactiveElements.length,
215
+ },
216
+ };
217
+ }
218
+
219
+ async act(options = {}) {
220
+ const targetFrame = await this._resolveObserveFrame(options);
221
+ if (options.menuPath) {
222
+ return this._actMenuPath(targetFrame, options);
223
+ }
224
+
225
+ const index = options.index !== undefined ? Number.parseInt(options.index, 10) : undefined;
226
+ const selector = this._resolveSelector(options.selector || options.click || options.hover);
227
+ const typeText = options.typeText ?? options.text;
228
+ const action = options.hover ? "hover" : typeText !== undefined ? "type" : "click";
229
+
230
+ const result = await targetFrame.frame.evaluate((actOptions) => {
231
+ function getText(el) {
232
+ return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120);
233
+ }
234
+
235
+ function roleOf(el) {
236
+ const explicit = el.getAttribute("role");
237
+ if (explicit) return explicit;
238
+ const tag = el.tagName.toLowerCase();
239
+ if (tag === "button") return "button";
240
+ if (tag === "a") return "link";
241
+ if (["input", "textarea"].includes(tag)) return "textbox";
242
+ if (tag === "select") return "combobox";
243
+ return "generic";
244
+ }
245
+
246
+ function getName(el, text) {
247
+ return (
248
+ el.getAttribute("aria-label") ||
249
+ el.getAttribute("title") ||
250
+ el.getAttribute("placeholder") ||
251
+ el.getAttribute("alt") ||
252
+ text ||
253
+ ""
254
+ ).slice(0, 120);
255
+ }
256
+
257
+ function isCandidate(el) {
258
+ const tag = el.tagName.toLowerCase();
259
+ return ["a", "button", "input", "select", "textarea", "summary"].includes(tag) ||
260
+ el.getAttribute("role") ||
261
+ el.getAttribute("tabindex") !== null ||
262
+ el.onclick ||
263
+ el.getAttribute("data-testid") ||
264
+ (typeof el.className === "string" && /btn|button|menu-item|ant-btn|ant-menu|ant-select|ant-input/.test(el.className));
265
+ }
266
+
267
+ function interactiveElements() {
268
+ return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
269
+ .filter(isCandidate);
270
+ }
271
+
272
+ function findBySelector(selector) {
273
+ if (!selector) return null;
274
+ if (selector.startsWith("text=")) {
275
+ const expected = selector.slice(5).trim();
276
+ return interactiveElements().find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected)) ||
277
+ Array.from(document.querySelectorAll("*")).find((el) => getText(el).includes(expected));
278
+ }
279
+ if (selector.startsWith("role=")) {
280
+ const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
281
+ const expectedRole = match?.[1]?.trim();
282
+ const expectedName = match?.[2]?.trim();
283
+ return interactiveElements().find((el) => {
284
+ const text = getText(el);
285
+ const name = getName(el, text);
286
+ return roleOf(el) === expectedRole && (!expectedName || name.includes(expectedName) || text.includes(expectedName));
287
+ });
288
+ }
289
+ try {
290
+ return document.querySelector(selector);
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ const el = actOptions.index !== undefined
297
+ ? interactiveElements()[actOptions.index - 1]
298
+ : findBySelector(actOptions.selector);
299
+
300
+ if (!el) {
301
+ return { found: false };
302
+ }
303
+
304
+ el.scrollIntoView({ block: "center", inline: "center" });
305
+ const rect = el.getBoundingClientRect();
306
+ const style = window.getComputedStyle(el);
307
+ const visible = rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
308
+ const enabled = !el.disabled && el.getAttribute("aria-disabled") !== "true";
309
+ const text = getText(el);
310
+ const name = getName(el, text);
311
+ const baseResult = {
312
+ found: true,
313
+ tagName: el.tagName,
314
+ role: roleOf(el),
315
+ name,
316
+ text,
317
+ visible,
318
+ enabled,
319
+ bbox: {
320
+ x: Math.round(rect.x),
321
+ y: Math.round(rect.y),
322
+ width: Math.round(rect.width),
323
+ height: Math.round(rect.height),
324
+ },
325
+ };
326
+
327
+ if (!visible || !enabled) {
328
+ return { ...baseResult, performed: false };
329
+ }
330
+
331
+ if (actOptions.action === "hover") {
332
+ el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
333
+ el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
334
+ return { ...baseResult, performed: true, action: "hover" };
335
+ }
336
+
337
+ if (actOptions.action === "type") {
338
+ el.focus();
339
+ if ("value" in el) {
340
+ el.value = actOptions.clear ? "" : el.value;
341
+ el.value += actOptions.typeText;
342
+ } else if (el.isContentEditable) {
343
+ el.textContent = actOptions.clear ? actOptions.typeText : `${el.textContent || ""}${actOptions.typeText}`;
344
+ } else {
345
+ return { ...baseResult, performed: false, error: "Target is not typeable" };
346
+ }
347
+ el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: actOptions.typeText }));
348
+ el.dispatchEvent(new Event("change", { bubbles: true }));
349
+ return { ...baseResult, performed: true, action: "type", value: "value" in el ? el.value : el.textContent };
350
+ }
351
+
352
+ el.click();
353
+ return { ...baseResult, performed: true, action: "click" };
354
+ }, {
355
+ index,
356
+ selector,
357
+ action,
358
+ typeText,
359
+ clear: Boolean(options.clear),
360
+ });
361
+
362
+ if (!result.found) {
363
+ throw new Error(index !== undefined ? `Interactive element index not found: ${index}` : `Element not found: ${selector}`);
364
+ }
365
+ if (!result.performed) {
366
+ throw new Error(result.error || `Element is not actionable: ${index ?? selector}`);
367
+ }
368
+
369
+ return {
370
+ acted: true,
371
+ action: result.action,
372
+ index,
373
+ selector,
374
+ target: {
375
+ frameId: targetFrame.frameId,
376
+ frameUrl: targetFrame.frame.url(),
377
+ matchedBy: targetFrame.matchedBy,
378
+ },
379
+ element: result,
380
+ };
381
+ }
382
+
383
+ async _actMenuPath(targetFrame, options = {}) {
384
+ const menuPath = Array.isArray(options.menuPath)
385
+ ? options.menuPath
386
+ : String(options.menuPath).split("/").map((item) => item.trim()).filter(Boolean);
387
+ const waitAfterEach = Number.parseInt(options.waitAfterEach ?? 300, 10);
388
+
389
+ if (menuPath.length === 0) {
390
+ throw new Error("menuPath is empty");
391
+ }
392
+
393
+ const result = await targetFrame.frame.evaluate(async (pathItems, waitMs) => {
394
+ function sleep(ms) {
395
+ return new Promise((resolve) => setTimeout(resolve, ms));
396
+ }
397
+
398
+ function textOf(el) {
399
+ return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
400
+ }
401
+
402
+ function classText(el) {
403
+ return typeof el.className === "string" ? el.className : "";
404
+ }
405
+
406
+ function isVisible(el) {
407
+ const rect = el.getBoundingClientRect();
408
+ const style = window.getComputedStyle(el);
409
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
410
+ }
411
+
412
+ function menuCandidates() {
413
+ return Array.from(document.querySelectorAll([
414
+ "[role='menuitem']",
415
+ ".ant-menu-item",
416
+ ".ant-menu-submenu-title",
417
+ "li[class*='menu']",
418
+ "a",
419
+ "button",
420
+ "[class*='menu-item']",
421
+ "[class*='submenu']",
422
+ ].join(","))).filter(isVisible);
423
+ }
424
+
425
+ function scoreCandidate(el, text, level) {
426
+ const actual = textOf(el);
427
+ if (!actual) return -1;
428
+ let score = -1;
429
+ if (actual === text) score = 100;
430
+ else if (actual.includes(text)) score = 70;
431
+ else if (text.includes(actual)) score = 50;
432
+ if (score < 0) return -1;
433
+ const cls = classText(el);
434
+ const role = el.getAttribute("role") || "";
435
+ if (role === "menuitem") score += 10;
436
+ if (cls.includes("ant-menu-submenu-title")) score += level < pathItems.length - 1 ? 20 : -5;
437
+ if (cls.includes("ant-menu-item")) score += level === pathItems.length - 1 ? 20 : 0;
438
+ if (cls.includes("ant-menu-item-selected")) score += 5;
439
+ return score;
440
+ }
441
+
442
+ function findMenuItem(text, level) {
443
+ return menuCandidates()
444
+ .map((el) => ({ el, score: scoreCandidate(el, text, level), text: textOf(el) }))
445
+ .filter((item) => item.score >= 0)
446
+ .sort((a, b) => b.score - a.score)[0] || null;
447
+ }
448
+
449
+ function elementSnapshot(el) {
450
+ const rect = el.getBoundingClientRect();
451
+ return {
452
+ tagName: el.tagName,
453
+ text: textOf(el).slice(0, 120),
454
+ role: el.getAttribute("role") || null,
455
+ className: classText(el),
456
+ ariaExpanded: el.getAttribute("aria-expanded"),
457
+ bbox: {
458
+ x: Math.round(rect.x),
459
+ y: Math.round(rect.y),
460
+ width: Math.round(rect.width),
461
+ height: Math.round(rect.height),
462
+ },
463
+ };
464
+ }
465
+
466
+ const steps = [];
467
+
468
+ for (let i = 0; i < pathItems.length; i++) {
469
+ const text = pathItems[i];
470
+ let match = findMenuItem(text, i);
471
+ if (!match && i > 0) {
472
+ await sleep(waitMs * 2);
473
+ match = findMenuItem(text, i);
474
+ }
475
+ if (!match) {
476
+ return { found: false, failedAt: text, steps };
477
+ }
478
+
479
+ const el = match.el;
480
+ el.scrollIntoView({ block: "center", inline: "center" });
481
+ const beforeUrl = location.href;
482
+ const beforeText = document.body?.innerText || "";
483
+ const isLast = i === pathItems.length - 1;
484
+ const before = elementSnapshot(el);
485
+
486
+ el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
487
+ el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
488
+ el.click();
489
+ await sleep(waitMs);
490
+
491
+ const afterClass = classText(el);
492
+ const afterExpanded = el.getAttribute("aria-expanded");
493
+ steps.push({
494
+ text,
495
+ action: isLast ? "click" : "expand",
496
+ status: "PASS",
497
+ matchedText: match.text,
498
+ before,
499
+ after: {
500
+ className: afterClass,
501
+ ariaExpanded: afterExpanded,
502
+ urlChanged: location.href !== beforeUrl,
503
+ textChanged: (document.body?.innerText || "") !== beforeText,
504
+ selected: afterClass.includes("selected") || afterClass.includes("active"),
505
+ },
506
+ });
507
+ }
508
+
509
+ return {
510
+ found: true,
511
+ performed: true,
512
+ steps,
513
+ currentUrl: location.href,
514
+ title: document.title,
515
+ };
516
+ }, menuPath, waitAfterEach);
517
+
518
+ if (!result.found) {
519
+ throw new Error(`Menu path item not found: ${result.failedAt}`);
520
+ }
521
+
522
+ return {
523
+ acted: true,
524
+ action: "menuPath",
525
+ menuPath,
526
+ target: {
527
+ frameId: targetFrame.frameId,
528
+ frameUrl: targetFrame.frame.url(),
529
+ matchedBy: targetFrame.matchedBy,
530
+ },
531
+ steps: result.steps,
532
+ currentUrl: result.currentUrl,
533
+ title: result.title,
534
+ };
535
+ }
536
+
537
+ async assert(options = {}) {
538
+ const targetFrame = await this._resolveObserveFrame(options);
539
+ const previousFrame = this._activeFrame;
540
+ this._activeFrame = targetFrame.frame === this.page.mainFrame() ? null : targetFrame.frame;
541
+
542
+ try {
543
+ const checks = {};
544
+ let passed = true;
545
+
546
+ if (options.selector) {
547
+ const elementResult = await this._checkElement({ selector: options.selector, expect: options.expect || {} });
548
+ checks.element = elementResult;
549
+ if (!elementResult.passed) passed = false;
550
+ }
551
+
552
+ if (options.urlContains) {
553
+ const currentUrl = this.page.url();
554
+ checks.urlContains = { expected: options.urlContains, actual: currentUrl, passed: currentUrl.includes(options.urlContains) };
555
+ if (!checks.urlContains.passed) passed = false;
556
+ }
557
+
558
+ if (options.textContains) {
559
+ const textResult = await this.safeEval((text) => {
560
+ const bodyText = document.body?.innerText || "";
561
+ return { expected: text, passed: bodyText.includes(text) };
562
+ }, options.textContains);
563
+ checks.textContains = textResult;
564
+ if (!textResult.passed) passed = false;
565
+ }
566
+
567
+ return {
568
+ passed,
569
+ target: {
570
+ frameId: targetFrame.frameId,
571
+ frameUrl: targetFrame.frame.url(),
572
+ matchedBy: targetFrame.matchedBy,
573
+ },
574
+ checks,
575
+ };
576
+ } finally {
577
+ this._activeFrame = previousFrame;
578
+ }
579
+ }
580
+
581
+ async captureApi(options = {}) {
582
+ const wait = Number.parseInt(options.wait ?? options.timeout ?? 15000, 10);
583
+ const includeRequestBody = options.includeRequestBody !== false;
584
+ const includeResponseBody = Boolean(options.includeResponseBody);
585
+ const methodFilter = options.method ? String(options.method).toUpperCase() : null;
586
+ const urlPattern = options.urlPattern || options.urlIncludes || "";
587
+ const targetFrame = await this._resolveObserveFrame(options).catch(() => null);
588
+ const requests = new Map();
589
+ const sessions = [];
590
+ const targets = [];
591
+ const pendingBodies = [];
592
+ const startedAt = Date.now();
593
+
594
+ const matchesUrl = (url = "") => !urlPattern || url.includes(urlPattern);
595
+ const matchesMethod = (method = "") => !methodFilter || method.toUpperCase() === methodFilter;
596
+ const normalizeHeaders = (headers = {}) => Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, String(value)]));
597
+
598
+ const addSession = async (session, targetInfo) => {
599
+ await session.send("Network.enable").catch(() => null);
600
+ sessions.push(session);
601
+ targets.push(targetInfo);
602
+
603
+ session.on("Network.requestWillBeSent", (event) => {
604
+ const request = event.request || {};
605
+ if (!matchesUrl(request.url) || !matchesMethod(request.method)) return;
606
+ requests.set(`${targetInfo.id}:${event.requestId}`, {
607
+ id: event.requestId,
608
+ targetId: targetInfo.id,
609
+ targetType: targetInfo.type,
610
+ targetUrl: targetInfo.url,
611
+ frameId: event.frameId,
612
+ url: request.url,
613
+ method: request.method,
614
+ requestHeaders: normalizeHeaders(request.headers),
615
+ requestBody: includeRequestBody ? request.postData || null : undefined,
616
+ status: null,
617
+ responseHeaders: null,
618
+ responseBody: null,
619
+ responseBodyBase64Encoded: null,
620
+ errorText: null,
621
+ startTime: Date.now(),
622
+ endTime: null,
623
+ duration: null,
624
+ });
625
+ });
626
+
627
+ session.on("Network.responseReceived", (event) => {
628
+ const item = requests.get(`${targetInfo.id}:${event.requestId}`);
629
+ if (!item) return;
630
+ const response = event.response || {};
631
+ item.status = response.status;
632
+ item.mimeType = response.mimeType;
633
+ item.responseHeaders = normalizeHeaders(response.headers);
634
+ item.responseUrl = response.url;
635
+ });
636
+
637
+ session.on("Network.loadingFinished", async (event) => {
638
+ const item = requests.get(`${targetInfo.id}:${event.requestId}`);
639
+ if (!item) return;
640
+ item.endTime = Date.now();
641
+ item.duration = item.endTime - item.startTime;
642
+ if (includeResponseBody) {
643
+ const bodyTask = session.send("Network.getResponseBody", { requestId: event.requestId })
644
+ .then((body) => {
645
+ item.responseBody = body.body;
646
+ item.responseBodyBase64Encoded = body.base64Encoded;
647
+ })
648
+ .catch((err) => {
649
+ item.responseBodyError = err.message;
650
+ });
651
+ pendingBodies.push(bodyTask);
652
+ }
653
+ });
654
+
655
+ session.on("Network.loadingFailed", (event) => {
656
+ const item = requests.get(`${targetInfo.id}:${event.requestId}`);
657
+ if (!item) return;
658
+ item.endTime = Date.now();
659
+ item.duration = item.endTime - item.startTime;
660
+ item.errorText = event.errorText || "loadingFailed";
661
+ });
662
+ };
663
+
664
+ const pageSession = await this.page.target().createCDPSession();
665
+ await addSession(pageSession, {
666
+ id: "page",
667
+ type: "page",
668
+ url: this.page.url(),
669
+ matchedBy: "currentPage",
670
+ });
671
+
672
+ const pageTarget = this.page.target();
673
+ const attachCandidates = this.browser.targets().filter((target) => {
674
+ if (target === pageTarget) return false;
675
+ if (!["iframe", "page"].includes(target.type())) return false;
676
+ const targetUrl = target.url() || "";
677
+ if (options.targetUrlContains && !targetUrl.includes(options.targetUrlContains)) return false;
678
+ if (targetFrame?.frame && targetUrl && targetFrame.frame.url() && targetUrl !== targetFrame.frame.url() && !targetUrl.includes(targetFrame.frame.url())) {
679
+ return options.includeAllTargets === true;
680
+ }
681
+ return true;
682
+ });
683
+
684
+ for (const target of attachCandidates) {
685
+ const session = await target.createCDPSession().catch(() => null);
686
+ if (!session) continue;
687
+ await addSession(session, {
688
+ id: target.url() || `${target.type()}-${targets.length}`,
689
+ type: target.type(),
690
+ url: target.url() || "",
691
+ matchedBy: "browser.targets",
692
+ });
693
+ }
694
+
695
+ if (options.reload) {
696
+ await this.page.reload({ waitUntil: options.waitUntil || "domcontentloaded", timeout: options.reloadTimeout || 30000 }).catch(() => null);
697
+ }
698
+
699
+ await new Promise((resolve) => setTimeout(resolve, wait));
700
+ await Promise.allSettled(pendingBodies);
701
+
702
+ await Promise.all(sessions.map((session) => session.send("Network.disable").catch(() => null)));
703
+
704
+ const requestList = Array.from(requests.values()).sort((a, b) => a.startTime - b.startTime);
705
+ const title = await this.page.title().catch(() => "");
706
+ const failed = requestList.filter((item) => item.errorText || (item.status && item.status >= 400));
707
+
708
+ return {
709
+ type: "apiCapture",
710
+ page: {
711
+ url: this.page.url(),
712
+ title,
713
+ },
714
+ target: targetFrame ? {
715
+ frameId: targetFrame.frameId,
716
+ frameUrl: targetFrame.frame.url(),
717
+ matchedBy: targetFrame.matchedBy,
718
+ } : null,
719
+ targets,
720
+ requests: requestList,
721
+ summary: {
722
+ total: requestList.length,
723
+ success: requestList.length - failed.length,
724
+ failed: failed.length,
725
+ targets: targets.length,
726
+ duration: Date.now() - startedAt,
727
+ },
728
+ };
729
+ }
730
+
731
+ assertApi(captureResult, options = {}) {
732
+ if (!captureResult || !Array.isArray(captureResult.requests)) {
733
+ throw new Error("assertApi requires an apiCapture result");
734
+ }
735
+
736
+ const requests = captureResult.requests;
737
+ const expect = options.expect || options;
738
+ const checks = {};
739
+ let passed = true;
740
+
741
+ if (expect.maxFailed !== undefined) {
742
+ const actual = requests.filter((item) => item.errorText || (item.status && item.status >= 400)).length;
743
+ checks.maxFailed = {
744
+ expected: Number.parseInt(expect.maxFailed, 10),
745
+ actual,
746
+ passed: actual <= Number.parseInt(expect.maxFailed, 10),
747
+ };
748
+ if (!checks.maxFailed.passed) passed = false;
749
+ }
750
+
751
+ if (expect.forbidStatusGte !== undefined) {
752
+ const threshold = Number.parseInt(expect.forbidStatusGte, 10);
753
+ const violations = requests
754
+ .filter((item) => item.status && item.status >= threshold)
755
+ .map((item) => ({ url: item.url, method: item.method, status: item.status }));
756
+ checks.forbidStatusGte = {
757
+ expected: threshold,
758
+ violations,
759
+ passed: violations.length === 0,
760
+ };
761
+ if (!checks.forbidStatusGte.passed) passed = false;
762
+ }
763
+
764
+ if (expect.requiredUrls !== undefined) {
765
+ const requiredUrls = Array.isArray(expect.requiredUrls) ? expect.requiredUrls : [expect.requiredUrls];
766
+ checks.requiredUrls = requiredUrls.map((url) => {
767
+ const matched = requests.filter((item) => item.url?.includes(url));
768
+ return {
769
+ url,
770
+ matched: matched.length,
771
+ passed: matched.length > 0,
772
+ };
773
+ });
774
+ if (checks.requiredUrls.some((item) => !item.passed)) passed = false;
775
+ }
776
+
777
+ if (expect.requiredMethods !== undefined) {
778
+ const requiredMethods = Array.isArray(expect.requiredMethods) ? expect.requiredMethods : [expect.requiredMethods];
779
+ checks.requiredMethods = requiredMethods.map((method) => {
780
+ const normalized = String(method).toUpperCase();
781
+ const matched = requests.filter((item) => item.method === normalized);
782
+ return {
783
+ method: normalized,
784
+ matched: matched.length,
785
+ passed: matched.length > 0,
786
+ };
787
+ });
788
+ if (checks.requiredMethods.some((item) => !item.passed)) passed = false;
789
+ }
790
+
791
+ if (expect.minTotal !== undefined) {
792
+ const expected = Number.parseInt(expect.minTotal, 10);
793
+ checks.minTotal = {
794
+ expected,
795
+ actual: requests.length,
796
+ passed: requests.length >= expected,
797
+ };
798
+ if (!checks.minTotal.passed) passed = false;
799
+ }
800
+
801
+ return {
802
+ type: "assertApi",
803
+ passed,
804
+ checks,
805
+ summary: captureResult?.summary || {
806
+ total: requests.length,
807
+ failed: requests.filter((item) => item.errorText || (item.status && item.status >= 400)).length,
808
+ },
809
+ };
810
+ }
811
+
812
+ async _resolveObserveFrame(options = {}) {
813
+ const frames = this.page.frames();
814
+ const mainFrame = this.page.mainFrame();
815
+
816
+ if (options.frameIndex !== undefined) {
817
+ const index = Number.parseInt(options.frameIndex, 10);
818
+ if (frames[index]) {
819
+ return { frame: frames[index], frameId: index === 0 ? "main" : `frame-${index}`, matchedBy: `frameIndex ${index}` };
820
+ }
821
+ }
822
+
823
+ if (options.frameUrlContains) {
824
+ const index = frames.findIndex((frame) => frame.url().includes(options.frameUrlContains));
825
+ if (index >= 0) {
826
+ return { frame: frames[index], frameId: index === 0 ? "main" : `frame-${index}`, matchedBy: `url contains "${options.frameUrlContains}"` };
827
+ }
828
+ }
829
+
830
+ if (options.frameContentContains) {
831
+ for (let i = 0; i < frames.length; i++) {
832
+ const matched = await frames[i].evaluate((text) =>
833
+ document.body?.innerText?.includes(text) || false,
834
+ options.frameContentContains
835
+ ).catch(() => false);
836
+ if (matched) {
837
+ return { frame: frames[i], frameId: i === 0 ? "main" : `frame-${i}`, matchedBy: `body contains "${options.frameContentContains}"` };
838
+ }
839
+ }
840
+ }
841
+
842
+ const mainIndex = frames.indexOf(mainFrame);
843
+ return { frame: mainFrame, frameId: mainIndex <= 0 ? "main" : `frame-${mainIndex}`, matchedBy: "main" };
844
+ }
845
+
846
+ async _getFrameSummaries() {
847
+ const frames = this.page.frames();
848
+ const mainFrame = this.page.mainFrame();
849
+ const summaries = [];
850
+ for (let i = 0; i < frames.length; i++) {
851
+ const frame = frames[i];
852
+ const title = await frame.evaluate(() => document.title || "").catch(() => "");
853
+ summaries.push({
854
+ frameId: frame === mainFrame ? "main" : `frame-${i}`,
855
+ url: frame.url(),
856
+ title,
857
+ isMain: frame === mainFrame,
858
+ });
859
+ }
860
+ return summaries;
861
+ }
862
+
863
+ async _buildDomTree(frame, selector, depth) {
864
+ return frame.evaluate((rootSelector, maxDepth) => {
865
+ function attrsOf(el) {
866
+ const attrs = {};
867
+ for (const attr of Array.from(el.attributes || [])) {
868
+ if (["id", "class", "role", "aria-label", "title", "placeholder", "href", "src", "data-testid"].includes(attr.name)) {
869
+ attrs[attr.name] = attr.value.slice(0, 120);
870
+ }
871
+ }
872
+ return attrs;
873
+ }
874
+
875
+ function directTextOf(el) {
876
+ return Array.from(el.childNodes || [])
877
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
878
+ .map((node) => node.textContent.trim())
879
+ .filter(Boolean)
880
+ .join(" ")
881
+ .slice(0, 120);
882
+ }
883
+
884
+ function build(el, currentDepth) {
885
+ if (!el || currentDepth > maxDepth) return null;
886
+ const tag = el.tagName?.toLowerCase();
887
+ if (!tag || ["script", "style", "path"].includes(tag)) return null;
888
+ const rect = el.getBoundingClientRect();
889
+ const node = {
890
+ tag,
891
+ attrs: attrsOf(el),
892
+ text: directTextOf(el),
893
+ visible: rect.width > 0 && rect.height > 0,
894
+ children: [],
895
+ };
896
+ for (const child of Array.from(el.children || [])) {
897
+ const childNode = build(child, currentDepth + 1);
898
+ if (childNode) node.children.push(childNode);
899
+ }
900
+ return node;
901
+ }
902
+
903
+ return build(document.querySelector(rootSelector) || document.body, 0);
904
+ }, selector, depth).catch((err) => ({ error: err.message, tag: "error", attrs: {}, text: "", visible: false, children: [] }));
905
+ }
906
+
907
+ async _collectInteractiveElements(frame, frameId, maxElements) {
908
+ return frame.evaluate((currentFrameId, limit) => {
909
+ function cssEscape(value) {
910
+ if (window.CSS?.escape) return window.CSS.escape(value);
911
+ return String(value).replace(/["'\\]/g, "\\$&");
912
+ }
913
+
914
+ function getText(el) {
915
+ return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120);
916
+ }
917
+
918
+ function getName(el, text) {
919
+ return (
920
+ el.getAttribute("aria-label") ||
921
+ el.getAttribute("title") ||
922
+ el.getAttribute("placeholder") ||
923
+ el.getAttribute("alt") ||
924
+ text ||
925
+ ""
926
+ ).slice(0, 120);
927
+ }
928
+
929
+ function roleOf(el) {
930
+ const explicit = el.getAttribute("role");
931
+ if (explicit) return explicit;
932
+ const tag = el.tagName.toLowerCase();
933
+ if (tag === "button") return "button";
934
+ if (tag === "a") return "link";
935
+ if (["input", "textarea"].includes(tag)) return "textbox";
936
+ if (tag === "select") return "combobox";
937
+ return "generic";
938
+ }
939
+
940
+ function selectorCandidates(el, role, name, text) {
941
+ const selectors = [];
942
+ const testId = el.getAttribute("data-testid");
943
+ if (testId) selectors.push(`[data-testid="${cssEscape(testId)}"]`);
944
+ if (el.id) selectors.push(`#${cssEscape(el.id)}`);
945
+ if (role && role !== "generic" && name) selectors.push(`role=${role}[name="${name.replace(/"/g, "\\\"")}"]`);
946
+ if (text) selectors.push(`text=${text.replace(/"/g, "\\\"")}`);
947
+ const tag = el.tagName.toLowerCase();
948
+ const className = typeof el.className === "string" ? el.className.trim().split(/\s+/).filter(Boolean)[0] : "";
949
+ if (className) selectors.push(`${tag}.${cssEscape(className)}`);
950
+ selectors.push(tag);
951
+ return [...new Set(selectors)].slice(0, 5);
952
+ }
953
+
954
+ function isCandidate(el) {
955
+ const tag = el.tagName.toLowerCase();
956
+ return ["a", "button", "input", "select", "textarea", "summary"].includes(tag) ||
957
+ el.getAttribute("role") ||
958
+ el.getAttribute("tabindex") !== null ||
959
+ el.onclick ||
960
+ el.getAttribute("data-testid") ||
961
+ (typeof el.className === "string" && /btn|button|menu-item|ant-btn|ant-menu|ant-select|ant-input/.test(el.className));
962
+ }
963
+
964
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
965
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
966
+ return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
967
+ .filter(isCandidate)
968
+ .slice(0, limit)
969
+ .map((el, idx) => {
970
+ const rect = el.getBoundingClientRect();
971
+ const style = window.getComputedStyle(el);
972
+ const text = getText(el);
973
+ const role = roleOf(el);
974
+ const name = getName(el, text);
975
+ const visible = rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
976
+ return {
977
+ index: idx + 1,
978
+ frameId: currentFrameId,
979
+ tag: el.tagName.toLowerCase(),
980
+ role,
981
+ name,
982
+ text,
983
+ selectorCandidates: selectorCandidates(el, role, name, text),
984
+ bbox: {
985
+ x: Math.round(rect.x),
986
+ y: Math.round(rect.y),
987
+ width: Math.round(rect.width),
988
+ height: Math.round(rect.height),
989
+ },
990
+ state: {
991
+ visible,
992
+ enabled: !el.disabled && el.getAttribute("aria-disabled") !== "true",
993
+ inViewport: rect.bottom >= 0 && rect.right >= 0 && rect.top <= viewportHeight && rect.left <= viewportWidth,
994
+ },
995
+ };
996
+ });
997
+ }, frameId, maxElements).catch(() => []);
998
+ }
999
+
181
1000
  /**
182
1001
  * 步骤分发
183
1002
  */
184
1003
  async _dispatchStep(step, context) {
185
1004
  switch (step.type) {
1005
+ case "observe": return this.observe(step);
1006
+ case "act": return this.act(step);
1007
+ case "assert": return this.assert(step);
1008
+ case "apiCapture": return this.captureApi(step);
1009
+ case "api-capture": return this.captureApi(step);
1010
+ case "assertApi": return this.assertApi(step.captureResult || context.lastApiCapture, step);
1011
+ case "assert-api": return this.assertApi(step.captureResult || context.lastApiCapture, step);
186
1012
  case "navigate": return this._navigate(step);
187
1013
  case "screenshot": return this._screenshot(step, context);
188
1014
  case "compare": return this._compare(step, context);
@@ -649,7 +1475,7 @@ export class BrowserEngine {
649
1475
  * 动态加载 puppeteer-core,从共享缓存目录加载,缺失时自动安装
650
1476
  */
651
1477
  async function loadPuppeteer() {
652
- const { ensureSharedDeps, loadFromShared } = await import("./shared-deps.js");
1478
+ const { ensureCoreDeps, loadFromShared } = await import("./shared-deps.js");
653
1479
 
654
1480
  // 1. 尝试从项目本地 node_modules 加载(开发环境或已手动安装)
655
1481
  try {
@@ -659,8 +1485,8 @@ async function loadPuppeteer() {
659
1485
  // 本地未安装
660
1486
  }
661
1487
 
662
- // 2. 确保共享缓存已安装,然后从共享目录加载
663
- await ensureSharedDeps();
1488
+ // 2. observe/act/assert 主链路只需要 puppeteer-core
1489
+ await ensureCoreDeps();
664
1490
 
665
1491
  try {
666
1492
  return loadFromShared("puppeteer-core");