@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.
- package/commands/szcd-mcp-browser-test.md +1 -1
- package/lib/browser-engine.js +831 -5
- package/lib/shared-deps.js +80 -40
- package/lib/visual-compare.js +3 -3
- package/local-browser-executor.js +321 -7
- package/opencode-extension/commands/szcd-mcp-browser-test.md +1 -1
- package/opencode-extension/skills/local-browser-test/SKILL.md +199 -15
- package/opencode-extension/skills/local-browser-test/local-browser-executor-old.cjs +395 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +348 -16
- package/package.json +1 -1
- package/qwen-extension/commands/szcd-mcp-browser-test.md +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +199 -15
- package/qwen-extension/skills/local-browser-test/local-browser-executor-old.cjs +395 -0
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +348 -16
- package/standard-skill/local-browser-test/SKILL.md +199 -15
- package/standard-skill/local-browser-test/local-browser-executor.cjs +348 -16
package/lib/browser-engine.js
CHANGED
|
@@ -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 {
|
|
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
|
|
1488
|
+
// 2. observe/act/assert 主链路只需要 puppeteer-core
|
|
1489
|
+
await ensureCoreDeps();
|
|
664
1490
|
|
|
665
1491
|
try {
|
|
666
1492
|
return loadFromShared("puppeteer-core");
|