explorbot 0.3.2 → 0.3.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 +14 -3
- package/dist/bin/explorbot-cli.js +12 -3
- package/dist/models.json +2 -0
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +1 -0
- package/dist/src/action-result.js +17 -12
- package/dist/src/action.d.ts +10 -0
- package/dist/src/action.js +16 -10
- package/dist/src/ai/conversation.d.ts +2 -1
- package/dist/src/ai/conversation.js +9 -4
- package/dist/src/ai/navigator.d.ts +3 -0
- package/dist/src/ai/navigator.js +20 -4
- package/dist/src/ai/pilot.js +9 -0
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.js +28 -2
- package/dist/src/ai/researcher/deep-analysis.js +5 -1
- package/dist/src/ai/researcher/sections.js +0 -1
- package/dist/src/ai/researcher.js +0 -1
- package/dist/src/ai/rules.js +0 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +8 -9
- package/dist/src/ai/tools.d.ts +2 -0
- package/dist/src/ai/tools.js +21 -4
- package/dist/src/commands/init-command.js +74 -24
- package/dist/src/components/InitWizard.d.ts +2 -1
- package/dist/src/components/InitWizard.js +8 -4
- package/dist/src/explorer.js +1 -0
- package/dist/src/knowledge-tracker.d.ts +3 -1
- package/dist/src/knowledge-tracker.js +4 -4
- package/dist/src/utils/aria.js +1 -1
- package/docs/basics/providers.md +2 -4
- package/models.json +2 -0
- package/package.json +1 -1
- package/src/action-result.ts +20 -15
- package/src/action.ts +21 -12
- package/src/ai/conversation.ts +11 -5
- package/src/ai/navigator.ts +22 -4
- package/src/ai/pilot.ts +9 -0
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +28 -2
- package/src/ai/researcher/deep-analysis.ts +6 -1
- package/src/ai/researcher/sections.ts +0 -1
- package/src/ai/researcher.ts +0 -1
- package/src/ai/rules.ts +0 -1
- package/src/ai/tester.ts +8 -7
- package/src/ai/tools.ts +20 -4
- package/src/commands/init-command.ts +81 -22
- package/src/components/InitWizard.tsx +8 -4
- package/src/explorer.ts +1 -0
- package/src/knowledge-tracker.ts +4 -4
- package/src/utils/aria.ts +1 -1
package/bin/explorbot-cli.ts
CHANGED
|
@@ -7,16 +7,16 @@ import { Command } from 'commander';
|
|
|
7
7
|
import figureSet from 'figures';
|
|
8
8
|
import { render } from 'ink';
|
|
9
9
|
import React from 'react';
|
|
10
|
+
import { flushTelemetry } from '../src/ai/provider.js';
|
|
10
11
|
import { App } from '../src/components/App.js';
|
|
11
12
|
import { StatusPane } from '../src/components/StatusPane.js';
|
|
12
|
-
import { flushTelemetry } from '../src/ai/provider.js';
|
|
13
13
|
import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
|
|
14
14
|
import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
|
|
15
15
|
import { remote } from '../src/remote.js';
|
|
16
16
|
import { Stats } from '../src/stats.js';
|
|
17
17
|
import { Plan } from '../src/test-plan.js';
|
|
18
18
|
import { getCliName } from '../src/utils/cli-name.ts';
|
|
19
|
-
import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode } from '../src/utils/logger.js';
|
|
19
|
+
import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode, tag } from '../src/utils/logger.js';
|
|
20
20
|
import { jsonToTable } from '../src/utils/markdown-parser.js';
|
|
21
21
|
import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js';
|
|
22
22
|
import { type NextStepSection, printNextSteps, relativeToCwd } from '../src/utils/next-steps.ts';
|
|
@@ -30,6 +30,16 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version as stri
|
|
|
30
30
|
program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
|
|
31
31
|
remote.registerOption(program);
|
|
32
32
|
|
|
33
|
+
process.on('uncaughtException', async (error) => {
|
|
34
|
+
tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
|
|
35
|
+
await flushTelemetry();
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
process.on('unhandledRejection', (reason) => {
|
|
40
|
+
tag('error').log(`Unhandled rejection: ${reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason)}`);
|
|
41
|
+
});
|
|
42
|
+
|
|
33
43
|
if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima')) {
|
|
34
44
|
console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
|
|
35
45
|
}
|
|
@@ -546,6 +556,7 @@ program
|
|
|
546
556
|
.command('learn [url] [description]')
|
|
547
557
|
.description('Add knowledge for URLs')
|
|
548
558
|
.option('-p, --path <path>', 'Working directory path')
|
|
559
|
+
.option('--replace', 'Replace existing knowledge for this URL instead of appending')
|
|
549
560
|
.action(async (url, description, options) => {
|
|
550
561
|
try {
|
|
551
562
|
await ConfigParser.getInstance().loadConfig({
|
|
@@ -556,7 +567,7 @@ program
|
|
|
556
567
|
const tracker = new KnowledgeTracker();
|
|
557
568
|
|
|
558
569
|
if (url && description) {
|
|
559
|
-
const result = tracker.addKnowledge(url, description);
|
|
570
|
+
const result = tracker.addKnowledge(url, description, { replace: options.replace });
|
|
560
571
|
const action = result.isNewFile ? 'Created' : 'Updated';
|
|
561
572
|
console.log(`Knowledge ${action} in: ${result.filename}`);
|
|
562
573
|
return;
|
|
@@ -7,16 +7,16 @@ import { Command } from 'commander';
|
|
|
7
7
|
import figureSet from 'figures';
|
|
8
8
|
import { render } from 'ink';
|
|
9
9
|
import React from 'react';
|
|
10
|
+
import { flushTelemetry } from '../src/ai/provider.js';
|
|
10
11
|
import { App } from '../src/components/App.js';
|
|
11
12
|
import { StatusPane } from '../src/components/StatusPane.js';
|
|
12
|
-
import { flushTelemetry } from '../src/ai/provider.js';
|
|
13
13
|
import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
|
|
14
14
|
import { ExplorBot } from '../src/explorbot.js';
|
|
15
15
|
import { remote } from '../src/remote.js';
|
|
16
16
|
import { Stats } from '../src/stats.js';
|
|
17
17
|
import { Plan } from '../src/test-plan.js';
|
|
18
18
|
import { getCliName } from "../src/utils/cli-name.js";
|
|
19
|
-
import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode } from '../src/utils/logger.js';
|
|
19
|
+
import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode, tag } from '../src/utils/logger.js';
|
|
20
20
|
import { jsonToTable } from '../src/utils/markdown-parser.js';
|
|
21
21
|
import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js';
|
|
22
22
|
import { printNextSteps, relativeToCwd } from "../src/utils/next-steps.js";
|
|
@@ -26,6 +26,14 @@ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../p
|
|
|
26
26
|
const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
|
|
27
27
|
program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
|
|
28
28
|
remote.registerOption(program);
|
|
29
|
+
process.on('uncaughtException', async (error) => {
|
|
30
|
+
tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
|
|
31
|
+
await flushTelemetry();
|
|
32
|
+
process.exit(1);
|
|
33
|
+
});
|
|
34
|
+
process.on('unhandledRejection', (reason) => {
|
|
35
|
+
tag('error').log(`Unhandled rejection: ${reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason)}`);
|
|
36
|
+
});
|
|
29
37
|
if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima')) {
|
|
30
38
|
console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
|
|
31
39
|
}
|
|
@@ -493,6 +501,7 @@ program
|
|
|
493
501
|
.command('learn [url] [description]')
|
|
494
502
|
.description('Add knowledge for URLs')
|
|
495
503
|
.option('-p, --path <path>', 'Working directory path')
|
|
504
|
+
.option('--replace', 'Replace existing knowledge for this URL instead of appending')
|
|
496
505
|
.action(async (url, description, options) => {
|
|
497
506
|
try {
|
|
498
507
|
await ConfigParser.getInstance().loadConfig({
|
|
@@ -501,7 +510,7 @@ program
|
|
|
501
510
|
const { KnowledgeTracker } = await import('../src/knowledge-tracker.js');
|
|
502
511
|
const tracker = new KnowledgeTracker();
|
|
503
512
|
if (url && description) {
|
|
504
|
-
const result = tracker.addKnowledge(url, description);
|
|
513
|
+
const result = tracker.addKnowledge(url, description, { replace: options.replace });
|
|
505
514
|
const action = result.isNewFile ? 'Created' : 'Updated';
|
|
506
515
|
console.log(`Knowledge ${action} in: ${result.filename}`);
|
|
507
516
|
return;
|
package/dist/models.json
CHANGED
package/dist/package.json
CHANGED
|
@@ -135,6 +135,7 @@ export declare class Diff {
|
|
|
135
135
|
isSameUrl(): boolean;
|
|
136
136
|
urlHasChanged(): boolean;
|
|
137
137
|
get htmlParts(): HtmlDiffPart[];
|
|
138
|
+
cleanedHtmlParts(): Promise<HtmlDiffPart[]>;
|
|
138
139
|
get ariaChanged(): string | null;
|
|
139
140
|
get ariaChangeCount(): number;
|
|
140
141
|
get htmlDiff(): HtmlDiffResult | null;
|
|
@@ -444,17 +444,9 @@ export class ActionResult {
|
|
|
444
444
|
pageDiff.ariaChangeCount = diff.ariaChangeCount;
|
|
445
445
|
}
|
|
446
446
|
if (diff.htmlParts.length > 0) {
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const filteredHtml = htmlCombinedSnapshot(part.subtree, htmlConfig?.combined);
|
|
451
|
-
const minified = await minifyHtml(filteredHtml);
|
|
452
|
-
if (minified) {
|
|
453
|
-
processedParts.push({ ...part, subtree: minified });
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
if (processedParts.length > 0) {
|
|
457
|
-
pageDiff.htmlParts = collapseHtmlParts(processedParts);
|
|
447
|
+
const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
|
|
448
|
+
if (collapsed.length > 0) {
|
|
449
|
+
pageDiff.htmlParts = collapsed;
|
|
458
450
|
}
|
|
459
451
|
}
|
|
460
452
|
if (pageDiff.ariaChanges && this.iframeSnapshots.length > 0) {
|
|
@@ -490,7 +482,9 @@ function collapseHtmlParts(parts) {
|
|
|
490
482
|
const total = parts.reduce((sum, p) => sum + p.subtree.length, 0);
|
|
491
483
|
const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
|
|
492
484
|
if (fullPageReRender) {
|
|
493
|
-
return parts
|
|
485
|
+
return parts
|
|
486
|
+
.filter((part) => part.added.length > 0 || part.removed.length > 0)
|
|
487
|
+
.map((part) => ({
|
|
494
488
|
...part,
|
|
495
489
|
subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
|
|
496
490
|
}));
|
|
@@ -543,6 +537,17 @@ export class Diff {
|
|
|
543
537
|
return [];
|
|
544
538
|
return this._htmlDiffResult.parts;
|
|
545
539
|
}
|
|
540
|
+
async cleanedHtmlParts() {
|
|
541
|
+
const htmlConfig = ConfigParser.getInstance().getConfig().html;
|
|
542
|
+
const cleaned = [];
|
|
543
|
+
for (const part of this.htmlParts) {
|
|
544
|
+
const minified = await minifyHtml(htmlCombinedSnapshot(part.subtree, htmlConfig?.combined));
|
|
545
|
+
if (!minified)
|
|
546
|
+
continue;
|
|
547
|
+
cleaned.push({ ...part, subtree: minified });
|
|
548
|
+
}
|
|
549
|
+
return cleaned;
|
|
550
|
+
}
|
|
546
551
|
get ariaChanged() {
|
|
547
552
|
return this._ariaDiffResult;
|
|
548
553
|
}
|
package/dist/src/action.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ declare class Action {
|
|
|
15
15
|
name: string;
|
|
16
16
|
args: any[];
|
|
17
17
|
}>;
|
|
18
|
+
executedSteps: ExecutedStep[];
|
|
18
19
|
lastValue: unknown;
|
|
19
20
|
recorder?: PlaywrightRecorder;
|
|
20
21
|
recovery: RecoveryRunner;
|
|
@@ -58,3 +59,12 @@ export type RecoveryRunner = <T>(fn: () => Promise<T>) => Promise<T>;
|
|
|
58
59
|
export interface ExecuteOptions {
|
|
59
60
|
verbatim?: boolean;
|
|
60
61
|
}
|
|
62
|
+
export declare const attachStepLogger: (target: ExecutedStep[], assertionsTarget?: Array<{
|
|
63
|
+
name: string;
|
|
64
|
+
args: any[];
|
|
65
|
+
}>) => (() => void);
|
|
66
|
+
export interface ExecutedStep {
|
|
67
|
+
command: string;
|
|
68
|
+
success: boolean;
|
|
69
|
+
error?: string;
|
|
70
|
+
}
|
package/dist/src/action.js
CHANGED
|
@@ -31,6 +31,7 @@ class Action {
|
|
|
31
31
|
playwrightHelper;
|
|
32
32
|
playwrightGroupId = null;
|
|
33
33
|
assertionSteps = [];
|
|
34
|
+
executedSteps = [];
|
|
34
35
|
lastValue;
|
|
35
36
|
recorder;
|
|
36
37
|
recovery;
|
|
@@ -304,7 +305,7 @@ class Action {
|
|
|
304
305
|
let codeString = code.replace(/^\(I\) => /, '').trim();
|
|
305
306
|
const executedSteps = [];
|
|
306
307
|
const assertionSteps = [];
|
|
307
|
-
const
|
|
308
|
+
const detachSteps = attachStepLogger(executedSteps, assertionSteps);
|
|
308
309
|
const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
|
|
309
310
|
this.playwrightGroupId = groupId;
|
|
310
311
|
const detachResponses = this.captureResponses();
|
|
@@ -330,10 +331,12 @@ class Action {
|
|
|
330
331
|
await recorder.add(() => sleep(this.config.action?.delay || 500));
|
|
331
332
|
await recorder.promise();
|
|
332
333
|
this.lastValue = await returned;
|
|
334
|
+
if (!recorder.isRunning())
|
|
335
|
+
throw new Error('CodeceptJS recorder is stopped, commands were skipped and never reached the browser');
|
|
333
336
|
}
|
|
334
337
|
this.restorePageTimeout();
|
|
335
338
|
if (executedSteps.length > 0) {
|
|
336
|
-
codeString = executedSteps.join('\n');
|
|
339
|
+
codeString = executedSteps.map((step) => step.command).join('\n');
|
|
337
340
|
}
|
|
338
341
|
const pageState = await this.captureOnce({ codeBlock: codeString });
|
|
339
342
|
this.actionResult = pageState;
|
|
@@ -350,11 +353,12 @@ class Action {
|
|
|
350
353
|
throw err;
|
|
351
354
|
}
|
|
352
355
|
finally {
|
|
356
|
+
this.executedSteps = executedSteps;
|
|
353
357
|
this.restorePageTimeout();
|
|
354
358
|
detachResponses();
|
|
355
359
|
if (groupId)
|
|
356
360
|
await this.recorder.endAction();
|
|
357
|
-
|
|
361
|
+
detachSteps();
|
|
358
362
|
if (stepSpan) {
|
|
359
363
|
stepSpan.end();
|
|
360
364
|
}
|
|
@@ -443,13 +447,16 @@ async function captureTitle(page, actor) {
|
|
|
443
447
|
return '';
|
|
444
448
|
}
|
|
445
449
|
const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeElement', 'seeInField', 'dontSeeInField', 'seeInCurrentUrl', 'dontSeeInCurrentUrl']);
|
|
446
|
-
const attachStepLogger = (target, assertionsTarget) => {
|
|
450
|
+
export const attachStepLogger = (target, assertionsTarget) => {
|
|
447
451
|
const listener = (step, error) => {
|
|
448
452
|
if (!step?.toCode)
|
|
449
453
|
return;
|
|
450
454
|
if (step.name?.startsWith('grab'))
|
|
451
455
|
return;
|
|
452
|
-
|
|
456
|
+
const executed = { command: step.toCode(), success: !error };
|
|
457
|
+
if (error)
|
|
458
|
+
executed.error = errorToString(error);
|
|
459
|
+
target.push(executed);
|
|
453
460
|
if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
|
|
454
461
|
assertionsTarget.push({ name: step.name, args: step.args || [] });
|
|
455
462
|
}
|
|
@@ -461,11 +468,10 @@ const attachStepLogger = (target, assertionsTarget) => {
|
|
|
461
468
|
};
|
|
462
469
|
codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
|
|
463
470
|
codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
|
|
464
|
-
return
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
|
|
471
|
+
return () => {
|
|
472
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
|
|
473
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
|
|
474
|
+
};
|
|
469
475
|
};
|
|
470
476
|
const readFocusedElement = () => {
|
|
471
477
|
const el = document.activeElement;
|
|
@@ -4,8 +4,9 @@ export interface ToolExecution {
|
|
|
4
4
|
input: any;
|
|
5
5
|
output: any;
|
|
6
6
|
wasSuccessful: boolean;
|
|
7
|
+
reasoning?: string;
|
|
7
8
|
}
|
|
8
|
-
export declare function toToolExecution(toolName: string, input: any, rawOutput: any): ToolExecution;
|
|
9
|
+
export declare function toToolExecution(toolName: string, input: any, rawOutput: any, reasoning?: string): ToolExecution;
|
|
9
10
|
export declare function toolExecutionLabel(input: Record<string, any> | undefined): string;
|
|
10
11
|
export declare const NARRATION_TOOL = "commentary";
|
|
11
12
|
export declare class Conversation {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export function toToolExecution(toolName, input, rawOutput) {
|
|
1
|
+
export function toToolExecution(toolName, input, rawOutput, reasoning) {
|
|
2
2
|
let output = rawOutput;
|
|
3
3
|
if (rawOutput?.type === 'json' && rawOutput?.value)
|
|
4
4
|
output = rawOutput.value;
|
|
5
|
-
return { toolName, input, output, wasSuccessful: output?.success !== false };
|
|
5
|
+
return { toolName, input, output, wasSuccessful: output?.success !== false, reasoning };
|
|
6
6
|
}
|
|
7
7
|
export function toolExecutionLabel(input) {
|
|
8
8
|
return input?.explanation || input?.assertion || input?.reason || input?.request || '';
|
|
@@ -189,10 +189,14 @@ export class Conversation {
|
|
|
189
189
|
continue;
|
|
190
190
|
if (!Array.isArray(message.content))
|
|
191
191
|
continue;
|
|
192
|
+
const reasoning = message.content
|
|
193
|
+
.filter((part) => part.type === 'reasoning' && part.text?.trim())
|
|
194
|
+
.map((part) => part.text.trim())
|
|
195
|
+
.join('\n');
|
|
192
196
|
for (const part of message.content) {
|
|
193
197
|
if (part.type !== 'tool-call')
|
|
194
198
|
continue;
|
|
195
|
-
toolCalls.set(part.toolCallId, part.input);
|
|
199
|
+
toolCalls.set(part.toolCallId, { input: part.input, reasoning });
|
|
196
200
|
}
|
|
197
201
|
}
|
|
198
202
|
const executions = [];
|
|
@@ -206,7 +210,8 @@ export class Conversation {
|
|
|
206
210
|
continue;
|
|
207
211
|
if (part.toolName === NARRATION_TOOL)
|
|
208
212
|
continue;
|
|
209
|
-
|
|
213
|
+
const call = toolCalls.get(part.toolCallId);
|
|
214
|
+
executions.push(toToolExecution(part.toolName, call?.input || {}, part.output, call?.reasoning));
|
|
210
215
|
}
|
|
211
216
|
}
|
|
212
217
|
return executions;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ActionResult } from '../action-result.js';
|
|
2
2
|
import type Action from '../action.js';
|
|
3
|
+
import type { ExecutedStep } from '../action.js';
|
|
3
4
|
import type { ExplorbotConfig } from '../config.js';
|
|
4
5
|
import type { ExperienceTracker } from '../experience-tracker.js';
|
|
5
6
|
import Explorer from '../explorer.js';
|
|
@@ -16,6 +17,7 @@ declare class Navigator implements Agent {
|
|
|
16
17
|
hooksRunner: HooksRunner;
|
|
17
18
|
MAX_ATTEMPTS: number;
|
|
18
19
|
lastFailureReason: string | null;
|
|
20
|
+
executedSteps: ExecutedStep[];
|
|
19
21
|
systemPrompt: string;
|
|
20
22
|
freeSailSystemPrompt: string;
|
|
21
23
|
explorer: Explorer;
|
|
@@ -55,6 +57,7 @@ declare class Navigator implements Agent {
|
|
|
55
57
|
freshState: ActionResult;
|
|
56
58
|
urlMatches: boolean;
|
|
57
59
|
}>;
|
|
60
|
+
targetUrlReached(action: Action, expectedUrl: string, state: ActionResult): boolean;
|
|
58
61
|
ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null>;
|
|
59
62
|
saveFlow(message: string, expectedUrl: string | undefined, actionResult: ActionResult, progressBlocks: string[]): void;
|
|
60
63
|
rescueDelayedRedirect(action: Action, expectedUrl: string): Promise<boolean>;
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -2,8 +2,8 @@ import { tool } from 'ai';
|
|
|
2
2
|
import dedent from 'dedent';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ActionResult } from '../action-result.js';
|
|
5
|
-
import { normalizeUrl } from '../state-manager.js';
|
|
6
5
|
import { renderAssertion } from "../playwright-recorder.js";
|
|
6
|
+
import { normalizeUrl } from '../state-manager.js';
|
|
7
7
|
import { isFatalBrowserError } from "../utils/browser-errors.js";
|
|
8
8
|
import { getCliName } from "../utils/cli-name.js";
|
|
9
9
|
import { extractCodeBlocks } from '../utils/code-extractor.js';
|
|
@@ -26,6 +26,7 @@ class Navigator {
|
|
|
26
26
|
hooksRunner;
|
|
27
27
|
MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
|
|
28
28
|
lastFailureReason = null;
|
|
29
|
+
executedSteps = [];
|
|
29
30
|
systemPrompt = dedent `
|
|
30
31
|
<role>
|
|
31
32
|
You are senior test automation engineer with master QA skills.
|
|
@@ -194,10 +195,15 @@ class Navigator {
|
|
|
194
195
|
if (!this.provider)
|
|
195
196
|
throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
196
197
|
this.lastFailureReason = null;
|
|
198
|
+
this.executedSteps = [];
|
|
197
199
|
tag('info').log('AI Navigator resolving state at', actionResult.url);
|
|
198
200
|
debugLog('Resolution message:', message);
|
|
199
201
|
const action = opts?.action ?? this.explorer.action();
|
|
200
202
|
const expectedUrl = opts?.expectedUrl;
|
|
203
|
+
if (expectedUrl && this.targetUrlReached(action, expectedUrl, actionResult)) {
|
|
204
|
+
tag('success').log(`Already at ${expectedUrl} — navigation resolved`);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
201
207
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
202
208
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
203
209
|
conversation.addUserText(await this.buildResolutionPrompt(message, actionResult, opts?.experience));
|
|
@@ -278,14 +284,20 @@ class Navigator {
|
|
|
278
284
|
const freshHash = check.freshState.getStateHash();
|
|
279
285
|
resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
|
|
280
286
|
if (!resolved && attempt.ok) {
|
|
281
|
-
|
|
287
|
+
if (check.urlMatches) {
|
|
288
|
+
lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
|
|
289
|
+
tag('warning').log(`Page state did not change at ${check.freshState.url}`);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
|
|
293
|
+
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
294
|
+
}
|
|
282
295
|
batchFailures.push({
|
|
283
296
|
code: codeBlock,
|
|
284
297
|
error: lastFailure,
|
|
285
298
|
ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
|
|
286
299
|
urlAfter: check.freshState.url,
|
|
287
300
|
});
|
|
288
|
-
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
289
301
|
}
|
|
290
302
|
if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
|
|
291
303
|
progressBlocks.push(codeBlock);
|
|
@@ -411,6 +423,7 @@ class Navigator {
|
|
|
411
423
|
await action.exitIframe();
|
|
412
424
|
debugLog(`Attempting resolution: ${codeBlock}`);
|
|
413
425
|
const ok = await action.attempt(codeBlock, message);
|
|
426
|
+
this.executedSteps.push(...action.executedSteps);
|
|
414
427
|
const page = action.playwrightHelper?.page;
|
|
415
428
|
if (page) {
|
|
416
429
|
try {
|
|
@@ -437,9 +450,12 @@ class Navigator {
|
|
|
437
450
|
}
|
|
438
451
|
}
|
|
439
452
|
const freshState = await this.explorer.capture();
|
|
440
|
-
const urlMatches = this.
|
|
453
|
+
const urlMatches = this.targetUrlReached(action, expectedUrl, freshState);
|
|
441
454
|
return { freshState, urlMatches };
|
|
442
455
|
}
|
|
456
|
+
targetUrlReached(action, expectedUrl, state) {
|
|
457
|
+
return this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(state, expectedUrl));
|
|
458
|
+
}
|
|
443
459
|
async ariaDiff(freshState, previous) {
|
|
444
460
|
if (freshState.getStateHash() === previous.getStateHash())
|
|
445
461
|
return null;
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -16,6 +16,7 @@ import { withdrawVisionTools } from "./tools.js";
|
|
|
16
16
|
const CHECK_TOOLS = ['verify', 'see', 'research'];
|
|
17
17
|
const EVIDENCE_TOOLS = ['verify', 'see'];
|
|
18
18
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
19
|
+
const PILOT_REASONING_LIMIT = 500;
|
|
19
20
|
const PILOT_MESSAGE_LIMIT = 2;
|
|
20
21
|
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
21
22
|
export class Pilot {
|
|
@@ -941,6 +942,12 @@ export class Pilot {
|
|
|
941
942
|
line += `\n result: ${resultMessage}`;
|
|
942
943
|
if (errorDetail && errorDetail !== resultMessage)
|
|
943
944
|
line += `\n error: ${errorDetail}`;
|
|
945
|
+
if (!t.wasSuccessful && t.reasoning) {
|
|
946
|
+
let rationale = t.reasoning;
|
|
947
|
+
if (rationale.length > PILOT_REASONING_LIMIT)
|
|
948
|
+
rationale = `...${rationale.slice(-PILOT_REASONING_LIMIT)}`;
|
|
949
|
+
line += `\n tester reasoned: ${rationale.replace(/\n+/g, ' ')}`;
|
|
950
|
+
}
|
|
944
951
|
const attempts = t.output?.attempts;
|
|
945
952
|
if (attempts && attempts.length > 1 && t.wasSuccessful) {
|
|
946
953
|
const failedBefore = attempts.filter((a) => !a.success);
|
|
@@ -998,6 +1005,8 @@ export class Pilot {
|
|
|
998
1005
|
state), instruct Tester to verify() and finish(). If goal was already true at the start, propose
|
|
999
1006
|
different input data so the test is meaningful. If Tester repeats the same successful action, STOP.
|
|
1000
1007
|
|
|
1008
|
+
If needed you should pick the exact item the scenario should act on (from the page, or precondition() one) and pass it to tester
|
|
1009
|
+
|
|
1001
1010
|
Action classification: GOAL-ADVANCING actions mutate the scenario's subject data (create/edit/delete/submit/verify).
|
|
1002
1011
|
VIEW-ONLY actions toggle filters/tabs/sort/collapse without changing data. One VIEW-ONLY to reveal a
|
|
1003
1012
|
target is fine; ≥2 consecutive VIEW-ONLY actions with no GOAL-ADVANCING action in between is thrashing
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -24,7 +24,7 @@ const TasksSchema = z.object({
|
|
|
24
24
|
planName: z.string().describe('Short descriptive name for the test plan (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")'),
|
|
25
25
|
scenarios: z
|
|
26
26
|
.array(z.object({
|
|
27
|
-
scenario: z.string().describe('A single sentence describing
|
|
27
|
+
scenario: z.string().describe('A single sentence describing the behavior to test.'),
|
|
28
28
|
priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
|
|
29
29
|
startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL. Use only stable feature/list/detail pages, not transient create/edit/modal URLs unless the scenario specifically starts inside that form.'),
|
|
30
30
|
steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -2,7 +2,8 @@ import { OpenTelemetry } from '@ai-sdk/otel';
|
|
|
2
2
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
3
3
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
4
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
-
import
|
|
5
|
+
import dedent from 'dedent';
|
|
6
|
+
import { APICallError, generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
|
|
6
7
|
import { z } from 'zod';
|
|
7
8
|
import { clearActivity, setActivity } from "../activity.js";
|
|
8
9
|
import { configuredModels, modelName as getModelName } from '../config.js';
|
|
@@ -382,9 +383,18 @@ export class Provider {
|
|
|
382
383
|
if (extraStop)
|
|
383
384
|
stopConditions.push(extraStop);
|
|
384
385
|
const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
386
|
+
let attemptMessages = messages;
|
|
387
|
+
let invalidRequestFeedbackAdded = false;
|
|
385
388
|
try {
|
|
386
389
|
const response = await this.withModelRequestSlot(() => withRetry(async () => {
|
|
387
|
-
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000))
|
|
390
|
+
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal }), config.timeout || 30000).catch((error) => {
|
|
391
|
+
if (!invalidRequestFeedbackAdded) {
|
|
392
|
+
const amended = withInvalidRequestFeedback(attemptMessages, error);
|
|
393
|
+
invalidRequestFeedbackAdded = amended !== attemptMessages;
|
|
394
|
+
attemptMessages = amended;
|
|
395
|
+
}
|
|
396
|
+
throw error;
|
|
397
|
+
}));
|
|
388
398
|
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
389
399
|
const hasToolCall = (result.toolCalls?.length || 0) > 0;
|
|
390
400
|
if (!result.text && !hasToolCall && result.finishReason === 'length') {
|
|
@@ -620,6 +630,22 @@ function repairToolCall(options) {
|
|
|
620
630
|
return repairChannelMarker(options);
|
|
621
631
|
return repairHarmonyChannel(options);
|
|
622
632
|
}
|
|
633
|
+
function withInvalidRequestFeedback(messages, error) {
|
|
634
|
+
if (!(error instanceof APICallError) || error.statusCode !== 400)
|
|
635
|
+
return messages;
|
|
636
|
+
tag('warning').log('Provider rejected the request as invalid — relaying its reason before the retry');
|
|
637
|
+
return [
|
|
638
|
+
...messages,
|
|
639
|
+
{
|
|
640
|
+
role: 'user',
|
|
641
|
+
content: dedent `
|
|
642
|
+
The previous request was rejected by the provider as invalid:
|
|
643
|
+
"${error.message}"
|
|
644
|
+
Fix what it describes and re-issue the request.
|
|
645
|
+
`,
|
|
646
|
+
},
|
|
647
|
+
];
|
|
648
|
+
}
|
|
623
649
|
function repairChannelMarker({ toolCall, tools }) {
|
|
624
650
|
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
625
651
|
if (markerIndex <= 0)
|
|
@@ -5,10 +5,12 @@ import { diffAriaSnapshots } from "../../utils/aria.js";
|
|
|
5
5
|
import { extractCodeBlocks } from "../../utils/code-extractor.js";
|
|
6
6
|
import { tag } from '../../utils/logger.js';
|
|
7
7
|
import { mdq } from "../../utils/markdown-query.js";
|
|
8
|
+
import { truncate } from "../../utils/strings.js";
|
|
8
9
|
import { getCachedResearch, getPreviousResearch, saveResearch } from "./cache.js";
|
|
9
10
|
import { debugLog } from "./mixin.js";
|
|
10
11
|
import { parseResearchSections } from "./parser.js";
|
|
11
12
|
const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
|
|
13
|
+
const MAX_HTML_DIFF_CHARS = 20_000;
|
|
12
14
|
export function WithDeepAnalysis(Base) {
|
|
13
15
|
return class extends Base {
|
|
14
16
|
async performDeepAnalysis(state, result) {
|
|
@@ -430,6 +432,8 @@ export function WithDeepAnalysis(Base) {
|
|
|
430
432
|
|
|
431
433
|
`;
|
|
432
434
|
}
|
|
435
|
+
const cleanedParts = await diff.cleanedHtmlParts();
|
|
436
|
+
const htmlChanges = truncate(cleanedParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n'), MAX_HTML_DIFF_CHARS);
|
|
433
437
|
const prompt = dedent `
|
|
434
438
|
${intro}
|
|
435
439
|
Analyze the changes and produce a UI map section.
|
|
@@ -438,7 +442,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
438
442
|
${diff.ariaChanged || 'none'}
|
|
439
443
|
|
|
440
444
|
HTML changes:
|
|
441
|
-
${
|
|
445
|
+
${htmlChanges || 'none'}
|
|
442
446
|
${alreadyHint}
|
|
443
447
|
|
|
444
448
|
Respond with a SINGLE section in this format:
|
|
@@ -93,7 +93,6 @@ export function WithSections(Base) {
|
|
|
93
93
|
- Do not copy global toolbar, navigation, list, or detail elements into this section unless they are descendants of this section container.
|
|
94
94
|
- Every element with eidx inside this section's container MUST appear in the table.
|
|
95
95
|
- Every row needs CSS; ARIA may be "-" for icon-only buttons.
|
|
96
|
-
- ARIA locator JSON uses keys "role" and "text" (NOT "name").
|
|
97
96
|
- Elements marked data-explorbot-hit="covered" or "offscreen" are not directly actionable; describe the covering or focused UI first.
|
|
98
97
|
- In split-pane pages, entity detail panels are active detail context; include close/back/pin controls in the detail panel section when present.
|
|
99
98
|
</rules>
|
|
@@ -345,7 +345,6 @@ export class Researcher extends ResearcherBase {
|
|
|
345
345
|
- If an element has data-explorbot-hit="covered" or "offscreen", do not present it as directly actionable. Prefer the overlay, drawer, dialog, or focused section covering it, and mention what must be dismissed or revealed first.
|
|
346
346
|
- Every element with an eidx attribute MUST appear in exactly one matching UI map section — describe icon-only buttons by their visual role.
|
|
347
347
|
- Every UI map row needs a CSS selector; ARIA may be "-" for icon-only buttons, CSS must never be "-".
|
|
348
|
-
- ARIA locator JSON uses keys "role" and "text" (NOT "name").
|
|
349
348
|
- Mark elements with likely hover interactions (title, aria-describedby, menu items with submenus) as "(hover)".
|
|
350
349
|
</rules>
|
|
351
350
|
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -67,7 +67,6 @@ const locatorStrategyRule = dedent `
|
|
|
67
67
|
|
|
68
68
|
<bad_aria_locator_example>
|
|
69
69
|
{ "role": "button", "text": "" } // INVALID - empty text is useless, use "-" instead
|
|
70
|
-
{ "role": "button", "name": "Save" } // WRONG key - use "text", not "name"
|
|
71
70
|
</bad_aria_locator_example>
|
|
72
71
|
|
|
73
72
|
NEVER include \`eidx\` attribute in any locator (ARIA, CSS, XPath). It is an internal annotation.
|
package/dist/src/ai/tester.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export declare class Tester extends TaskAgent implements Agent {
|
|
|
33
33
|
lastAnalyzedStateHash: string | null;
|
|
34
34
|
stalledIterations: number;
|
|
35
35
|
readonly MAX_STALLED_ITERATIONS = 3;
|
|
36
|
+
skipResearch: (err: Error) => string;
|
|
36
37
|
constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
|
|
37
38
|
getNavigator(): Navigator;
|
|
38
39
|
setPilot(pilot: Pilot): void;
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -51,6 +51,12 @@ export class Tester extends TaskAgent {
|
|
|
51
51
|
lastAnalyzedStateHash = null;
|
|
52
52
|
stalledIterations = 0;
|
|
53
53
|
MAX_STALLED_ITERATIONS = 3;
|
|
54
|
+
skipResearch = (err) => {
|
|
55
|
+
if (err.name === 'AbortError')
|
|
56
|
+
throw err;
|
|
57
|
+
tag('warning').log(`Research skipped: ${err.message}`);
|
|
58
|
+
return '';
|
|
59
|
+
};
|
|
54
60
|
constructor(deps, researcher, navigator, agentTools) {
|
|
55
61
|
super(deps);
|
|
56
62
|
this.requestStore = deps.requestStore;
|
|
@@ -512,14 +518,7 @@ export class Tester extends TaskAgent {
|
|
|
512
518
|
const alreadySeenUiMap = this.seenUiMapUrls.has(currentUrl);
|
|
513
519
|
let research = '';
|
|
514
520
|
if (!alreadySeenUiMap) {
|
|
515
|
-
|
|
516
|
-
research = await this.researcher.research(currentState);
|
|
517
|
-
}
|
|
518
|
-
catch (err) {
|
|
519
|
-
if (!(err instanceof ErrorPageError))
|
|
520
|
-
throw err;
|
|
521
|
-
tag('warning').log(`Research skipped: ${err.message}`);
|
|
522
|
-
}
|
|
521
|
+
research = await this.researcher.research(currentState).catch(this.skipResearch);
|
|
523
522
|
}
|
|
524
523
|
this.pageStateHash = currentStateHash;
|
|
525
524
|
this.pageActionResult = currentState;
|
|
@@ -559,7 +558,7 @@ export class Tester extends TaskAgent {
|
|
|
559
558
|
return context;
|
|
560
559
|
}
|
|
561
560
|
if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
|
|
562
|
-
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
|
|
561
|
+
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
|
|
563
562
|
if (overlaySection) {
|
|
564
563
|
context += dedent `
|
|
565
564
|
|
package/dist/src/ai/tools.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ExecutedStep } from '../action.js';
|
|
1
2
|
import { ActionResult, type PageDiff } from '../action-result.js';
|
|
2
3
|
import { type ExperienceTracker } from '../experience-tracker.js';
|
|
3
4
|
import { type Task } from '../test-plan.js';
|
|
@@ -62,6 +63,7 @@ export declare function successToolResult(action: string, data?: Record<string,
|
|
|
62
63
|
}): Record<string, any>;
|
|
63
64
|
export declare function isMajorPageChange(pageDiff: PageDiff): boolean;
|
|
64
65
|
export declare function hasFailedRequest(pageDiff: PageDiff): boolean;
|
|
66
|
+
export declare function formatExecutedSteps(steps: ExecutedStep[], requestedCount?: number): string;
|
|
65
67
|
export declare function failedToolResult(action: string, message: string, data?: Record<string, any>, error?: Error | null): Promise<Record<string, any>>;
|
|
66
68
|
export declare function withdrawVisionTools(tools: Record<string, any>): void;
|
|
67
69
|
export declare function clickFailureSuggestion(attempts: Array<{
|