@szc-ft/mcp-szcd-client 0.27.2 → 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.
- package/lib/browser-engine.js +397 -1
- package/local-browser-executor.js +130 -3
- package/opencode-extension/skills/local-browser-test/SKILL.md +43 -1
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +43 -1
- package/standard-skill/local-browser-test/SKILL.md +43 -1
- package/standard-skill/local-browser-test/local-browser-executor-old.cjs +0 -395
package/lib/browser-engine.js
CHANGED
|
@@ -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 {
|
|
@@ -215,6 +218,10 @@ export class BrowserEngine {
|
|
|
215
218
|
|
|
216
219
|
async act(options = {}) {
|
|
217
220
|
const targetFrame = await this._resolveObserveFrame(options);
|
|
221
|
+
if (options.menuPath) {
|
|
222
|
+
return this._actMenuPath(targetFrame, options);
|
|
223
|
+
}
|
|
224
|
+
|
|
218
225
|
const index = options.index !== undefined ? Number.parseInt(options.index, 10) : undefined;
|
|
219
226
|
const selector = this._resolveSelector(options.selector || options.click || options.hover);
|
|
220
227
|
const typeText = options.typeText ?? options.text;
|
|
@@ -373,6 +380,160 @@ export class BrowserEngine {
|
|
|
373
380
|
};
|
|
374
381
|
}
|
|
375
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
|
+
|
|
376
537
|
async assert(options = {}) {
|
|
377
538
|
const targetFrame = await this._resolveObserveFrame(options);
|
|
378
539
|
const previousFrame = this._activeFrame;
|
|
@@ -417,6 +578,237 @@ export class BrowserEngine {
|
|
|
417
578
|
}
|
|
418
579
|
}
|
|
419
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
|
+
|
|
420
812
|
async _resolveObserveFrame(options = {}) {
|
|
421
813
|
const frames = this.page.frames();
|
|
422
814
|
const mainFrame = this.page.mainFrame();
|
|
@@ -613,6 +1005,10 @@ export class BrowserEngine {
|
|
|
613
1005
|
case "observe": return this.observe(step);
|
|
614
1006
|
case "act": return this.act(step);
|
|
615
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);
|
|
616
1012
|
case "navigate": return this._navigate(step);
|
|
617
1013
|
case "screenshot": return this._screenshot(step, context);
|
|
618
1014
|
case "compare": return this._compare(step, context);
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* 用法:
|
|
6
6
|
* node local-browser-executor.js --action observe --output /tmp/observe.json
|
|
7
7
|
* node local-browser-executor.js --action act --index 1 --observe-after
|
|
8
|
+
* node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after
|
|
9
|
+
* node local-browser-executor.js --action api-capture --wait 15000 --include-response-body
|
|
10
|
+
* node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0
|
|
8
11
|
* node local-browser-executor.js --plan '<JSON>' --output /tmp/result.json
|
|
9
12
|
* node local-browser-executor.js --plan-file /path/to/plan.json --output /tmp/result.json
|
|
10
13
|
*
|
|
11
14
|
* 参数:
|
|
12
|
-
* --action 直接执行动作,当前支持 observe/act/assert
|
|
15
|
+
* --action 直接执行动作,当前支持 observe/act/assert/api-capture
|
|
13
16
|
* --plan 测试计划 JSON 字符串
|
|
14
17
|
* --plan-file 测试计划 JSON 文件路径(与 --plan 二选一)
|
|
15
18
|
* --output 结果输出文件路径(默认 stdout)
|
|
@@ -93,6 +96,12 @@ function parseArgs() {
|
|
|
93
96
|
case "--hover":
|
|
94
97
|
parsed.hover = args[++i];
|
|
95
98
|
break;
|
|
99
|
+
case "--menu-path":
|
|
100
|
+
parsed.menuPath = args[++i];
|
|
101
|
+
break;
|
|
102
|
+
case "--wait-after-each":
|
|
103
|
+
parsed.waitAfterEach = args[++i];
|
|
104
|
+
break;
|
|
96
105
|
case "--observe-after":
|
|
97
106
|
parsed.observeAfter = true;
|
|
98
107
|
break;
|
|
@@ -111,6 +120,51 @@ function parseArgs() {
|
|
|
111
120
|
case "--has-text":
|
|
112
121
|
parsed.hasText = args[++i];
|
|
113
122
|
break;
|
|
123
|
+
case "--wait":
|
|
124
|
+
parsed.wait = args[++i];
|
|
125
|
+
break;
|
|
126
|
+
case "--method":
|
|
127
|
+
parsed.method = args[++i];
|
|
128
|
+
break;
|
|
129
|
+
case "--url-pattern":
|
|
130
|
+
parsed.urlPattern = args[++i];
|
|
131
|
+
break;
|
|
132
|
+
case "--target-url-contains":
|
|
133
|
+
parsed.targetUrlContains = args[++i];
|
|
134
|
+
break;
|
|
135
|
+
case "--include-request-body":
|
|
136
|
+
parsed.includeRequestBody = true;
|
|
137
|
+
break;
|
|
138
|
+
case "--no-request-body":
|
|
139
|
+
parsed.includeRequestBody = false;
|
|
140
|
+
break;
|
|
141
|
+
case "--include-response-body":
|
|
142
|
+
parsed.includeResponseBody = true;
|
|
143
|
+
break;
|
|
144
|
+
case "--reload":
|
|
145
|
+
parsed.reload = true;
|
|
146
|
+
break;
|
|
147
|
+
case "--include-all-targets":
|
|
148
|
+
parsed.includeAllTargets = true;
|
|
149
|
+
break;
|
|
150
|
+
case "--capture-file":
|
|
151
|
+
parsed.captureFile = args[++i];
|
|
152
|
+
break;
|
|
153
|
+
case "--max-failed":
|
|
154
|
+
parsed.maxFailed = args[++i];
|
|
155
|
+
break;
|
|
156
|
+
case "--forbid-status-gte":
|
|
157
|
+
parsed.forbidStatusGte = args[++i];
|
|
158
|
+
break;
|
|
159
|
+
case "--required-url":
|
|
160
|
+
parsed.requiredUrls = [...(parsed.requiredUrls || []), args[++i]];
|
|
161
|
+
break;
|
|
162
|
+
case "--required-method":
|
|
163
|
+
parsed.requiredMethods = [...(parsed.requiredMethods || []), args[++i]];
|
|
164
|
+
break;
|
|
165
|
+
case "--min-total":
|
|
166
|
+
parsed.minTotal = args[++i];
|
|
167
|
+
break;
|
|
114
168
|
case "--base-url":
|
|
115
169
|
parsed.baseUrl = args[++i];
|
|
116
170
|
break;
|
|
@@ -133,12 +187,15 @@ function printHelp() {
|
|
|
133
187
|
用法:
|
|
134
188
|
node local-browser-executor.js --action observe [options]
|
|
135
189
|
node local-browser-executor.js --action act --index 1 --observe-after [options]
|
|
190
|
+
node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after [options]
|
|
136
191
|
node local-browser-executor.js --action assert --selector ".ant-table" --expect-visible [options]
|
|
192
|
+
node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
|
|
193
|
+
node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
|
|
137
194
|
node local-browser-executor.js --plan '<JSON>' [options]
|
|
138
195
|
node local-browser-executor.js --plan-file <path> [options]
|
|
139
196
|
|
|
140
197
|
选项:
|
|
141
|
-
--action <name> 直接执行动作,当前支持 observe/act/assert
|
|
198
|
+
--action <name> 直接执行动作,当前支持 observe/act/assert/api-capture
|
|
142
199
|
--plan <json> 测试计划 JSON 字符串
|
|
143
200
|
--plan-file <path> 测试计划 JSON 文件路径
|
|
144
201
|
--output <path> 结果输出文件路径(默认 stdout)
|
|
@@ -156,12 +213,29 @@ function printHelp() {
|
|
|
156
213
|
--type <text> act 时输入文本
|
|
157
214
|
--clear act 输入前清空目标
|
|
158
215
|
--hover <selector> act 时悬停 selector/text=/role=/~ 目标
|
|
216
|
+
--menu-path <a/b> act 时按菜单路径展开并点击,如 "目录管理/数据集目录"
|
|
217
|
+
--wait-after-each <ms> menu-path 每步点击后的等待时间(默认 300)
|
|
159
218
|
--observe-after act 后再次 observe,返回 before/act/after 闭环结果
|
|
160
219
|
--url-contains-assert <text> assert 当前 URL 包含文本
|
|
161
220
|
--text-contains <text> assert 页面文本包含内容
|
|
162
221
|
--expect-visible assert selector 可见
|
|
163
222
|
--min-count <n> assert selector 数量下限
|
|
164
223
|
--has-text <text> assert selector 文本包含内容
|
|
224
|
+
--wait <ms> api-capture 监听时长(默认 15000)
|
|
225
|
+
--method <GET|POST> api-capture 按请求方法过滤
|
|
226
|
+
--url-pattern <text> api-capture 按 URL 片段过滤
|
|
227
|
+
--target-url-contains <text> api-capture 按 target URL 过滤 iframe/page target
|
|
228
|
+
--include-request-body api-capture 包含请求体(默认包含)
|
|
229
|
+
--no-request-body api-capture 不包含请求体
|
|
230
|
+
--include-response-body api-capture 调用 Network.getResponseBody 获取响应体
|
|
231
|
+
--reload api-capture 开始监听后刷新页面触发请求
|
|
232
|
+
--include-all-targets api-capture 不限制到匹配 frame,监听所有 page/iframe targets
|
|
233
|
+
--capture-file <path> assert-api 读取 api-capture JSON 结果
|
|
234
|
+
--max-failed <n> assert-api 允许失败请求数上限
|
|
235
|
+
--forbid-status-gte <n> assert-api 禁止出现大于等于该状态码的响应
|
|
236
|
+
--required-url <text> assert-api 要求出现的 URL 片段,可重复传入
|
|
237
|
+
--required-method <GET|POST> assert-api 要求出现的请求方法,可重复传入
|
|
238
|
+
--min-total <n> assert-api 要求捕获请求总数下限
|
|
165
239
|
--allow-launch action 模式允许无 CDP 时启动 headless Chrome
|
|
166
240
|
--base-url <url> 基础 URL,替换计划中的 {{baseUrl}} 占位符
|
|
167
241
|
-h, --help 显示帮助
|
|
@@ -269,6 +343,25 @@ async function executePlan(plan, options) {
|
|
|
269
343
|
|
|
270
344
|
// ==================== 主流程 ====================
|
|
271
345
|
|
|
346
|
+
function executeAssertApiAction(args) {
|
|
347
|
+
if (!args.captureFile) {
|
|
348
|
+
return {
|
|
349
|
+
error: "必须提供 --capture-file 参数",
|
|
350
|
+
errorCode: "CAPTURE_FILE_REQUIRED",
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const captureResult = JSON.parse(fs.readFileSync(args.captureFile, "utf8"));
|
|
355
|
+
const engine = new BrowserEngine();
|
|
356
|
+
return engine.assertApi(captureResult, {
|
|
357
|
+
maxFailed: args.maxFailed,
|
|
358
|
+
forbidStatusGte: args.forbidStatusGte,
|
|
359
|
+
requiredUrls: args.requiredUrls,
|
|
360
|
+
requiredMethods: args.requiredMethods,
|
|
361
|
+
minTotal: args.minTotal,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
272
365
|
async function executeAction(args) {
|
|
273
366
|
const engine = new BrowserEngine();
|
|
274
367
|
const startedAt = new Date().toISOString();
|
|
@@ -308,6 +401,8 @@ async function executeAction(args) {
|
|
|
308
401
|
selector: args.selector,
|
|
309
402
|
click: args.click,
|
|
310
403
|
hover: args.hover,
|
|
404
|
+
menuPath: args.menuPath,
|
|
405
|
+
waitAfterEach: args.waitAfterEach,
|
|
311
406
|
typeText: args.typeText,
|
|
312
407
|
clear: args.clear,
|
|
313
408
|
frameUrlContains: args.frameUrlContains,
|
|
@@ -342,11 +437,28 @@ async function executeAction(args) {
|
|
|
342
437
|
});
|
|
343
438
|
return { type: "assert", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
|
|
344
439
|
}
|
|
440
|
+
case "api-capture":
|
|
441
|
+
case "apiCapture": {
|
|
442
|
+
const result = await engine.captureApi({
|
|
443
|
+
wait: args.wait,
|
|
444
|
+
method: args.method,
|
|
445
|
+
urlPattern: args.urlPattern,
|
|
446
|
+
targetUrlContains: args.targetUrlContains,
|
|
447
|
+
includeRequestBody: args.includeRequestBody,
|
|
448
|
+
includeResponseBody: args.includeResponseBody,
|
|
449
|
+
reload: args.reload,
|
|
450
|
+
includeAllTargets: args.includeAllTargets,
|
|
451
|
+
frameUrlContains: args.frameUrlContains,
|
|
452
|
+
frameContentContains: args.frameContentContains,
|
|
453
|
+
frameIndex: args.frameIndex,
|
|
454
|
+
});
|
|
455
|
+
return { ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
|
|
456
|
+
}
|
|
345
457
|
default:
|
|
346
458
|
return {
|
|
347
459
|
error: `未知 action: ${args.action}`,
|
|
348
460
|
errorCode: "UNKNOWN_ACTION",
|
|
349
|
-
supportedActions: ["observe", "act", "assert"],
|
|
461
|
+
supportedActions: ["observe", "act", "assert", "api-capture"],
|
|
350
462
|
startTime: startedAt,
|
|
351
463
|
endTime: new Date().toISOString(),
|
|
352
464
|
};
|
|
@@ -367,6 +479,21 @@ async function executeAction(args) {
|
|
|
367
479
|
async function main() {
|
|
368
480
|
const args = parseArgs();
|
|
369
481
|
|
|
482
|
+
if (args.action === "assert-api" || args.action === "assertApi") {
|
|
483
|
+
try {
|
|
484
|
+
const results = executeAssertApiAction(args);
|
|
485
|
+
outputResult(results, args.output);
|
|
486
|
+
process.exit(results.error ? 2 : results.passed === false ? 1 : 0);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
const errorResult = {
|
|
489
|
+
error: err.message,
|
|
490
|
+
errorCode: "ASSERT_API_FAILED",
|
|
491
|
+
};
|
|
492
|
+
outputResult(errorResult, args.output);
|
|
493
|
+
process.exit(2);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
370
497
|
if (args.action) {
|
|
371
498
|
const results = await executeAction(args);
|
|
372
499
|
outputResult(results, args.output);
|
|
@@ -79,8 +79,10 @@ AI 直接构造测试计划 JSON:
|
|
|
79
79
|
| type | 功能 | 参数 | 返回 |
|
|
80
80
|
|------|------|------|------|
|
|
81
81
|
| observe | 结构化观察 | `selector`, `depth`, `frameContentContains`, `maxInteractiveElements` | `{ frames, tree, interactiveElements[] }` |
|
|
82
|
-
| act | 按 index/selector 交互 | `index` 或 `selector/click/hover`, `typeText`, `clear`, `frameContentContains` | `{ acted, action, element }` |
|
|
82
|
+
| act | 按 index/selector/menuPath 交互 | `index` 或 `selector/click/hover` 或 `menuPath`, `typeText`, `clear`, `frameContentContains` | `{ acted, action, element/steps }` |
|
|
83
83
|
| assert | 轻量断言 | `selector`, `expect`, `urlContains`, `textContains` | `{ passed, checks }` |
|
|
84
|
+
| apiCapture | API 请求/响应捕获 | `wait`, `urlPattern`, `method`, `includeResponseBody`, `frameContentContains` | `{ targets, requests, summary }` |
|
|
85
|
+
| assertApi | API 捕获结果断言 | `maxFailed`, `requiredUrls`, `forbidStatusGte`, `minTotal` | `{ passed, checks, summary }` |
|
|
84
86
|
| navigate | 导航到 URL | `url`, `waitUntil` | `{ url, title, loadTime }` |
|
|
85
87
|
| screenshot | 页面截图 | `fullPage`, `clip`, `filename` | `{ filepath, width, height }` |
|
|
86
88
|
| compare | 设计稿对比 | `designImagePath`, `threshold`, `regions[]` | `{ fidelity, diffPixels, diffImagePath, regions[] }` |
|
|
@@ -236,6 +238,18 @@ node {client_path}/local-browser-executor.js \
|
|
|
236
238
|
--output /tmp/browser-act-observe.json
|
|
237
239
|
```
|
|
238
240
|
|
|
241
|
+
### 左侧菜单路径稳定点击
|
|
242
|
+
```bash
|
|
243
|
+
node {client_path}/local-browser-executor.js \
|
|
244
|
+
--action act \
|
|
245
|
+
--url-contains "platform.aicityos.com" \
|
|
246
|
+
--menu-path "目录管理/数据集目录" \
|
|
247
|
+
--observe-after \
|
|
248
|
+
--output /tmp/browser-menu-act.json
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`--menu-path` 会按路径逐级匹配菜单项,非末级优先执行展开,末级执行点击;匹配优先考虑 `role=menuitem`、`.ant-menu-item`、`.ant-menu-submenu-title` 和文本精确/包含匹配。
|
|
252
|
+
|
|
239
253
|
### 轻量 DOM 断言
|
|
240
254
|
```bash
|
|
241
255
|
node {client_path}/local-browser-executor.js \
|
|
@@ -247,6 +261,34 @@ node {client_path}/local-browser-executor.js \
|
|
|
247
261
|
--output /tmp/browser-assert.json
|
|
248
262
|
```
|
|
249
263
|
|
|
264
|
+
### API 请求/响应捕获(真实浏览器 + 微前端)
|
|
265
|
+
```bash
|
|
266
|
+
node {client_path}/local-browser-executor.js \
|
|
267
|
+
--action api-capture \
|
|
268
|
+
--url-contains "platform.aicityos.com" \
|
|
269
|
+
--frame-content-contains "数据集目录" \
|
|
270
|
+
--url-pattern "/api/" \
|
|
271
|
+
--wait 15000 \
|
|
272
|
+
--include-response-body \
|
|
273
|
+
--reload \
|
|
274
|
+
--output /tmp/browser-api-capture.json
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`api-capture` 使用 CDP `Network.*` 事件监听主页面和可 attach 的 page/iframe target,不使用 `setRequestInterception`,避免阻断真实请求。wujie/qiankun 场景优先传 `--frame-content-contains` 锁定业务 iframe;如需监听全部 target,追加 `--include-all-targets`。
|
|
278
|
+
|
|
279
|
+
### API 捕获结果断言
|
|
280
|
+
```bash
|
|
281
|
+
node {client_path}/local-browser-executor.js \
|
|
282
|
+
--action assert-api \
|
|
283
|
+
--capture-file /tmp/browser-api-capture.json \
|
|
284
|
+
--max-failed 0 \
|
|
285
|
+
--forbid-status-gte 400 \
|
|
286
|
+
--required-url "/list" \
|
|
287
|
+
--output /tmp/browser-api-assert.json
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
计划内可直接在 `apiCapture` 后追加 `{ "type": "assertApi", "maxFailed": 0, "forbidStatusGte": 400 }`,会自动读取上一步捕获结果。
|
|
291
|
+
|
|
250
292
|
### 快速诊断(过渡兼容)
|
|
251
293
|
```bash
|
|
252
294
|
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
package/package.json
CHANGED
|
@@ -79,8 +79,10 @@ AI 直接构造测试计划 JSON:
|
|
|
79
79
|
| type | 功能 | 参数 | 返回 |
|
|
80
80
|
|------|------|------|------|
|
|
81
81
|
| observe | 结构化观察 | `selector`, `depth`, `frameContentContains`, `maxInteractiveElements` | `{ frames, tree, interactiveElements[] }` |
|
|
82
|
-
| act | 按 index/selector 交互 | `index` 或 `selector/click/hover`, `typeText`, `clear`, `frameContentContains` | `{ acted, action, element }` |
|
|
82
|
+
| act | 按 index/selector/menuPath 交互 | `index` 或 `selector/click/hover` 或 `menuPath`, `typeText`, `clear`, `frameContentContains` | `{ acted, action, element/steps }` |
|
|
83
83
|
| assert | 轻量断言 | `selector`, `expect`, `urlContains`, `textContains` | `{ passed, checks }` |
|
|
84
|
+
| apiCapture | API 请求/响应捕获 | `wait`, `urlPattern`, `method`, `includeResponseBody`, `frameContentContains` | `{ targets, requests, summary }` |
|
|
85
|
+
| assertApi | API 捕获结果断言 | `maxFailed`, `requiredUrls`, `forbidStatusGte`, `minTotal` | `{ passed, checks, summary }` |
|
|
84
86
|
| navigate | 导航到 URL | `url`, `waitUntil` | `{ url, title, loadTime }` |
|
|
85
87
|
| screenshot | 页面截图 | `fullPage`, `clip`, `filename` | `{ filepath, width, height }` |
|
|
86
88
|
| compare | 设计稿对比 | `designImagePath`, `threshold`, `regions[]` | `{ fidelity, diffPixels, diffImagePath, regions[] }` |
|
|
@@ -236,6 +238,18 @@ node {client_path}/local-browser-executor.js \
|
|
|
236
238
|
--output /tmp/browser-act-observe.json
|
|
237
239
|
```
|
|
238
240
|
|
|
241
|
+
### 左侧菜单路径稳定点击
|
|
242
|
+
```bash
|
|
243
|
+
node {client_path}/local-browser-executor.js \
|
|
244
|
+
--action act \
|
|
245
|
+
--url-contains "platform.aicityos.com" \
|
|
246
|
+
--menu-path "目录管理/数据集目录" \
|
|
247
|
+
--observe-after \
|
|
248
|
+
--output /tmp/browser-menu-act.json
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`--menu-path` 会按路径逐级匹配菜单项,非末级优先执行展开,末级执行点击;匹配优先考虑 `role=menuitem`、`.ant-menu-item`、`.ant-menu-submenu-title` 和文本精确/包含匹配。
|
|
252
|
+
|
|
239
253
|
### 轻量 DOM 断言
|
|
240
254
|
```bash
|
|
241
255
|
node {client_path}/local-browser-executor.js \
|
|
@@ -247,6 +261,34 @@ node {client_path}/local-browser-executor.js \
|
|
|
247
261
|
--output /tmp/browser-assert.json
|
|
248
262
|
```
|
|
249
263
|
|
|
264
|
+
### API 请求/响应捕获(真实浏览器 + 微前端)
|
|
265
|
+
```bash
|
|
266
|
+
node {client_path}/local-browser-executor.js \
|
|
267
|
+
--action api-capture \
|
|
268
|
+
--url-contains "platform.aicityos.com" \
|
|
269
|
+
--frame-content-contains "数据集目录" \
|
|
270
|
+
--url-pattern "/api/" \
|
|
271
|
+
--wait 15000 \
|
|
272
|
+
--include-response-body \
|
|
273
|
+
--reload \
|
|
274
|
+
--output /tmp/browser-api-capture.json
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`api-capture` 使用 CDP `Network.*` 事件监听主页面和可 attach 的 page/iframe target,不使用 `setRequestInterception`,避免阻断真实请求。wujie/qiankun 场景优先传 `--frame-content-contains` 锁定业务 iframe;如需监听全部 target,追加 `--include-all-targets`。
|
|
278
|
+
|
|
279
|
+
### API 捕获结果断言
|
|
280
|
+
```bash
|
|
281
|
+
node {client_path}/local-browser-executor.js \
|
|
282
|
+
--action assert-api \
|
|
283
|
+
--capture-file /tmp/browser-api-capture.json \
|
|
284
|
+
--max-failed 0 \
|
|
285
|
+
--forbid-status-gte 400 \
|
|
286
|
+
--required-url "/list" \
|
|
287
|
+
--output /tmp/browser-api-assert.json
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
计划内可直接在 `apiCapture` 后追加 `{ "type": "assertApi", "maxFailed": 0, "forbidStatusGte": 400 }`,会自动读取上一步捕获结果。
|
|
291
|
+
|
|
250
292
|
### 快速诊断(过渡兼容)
|
|
251
293
|
```bash
|
|
252
294
|
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
@@ -79,8 +79,10 @@ AI 直接构造测试计划 JSON:
|
|
|
79
79
|
| type | 功能 | 参数 | 返回 |
|
|
80
80
|
|------|------|------|------|
|
|
81
81
|
| observe | 结构化观察 | `selector`, `depth`, `frameContentContains`, `maxInteractiveElements` | `{ frames, tree, interactiveElements[] }` |
|
|
82
|
-
| act | 按 index/selector 交互 | `index` 或 `selector/click/hover`, `typeText`, `clear`, `frameContentContains` | `{ acted, action, element }` |
|
|
82
|
+
| act | 按 index/selector/menuPath 交互 | `index` 或 `selector/click/hover` 或 `menuPath`, `typeText`, `clear`, `frameContentContains` | `{ acted, action, element/steps }` |
|
|
83
83
|
| assert | 轻量断言 | `selector`, `expect`, `urlContains`, `textContains` | `{ passed, checks }` |
|
|
84
|
+
| apiCapture | API 请求/响应捕获 | `wait`, `urlPattern`, `method`, `includeResponseBody`, `frameContentContains` | `{ targets, requests, summary }` |
|
|
85
|
+
| assertApi | API 捕获结果断言 | `maxFailed`, `requiredUrls`, `forbidStatusGte`, `minTotal` | `{ passed, checks, summary }` |
|
|
84
86
|
| navigate | 导航到 URL | `url`, `waitUntil` | `{ url, title, loadTime }` |
|
|
85
87
|
| screenshot | 页面截图 | `fullPage`, `clip`, `filename` | `{ filepath, width, height }` |
|
|
86
88
|
| compare | 设计稿对比 | `designImagePath`, `threshold`, `regions[]` | `{ fidelity, diffPixels, diffImagePath, regions[] }` |
|
|
@@ -236,6 +238,18 @@ node {client_path}/local-browser-executor.js \
|
|
|
236
238
|
--output /tmp/browser-act-observe.json
|
|
237
239
|
```
|
|
238
240
|
|
|
241
|
+
### 左侧菜单路径稳定点击
|
|
242
|
+
```bash
|
|
243
|
+
node {client_path}/local-browser-executor.js \
|
|
244
|
+
--action act \
|
|
245
|
+
--url-contains "platform.aicityos.com" \
|
|
246
|
+
--menu-path "目录管理/数据集目录" \
|
|
247
|
+
--observe-after \
|
|
248
|
+
--output /tmp/browser-menu-act.json
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`--menu-path` 会按路径逐级匹配菜单项,非末级优先执行展开,末级执行点击;匹配优先考虑 `role=menuitem`、`.ant-menu-item`、`.ant-menu-submenu-title` 和文本精确/包含匹配。
|
|
252
|
+
|
|
239
253
|
### 轻量 DOM 断言
|
|
240
254
|
```bash
|
|
241
255
|
node {client_path}/local-browser-executor.js \
|
|
@@ -247,6 +261,34 @@ node {client_path}/local-browser-executor.js \
|
|
|
247
261
|
--output /tmp/browser-assert.json
|
|
248
262
|
```
|
|
249
263
|
|
|
264
|
+
### API 请求/响应捕获(真实浏览器 + 微前端)
|
|
265
|
+
```bash
|
|
266
|
+
node {client_path}/local-browser-executor.js \
|
|
267
|
+
--action api-capture \
|
|
268
|
+
--url-contains "platform.aicityos.com" \
|
|
269
|
+
--frame-content-contains "数据集目录" \
|
|
270
|
+
--url-pattern "/api/" \
|
|
271
|
+
--wait 15000 \
|
|
272
|
+
--include-response-body \
|
|
273
|
+
--reload \
|
|
274
|
+
--output /tmp/browser-api-capture.json
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`api-capture` 使用 CDP `Network.*` 事件监听主页面和可 attach 的 page/iframe target,不使用 `setRequestInterception`,避免阻断真实请求。wujie/qiankun 场景优先传 `--frame-content-contains` 锁定业务 iframe;如需监听全部 target,追加 `--include-all-targets`。
|
|
278
|
+
|
|
279
|
+
### API 捕获结果断言
|
|
280
|
+
```bash
|
|
281
|
+
node {client_path}/local-browser-executor.js \
|
|
282
|
+
--action assert-api \
|
|
283
|
+
--capture-file /tmp/browser-api-capture.json \
|
|
284
|
+
--max-failed 0 \
|
|
285
|
+
--forbid-status-gte 400 \
|
|
286
|
+
--required-url "/list" \
|
|
287
|
+
--output /tmp/browser-api-assert.json
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
计划内可直接在 `apiCapture` 后追加 `{ "type": "assertApi", "maxFailed": 0, "forbidStatusGte": 400 }`,会自动读取上一步捕获结果。
|
|
291
|
+
|
|
250
292
|
### 快速诊断(过渡兼容)
|
|
251
293
|
```bash
|
|
252
294
|
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
@@ -1,395 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* local-browser-executor.js - 本地浏览器自动化执行器
|
|
4
|
-
*
|
|
5
|
-
* 功能:
|
|
6
|
-
* --action diagnose 全面诊断(JS错误、网络请求、API检查、资源加载)
|
|
7
|
-
* --action list-pages 列出所有标签页
|
|
8
|
-
* --action screenshot 截图
|
|
9
|
-
* --action api-check 检查 API 请求状态
|
|
10
|
-
* --action plan 执行测试计划 JSON
|
|
11
|
-
*
|
|
12
|
-
* 连接方式:
|
|
13
|
-
* --cdp-url http://localhost:9222 (默认自动检测 9222)
|
|
14
|
-
* --auto-detect 自动检测 Chrome/Edge 进程
|
|
15
|
-
*
|
|
16
|
-
* 通用参数:
|
|
17
|
-
* --url <url> 目标页面 URL
|
|
18
|
-
* --url-contains <s> 按关键词查找已打开的页面
|
|
19
|
-
* --wait <ms> 额外等待时间(默认 5000)
|
|
20
|
-
* --output <path> 结果输出文件
|
|
21
|
-
* --output-dir <dir> 截图/产物输出目录
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
const puppeteer = require('puppeteer-core');
|
|
25
|
-
const fs = require('fs');
|
|
26
|
-
const path = require('path');
|
|
27
|
-
const http = require('http');
|
|
28
|
-
|
|
29
|
-
// ===== 参数解析 =====
|
|
30
|
-
const args = process.argv.slice(2);
|
|
31
|
-
function getArg(name, defaultVal) {
|
|
32
|
-
const idx = args.indexOf(`--${name}`);
|
|
33
|
-
if (idx === -1) return defaultVal;
|
|
34
|
-
return args[idx + 1] || defaultVal;
|
|
35
|
-
}
|
|
36
|
-
function hasFlag(name) {
|
|
37
|
-
return args.includes(`--${name}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const ACTION = getArg('action', 'diagnose');
|
|
41
|
-
const CDP_URL = getArg('cdp-url', 'http://localhost:9222');
|
|
42
|
-
const TARGET_URL = getArg('url', null);
|
|
43
|
-
const URL_CONTAINS = getArg('url-contains', null);
|
|
44
|
-
const WAIT_MS = parseInt(getArg('wait', '5000'), 10);
|
|
45
|
-
const OUTPUT = getArg('output', null);
|
|
46
|
-
const OUTPUT_DIR = getArg('output-dir', '/tmp/browser-test');
|
|
47
|
-
const PLAN_FILE = getArg('plan-file', null);
|
|
48
|
-
|
|
49
|
-
// ===== 工具函数 =====
|
|
50
|
-
function log(msg) { console.log(msg); }
|
|
51
|
-
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
|
|
52
|
-
|
|
53
|
-
function checkCDP(url) {
|
|
54
|
-
return new Promise((resolve) => {
|
|
55
|
-
const req = http.get(url + '/json/version', { timeout: 2000 }, (res) => {
|
|
56
|
-
let data = '';
|
|
57
|
-
res.on('data', c => data += c);
|
|
58
|
-
res.on('end', () => {
|
|
59
|
-
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
req.on('error', () => resolve(null));
|
|
63
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function findPage(browser) {
|
|
68
|
-
const pages = await browser.pages();
|
|
69
|
-
if (TARGET_URL) {
|
|
70
|
-
for (const p of pages) {
|
|
71
|
-
if (p.url().includes(TARGET_URL) || p.url() === TARGET_URL) return p;
|
|
72
|
-
}
|
|
73
|
-
// 导航到目标 URL
|
|
74
|
-
const p = pages[0] || await browser.newPage();
|
|
75
|
-
await p.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
76
|
-
return p;
|
|
77
|
-
}
|
|
78
|
-
if (URL_CONTAINS) {
|
|
79
|
-
for (const p of pages) {
|
|
80
|
-
if (p.url().includes(URL_CONTAINS)) return p;
|
|
81
|
-
}
|
|
82
|
-
log(`[WARN] 未找到包含 "${URL_CONTAINS}" 的页面,使用第一个页面`);
|
|
83
|
-
}
|
|
84
|
-
// 找到非 about:blank 的页面
|
|
85
|
-
for (const p of pages) {
|
|
86
|
-
if (!p.url().startsWith('about:') && !p.url().startsWith('devtools:')) return p;
|
|
87
|
-
}
|
|
88
|
-
return pages[0];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ===== Action: list-pages =====
|
|
92
|
-
async function listPages(browser) {
|
|
93
|
-
const pages = await browser.pages();
|
|
94
|
-
const result = [];
|
|
95
|
-
for (let i = 0; i < pages.length; i++) {
|
|
96
|
-
const p = pages[i];
|
|
97
|
-
let title = '';
|
|
98
|
-
try { title = await p.title(); } catch {}
|
|
99
|
-
result.push({ index: i, url: p.url(), title });
|
|
100
|
-
log(`[${i}] ${p.url()}`);
|
|
101
|
-
if (title) log(` title: ${title}`);
|
|
102
|
-
}
|
|
103
|
-
return { pages: result, total: result.length };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ===== Action: diagnose =====
|
|
107
|
-
async function diagnose(browser) {
|
|
108
|
-
ensureDir(OUTPUT_DIR);
|
|
109
|
-
const page = await findPage(browser);
|
|
110
|
-
log(`[PAGE] ${page.url()}`);
|
|
111
|
-
|
|
112
|
-
// 收集数据
|
|
113
|
-
const pageErrors = [];
|
|
114
|
-
const consoleMessages = [];
|
|
115
|
-
const failedRequests = [];
|
|
116
|
-
const apiRequests = [];
|
|
117
|
-
const resourceRequests = [];
|
|
118
|
-
|
|
119
|
-
page.on('pageerror', err => {
|
|
120
|
-
pageErrors.push({ message: err.message, stack: err.stack?.split('\n').slice(0, 5).join('\n') });
|
|
121
|
-
});
|
|
122
|
-
page.on('console', msg => {
|
|
123
|
-
const type = msg.type();
|
|
124
|
-
const text = msg.text().substring(0, 500);
|
|
125
|
-
consoleMessages.push({ type, text });
|
|
126
|
-
});
|
|
127
|
-
page.on('response', resp => {
|
|
128
|
-
const url = resp.url();
|
|
129
|
-
const status = resp.status();
|
|
130
|
-
const type = resp.request().resourceType();
|
|
131
|
-
if (type === 'xhr' || type === 'fetch') {
|
|
132
|
-
apiRequests.push({ status, url, method: resp.request().method() });
|
|
133
|
-
}
|
|
134
|
-
if (type === 'stylesheet' || type === 'script') {
|
|
135
|
-
resourceRequests.push({ status, url, type });
|
|
136
|
-
}
|
|
137
|
-
if (status >= 400) {
|
|
138
|
-
failedRequests.push({ status, url, type });
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
page.on('requestfailed', req => {
|
|
142
|
-
failedRequests.push({ status: 'ABORTED', url: req.url(), error: req.failure()?.errorText });
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// 刷新页面收集
|
|
146
|
-
log('[INFO] 刷新页面收集诊断数据...');
|
|
147
|
-
try {
|
|
148
|
-
await page.reload({ waitUntil: 'load', timeout: 30000 });
|
|
149
|
-
} catch (e) {
|
|
150
|
-
log(`[WARN] 刷新超时: ${e.message.substring(0, 100)}`);
|
|
151
|
-
}
|
|
152
|
-
await new Promise(r => setTimeout(r, WAIT_MS));
|
|
153
|
-
|
|
154
|
-
// 截图
|
|
155
|
-
const screenshotPath = path.join(OUTPUT_DIR, 'diagnose-screenshot.png');
|
|
156
|
-
await page.screenshot({ path: screenshotPath, fullPage: false });
|
|
157
|
-
log(`[OK] 截图: ${screenshotPath}`);
|
|
158
|
-
|
|
159
|
-
// 检查 iframe
|
|
160
|
-
const frames = page.frames();
|
|
161
|
-
const iframeInfo = [];
|
|
162
|
-
for (const frame of frames) {
|
|
163
|
-
const url = frame.url();
|
|
164
|
-
if (url === page.url() || url === 'about:blank') continue;
|
|
165
|
-
let bodyText = '';
|
|
166
|
-
let rootContent = '';
|
|
167
|
-
try {
|
|
168
|
-
const info = await frame.evaluate(() => ({
|
|
169
|
-
bodyText: document.body?.innerText?.substring(0, 200) || '',
|
|
170
|
-
rootHTML: document.getElementById('root')?.innerHTML?.substring(0, 200) || 'no root',
|
|
171
|
-
hasReactRoot: !document.getElementById('root')?.innerHTML?.includes('loading-spinner'),
|
|
172
|
-
antdElements: document.querySelectorAll('[class*="ant-"]').length,
|
|
173
|
-
}));
|
|
174
|
-
bodyText = info.bodyText;
|
|
175
|
-
rootContent = info.rootHTML;
|
|
176
|
-
iframeInfo.push({ url, ...info });
|
|
177
|
-
} catch (e) {
|
|
178
|
-
iframeInfo.push({ url, error: e.message.substring(0, 100) });
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// 输出结果
|
|
183
|
-
const errors = consoleMessages.filter(m => m.type === 'error');
|
|
184
|
-
const warnings = consoleMessages.filter(m => m.type === 'warning');
|
|
185
|
-
|
|
186
|
-
const result = {
|
|
187
|
-
url: page.url(),
|
|
188
|
-
timestamp: new Date().toISOString(),
|
|
189
|
-
screenshot: screenshotPath,
|
|
190
|
-
pageErrors,
|
|
191
|
-
consoleErrors: errors,
|
|
192
|
-
consoleWarnings: warnings,
|
|
193
|
-
consoleTotal: consoleMessages.length,
|
|
194
|
-
failedRequests,
|
|
195
|
-
apiRequests,
|
|
196
|
-
resourceStatus: resourceRequests.map(r => ({
|
|
197
|
-
status: r.status, type: r.type, name: r.url.split('/').pop()
|
|
198
|
-
})),
|
|
199
|
-
iframes: iframeInfo,
|
|
200
|
-
summary: {
|
|
201
|
-
jsErrors: pageErrors.length,
|
|
202
|
-
consoleErrors: consoleMessages.filter(m => m.type === 'error').length,
|
|
203
|
-
failedNetwork: failedRequests.length,
|
|
204
|
-
totalAPI: apiRequests.length,
|
|
205
|
-
failedAPI: apiRequests.filter(r => r.status >= 400).length,
|
|
206
|
-
totalResources: resourceRequests.length,
|
|
207
|
-
failedResources: resourceRequests.filter(r => r.status >= 400).length,
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
// 打印摘要
|
|
212
|
-
log('\n========== 诊断结果 ==========');
|
|
213
|
-
log(`JS 异常: ${result.summary.jsErrors}`);
|
|
214
|
-
pageErrors.forEach(e => log(` [PAGEERROR] ${e.message}`));
|
|
215
|
-
log(`Console 错误: ${errors.length}`);
|
|
216
|
-
errors.forEach(e => log(` [ERROR] ${e.text}`));
|
|
217
|
-
log(`Console 警告: ${warnings.length}`);
|
|
218
|
-
warnings.forEach(e => log(` [WARN] ${e.text}`));
|
|
219
|
-
log(`Console 消息总数: ${consoleMessages.length}`);
|
|
220
|
-
log(`失败网络请求: ${result.summary.failedNetwork}`);
|
|
221
|
-
failedRequests.forEach(r => log(` [${r.status}] ${r.url}`));
|
|
222
|
-
log(`API 请求: ${result.summary.totalAPI} (失败: ${result.summary.failedAPI})`);
|
|
223
|
-
apiRequests.forEach(r => {
|
|
224
|
-
const marker = r.status >= 400 ? '❌' : '✅';
|
|
225
|
-
log(` ${marker} [${r.status}] ${r.method} ${r.url}`);
|
|
226
|
-
});
|
|
227
|
-
log(`CSS/JS 资源: ${result.summary.totalResources} (失败: ${result.summary.failedResources})`);
|
|
228
|
-
resourceRequests.filter(r => r.status >= 400).forEach(r => log(` ❌ [${r.status}] ${r.url}`));
|
|
229
|
-
if (iframeInfo.length > 0) {
|
|
230
|
-
log(`\niframe 信息:`);
|
|
231
|
-
iframeInfo.forEach(f => log(` ${f.url} → ${f.error || (f.hasReactRoot ? 'React已挂载' : '未挂载')}`));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// 保存结果
|
|
235
|
-
if (OUTPUT) {
|
|
236
|
-
fs.writeFileSync(OUTPUT, JSON.stringify(result, null, 2));
|
|
237
|
-
log(`\n[OK] 结果已保存: ${OUTPUT}`);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return result;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ===== Action: screenshot =====
|
|
244
|
-
async function screenshot(browser) {
|
|
245
|
-
ensureDir(OUTPUT_DIR);
|
|
246
|
-
const page = await findPage(browser);
|
|
247
|
-
const filename = getArg('filename', 'screenshot.png');
|
|
248
|
-
const fullPath = path.join(OUTPUT_DIR, filename);
|
|
249
|
-
const fullPage = hasFlag('full-page');
|
|
250
|
-
await page.screenshot({ path: fullPath, fullPage });
|
|
251
|
-
log(`[OK] 截图已保存: ${fullPath}`);
|
|
252
|
-
return { filepath: fullPath };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ===== Action: api-check =====
|
|
256
|
-
async function apiCheck(browser) {
|
|
257
|
-
const page = await findPage(browser);
|
|
258
|
-
log(`[PAGE] ${page.url()}`);
|
|
259
|
-
|
|
260
|
-
const requests = [];
|
|
261
|
-
const failed = [];
|
|
262
|
-
|
|
263
|
-
page.on('response', resp => {
|
|
264
|
-
const type = resp.request().resourceType();
|
|
265
|
-
if (type === 'xhr' || type === 'fetch') {
|
|
266
|
-
requests.push({
|
|
267
|
-
status: resp.status(),
|
|
268
|
-
url: resp.url(),
|
|
269
|
-
method: resp.request().method(),
|
|
270
|
-
frameUrl: resp.request().frame()?.url?.() || '',
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
page.on('requestfailed', req => {
|
|
275
|
-
failed.push({ url: req.url(), error: req.failure()?.errorText });
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
log('[INFO] 刷新页面收集 API 请求...');
|
|
279
|
-
try { await page.reload({ waitUntil: 'load', timeout: 30000 }); } catch {}
|
|
280
|
-
await new Promise(r => setTimeout(r, WAIT_MS));
|
|
281
|
-
|
|
282
|
-
log('\n========== API 请求 ==========');
|
|
283
|
-
requests.forEach(r => {
|
|
284
|
-
const marker = r.status >= 400 ? '❌' : '✅';
|
|
285
|
-
const isIframe = r.frameUrl && r.frameUrl !== page.url() ? ' [iframe]' : '';
|
|
286
|
-
log(`${marker} [${r.status}] ${r.method} ${r.url}${isIframe}`);
|
|
287
|
-
});
|
|
288
|
-
if (failed.length > 0) {
|
|
289
|
-
log('\n========== 失败请求 ==========');
|
|
290
|
-
failed.forEach(r => log(`[FAILED] ${r.url} → ${r.error}`));
|
|
291
|
-
}
|
|
292
|
-
log(`\n总计: ${requests.length} 请求, 失败: ${requests.filter(r => r.status >= 400).length + failed.length}`);
|
|
293
|
-
|
|
294
|
-
return { requests, failed };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ===== Action: plan =====
|
|
298
|
-
async function executePlan(browser) {
|
|
299
|
-
const planPath = PLAN_FILE;
|
|
300
|
-
if (!planPath) { log('[ERROR] --plan-file 未指定'); return; }
|
|
301
|
-
const plan = JSON.parse(fs.readFileSync(planPath, 'utf-8'));
|
|
302
|
-
ensureDir(plan.options?.outputDir || OUTPUT_DIR);
|
|
303
|
-
|
|
304
|
-
log(`[PLAN] ${plan.description || plan.planId}`);
|
|
305
|
-
log(`[STEPS] ${plan.steps.length} 个步骤`);
|
|
306
|
-
|
|
307
|
-
const results = [];
|
|
308
|
-
let currentPage = await findPage(browser);
|
|
309
|
-
|
|
310
|
-
for (let i = 0; i < plan.steps.length; i++) {
|
|
311
|
-
const step = plan.steps[i];
|
|
312
|
-
const start = Date.now();
|
|
313
|
-
try {
|
|
314
|
-
let result = {};
|
|
315
|
-
switch (step.type) {
|
|
316
|
-
case 'navigate':
|
|
317
|
-
await currentPage.goto(step.url, { waitUntil: step.waitUntil || 'domcontentloaded', timeout: 30000 });
|
|
318
|
-
result = { url: currentPage.url() };
|
|
319
|
-
break;
|
|
320
|
-
case 'screenshot':
|
|
321
|
-
const fp = path.join(plan.options?.outputDir || OUTPUT_DIR, step.filename || `step-${i}.png`);
|
|
322
|
-
await currentPage.screenshot({ path: fp, fullPage: step.fullPage });
|
|
323
|
-
result = { filepath: fp };
|
|
324
|
-
break;
|
|
325
|
-
case 'waitFor':
|
|
326
|
-
if (step.selector) await currentPage.waitForSelector(step.selector, { timeout: step.timeout || 10000 });
|
|
327
|
-
result = { matched: true };
|
|
328
|
-
break;
|
|
329
|
-
case 'evaluate':
|
|
330
|
-
const evalResult = await currentPage.evaluate(step.expression);
|
|
331
|
-
result = { result: evalResult };
|
|
332
|
-
break;
|
|
333
|
-
case 'click':
|
|
334
|
-
await currentPage.click(step.selector);
|
|
335
|
-
result = { clicked: true };
|
|
336
|
-
break;
|
|
337
|
-
case 'checkElement':
|
|
338
|
-
const count = await currentPage.$$eval(step.selector, els => els.length).catch(() => 0);
|
|
339
|
-
const passed = step.expect?.minCount ? count >= step.expect.minCount : count > 0;
|
|
340
|
-
result = { passed, elementCount: count };
|
|
341
|
-
break;
|
|
342
|
-
default:
|
|
343
|
-
result = { skipped: true, reason: `unknown step type: ${step.type}` };
|
|
344
|
-
}
|
|
345
|
-
const duration = Date.now() - start;
|
|
346
|
-
results.push({ index: i, step: step.type, status: 'PASS', duration, ...result });
|
|
347
|
-
log(` [${i + 1}/${plan.steps.length}] ✅ ${step.type} (${duration}ms)`);
|
|
348
|
-
} catch (e) {
|
|
349
|
-
const duration = Date.now() - start;
|
|
350
|
-
results.push({ index: i, step: step.type, status: 'FAIL', duration, error: e.message });
|
|
351
|
-
log(` [${i + 1}/${plan.steps.length}] ❌ ${step.type} (${duration}ms) → ${e.message.substring(0, 100)}`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const summary = {
|
|
356
|
-
total: results.length,
|
|
357
|
-
passed: results.filter(r => r.status === 'PASS').length,
|
|
358
|
-
failed: results.filter(r => r.status === 'FAIL').length,
|
|
359
|
-
};
|
|
360
|
-
log(`\n[RESULT] ${summary.passed}/${summary.total} 通过`);
|
|
361
|
-
|
|
362
|
-
if (OUTPUT) {
|
|
363
|
-
fs.writeFileSync(OUTPUT, JSON.stringify({ steps: results, summary }, null, 2));
|
|
364
|
-
}
|
|
365
|
-
return { steps: results, summary };
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// ===== 主入口 =====
|
|
369
|
-
(async () => {
|
|
370
|
-
// 检测 CDP
|
|
371
|
-
const cdpInfo = await checkCDP(CDP_URL);
|
|
372
|
-
if (!cdpInfo) {
|
|
373
|
-
log(`[ERROR] 无法连接到 CDP: ${CDP_URL}`);
|
|
374
|
-
log('请确保浏览器已启动调试端口:');
|
|
375
|
-
log(' Windows Edge: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" --remote-debugging-port=9222');
|
|
376
|
-
log(' Windows Chrome: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222');
|
|
377
|
-
process.exit(1);
|
|
378
|
-
}
|
|
379
|
-
log(`[CDP] ${cdpInfo.Browser || 'connected'} (${CDP_URL})`);
|
|
380
|
-
|
|
381
|
-
const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: null });
|
|
382
|
-
|
|
383
|
-
let result;
|
|
384
|
-
switch (ACTION) {
|
|
385
|
-
case 'list-pages': result = await listPages(browser); break;
|
|
386
|
-
case 'diagnose': result = await diagnose(browser); break;
|
|
387
|
-
case 'screenshot': result = await screenshot(browser); break;
|
|
388
|
-
case 'api-check': result = await apiCheck(browser); break;
|
|
389
|
-
case 'plan': result = await executePlan(browser); break;
|
|
390
|
-
default: log(`[ERROR] 未知 action: ${ACTION}`);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
browser.disconnect();
|
|
394
|
-
log('\n[DONE]');
|
|
395
|
-
})();
|