@kkeve/suite 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/run.js +4 -1
- package/dist/core/generateReport/reportData.js +6 -3
- package/dist/core/global-setup.d.ts +1 -1
- package/dist/core/global-setup.js +22 -12
- package/dist/core/keve-test.js +78 -32
- package/dist/page-agent/agent.d.ts +16 -0
- package/dist/page-agent/agent.js +90 -0
- package/dist/page-agent/system_prompt.md +1 -1
- package/dist/page-agent/tools.js +297 -1
- package/doc/skills/keve-e2e-gen/references/script-generation-guide.md +11 -0
- package/package.json +1 -1
package/dist/commands/run.js
CHANGED
|
@@ -230,7 +230,10 @@ async function run(options) {
|
|
|
230
230
|
console.log(chalk_1.default.blue(` ▶ Round ${round} — Executing Playwright tests...\n`));
|
|
231
231
|
await ensurePlaywrightBrowsers();
|
|
232
232
|
if (!env.KEVE_CDP_ENDPOINT && !env.KEVE_CDP_WS_ENDPOINT) {
|
|
233
|
-
|
|
233
|
+
// 不再盲目设置 KEVE_CDP_ENDPOINT fallback — 如果 browserSetup() 未成功启动 Chrome,
|
|
234
|
+
// 这个 fallback 会让 isCdpMode() 返回 true → getCdpBrowser() 去连一个不存在的 CDP → throw
|
|
235
|
+
// 现在由 getCdpBrowser() 内部处理 graceful fallback 到非 CDP 模式
|
|
236
|
+
console.log(chalk_1.default.gray(' Note: No CDP endpoint configured — Playwright will launch its own browser'));
|
|
234
237
|
}
|
|
235
238
|
const exitCode = await new Promise((resolve) => {
|
|
236
239
|
const child = (0, node_child_process_1.spawn)('npx', pwArgs, {
|
|
@@ -73,9 +73,12 @@ function resolveOptions(opts) {
|
|
|
73
73
|
// 2. ktest 原始的 TCDPQA100195 / TCDPQA100189 等:「大写字母+数字」连续无分隔
|
|
74
74
|
// (ktest 命名规则:TC=TestCase DPQA=数据平台QA 100195=序列号)
|
|
75
75
|
function parseCaseIdFromTitle(title) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
76
|
+
// Case ID patterns: "SKILL_SHARE-01:", "AG-01:", "ABC123:", "[REG-02]"
|
|
77
|
+
// Supports: underscores (SKILL_SHARE), hyphens (REG-01), pure alphanumeric (ABC123)
|
|
78
|
+
const caseIdPattern = '[A-Z][A-Z0-9]*(?:[_-][A-Z0-9]+)*-\\d+|[A-Z]+\\d+';
|
|
79
|
+
const colonMatch = title.match(new RegExp(`^(${caseIdPattern}):`));
|
|
80
|
+
const bracketMatch = title.match(new RegExp(`\\[(${caseIdPattern})\\]`));
|
|
81
|
+
const spaceMatch = title.match(new RegExp(`^(${caseIdPattern})\\s`));
|
|
79
82
|
return colonMatch?.[1] || bracketMatch?.[1] || spaceMatch?.[1] || null;
|
|
80
83
|
}
|
|
81
84
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
declare global {
|
|
2
2
|
var __keve_cdp_browser: import('@playwright/test').Browser | undefined;
|
|
3
3
|
}
|
|
4
|
-
export declare function getCdpBrowser(): Promise<import('@playwright/test').Browser>;
|
|
4
|
+
export declare function getCdpBrowser(): Promise<import('@playwright/test').Browser | null>;
|
|
5
5
|
export declare function isCdpMode(): boolean;
|
|
6
6
|
export default function (): Promise<void>;
|
|
@@ -50,21 +50,30 @@ async function getCdpBrowser() {
|
|
|
50
50
|
if (!wsUrl && process.env.KEVE_CDP_ENDPOINT) {
|
|
51
51
|
const httpUrl = process.env.KEVE_CDP_ENDPOINT.replace(/\/$/, '');
|
|
52
52
|
try {
|
|
53
|
-
const resp = await fetch(`${httpUrl}/json/version
|
|
53
|
+
const resp = await fetch(`${httpUrl}/json/version`, { signal: AbortSignal.timeout(5000) });
|
|
54
54
|
const data = await resp.json();
|
|
55
55
|
wsUrl = data.webSocketDebuggerUrl;
|
|
56
56
|
console.log(`[keve CDP] Resolved WS endpoint: ${wsUrl}`);
|
|
57
57
|
}
|
|
58
58
|
catch (e) {
|
|
59
|
-
|
|
59
|
+
console.warn(`[keve CDP] Failed to resolve CDP WebSocket URL from ${httpUrl}: ${e.message} — falling back to non-CDP mode`);
|
|
60
|
+
return null;
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
|
-
if (!wsUrl)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
if (!wsUrl) {
|
|
64
|
+
console.warn(`[keve CDP] No CDP endpoint configured — falling back to non-CDP mode`);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const browser = await chromium.connectOverCDP(wsUrl, { timeout: 15000 });
|
|
69
|
+
console.log(`[keve CDP] Connected via ${wsUrl}`);
|
|
70
|
+
globalThis.__keve_cdp_browser = browser;
|
|
71
|
+
return browser;
|
|
72
|
+
}
|
|
73
|
+
catch (connectErr) {
|
|
74
|
+
console.warn(`[keve CDP] connectOverCDP failed: ${connectErr.message} — falling back to non-CDP mode`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
68
77
|
}
|
|
69
78
|
function isCdpMode() {
|
|
70
79
|
return !!process.env.KEVE_CDP_WS_ENDPOINT || !!process.env.KEVE_CDP_ENDPOINT;
|
|
@@ -76,8 +85,9 @@ async function default_1() {
|
|
|
76
85
|
if (cdpEndpoint) {
|
|
77
86
|
console.log(`[keve global-setup] CDP endpoint detected: ${cdpEndpoint}`);
|
|
78
87
|
// CDP 模式:在 global-setup 中提前连接一次,缓存 Browser 对象
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
// getCdpBrowser() 失败时返回 null 并清除 CDP 环境变量 → 后续 fixture 自动 fallback
|
|
89
|
+
const browser = await getCdpBrowser();
|
|
90
|
+
if (browser) {
|
|
81
91
|
const contexts = browser.contexts();
|
|
82
92
|
const pages = contexts.length > 0 ? contexts[0].pages() : [];
|
|
83
93
|
console.log(`[keve global-setup] CDP connected. contexts=${contexts.length}, pages=${pages.length}`);
|
|
@@ -85,8 +95,8 @@ async function default_1() {
|
|
|
85
95
|
console.log(`[keve global-setup] Existing tabs: ${pages.map(p => p.url()).join(', ')}`);
|
|
86
96
|
}
|
|
87
97
|
}
|
|
88
|
-
|
|
89
|
-
console.warn(`[keve global-setup] CDP connection failed
|
|
98
|
+
else {
|
|
99
|
+
console.warn(`[keve global-setup] CDP connection failed — tests will use non-CDP fallback (chromium.launch)`);
|
|
90
100
|
}
|
|
91
101
|
if (!fs.existsSync(storageStatePath)) {
|
|
92
102
|
const authDir = path.dirname(storageStatePath);
|
package/dist/core/keve-test.js
CHANGED
|
@@ -104,8 +104,17 @@ exports.test = test_1.test.extend({
|
|
|
104
104
|
browser: async ({}, use, testInfo) => {
|
|
105
105
|
if ((0, global_setup_1.isCdpMode)()) {
|
|
106
106
|
const browser = await (0, global_setup_1.getCdpBrowser)(); // 复用全局共享的 CDP browser
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
if (browser) {
|
|
108
|
+
await use(browser);
|
|
109
|
+
// CDP 模式下不关闭浏览器(共享连接,关闭会影响后续用例)
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// CDP 不可达 → graceful fallback 到独立启动浏览器
|
|
113
|
+
console.log(`[keve-test] CDP unavailable, falling back to chromium.launch()`);
|
|
114
|
+
const fallback = await test_1.chromium.launch(testInfo.project?.use?.launchOptions || {});
|
|
115
|
+
await use(fallback);
|
|
116
|
+
await fallback.close();
|
|
117
|
+
}
|
|
109
118
|
}
|
|
110
119
|
else {
|
|
111
120
|
const browser = await test_1.chromium.launch(testInfo.project?.use?.launchOptions || {});
|
|
@@ -114,7 +123,8 @@ exports.test = test_1.test.extend({
|
|
|
114
123
|
}
|
|
115
124
|
},
|
|
116
125
|
page: async ({ browser }, use) => {
|
|
117
|
-
|
|
126
|
+
const isCdp = (0, global_setup_1.isCdpMode)() && browser.isConnected();
|
|
127
|
+
if (isCdp) {
|
|
118
128
|
const contexts = browser.contexts();
|
|
119
129
|
const cdpContext = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
120
130
|
// CDP 模式:优先复用已有 page(保留登录状态),仅在无可用 page 时新开
|
|
@@ -224,43 +234,69 @@ exports.test = test_1.test.extend({
|
|
|
224
234
|
};
|
|
225
235
|
}
|
|
226
236
|
else {
|
|
227
|
-
// ──
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const goalTimeout = setTimeout(() => {
|
|
232
|
-
goalTimeoutController.abort();
|
|
233
|
-
console.log(`[keveGoal] ⏰ "${options.step}" timed out after ${GOAL_TIMEOUT_MS}ms — stopping agent`);
|
|
234
|
-
}, GOAL_TIMEOUT_MS);
|
|
237
|
+
// ── Page liveness check: if fn killed the page, skip Agent Re-Act ──
|
|
238
|
+
// When fn error involves page/browser close, the page is dead —
|
|
239
|
+
// Agent cannot take a11y snapshots or interact. Skip immediately.
|
|
240
|
+
let pageAlive = true;
|
|
235
241
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
fnResult: fnError ? { error: fnError } : { success: true },
|
|
241
|
-
signal: goalTimeoutController.signal,
|
|
242
|
-
goalScreenshotBefore: goalScreenshotBefore || undefined,
|
|
243
|
-
fnAfterScreenshot: fnAfterScreenshot || undefined,
|
|
244
|
-
});
|
|
242
|
+
await page.url();
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
pageAlive = false;
|
|
245
246
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const isGoalTimeout = goalTimeoutController.signal.aborted;
|
|
249
|
-
const msg = isGoalTimeout
|
|
250
|
-
? `Goal timed out (${GOAL_TIMEOUT_MS}ms)`
|
|
251
|
-
: (reactErr?.message || String(reactErr));
|
|
252
|
-
console.log(`[keveGoal] reactLoop interrupted: ${msg.slice(0, 200)}`);
|
|
247
|
+
if (!pageAlive) {
|
|
248
|
+
console.log(`[keveGoal] 🚫 "${options.step}" BLOCKED — page is dead after fn error, skipping agent Re-Act`);
|
|
253
249
|
reactResult = {
|
|
254
250
|
expectedMet: false,
|
|
255
|
-
actions: [
|
|
251
|
+
actions: [{
|
|
252
|
+
action: { tool: 'done', verdict: 'blocked', text: `Page closed after fn error: ${fnError}` },
|
|
253
|
+
toolOutput: '',
|
|
254
|
+
result: 'ok',
|
|
255
|
+
}],
|
|
256
256
|
finalSnapshot: '',
|
|
257
257
|
agentScreenshots: [],
|
|
258
|
-
conclusion:
|
|
258
|
+
conclusion: 'blocked',
|
|
259
259
|
};
|
|
260
260
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
else {
|
|
262
|
+
// ── Per-goal timeout: prevent one slow goal from exhausting the test timeout ──
|
|
263
|
+
// Default 300s per goal; total test timeout should be ≥ (goals × 300s + overhead)
|
|
264
|
+
const GOAL_TIMEOUT_MS = 300_000;
|
|
265
|
+
const goalTimeoutController = new AbortController();
|
|
266
|
+
const goalTimeout = setTimeout(() => {
|
|
267
|
+
goalTimeoutController.abort();
|
|
268
|
+
console.log(`[keveGoal] ⏰ "${options.step}" timed out after ${GOAL_TIMEOUT_MS}ms — stopping agent`);
|
|
269
|
+
}, GOAL_TIMEOUT_MS);
|
|
270
|
+
try {
|
|
271
|
+
reactResult = await (0, agent_1.reactLoop)(page, options.step, options.expected, {
|
|
272
|
+
learnedActionsHint: learnedHint,
|
|
273
|
+
specFilePath: ctx.specFilePath,
|
|
274
|
+
fnSource,
|
|
275
|
+
fnResult: fnError ? { error: fnError } : { success: true },
|
|
276
|
+
signal: goalTimeoutController.signal,
|
|
277
|
+
goalScreenshotBefore: goalScreenshotBefore || undefined,
|
|
278
|
+
fnAfterScreenshot: fnAfterScreenshot || undefined,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
catch (reactErr) {
|
|
282
|
+
reactTimedOut = true;
|
|
283
|
+
const isGoalTimeout = goalTimeoutController.signal.aborted;
|
|
284
|
+
const msg = isGoalTimeout
|
|
285
|
+
? `Goal timed out (${GOAL_TIMEOUT_MS}ms)`
|
|
286
|
+
: (reactErr?.message || String(reactErr));
|
|
287
|
+
console.log(`[keveGoal] reactLoop interrupted: ${msg.slice(0, 200)}`);
|
|
288
|
+
reactResult = {
|
|
289
|
+
expectedMet: false,
|
|
290
|
+
actions: [],
|
|
291
|
+
finalSnapshot: '',
|
|
292
|
+
agentScreenshots: [],
|
|
293
|
+
conclusion: isGoalTimeout ? 'blocked' : 'fail',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
clearTimeout(goalTimeout);
|
|
298
|
+
}
|
|
299
|
+
} // end of page-alive else (agent Re-Act path)
|
|
264
300
|
} // end of else (non-blocked path)
|
|
265
301
|
// ── Deterministic override: fn assertion failure cannot be overridden by Agent "pass" ──
|
|
266
302
|
// When fn has a hard assertion failure (Expected X, Received Y), the value mismatch
|
|
@@ -466,6 +502,16 @@ function isEnvironmentBlockedError(err) {
|
|
|
466
502
|
|| msg.includes('err_too_many_redirects')) {
|
|
467
503
|
return true;
|
|
468
504
|
}
|
|
505
|
+
// Page/context/browser closed — dead page, Agent cannot explore
|
|
506
|
+
// e.g. "page.waitForLoadState: Target page, context or browser has been closed"
|
|
507
|
+
// e.g. "page.ariaSnapshot: Target page, context or browser has been closed"
|
|
508
|
+
// When the CDP connection drops or the user closes the tab, any page API throws this.
|
|
509
|
+
// Agent Re-Act on a dead page is a waste of time → BLOCKED immediately.
|
|
510
|
+
if (msg.includes('target page, context or browser has been closed')
|
|
511
|
+
|| msg.includes('page has been closed') || msg.includes('browser has been closed')
|
|
512
|
+
|| msg.includes('context has been closed')) {
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
469
515
|
return false;
|
|
470
516
|
}
|
|
471
517
|
/**
|
|
@@ -85,6 +85,19 @@ export declare class KevePageAgent {
|
|
|
85
85
|
/** Current execute options (set at start of execute(), accessible from hooks) */
|
|
86
86
|
options?: AgentOptions;
|
|
87
87
|
private hooks?;
|
|
88
|
+
_dialogHandler: {
|
|
89
|
+
accept: boolean;
|
|
90
|
+
promptText?: string;
|
|
91
|
+
} | null;
|
|
92
|
+
_lastDialogInfo: {
|
|
93
|
+
type: string;
|
|
94
|
+
message: string;
|
|
95
|
+
accepted: boolean;
|
|
96
|
+
} | null;
|
|
97
|
+
_pendingFileChooser: any;
|
|
98
|
+
_consoleMessages: any[];
|
|
99
|
+
_networkRequests: any[];
|
|
100
|
+
_pageListenersSetup: boolean;
|
|
88
101
|
constructor(page: Page, options?: {
|
|
89
102
|
maxSteps?: number;
|
|
90
103
|
customSystemPrompt?: string;
|
|
@@ -92,6 +105,9 @@ export declare class KevePageAgent {
|
|
|
92
105
|
});
|
|
93
106
|
/** Stop the current execution */
|
|
94
107
|
stop(): void;
|
|
108
|
+
/** Set up page event listeners for dialog, console, network, and file chooser capture.
|
|
109
|
+
* Called once per page; subsequent calls are no-ops. */
|
|
110
|
+
private setupPageListeners;
|
|
95
111
|
/** Main Re-Act execute loop */
|
|
96
112
|
execute(step: string, expected: string, options?: AgentOptions): Promise<AgentResult>;
|
|
97
113
|
private pushObservation;
|
package/dist/page-agent/agent.js
CHANGED
|
@@ -157,6 +157,13 @@ class KevePageAgent {
|
|
|
157
157
|
/** Current execute options (set at start of execute(), accessible from hooks) */
|
|
158
158
|
options;
|
|
159
159
|
hooks;
|
|
160
|
+
// Page event listener state (used by handle_dialog, console_messages, file_upload, network_requests)
|
|
161
|
+
_dialogHandler = null;
|
|
162
|
+
_lastDialogInfo = null;
|
|
163
|
+
_pendingFileChooser = null;
|
|
164
|
+
_consoleMessages = [];
|
|
165
|
+
_networkRequests = [];
|
|
166
|
+
_pageListenersSetup = false;
|
|
160
167
|
constructor(page, options) {
|
|
161
168
|
this.page = page;
|
|
162
169
|
this.maxSteps = options?.maxSteps ?? 8;
|
|
@@ -175,12 +182,95 @@ class KevePageAgent {
|
|
|
175
182
|
stop() {
|
|
176
183
|
this.abortController.abort();
|
|
177
184
|
}
|
|
185
|
+
/** Set up page event listeners for dialog, console, network, and file chooser capture.
|
|
186
|
+
* Called once per page; subsequent calls are no-ops. */
|
|
187
|
+
setupPageListeners() {
|
|
188
|
+
if (this._pageListenersSetup)
|
|
189
|
+
return;
|
|
190
|
+
this._pageListenersSetup = true;
|
|
191
|
+
// ── Dialog handler ──
|
|
192
|
+
// Playwright auto-dismisses dialogs unless a handler is registered.
|
|
193
|
+
// Our handler auto-accepts by default, but _dialogHandler can override.
|
|
194
|
+
this.page.on('dialog', async (dialog) => {
|
|
195
|
+
const handler = this._dialogHandler;
|
|
196
|
+
const accept = handler?.accept ?? true;
|
|
197
|
+
const promptText = handler?.promptText;
|
|
198
|
+
try {
|
|
199
|
+
if (accept) {
|
|
200
|
+
await dialog.accept(promptText || '');
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
await dialog.dismiss();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Fallback: always accept to unblock the page
|
|
208
|
+
try {
|
|
209
|
+
await dialog.accept();
|
|
210
|
+
}
|
|
211
|
+
catch { /* give up */ }
|
|
212
|
+
}
|
|
213
|
+
this._lastDialogInfo = {
|
|
214
|
+
type: dialog.type(),
|
|
215
|
+
message: dialog.message(),
|
|
216
|
+
accepted: accept,
|
|
217
|
+
};
|
|
218
|
+
this._dialogHandler = null;
|
|
219
|
+
// Notify the Agent so it knows a dialog appeared
|
|
220
|
+
this.pushObservation(`🔔 Native dialog [${dialog.type()}] appeared: "${dialog.message().slice(0, 100)}" — was ${accept ? 'accepted' : 'dismissed'}.`);
|
|
221
|
+
});
|
|
222
|
+
// ── Console messages ──
|
|
223
|
+
this.page.on('console', (msg) => {
|
|
224
|
+
this._consoleMessages.push({ level: msg.type(), text: msg.text() });
|
|
225
|
+
if (this._consoleMessages.length > 200)
|
|
226
|
+
this._consoleMessages.shift();
|
|
227
|
+
});
|
|
228
|
+
this.page.on('pageerror', (err) => {
|
|
229
|
+
this._consoleMessages.push({ level: 'error', text: `Uncaught: ${err.message}` });
|
|
230
|
+
});
|
|
231
|
+
// ── Network requests ──
|
|
232
|
+
this.page.on('request', (req) => {
|
|
233
|
+
const url = req.url();
|
|
234
|
+
const isStatic = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map)(\?|$)/i.test(url);
|
|
235
|
+
this._networkRequests.push({
|
|
236
|
+
_req: req,
|
|
237
|
+
method: req.method(),
|
|
238
|
+
url,
|
|
239
|
+
isStatic,
|
|
240
|
+
status: null,
|
|
241
|
+
});
|
|
242
|
+
if (this._networkRequests.length > 100)
|
|
243
|
+
this._networkRequests.shift();
|
|
244
|
+
});
|
|
245
|
+
this.page.on('response', (res) => {
|
|
246
|
+
const req = res.request();
|
|
247
|
+
// Match by reference identity — Playwright guarantees same Request object
|
|
248
|
+
for (let i = this._networkRequests.length - 1; i >= 0; i--) {
|
|
249
|
+
if (this._networkRequests[i]._req === req) {
|
|
250
|
+
this._networkRequests[i].status = res.status();
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
// ── File chooser ──
|
|
256
|
+
this.page.on('filechooser', (fileChooser) => {
|
|
257
|
+
this._pendingFileChooser = fileChooser;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
178
260
|
/** Main Re-Act execute loop */
|
|
179
261
|
async execute(step, expected, options) {
|
|
180
262
|
// Reset state
|
|
181
263
|
this.events = [];
|
|
182
264
|
this.abortController = new AbortController();
|
|
183
265
|
this.options = options;
|
|
266
|
+
// Reset page event listener state
|
|
267
|
+
this._dialogHandler = null;
|
|
268
|
+
this._lastDialogInfo = null;
|
|
269
|
+
this._pendingFileChooser = null;
|
|
270
|
+
this._consoleMessages = [];
|
|
271
|
+
this._networkRequests = [];
|
|
272
|
+
// Set up page event listeners (idempotent — only runs once per page)
|
|
273
|
+
this.setupPageListeners();
|
|
184
274
|
const hooks = options?.hooks ?? this.hooks;
|
|
185
275
|
// Link external signal (e.g. per-goal timeout) to internal abort controller
|
|
186
276
|
const externalSignal = options?.signal;
|
|
@@ -293,7 +293,7 @@ You MUST output a JSON object with the following FLAT structure every step:
|
|
|
293
293
|
"evaluation_previous_goal": "Concise one-sentence analysis of your last action. State success, failure, or uncertain. Reference the screenshot if it confirms or contradicts the expected state.",
|
|
294
294
|
"memory": "1-3 concise sentences of key observations that will help in future steps.",
|
|
295
295
|
"next_goal": "State the next immediate goal and action to achieve it.",
|
|
296
|
-
"tool": "The action tool name (click, type, hover, navigate, scroll, wait, done, visual_locate, visual_assert, etc.)",
|
|
296
|
+
"tool": "The action tool name (click, type, hover, navigate, scroll, wait, wait_for, done, drag, verify_value, handle_dialog, console_messages, file_upload, network_requests, resize_viewport, visual_locate, visual_assert, select_option, fill_form, pressKey, execute_javascript, etc.)",
|
|
297
297
|
"ref": "Element ref from the accessibility tree, e.g. 'f5e13' (for click/type/hover)",
|
|
298
298
|
"text": "Text to type (for type) OR description for done/visual_locate/visual_assert",
|
|
299
299
|
...other tool-specific fields...
|
package/dist/page-agent/tools.js
CHANGED
|
@@ -499,7 +499,7 @@ exports.tools.set('navigate', tool({
|
|
|
499
499
|
}
|
|
500
500
|
url = new URL(url, base).href;
|
|
501
501
|
}
|
|
502
|
-
await this.page.goto(url, { waitUntil: '
|
|
502
|
+
await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
503
503
|
return `✅ Navigated to ${url}`;
|
|
504
504
|
},
|
|
505
505
|
}));
|
|
@@ -747,6 +747,302 @@ exports.tools.set('fill_form', tool({
|
|
|
747
747
|
return results.join('\n');
|
|
748
748
|
},
|
|
749
749
|
}));
|
|
750
|
+
// --- drag ---
|
|
751
|
+
exports.tools.set('drag', tool({
|
|
752
|
+
description: `Drag from a starting point to an ending point using real mouse events (mousedown → mousemove steps → mouseup). Use for: resizing elements via drag handles, drag-to-reorder lists, any UI requiring hold+move interaction.
|
|
753
|
+
|
|
754
|
+
Two targeting modes:
|
|
755
|
+
1. By ref — drags from the element's center (use for drag-to-reorder)
|
|
756
|
+
2. By coordinates (startX/startY) — precise point (use for resize handles found via visual_locate)
|
|
757
|
+
|
|
758
|
+
Target specification:
|
|
759
|
+
- Relative: use deltaX/deltaY (e.g. deltaX=200 moves 200px right)
|
|
760
|
+
- Absolute: use endX/endY (e.g. endX=800, endY=400)
|
|
761
|
+
|
|
762
|
+
NOT for: scrolling (use scroll), clicking (use click), HTML5 DnD between elements.`,
|
|
763
|
+
inputSchema: zod_1.z.object({
|
|
764
|
+
ref: zod_1.z.string().optional()
|
|
765
|
+
.describe('Element ref to start dragging from. Drags from element center. Use startX/startY for resize handles.'),
|
|
766
|
+
startX: zod_1.z.number().optional()
|
|
767
|
+
.describe('X coordinate to start dragging from (viewport pixels). Use when you have precise coordinates from visual_locate.'),
|
|
768
|
+
startY: zod_1.z.number().optional()
|
|
769
|
+
.describe('Y coordinate to start dragging from (viewport pixels).'),
|
|
770
|
+
deltaX: zod_1.z.number().optional()
|
|
771
|
+
.describe('Horizontal displacement in pixels. Positive=right, negative=left. Use with ref or startX/startY.'),
|
|
772
|
+
deltaY: zod_1.z.number().optional()
|
|
773
|
+
.describe('Vertical displacement in pixels. Positive=down, negative=up.'),
|
|
774
|
+
endX: zod_1.z.number().optional()
|
|
775
|
+
.describe('Absolute X coordinate to drag to (viewport pixels). Use deltaX/deltaY for relative, endX/endY for absolute.'),
|
|
776
|
+
endY: zod_1.z.number().optional()
|
|
777
|
+
.describe('Absolute Y coordinate to drag to (viewport pixels).'),
|
|
778
|
+
steps: zod_1.z.number().min(1).max(100).default(10)
|
|
779
|
+
.describe('Number of intermediate mousemove steps. More=smoother animation. Default 10. Use 20+ for CSS resize.'),
|
|
780
|
+
}),
|
|
781
|
+
execute: async function (input) {
|
|
782
|
+
// 1. Determine start coordinates
|
|
783
|
+
let sx, sy;
|
|
784
|
+
if (input.ref) {
|
|
785
|
+
const locator = this.page.locator(`aria-ref=${input.ref}`);
|
|
786
|
+
try {
|
|
787
|
+
const box = await locator.boundingBox({ timeout: 5000 });
|
|
788
|
+
if (!box)
|
|
789
|
+
return `❌ Cannot drag: element [ref=${input.ref}] not visible or detached`;
|
|
790
|
+
sx = box.x + box.width / 2;
|
|
791
|
+
sy = box.y + box.height / 2;
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
return `❌ Cannot drag: element [ref=${input.ref}] error: ${err.message}`;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
else if (input.startX !== undefined && input.startY !== undefined) {
|
|
798
|
+
sx = input.startX;
|
|
799
|
+
sy = input.startY;
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
return `❌ Cannot drag: provide either ref or startX/startY`;
|
|
803
|
+
}
|
|
804
|
+
// 2. Determine end coordinates
|
|
805
|
+
let ex, ey;
|
|
806
|
+
if (input.endX !== undefined && input.endY !== undefined) {
|
|
807
|
+
ex = input.endX;
|
|
808
|
+
ey = input.endY;
|
|
809
|
+
}
|
|
810
|
+
else if (input.deltaX !== undefined || input.deltaY !== undefined) {
|
|
811
|
+
ex = sx + (input.deltaX || 0);
|
|
812
|
+
ey = sy + (input.deltaY || 0);
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
return `❌ Cannot drag: provide either deltaX/deltaY or endX/endY`;
|
|
816
|
+
}
|
|
817
|
+
// 3. Execute real mouse drag with intermediate steps
|
|
818
|
+
const steps = input.steps ?? 10;
|
|
819
|
+
try {
|
|
820
|
+
await this.page.mouse.move(sx, sy);
|
|
821
|
+
await this.page.mouse.down();
|
|
822
|
+
for (let i = 1; i <= steps; i++) {
|
|
823
|
+
const progress = i / steps;
|
|
824
|
+
await this.page.mouse.move(sx + (ex - sx) * progress, sy + (ey - sy) * progress);
|
|
825
|
+
await this.page.waitForTimeout(16); // ~60fps
|
|
826
|
+
}
|
|
827
|
+
await this.page.mouse.up();
|
|
828
|
+
await this.page.waitForTimeout(300); // wait for resize/layout to settle
|
|
829
|
+
return `✅ Dragged from (${Math.round(sx)}, ${Math.round(sy)}) to (${Math.round(ex)}, ${Math.round(ey)}) in ${steps} steps (Δx=${Math.round(ex - sx)}, Δy=${Math.round(ey - sy)})`;
|
|
830
|
+
}
|
|
831
|
+
catch (err) {
|
|
832
|
+
try {
|
|
833
|
+
await this.page.mouse.up();
|
|
834
|
+
}
|
|
835
|
+
catch { /* ensure mouse released */ }
|
|
836
|
+
return `❌ Drag failed: ${err.message}`;
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
}));
|
|
840
|
+
// --- wait_for ---
|
|
841
|
+
exports.tools.set('wait_for', tool({
|
|
842
|
+
description: `Wait for text to appear or disappear on the page, or for a specified duration. More efficient than polling with wait(time).
|
|
843
|
+
|
|
844
|
+
- waitForText: wait until specific text becomes visible on the page (e.g. wait for "保存成功" toast)
|
|
845
|
+
- waitForTextGone: wait until specific text disappears (e.g. wait for "加载中..." spinner to vanish)
|
|
846
|
+
- seconds: simple time-based wait (max 30s)
|
|
847
|
+
|
|
848
|
+
At least one parameter is required. You can combine them (e.g. wait for text + timeout).`,
|
|
849
|
+
inputSchema: zod_1.z.object({
|
|
850
|
+
waitForText: zod_1.z.string().optional()
|
|
851
|
+
.describe('Wait for this text to appear on the page (visible)'),
|
|
852
|
+
waitForTextGone: zod_1.z.string().optional()
|
|
853
|
+
.describe('Wait for this text to disappear from the page (hidden)'),
|
|
854
|
+
seconds: zod_1.z.number().min(0.1).max(30).optional()
|
|
855
|
+
.describe('Wait time in seconds (max 30)'),
|
|
856
|
+
}),
|
|
857
|
+
execute: async function (input) {
|
|
858
|
+
if (!input.waitForText && !input.waitForTextGone && !input.seconds) {
|
|
859
|
+
return `❌ wait_for: provide at least one of waitForText, waitForTextGone, or seconds`;
|
|
860
|
+
}
|
|
861
|
+
const results = [];
|
|
862
|
+
if (input.seconds) {
|
|
863
|
+
await this.page.waitForTimeout(input.seconds * 1000);
|
|
864
|
+
results.push(`waited ${input.seconds}s`);
|
|
865
|
+
}
|
|
866
|
+
if (input.waitForTextGone) {
|
|
867
|
+
const locator = this.page.getByText(input.waitForTextGone).first();
|
|
868
|
+
try {
|
|
869
|
+
await locator.waitFor({ state: 'hidden', timeout: 15000 });
|
|
870
|
+
results.push(`"${input.waitForTextGone}" disappeared`);
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
results.push(`⚠️ "${input.waitForTextGone}" still visible after 15s`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (input.waitForText) {
|
|
877
|
+
const locator = this.page.getByText(input.waitForText).first();
|
|
878
|
+
try {
|
|
879
|
+
await locator.waitFor({ state: 'visible', timeout: 15000 });
|
|
880
|
+
results.push(`"${input.waitForText}" appeared`);
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
results.push(`⚠️ "${input.waitForText}" not found after 15s`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return `✅ ${results.join(', ')}`;
|
|
887
|
+
},
|
|
888
|
+
}));
|
|
889
|
+
// --- verify_value ---
|
|
890
|
+
exports.tools.set('verify_value', tool({
|
|
891
|
+
description: `Deterministically verify a form element's current value — no LLM needed. Faster and more precise than visual_assert for value checks.
|
|
892
|
+
|
|
893
|
+
- textbox/slider/combobox → checks locator.inputValue()
|
|
894
|
+
- checkbox/radio → checks locator.isChecked()
|
|
895
|
+
|
|
896
|
+
Returns ✅ if value matches, ❌ if mismatch. Use for precise value assertions instead of visual inspection.`,
|
|
897
|
+
inputSchema: zod_1.z.object({
|
|
898
|
+
ref: zod_1.z.string().describe('Element ref from the accessibility tree'),
|
|
899
|
+
type: zod_1.z.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider'])
|
|
900
|
+
.describe('Element type: textbox/slider/combobox → check inputValue; checkbox/radio → check isChecked'),
|
|
901
|
+
value: zod_1.z.string().describe('Expected value. For checkbox/radio: "true" or "false". For textbox: expected text. For combobox: expected selected value.'),
|
|
902
|
+
}),
|
|
903
|
+
execute: async function (input) {
|
|
904
|
+
const locator = this.page.locator(`aria-ref=${input.ref}`);
|
|
905
|
+
try {
|
|
906
|
+
if (input.type === 'checkbox' || input.type === 'radio') {
|
|
907
|
+
const checked = await locator.isChecked({ timeout: 5000 });
|
|
908
|
+
const expected = input.value === 'true';
|
|
909
|
+
if (checked === expected) {
|
|
910
|
+
return `✅ Verify [ref=${input.ref}] ${input.type}: checked=${checked} (expected ${input.value})`;
|
|
911
|
+
}
|
|
912
|
+
return `❌ Verify [ref=${input.ref}] ${input.type}: checked=${checked} (expected ${input.value})`;
|
|
913
|
+
}
|
|
914
|
+
// textbox, slider, combobox — check inputValue
|
|
915
|
+
const actualValue = await locator.inputValue({ timeout: 5000 });
|
|
916
|
+
if (actualValue === input.value) {
|
|
917
|
+
return `✅ Verify [ref=${input.ref}] ${input.type}: value="${actualValue}" (expected "${input.value}")`;
|
|
918
|
+
}
|
|
919
|
+
// For combobox, also check textContent (custom components may not expose value via inputValue)
|
|
920
|
+
if (input.type === 'combobox') {
|
|
921
|
+
const textContent = await locator.textContent({ timeout: 2000 }).catch(() => '');
|
|
922
|
+
if (textContent?.includes(input.value)) {
|
|
923
|
+
return `✅ Verify [ref=${input.ref}] combobox: text contains "${input.value}" (inputValue="${actualValue}")`;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return `❌ Verify [ref=${input.ref}] ${input.type}: value="${actualValue}" (expected "${input.value}")`;
|
|
927
|
+
}
|
|
928
|
+
catch (err) {
|
|
929
|
+
return `❌ Verify [ref=${input.ref}] failed: ${err.message}`;
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
}));
|
|
933
|
+
// --- handle_dialog ---
|
|
934
|
+
exports.tools.set('handle_dialog', tool({
|
|
935
|
+
description: `Handle native browser dialogs (alert, confirm, prompt). Native dialogs are auto-accepted by default when they appear.
|
|
936
|
+
|
|
937
|
+
Two usage modes:
|
|
938
|
+
1. BEFORE a dialog-triggering action: call handle_dialog(accept=false) to override the default auto-accept behavior. Then click the button that triggers the dialog.
|
|
939
|
+
2. AFTER a dialog appeared: call handle_dialog to check what dialog was captured and how it was handled.
|
|
940
|
+
|
|
941
|
+
Native dialogs are auto-accepted unless you pre-set a different action. If a click triggers a dialog, it will be handled automatically based on your pre-set preference.`,
|
|
942
|
+
inputSchema: zod_1.z.object({
|
|
943
|
+
accept: zod_1.z.boolean().default(true).describe('Whether to accept (true) or dismiss (false) the next native dialog. Default true.'),
|
|
944
|
+
promptText: zod_1.z.string().optional().describe('Text to enter in prompt dialog (only when accept=true and dialog is a prompt)'),
|
|
945
|
+
}),
|
|
946
|
+
execute: async function (input) {
|
|
947
|
+
// Set handler for the NEXT dialog
|
|
948
|
+
this._dialogHandler = { accept: input.accept, promptText: input.promptText };
|
|
949
|
+
// Also report the last captured dialog (if any)
|
|
950
|
+
const last = this._lastDialogInfo;
|
|
951
|
+
if (last) {
|
|
952
|
+
return `Last dialog: [${last.type}] "${last.message.slice(0, 80)}" — was ${last.accepted ? 'accepted' : 'dismissed'}. Next dialog will be ${input.accept ? 'accepted' : 'dismissed'}.`;
|
|
953
|
+
}
|
|
954
|
+
return `Next native dialog will be ${input.accept ? 'accepted' : 'dismissed'}${input.promptText ? ` with text "${input.promptText}"` : ''}.`;
|
|
955
|
+
},
|
|
956
|
+
}));
|
|
957
|
+
// --- console_messages ---
|
|
958
|
+
exports.tools.set('console_messages', tool({
|
|
959
|
+
description: `Get browser console log messages captured during page execution. Use to check for JavaScript errors, verify log output, or debug page issues. More reliable than visual checking for error toasts or log messages.`,
|
|
960
|
+
inputSchema: zod_1.z.object({
|
|
961
|
+
level: zod_1.z.enum(['error', 'warning', 'info', 'debug']).default('info')
|
|
962
|
+
.describe('Minimum log level. "error"=only errors, "warning"=errors+warnings, "info"=add info logs, "debug"=all. Default "info".'),
|
|
963
|
+
}),
|
|
964
|
+
execute: async function (input) {
|
|
965
|
+
const messages = this._consoleMessages || [];
|
|
966
|
+
const levelPriority = { debug: 0, info: 1, warning: 2, error: 3 };
|
|
967
|
+
const minPriority = levelPriority[input.level] ?? 1;
|
|
968
|
+
const filtered = messages.filter((m) => (levelPriority[m.level] ?? 0) >= minPriority);
|
|
969
|
+
if (filtered.length === 0) {
|
|
970
|
+
return `No console messages at level "${input.level}" or above. Total captured: ${messages.length}.`;
|
|
971
|
+
}
|
|
972
|
+
const formatted = filtered.map((m, i) => `[${i + 1}] ${m.level.toUpperCase()}: ${m.text.slice(0, 200)}`).join('\n');
|
|
973
|
+
return `Console messages (${filtered.length}/${messages.length} at level ≥ "${input.level}"):\n${formatted}`;
|
|
974
|
+
},
|
|
975
|
+
}));
|
|
976
|
+
// --- file_upload ---
|
|
977
|
+
exports.tools.set('file_upload', tool({
|
|
978
|
+
description: `Upload one or more files via a file input element. When you click a file input button, a file chooser dialog appears. This tool uploads files to the pending file chooser.
|
|
979
|
+
|
|
980
|
+
Flow: 1) Click the file input button (e.g. "上传文件", "选择文件"), 2) Call file_upload with the file paths.
|
|
981
|
+
|
|
982
|
+
Paths must be absolute. The file chooser is single-use — after uploading, a new click is needed for another upload.`,
|
|
983
|
+
inputSchema: zod_1.z.object({
|
|
984
|
+
paths: zod_1.z.array(zod_1.z.string()).describe('Absolute file paths to upload'),
|
|
985
|
+
}),
|
|
986
|
+
execute: async function (input) {
|
|
987
|
+
const fileChooser = this._pendingFileChooser;
|
|
988
|
+
if (!fileChooser) {
|
|
989
|
+
return `❌ No pending file chooser. Click a file input element first to trigger a file chooser dialog.`;
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
await fileChooser.setFiles(input.paths);
|
|
993
|
+
this._pendingFileChooser = null;
|
|
994
|
+
return `✅ Uploaded ${input.paths.length} file(s): ${input.paths.map(p => p.split('/').pop()).join(', ')}`;
|
|
995
|
+
}
|
|
996
|
+
catch (err) {
|
|
997
|
+
this._pendingFileChooser = null;
|
|
998
|
+
return `❌ File upload failed: ${err.message}`;
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
}));
|
|
1002
|
+
// --- network_requests ---
|
|
1003
|
+
exports.tools.set('network_requests', tool({
|
|
1004
|
+
description: `List network requests made by the page. Use to verify API calls were made, check response status codes, or debug network issues. Returns a numbered list with method, URL, and status code.
|
|
1005
|
+
|
|
1006
|
+
Filter by URL pattern (regex) and optionally include static resources (images, fonts, scripts). By default, only API/XHR requests are shown.`,
|
|
1007
|
+
inputSchema: zod_1.z.object({
|
|
1008
|
+
filter: zod_1.z.string().optional()
|
|
1009
|
+
.describe('Regex pattern to filter URLs (e.g. "/api/.*user")'),
|
|
1010
|
+
includeStatic: zod_1.z.boolean().default(false)
|
|
1011
|
+
.describe('Include static resources (images, fonts, scripts). Default false.'),
|
|
1012
|
+
}),
|
|
1013
|
+
execute: async function (input) {
|
|
1014
|
+
const requests = this._networkRequests || [];
|
|
1015
|
+
const filter = input.filter ? new RegExp(input.filter) : undefined;
|
|
1016
|
+
const lines = [];
|
|
1017
|
+
for (let i = 0; i < requests.length; i++) {
|
|
1018
|
+
const req = requests[i];
|
|
1019
|
+
if (!input.includeStatic && req.isStatic)
|
|
1020
|
+
continue;
|
|
1021
|
+
if (filter && !filter.test(req.url))
|
|
1022
|
+
continue;
|
|
1023
|
+
const status = req.status ? ` [${req.status}]` : '';
|
|
1024
|
+
const method = req.method || '?';
|
|
1025
|
+
lines.push(`[${i + 1}] ${method} ${req.url.slice(0, 120)}${status}`);
|
|
1026
|
+
}
|
|
1027
|
+
if (lines.length === 0) {
|
|
1028
|
+
return `No matching network requests. Total captured: ${requests.length}.`;
|
|
1029
|
+
}
|
|
1030
|
+
return `Network requests (${lines.length} shown, ${requests.length} total):\n${lines.join('\n')}`;
|
|
1031
|
+
},
|
|
1032
|
+
}));
|
|
1033
|
+
// --- resize_viewport ---
|
|
1034
|
+
exports.tools.set('resize_viewport', tool({
|
|
1035
|
+
description: `Resize the browser viewport (window size). Use for responsive design testing or to trigger layout changes at specific viewport sizes. Common breakpoints: 375×667 (mobile), 768×1024 (tablet), 1280×720 (desktop), 1920×1080 (full HD).`,
|
|
1036
|
+
inputSchema: zod_1.z.object({
|
|
1037
|
+
width: zod_1.z.number().min(320).max(3840).describe('Viewport width in pixels'),
|
|
1038
|
+
height: zod_1.z.number().min(240).max(2160).describe('Viewport height in pixels'),
|
|
1039
|
+
}),
|
|
1040
|
+
execute: async function (input) {
|
|
1041
|
+
await this.page.setViewportSize({ width: input.width, height: input.height });
|
|
1042
|
+
await this.page.waitForTimeout(300);
|
|
1043
|
+
return `✅ Viewport resized to ${input.width}×${input.height}`;
|
|
1044
|
+
},
|
|
1045
|
+
}));
|
|
750
1046
|
// ── verify_expected fully removed (Phase 6) ──
|
|
751
1047
|
// LLM now sees screenshots via multimodal prompt and self-evaluates.
|
|
752
1048
|
// No separate verification tool or function needed — each step's screenshot
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
> 2. **绝不硬编码等待时间**:不使用 `waitForTimeout()`,通过 POM waitForReady 或断言等待
|
|
52
52
|
> 3. **绝不硬编码环境数据**(D05):ID/配置值从 `fixtures/test-data.ts` 导入
|
|
53
53
|
> 4. **每个测试覆盖恰好一个排列**:测试体内无 if/else 分支逻辑
|
|
54
|
+
> 5. **绝不使用 `waitForLoadState('networkidle')`**:BI 仪表盘等页面有 WebSocket/SSE/长轮询等持久连接,`networkidle` 永远不会 resolve 导致无限挂起。使用 `waitForLoadState('domcontentloaded')` 或 `page.goto(url, { waitUntil: 'domcontentloaded' })` 代替
|
|
54
55
|
|
|
55
56
|
### 正确模式 vs 禁止模式
|
|
56
57
|
|
|
@@ -64,6 +65,16 @@ await expect(page.locator('[data-e2e-name="智能应用列表-筛选区域"]')).
|
|
|
64
65
|
await page.locator('.filter-area').click();
|
|
65
66
|
await page.waitForTimeout(5000);
|
|
66
67
|
await agentDetailPage.navigate(636, 'edit');
|
|
68
|
+
|
|
69
|
+
// ❌ 禁止:waitForLoadState('networkidle') — BI 页面有持久连接会无限挂起
|
|
70
|
+
await page.goto(url);
|
|
71
|
+
await page.waitForLoadState('networkidle'); // ← 永远不会 resolve!
|
|
72
|
+
|
|
73
|
+
// ✅ 正确:使用 domcontentloaded 替代
|
|
74
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
75
|
+
// 或
|
|
76
|
+
await page.goto(url);
|
|
77
|
+
await page.waitForLoadState('domcontentloaded');
|
|
67
78
|
```
|
|
68
79
|
|
|
69
80
|
### fixtures/test-data.ts 结构
|
package/package.json
CHANGED