@kkeve/suite 0.0.12 → 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 +83 -35
- package/dist/core/learned-actions.d.ts +37 -8
- package/dist/core/learned-actions.js +214 -22
- package/dist/page-agent/agent.d.ts +21 -0
- package/dist/page-agent/agent.js +282 -4
- package/dist/page-agent/system_prompt.md +70 -14
- package/dist/page-agent/tools.js +814 -17
- 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
|
@@ -87,9 +87,10 @@ keve_aspect_1.keveAspect.register({
|
|
|
87
87
|
phase: 'after',
|
|
88
88
|
order: 20,
|
|
89
89
|
async execute(ctx, result) {
|
|
90
|
-
if (!result?.actions?.length
|
|
90
|
+
if (!result?.actions?.length)
|
|
91
91
|
return;
|
|
92
|
-
|
|
92
|
+
const url = ctx.page.url();
|
|
93
|
+
learned_actions_1.learnedActions.add(ctx.step, result.actions, url, result.success);
|
|
93
94
|
},
|
|
94
95
|
});
|
|
95
96
|
// ─── (diagnostic extraction moved to page-agent/diagnostic.ts, called via onAfterTask hook) ──
|
|
@@ -103,8 +104,17 @@ exports.test = test_1.test.extend({
|
|
|
103
104
|
browser: async ({}, use, testInfo) => {
|
|
104
105
|
if ((0, global_setup_1.isCdpMode)()) {
|
|
105
106
|
const browser = await (0, global_setup_1.getCdpBrowser)(); // 复用全局共享的 CDP browser
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
}
|
|
108
118
|
}
|
|
109
119
|
else {
|
|
110
120
|
const browser = await test_1.chromium.launch(testInfo.project?.use?.launchOptions || {});
|
|
@@ -113,7 +123,8 @@ exports.test = test_1.test.extend({
|
|
|
113
123
|
}
|
|
114
124
|
},
|
|
115
125
|
page: async ({ browser }, use) => {
|
|
116
|
-
|
|
126
|
+
const isCdp = (0, global_setup_1.isCdpMode)() && browser.isConnected();
|
|
127
|
+
if (isCdp) {
|
|
117
128
|
const contexts = browser.contexts();
|
|
118
129
|
const cdpContext = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
119
130
|
// CDP 模式:优先复用已有 page(保留登录状态),仅在无可用 page 时新开
|
|
@@ -202,7 +213,8 @@ exports.test = test_1.test.extend({
|
|
|
202
213
|
catch { /* non-critical */ }
|
|
203
214
|
}
|
|
204
215
|
// ── Hand off to agent: agent does Re-Act (OR shortcut to blocked if fnBlocked) ──
|
|
205
|
-
const
|
|
216
|
+
const currentUrl = page.url();
|
|
217
|
+
const learnedHint = learned_actions_1.learnedActions.getHint(options.step, currentUrl);
|
|
206
218
|
let reactResult;
|
|
207
219
|
let reactTimedOut = false;
|
|
208
220
|
if (fnBlocked) {
|
|
@@ -222,43 +234,69 @@ exports.test = test_1.test.extend({
|
|
|
222
234
|
};
|
|
223
235
|
}
|
|
224
236
|
else {
|
|
225
|
-
// ──
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const goalTimeout = setTimeout(() => {
|
|
230
|
-
goalTimeoutController.abort();
|
|
231
|
-
console.log(`[keveGoal] ⏰ "${options.step}" timed out after ${GOAL_TIMEOUT_MS}ms — stopping agent`);
|
|
232
|
-
}, 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;
|
|
233
241
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
fnResult: fnError ? { error: fnError } : { success: true },
|
|
239
|
-
signal: goalTimeoutController.signal,
|
|
240
|
-
goalScreenshotBefore: goalScreenshotBefore || undefined,
|
|
241
|
-
fnAfterScreenshot: fnAfterScreenshot || undefined,
|
|
242
|
-
});
|
|
242
|
+
await page.url();
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
pageAlive = false;
|
|
243
246
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const isGoalTimeout = goalTimeoutController.signal.aborted;
|
|
247
|
-
const msg = isGoalTimeout
|
|
248
|
-
? `Goal timed out (${GOAL_TIMEOUT_MS}ms)`
|
|
249
|
-
: (reactErr?.message || String(reactErr));
|
|
250
|
-
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`);
|
|
251
249
|
reactResult = {
|
|
252
250
|
expectedMet: false,
|
|
253
|
-
actions: [
|
|
251
|
+
actions: [{
|
|
252
|
+
action: { tool: 'done', verdict: 'blocked', text: `Page closed after fn error: ${fnError}` },
|
|
253
|
+
toolOutput: '',
|
|
254
|
+
result: 'ok',
|
|
255
|
+
}],
|
|
254
256
|
finalSnapshot: '',
|
|
255
257
|
agentScreenshots: [],
|
|
256
|
-
conclusion:
|
|
258
|
+
conclusion: 'blocked',
|
|
257
259
|
};
|
|
258
260
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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)
|
|
262
300
|
} // end of else (non-blocked path)
|
|
263
301
|
// ── Deterministic override: fn assertion failure cannot be overridden by Agent "pass" ──
|
|
264
302
|
// When fn has a hard assertion failure (Expected X, Received Y), the value mismatch
|
|
@@ -464,6 +502,16 @@ function isEnvironmentBlockedError(err) {
|
|
|
464
502
|
|| msg.includes('err_too_many_redirects')) {
|
|
465
503
|
return true;
|
|
466
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
|
+
}
|
|
467
515
|
return false;
|
|
468
516
|
}
|
|
469
517
|
/**
|
|
@@ -4,21 +4,50 @@
|
|
|
4
4
|
* When one keveGoal's Re-Act loop discovers a working action sequence,
|
|
5
5
|
* other goals in the same round can use those discoveries as hints.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* v2: Fuzzy matching by token Jaccard similarity + page URL pattern.
|
|
8
|
+
* Exact match is tried first; if no hit, fuzzy search across all entries.
|
|
9
9
|
*/
|
|
10
|
+
interface CacheEntry {
|
|
11
|
+
step: string;
|
|
12
|
+
actions: any[];
|
|
13
|
+
url?: string;
|
|
14
|
+
success: boolean;
|
|
15
|
+
}
|
|
10
16
|
export declare class LearnedActions {
|
|
11
17
|
private cache;
|
|
12
|
-
/** Store discovered actions for a step
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
/** Store discovered actions for a step (with optional page URL for context matching)
|
|
19
|
+
* @param success Whether the step succeeded — failed entries are stored with warnings only
|
|
20
|
+
*/
|
|
21
|
+
add(step: string, actions: any[], url?: string, success?: boolean): void;
|
|
22
|
+
/** Retrieve discovered actions for a step (exact match) */
|
|
15
23
|
get(step: string): any[] | undefined;
|
|
16
|
-
/**
|
|
17
|
-
|
|
24
|
+
/** Tokenize a Chinese/mixed step description for fuzzy matching.
|
|
25
|
+
* Uses bigram (character pair) extraction for Chinese — more granular than
|
|
26
|
+
* whitespace splitting, captures shared sub-phrases like "指标标准名称".
|
|
27
|
+
*/
|
|
28
|
+
private tokenize;
|
|
29
|
+
/** Compute Jaccard similarity between two token sets */
|
|
30
|
+
private jaccard;
|
|
31
|
+
/** Find the best matching cached entry for a given step description */
|
|
32
|
+
private findBestMatch;
|
|
33
|
+
/** Get a hint string for injecting into Re-Act prompt.
|
|
34
|
+
* 1. Exact match → direct reuse
|
|
35
|
+
* 2. Fuzzy match by token similarity → similar step reuse
|
|
36
|
+
* 3. Same-page fallback → if same URL path, use the most recent successful entry
|
|
37
|
+
*/
|
|
38
|
+
getHint(step: string, currentUrl?: string): string | undefined;
|
|
39
|
+
/** Extract path portion of URL for same-page matching (ignore query/hash) */
|
|
40
|
+
private extractPath;
|
|
41
|
+
/** Format a hint from a cache entry's actions.
|
|
42
|
+
* Success: action sequence summary + key discoveries
|
|
43
|
+
* Failure: warnings/blockers only — avoid misleading Agent into reusing failed paths
|
|
44
|
+
*/
|
|
45
|
+
private formatHint;
|
|
18
46
|
/** Get all cached entries */
|
|
19
|
-
getAll(): Map<string,
|
|
47
|
+
getAll(): Map<string, CacheEntry>;
|
|
20
48
|
/** Clear all cached entries (typically at round start) */
|
|
21
49
|
clear(): void;
|
|
22
50
|
}
|
|
23
51
|
/** Global singleton for same-round sharing */
|
|
24
52
|
export declare const learnedActions: LearnedActions;
|
|
53
|
+
export {};
|
|
@@ -5,51 +5,243 @@
|
|
|
5
5
|
* When one keveGoal's Re-Act loop discovers a working action sequence,
|
|
6
6
|
* other goals in the same round can use those discoveries as hints.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* v2: Fuzzy matching by token Jaccard similarity + page URL pattern.
|
|
9
|
+
* Exact match is tried first; if no hit, fuzzy search across all entries.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.learnedActions = exports.LearnedActions = void 0;
|
|
13
13
|
class LearnedActions {
|
|
14
14
|
cache = new Map();
|
|
15
|
-
/** Store discovered actions for a step
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/** Store discovered actions for a step (with optional page URL for context matching)
|
|
16
|
+
* @param success Whether the step succeeded — failed entries are stored with warnings only
|
|
17
|
+
*/
|
|
18
|
+
add(step, actions, url, success = true) {
|
|
18
19
|
if (actions.length > 0) {
|
|
19
|
-
this.cache.set(step, actions);
|
|
20
|
+
this.cache.set(step, { step, actions, url, success });
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
|
-
/** Retrieve discovered actions for a step */
|
|
23
|
+
/** Retrieve discovered actions for a step (exact match) */
|
|
23
24
|
get(step) {
|
|
24
|
-
return this.cache.get(step);
|
|
25
|
+
return this.cache.get(step)?.actions;
|
|
25
26
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
27
|
+
/** Tokenize a Chinese/mixed step description for fuzzy matching.
|
|
28
|
+
* Uses bigram (character pair) extraction for Chinese — more granular than
|
|
29
|
+
* whitespace splitting, captures shared sub-phrases like "指标标准名称".
|
|
30
|
+
*/
|
|
31
|
+
tokenize(text) {
|
|
32
|
+
// 1. Split by delimiters to get chunks
|
|
33
|
+
const chunks = text
|
|
34
|
+
.replace(/[,。、;:""''()【】《》\s,.;:!?/\\|]+/g, ' ')
|
|
35
|
+
.split(' ')
|
|
36
|
+
.filter(t => t.length > 0);
|
|
37
|
+
// 2. For each chunk, extract character bigrams (for Chinese sub-phrase matching)
|
|
38
|
+
// AND keep the original chunk (for exact chunk matches)
|
|
39
|
+
const tokens = [];
|
|
40
|
+
for (const chunk of chunks) {
|
|
41
|
+
tokens.push(chunk.toLowerCase());
|
|
42
|
+
// Generate bigrams: sliding window of 2 chars
|
|
43
|
+
for (let i = 0; i < chunk.length - 1; i++) {
|
|
44
|
+
tokens.push(chunk.substring(i, i + 2).toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return tokens;
|
|
48
|
+
}
|
|
49
|
+
/** Compute Jaccard similarity between two token sets */
|
|
50
|
+
jaccard(a, b) {
|
|
51
|
+
const setA = new Set(a.map(t => t.toLowerCase()));
|
|
52
|
+
const setB = new Set(b.map(t => t.toLowerCase()));
|
|
53
|
+
const intersection = [...setA].filter(t => setB.has(t)).length;
|
|
54
|
+
const union = new Set([...setA, ...setB]).size;
|
|
55
|
+
return union === 0 ? 0 : intersection / union;
|
|
56
|
+
}
|
|
57
|
+
/** Find the best matching cached entry for a given step description */
|
|
58
|
+
findBestMatch(step) {
|
|
59
|
+
const tokens = this.tokenize(step);
|
|
60
|
+
let bestEntry;
|
|
61
|
+
let bestScore = 0;
|
|
62
|
+
// Threshold: 18% token overlap — low enough to catch "填写其他必填项" vs "必填项填入合法值",
|
|
63
|
+
// high enough to avoid completely unrelated steps matching
|
|
64
|
+
const THRESHOLD = 0.18;
|
|
65
|
+
for (const [, entry] of this.cache) {
|
|
66
|
+
const entryTokens = this.tokenize(entry.step);
|
|
67
|
+
const score = this.jaccard(tokens, entryTokens);
|
|
68
|
+
if (score > bestScore && score >= THRESHOLD) {
|
|
69
|
+
bestScore = score;
|
|
70
|
+
bestEntry = entry;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return bestEntry;
|
|
74
|
+
}
|
|
75
|
+
/** Get a hint string for injecting into Re-Act prompt.
|
|
76
|
+
* 1. Exact match → direct reuse
|
|
77
|
+
* 2. Fuzzy match by token similarity → similar step reuse
|
|
78
|
+
* 3. Same-page fallback → if same URL path, use the most recent successful entry
|
|
79
|
+
*/
|
|
80
|
+
getHint(step, currentUrl) {
|
|
81
|
+
// 1. Try exact match first (prefer success entries over failures)
|
|
82
|
+
const exact = this.cache.get(step);
|
|
83
|
+
if (exact?.actions.length) {
|
|
84
|
+
const hint = this.formatHint(exact.step, exact.actions, false, exact.success);
|
|
85
|
+
if (hint)
|
|
86
|
+
return hint;
|
|
87
|
+
}
|
|
88
|
+
// 2. Fuzzy match by step description similarity (prefer success, but include failures)
|
|
89
|
+
const best = this.findBestMatch(step);
|
|
90
|
+
if (best?.actions.length) {
|
|
91
|
+
const hint = this.formatHint(best.step, best.actions, true, best.success);
|
|
92
|
+
if (hint)
|
|
93
|
+
return hint;
|
|
94
|
+
}
|
|
95
|
+
// 3. Same-page fallback: if on the same URL path as a cached entry, use the most
|
|
96
|
+
// relevant one (highest Jaccard, even if below fuzzy threshold).
|
|
97
|
+
// Minimum Jaccard = 0.08 to avoid completely unrelated steps on the same page.
|
|
98
|
+
if (currentUrl) {
|
|
99
|
+
const urlPath = this.extractPath(currentUrl);
|
|
100
|
+
let bestSamePage;
|
|
101
|
+
let bestSamePageScore = 0;
|
|
102
|
+
const SAME_PAGE_MIN = 0.08;
|
|
103
|
+
const stepTokens = this.tokenize(step);
|
|
104
|
+
for (const [, entry] of this.cache) {
|
|
105
|
+
if (entry.url && this.extractPath(entry.url) === urlPath) {
|
|
106
|
+
const entryTokens = this.tokenize(entry.step);
|
|
107
|
+
const score = this.jaccard(stepTokens, entryTokens);
|
|
108
|
+
if (score > bestSamePageScore && score >= SAME_PAGE_MIN) {
|
|
109
|
+
bestSamePageScore = score;
|
|
110
|
+
bestSamePage = entry;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (bestSamePage?.actions.length) {
|
|
115
|
+
const hint = this.formatHint(bestSamePage.step, bestSamePage.actions, true, bestSamePage.success);
|
|
116
|
+
if (hint)
|
|
117
|
+
return hint;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
/** Extract path portion of URL for same-page matching (ignore query/hash) */
|
|
123
|
+
extractPath(url) {
|
|
124
|
+
try {
|
|
125
|
+
const u = new URL(url);
|
|
126
|
+
return u.pathname;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return url;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Format a hint from a cache entry's actions.
|
|
133
|
+
* Success: action sequence summary + key discoveries
|
|
134
|
+
* Failure: warnings/blockers only — avoid misleading Agent into reusing failed paths
|
|
135
|
+
*/
|
|
136
|
+
formatHint(sourceStep, actions, isFuzzy, success) {
|
|
137
|
+
if (!success) {
|
|
138
|
+
// Failed step — surface warnings/blockers AND combobox conflict info
|
|
139
|
+
const discoveries = [];
|
|
140
|
+
for (const a of actions) {
|
|
141
|
+
const out = a.toolOutput || '';
|
|
142
|
+
if (out.startsWith('⚠️'))
|
|
143
|
+
discoveries.push(out.slice(0, 120));
|
|
144
|
+
// Also capture combobox "重复" conflicts — valuable for later goals to avoid same data
|
|
145
|
+
if (out.includes('重复') || out.includes('duplicate') || out.includes('conflict')) {
|
|
146
|
+
discoveries.push(`DATA CONFLICT: ${out.slice(0, 120)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (discoveries.length === 0)
|
|
150
|
+
return undefined;
|
|
151
|
+
const prefix = isFuzzy
|
|
152
|
+
? `Previous FAILED step SIMILAR to "${sourceStep}"`
|
|
153
|
+
: `Previous FAILED step "${sourceStep}"`;
|
|
154
|
+
return `${prefix} — avoid these approaches. Blocked by: ${discoveries.slice(-3).map(d => `[${d}]`).join('; ')}`;
|
|
155
|
+
}
|
|
156
|
+
// Success — full action sequence + discoveries (existing logic)
|
|
157
|
+
// Extract stable element identity from toolOutput (not from action fields which lack role/name)
|
|
32
158
|
const summary = actions
|
|
33
|
-
.filter(a => a.action.tool !== 'done')
|
|
159
|
+
.filter(a => a.action?.tool !== 'done' && a.tool !== 'done')
|
|
34
160
|
.map(a => {
|
|
35
|
-
const act = a.action;
|
|
161
|
+
const act = a.action || a;
|
|
162
|
+
const out = a.toolOutput || '';
|
|
36
163
|
if (act.tool === 'navigate')
|
|
37
164
|
return `navigate(${act.url})`;
|
|
38
|
-
if (act.tool === 'click')
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
165
|
+
if (act.tool === 'click') {
|
|
166
|
+
// Parse toolOutput: "✅ Clicked [ref=e688] button \"新增指标\"" → "button 新增指标"
|
|
167
|
+
// or: "⚠️ Clicked [ref=e2843] button \"确定\" — dialog still open" → "button 确定"
|
|
168
|
+
const descMatch = out.match(/Clicked \[ref=\w+\]\s+(\S+\s+"[^"]*"|\S+)/);
|
|
169
|
+
if (descMatch) {
|
|
170
|
+
// descMatch[1] = 'button "新增指标"' or 'button "确定"' or 'element'
|
|
171
|
+
const clean = descMatch[1].replace(/"/g, '').trim();
|
|
172
|
+
return `click(${clean})`;
|
|
173
|
+
}
|
|
174
|
+
return `click(?)`;
|
|
175
|
+
}
|
|
176
|
+
if (act.tool === 'type') {
|
|
177
|
+
// Parse toolOutput for stable type info
|
|
178
|
+
const typeMatch = out.match(/✅ (replace|append) "([^"]*)" into element \[ref=\w+\]/);
|
|
179
|
+
if (typeMatch)
|
|
180
|
+
return `type("${typeMatch[2].slice(0, 30)}"${typeMatch[1] === 'append' ? ', append' : ''})`;
|
|
181
|
+
// Combobox selection
|
|
182
|
+
const cbMatch = out.match(/✅ Selected "([^"]*)" in combobox/);
|
|
183
|
+
if (cbMatch)
|
|
184
|
+
return `select("${cbMatch[1].slice(0, 30)}")`;
|
|
185
|
+
return `type(?)`;
|
|
186
|
+
}
|
|
187
|
+
if (act.tool === 'fill_form') {
|
|
188
|
+
// Extract filled fields from multi-line toolOutput
|
|
189
|
+
// Lines look like: "✅ textbox [e2755] = "测试指标标准名称"" or "✅ combobox [e2800] = "KwaiBI官方数据集DEMO" (custom)"
|
|
190
|
+
const fields = [];
|
|
191
|
+
for (const line of out.split('\n')) {
|
|
192
|
+
const fieldMatch = line.match(/✅ (\w+) \[\w+\] = "?([^"]*?)"?\s*(\(custom\))?$/);
|
|
193
|
+
if (fieldMatch && fieldMatch[2]) {
|
|
194
|
+
const fType = fieldMatch[1];
|
|
195
|
+
const fVal = fieldMatch[2].slice(0, 25);
|
|
196
|
+
fields.push(`${fType}="${fVal}"`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return fields.length ? `fill_form(${fields.join(', ')})` : 'fill_form(...)';
|
|
200
|
+
}
|
|
201
|
+
if (act.tool === 'select_option') {
|
|
202
|
+
const selMatch = out.match(/✅ Selected "([^"]*)"/);
|
|
203
|
+
if (selMatch)
|
|
204
|
+
return `select_option("${selMatch[1].slice(0, 30)}")`;
|
|
205
|
+
return `select_option(?)`;
|
|
206
|
+
}
|
|
42
207
|
if (act.tool === 'hover')
|
|
43
|
-
return `hover(${act.role} "${act.name}")`;
|
|
208
|
+
return `hover(${act.role || ''} "${act.name || ''}")`;
|
|
44
209
|
if (act.tool === 'pressKey')
|
|
45
210
|
return `pressKey(${act.key})`;
|
|
46
211
|
if (act.tool === 'scroll')
|
|
47
212
|
return `scroll(${act.direction})`;
|
|
48
213
|
if (act.tool === 'wait')
|
|
49
214
|
return `wait(${act.time}ms)`;
|
|
215
|
+
if (act.tool === 'execute_javascript') {
|
|
216
|
+
const jsResult = out.match(/✅ JS executed\. Result: (.+)/);
|
|
217
|
+
if (jsResult)
|
|
218
|
+
return `js(→ ${jsResult[1].slice(0, 40)})`;
|
|
219
|
+
return 'js(?)';
|
|
220
|
+
}
|
|
50
221
|
return act.tool;
|
|
51
222
|
});
|
|
52
|
-
|
|
223
|
+
// Extract key toolOutput lines — discoveries that other goals need to know
|
|
224
|
+
// Focus on: combobox option results (✅/❌), dialog warnings (⚠️), validation messages
|
|
225
|
+
const discoveries = [];
|
|
226
|
+
for (const a of actions) {
|
|
227
|
+
const out = a.toolOutput || '';
|
|
228
|
+
// Combobox option selection results (most valuable for form reuse)
|
|
229
|
+
if (out.includes('combobox') && (out.includes('(custom)') || out.includes('could not find option'))) {
|
|
230
|
+
discoveries.push(out.slice(0, 120));
|
|
231
|
+
}
|
|
232
|
+
// Dialog warnings that blocked submission
|
|
233
|
+
if (out.startsWith('⚠️')) {
|
|
234
|
+
discoveries.push(out.slice(0, 120));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Limit to 5 most important discoveries
|
|
238
|
+
const discoveryLines = discoveries.length > 0
|
|
239
|
+
? `\nKey discoveries: ${discoveries.slice(-5).map(d => `[${d}]`).join('; ')}`
|
|
240
|
+
: '';
|
|
241
|
+
const prefix = isFuzzy
|
|
242
|
+
? `Previous successful actions for SIMILAR step "${sourceStep}"`
|
|
243
|
+
: `Previous successful actions for "${sourceStep}"`;
|
|
244
|
+
return `${prefix}: ${summary.join(' → ')}${discoveryLines}`;
|
|
53
245
|
}
|
|
54
246
|
/** Get all cached entries */
|
|
55
247
|
getAll() {
|
|
@@ -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;
|
|
@@ -110,6 +126,11 @@ export declare class KevePageAgent {
|
|
|
110
126
|
bbox?: number[];
|
|
111
127
|
analysis?: string;
|
|
112
128
|
}>;
|
|
129
|
+
/** Visual assertion: check a visual condition without interacting with the page */
|
|
130
|
+
visualAssertElement(screenshotBase64: string, assertion: string): Promise<{
|
|
131
|
+
passed: boolean;
|
|
132
|
+
reasoning: string;
|
|
133
|
+
}>;
|
|
113
134
|
/** Build final result */
|
|
114
135
|
private buildResult;
|
|
115
136
|
}
|