explorbot 0.1.20 → 0.1.21
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 +54 -25
- package/boat/doc-collector/src/ai/documentarian.ts +259 -84
- package/boat/doc-collector/src/ai/tools.ts +544 -0
- package/boat/doc-collector/src/cli.ts +1 -0
- package/boat/doc-collector/src/config.ts +2 -0
- package/boat/doc-collector/src/docbot.ts +64 -5
- package/boat/doc-collector/src/docs-renderer.ts +56 -2
- package/dist/bin/explorbot-cli.js +30 -4
- package/dist/boat/doc-collector/src/ai/documentarian.js +220 -71
- package/dist/boat/doc-collector/src/ai/tools.js +415 -0
- package/dist/boat/doc-collector/src/cli.js +1 -0
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +57 -5
- package/dist/boat/doc-collector/src/docs-renderer.js +46 -0
- package/dist/package.json +1 -1
- package/dist/src/ai/navigator.js +82 -6
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/plans-command.js +83 -0
- package/dist/src/commands/test-command.js +14 -8
- package/dist/src/playwright-recorder.js +19 -5
- package/package.json +1 -1
- package/src/ai/navigator.ts +79 -7
- package/src/commands/index.ts +2 -0
- package/src/commands/plans-command.ts +99 -0
- package/src/commands/test-command.ts +15 -8
- package/src/playwright-recorder.ts +19 -5
package/dist/src/ai/navigator.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
1
2
|
import dedent from 'dedent';
|
|
3
|
+
import { z } from 'zod';
|
|
2
4
|
import { ActionResult } from '../action-result.js';
|
|
3
5
|
import { ExperienceTracker, renderExperienceToc } from '../experience-tracker.js';
|
|
4
6
|
import { KnowledgeTracker } from '../knowledge-tracker.js';
|
|
@@ -213,7 +215,27 @@ class Navigator {
|
|
|
213
215
|
`;
|
|
214
216
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
215
217
|
conversation.addUserText(prompt);
|
|
216
|
-
|
|
218
|
+
let stopReason = null;
|
|
219
|
+
const tools = {
|
|
220
|
+
stop: tool({
|
|
221
|
+
description: dedent `
|
|
222
|
+
Stop the navigation because no locator or strategy change can reach the goal.
|
|
223
|
+
Use this when reaching the goal requires something only the user can supply or that the
|
|
224
|
+
page cannot grant from the current state — for example: an authentication failure you
|
|
225
|
+
cannot guess past, a captcha or human-verification step, a permission the test cannot
|
|
226
|
+
satisfy, a piece of data not present in the available knowledge / hint context, or a
|
|
227
|
+
blocking error or dialog you cannot dismiss.
|
|
228
|
+
Do NOT use this for locator or strategy problems — for those, emit new code blocks instead.
|
|
229
|
+
`,
|
|
230
|
+
inputSchema: z.object({
|
|
231
|
+
reason: z.string().describe('Short user-facing explanation. Quote what you observed (alert text, dialog title, status message, validation note) and name what is missing or required.'),
|
|
232
|
+
}),
|
|
233
|
+
execute: async ({ reason }) => {
|
|
234
|
+
stopReason = reason;
|
|
235
|
+
return { success: true, message: 'Recorded. Navigator will stop and surface the reason.' };
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
};
|
|
217
239
|
let codeBlocks = [];
|
|
218
240
|
let htmlContextAdded = false;
|
|
219
241
|
let codeBlockIndex = 0;
|
|
@@ -226,6 +248,12 @@ class Navigator {
|
|
|
226
248
|
const result = await this.provider.invokeConversation(conversation, tools);
|
|
227
249
|
if (!result)
|
|
228
250
|
return;
|
|
251
|
+
if (stopReason) {
|
|
252
|
+
tag('error').log(`Navigator stopped: ${stopReason}`);
|
|
253
|
+
resolved = false;
|
|
254
|
+
stop();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
229
257
|
const aiResponse = result?.response?.text;
|
|
230
258
|
debugLog('AI:', aiResponse?.split('\n')[0]);
|
|
231
259
|
debugLog('Received AI response:', aiResponse?.length ?? 0, 'characters');
|
|
@@ -245,14 +273,40 @@ class Navigator {
|
|
|
245
273
|
tag('substep').log('Feeding failures back to AI for a new batch...');
|
|
246
274
|
let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
|
|
247
275
|
if (batchFailures.length > 0) {
|
|
248
|
-
const lines = batchFailures
|
|
276
|
+
const lines = batchFailures
|
|
277
|
+
.map((f) => {
|
|
278
|
+
const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
|
|
279
|
+
if (!f.ariaChanges)
|
|
280
|
+
return head;
|
|
281
|
+
const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
|
|
282
|
+
return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
|
|
283
|
+
})
|
|
284
|
+
.join('\n');
|
|
249
285
|
contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
|
|
250
286
|
}
|
|
251
287
|
if (!htmlContextAdded) {
|
|
252
288
|
htmlContextAdded = true;
|
|
253
289
|
contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
|
|
254
290
|
}
|
|
255
|
-
|
|
291
|
+
const pageReacted = batchFailures.some((f) => f.ariaChanges);
|
|
292
|
+
if (pageReacted) {
|
|
293
|
+
contextMsg += dedent `
|
|
294
|
+
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.
|
|
295
|
+
|
|
296
|
+
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.
|
|
297
|
+
|
|
298
|
+
Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
|
|
299
|
+
|
|
300
|
+
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.
|
|
301
|
+
|
|
302
|
+
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.
|
|
303
|
+
|
|
304
|
+
C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
|
|
305
|
+
`;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
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.';
|
|
309
|
+
}
|
|
256
310
|
conversation.addUserText(contextMsg);
|
|
257
311
|
codeBlocks = [];
|
|
258
312
|
batchFailures.length = 0;
|
|
@@ -261,7 +315,8 @@ class Navigator {
|
|
|
261
315
|
codeBlockIndex++;
|
|
262
316
|
totalAttempts++;
|
|
263
317
|
await this.explorer.switchToMainFrame();
|
|
264
|
-
const
|
|
318
|
+
const prevActionResult = action.actionResult ?? actionResult;
|
|
319
|
+
const prevHash = prevActionResult.getStateHash();
|
|
265
320
|
debugLog(`Attempting resolution: ${codeBlock}`);
|
|
266
321
|
const attemptOk = await action.attempt(codeBlock, message);
|
|
267
322
|
const page = action.playwrightHelper?.page;
|
|
@@ -294,6 +349,23 @@ class Navigator {
|
|
|
294
349
|
const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
|
|
295
350
|
resolved = urlMatches && stateChanged;
|
|
296
351
|
if (!resolved && attemptOk) {
|
|
352
|
+
let ariaChanges = null;
|
|
353
|
+
if (freshState.getStateHash() !== prevHash) {
|
|
354
|
+
try {
|
|
355
|
+
const diff = await freshState.diff(prevActionResult);
|
|
356
|
+
await diff.calculate();
|
|
357
|
+
ariaChanges = diff.ariaChanged;
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
debugLog('Failed to compute pageDiff for failed URL verification:', err);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
batchFailures.push({
|
|
364
|
+
code: codeBlock,
|
|
365
|
+
error: `URL did not change (still ${freshState.url})`,
|
|
366
|
+
ariaChanges,
|
|
367
|
+
urlAfter: freshState.url,
|
|
368
|
+
});
|
|
297
369
|
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${freshState.url}`);
|
|
298
370
|
}
|
|
299
371
|
if (freshState.getStateHash() !== prevHash && (attemptOk || urlMatches)) {
|
|
@@ -343,11 +415,15 @@ class Navigator {
|
|
|
343
415
|
tag('success').log('Navigation resolved after delayed redirect');
|
|
344
416
|
}
|
|
345
417
|
}
|
|
346
|
-
if (!resolved &&
|
|
418
|
+
if (!resolved && stopReason) {
|
|
419
|
+
tag('error').log(`Navigator stopped: ${stopReason}`);
|
|
420
|
+
}
|
|
421
|
+
else if (!resolved && totalAttempts > 0) {
|
|
347
422
|
tag('error').log(`Navigation failed after ${totalAttempts} attempts`);
|
|
348
423
|
}
|
|
349
424
|
if (!resolved && isInteractive()) {
|
|
350
|
-
const
|
|
425
|
+
const stopLine = stopReason ? `Navigator stopped: ${stopReason}\n` : '';
|
|
426
|
+
const userInput = await pause(`${stopLine}Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\n` + `Target: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
|
|
351
427
|
if (userInput?.trim()) {
|
|
352
428
|
resolved = await action.attempt(userInput, message);
|
|
353
429
|
if (resolved && expectedUrl) {
|
|
@@ -24,6 +24,7 @@ import { PlanEditCommand } from './plan-edit-command.js';
|
|
|
24
24
|
import { PlanLoadCommand } from './plan-load-command.js';
|
|
25
25
|
import { PlanReloadCommand } from './plan-reload-command.js';
|
|
26
26
|
import { PlanSaveCommand } from './plan-save-command.js';
|
|
27
|
+
import { PlansCommand } from './plans-command.js';
|
|
27
28
|
import { RerunCommand } from './rerun-command.js';
|
|
28
29
|
import { ResearchCommand } from './research-command.js';
|
|
29
30
|
import { RunsCommand } from './runs-command.js';
|
|
@@ -42,6 +43,7 @@ const commandClasses = [
|
|
|
42
43
|
PlanCommand,
|
|
43
44
|
PlanSaveCommand,
|
|
44
45
|
PlanLoadCommand,
|
|
46
|
+
PlansCommand,
|
|
45
47
|
PlanReloadCommand,
|
|
46
48
|
PlanClearCommand,
|
|
47
49
|
PlanEditCommand,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Plan } from '../test-plan.js';
|
|
4
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
5
|
+
import { tag } from '../utils/logger.js';
|
|
6
|
+
import { relativeToCwd } from '../utils/next-steps.js';
|
|
7
|
+
import { BaseCommand } from './base-command.js';
|
|
8
|
+
export class PlansCommand extends BaseCommand {
|
|
9
|
+
name = 'plans';
|
|
10
|
+
description = 'List saved plans and show their test scenarios';
|
|
11
|
+
options = [{ flags: '--from-plan <file>', description: 'Plan file to show' }];
|
|
12
|
+
async execute(args) {
|
|
13
|
+
const { opts, args: remaining } = this.parseArgs(args);
|
|
14
|
+
const files = this.getPlanFiles();
|
|
15
|
+
const target = String(opts.fromPlan || remaining[0] || '').trim();
|
|
16
|
+
if (!target) {
|
|
17
|
+
this.printPlans(files);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const file = this.resolvePlanFile(target, files);
|
|
21
|
+
const plan = Plan.fromMarkdown(file.path);
|
|
22
|
+
this.printPlanDetails(plan, file);
|
|
23
|
+
}
|
|
24
|
+
getPlanFiles() {
|
|
25
|
+
const plansDir = this.explorBot.getPlansDir();
|
|
26
|
+
if (!existsSync(plansDir))
|
|
27
|
+
return [];
|
|
28
|
+
return readdirSync(plansDir)
|
|
29
|
+
.filter((file) => file.endsWith('.md'))
|
|
30
|
+
.map((file) => {
|
|
31
|
+
const filePath = path.join(plansDir, file);
|
|
32
|
+
const stat = statSync(filePath);
|
|
33
|
+
return {
|
|
34
|
+
name: file,
|
|
35
|
+
path: filePath,
|
|
36
|
+
modifiedAt: stat.mtimeMs,
|
|
37
|
+
};
|
|
38
|
+
})
|
|
39
|
+
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
40
|
+
}
|
|
41
|
+
printPlans(files) {
|
|
42
|
+
if (files.length === 0) {
|
|
43
|
+
tag('info').log(`No saved plans found in ${relativeToCwd(this.explorBot.getPlansDir())}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
tag('info').log('Saved plans:');
|
|
47
|
+
for (let i = 0; i < files.length; i++) {
|
|
48
|
+
const file = files[i];
|
|
49
|
+
const plan = Plan.fromMarkdown(file.path);
|
|
50
|
+
tag('info').log(`${i + 1}. ${plan.title} (${plan.tests.length} tests) - ${file.name}`);
|
|
51
|
+
}
|
|
52
|
+
tag('info').log('');
|
|
53
|
+
tag('info').log(`View plan tests: ${getCliName()} plans <number>`);
|
|
54
|
+
}
|
|
55
|
+
printPlanDetails(plan, file) {
|
|
56
|
+
tag('info').log(`${plan.title} (${plan.tests.length} tests)`);
|
|
57
|
+
for (let i = 0; i < plan.tests.length; i++) {
|
|
58
|
+
const test = plan.tests[i];
|
|
59
|
+
tag('info').log(`${i + 1}. ${test.scenario}`);
|
|
60
|
+
}
|
|
61
|
+
tag('info').log('');
|
|
62
|
+
tag('info').log('Run test from this plan as:');
|
|
63
|
+
tag('info').log(`${getCliName()} test 1 --from-plan ${file.name}`);
|
|
64
|
+
}
|
|
65
|
+
resolvePlanFile(target, files) {
|
|
66
|
+
const index = Number.parseInt(target, 10);
|
|
67
|
+
if (!Number.isNaN(index) && String(index) === target) {
|
|
68
|
+
const file = files[index - 1];
|
|
69
|
+
if (!file)
|
|
70
|
+
throw new Error(`Plan #${target} not found. Available: 1-${files.length}`);
|
|
71
|
+
return file;
|
|
72
|
+
}
|
|
73
|
+
const resolved = this.explorBot.resolvePlanPath(target);
|
|
74
|
+
if (!existsSync(resolved)) {
|
|
75
|
+
throw new Error(`Plan file not found: ${resolved}`);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
name: path.basename(resolved),
|
|
79
|
+
path: resolved,
|
|
80
|
+
modifiedAt: statSync(resolved).mtimeMs,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -5,12 +5,18 @@ import { BaseCommand } from './base-command.js';
|
|
|
5
5
|
export class TestCommand extends BaseCommand {
|
|
6
6
|
name = 'test';
|
|
7
7
|
description = 'Launch tester agent to execute test scenarios';
|
|
8
|
+
options = [{ flags: '--from-plan <file>', description: 'Load plan file before selecting tests' }];
|
|
8
9
|
suggestions = [
|
|
9
10
|
{ command: 'test', hint: 'run next test' },
|
|
10
11
|
{ command: 'plan', hint: 'create new plan' },
|
|
11
12
|
];
|
|
12
13
|
async execute(args) {
|
|
14
|
+
const { opts, args: remaining } = this.parseArgs(args);
|
|
15
|
+
if (opts.fromPlan) {
|
|
16
|
+
this.explorBot.loadPlan(String(opts.fromPlan));
|
|
17
|
+
}
|
|
13
18
|
const plan = this.explorBot.getCurrentPlan();
|
|
19
|
+
const selector = remaining.join(' ');
|
|
14
20
|
Stats.mode = 'test';
|
|
15
21
|
Stats.focus = plan?.title;
|
|
16
22
|
const toExecute = [];
|
|
@@ -19,25 +25,25 @@ export class TestCommand extends BaseCommand {
|
|
|
19
25
|
throw new Error('No plan found. Please run /plan first to create test scenarios.');
|
|
20
26
|
return plan;
|
|
21
27
|
};
|
|
22
|
-
if (!
|
|
28
|
+
if (!selector) {
|
|
23
29
|
const pending = requirePlan().getPendingTests();
|
|
24
30
|
if (pending.length === 0) {
|
|
25
31
|
throw new Error('All tests are already complete. Please run /plan to create new test scenarios.');
|
|
26
32
|
}
|
|
27
33
|
toExecute.push(pending[0]);
|
|
28
34
|
}
|
|
29
|
-
else if (
|
|
35
|
+
else if (selector === '*' || selector === 'all') {
|
|
30
36
|
toExecute.push(...requirePlan().getPendingTests());
|
|
31
37
|
}
|
|
32
|
-
else if (
|
|
38
|
+
else if (selector.match(/^[\d,\-\s]+$/)) {
|
|
33
39
|
const visible = requirePlan().tests.filter((t) => t.enabled);
|
|
34
|
-
const indices = parseTestIndices(
|
|
40
|
+
const indices = parseTestIndices(selector, visible.length);
|
|
35
41
|
for (const idx of indices) {
|
|
36
42
|
toExecute.push(visible[idx]);
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
else {
|
|
40
|
-
const matching = plan?.getPendingTests().filter((test) => test.scenario.toLowerCase().includes(
|
|
46
|
+
const matching = plan?.getPendingTests().filter((test) => test.scenario.toLowerCase().includes(selector.toLowerCase())) || [];
|
|
41
47
|
if (matching.length > 0) {
|
|
42
48
|
toExecute.push(...matching);
|
|
43
49
|
}
|
|
@@ -46,13 +52,13 @@ export class TestCommand extends BaseCommand {
|
|
|
46
52
|
if (!state) {
|
|
47
53
|
throw new Error('No page loaded. Please navigate to a page first.');
|
|
48
54
|
}
|
|
49
|
-
const newTest = new Test(
|
|
55
|
+
const newTest = new Test(selector, 'unknown', [], state.url);
|
|
50
56
|
if (plan) {
|
|
51
57
|
plan.addTest(newTest);
|
|
52
|
-
tag('info').log(`Created new test: "${
|
|
58
|
+
tag('info').log(`Created new test: "${selector}" and added to current plan.`);
|
|
53
59
|
}
|
|
54
60
|
else {
|
|
55
|
-
tag('info').log(`Created ad-hoc test: "${
|
|
61
|
+
tag('info').log(`Created ad-hoc test: "${selector}"`);
|
|
56
62
|
}
|
|
57
63
|
toExecute.push(newTest);
|
|
58
64
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
// @ts-ignore — package ships a .js re-export without typings for this sub-path
|
|
3
|
-
import * as playwrightUtils from 'playwright-core/lib/utils';
|
|
4
3
|
import { createDebug } from "./utils/logger.js";
|
|
5
4
|
const debugLog = createDebug('explorbot:playwright-recorder');
|
|
6
5
|
const RECORDABLE = {
|
|
@@ -8,11 +7,26 @@ const RECORDABLE = {
|
|
|
8
7
|
Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
|
|
9
8
|
};
|
|
10
9
|
const PLAYWRIGHT_INCOMPATIBLE = "Playwright output is not compatible with this Playwright version (playwright-core/lib/utils does not expose asLocator). Use output.framework: 'codeceptjs' instead, or pin Playwright to a version shipping lib/utils/isomorphic/locatorGenerators.js.";
|
|
10
|
+
let cachedAsLocator = null;
|
|
11
|
+
let asLocatorLoadAttempted = false;
|
|
12
|
+
const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url);
|
|
11
13
|
function getAsLocator() {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
if (cachedAsLocator)
|
|
15
|
+
return cachedAsLocator;
|
|
16
|
+
if (asLocatorLoadAttempted)
|
|
14
17
|
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
15
|
-
|
|
18
|
+
asLocatorLoadAttempted = true;
|
|
19
|
+
try {
|
|
20
|
+
const mod = nodeRequire('playwright-core/lib/utils');
|
|
21
|
+
if (typeof mod?.asLocator === 'function') {
|
|
22
|
+
cachedAsLocator = mod.asLocator;
|
|
23
|
+
return cachedAsLocator;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Module not exported or not found
|
|
28
|
+
}
|
|
29
|
+
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
16
30
|
}
|
|
17
31
|
export class PlaywrightRecorder {
|
|
18
32
|
context = null;
|
package/package.json
CHANGED
package/src/ai/navigator.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
1
2
|
import dedent from 'dedent';
|
|
3
|
+
import { z } from 'zod';
|
|
2
4
|
import { ActionResult } from '../action-result.js';
|
|
3
5
|
import type Action from '../action.ts';
|
|
4
6
|
import { ExperienceTracker, renderExperienceToc } from '../experience-tracker.js';
|
|
@@ -238,14 +240,34 @@ class Navigator implements Agent {
|
|
|
238
240
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
239
241
|
conversation.addUserText(prompt);
|
|
240
242
|
|
|
241
|
-
|
|
243
|
+
let stopReason: string | null = null;
|
|
244
|
+
const tools = {
|
|
245
|
+
stop: tool({
|
|
246
|
+
description: dedent`
|
|
247
|
+
Stop the navigation because no locator or strategy change can reach the goal.
|
|
248
|
+
Use this when reaching the goal requires something only the user can supply or that the
|
|
249
|
+
page cannot grant from the current state — for example: an authentication failure you
|
|
250
|
+
cannot guess past, a captcha or human-verification step, a permission the test cannot
|
|
251
|
+
satisfy, a piece of data not present in the available knowledge / hint context, or a
|
|
252
|
+
blocking error or dialog you cannot dismiss.
|
|
253
|
+
Do NOT use this for locator or strategy problems — for those, emit new code blocks instead.
|
|
254
|
+
`,
|
|
255
|
+
inputSchema: z.object({
|
|
256
|
+
reason: z.string().describe('Short user-facing explanation. Quote what you observed (alert text, dialog title, status message, validation note) and name what is missing or required.'),
|
|
257
|
+
}),
|
|
258
|
+
execute: async ({ reason }) => {
|
|
259
|
+
stopReason = reason;
|
|
260
|
+
return { success: true, message: 'Recorded. Navigator will stop and surface the reason.' };
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
242
264
|
|
|
243
265
|
let codeBlocks: string[] = [];
|
|
244
266
|
let htmlContextAdded = false;
|
|
245
267
|
let codeBlockIndex = 0;
|
|
246
268
|
let totalAttempts = 0;
|
|
247
269
|
const progressBlocks: string[] = [];
|
|
248
|
-
const batchFailures: Array<{ code: string; error: string }> = [];
|
|
270
|
+
const batchFailures: Array<{ code: string; error: string; ariaChanges?: string | null; urlAfter?: string }> = [];
|
|
249
271
|
|
|
250
272
|
let resolved = false;
|
|
251
273
|
await loop(
|
|
@@ -253,6 +275,12 @@ class Navigator implements Agent {
|
|
|
253
275
|
if (codeBlocks.length === 0) {
|
|
254
276
|
const result = await this.provider.invokeConversation(conversation, tools);
|
|
255
277
|
if (!result) return;
|
|
278
|
+
if (stopReason) {
|
|
279
|
+
tag('error').log(`Navigator stopped: ${stopReason}`);
|
|
280
|
+
resolved = false;
|
|
281
|
+
stop();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
256
284
|
const aiResponse = result?.response?.text;
|
|
257
285
|
debugLog('AI:', aiResponse?.split('\n')[0]);
|
|
258
286
|
debugLog('Received AI response:', aiResponse?.length ?? 0, 'characters');
|
|
@@ -274,14 +302,38 @@ class Navigator implements Agent {
|
|
|
274
302
|
tag('substep').log('Feeding failures back to AI for a new batch...');
|
|
275
303
|
let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
|
|
276
304
|
if (batchFailures.length > 0) {
|
|
277
|
-
const lines = batchFailures
|
|
305
|
+
const lines = batchFailures
|
|
306
|
+
.map((f) => {
|
|
307
|
+
const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
|
|
308
|
+
if (!f.ariaChanges) return head;
|
|
309
|
+
const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
|
|
310
|
+
return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
|
|
311
|
+
})
|
|
312
|
+
.join('\n');
|
|
278
313
|
contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
|
|
279
314
|
}
|
|
280
315
|
if (!htmlContextAdded) {
|
|
281
316
|
htmlContextAdded = true;
|
|
282
317
|
contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
|
|
283
318
|
}
|
|
284
|
-
|
|
319
|
+
const pageReacted = batchFailures.some((f) => f.ariaChanges);
|
|
320
|
+
if (pageReacted) {
|
|
321
|
+
contextMsg += dedent`
|
|
322
|
+
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.
|
|
323
|
+
|
|
324
|
+
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.
|
|
325
|
+
|
|
326
|
+
Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
|
|
327
|
+
|
|
328
|
+
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.
|
|
329
|
+
|
|
330
|
+
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.
|
|
331
|
+
|
|
332
|
+
C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
|
|
333
|
+
`;
|
|
334
|
+
} else {
|
|
335
|
+
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.';
|
|
336
|
+
}
|
|
285
337
|
conversation.addUserText(contextMsg);
|
|
286
338
|
codeBlocks = [];
|
|
287
339
|
batchFailures.length = 0;
|
|
@@ -292,7 +344,8 @@ class Navigator implements Agent {
|
|
|
292
344
|
|
|
293
345
|
await this.explorer.switchToMainFrame();
|
|
294
346
|
|
|
295
|
-
const
|
|
347
|
+
const prevActionResult = action.actionResult ?? actionResult;
|
|
348
|
+
const prevHash = prevActionResult.getStateHash();
|
|
296
349
|
|
|
297
350
|
debugLog(`Attempting resolution: ${codeBlock}`);
|
|
298
351
|
const attemptOk = await action.attempt(codeBlock, message);
|
|
@@ -328,6 +381,22 @@ class Navigator implements Agent {
|
|
|
328
381
|
resolved = urlMatches && stateChanged;
|
|
329
382
|
|
|
330
383
|
if (!resolved && attemptOk) {
|
|
384
|
+
let ariaChanges: string | null = null;
|
|
385
|
+
if (freshState.getStateHash() !== prevHash) {
|
|
386
|
+
try {
|
|
387
|
+
const diff = await freshState.diff(prevActionResult);
|
|
388
|
+
await diff.calculate();
|
|
389
|
+
ariaChanges = diff.ariaChanged;
|
|
390
|
+
} catch (err) {
|
|
391
|
+
debugLog('Failed to compute pageDiff for failed URL verification:', err);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
batchFailures.push({
|
|
395
|
+
code: codeBlock,
|
|
396
|
+
error: `URL did not change (still ${freshState.url})`,
|
|
397
|
+
ariaChanges,
|
|
398
|
+
urlAfter: freshState.url,
|
|
399
|
+
});
|
|
331
400
|
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${freshState.url}`);
|
|
332
401
|
}
|
|
333
402
|
if (freshState.getStateHash() !== prevHash && (attemptOk || urlMatches)) {
|
|
@@ -380,12 +449,15 @@ class Navigator implements Agent {
|
|
|
380
449
|
}
|
|
381
450
|
}
|
|
382
451
|
|
|
383
|
-
if (!resolved &&
|
|
452
|
+
if (!resolved && stopReason) {
|
|
453
|
+
tag('error').log(`Navigator stopped: ${stopReason}`);
|
|
454
|
+
} else if (!resolved && totalAttempts > 0) {
|
|
384
455
|
tag('error').log(`Navigation failed after ${totalAttempts} attempts`);
|
|
385
456
|
}
|
|
386
457
|
|
|
387
458
|
if (!resolved && isInteractive()) {
|
|
388
|
-
const
|
|
459
|
+
const stopLine = stopReason ? `Navigator stopped: ${stopReason}\n` : '';
|
|
460
|
+
const userInput = await pause(`${stopLine}Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\n` + `Target: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
|
|
389
461
|
|
|
390
462
|
if (userInput?.trim()) {
|
|
391
463
|
resolved = await action.attempt(userInput, message);
|
package/src/commands/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { PlanEditCommand } from './plan-edit-command.js';
|
|
|
26
26
|
import { PlanLoadCommand } from './plan-load-command.js';
|
|
27
27
|
import { PlanReloadCommand } from './plan-reload-command.js';
|
|
28
28
|
import { PlanSaveCommand } from './plan-save-command.js';
|
|
29
|
+
import { PlansCommand } from './plans-command.js';
|
|
29
30
|
import { RerunCommand } from './rerun-command.js';
|
|
30
31
|
import { ResearchCommand } from './research-command.js';
|
|
31
32
|
import { RunsCommand } from './runs-command.js';
|
|
@@ -48,6 +49,7 @@ const commandClasses: CommandClass[] = [
|
|
|
48
49
|
PlanCommand,
|
|
49
50
|
PlanSaveCommand,
|
|
50
51
|
PlanLoadCommand,
|
|
52
|
+
PlansCommand,
|
|
51
53
|
PlanReloadCommand,
|
|
52
54
|
PlanClearCommand,
|
|
53
55
|
PlanEditCommand,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Plan } from '../test-plan.js';
|
|
4
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
5
|
+
import { tag } from '../utils/logger.js';
|
|
6
|
+
import { relativeToCwd } from '../utils/next-steps.js';
|
|
7
|
+
import { BaseCommand } from './base-command.js';
|
|
8
|
+
|
|
9
|
+
export class PlansCommand extends BaseCommand {
|
|
10
|
+
name = 'plans';
|
|
11
|
+
description = 'List saved plans and show their test scenarios';
|
|
12
|
+
options = [{ flags: '--from-plan <file>', description: 'Plan file to show' }];
|
|
13
|
+
|
|
14
|
+
async execute(args: string): Promise<void> {
|
|
15
|
+
const { opts, args: remaining } = this.parseArgs(args);
|
|
16
|
+
const files = this.getPlanFiles();
|
|
17
|
+
const target = String(opts.fromPlan || remaining[0] || '').trim();
|
|
18
|
+
|
|
19
|
+
if (!target) {
|
|
20
|
+
this.printPlans(files);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const file = this.resolvePlanFile(target, files);
|
|
25
|
+
const plan = Plan.fromMarkdown(file.path);
|
|
26
|
+
this.printPlanDetails(plan, file);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private getPlanFiles(): PlanFile[] {
|
|
30
|
+
const plansDir = this.explorBot.getPlansDir();
|
|
31
|
+
if (!existsSync(plansDir)) return [];
|
|
32
|
+
|
|
33
|
+
return readdirSync(plansDir)
|
|
34
|
+
.filter((file) => file.endsWith('.md'))
|
|
35
|
+
.map((file) => {
|
|
36
|
+
const filePath = path.join(plansDir, file);
|
|
37
|
+
const stat = statSync(filePath);
|
|
38
|
+
return {
|
|
39
|
+
name: file,
|
|
40
|
+
path: filePath,
|
|
41
|
+
modifiedAt: stat.mtimeMs,
|
|
42
|
+
};
|
|
43
|
+
})
|
|
44
|
+
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private printPlans(files: PlanFile[]): void {
|
|
48
|
+
if (files.length === 0) {
|
|
49
|
+
tag('info').log(`No saved plans found in ${relativeToCwd(this.explorBot.getPlansDir())}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
tag('info').log('Saved plans:');
|
|
54
|
+
for (let i = 0; i < files.length; i++) {
|
|
55
|
+
const file = files[i];
|
|
56
|
+
const plan = Plan.fromMarkdown(file.path);
|
|
57
|
+
tag('info').log(`${i + 1}. ${plan.title} (${plan.tests.length} tests) - ${file.name}`);
|
|
58
|
+
}
|
|
59
|
+
tag('info').log('');
|
|
60
|
+
tag('info').log(`View plan tests: ${getCliName()} plans <number>`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private printPlanDetails(plan: Plan, file: PlanFile): void {
|
|
64
|
+
tag('info').log(`${plan.title} (${plan.tests.length} tests)`);
|
|
65
|
+
for (let i = 0; i < plan.tests.length; i++) {
|
|
66
|
+
const test = plan.tests[i];
|
|
67
|
+
tag('info').log(`${i + 1}. ${test.scenario}`);
|
|
68
|
+
}
|
|
69
|
+
tag('info').log('');
|
|
70
|
+
tag('info').log('Run test from this plan as:');
|
|
71
|
+
tag('info').log(`${getCliName()} test 1 --from-plan ${file.name}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private resolvePlanFile(target: string, files: PlanFile[]): PlanFile {
|
|
75
|
+
const index = Number.parseInt(target, 10);
|
|
76
|
+
if (!Number.isNaN(index) && String(index) === target) {
|
|
77
|
+
const file = files[index - 1];
|
|
78
|
+
if (!file) throw new Error(`Plan #${target} not found. Available: 1-${files.length}`);
|
|
79
|
+
return file;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const resolved = this.explorBot.resolvePlanPath(target);
|
|
83
|
+
if (!existsSync(resolved)) {
|
|
84
|
+
throw new Error(`Plan file not found: ${resolved}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
name: path.basename(resolved),
|
|
89
|
+
path: resolved,
|
|
90
|
+
modifiedAt: statSync(resolved).mtimeMs,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface PlanFile {
|
|
96
|
+
name: string;
|
|
97
|
+
path: string;
|
|
98
|
+
modifiedAt: number;
|
|
99
|
+
}
|