explorbot 0.0.5 → 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 +97 -39
- package/dist/bin/explorbot-cli.js +75 -19
- package/dist/rules/rerunner/healing-approach.md +19 -0
- package/dist/src/action.js +8 -7
- 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/subpages.js +42 -6
- package/dist/src/ai/planner.js +44 -13
- package/dist/src/ai/rerunner.js +472 -0
- package/dist/src/ai/researcher/cache.js +13 -8
- package/dist/src/ai/researcher/coordinates.js +4 -2
- package/dist/src/ai/researcher/deep-analysis.js +16 -19
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/parser.js +4 -3
- package/dist/src/ai/researcher/research-result.js +2 -0
- package/dist/src/ai/researcher.js +3 -3
- package/dist/src/ai/rules.js +2 -2
- package/dist/src/ai/tools.js +6 -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 +10 -3
- package/dist/src/commands/drill-command.js +0 -1
- package/dist/src/commands/explore-command.js +21 -6
- package/dist/src/commands/freesail-command.js +8 -22
- package/dist/src/commands/index.js +4 -0
- package/dist/src/commands/init-command.js +7 -5
- package/dist/src/commands/path-command.js +2 -1
- package/dist/src/commands/plan-command.js +38 -11
- 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 +20 -1
- package/dist/src/explorer.js +59 -16
- 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/dist/src/utils/web-element.js +6 -4
- package/package.json +3 -2
- package/rules/rerunner/healing-approach.md +19 -0
- package/src/action.ts +8 -6
- 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/subpages.ts +37 -7
- package/src/ai/planner.ts +44 -12
- package/src/ai/rerunner.ts +532 -0
- package/src/ai/researcher/cache.ts +14 -8
- package/src/ai/researcher/coordinates.ts +8 -7
- package/src/ai/researcher/deep-analysis.ts +18 -21
- package/src/ai/researcher/locators.ts +3 -3
- package/src/ai/researcher/parser.ts +4 -4
- package/src/ai/researcher/research-result.ts +1 -0
- package/src/ai/researcher.ts +3 -3
- package/src/ai/rules.ts +2 -2
- package/src/ai/tools.ts +7 -2
- package/src/commands/add-rule-command.ts +1 -2
- package/src/commands/base-command.ts +13 -0
- package/src/commands/context-command.ts +10 -3
- package/src/commands/drill-command.ts +0 -1
- package/src/commands/explore-command.ts +22 -6
- package/src/commands/freesail-command.ts +6 -23
- package/src/commands/index.ts +4 -0
- package/src/commands/init-command.ts +8 -5
- package/src/commands/path-command.ts +2 -1
- package/src/commands/plan-command.ts +46 -12
- 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 +24 -0
- package/src/explorbot.ts +22 -1
- package/src/explorer.ts +68 -20
- 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/src/utils/web-element.ts +12 -10
|
@@ -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
|
+
}
|
|
@@ -59,7 +59,7 @@ export function saveResearch(hash, text, combinedHtml) {
|
|
|
59
59
|
}
|
|
60
60
|
return researchFile;
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
function findSimilarMatch(combinedHtml) {
|
|
63
63
|
const statesDir = getStatesDir();
|
|
64
64
|
if (!existsSync(statesDir))
|
|
65
65
|
return Promise.resolve(null);
|
|
@@ -76,13 +76,8 @@ export function findSimilarResearch(combinedHtml) {
|
|
|
76
76
|
resolve(null);
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
|
-
debugLog(`Similar
|
|
80
|
-
|
|
81
|
-
if (research) {
|
|
82
|
-
resolve(research);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
resolve(null);
|
|
79
|
+
debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
|
|
80
|
+
resolve({ hash: matchHash, similarity });
|
|
86
81
|
};
|
|
87
82
|
worker.postMessage({
|
|
88
83
|
html: combinedHtml,
|
|
@@ -92,3 +87,13 @@ export function findSimilarResearch(combinedHtml) {
|
|
|
92
87
|
});
|
|
93
88
|
});
|
|
94
89
|
}
|
|
90
|
+
export async function findSimilarResearch(combinedHtml) {
|
|
91
|
+
const match = await findSimilarMatch(combinedHtml);
|
|
92
|
+
if (!match)
|
|
93
|
+
return null;
|
|
94
|
+
return getCachedResearch(match.hash) || null;
|
|
95
|
+
}
|
|
96
|
+
export async function findSimilarStateHash(combinedHtml) {
|
|
97
|
+
const match = await findSimilarMatch(combinedHtml);
|
|
98
|
+
return match?.hash || null;
|
|
99
|
+
}
|
|
@@ -115,9 +115,11 @@ export function WithCoordinates(Base) {
|
|
|
115
115
|
const text = aiResult.text || '';
|
|
116
116
|
const rows = mdq(text).query('table').toJson();
|
|
117
117
|
for (const row of rows) {
|
|
118
|
-
|
|
119
|
-
if (
|
|
118
|
+
let eidx = (row.eidx || '').trim();
|
|
119
|
+
if (!eidx || eidx === '-')
|
|
120
120
|
continue;
|
|
121
|
+
if (/^\d+$/.test(eidx))
|
|
122
|
+
eidx = `e${eidx}`;
|
|
121
123
|
const val = (v) => (v && v !== '-' ? v : null);
|
|
122
124
|
elements.set(eidx, {
|
|
123
125
|
coordinates: val(row.Coordinates),
|
|
@@ -68,7 +68,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
68
68
|
From this UI research, identify elements that could reveal hidden UI when clicked
|
|
69
69
|
(dropdown menus, popups, expandable panels, accordion sections, overflow menus, tab switches).
|
|
70
70
|
|
|
71
|
-
Available eidx
|
|
71
|
+
Available eidx refs: ${eidxList}
|
|
72
72
|
|
|
73
73
|
${researchText}
|
|
74
74
|
|
|
@@ -76,7 +76,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
76
76
|
- Only pick elements that HIDE content until clicked (menus, dropdowns, accordions, tabs)
|
|
77
77
|
- Skip regular links, data items, and navigation
|
|
78
78
|
- For repeated elements (same expand button on every row), pick only the FIRST one
|
|
79
|
-
- Respond with comma-separated eidx
|
|
79
|
+
- Respond with comma-separated eidx refs only, e.g.: e3, e7, e15
|
|
80
80
|
`;
|
|
81
81
|
const model = this.provider.getModelForAgent('researcher');
|
|
82
82
|
const textCall = this.provider.chat([{ role: 'user', content: textPrompt }], model, {
|
|
@@ -87,7 +87,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
87
87
|
const screenshot = this.actionResult?.screenshot;
|
|
88
88
|
if (screenshot && this.provider.hasVision()) {
|
|
89
89
|
const visionPrompt = dedent `
|
|
90
|
-
This screenshot has interactive elements labeled with eidx
|
|
90
|
+
This screenshot has interactive elements labeled with eidx refs (solid bordered boxes with labels).
|
|
91
91
|
Identify elements that could reveal hidden UI when clicked.
|
|
92
92
|
|
|
93
93
|
Look for: overflow/ellipsis menus, chevron dropdowns, hamburger menus,
|
|
@@ -96,30 +96,27 @@ export function WithDeepAnalysis(Base) {
|
|
|
96
96
|
Rules:
|
|
97
97
|
- For repeated icons (same icon on every list row), pick only the FIRST one
|
|
98
98
|
- Skip regular text buttons, links, and navigation items
|
|
99
|
-
- Respond with comma-separated eidx
|
|
99
|
+
- Respond with comma-separated eidx refs only, e.g.: e3, e7, e15
|
|
100
100
|
`;
|
|
101
101
|
visionCall = this.provider.processImage(visionPrompt, screenshot.toString('base64'));
|
|
102
102
|
}
|
|
103
103
|
const [textRes, visionRes] = await Promise.all([textCall, visionCall]);
|
|
104
104
|
const eidxSet = new Set();
|
|
105
|
+
const parseRefs = (text) => {
|
|
106
|
+
if (!text)
|
|
107
|
+
return [];
|
|
108
|
+
const matches = text.match(/e?\d+/g) || [];
|
|
109
|
+
const refs = matches.map((m) => (m.startsWith('e') ? m : `e${m}`));
|
|
110
|
+
return refs.filter((r) => allElements.has(r));
|
|
111
|
+
};
|
|
105
112
|
for (const res of [textRes, visionRes]) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const nums = res.text.match(/\d+/g)?.map(Number) || [];
|
|
109
|
-
for (const n of nums) {
|
|
110
|
-
if (allElements.has(n))
|
|
111
|
-
eidxSet.add(n);
|
|
113
|
+
for (const ref of parseRefs(res?.text)) {
|
|
114
|
+
eidxSet.add(ref);
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
.filter((n) => allElements.has(n)) || [];
|
|
118
|
-
const visionNums = visionRes?.text
|
|
119
|
-
?.match(/\d+/g)
|
|
120
|
-
?.map(Number)
|
|
121
|
-
.filter((n) => allElements.has(n)) || [];
|
|
122
|
-
debugLog(`Text model picked eidx: [${textNums.join(', ')}], Vision model picked eidx: [${visionNums.join(', ')}]`);
|
|
117
|
+
const textRefs = parseRefs(textRes?.text);
|
|
118
|
+
const visionRefs = parseRefs(visionRes?.text);
|
|
119
|
+
debugLog(`Text model picked eidx: [${textRefs.join(', ')}], Vision model picked eidx: [${visionRefs.join(', ')}]`);
|
|
123
120
|
return [...eidxSet].map((eidx) => allElements.get(eidx));
|
|
124
121
|
}
|
|
125
122
|
_buildClickCommands(el) {
|
|
@@ -126,7 +126,7 @@ export function WithLocators(Base) {
|
|
|
126
126
|
for (const fixedSection of fixedSections) {
|
|
127
127
|
const originalSections = parseResearchSections(result.text);
|
|
128
128
|
const original = originalSections.find((s) => s.name === fixedSection.name);
|
|
129
|
-
if (!original)
|
|
129
|
+
if (!original || original.elements.length === 0)
|
|
130
130
|
continue;
|
|
131
131
|
if (fixedSection.containerCss && fixedSection.containerCss !== original.containerCss) {
|
|
132
132
|
debugLog(`Fixed container for "${fixedSection.name}": '${original.containerCss}' → '${fixedSection.containerCss}'`);
|
|
@@ -37,8 +37,9 @@ export function mapRowToElement(row) {
|
|
|
37
37
|
const name = stripQuotes(colMap.element || '');
|
|
38
38
|
if (!name)
|
|
39
39
|
return null;
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
let eidxRaw = (colMap.eidx || '').trim();
|
|
41
|
+
if (eidxRaw && /^\d+$/.test(eidxRaw))
|
|
42
|
+
eidxRaw = `e${eidxRaw}`;
|
|
42
43
|
const aria = parseAriaLocator(colMap.aria || '-');
|
|
43
44
|
return {
|
|
44
45
|
name,
|
|
@@ -49,7 +50,7 @@ export function mapRowToElement(row) {
|
|
|
49
50
|
coordinates: (colMap.coordinates || '-').trim() === '-' ? null : colMap.coordinates.trim(),
|
|
50
51
|
color: (colMap.color || '-').trim() === '-' || (colMap.color || '').trim() === '' ? null : colMap.color.trim(),
|
|
51
52
|
icon: (colMap.icon || '-').trim() === '-' || (colMap.icon || '').trim() === '' ? null : colMap.icon.trim(),
|
|
52
|
-
eidx:
|
|
53
|
+
eidx: eidxRaw && eidxRaw !== '-' ? eidxRaw : null,
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
56
|
export function extractContainerFromBlockquote(sectionMarkdown) {
|
|
@@ -62,6 +62,8 @@ export class ResearchResult {
|
|
|
62
62
|
this.rebuildSectionInText(section);
|
|
63
63
|
}
|
|
64
64
|
rebuildSectionInText(section) {
|
|
65
|
+
if (section.elements.length === 0)
|
|
66
|
+
return;
|
|
65
67
|
const newTable = rebuildSectionMarkdown(section);
|
|
66
68
|
const escaped = section.name.replace(/"/g, '\\"');
|
|
67
69
|
let sectionQuery = mdq(this.text).query(`section2(~"${escaped}")`);
|
|
@@ -98,8 +98,8 @@ 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
|
|
102
|
-
debugLog(`Annotated ${
|
|
101
|
+
const annotatedElements = await this.explorer.annotateElements();
|
|
102
|
+
debugLog(`Annotated ${annotatedElements.length} interactive elements with eidx`);
|
|
103
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);
|
|
@@ -117,7 +117,7 @@ export class Researcher extends ResearcherBase {
|
|
|
117
117
|
}
|
|
118
118
|
debugLog('Researching web page:', this.actionResult.url);
|
|
119
119
|
const combinedHtml = await this.actionResult.combinedHtml();
|
|
120
|
-
if (!deep) {
|
|
120
|
+
if (!deep && !force) {
|
|
121
121
|
const similar = await findSimilarResearch(combinedHtml);
|
|
122
122
|
if (similar) {
|
|
123
123
|
tag('info').log('Similar research found, reusing cached result');
|
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
|
|