explorbot 0.2.4 → 0.2.5
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/bin/explorbot-cli.ts +19 -7
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/prima/src/cli.ts +24 -12
- package/boat/prima/src/envelope.ts +35 -13
- package/boat/prima/src/prima.ts +61 -41
- package/dist/bin/explorbot-cli.js +19 -7
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/prima/src/cli.js +19 -7
- package/dist/boat/prima/src/envelope.js +32 -8
- package/dist/boat/prima/src/prima.js +57 -39
- package/dist/package.json +1 -1
- package/dist/src/action.js +5 -1
- package/dist/src/ai/navigator.d.ts +27 -0
- package/dist/src/ai/navigator.js +227 -175
- package/dist/src/ai/pilot.d.ts +7 -4
- package/dist/src/ai/pilot.js +50 -8
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +12 -21
- package/dist/src/ai/researcher/cache.d.ts +2 -0
- package/dist/src/ai/researcher/cache.js +10 -2
- package/dist/src/ai/researcher.js +2 -1
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/tester.d.ts +5 -2
- package/dist/src/ai/tester.js +17 -13
- package/dist/src/ai/tools.js +4 -1
- package/dist/src/commands/config-command.d.ts +51 -0
- package/dist/src/commands/config-command.js +117 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/config.d.ts +8 -1
- package/dist/src/config.js +40 -0
- package/dist/src/explorbot.js +4 -1
- package/dist/src/remote.d.ts +3 -2
- package/dist/src/remote.js +8 -2
- package/dist/src/state-manager.d.ts +1 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +19 -0
- package/dist/src/utils/logger.d.ts +1 -1
- package/dist/src/utils/logger.js +8 -0
- package/docs/index.json +2 -1
- package/docs/reference/commands.md +3 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/package.json +1 -1
- package/src/action.ts +5 -1
- package/src/ai/navigator.ts +241 -178
- package/src/ai/pilot.ts +63 -12
- package/src/ai/provider.ts +12 -20
- package/src/ai/researcher/cache.ts +12 -2
- package/src/ai/researcher.ts +2 -1
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/tester.ts +20 -12
- package/src/ai/tools.ts +4 -1
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +45 -1
- package/src/explorbot.ts +3 -1
- package/src/remote.ts +8 -2
- package/src/state-manager.ts +5 -2
- package/src/test-plan.ts +20 -0
- package/src/utils/logger.ts +9 -1
package/dist/src/ai/navigator.js
CHANGED
|
@@ -4,11 +4,14 @@ import { z } from 'zod';
|
|
|
4
4
|
import { ActionResult } from '../action-result.js';
|
|
5
5
|
import { normalizeUrl } from '../state-manager.js';
|
|
6
6
|
import { renderAssertion } from "../playwright-recorder.js";
|
|
7
|
+
import { isFatalBrowserError } from "../utils/browser-errors.js";
|
|
8
|
+
import { getCliName } from "../utils/cli-name.js";
|
|
7
9
|
import { extractCodeBlocks } from '../utils/code-extractor.js';
|
|
8
10
|
import { HooksRunner } from "../utils/hooks-runner.js";
|
|
9
11
|
import { createDebug, pluralize, tag } from '../utils/logger.js';
|
|
10
12
|
import { loop, pause } from '../utils/loop.js';
|
|
11
13
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
14
|
+
import { normalizeInlineText } from "../utils/strings.js";
|
|
12
15
|
import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
|
|
13
16
|
import { Researcher } from "./researcher.js";
|
|
14
17
|
import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
|
|
@@ -22,6 +25,7 @@ class Navigator {
|
|
|
22
25
|
experienceTracker;
|
|
23
26
|
hooksRunner;
|
|
24
27
|
MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
|
|
28
|
+
lastFailureReason = null;
|
|
25
29
|
systemPrompt = dedent `
|
|
26
30
|
<role>
|
|
27
31
|
You are senior test automation engineer with master QA skills.
|
|
@@ -90,8 +94,12 @@ class Navigator {
|
|
|
90
94
|
const currentState = stateManager.getCurrentState();
|
|
91
95
|
if (!currentState)
|
|
92
96
|
return '';
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
return this.comparableUrl(currentState, expectedUrl);
|
|
98
|
+
}
|
|
99
|
+
comparableUrl(state, expectedUrl) {
|
|
100
|
+
if (/^https?:\/\//i.test(expectedUrl))
|
|
101
|
+
return state.fullUrl || state.url || '';
|
|
102
|
+
return state.url || '';
|
|
95
103
|
}
|
|
96
104
|
isSameExpectedOrigin(expectedUrl, stateManager) {
|
|
97
105
|
const currentState = stateManager.getCurrentState();
|
|
@@ -139,7 +147,7 @@ class Navigator {
|
|
|
139
147
|
const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
|
|
140
148
|
const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
|
|
141
149
|
if (!resolved) {
|
|
142
|
-
throw
|
|
150
|
+
throw this.navigationError(url, `redirected to ${actualPath} and could not resolve`);
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
else if (action.lastError) {
|
|
@@ -151,7 +159,7 @@ class Navigator {
|
|
|
151
159
|
`.trim();
|
|
152
160
|
const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
|
|
153
161
|
if (!resolved) {
|
|
154
|
-
throw
|
|
162
|
+
throw this.navigationError(url, action.lastError?.message || 'Navigation failed');
|
|
155
163
|
}
|
|
156
164
|
}
|
|
157
165
|
await this.explorer.capture({ screenshot: true });
|
|
@@ -167,57 +175,32 @@ class Navigator {
|
|
|
167
175
|
throw error;
|
|
168
176
|
}
|
|
169
177
|
}
|
|
178
|
+
navigationError(url, fallback) {
|
|
179
|
+
if (this.lastFailureReason)
|
|
180
|
+
return new Error(`Navigation to ${url} failed: ${this.lastFailureReason}`);
|
|
181
|
+
return new Error(`Navigation to ${url} failed: ${fallback}`);
|
|
182
|
+
}
|
|
183
|
+
failureReason(stopReason, knowledge, url) {
|
|
184
|
+
const reasons = [];
|
|
185
|
+
if (stopReason)
|
|
186
|
+
reasons.push(stopReason);
|
|
187
|
+
if (!knowledge) {
|
|
188
|
+
const path = extractStatePath(url).split('?')[0].split('#')[0];
|
|
189
|
+
reasons.push(`no knowledge is set for ${path} — teach it what this page needs (credentials, hints) with: ${getCliName()} learn "${path}" "<facts>"`);
|
|
190
|
+
}
|
|
191
|
+
return reasons.join('; ') || null;
|
|
192
|
+
}
|
|
170
193
|
async resolveState(message, actionResult, opts) {
|
|
171
194
|
if (!this.provider)
|
|
172
195
|
throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
196
|
+
this.lastFailureReason = null;
|
|
173
197
|
tag('info').log('AI Navigator resolving state at', actionResult.url);
|
|
174
198
|
debugLog('Resolution message:', message);
|
|
175
199
|
const action = opts?.action ?? this.explorer.action();
|
|
176
200
|
const expectedUrl = opts?.expectedUrl;
|
|
177
201
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
178
|
-
let experience = '';
|
|
179
|
-
if (!actionResult.isInsideIframe) {
|
|
180
|
-
const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
|
|
181
|
-
if (successful.length > 0) {
|
|
182
|
-
tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
|
|
183
|
-
experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
const prompt = dedent `
|
|
187
|
-
<message>
|
|
188
|
-
${message}
|
|
189
|
-
</message>
|
|
190
|
-
|
|
191
|
-
<page>
|
|
192
|
-
${actionResult.toAiContext()}
|
|
193
|
-
|
|
194
|
-
<page_html>
|
|
195
|
-
${await actionResult.combinedHtml()}
|
|
196
|
-
</page_html>
|
|
197
|
-
</page>
|
|
198
|
-
|
|
199
|
-
<task>
|
|
200
|
-
Identify the actual request of the user.
|
|
201
|
-
Identify what is expected by user.
|
|
202
|
-
Identify what might have caused the error.
|
|
203
|
-
Propose different solutions to achieve the result.
|
|
204
|
-
Solution should be valid CodeceptJS code.
|
|
205
|
-
Use only data from the <page> context to plan the solution.
|
|
206
|
-
Try various ways to achieve the result
|
|
207
|
-
</task>
|
|
208
|
-
|
|
209
|
-
${actionRule}
|
|
210
|
-
|
|
211
|
-
${unexpectedPopupRule}
|
|
212
|
-
|
|
213
|
-
${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
|
|
214
|
-
|
|
215
|
-
${experience}
|
|
216
|
-
|
|
217
|
-
${knowledge}
|
|
218
|
-
`;
|
|
219
202
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
220
|
-
conversation.addUserText(
|
|
203
|
+
conversation.addUserText(await this.buildResolutionPrompt(message, actionResult));
|
|
221
204
|
let stopReason = null;
|
|
222
205
|
const tools = {
|
|
223
206
|
stop: tool({
|
|
@@ -243,6 +226,7 @@ class Navigator {
|
|
|
243
226
|
let htmlContextAdded = false;
|
|
244
227
|
let codeBlockIndex = 0;
|
|
245
228
|
let totalAttempts = 0;
|
|
229
|
+
let lastFailure = null;
|
|
246
230
|
const progressBlocks = [];
|
|
247
231
|
const batchFailures = [];
|
|
248
232
|
let resolved = false;
|
|
@@ -252,7 +236,6 @@ class Navigator {
|
|
|
252
236
|
if (!result)
|
|
253
237
|
return;
|
|
254
238
|
if (stopReason) {
|
|
255
|
-
tag('error').log(`Navigator stopped: ${stopReason}`);
|
|
256
239
|
resolved = false;
|
|
257
240
|
stop();
|
|
258
241
|
return;
|
|
@@ -274,173 +257,242 @@ class Navigator {
|
|
|
274
257
|
return;
|
|
275
258
|
}
|
|
276
259
|
tag('operation').log('Feeding failures back to AI for a new batch...');
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const lines = batchFailures
|
|
280
|
-
.map((f) => {
|
|
281
|
-
const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
|
|
282
|
-
if (!f.ariaChanges)
|
|
283
|
-
return head;
|
|
284
|
-
const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
|
|
285
|
-
return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
|
|
286
|
-
})
|
|
287
|
-
.join('\n');
|
|
288
|
-
contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
|
|
289
|
-
}
|
|
290
|
-
if (!htmlContextAdded) {
|
|
291
|
-
htmlContextAdded = true;
|
|
292
|
-
contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
|
|
293
|
-
}
|
|
294
|
-
const pageReacted = batchFailures.some((f) => f.ariaChanges);
|
|
295
|
-
if (pageReacted) {
|
|
296
|
-
contextMsg += dedent `
|
|
297
|
-
Some steps in the previous batch did not throw, but the URL did not change to the expected target and the page changed in other ways — the ARIA diff for each such step is listed in <previous_failures> above.
|
|
298
|
-
|
|
299
|
-
Read those diffs and judge what each step actually triggered. Different action types produce different reactions; the diff is your only evidence of what happened. A diff might show, for example: a new alert / alertdialog / status / validation message appearing near a field or at page level; a modal, dialog, or wizard step opening; a banner, toast, or notification region appearing; a section expanding or collapsing; a tab or accordion switching content. A diff might also be empty or unrelated to the step — that is also a signal.
|
|
300
|
-
|
|
301
|
-
Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
|
|
302
|
-
|
|
303
|
-
A. The diff indicates the application requires something only the user can supply — for example: an authentication failure you cannot guess past, a captcha, a permission the test cannot satisfy, or knowledge that is not present in the provided context. Call the stop() tool and quote what you saw in the diff and what is needed.
|
|
304
|
-
|
|
305
|
-
B. The diff indicates the next step is something you can perform from the existing knowledge / hint context — for example: re-emit a step with a value that exists in the knowledge but was used incorrectly; dismiss an unexpected modal; accept a confirmation; take a follow-up step the page now requires. Emit code blocks for that next step. Do NOT change the locator of a step that already produced a reaction.
|
|
306
|
-
|
|
307
|
-
C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
|
|
308
|
-
`;
|
|
309
|
-
}
|
|
310
|
-
else {
|
|
311
|
-
contextMsg += 'Propose new solutions. If errors mention "intercepts pointer events" or timeouts on visible elements, an overlay is blocking — dismiss it first (Escape, click outside, Close button) before retrying the original action.';
|
|
312
|
-
}
|
|
313
|
-
conversation.addUserText(contextMsg);
|
|
260
|
+
conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
|
|
261
|
+
htmlContextAdded = true;
|
|
314
262
|
codeBlocks = [];
|
|
315
263
|
batchFailures.length = 0;
|
|
316
264
|
return;
|
|
317
265
|
}
|
|
318
266
|
codeBlockIndex++;
|
|
319
267
|
totalAttempts++;
|
|
320
|
-
await action.exitIframe();
|
|
321
268
|
const prevActionResult = action.actionResult ?? actionResult;
|
|
322
269
|
const prevHash = prevActionResult.getStateHash();
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
await page.waitForLoadState('load', { timeout: 5000 });
|
|
329
|
-
}
|
|
330
|
-
catch {
|
|
331
|
-
// Navigation did not reach 'load' state within timeout; continue and verify URL
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
if (attemptOk)
|
|
335
|
-
opts?.onAttempt?.({ code: codeBlock });
|
|
336
|
-
if (!attemptOk) {
|
|
337
|
-
const raw = action.lastError?.message || 'attempt failed';
|
|
338
|
-
const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
|
|
339
|
-
const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
340
|
-
batchFailures.push({ code: codeBlock, error: shortErr });
|
|
341
|
-
opts?.onAttempt?.({ code: codeBlock, error: shortErr });
|
|
270
|
+
const attempt = await this.executeAttempt(action, codeBlock, message);
|
|
271
|
+
opts?.onAttempt?.({ code: codeBlock, error: attempt.error });
|
|
272
|
+
if (attempt.error) {
|
|
273
|
+
batchFailures.push({ code: codeBlock, error: attempt.error });
|
|
274
|
+
lastFailure = attempt.error;
|
|
342
275
|
}
|
|
343
276
|
if (expectedUrl) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// URL did not transition to expectedUrl within timeout
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
const freshState = await this.explorer.capture();
|
|
353
|
-
const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
|
|
354
|
-
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
|
|
355
|
-
const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
|
|
356
|
-
resolved = urlMatches && stateChanged;
|
|
357
|
-
if (!resolved && attemptOk) {
|
|
358
|
-
let ariaChanges = null;
|
|
359
|
-
if (freshState.getStateHash() !== prevHash) {
|
|
360
|
-
try {
|
|
361
|
-
const diff = await freshState.diff(prevActionResult);
|
|
362
|
-
ariaChanges = diff.ariaChanged;
|
|
363
|
-
}
|
|
364
|
-
catch (err) {
|
|
365
|
-
debugLog('Failed to compute pageDiff for failed URL verification:', err);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
277
|
+
const check = await this.verifyNavigation(action, expectedUrl);
|
|
278
|
+
const freshHash = check.freshState.getStateHash();
|
|
279
|
+
resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
|
|
280
|
+
if (!resolved && attempt.ok) {
|
|
281
|
+
lastFailure = `URL did not change (still ${check.freshState.url})`;
|
|
368
282
|
batchFailures.push({
|
|
369
283
|
code: codeBlock,
|
|
370
|
-
error:
|
|
371
|
-
ariaChanges,
|
|
372
|
-
urlAfter: freshState.url,
|
|
284
|
+
error: lastFailure,
|
|
285
|
+
ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
|
|
286
|
+
urlAfter: check.freshState.url,
|
|
373
287
|
});
|
|
374
|
-
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${freshState.url}`);
|
|
288
|
+
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
375
289
|
}
|
|
376
|
-
if (
|
|
290
|
+
if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
|
|
377
291
|
progressBlocks.push(codeBlock);
|
|
378
292
|
}
|
|
379
293
|
}
|
|
380
294
|
else {
|
|
381
|
-
resolved =
|
|
382
|
-
if (
|
|
295
|
+
resolved = attempt.ok;
|
|
296
|
+
if (attempt.ok)
|
|
383
297
|
progressBlocks.push(codeBlock);
|
|
384
298
|
}
|
|
385
|
-
if (resolved)
|
|
386
|
-
tag('success').log('Navigation resolved successfully');
|
|
387
|
-
let scenario = message.split('\n')[0];
|
|
388
|
-
if (expectedUrl) {
|
|
389
|
-
const fromPath = extractStatePath(actionResult.url || '');
|
|
390
|
-
const toPath = extractStatePath(expectedUrl);
|
|
391
|
-
scenario = `reach ${toPath} from ${fromPath}`;
|
|
392
|
-
}
|
|
393
|
-
const recipe = progressBlocks
|
|
394
|
-
.join('\n')
|
|
395
|
-
.split('\n')
|
|
396
|
-
.filter((line) => !/^\s*I\.amOnPage\s*\(/.test(line))
|
|
397
|
-
.join('\n')
|
|
398
|
-
.trim();
|
|
399
|
-
if (recipe) {
|
|
400
|
-
const body = `## FLOW: ${scenario}\n\n* ${scenario}\n\n\`\`\`js\n${recipe}\n\`\`\`\n\n---\n`;
|
|
401
|
-
this.experienceTracker.writeFlow(actionResult, body);
|
|
402
|
-
}
|
|
403
|
-
stop();
|
|
299
|
+
if (!resolved)
|
|
404
300
|
return;
|
|
405
|
-
|
|
301
|
+
tag('success').log('Navigation resolved successfully');
|
|
302
|
+
this.saveFlow(message, expectedUrl, actionResult, progressBlocks);
|
|
303
|
+
stop();
|
|
406
304
|
}, {
|
|
407
305
|
maxAttempts: this.MAX_ATTEMPTS * 2,
|
|
408
306
|
observability: {
|
|
409
307
|
agent: 'navigator',
|
|
410
308
|
},
|
|
411
|
-
catch: async (error) => {
|
|
309
|
+
catch: async ({ error }) => {
|
|
310
|
+
if (isFatalBrowserError(error))
|
|
311
|
+
throw error;
|
|
412
312
|
debugLog(error);
|
|
413
313
|
resolved = false;
|
|
414
314
|
},
|
|
415
315
|
});
|
|
416
|
-
if (!resolved && expectedUrl)
|
|
417
|
-
await
|
|
418
|
-
if (this.isOnExpectedPage(expectedUrl, action.stateManager)) {
|
|
419
|
-
resolved = true;
|
|
420
|
-
tag('success').log('Navigation resolved after delayed redirect');
|
|
421
|
-
}
|
|
422
|
-
}
|
|
316
|
+
if (!resolved && expectedUrl)
|
|
317
|
+
resolved = await this.rescueDelayedRedirect(action, expectedUrl);
|
|
423
318
|
if (!resolved && stopReason) {
|
|
424
319
|
tag('error').log(`Navigator stopped: ${stopReason}`);
|
|
425
320
|
}
|
|
426
321
|
else if (!resolved && totalAttempts > 0) {
|
|
427
322
|
tag('error').log(`Navigation failed after ${totalAttempts} attempts`);
|
|
428
323
|
}
|
|
429
|
-
if (!resolved && isInteractive())
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
}
|
|
324
|
+
if (!resolved && isInteractive())
|
|
325
|
+
resolved = await this.askUserToResolve(action, message, expectedUrl, stopReason);
|
|
326
|
+
if (!resolved) {
|
|
327
|
+
let cause = 'the AI proposed no working solution for this page';
|
|
328
|
+
if (lastFailure)
|
|
329
|
+
cause = `${totalAttempts} ${pluralize(totalAttempts, 'attempt')} failed, last: ${lastFailure}`;
|
|
330
|
+
if (stopReason)
|
|
331
|
+
cause = stopReason;
|
|
332
|
+
this.lastFailureReason = this.failureReason(cause, knowledge, actionResult.url || '');
|
|
441
333
|
}
|
|
442
334
|
return resolved;
|
|
443
335
|
}
|
|
336
|
+
async buildResolutionPrompt(message, actionResult) {
|
|
337
|
+
let experience = '';
|
|
338
|
+
if (!actionResult.isInsideIframe) {
|
|
339
|
+
const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
|
|
340
|
+
if (successful.length > 0) {
|
|
341
|
+
tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
|
|
342
|
+
experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return dedent `
|
|
346
|
+
<message>
|
|
347
|
+
${message}
|
|
348
|
+
</message>
|
|
349
|
+
|
|
350
|
+
<page>
|
|
351
|
+
${actionResult.toAiContext()}
|
|
352
|
+
|
|
353
|
+
<page_html>
|
|
354
|
+
${await actionResult.combinedHtml()}
|
|
355
|
+
</page_html>
|
|
356
|
+
</page>
|
|
357
|
+
|
|
358
|
+
<task>
|
|
359
|
+
Identify the actual request of the user.
|
|
360
|
+
Identify what is expected by user.
|
|
361
|
+
Identify what might have caused the error.
|
|
362
|
+
Propose different solutions to achieve the result.
|
|
363
|
+
Solution should be valid CodeceptJS code.
|
|
364
|
+
Use only data from the <page> context to plan the solution.
|
|
365
|
+
Try various ways to achieve the result
|
|
366
|
+
</task>
|
|
367
|
+
|
|
368
|
+
${actionRule}
|
|
369
|
+
|
|
370
|
+
${unexpectedPopupRule}
|
|
371
|
+
|
|
372
|
+
${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
|
|
373
|
+
|
|
374
|
+
${experience}
|
|
375
|
+
|
|
376
|
+
${this.knowledgeTracker.renderRelevantContext(actionResult)}
|
|
377
|
+
`;
|
|
378
|
+
}
|
|
379
|
+
async buildRetryFeedback(failures, includeHtml, actionResult) {
|
|
380
|
+
let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
|
|
381
|
+
if (failures.length > 0) {
|
|
382
|
+
const lines = failures
|
|
383
|
+
.map((f) => {
|
|
384
|
+
const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
|
|
385
|
+
if (!f.ariaChanges)
|
|
386
|
+
return head;
|
|
387
|
+
const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
|
|
388
|
+
return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
|
|
389
|
+
})
|
|
390
|
+
.join('\n');
|
|
391
|
+
contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
|
|
392
|
+
}
|
|
393
|
+
if (includeHtml) {
|
|
394
|
+
contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
|
|
395
|
+
}
|
|
396
|
+
if (!failures.some((f) => f.ariaChanges)) {
|
|
397
|
+
return `${contextMsg}Propose new solutions. If errors mention "intercepts pointer events" or timeouts on visible elements, an overlay is blocking — dismiss it first (Escape, click outside, Close button) before retrying the original action.`;
|
|
398
|
+
}
|
|
399
|
+
return (contextMsg +
|
|
400
|
+
dedent `
|
|
401
|
+
Some steps in the previous batch did not throw, but the URL did not change to the expected target and the page changed in other ways — the ARIA diff for each such step is listed in <previous_failures> above.
|
|
402
|
+
|
|
403
|
+
Read those diffs and judge what each step actually triggered. Different action types produce different reactions; the diff is your only evidence of what happened. A diff might show, for example: a new alert / alertdialog / status / validation message appearing near a field or at page level; a modal, dialog, or wizard step opening; a banner, toast, or notification region appearing; a section expanding or collapsing; a tab or accordion switching content. A diff might also be empty or unrelated to the step — that is also a signal.
|
|
404
|
+
|
|
405
|
+
Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
|
|
406
|
+
|
|
407
|
+
A. The diff indicates the application requires something only the user can supply — for example: an authentication failure you cannot guess past, a captcha, a permission the test cannot satisfy, or knowledge that is not present in the provided context. Call the stop() tool and quote what you saw in the diff and what is needed.
|
|
408
|
+
|
|
409
|
+
B. The diff indicates the next step is something you can perform from the existing knowledge / hint context — for example: re-emit a step with a value that exists in the knowledge but was used incorrectly; dismiss an unexpected modal; accept a confirmation; take a follow-up step the page now requires. Emit code blocks for that next step. Do NOT change the locator of a step that already produced a reaction.
|
|
410
|
+
|
|
411
|
+
C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
|
|
412
|
+
`);
|
|
413
|
+
}
|
|
414
|
+
async executeAttempt(action, codeBlock, message) {
|
|
415
|
+
await action.exitIframe();
|
|
416
|
+
debugLog(`Attempting resolution: ${codeBlock}`);
|
|
417
|
+
const ok = await action.attempt(codeBlock, message);
|
|
418
|
+
const page = action.playwrightHelper?.page;
|
|
419
|
+
if (page) {
|
|
420
|
+
try {
|
|
421
|
+
await page.waitForLoadState('load', { timeout: 5000 });
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// Navigation did not reach 'load' state within timeout; continue and verify URL
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (ok)
|
|
428
|
+
return { ok };
|
|
429
|
+
const raw = action.lastError?.message || 'attempt failed';
|
|
430
|
+
const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
|
|
431
|
+
return { ok, error: normalizeInlineText(firstMeaningful).slice(0, 220) || 'attempt failed' };
|
|
432
|
+
}
|
|
433
|
+
async verifyNavigation(action, expectedUrl) {
|
|
434
|
+
const page = action.playwrightHelper?.page;
|
|
435
|
+
if (page) {
|
|
436
|
+
try {
|
|
437
|
+
await page.waitForURL((url) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// URL did not transition to expectedUrl within timeout
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const freshState = await this.explorer.capture();
|
|
444
|
+
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(freshState, expectedUrl));
|
|
445
|
+
return { freshState, urlMatches };
|
|
446
|
+
}
|
|
447
|
+
async ariaDiff(freshState, previous) {
|
|
448
|
+
if (freshState.getStateHash() === previous.getStateHash())
|
|
449
|
+
return null;
|
|
450
|
+
try {
|
|
451
|
+
const diff = await freshState.diff(previous);
|
|
452
|
+
return diff.ariaChanged;
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
debugLog('Failed to compute pageDiff for failed URL verification:', err);
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
saveFlow(message, expectedUrl, actionResult, progressBlocks) {
|
|
460
|
+
let scenario = message.split('\n')[0];
|
|
461
|
+
if (expectedUrl) {
|
|
462
|
+
scenario = `reach ${extractStatePath(expectedUrl)} from ${extractStatePath(actionResult.url || '')}`;
|
|
463
|
+
}
|
|
464
|
+
const recipe = progressBlocks
|
|
465
|
+
.join('\n')
|
|
466
|
+
.split('\n')
|
|
467
|
+
.filter((line) => !/^\s*I\.amOnPage\s*\(/.test(line))
|
|
468
|
+
.join('\n')
|
|
469
|
+
.trim();
|
|
470
|
+
if (!recipe)
|
|
471
|
+
return;
|
|
472
|
+
this.experienceTracker.writeFlow(actionResult, `## FLOW: ${scenario}\n\n* ${scenario}\n\n\`\`\`js\n${recipe}\n\`\`\`\n\n---\n`);
|
|
473
|
+
}
|
|
474
|
+
async rescueDelayedRedirect(action, expectedUrl) {
|
|
475
|
+
await action.getActor().wait(1);
|
|
476
|
+
if (!this.isOnExpectedPage(expectedUrl, action.stateManager))
|
|
477
|
+
return false;
|
|
478
|
+
tag('success').log('Navigation resolved after delayed redirect');
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
async askUserToResolve(action, message, expectedUrl, stopReason) {
|
|
482
|
+
let stopLine = '';
|
|
483
|
+
if (stopReason)
|
|
484
|
+
stopLine = `Navigator stopped: ${stopReason}\n`;
|
|
485
|
+
const userInput = await pause(`${stopLine}Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\nTarget: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
|
|
486
|
+
if (!userInput?.trim())
|
|
487
|
+
return false;
|
|
488
|
+
const resolved = await action.attempt(userInput, message);
|
|
489
|
+
if (!resolved)
|
|
490
|
+
return false;
|
|
491
|
+
if (!expectedUrl)
|
|
492
|
+
return true;
|
|
493
|
+
await action.getActor().wait(1);
|
|
494
|
+
return this.isOnExpectedPage(expectedUrl, action.stateManager);
|
|
495
|
+
}
|
|
444
496
|
buildExperienceTools() {
|
|
445
497
|
if (!this.experienceTracker)
|
|
446
498
|
return undefined;
|
package/dist/src/ai/pilot.d.ts
CHANGED
|
@@ -40,10 +40,7 @@ export declare class Pilot implements Agent {
|
|
|
40
40
|
planTest(task: Test, currentState: ActionResult): Promise<string>;
|
|
41
41
|
reviewNewPage(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
42
42
|
analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
43
|
-
settleExpectations(task: Test): Promise<
|
|
44
|
-
text: string;
|
|
45
|
-
status: 'passed' | 'failed' | 'unverified';
|
|
46
|
-
}>>;
|
|
43
|
+
settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]>;
|
|
47
44
|
formatExpectations(task: Test): string;
|
|
48
45
|
sendToPilot(userText: string, functionId: string, opts: {
|
|
49
46
|
tools?: boolean;
|
|
@@ -90,3 +87,9 @@ export declare class Pilot implements Agent {
|
|
|
90
87
|
buildDeletionScope(task: Test): string;
|
|
91
88
|
getSystemPrompt(task: Test, initialState: ActionResult): string;
|
|
92
89
|
}
|
|
90
|
+
export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
|
|
91
|
+
export interface SettledExpectation {
|
|
92
|
+
text: string;
|
|
93
|
+
status: SettledStatus;
|
|
94
|
+
evidence?: string;
|
|
95
|
+
}
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -3,6 +3,7 @@ import dedent from 'dedent';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ActionResult } from "../action-result.js";
|
|
5
5
|
import { ConfigParser } from "../config.js";
|
|
6
|
+
import { Stats } from "../stats.js";
|
|
6
7
|
import { TestResult } from "../test-plan.js";
|
|
7
8
|
import { collectInteractiveNodes, detectFocusArea } from "../utils/aria.js";
|
|
8
9
|
import { ErrorPageError } from "../utils/error-page.js";
|
|
@@ -485,21 +486,43 @@ export class Pilot {
|
|
|
485
486
|
}
|
|
486
487
|
return text;
|
|
487
488
|
}
|
|
488
|
-
async settleExpectations(task) {
|
|
489
|
-
|
|
489
|
+
async settleExpectations(task, finalState) {
|
|
490
|
+
let image = null;
|
|
491
|
+
if (finalState?.screenshot && this.provider.hasVision())
|
|
492
|
+
image = `data:image/png;base64,${finalState.screenshot.toString('base64')}`;
|
|
490
493
|
const decided = (text) => {
|
|
491
494
|
if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text))
|
|
492
495
|
return 'passed';
|
|
493
496
|
return 'failed';
|
|
494
497
|
};
|
|
498
|
+
let undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
|
|
499
|
+
if (image)
|
|
500
|
+
undecided = task.expected;
|
|
495
501
|
if (!undecided.length)
|
|
496
502
|
return task.expected.map((text) => ({ text, status: decided(text) }));
|
|
497
503
|
const schema = z.object({
|
|
498
504
|
outcomes: z.array(z.object({
|
|
499
505
|
expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
|
|
500
|
-
status: z.enum(['passed', 'failed', 'unverified']).describe('passed = the
|
|
506
|
+
status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
|
|
507
|
+
evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
|
|
501
508
|
})),
|
|
502
509
|
});
|
|
510
|
+
let pageEvidence = '';
|
|
511
|
+
if (image) {
|
|
512
|
+
pageEvidence = dedent `
|
|
513
|
+
A screenshot of the whole page as the run left it is attached. It is the proof: an outcome is satisfied
|
|
514
|
+
when the page shows it to somebody looking at it. The log only says what the run did.
|
|
515
|
+
|
|
516
|
+
Not finding something in the picture is not by itself a disagreement. Report "contradiction" only when
|
|
517
|
+
the picture shows something incompatible with what the run claims — a list visibly empty, an error where
|
|
518
|
+
a result was expected, the old value still displayed, a control visibly disabled. When you simply cannot
|
|
519
|
+
make it out, say "unverified" and name what you could not find.
|
|
520
|
+
|
|
521
|
+
The picture covers the full page, but not the inside of a region that scrolls on its own, and not the
|
|
522
|
+
state of the page before the run ended. An outcome established earlier stays established even when the
|
|
523
|
+
page has moved past it, and that is not a contradiction.
|
|
524
|
+
`;
|
|
525
|
+
}
|
|
503
526
|
const userContent = dedent `
|
|
504
527
|
A test run has finished. Decide, for each expected outcome, what the run established about it.
|
|
505
528
|
|
|
@@ -511,22 +534,41 @@ export class Pilot {
|
|
|
511
534
|
${task.notesToString() || 'No steps recorded.'}
|
|
512
535
|
</run_log>
|
|
513
536
|
|
|
537
|
+
${pageEvidence}
|
|
538
|
+
|
|
514
539
|
The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
|
|
515
540
|
differently. Judge by what the steps show happened, not by whether the wording matches.
|
|
516
|
-
Choose "unverified" only when the
|
|
541
|
+
Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
|
|
517
542
|
that is a statement about the run, not about the application.
|
|
518
543
|
`;
|
|
519
|
-
const
|
|
520
|
-
.generateObject([{ role: 'user', content
|
|
544
|
+
const settle = (content, model) => this.provider
|
|
545
|
+
.generateObject([{ role: 'user', content }], schema, model, {
|
|
521
546
|
agentName: 'pilot',
|
|
522
547
|
telemetry: { functionId: 'pilot.settleExpectations' },
|
|
523
548
|
})
|
|
524
549
|
.catch(() => null);
|
|
525
|
-
|
|
550
|
+
let response = null;
|
|
551
|
+
if (image) {
|
|
552
|
+
const seen = [
|
|
553
|
+
{ type: 'text', text: userContent },
|
|
554
|
+
{ type: 'file', mediaType: 'image/png', data: image },
|
|
555
|
+
];
|
|
556
|
+
response = await settle(seen, this.provider.getVisionModel());
|
|
557
|
+
if (!response) {
|
|
558
|
+
Stats.visionDisabled = true;
|
|
559
|
+
tag('warning').log('⚠️ Vision model could not judge the outcomes. Settling them from the run log instead.');
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (!response)
|
|
563
|
+
response = await settle(userContent, this.provider.getAgenticModel('pilot'));
|
|
564
|
+
const judged = new Map((response?.object?.outcomes || []).map((outcome) => [outcome.expectation, outcome]));
|
|
526
565
|
return task.expected.map((text) => {
|
|
527
566
|
if (!undecided.includes(text))
|
|
528
567
|
return { text, status: decided(text) };
|
|
529
|
-
|
|
568
|
+
const outcome = judged.get(text);
|
|
569
|
+
if (!outcome)
|
|
570
|
+
return { text, status: 'unverified' };
|
|
571
|
+
return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
|
|
530
572
|
});
|
|
531
573
|
}
|
|
532
574
|
formatExpectations(task) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
2
2
|
import type { ModelMessage } from 'ai';
|
|
3
|
-
import type
|
|
3
|
+
import { type AIConfig } from '../config.js';
|
|
4
4
|
import { type RetryOptions } from '../utils/retry.js';
|
|
5
5
|
import { Conversation } from './conversation.js';
|
|
6
6
|
declare class AiError extends Error {
|
|
@@ -14,10 +14,10 @@ export declare class Provider {
|
|
|
14
14
|
defaultRetryOptions: RetryOptions;
|
|
15
15
|
lastConversation: Conversation | null;
|
|
16
16
|
constructor(config: AIConfig);
|
|
17
|
-
getModelName(model: any): string;
|
|
18
17
|
validateConnection(): Promise<void>;
|
|
19
18
|
getModelForAgent(agentName?: string): any;
|
|
20
19
|
getAgenticModel(agentName?: string): any;
|
|
20
|
+
getVisionModel(): any;
|
|
21
21
|
getConfiguredModels(): Record<string, string>;
|
|
22
22
|
getSystemPromptForAgent(agentName: string, currentUrl?: string): string | undefined;
|
|
23
23
|
getProviderOptionsForAgent(agentName: string): Record<string, any> | undefined;
|