explorbot 0.1.0 → 0.1.2

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.
Files changed (77) hide show
  1. package/bin/explorbot-cli.ts +93 -36
  2. package/dist/bin/explorbot-cli.js +71 -16
  3. package/dist/rules/rerunner/healing-approach.md +19 -0
  4. package/dist/src/action.js +8 -10
  5. package/dist/src/ai/historian.js +34 -3
  6. package/dist/src/ai/navigator.js +35 -28
  7. package/dist/src/ai/pilot.js +33 -9
  8. package/dist/src/ai/planner/session-dedup.js +3 -0
  9. package/dist/src/ai/planner/styles.js +3 -0
  10. package/dist/src/ai/planner.js +29 -10
  11. package/dist/src/ai/rerunner.js +472 -0
  12. package/dist/src/ai/researcher/cache.js +4 -3
  13. package/dist/src/ai/researcher/fingerprint-worker.js +7 -6
  14. package/dist/src/ai/researcher.js +3 -4
  15. package/dist/src/ai/rules.js +2 -2
  16. package/dist/src/ai/tools.js +2 -2
  17. package/dist/src/commands/add-rule-command.js +1 -2
  18. package/dist/src/commands/base-command.js +12 -0
  19. package/dist/src/commands/context-command.js +12 -5
  20. package/dist/src/commands/drill-command.js +0 -1
  21. package/dist/src/commands/explore-command.js +20 -5
  22. package/dist/src/commands/freesail-command.js +8 -22
  23. package/dist/src/commands/index.js +4 -0
  24. package/dist/src/commands/init-command.js +3 -3
  25. package/dist/src/commands/path-command.js +2 -1
  26. package/dist/src/commands/plan-command.js +37 -15
  27. package/dist/src/commands/rerun-command.js +42 -0
  28. package/dist/src/commands/research-command.js +10 -4
  29. package/dist/src/commands/runs-command.js +22 -0
  30. package/dist/src/commands/start-command.js +0 -1
  31. package/dist/src/commands/test-command.js +3 -3
  32. package/dist/src/components/App.js +8 -0
  33. package/dist/src/config.js +3 -0
  34. package/dist/src/explorbot.js +19 -0
  35. package/dist/src/explorer.js +2 -1
  36. package/dist/src/suite.js +115 -0
  37. package/dist/src/utils/html.js +2 -5
  38. package/dist/src/utils/rules-loader.js +33 -17
  39. package/dist/src/utils/test-files.js +103 -0
  40. package/package.json +3 -1
  41. package/rules/rerunner/healing-approach.md +19 -0
  42. package/src/action.ts +7 -9
  43. package/src/ai/historian.ts +37 -3
  44. package/src/ai/navigator.ts +35 -28
  45. package/src/ai/pilot.ts +33 -9
  46. package/src/ai/planner/session-dedup.ts +4 -0
  47. package/src/ai/planner/styles.ts +4 -0
  48. package/src/ai/planner.ts +28 -9
  49. package/src/ai/rerunner.ts +532 -0
  50. package/src/ai/researcher/cache.ts +4 -3
  51. package/src/ai/researcher/fingerprint-worker.ts +7 -13
  52. package/src/ai/researcher.ts +3 -4
  53. package/src/ai/rules.ts +2 -2
  54. package/src/ai/tools.ts +2 -2
  55. package/src/commands/add-rule-command.ts +1 -2
  56. package/src/commands/base-command.ts +13 -0
  57. package/src/commands/context-command.ts +12 -5
  58. package/src/commands/drill-command.ts +0 -1
  59. package/src/commands/explore-command.ts +21 -5
  60. package/src/commands/freesail-command.ts +6 -23
  61. package/src/commands/index.ts +4 -0
  62. package/src/commands/init-command.ts +3 -3
  63. package/src/commands/path-command.ts +2 -1
  64. package/src/commands/plan-command.ts +45 -16
  65. package/src/commands/rerun-command.ts +46 -0
  66. package/src/commands/research-command.ts +10 -4
  67. package/src/commands/runs-command.ts +27 -0
  68. package/src/commands/start-command.ts +0 -1
  69. package/src/commands/test-command.ts +3 -3
  70. package/src/components/App.tsx +8 -0
  71. package/src/config.ts +23 -0
  72. package/src/explorbot.ts +21 -0
  73. package/src/explorer.ts +3 -2
  74. package/src/suite.ts +135 -0
  75. package/src/utils/html.ts +1 -5
  76. package/src/utils/rules-loader.ts +35 -17
  77. package/src/utils/test-files.ts +122 -0
@@ -222,9 +222,18 @@ export class Pilot {
222
222
 
223
223
  Plan the test execution for this scenario.
224
224
 
225
- FIRST: Call precondition() to create fresh data that this test will act on.
226
- Ask: "What will this test edit/delete/use?" — create THAT item via precondition.
227
- Do not describe what's already on the page — create new disposable items for the test.
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
- Examples:
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
- WRONG: precondition("1 test suite named Updated Suite with existing tests") this describes the page, not what to create
692
+ Exampleswhen 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
- Call precondition() for EVERY item the scenario will act on. Keep descriptions short and specific.
700
+ Keep descriptions short and specific.
677
701
 
678
702
  Response format:
679
703
  PROGRESS: <1 sentence assessment>
@@ -25,3 +25,6 @@ export function WithSessionDedup(Base) {
25
25
  }
26
26
  };
27
27
  }
28
+ export function clearSessionDedup() {
29
+ previousPlans.length = 0;
30
+ }
@@ -12,3 +12,6 @@ export function getStyles() {
12
12
  export function getActiveStyle(iteration, override) {
13
13
  return RulesLoader.getActiveStyle(getStyles(), iteration, override);
14
14
  }
15
+ export function clearStyleCache() {
16
+ cache = null;
17
+ }
@@ -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
- moveExecutedTestsToEnd() {
205
- if (!this.currentPlan)
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
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { Worker } from 'node:worker_threads';
3
4
  import { outputPath } from "../../config.js";
4
5
  import { computeHtmlFingerprint } from "../../utils/html-diff.js";
5
6
  import { debugLog } from "./mixin.js";
@@ -69,16 +70,16 @@ function findSimilarMatch(combinedHtml) {
69
70
  debugLog('Fingerprint worker timed out');
70
71
  resolve(null);
71
72
  }, FINGERPRINT_WORKER_TIMEOUT_MS);
72
- worker.onmessage = (event) => {
73
+ worker.on('message', (data) => {
73
74
  clearTimeout(timeout);
74
- const { matchHash, similarity } = event.data;
75
+ const { matchHash, similarity } = data;
75
76
  if (!matchHash) {
76
77
  resolve(null);
77
78
  return;
78
79
  }
79
80
  debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
80
81
  resolve({ hash: matchHash, similarity });
81
- };
82
+ });
82
83
  worker.postMessage({
83
84
  html: combinedHtml,
84
85
  statesDir,