explorbot 0.1.0 → 0.1.1
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 +93 -36
- package/dist/bin/explorbot-cli.js +71 -16
- package/dist/rules/rerunner/healing-approach.md +19 -0
- package/dist/src/action.js +8 -10
- package/dist/src/ai/historian.js +34 -3
- package/dist/src/ai/navigator.js +35 -28
- package/dist/src/ai/pilot.js +33 -9
- package/dist/src/ai/planner.js +29 -10
- package/dist/src/ai/rerunner.js +472 -0
- package/dist/src/ai/researcher.js +3 -4
- package/dist/src/ai/rules.js +2 -2
- package/dist/src/ai/tools.js +2 -2
- package/dist/src/commands/add-rule-command.js +1 -2
- package/dist/src/commands/base-command.js +12 -0
- package/dist/src/commands/context-command.js +12 -5
- package/dist/src/commands/drill-command.js +0 -1
- package/dist/src/commands/explore-command.js +20 -5
- package/dist/src/commands/freesail-command.js +8 -22
- package/dist/src/commands/index.js +4 -0
- package/dist/src/commands/init-command.js +3 -3
- package/dist/src/commands/path-command.js +2 -1
- package/dist/src/commands/plan-command.js +37 -15
- package/dist/src/commands/rerun-command.js +42 -0
- package/dist/src/commands/research-command.js +10 -4
- package/dist/src/commands/runs-command.js +22 -0
- package/dist/src/commands/start-command.js +0 -1
- package/dist/src/commands/test-command.js +3 -3
- package/dist/src/components/App.js +8 -0
- package/dist/src/config.js +3 -0
- package/dist/src/explorbot.js +19 -0
- package/dist/src/explorer.js +2 -1
- package/dist/src/suite.js +115 -0
- package/dist/src/utils/html.js +2 -5
- package/dist/src/utils/rules-loader.js +33 -17
- package/dist/src/utils/test-files.js +103 -0
- package/package.json +2 -1
- package/rules/rerunner/healing-approach.md +19 -0
- package/src/action.ts +7 -9
- package/src/ai/historian.ts +37 -3
- package/src/ai/navigator.ts +35 -28
- package/src/ai/pilot.ts +33 -9
- package/src/ai/planner.ts +28 -9
- package/src/ai/rerunner.ts +532 -0
- package/src/ai/researcher.ts +3 -4
- package/src/ai/rules.ts +2 -2
- package/src/ai/tools.ts +2 -2
- package/src/commands/add-rule-command.ts +1 -2
- package/src/commands/base-command.ts +13 -0
- package/src/commands/context-command.ts +12 -5
- package/src/commands/drill-command.ts +0 -1
- package/src/commands/explore-command.ts +21 -5
- package/src/commands/freesail-command.ts +6 -23
- package/src/commands/index.ts +4 -0
- package/src/commands/init-command.ts +3 -3
- package/src/commands/path-command.ts +2 -1
- package/src/commands/plan-command.ts +45 -16
- package/src/commands/rerun-command.ts +46 -0
- package/src/commands/research-command.ts +10 -4
- package/src/commands/runs-command.ts +27 -0
- package/src/commands/start-command.ts +0 -1
- package/src/commands/test-command.ts +3 -3
- package/src/components/App.tsx +8 -0
- package/src/config.ts +23 -0
- package/src/explorbot.ts +21 -0
- package/src/explorer.ts +3 -2
- package/src/suite.ts +135 -0
- package/src/utils/html.ts +1 -5
- package/src/utils/rules-loader.ts +35 -17
- package/src/utils/test-files.ts +122 -0
package/dist/src/ai/pilot.js
CHANGED
|
@@ -222,9 +222,18 @@ export class Pilot {
|
|
|
222
222
|
|
|
223
223
|
Plan the test execution for this scenario.
|
|
224
224
|
|
|
225
|
-
FIRST:
|
|
226
|
-
|
|
227
|
-
|
|
225
|
+
FIRST: Decide if precondition() is needed.
|
|
226
|
+
|
|
227
|
+
Call precondition() WHEN:
|
|
228
|
+
- The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
|
|
229
|
+
- The scenario needs specific data clearly NOT on the current page (e.g., items with specific statuses for filtering)
|
|
230
|
+
|
|
231
|
+
SKIP precondition() WHEN:
|
|
232
|
+
- The scenario is "Create X" — the test itself creates the item
|
|
233
|
+
- The current page already shows the item the test will act on (check <state> and <page_summary>)
|
|
234
|
+
- The scenario tests navigation, UI behavior, or viewing — no data mutation needed
|
|
235
|
+
|
|
236
|
+
If needed, call precondition() now. If not, proceed directly to planning.
|
|
228
237
|
|
|
229
238
|
THEN: Based on the page elements and current state, outline:
|
|
230
239
|
1. Which elements to interact with and in what order
|
|
@@ -608,6 +617,8 @@ export class Pilot {
|
|
|
608
617
|
- Click succeeded but ariaDiff shows elements unrelated to tester's intention (e.g., clicked "Edit" but dropdown appeared) → wrong button or unexpected behavior. Instruct Tester to Escape and try a different approach.
|
|
609
618
|
- form(I.type()) succeeded → I.type() sends keys to whatever is focused, no guarantee it's the right field. Instruct Tester to verify with see() that text appeared in the correct field. If targetedHtml shows a button/link, text went to wrong element — click the correct field first and retry.
|
|
610
619
|
- ariaDiff shows 5+ elements removed/added after clicking content → page entered a different mode (editor, panel, modal). Instruct Tester to call context() to see current state before guessing selectors.
|
|
620
|
+
- Dropdown/select opened but contains NO options, or a list/table is empty when items were expected → data doesn't exist yet. Call precondition() to create the missing items (labels, categories, etc.), then instruct Tester to retry.
|
|
621
|
+
- Tester tries to select/filter/assign something but the option list is empty or expected value is not present → missing auxiliary data. Call precondition() to create it.
|
|
611
622
|
|
|
612
623
|
Detecting logically wrong successes — review "executed", "element", and "skipped" fields:
|
|
613
624
|
- Click SUCCESS but "executed" command differs from "explanation" intent → wrong element was clicked. The intended element wasn't found and a different one was clicked instead.
|
|
@@ -657,23 +668,36 @@ export class Pilot {
|
|
|
657
668
|
YOUR tools (Pilot-only):
|
|
658
669
|
- precondition(description) — create FRESH test data via API that the test will act on. Do NOT request users.
|
|
659
670
|
|
|
660
|
-
PRECONDITIONS — what to create:
|
|
671
|
+
PRECONDITIONS — when and what to create:
|
|
661
672
|
Preconditions create NEW disposable items that the test will modify, delete, or interact with.
|
|
662
|
-
Do NOT describe what already exists on the page — describe what NEW data the test needs to act on.
|
|
663
673
|
|
|
664
674
|
Ask yourself: "What object will this test change/delete/use? Create THAT."
|
|
665
675
|
|
|
666
|
-
|
|
676
|
+
When to call precondition():
|
|
677
|
+
- Scenario edits/deletes/modifies an item → create a disposable target
|
|
678
|
+
- Scenario needs auxiliary data (labels, categories, statuses to filter by)
|
|
679
|
+
- Tester failed because required data is missing (empty dropdown, no items to select)
|
|
680
|
+
|
|
681
|
+
When to SKIP precondition():
|
|
682
|
+
- Scenario is "Create X" — the test itself creates the item, no precondition needed
|
|
683
|
+
- Current page already shows the exact data needed (check <state> h1/title and <page_summary>)
|
|
684
|
+
- Scenario tests navigation, search UI, or viewing — no data mutation involved
|
|
685
|
+
|
|
686
|
+
Examples — when to create:
|
|
667
687
|
- "Edit test description" → precondition("1 test") — the test will edit this item
|
|
668
688
|
- "Delete a comment" → precondition("1 comment") — the test will delete this item
|
|
669
689
|
- "Assign a label to item" → precondition("1 item and 1 label named Bug") — test assigns the label
|
|
670
690
|
- "Filter by status" → precondition("3 items: 2 with status Open, 1 with status Closed")
|
|
671
|
-
- "Move item between lists" → precondition("1 item in list A")
|
|
672
691
|
|
|
673
|
-
|
|
692
|
+
Examples — when to skip:
|
|
693
|
+
- "Create a new blog post" → SKIP, the test creates it
|
|
694
|
+
- "Edit blog post" while on a blog post page → SKIP, data already exists
|
|
695
|
+
- "View dashboard" → SKIP, no data mutation
|
|
696
|
+
|
|
697
|
+
WRONG: precondition("1 test suite named Updated Suite with existing tests") — describes the page, not what to create
|
|
674
698
|
RIGHT: precondition("1 test") — create a fresh test that the scenario will edit
|
|
675
699
|
|
|
676
|
-
|
|
700
|
+
Keep descriptions short and specific.
|
|
677
701
|
|
|
678
702
|
Response format:
|
|
679
703
|
PROGRESS: <1 sentence assessment>
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -18,6 +18,7 @@ import { WithSubPages, getPlannedByStateHash, getRegisteredPlan, registerPlan }
|
|
|
18
18
|
import { findSimilarStateHash } from "./researcher/cache.js";
|
|
19
19
|
import { hasFocusedSection } from "./researcher/focus.js";
|
|
20
20
|
import { POSSIBLE_SECTIONS, Researcher } from "./researcher.js";
|
|
21
|
+
import { Suite } from "../suite.js";
|
|
21
22
|
import { fileUploadRule, protectionRule } from "./rules.js";
|
|
22
23
|
const debugLog = createDebug('explorbot:planner');
|
|
23
24
|
const TasksSchema = z.object({
|
|
@@ -46,6 +47,7 @@ export class Planner extends PlannerBase {
|
|
|
46
47
|
currentPlan = null;
|
|
47
48
|
freshStart = false;
|
|
48
49
|
lastStyleName = '';
|
|
50
|
+
lastSuite = null;
|
|
49
51
|
researcher;
|
|
50
52
|
fisherman = null;
|
|
51
53
|
constructor(explorer, provider) {
|
|
@@ -173,6 +175,9 @@ export class Planner extends PlannerBase {
|
|
|
173
175
|
if (parentPlan)
|
|
174
176
|
this.currentPlan.parentPlan = parentPlan;
|
|
175
177
|
const allPreviousScenarios = this.getPreviousSessionScenarios();
|
|
178
|
+
const existingTestScenarios = this.getExistingTestFileScenarios(state.url);
|
|
179
|
+
for (const s of existingTestScenarios)
|
|
180
|
+
allPreviousScenarios.add(s);
|
|
176
181
|
for (const t of tests) {
|
|
177
182
|
if (allPreviousScenarios.has(t.scenario.toLowerCase()))
|
|
178
183
|
continue;
|
|
@@ -180,8 +185,6 @@ export class Planner extends PlannerBase {
|
|
|
180
185
|
t.startUrl = state.url;
|
|
181
186
|
this.currentPlan.addTest(t);
|
|
182
187
|
}
|
|
183
|
-
const summary = `Scenarios:\n${this.currentPlan.tests.map((t) => `- [${t.priority}] ${t.scenario}`).join('\n')}`;
|
|
184
|
-
tag('multiline').log(summary);
|
|
185
188
|
}
|
|
186
189
|
else {
|
|
187
190
|
tag('step').log(`Expanding plan: "${this.currentPlan.title}"`);
|
|
@@ -192,7 +195,6 @@ export class Planner extends PlannerBase {
|
|
|
192
195
|
tag('multiline').log(summary);
|
|
193
196
|
}
|
|
194
197
|
}
|
|
195
|
-
this.moveExecutedTestsToEnd();
|
|
196
198
|
const availableStyles = Object.keys(getStyles()).join(', ');
|
|
197
199
|
tag('success').log(`Planning complete! ${this.currentPlan.tests.length} tests in plan: ${this.currentPlan.title}`);
|
|
198
200
|
tag('info').log(`Planning style: ${this.lastStyleName} (available: ${availableStyles})`);
|
|
@@ -201,13 +203,8 @@ export class Planner extends PlannerBase {
|
|
|
201
203
|
this.registerPlanInSession(this.currentPlan);
|
|
202
204
|
return this.currentPlan;
|
|
203
205
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return;
|
|
207
|
-
const pending = this.currentPlan.tests.filter((t) => t.result === null);
|
|
208
|
-
const executed = this.currentPlan.tests.filter((t) => t.result !== null);
|
|
209
|
-
this.currentPlan.tests = [...pending, ...executed];
|
|
210
|
-
this.currentPlan.notifyChange();
|
|
206
|
+
getSuite() {
|
|
207
|
+
return this.lastSuite;
|
|
211
208
|
}
|
|
212
209
|
addNewTests(tests, defaultStartUrl) {
|
|
213
210
|
if (!this.currentPlan)
|
|
@@ -229,6 +226,18 @@ export class Planner extends PlannerBase {
|
|
|
229
226
|
}
|
|
230
227
|
return added;
|
|
231
228
|
}
|
|
229
|
+
getExistingTestFileScenarios(currentUrl) {
|
|
230
|
+
if (!currentUrl)
|
|
231
|
+
return new Set();
|
|
232
|
+
try {
|
|
233
|
+
this.lastSuite = new Suite(currentUrl);
|
|
234
|
+
return this.lastSuite.getActiveScenarioTitles();
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
debugLog('Failed to load existing test files: %s', err.message);
|
|
238
|
+
return new Set();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
232
241
|
cleanExperienceFlows(text) {
|
|
233
242
|
const seenTitles = new Set();
|
|
234
243
|
let result = text;
|
|
@@ -376,6 +385,16 @@ export class Planner extends PlannerBase {
|
|
|
376
385
|
`);
|
|
377
386
|
}
|
|
378
387
|
}
|
|
388
|
+
if (this.lastSuite && this.lastSuite.automatedTestCount > 0) {
|
|
389
|
+
const automatedNames = this.lastSuite.getAutomatedTestNames();
|
|
390
|
+
conversation.addUserText(dedent `
|
|
391
|
+
<existing_automated_tests>
|
|
392
|
+
The following ${automatedNames.length} tests are already implemented and automated for this URL.
|
|
393
|
+
Do not propose tests that duplicate these:
|
|
394
|
+
${automatedNames.map((n) => `- ${n}`).join('\n')}
|
|
395
|
+
</existing_automated_tests>
|
|
396
|
+
`);
|
|
397
|
+
}
|
|
379
398
|
if (this.currentPlan) {
|
|
380
399
|
tag('step').log('Analyzing current plan to expand testing');
|
|
381
400
|
const allTests = this.currentPlan.getAllTests();
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { relative, resolve } from 'node:path';
|
|
3
|
+
import { tool } from 'ai';
|
|
4
|
+
import { createBashTool } from 'bash-tool';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { highlight } from 'cli-highlight';
|
|
7
|
+
import * as codeceptjs from 'codeceptjs';
|
|
8
|
+
import heal from 'codeceptjs/lib/heal';
|
|
9
|
+
import aiTracePlugin from 'codeceptjs/lib/plugin/aiTrace';
|
|
10
|
+
import figureSet from 'figures';
|
|
11
|
+
import dedent from 'dedent';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { ActionResult } from "../action-result.js";
|
|
14
|
+
import { setActivity } from "../activity.js";
|
|
15
|
+
import { Stats } from "../stats.js";
|
|
16
|
+
import { Task, Test, TestResult } from "../test-plan.js";
|
|
17
|
+
import { createDebug, tag } from "../utils/logger.js";
|
|
18
|
+
import { loop } from "../utils/loop.js";
|
|
19
|
+
import { loadTestSuites, printTestList } from "../utils/test-files.js";
|
|
20
|
+
import { toolExecutionLabel } from "./conversation.js";
|
|
21
|
+
import { locatorRule, actionRule, sectionContextRule } from "./rules.js";
|
|
22
|
+
import { TaskAgent } from "./task-agent.js";
|
|
23
|
+
import { RulesLoader } from "../utils/rules-loader.js";
|
|
24
|
+
import { createCodeceptJSTools } from "./tools.js";
|
|
25
|
+
const debugLog = createDebug('explorbot:rerunner');
|
|
26
|
+
export class Rerunner extends TaskAgent {
|
|
27
|
+
ACTION_TOOLS = ['click', 'pressKey', 'form'];
|
|
28
|
+
emoji = '🔄';
|
|
29
|
+
explorer;
|
|
30
|
+
provider;
|
|
31
|
+
agentTools;
|
|
32
|
+
healedSteps = [];
|
|
33
|
+
traceDir = '';
|
|
34
|
+
constructor(explorer, provider, agentTools) {
|
|
35
|
+
super();
|
|
36
|
+
this.explorer = explorer;
|
|
37
|
+
this.provider = provider;
|
|
38
|
+
this.agentTools = agentTools;
|
|
39
|
+
}
|
|
40
|
+
getNavigator() {
|
|
41
|
+
throw new Error('Rerunner does not use Navigator');
|
|
42
|
+
}
|
|
43
|
+
getExperienceTracker() {
|
|
44
|
+
return this.explorer.getStateManager().getExperienceTracker();
|
|
45
|
+
}
|
|
46
|
+
getKnowledgeTracker() {
|
|
47
|
+
return this.explorer.getKnowledgeTracker();
|
|
48
|
+
}
|
|
49
|
+
getProvider() {
|
|
50
|
+
return this.provider;
|
|
51
|
+
}
|
|
52
|
+
get rerunnerConfig() {
|
|
53
|
+
return this.explorer.getConfig().ai?.agents?.rerunner || {};
|
|
54
|
+
}
|
|
55
|
+
get healLimit() {
|
|
56
|
+
return this.rerunnerConfig.healLimit ?? 3;
|
|
57
|
+
}
|
|
58
|
+
get healMaxIterations() {
|
|
59
|
+
return this.rerunnerConfig.healMaxIterations ?? 3;
|
|
60
|
+
}
|
|
61
|
+
listTests(testsDir) {
|
|
62
|
+
printTestList(loadTestSuites(testsDir));
|
|
63
|
+
}
|
|
64
|
+
async rerun(filePath, options) {
|
|
65
|
+
const absPath = resolve(filePath);
|
|
66
|
+
if (!existsSync(absPath)) {
|
|
67
|
+
tag('error').log(`Test file not found: ${absPath}`);
|
|
68
|
+
return { total: 0, passed: 0, failed: 0, healed: 0 };
|
|
69
|
+
}
|
|
70
|
+
tag('info').log(`Re-running tests from: ${relative(process.cwd(), absPath)}`);
|
|
71
|
+
setActivity('🔄 Re-running tests...', 'action');
|
|
72
|
+
this.healedSteps = [];
|
|
73
|
+
this.setupPlugins();
|
|
74
|
+
const testMap = new Map();
|
|
75
|
+
const results = [];
|
|
76
|
+
const onTestBefore = (mochaTest) => {
|
|
77
|
+
if (!mochaTest.file)
|
|
78
|
+
mochaTest.file = absPath;
|
|
79
|
+
const task = new Test(mochaTest.title, 'normal', [], '');
|
|
80
|
+
task.start();
|
|
81
|
+
testMap.set(mochaTest.id || mochaTest.title, task);
|
|
82
|
+
Stats.tests++;
|
|
83
|
+
console.log(`\n ${chalk.green(figureSet.pointer)} ${chalk.bold(mochaTest.title)}`);
|
|
84
|
+
};
|
|
85
|
+
const onStepStarted = (step) => {
|
|
86
|
+
if (!step.toCode)
|
|
87
|
+
return;
|
|
88
|
+
const code = highlight(step.toCode(), { language: 'javascript' });
|
|
89
|
+
console.log(chalk.dim(` ${code}`));
|
|
90
|
+
};
|
|
91
|
+
const onStepPassed = (step) => {
|
|
92
|
+
const task = this.getCurrentTask(testMap);
|
|
93
|
+
if (!task || !step.toCode)
|
|
94
|
+
return;
|
|
95
|
+
task.addStep(step.toCode(), step.duration, 'passed');
|
|
96
|
+
};
|
|
97
|
+
const onStepFailed = (step, error) => {
|
|
98
|
+
const task = this.getCurrentTask(testMap);
|
|
99
|
+
if (!task || !step.toCode)
|
|
100
|
+
return;
|
|
101
|
+
task.addStep(step.toCode(), step.duration, 'failed', error?.message);
|
|
102
|
+
console.log(chalk.red(` ${figureSet.cross} ${step.toCode()} — ${error?.message || 'failed'}`));
|
|
103
|
+
};
|
|
104
|
+
const onTestPassed = (mochaTest) => {
|
|
105
|
+
const task = testMap.get(mochaTest.id || mochaTest.title);
|
|
106
|
+
if (!task)
|
|
107
|
+
return;
|
|
108
|
+
task.finish(TestResult.PASSED);
|
|
109
|
+
results.push({ test: task, mochaState: 'passed' });
|
|
110
|
+
console.log(chalk.green(` ${figureSet.tick} passed`));
|
|
111
|
+
};
|
|
112
|
+
const onTestFailed = (mochaTest, error) => {
|
|
113
|
+
const task = testMap.get(mochaTest.id || mochaTest.title);
|
|
114
|
+
if (!task)
|
|
115
|
+
return;
|
|
116
|
+
task.addNote(`Failed: ${error?.message || 'unknown error'}`, TestResult.FAILED);
|
|
117
|
+
task.finish(TestResult.FAILED);
|
|
118
|
+
results.push({ test: task, mochaState: 'failed' });
|
|
119
|
+
console.log(chalk.red(` ${figureSet.cross} failed: ${error?.message || 'unknown'}`));
|
|
120
|
+
};
|
|
121
|
+
const { dispatcher } = codeceptjs.event;
|
|
122
|
+
dispatcher.on('test.before', onTestBefore);
|
|
123
|
+
dispatcher.on('step.start', onStepStarted);
|
|
124
|
+
dispatcher.on('step.passed', onStepPassed);
|
|
125
|
+
dispatcher.on('step.failed', onStepFailed);
|
|
126
|
+
dispatcher.on('test.passed', onTestPassed);
|
|
127
|
+
dispatcher.on('test.failed', onTestFailed);
|
|
128
|
+
try {
|
|
129
|
+
codeceptjs.container.createMocha();
|
|
130
|
+
const mocha = codeceptjs.container.mocha();
|
|
131
|
+
mocha.reporter(class {
|
|
132
|
+
});
|
|
133
|
+
mocha.files = [absPath];
|
|
134
|
+
mocha.loadFiles();
|
|
135
|
+
let testIndex = 0;
|
|
136
|
+
for (const suite of mocha.suite.suites || []) {
|
|
137
|
+
for (const test of suite.tests || []) {
|
|
138
|
+
if (test.pending) {
|
|
139
|
+
testIndex++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (options?.testIndices?.length && !options.testIndices.includes(testIndex)) {
|
|
143
|
+
test.pending = true;
|
|
144
|
+
testIndex++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!hasAssertions(test.body)) {
|
|
148
|
+
test.pending = true;
|
|
149
|
+
tag('substep').log(`Skipping: ${test.title} (no assertions)`);
|
|
150
|
+
}
|
|
151
|
+
testIndex++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
await new Promise((resolveRun) => {
|
|
155
|
+
mocha.run((failures) => {
|
|
156
|
+
debugLog('Mocha run finished with %d failures', failures);
|
|
157
|
+
resolveRun();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
tag('error').log(`Rerun error: ${error instanceof Error ? error.message : error}`);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
dispatcher.off('test.before', onTestBefore);
|
|
166
|
+
dispatcher.off('step.start', onStepStarted);
|
|
167
|
+
dispatcher.off('step.passed', onStepPassed);
|
|
168
|
+
dispatcher.off('step.failed', onStepFailed);
|
|
169
|
+
dispatcher.off('test.passed', onTestPassed);
|
|
170
|
+
dispatcher.off('test.failed', onTestFailed);
|
|
171
|
+
this.teardownHealing();
|
|
172
|
+
}
|
|
173
|
+
if (this.healedSteps.length > 0) {
|
|
174
|
+
this.getHistorian().rewriteScenarioInFile(absPath, this.healedSteps);
|
|
175
|
+
tag('info').log(`Healed ${this.healedSteps.length} step(s), original file updated`);
|
|
176
|
+
}
|
|
177
|
+
const passed = results.filter((r) => r.mochaState === 'passed').length;
|
|
178
|
+
const failed = results.filter((r) => r.mochaState === 'failed').length;
|
|
179
|
+
const result = {
|
|
180
|
+
total: results.length,
|
|
181
|
+
passed,
|
|
182
|
+
failed,
|
|
183
|
+
healed: this.healedSteps.length,
|
|
184
|
+
};
|
|
185
|
+
this.printResults(result);
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
getCurrentTask(testMap) {
|
|
189
|
+
const entries = [...testMap.values()];
|
|
190
|
+
return entries[entries.length - 1];
|
|
191
|
+
}
|
|
192
|
+
setupPlugins() {
|
|
193
|
+
const healMod = heal.default || heal;
|
|
194
|
+
healMod.connectToEvents();
|
|
195
|
+
healMod.addRecipe('explorbot-ai-healer', {
|
|
196
|
+
priority: 10,
|
|
197
|
+
fn: async (context) => {
|
|
198
|
+
return this.healStep(context.step, context.error);
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const userRecipes = (this.rerunnerConfig.recipes || {});
|
|
202
|
+
for (const [name, recipe] of Object.entries(userRecipes)) {
|
|
203
|
+
healMod.addRecipe(name, recipe);
|
|
204
|
+
}
|
|
205
|
+
let currentTest = null;
|
|
206
|
+
let healTries = 0;
|
|
207
|
+
let isHealing = false;
|
|
208
|
+
let caughtError = null;
|
|
209
|
+
const healLimit = this.healLimit;
|
|
210
|
+
codeceptjs.event.dispatcher.on('test.before', (test) => {
|
|
211
|
+
currentTest = test;
|
|
212
|
+
healTries = 0;
|
|
213
|
+
caughtError = null;
|
|
214
|
+
});
|
|
215
|
+
codeceptjs.event.dispatcher.on('step.after', (step) => {
|
|
216
|
+
if (isHealing)
|
|
217
|
+
return;
|
|
218
|
+
if (healTries >= healLimit)
|
|
219
|
+
return;
|
|
220
|
+
if (!healMod.hasCorrespondingRecipes(step))
|
|
221
|
+
return;
|
|
222
|
+
codeceptjs.recorder.catchWithoutStop(async (err) => {
|
|
223
|
+
isHealing = true;
|
|
224
|
+
if (caughtError === err)
|
|
225
|
+
throw err;
|
|
226
|
+
caughtError = err;
|
|
227
|
+
codeceptjs.recorder.session.start('heal');
|
|
228
|
+
debugLog('Healing started for: %s', step.toCode());
|
|
229
|
+
await healMod.healStep(step, err, { test: currentTest });
|
|
230
|
+
healTries++;
|
|
231
|
+
codeceptjs.recorder.add('close healing session', () => {
|
|
232
|
+
codeceptjs.recorder.reset();
|
|
233
|
+
codeceptjs.recorder.session.restore('heal');
|
|
234
|
+
codeceptjs.recorder.ignoreErr(err);
|
|
235
|
+
});
|
|
236
|
+
await codeceptjs.recorder.promise();
|
|
237
|
+
isHealing = false;
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
global.container = codeceptjs.container;
|
|
241
|
+
codeceptjs.recorder.retry({
|
|
242
|
+
retries: 3,
|
|
243
|
+
when: (err) => {
|
|
244
|
+
if (!err?.message)
|
|
245
|
+
return false;
|
|
246
|
+
return err.message.includes('was not found') || err.message.includes('Timeout') || err.message.includes('exceeded');
|
|
247
|
+
},
|
|
248
|
+
minTimeout: 2000,
|
|
249
|
+
maxTimeout: 5000,
|
|
250
|
+
factor: 1.5,
|
|
251
|
+
});
|
|
252
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
253
|
+
const outputDir = global.output_dir || 'output';
|
|
254
|
+
this.traceDir = `${outputDir}/rerun_${timestamp}`;
|
|
255
|
+
const aiTrace = aiTracePlugin.default || aiTracePlugin;
|
|
256
|
+
aiTrace(this.rerunnerConfig.aiTrace || { output: this.traceDir });
|
|
257
|
+
import('@testomatio/reporter/codecept')
|
|
258
|
+
.then((mod) => {
|
|
259
|
+
const plugin = mod.default || mod;
|
|
260
|
+
plugin({ enabled: true });
|
|
261
|
+
})
|
|
262
|
+
.catch(() => debugLog('Testomatio reporter plugin not available'));
|
|
263
|
+
}
|
|
264
|
+
teardownHealing() {
|
|
265
|
+
const healMod = heal.default || heal;
|
|
266
|
+
healMod.recipes['explorbot-ai-healer'] = undefined;
|
|
267
|
+
for (const name of Object.keys(this.rerunnerConfig.recipes || {})) {
|
|
268
|
+
healMod.recipes[name] = undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async healStep(step, error) {
|
|
272
|
+
const failedCode = step.toCode?.() || '';
|
|
273
|
+
console.log(chalk.yellow(` ${figureSet.arrowRight} Healing: ${failedCode}`));
|
|
274
|
+
return async ({ I }) => {
|
|
275
|
+
const bashTool = await createBashTool({
|
|
276
|
+
destination: this.traceDir,
|
|
277
|
+
onBeforeBashCall: ({ command }) => {
|
|
278
|
+
if (/>[^>]|>>|\btee\b|\brm\b/.test(command)) {
|
|
279
|
+
return { command: 'echo "Read-only" >&2 && exit 1' };
|
|
280
|
+
}
|
|
281
|
+
return { command };
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const healTask = new Task(`Heal: ${failedCode}`);
|
|
285
|
+
const codeceptTools = createCodeceptJSTools(this.explorer, healTask);
|
|
286
|
+
let healed = false;
|
|
287
|
+
let healedCommand = '';
|
|
288
|
+
const tools = {
|
|
289
|
+
bash: bashTool.bash,
|
|
290
|
+
...codeceptTools,
|
|
291
|
+
...this.agentTools,
|
|
292
|
+
wait: tool({
|
|
293
|
+
description: 'Wait N seconds for page to load. Use when loading indicators are detected.',
|
|
294
|
+
inputSchema: z.object({
|
|
295
|
+
seconds: z.number().describe('Seconds to wait'),
|
|
296
|
+
note: z.string().optional().describe('What are you waiting for'),
|
|
297
|
+
}),
|
|
298
|
+
execute: async ({ seconds, note }) => {
|
|
299
|
+
if (note) {
|
|
300
|
+
healTask.addNote(note);
|
|
301
|
+
tag('substep').log(note);
|
|
302
|
+
}
|
|
303
|
+
const action = this.explorer.createAction();
|
|
304
|
+
await action.execute(`I.wait(${seconds})`);
|
|
305
|
+
const state = this.explorer.getStateManager().getCurrentState();
|
|
306
|
+
const ar = state ? ActionResult.fromState(state) : null;
|
|
307
|
+
return {
|
|
308
|
+
success: true,
|
|
309
|
+
message: `Waited ${seconds}s`,
|
|
310
|
+
url: state?.url,
|
|
311
|
+
title: state?.title,
|
|
312
|
+
aria: ar?.getInteractiveARIA(),
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
}),
|
|
316
|
+
done: tool({
|
|
317
|
+
description: 'Healing succeeded. Report the command that fixed the step.',
|
|
318
|
+
inputSchema: z.object({
|
|
319
|
+
healedCommand: z.string().describe('The CodeceptJS command that fixed the step'),
|
|
320
|
+
}),
|
|
321
|
+
execute: async ({ healedCommand: cmd }) => {
|
|
322
|
+
healed = true;
|
|
323
|
+
healedCommand = cmd;
|
|
324
|
+
return { success: true, healedCommand: cmd };
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
giveUp: tool({
|
|
328
|
+
description: 'Cannot heal. The issue is not fixable (missing data, page fundamentally different).',
|
|
329
|
+
inputSchema: z.object({
|
|
330
|
+
reason: z.string().describe('Why healing is not possible'),
|
|
331
|
+
}),
|
|
332
|
+
execute: async ({ reason }) => {
|
|
333
|
+
console.log(chalk.gray(` ${figureSet.line} Cannot heal: ${reason}`));
|
|
334
|
+
return { success: false, reason };
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
337
|
+
};
|
|
338
|
+
const conversation = this.provider.startConversation(this.getHealSystemPrompt(), 'rerunner');
|
|
339
|
+
conversation.addUserText(this.getHealUserPrompt(failedCode, error));
|
|
340
|
+
await loop(async ({ stop }) => {
|
|
341
|
+
if (healed) {
|
|
342
|
+
stop();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const result = await this.provider.invokeConversation(conversation, tools, {
|
|
346
|
+
maxToolRoundtrips: 5,
|
|
347
|
+
toolChoice: 'auto',
|
|
348
|
+
});
|
|
349
|
+
if (!result?.toolExecutions?.length) {
|
|
350
|
+
stop();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
for (const exec of result.toolExecutions) {
|
|
354
|
+
const icon = exec.wasSuccessful ? chalk.green(figureSet.tick) : chalk.red(figureSet.cross);
|
|
355
|
+
let label = toolExecutionLabel(exec.input) || exec.toolName;
|
|
356
|
+
if (exec.toolName === 'bash')
|
|
357
|
+
label = `bash [${this.traceDir}]: ${(exec.input?.command || '').substring(0, 100)}`;
|
|
358
|
+
tag('substep').log(`${icon} ${label}`);
|
|
359
|
+
if (exec.toolName === 'done') {
|
|
360
|
+
healed = true;
|
|
361
|
+
stop();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (exec.toolName === 'giveUp') {
|
|
365
|
+
stop();
|
|
366
|
+
throw new Error(exec.input?.reason || 'Healing aborted');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}, {
|
|
370
|
+
maxAttempts: this.healMaxIterations,
|
|
371
|
+
catch: async ({ error: err, stop }) => {
|
|
372
|
+
if (err.message?.includes('Healing aborted'))
|
|
373
|
+
throw err;
|
|
374
|
+
tag('warning').log(`Healing error: ${err.message}`);
|
|
375
|
+
stop();
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
if (!healed) {
|
|
379
|
+
throw new Error(`Could not heal: ${failedCode}`);
|
|
380
|
+
}
|
|
381
|
+
this.healedSteps.push({ test: '', original: failedCode, healed: healedCommand });
|
|
382
|
+
console.log(chalk.green(` ${figureSet.tick} Healed: ${healedCommand}`));
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
getHealSystemPrompt() {
|
|
386
|
+
const customRules = this.provider.getSystemPromptForAgent('rerunner', this.explorer.getStateManager().getCurrentState()?.url) || '';
|
|
387
|
+
const currentUrl = this.explorer.getStateManager().getCurrentState()?.url || '';
|
|
388
|
+
const approach = RulesLoader.loadRules('rerunner', ['healing-approach'], currentUrl);
|
|
389
|
+
return dedent `
|
|
390
|
+
<role>
|
|
391
|
+
You are a senior test automation engineer healing a failed CodeceptJS test step.
|
|
392
|
+
The failed step did NOT execute. You MUST perform the action it was supposed to do.
|
|
393
|
+
</role>
|
|
394
|
+
|
|
395
|
+
${approach}
|
|
396
|
+
|
|
397
|
+
<tools>
|
|
398
|
+
- You MUST execute the replacement action — not just diagnose
|
|
399
|
+
- Use click() for buttons, links — commands array is FALLBACK LOCATORS for the SAME element
|
|
400
|
+
- Use form() for text input, dropdown selection, file uploads
|
|
401
|
+
- Use pressKey() for special keys or key combinations
|
|
402
|
+
- Use wait() when page is loading — returns fresh ARIA automatically
|
|
403
|
+
- Use research() to understand page structure, sections, and available UI elements
|
|
404
|
+
- Use xpathCheck() to search large HTML when element can't be found in ARIA
|
|
405
|
+
- Use see() for visual verification when unsure
|
|
406
|
+
- Use context() to refresh ARIA/HTML after actions
|
|
407
|
+
- Use bash to read trace files (cat */trace.md, grep *_console.json, cat *_aria.txt)
|
|
408
|
+
</tools>
|
|
409
|
+
|
|
410
|
+
${locatorRule}
|
|
411
|
+
|
|
412
|
+
${actionRule}
|
|
413
|
+
|
|
414
|
+
${sectionContextRule}
|
|
415
|
+
|
|
416
|
+
${customRules}
|
|
417
|
+
`;
|
|
418
|
+
}
|
|
419
|
+
getHealUserPrompt(failedCode, error) {
|
|
420
|
+
const state = this.explorer.getStateManager().getCurrentState();
|
|
421
|
+
const actionResult = state ? ActionResult.fromState(state) : null;
|
|
422
|
+
const headings = [];
|
|
423
|
+
if (state?.h1)
|
|
424
|
+
headings.push(`H1: ${state.h1}`);
|
|
425
|
+
if (state?.h2)
|
|
426
|
+
headings.push(`H2: ${state.h2}`);
|
|
427
|
+
if (state?.h3)
|
|
428
|
+
headings.push(`H3: ${state.h3}`);
|
|
429
|
+
if (state?.h4)
|
|
430
|
+
headings.push(`H4: ${state.h4}`);
|
|
431
|
+
return dedent `
|
|
432
|
+
A test step failed and needs healing.
|
|
433
|
+
|
|
434
|
+
<failed_step>
|
|
435
|
+
Command: ${failedCode}
|
|
436
|
+
Error: ${error.message}
|
|
437
|
+
</failed_step>
|
|
438
|
+
|
|
439
|
+
<page>
|
|
440
|
+
URL: ${state?.url || 'unknown'}
|
|
441
|
+
Title: ${state?.title || 'unknown'}
|
|
442
|
+
${headings.join('\n')}
|
|
443
|
+
</page>
|
|
444
|
+
|
|
445
|
+
<page_aria>
|
|
446
|
+
${actionResult?.getInteractiveARIA() || 'No ARIA available'}
|
|
447
|
+
</page_aria>
|
|
448
|
+
|
|
449
|
+
Trace directory: ${this.traceDir}
|
|
450
|
+
|
|
451
|
+
Diagnose and fix the failed step. You MUST execute the replacement action.
|
|
452
|
+
`;
|
|
453
|
+
}
|
|
454
|
+
printResults(result) {
|
|
455
|
+
const parts = [];
|
|
456
|
+
if (result.passed > 0)
|
|
457
|
+
parts.push(`${result.passed} passed`);
|
|
458
|
+
if (result.failed > 0)
|
|
459
|
+
parts.push(`${result.failed} failed`);
|
|
460
|
+
if (result.healed > 0)
|
|
461
|
+
parts.push(`${result.healed} healed`);
|
|
462
|
+
console.log(`\n${chalk.bold(`${result.total}`)} tests — ${parts.join(', ')}`);
|
|
463
|
+
if (this.traceDir) {
|
|
464
|
+
console.log(chalk.gray(`Traces: ${this.traceDir}`));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function hasAssertions(body) {
|
|
469
|
+
if (!body)
|
|
470
|
+
return false;
|
|
471
|
+
return /I\.(see|dontSee|seeElement|dontSeeElement|seeInField|seeInSource|dontSeeInSource)\b/.test(body);
|
|
472
|
+
}
|
|
@@ -98,9 +98,9 @@ export class Researcher extends ResearcherBase {
|
|
|
98
98
|
setActivity(`${this.emoji} Researching...`, 'action');
|
|
99
99
|
await this.ensureNavigated(state.url, screenshot && this.provider.hasVision());
|
|
100
100
|
await this.hooksRunner.runBeforeHook('researcher', state.url);
|
|
101
|
-
const
|
|
101
|
+
const annotatedElements = await this.explorer.annotateElements();
|
|
102
102
|
debugLog(`Annotated ${annotatedElements.length} interactive elements with eidx`);
|
|
103
|
-
this.actionResult = await this.explorer.createAction().capturePageState({ includeScreenshot: screenshot && this.provider.hasVision()
|
|
103
|
+
this.actionResult = await this.explorer.createAction().capturePageState({ includeScreenshot: screenshot && this.provider.hasVision() });
|
|
104
104
|
if (isErrorPage(this.actionResult)) {
|
|
105
105
|
const recovered = await this.waitForPageLoad(screenshot);
|
|
106
106
|
if (!recovered) {
|
|
@@ -325,10 +325,9 @@ export class Researcher extends ResearcherBase {
|
|
|
325
325
|
return false;
|
|
326
326
|
try {
|
|
327
327
|
await withRetry(async () => {
|
|
328
|
-
|
|
328
|
+
await this.explorer.annotateElements();
|
|
329
329
|
this.actionResult = await this.explorer.createAction().capturePageState({
|
|
330
330
|
includeScreenshot: screenshot && this.provider.hasVision(),
|
|
331
|
-
ariaSnapshot,
|
|
332
331
|
});
|
|
333
332
|
if (isErrorPage(this.actionResult))
|
|
334
333
|
throw new Error('Error page detected');
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -257,7 +257,7 @@ export const actionRule = dedent `
|
|
|
257
257
|
I.fillField('Username', 'John', '.login-form'); // fills Username inside .login-form
|
|
258
258
|
I.fillField('Username', 'John'); // fills the field located by name or placeholder or label "Username" with the text "John"
|
|
259
259
|
I.fillField('//user/input', 'John'); // fills the field located by XPath "//user/input" with the text "John"
|
|
260
|
-
</example>
|
|
260
|
+
</example>
|
|
261
261
|
|
|
262
262
|
### I.type
|
|
263
263
|
|
|
@@ -294,7 +294,7 @@ export const actionRule = dedent `
|
|
|
294
294
|
</example>
|
|
295
295
|
|
|
296
296
|
IMPORTANT: Requires an active/focused element for most keys.
|
|
297
|
-
Commonly used after I.type() to submit forms or navigate dropdowns.
|
|
297
|
+
Commonly used after I.type() or I.fillField() to submit forms or navigate dropdowns.
|
|
298
298
|
|
|
299
299
|
### I.switchTo
|
|
300
300
|
|