explorbot 0.2.3 → 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/README.md +1 -1
- package/bin/explorbot-cli.ts +26 -8
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/api-tester/src/config.ts +4 -2
- package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
- package/boat/doc-collector/src/ai/documentarian.ts +61 -31
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/doc-collector/src/config.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +0 -0
- package/boat/prima/src/activity-line.ts +33 -0
- package/boat/prima/src/cli.ts +127 -86
- package/boat/prima/src/envelope.ts +102 -52
- package/boat/prima/src/prima.ts +567 -128
- package/boat/prima/src/pw-parser.ts +11 -1
- package/boat/prima/src/pw-registry.ts +4 -5
- package/boat/prima/src/session-log.ts +126 -0
- package/dist/bin/explorbot-cli.js +26 -8
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/api-tester/src/config.js +4 -2
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/doc-collector/src/config.js +4 -2
- package/dist/boat/prima/src/activity-line.js +30 -0
- package/dist/boat/prima/src/cli.js +109 -77
- package/dist/boat/prima/src/envelope.js +94 -44
- package/dist/boat/prima/src/prima.js +533 -119
- package/dist/boat/prima/src/pw-parser.js +13 -1
- package/dist/boat/prima/src/pw-registry.js +4 -5
- package/dist/boat/prima/src/session-log.js +108 -0
- package/dist/package.json +3 -2
- package/dist/rules/navigator/verification-actions.md +20 -0
- package/dist/src/action-result.d.ts +7 -0
- package/dist/src/action-result.js +4 -0
- package/dist/src/action.d.ts +2 -0
- package/dist/src/action.js +41 -2
- package/dist/src/ai/captain/web-mode.js +6 -3
- package/dist/src/ai/captain.js +2 -0
- package/dist/src/ai/navigator.d.ts +34 -0
- package/dist/src/ai/navigator.js +237 -181
- package/dist/src/ai/pilot.d.ts +7 -0
- package/dist/src/ai/pilot.js +90 -2
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +14 -23
- package/dist/src/ai/rerunner.js +2 -1
- 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 +3 -2
- package/dist/src/ai/rules.js +17 -10
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/task-agent.js +4 -1
- package/dist/src/ai/tester.d.ts +6 -3
- package/dist/src/ai/tester.js +50 -46
- package/dist/src/ai/tools.d.ts +14 -0
- package/dist/src/ai/tools.js +117 -37
- 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 +9 -1
- package/dist/src/config.js +53 -4
- package/dist/src/execution-controller.d.ts +2 -0
- package/dist/src/execution-controller.js +6 -0
- package/dist/src/explorbot.d.ts +2 -1
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +2 -3
- package/dist/src/playwright-recorder.js +30 -0
- package/dist/src/remote.d.ts +55 -0
- package/dist/src/remote.js +235 -0
- package/dist/src/reporter.d.ts +1 -0
- package/dist/src/reporter.js +7 -1
- package/dist/src/state-manager.d.ts +2 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/stats.d.ts +1 -0
- package/dist/src/stats.js +1 -0
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +26 -0
- package/dist/src/utils/aria.d.ts +2 -8
- package/dist/src/utils/aria.js +69 -40
- package/dist/src/utils/html.js +1 -0
- package/dist/src/utils/logger.d.ts +7 -1
- package/dist/src/utils/logger.js +32 -0
- package/dist/src/utils/page-readiness.js +18 -1
- package/dist/src/utils/url-matcher.js +3 -0
- package/dist/src/utils/web-element.d.ts +2 -0
- package/dist/src/utils/web-element.js +8 -0
- package/dist/src/utils/web-sandbox.d.ts +1 -1
- package/dist/src/utils/web-sandbox.js +2 -3
- package/docs/api-testing/basics.md +90 -0
- package/docs/api-testing/planning.md +57 -0
- package/docs/api-testing/running-tests.md +55 -0
- package/docs/assets/cloud-report.png +0 -0
- package/docs/assets/html-report.png +0 -0
- package/docs/assets/langfuse-trace.png +0 -0
- package/docs/assets/successful-explore-run.png +0 -0
- package/docs/basics/getting-started.md +140 -0
- package/docs/basics/prerequisites.md +63 -0
- package/docs/basics/providers.md +362 -0
- package/docs/basics/running.md +78 -0
- package/docs/contributing/ai-integration-tests.md +57 -0
- package/docs/contributing/contributing.md +90 -0
- package/docs/contributing/demo-videos.md +36 -0
- package/docs/contributing/npm-package.md +138 -0
- package/docs/contributing/observability.md +227 -0
- package/docs/contributing/regression-tests.md +103 -0
- package/docs/contributing/testing.md +95 -0
- package/docs/doc-collection/basics.md +128 -0
- package/docs/doc-collection/crawling.md +67 -0
- package/docs/doc-collection/interactive-mode.md +99 -0
- package/docs/index.json +87 -0
- package/docs/reference/commands.md +997 -0
- package/docs/reference/configuration.md +569 -0
- package/docs/reference/scripting.md +303 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
- package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
- package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
- package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
- package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
- package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
- package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/docs/web-testing/agents.md +158 -0
- package/docs/web-testing/automated-tests.md +134 -0
- package/docs/web-testing/basics.md +91 -0
- package/docs/web-testing/customization.md +131 -0
- package/docs/web-testing/hooks.md +238 -0
- package/docs/web-testing/page-interaction.md +84 -0
- package/docs/web-testing/planner.md +122 -0
- package/docs/web-testing/rerun.md +164 -0
- package/docs/web-testing/researcher.md +380 -0
- package/docs/workflow/agentic-usage.md +233 -0
- package/docs/workflow/application-spec.md +73 -0
- package/docs/workflow/ci.md +202 -0
- package/docs/workflow/knowledge.md +310 -0
- package/docs/workflow/planning-styles.md +67 -0
- package/docs/workflow/reporting.md +133 -0
- package/docs/workflow/test-plans.md +90 -0
- package/package.json +3 -2
- package/rules/navigator/verification-actions.md +20 -0
- package/src/action-result.ts +11 -0
- package/src/action.ts +43 -3
- package/src/ai/captain/web-mode.ts +6 -3
- package/src/ai/captain.ts +3 -0
- package/src/ai/navigator.ts +255 -186
- package/src/ai/pilot.ts +104 -2
- package/src/ai/provider.ts +14 -24
- package/src/ai/rerunner.ts +2 -1
- package/src/ai/researcher/cache.ts +12 -2
- package/src/ai/researcher.ts +3 -2
- package/src/ai/rules.ts +17 -10
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/task-agent.ts +3 -1
- package/src/ai/tester.ts +52 -45
- package/src/ai/tools.ts +136 -37
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +60 -5
- package/src/execution-controller.ts +8 -0
- package/src/explorbot.ts +7 -3
- package/src/explorer.ts +2 -2
- package/src/playwright-recorder.ts +23 -0
- package/src/remote.ts +244 -0
- package/src/reporter.ts +7 -1
- package/src/state-manager.ts +6 -2
- package/src/stats.ts +1 -0
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +65 -45
- package/src/utils/html.ts +1 -0
- package/src/utils/logger.ts +33 -2
- package/src/utils/page-readiness.ts +24 -1
- package/src/utils/url-matcher.ts +3 -0
- package/src/utils/web-element.ts +9 -0
- package/src/utils/web-sandbox.ts +3 -4
package/dist/src/ai/navigator.js
CHANGED
|
@@ -3,11 +3,15 @@ import dedent from 'dedent';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ActionResult } from '../action-result.js';
|
|
5
5
|
import { normalizeUrl } from '../state-manager.js';
|
|
6
|
+
import { renderAssertion } from "../playwright-recorder.js";
|
|
7
|
+
import { isFatalBrowserError } from "../utils/browser-errors.js";
|
|
8
|
+
import { getCliName } from "../utils/cli-name.js";
|
|
6
9
|
import { extractCodeBlocks } from '../utils/code-extractor.js';
|
|
7
10
|
import { HooksRunner } from "../utils/hooks-runner.js";
|
|
8
11
|
import { createDebug, pluralize, tag } from '../utils/logger.js';
|
|
9
12
|
import { loop, pause } from '../utils/loop.js';
|
|
10
13
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
14
|
+
import { normalizeInlineText } from "../utils/strings.js";
|
|
11
15
|
import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
|
|
12
16
|
import { Researcher } from "./researcher.js";
|
|
13
17
|
import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
|
|
@@ -21,6 +25,7 @@ class Navigator {
|
|
|
21
25
|
experienceTracker;
|
|
22
26
|
hooksRunner;
|
|
23
27
|
MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
|
|
28
|
+
lastFailureReason = null;
|
|
24
29
|
systemPrompt = dedent `
|
|
25
30
|
<role>
|
|
26
31
|
You are senior test automation engineer with master QA skills.
|
|
@@ -89,8 +94,12 @@ class Navigator {
|
|
|
89
94
|
const currentState = stateManager.getCurrentState();
|
|
90
95
|
if (!currentState)
|
|
91
96
|
return '';
|
|
92
|
-
|
|
93
|
-
|
|
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 || '';
|
|
94
103
|
}
|
|
95
104
|
isSameExpectedOrigin(expectedUrl, stateManager) {
|
|
96
105
|
const currentState = stateManager.getCurrentState();
|
|
@@ -138,7 +147,7 @@ class Navigator {
|
|
|
138
147
|
const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
|
|
139
148
|
const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
|
|
140
149
|
if (!resolved) {
|
|
141
|
-
throw
|
|
150
|
+
throw this.navigationError(url, `redirected to ${actualPath} and could not resolve`);
|
|
142
151
|
}
|
|
143
152
|
}
|
|
144
153
|
else if (action.lastError) {
|
|
@@ -150,7 +159,7 @@ class Navigator {
|
|
|
150
159
|
`.trim();
|
|
151
160
|
const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
|
|
152
161
|
if (!resolved) {
|
|
153
|
-
throw
|
|
162
|
+
throw this.navigationError(url, action.lastError?.message || 'Navigation failed');
|
|
154
163
|
}
|
|
155
164
|
}
|
|
156
165
|
await this.explorer.capture({ screenshot: true });
|
|
@@ -166,57 +175,32 @@ class Navigator {
|
|
|
166
175
|
throw error;
|
|
167
176
|
}
|
|
168
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
|
+
}
|
|
169
193
|
async resolveState(message, actionResult, opts) {
|
|
170
194
|
if (!this.provider)
|
|
171
195
|
throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
196
|
+
this.lastFailureReason = null;
|
|
172
197
|
tag('info').log('AI Navigator resolving state at', actionResult.url);
|
|
173
198
|
debugLog('Resolution message:', message);
|
|
174
199
|
const action = opts?.action ?? this.explorer.action();
|
|
175
200
|
const expectedUrl = opts?.expectedUrl;
|
|
176
201
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
177
|
-
let experience = '';
|
|
178
|
-
if (!actionResult.isInsideIframe) {
|
|
179
|
-
const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
|
|
180
|
-
if (successful.length > 0) {
|
|
181
|
-
tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
|
|
182
|
-
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>`;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
const prompt = dedent `
|
|
186
|
-
<message>
|
|
187
|
-
${message}
|
|
188
|
-
</message>
|
|
189
|
-
|
|
190
|
-
<page>
|
|
191
|
-
${actionResult.toAiContext()}
|
|
192
|
-
|
|
193
|
-
<page_html>
|
|
194
|
-
${await actionResult.combinedHtml()}
|
|
195
|
-
</page_html>
|
|
196
|
-
</page>
|
|
197
|
-
|
|
198
|
-
<task>
|
|
199
|
-
Identify the actual request of the user.
|
|
200
|
-
Identify what is expected by user.
|
|
201
|
-
Identify what might have caused the error.
|
|
202
|
-
Propose different solutions to achieve the result.
|
|
203
|
-
Solution should be valid CodeceptJS code.
|
|
204
|
-
Use only data from the <page> context to plan the solution.
|
|
205
|
-
Try various ways to achieve the result
|
|
206
|
-
</task>
|
|
207
|
-
|
|
208
|
-
${actionRule}
|
|
209
|
-
|
|
210
|
-
${unexpectedPopupRule}
|
|
211
|
-
|
|
212
|
-
${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
|
|
213
|
-
|
|
214
|
-
${experience}
|
|
215
|
-
|
|
216
|
-
${knowledge}
|
|
217
|
-
`;
|
|
218
202
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
219
|
-
conversation.addUserText(
|
|
203
|
+
conversation.addUserText(await this.buildResolutionPrompt(message, actionResult));
|
|
220
204
|
let stopReason = null;
|
|
221
205
|
const tools = {
|
|
222
206
|
stop: tool({
|
|
@@ -242,6 +226,7 @@ class Navigator {
|
|
|
242
226
|
let htmlContextAdded = false;
|
|
243
227
|
let codeBlockIndex = 0;
|
|
244
228
|
let totalAttempts = 0;
|
|
229
|
+
let lastFailure = null;
|
|
245
230
|
const progressBlocks = [];
|
|
246
231
|
const batchFailures = [];
|
|
247
232
|
let resolved = false;
|
|
@@ -251,7 +236,6 @@ class Navigator {
|
|
|
251
236
|
if (!result)
|
|
252
237
|
return;
|
|
253
238
|
if (stopReason) {
|
|
254
|
-
tag('error').log(`Navigator stopped: ${stopReason}`);
|
|
255
239
|
resolved = false;
|
|
256
240
|
stop();
|
|
257
241
|
return;
|
|
@@ -273,173 +257,242 @@ class Navigator {
|
|
|
273
257
|
return;
|
|
274
258
|
}
|
|
275
259
|
tag('operation').log('Feeding failures back to AI for a new batch...');
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const lines = batchFailures
|
|
279
|
-
.map((f) => {
|
|
280
|
-
const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
|
|
281
|
-
if (!f.ariaChanges)
|
|
282
|
-
return head;
|
|
283
|
-
const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
|
|
284
|
-
return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
|
|
285
|
-
})
|
|
286
|
-
.join('\n');
|
|
287
|
-
contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
|
|
288
|
-
}
|
|
289
|
-
if (!htmlContextAdded) {
|
|
290
|
-
htmlContextAdded = true;
|
|
291
|
-
contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
|
|
292
|
-
}
|
|
293
|
-
const pageReacted = batchFailures.some((f) => f.ariaChanges);
|
|
294
|
-
if (pageReacted) {
|
|
295
|
-
contextMsg += dedent `
|
|
296
|
-
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.
|
|
297
|
-
|
|
298
|
-
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.
|
|
299
|
-
|
|
300
|
-
Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
|
|
301
|
-
|
|
302
|
-
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.
|
|
303
|
-
|
|
304
|
-
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.
|
|
305
|
-
|
|
306
|
-
C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
|
|
307
|
-
`;
|
|
308
|
-
}
|
|
309
|
-
else {
|
|
310
|
-
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.';
|
|
311
|
-
}
|
|
312
|
-
conversation.addUserText(contextMsg);
|
|
260
|
+
conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
|
|
261
|
+
htmlContextAdded = true;
|
|
313
262
|
codeBlocks = [];
|
|
314
263
|
batchFailures.length = 0;
|
|
315
264
|
return;
|
|
316
265
|
}
|
|
317
266
|
codeBlockIndex++;
|
|
318
267
|
totalAttempts++;
|
|
319
|
-
await action.exitIframe();
|
|
320
268
|
const prevActionResult = action.actionResult ?? actionResult;
|
|
321
269
|
const prevHash = prevActionResult.getStateHash();
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
await page.waitForLoadState('load', { timeout: 5000 });
|
|
328
|
-
}
|
|
329
|
-
catch {
|
|
330
|
-
// Navigation did not reach 'load' state within timeout; continue and verify URL
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
if (attemptOk)
|
|
334
|
-
opts?.onAttempt?.({ code: codeBlock });
|
|
335
|
-
if (!attemptOk) {
|
|
336
|
-
const raw = action.lastError?.message || 'attempt failed';
|
|
337
|
-
const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
|
|
338
|
-
const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
339
|
-
batchFailures.push({ code: codeBlock, error: shortErr });
|
|
340
|
-
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;
|
|
341
275
|
}
|
|
342
276
|
if (expectedUrl) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
// URL did not transition to expectedUrl within timeout
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
const freshState = await this.explorer.capture();
|
|
352
|
-
const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
|
|
353
|
-
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
|
|
354
|
-
const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
|
|
355
|
-
resolved = urlMatches && stateChanged;
|
|
356
|
-
if (!resolved && attemptOk) {
|
|
357
|
-
let ariaChanges = null;
|
|
358
|
-
if (freshState.getStateHash() !== prevHash) {
|
|
359
|
-
try {
|
|
360
|
-
const diff = await freshState.diff(prevActionResult);
|
|
361
|
-
ariaChanges = diff.ariaChanged;
|
|
362
|
-
}
|
|
363
|
-
catch (err) {
|
|
364
|
-
debugLog('Failed to compute pageDiff for failed URL verification:', err);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
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})`;
|
|
367
282
|
batchFailures.push({
|
|
368
283
|
code: codeBlock,
|
|
369
|
-
error:
|
|
370
|
-
ariaChanges,
|
|
371
|
-
urlAfter: freshState.url,
|
|
284
|
+
error: lastFailure,
|
|
285
|
+
ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
|
|
286
|
+
urlAfter: check.freshState.url,
|
|
372
287
|
});
|
|
373
|
-
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${freshState.url}`);
|
|
288
|
+
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
374
289
|
}
|
|
375
|
-
if (
|
|
290
|
+
if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
|
|
376
291
|
progressBlocks.push(codeBlock);
|
|
377
292
|
}
|
|
378
293
|
}
|
|
379
294
|
else {
|
|
380
|
-
resolved =
|
|
381
|
-
if (
|
|
295
|
+
resolved = attempt.ok;
|
|
296
|
+
if (attempt.ok)
|
|
382
297
|
progressBlocks.push(codeBlock);
|
|
383
298
|
}
|
|
384
|
-
if (resolved)
|
|
385
|
-
tag('success').log('Navigation resolved successfully');
|
|
386
|
-
let scenario = message.split('\n')[0];
|
|
387
|
-
if (expectedUrl) {
|
|
388
|
-
const fromPath = extractStatePath(actionResult.url || '');
|
|
389
|
-
const toPath = extractStatePath(expectedUrl);
|
|
390
|
-
scenario = `reach ${toPath} from ${fromPath}`;
|
|
391
|
-
}
|
|
392
|
-
const recipe = progressBlocks
|
|
393
|
-
.join('\n')
|
|
394
|
-
.split('\n')
|
|
395
|
-
.filter((line) => !/^\s*I\.amOnPage\s*\(/.test(line))
|
|
396
|
-
.join('\n')
|
|
397
|
-
.trim();
|
|
398
|
-
if (recipe) {
|
|
399
|
-
const body = `## FLOW: ${scenario}\n\n* ${scenario}\n\n\`\`\`js\n${recipe}\n\`\`\`\n\n---\n`;
|
|
400
|
-
this.experienceTracker.writeFlow(actionResult, body);
|
|
401
|
-
}
|
|
402
|
-
stop();
|
|
299
|
+
if (!resolved)
|
|
403
300
|
return;
|
|
404
|
-
|
|
301
|
+
tag('success').log('Navigation resolved successfully');
|
|
302
|
+
this.saveFlow(message, expectedUrl, actionResult, progressBlocks);
|
|
303
|
+
stop();
|
|
405
304
|
}, {
|
|
406
305
|
maxAttempts: this.MAX_ATTEMPTS * 2,
|
|
407
306
|
observability: {
|
|
408
307
|
agent: 'navigator',
|
|
409
308
|
},
|
|
410
|
-
catch: async (error) => {
|
|
309
|
+
catch: async ({ error }) => {
|
|
310
|
+
if (isFatalBrowserError(error))
|
|
311
|
+
throw error;
|
|
411
312
|
debugLog(error);
|
|
412
313
|
resolved = false;
|
|
413
314
|
},
|
|
414
315
|
});
|
|
415
|
-
if (!resolved && expectedUrl)
|
|
416
|
-
await
|
|
417
|
-
if (this.isOnExpectedPage(expectedUrl, action.stateManager)) {
|
|
418
|
-
resolved = true;
|
|
419
|
-
tag('success').log('Navigation resolved after delayed redirect');
|
|
420
|
-
}
|
|
421
|
-
}
|
|
316
|
+
if (!resolved && expectedUrl)
|
|
317
|
+
resolved = await this.rescueDelayedRedirect(action, expectedUrl);
|
|
422
318
|
if (!resolved && stopReason) {
|
|
423
319
|
tag('error').log(`Navigator stopped: ${stopReason}`);
|
|
424
320
|
}
|
|
425
321
|
else if (!resolved && totalAttempts > 0) {
|
|
426
322
|
tag('error').log(`Navigation failed after ${totalAttempts} attempts`);
|
|
427
323
|
}
|
|
428
|
-
if (!resolved && isInteractive())
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
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 || '');
|
|
440
333
|
}
|
|
441
334
|
return resolved;
|
|
442
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
|
+
}
|
|
443
496
|
buildExperienceTools() {
|
|
444
497
|
if (!this.experienceTracker)
|
|
445
498
|
return undefined;
|
|
@@ -628,6 +681,7 @@ class Navigator {
|
|
|
628
681
|
const tools = this.buildExperienceTools();
|
|
629
682
|
let codeBlocks = [];
|
|
630
683
|
const successfulCodes = [];
|
|
684
|
+
const results = [];
|
|
631
685
|
const assertionSteps = [];
|
|
632
686
|
const action = this.explorer.action();
|
|
633
687
|
let failures = 0;
|
|
@@ -660,6 +714,8 @@ class Navigator {
|
|
|
660
714
|
}
|
|
661
715
|
await action.exitIframe();
|
|
662
716
|
const verified = await action.attempt(codeBlock, message);
|
|
717
|
+
const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
|
|
718
|
+
results.push({ code: codeBlock, passed: verified, proof });
|
|
663
719
|
if (verified) {
|
|
664
720
|
tag('success').log('Verification passed');
|
|
665
721
|
successfulCodes.push(codeBlock);
|
|
@@ -668,11 +724,6 @@ class Navigator {
|
|
|
668
724
|
else {
|
|
669
725
|
failures++;
|
|
670
726
|
}
|
|
671
|
-
const target = Math.min(codeBlocks.length, this.verifyAttempts);
|
|
672
|
-
const majorityNeeded = Math.floor(target / 2) + 1;
|
|
673
|
-
if (successfulCodes.length >= majorityNeeded || failures > target - majorityNeeded) {
|
|
674
|
-
stop();
|
|
675
|
-
}
|
|
676
727
|
}, {
|
|
677
728
|
maxAttempts: this.verifyAttempts,
|
|
678
729
|
observability: {
|
|
@@ -691,9 +742,14 @@ class Navigator {
|
|
|
691
742
|
let verified = successfulCodes.length >= majorityNeeded;
|
|
692
743
|
if (alreadyVerified)
|
|
693
744
|
verified = true;
|
|
745
|
+
const inexpressible = !alreadyVerified && totalAttempted === 0;
|
|
746
|
+
if (inexpressible) {
|
|
747
|
+
tag('warning').log('No assertion could express this claim');
|
|
748
|
+
return { verified: false, inexpressible, results, successfulCodes, assertionSteps, totalAttempted };
|
|
749
|
+
}
|
|
694
750
|
actionResult.addVerification(message, verified);
|
|
695
751
|
this.stateManager.updateState(actionResult);
|
|
696
|
-
return { verified, successfulCodes, assertionSteps, totalAttempted };
|
|
752
|
+
return { verified, inexpressible, results, successfulCodes, assertionSteps, totalAttempted };
|
|
697
753
|
}
|
|
698
754
|
checkAlreadyVerified(aiResponse, actionResult) {
|
|
699
755
|
const verifiedMatch = aiResponse.match(/ALREADY_VERIFIED:\s*(.+)/i);
|
package/dist/src/ai/pilot.d.ts
CHANGED
|
@@ -40,6 +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, finalState?: ActionResult): Promise<SettledExpectation[]>;
|
|
43
44
|
formatExpectations(task: Test): string;
|
|
44
45
|
sendToPilot(userText: string, functionId: string, opts: {
|
|
45
46
|
tools?: boolean;
|
|
@@ -86,3 +87,9 @@ export declare class Pilot implements Agent {
|
|
|
86
87
|
buildDeletionScope(task: Test): string;
|
|
87
88
|
getSystemPrompt(task: Test, initialState: ActionResult): string;
|
|
88
89
|
}
|
|
90
|
+
export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
|
|
91
|
+
export interface SettledExpectation {
|
|
92
|
+
text: string;
|
|
93
|
+
status: SettledStatus;
|
|
94
|
+
evidence?: string;
|
|
95
|
+
}
|