explorbot 0.1.10 → 0.1.12
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/README.md +37 -1
- package/bin/explorbot-cli.ts +27 -18
- package/dist/bin/explorbot-cli.js +26 -18
- package/dist/package.json +3 -3
- package/dist/rules/navigator/output.md +9 -0
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action-result.js +23 -1
- package/dist/src/action.js +51 -42
- package/dist/src/ai/bosun.js +11 -1
- package/dist/src/ai/conversation.js +39 -0
- package/dist/src/ai/historian/codeceptjs.js +109 -0
- package/dist/src/ai/historian/experience.js +321 -0
- package/dist/src/ai/historian/mixin.js +2 -0
- package/dist/src/ai/historian/playwright.js +145 -0
- package/dist/src/ai/historian/screencast.js +121 -0
- package/dist/src/ai/historian/utils.js +18 -0
- package/dist/src/ai/historian.js +21 -405
- package/dist/src/ai/navigator.js +82 -29
- package/dist/src/ai/pilot.js +232 -13
- package/dist/src/ai/planner.js +29 -9
- package/dist/src/ai/provider.js +54 -17
- package/dist/src/ai/researcher.js +41 -32
- package/dist/src/ai/rules.js +26 -14
- package/dist/src/ai/tester.js +90 -26
- package/dist/src/ai/tools.js +13 -7
- package/dist/src/browser-server.js +16 -3
- package/dist/src/commands/add-rule-command.js +11 -8
- package/dist/src/commands/clean-command.js +2 -1
- package/dist/src/commands/explore-command.js +43 -15
- package/dist/src/commands/init-command.js +9 -8
- package/dist/src/commands/plan-command.js +32 -0
- package/dist/src/commands/plan-save-command.js +19 -7
- package/dist/src/commands/rerun-command.js +4 -0
- package/dist/src/components/App.js +15 -5
- package/dist/src/execution-controller.js +13 -2
- package/dist/src/experience-tracker.js +20 -64
- package/dist/src/explorbot.js +8 -8
- package/dist/src/explorer.js +11 -3
- package/dist/src/observability.js +50 -99
- package/dist/src/playwright-recorder.js +309 -0
- package/dist/src/reporter.js +4 -1
- package/dist/src/test-plan.js +12 -0
- package/dist/src/utils/aria.js +37 -1
- package/dist/src/utils/error-page.js +20 -7
- package/dist/src/utils/next-steps.js +37 -0
- package/dist/src/utils/strings.js +15 -0
- package/package.json +3 -3
- package/rules/navigator/output.md +9 -0
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action-result.ts +26 -1
- package/src/action.ts +49 -41
- package/src/ai/bosun.ts +11 -1
- package/src/ai/conversation.ts +37 -0
- package/src/ai/historian/codeceptjs.ts +130 -0
- package/src/ai/historian/experience.ts +384 -0
- package/src/ai/historian/mixin.ts +4 -0
- package/src/ai/historian/playwright.ts +169 -0
- package/src/ai/historian/screencast.ts +133 -0
- package/src/ai/historian/utils.ts +23 -0
- package/src/ai/historian.ts +37 -473
- package/src/ai/navigator.ts +82 -29
- package/src/ai/pilot.ts +237 -14
- package/src/ai/planner.ts +29 -9
- package/src/ai/provider.ts +51 -17
- package/src/ai/researcher.ts +45 -33
- package/src/ai/rules.ts +27 -14
- package/src/ai/tester.ts +94 -26
- package/src/ai/tools.ts +47 -25
- package/src/browser-server.ts +17 -3
- package/src/commands/add-rule-command.ts +11 -7
- package/src/commands/clean-command.ts +2 -1
- package/src/commands/explore-command.ts +46 -14
- package/src/commands/init-command.ts +9 -8
- package/src/commands/plan-command.ts +35 -0
- package/src/commands/plan-save-command.ts +18 -7
- package/src/commands/rerun-command.ts +5 -0
- package/src/components/App.tsx +16 -5
- package/src/config.ts +12 -1
- package/src/execution-controller.ts +14 -3
- package/src/experience-tracker.ts +21 -72
- package/src/explorbot.ts +8 -8
- package/src/explorer.ts +13 -3
- package/src/observability.ts +50 -109
- package/src/playwright-recorder.ts +305 -0
- package/src/reporter.ts +4 -1
- package/src/test-plan.ts +12 -0
- package/src/utils/aria.ts +38 -1
- package/src/utils/error-page.ts +22 -7
- package/src/utils/next-steps.ts +51 -0
- package/src/utils/strings.ts +17 -0
package/README.md
CHANGED
|
@@ -169,10 +169,46 @@ See [docs/commands.md](docs/commands.md) for all commands.
|
|
|
169
169
|
|
|
170
170
|
| Output | Location | Description |
|
|
171
171
|
|--------|----------|-------------|
|
|
172
|
-
| Test files | `output/tests/*.js` |
|
|
172
|
+
| Test files | `output/tests/*.spec.ts` or `*.js` | Runnable Playwright or CodeceptJS tests |
|
|
173
173
|
| Test plans | `output/plans/*.md` | Markdown documentation of scenarios |
|
|
174
174
|
| Experience | `./experience/` | What Explorbot learned about your app |
|
|
175
175
|
|
|
176
|
+
Every run is saved as a real Playwright or CodeceptJS test you can commit and run from CI. Configure the Historian to choose the output framework, record screencasts of every run, or both:
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
ai: {
|
|
180
|
+
agents: {
|
|
181
|
+
historian: {
|
|
182
|
+
framework: 'playwright', // or 'codeceptjs' (default)
|
|
183
|
+
screencast: true, // record .webm video per scenario, chapters labelled with each step
|
|
184
|
+
// screencast: { size: { width: 1280, height: 720 }, quality: 95 }
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Screencasts land in `output/screencasts/<plan>-<n>-<scenario>.webm` and are listed alongside generated tests at the end of every run.
|
|
191
|
+
|
|
192
|
+
Playwright output uses the actual `page.locator(...)` calls executed during the run, with each action wrapped in `test.step` so failures land on a labelled step:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
test('Create a new manual plan', async ({ page }) => {
|
|
196
|
+
await test.step("Click the 'New plan' button in toolbar", async () => {
|
|
197
|
+
await page.getByRole('button', { name: 'New plan' }).first().click();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test.step('Select Manual plan type in modal', async () => {
|
|
201
|
+
await page.locator('#portal-container').getByRole('button', { name: 'Manual' }).click();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await test.step('Verification', async () => {
|
|
205
|
+
await expect(page).toContainText('Test Plan UI Creation 001');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
See [Automated Tests](docs/automated-tests.md) for the CodeceptJS version and how failed or unfinished scenarios are handled.
|
|
211
|
+
|
|
176
212
|
## Two Ways to Run
|
|
177
213
|
|
|
178
214
|
**Interactive mode** — Launch TUI, guide exploration, get real-time feedback:
|
package/bin/explorbot-cli.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { getCliName } from '../src/utils/cli-name.ts';
|
|
|
17
17
|
import { log, setPreserveConsoleLogs } from '../src/utils/logger.js';
|
|
18
18
|
import { jsonToTable } from '../src/utils/markdown-parser.js';
|
|
19
19
|
import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js';
|
|
20
|
+
import { type NextStepSection, printNextSteps, relativeToCwd } from '../src/utils/next-steps.ts';
|
|
20
21
|
|
|
21
22
|
const program = new Command();
|
|
22
23
|
const cli = getCliName();
|
|
@@ -25,9 +26,10 @@ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../p
|
|
|
25
26
|
const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version as string;
|
|
26
27
|
|
|
27
28
|
program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
|
|
28
|
-
|
|
29
|
+
|
|
30
|
+
if (!process.env.EXPLORBOT_NO_BANNER) {
|
|
29
31
|
console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
|
|
30
|
-
}
|
|
32
|
+
}
|
|
31
33
|
|
|
32
34
|
interface CLIOptions {
|
|
33
35
|
verbose?: boolean;
|
|
@@ -188,26 +190,32 @@ addCommonOptions(program.command('plan <path>').description('Generate test plan
|
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
const savedPath = explorBot.savePlan();
|
|
191
|
-
const planFile = savedPath ? path.basename(savedPath) : 'plan.md';
|
|
192
193
|
|
|
193
194
|
const cliFlags = [options.path ? `--path ${options.path}` : '', options.session ? '--session' : ''].filter(Boolean).join(' ');
|
|
194
195
|
const cliSuffix = cliFlags ? ` ${cliFlags}` : '';
|
|
195
196
|
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
{
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
command:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
197
|
+
const sections: NextStepSection[] = [];
|
|
198
|
+
if (savedPath) {
|
|
199
|
+
const relPlan = relativeToCwd(savedPath);
|
|
200
|
+
sections.push({
|
|
201
|
+
label: 'Plan',
|
|
202
|
+
path: savedPath,
|
|
203
|
+
commands: [
|
|
204
|
+
{ label: 'Re-run', command: `${cli} test ${relPlan} 1${cliSuffix}` },
|
|
205
|
+
{ label: 'Run all', command: `${cli} test ${relPlan} *${cliSuffix}` },
|
|
206
|
+
{ label: 'Run range', command: `${cli} test ${relPlan} 1-3${cliSuffix}` },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const automatedFiles = suite && suite.automatedTestCount > 0 ? suite.getAutomatedTestFiles() : [];
|
|
211
|
+
if (automatedFiles.length > 0) {
|
|
212
|
+
const commands = automatedFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}${cliSuffix}` }));
|
|
213
|
+
sections.push({
|
|
214
|
+
label: `Automated tests (${automatedFiles.length})`,
|
|
215
|
+
commands,
|
|
216
|
+
});
|
|
209
217
|
}
|
|
210
|
-
|
|
218
|
+
printNextSteps(sections);
|
|
211
219
|
|
|
212
220
|
await explorBot.stop();
|
|
213
221
|
await showStatsAndExit(0);
|
|
@@ -387,7 +395,7 @@ program
|
|
|
387
395
|
|
|
388
396
|
program
|
|
389
397
|
.command('clean [target]')
|
|
390
|
-
.description('Clean files: states, research, plans, experiences, output (default: output + experiences)')
|
|
398
|
+
.description('Clean files: states, research, plans, tests, experiences, output (default: output + experiences)')
|
|
391
399
|
.option('-p, --path <path>', 'Custom path to clean')
|
|
392
400
|
.action(async (target, options) => {
|
|
393
401
|
const customPath = options.path;
|
|
@@ -398,6 +406,7 @@ program
|
|
|
398
406
|
states: { description: 'page states', dir: path.join(basePath, 'output', 'states') },
|
|
399
407
|
research: { description: 'research cache', dir: path.join(basePath, 'output', 'research') },
|
|
400
408
|
plans: { description: 'test plans', dir: path.join(basePath, 'output', 'plans') },
|
|
409
|
+
tests: { description: 'generated tests', dir: path.join(basePath, 'output', 'tests') },
|
|
401
410
|
experiences: { description: 'experience files', dir: path.join(basePath, 'experience') },
|
|
402
411
|
output: { description: 'all output files', dir: path.join(basePath, 'output') },
|
|
403
412
|
};
|
|
@@ -17,14 +17,15 @@ import { getCliName } from "../src/utils/cli-name.js";
|
|
|
17
17
|
import { log, setPreserveConsoleLogs } from '../src/utils/logger.js';
|
|
18
18
|
import { jsonToTable } from '../src/utils/markdown-parser.js';
|
|
19
19
|
import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js';
|
|
20
|
+
import { printNextSteps, relativeToCwd } from "../src/utils/next-steps.js";
|
|
20
21
|
const program = new Command();
|
|
21
22
|
const cli = getCliName();
|
|
22
23
|
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../package.json');
|
|
23
24
|
const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
|
|
24
25
|
program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
|
|
25
|
-
|
|
26
|
+
if (!process.env.EXPLORBOT_NO_BANNER) {
|
|
26
27
|
console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
|
|
27
|
-
}
|
|
28
|
+
}
|
|
28
29
|
function buildExplorBotOptions(from, options) {
|
|
29
30
|
return {
|
|
30
31
|
from,
|
|
@@ -155,24 +156,30 @@ addCommonOptions(program.command('plan <path>').description('Generate test plan
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
const savedPath = explorBot.savePlan();
|
|
158
|
-
const planFile = savedPath ? path.basename(savedPath) : 'plan.md';
|
|
159
159
|
const cliFlags = [options.path ? `--path ${options.path}` : '', options.session ? '--session' : ''].filter(Boolean).join(' ');
|
|
160
160
|
const cliSuffix = cliFlags ? ` ${cliFlags}` : '';
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
command:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
161
|
+
const sections = [];
|
|
162
|
+
if (savedPath) {
|
|
163
|
+
const relPlan = relativeToCwd(savedPath);
|
|
164
|
+
sections.push({
|
|
165
|
+
label: 'Plan',
|
|
166
|
+
path: savedPath,
|
|
167
|
+
commands: [
|
|
168
|
+
{ label: 'Re-run', command: `${cli} test ${relPlan} 1${cliSuffix}` },
|
|
169
|
+
{ label: 'Run all', command: `${cli} test ${relPlan} *${cliSuffix}` },
|
|
170
|
+
{ label: 'Run range', command: `${cli} test ${relPlan} 1-3${cliSuffix}` },
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const automatedFiles = suite && suite.automatedTestCount > 0 ? suite.getAutomatedTestFiles() : [];
|
|
175
|
+
if (automatedFiles.length > 0) {
|
|
176
|
+
const commands = automatedFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}${cliSuffix}` }));
|
|
177
|
+
sections.push({
|
|
178
|
+
label: `Automated tests (${automatedFiles.length})`,
|
|
179
|
+
commands,
|
|
180
|
+
});
|
|
174
181
|
}
|
|
175
|
-
|
|
182
|
+
printNextSteps(sections);
|
|
176
183
|
await explorBot.stop();
|
|
177
184
|
await showStatsAndExit(0);
|
|
178
185
|
}
|
|
@@ -341,7 +348,7 @@ program
|
|
|
341
348
|
});
|
|
342
349
|
program
|
|
343
350
|
.command('clean [target]')
|
|
344
|
-
.description('Clean files: states, research, plans, experiences, output (default: output + experiences)')
|
|
351
|
+
.description('Clean files: states, research, plans, tests, experiences, output (default: output + experiences)')
|
|
345
352
|
.option('-p, --path <path>', 'Custom path to clean')
|
|
346
353
|
.action(async (target, options) => {
|
|
347
354
|
const customPath = options.path;
|
|
@@ -351,6 +358,7 @@ program
|
|
|
351
358
|
states: { description: 'page states', dir: path.join(basePath, 'output', 'states') },
|
|
352
359
|
research: { description: 'research cache', dir: path.join(basePath, 'output', 'research') },
|
|
353
360
|
plans: { description: 'test plans', dir: path.join(basePath, 'output', 'plans') },
|
|
361
|
+
tests: { description: 'generated tests', dir: path.join(basePath, 'output', 'tests') },
|
|
354
362
|
experiences: { description: 'experience files', dir: path.join(basePath, 'experience') },
|
|
355
363
|
output: { description: 'all output files', dir: path.join(basePath, 'output') },
|
|
356
364
|
};
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"axe-core": "^4.11.1",
|
|
84
84
|
"bash-tool": "^1.3.15",
|
|
85
85
|
"cli-highlight": "^2.1.11",
|
|
86
|
-
"codeceptjs": "4.0.0-rc.
|
|
86
|
+
"codeceptjs": "4.0.0-rc.16",
|
|
87
87
|
"commander": "^14.0.1",
|
|
88
88
|
"debug": "^4.4.3",
|
|
89
89
|
"dedent": "^1.6.0",
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"micromatch": "^4.0.8",
|
|
105
105
|
"ora-classic": "^5.4.2",
|
|
106
106
|
"parse5": "^8.0.0",
|
|
107
|
-
"playwright": "^1.
|
|
107
|
+
"playwright": "^1.59.0",
|
|
108
108
|
"react": "^19.1.1",
|
|
109
109
|
"strip-ansi": "^7.1.2",
|
|
110
110
|
"turndown": "^7.2.1",
|
|
@@ -13,6 +13,10 @@ In <explanation> write only one line without heading or bullet list or any other
|
|
|
13
13
|
Check previous solutions, if there is already successful solution, use it!
|
|
14
14
|
CodeceptJS code must start with "I."
|
|
15
15
|
All lines of code must be CodeceptJS code and start with "I."
|
|
16
|
+
|
|
17
|
+
Do not mix form filling with navigation in the same code block.
|
|
18
|
+
If the code block fills a form and clicks a submit/confirm control, stop there — do not append I.amOnPage afterwards. Submitting the form already triggers navigation on the server side, and a follow-up I.amOnPage cancels that in-flight navigation and discards the just-submitted state (session, cookies, redirect target).
|
|
19
|
+
If the action does not cause navigation on its own and a separate page visit is required to reach the target, put I.amOnPage in its own code block as a distinct step, not glued onto the form submission block.
|
|
16
20
|
</rules>
|
|
17
21
|
|
|
18
22
|
<output>
|
|
@@ -42,6 +46,11 @@ Use only locators from HTML PAGE that was passed in <page> context.
|
|
|
42
46
|
<example_output>
|
|
43
47
|
Trying to fill the form on the page
|
|
44
48
|
|
|
49
|
+
```js
|
|
50
|
+
I.fillField({ "role": "textbox", "text": "Name" }, 'Value');
|
|
51
|
+
I.click({ "role": "button", "text": "Submit" });
|
|
52
|
+
```
|
|
53
|
+
|
|
45
54
|
```js
|
|
46
55
|
I.fillField('Name', 'Value');
|
|
47
56
|
I.click('Submit');
|
|
@@ -113,6 +113,8 @@ For input field values, ALWAYS use I.seeInField() — never check value via CSS
|
|
|
113
113
|
Prefer text locators (label, name, placeholder) for form fields: I.seeInField('Search', 'value') over I.seeInField('input[name="search"]', 'value').
|
|
114
114
|
Only use locators that exist in the provided HTML or ARIA snapshot.
|
|
115
115
|
Verify exact conditions, not approximate matches.
|
|
116
|
+
NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking text inside an element is the job of I.see(text, context) — the `:has-text()` form duplicates that capability with a fragile selector.
|
|
117
|
+
NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
|
|
116
118
|
</verification_rules>
|
|
117
119
|
|
|
118
120
|
[DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
|
|
@@ -502,7 +502,7 @@ export class ActionResult {
|
|
|
502
502
|
}
|
|
503
503
|
}
|
|
504
504
|
if (processedParts.length > 0) {
|
|
505
|
-
pageDiff.htmlParts = processedParts;
|
|
505
|
+
pageDiff.htmlParts = collapseHtmlParts(processedParts);
|
|
506
506
|
}
|
|
507
507
|
}
|
|
508
508
|
if (pageDiff.ariaChanges && this.iframeSnapshots.length > 0) {
|
|
@@ -517,6 +517,28 @@ export class ActionResult {
|
|
|
517
517
|
return result;
|
|
518
518
|
}
|
|
519
519
|
}
|
|
520
|
+
const HTML_PARTS_TOTAL_BUDGET = 8000;
|
|
521
|
+
const HTML_PARTS_COUNT_LIMIT = 8;
|
|
522
|
+
const HTML_PART_SUBTREE_BUDGET = 2000;
|
|
523
|
+
function collapseHtmlParts(parts) {
|
|
524
|
+
const total = parts.reduce((sum, p) => sum + p.subtree.length, 0);
|
|
525
|
+
const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
|
|
526
|
+
if (fullPageReRender) {
|
|
527
|
+
return parts.map((part) => ({
|
|
528
|
+
...part,
|
|
529
|
+
subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
return parts.map((part) => {
|
|
533
|
+
if (part.subtree.length <= HTML_PART_SUBTREE_BUDGET)
|
|
534
|
+
return part;
|
|
535
|
+
const head = part.subtree.slice(0, HTML_PART_SUBTREE_BUDGET);
|
|
536
|
+
return {
|
|
537
|
+
...part,
|
|
538
|
+
subtree: `${head}...<!-- truncated ${part.subtree.length - HTML_PART_SUBTREE_BUDGET} chars -->`,
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
}
|
|
520
542
|
export class Diff {
|
|
521
543
|
current;
|
|
522
544
|
previous;
|
package/dist/src/action.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ConfigParser, outputPath } from './config.js';
|
|
|
12
12
|
import { Observability } from "./observability.js";
|
|
13
13
|
import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
14
14
|
import { createDebug, log, setStepSpanParent, tag } from './utils/logger.js';
|
|
15
|
+
import { safeFilename } from "./utils/strings.js";
|
|
15
16
|
const debugLog = createDebug('explorbot:action');
|
|
16
17
|
const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
|
|
17
18
|
class Action {
|
|
@@ -24,11 +25,15 @@ class Action {
|
|
|
24
25
|
expectation = null;
|
|
25
26
|
lastError = null;
|
|
26
27
|
playwrightHelper;
|
|
27
|
-
|
|
28
|
+
playwrightGroupId = null;
|
|
29
|
+
assertionSteps = [];
|
|
30
|
+
recorder;
|
|
31
|
+
constructor(actor, stateManager, recorder) {
|
|
28
32
|
this.actor = actor;
|
|
29
33
|
this.stateManager = stateManager;
|
|
30
34
|
this.config = ConfigParser.getInstance().getConfig();
|
|
31
35
|
this.playwrightHelper = container.helpers('Playwright');
|
|
36
|
+
this.recorder = recorder;
|
|
32
37
|
}
|
|
33
38
|
async caputrePageWithScreenshot() {
|
|
34
39
|
return this.capturePageState({ includeScreenshot: true });
|
|
@@ -57,11 +62,19 @@ class Action {
|
|
|
57
62
|
const timestamp = Date.now();
|
|
58
63
|
const page = this.playwrightHelper.page;
|
|
59
64
|
const frame = this.playwrightHelper.frame;
|
|
60
|
-
|
|
65
|
+
await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
|
|
66
|
+
const grabAll = () => Promise.all([this.actor.grabSource(), this.actor.grabTitle(), this.captureBrowserLogs()]);
|
|
67
|
+
const [html, title, browserLogs] = await grabAll().catch(async (err) => {
|
|
68
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
69
|
+
if (!/navigating and changing the content/i.test(msg))
|
|
70
|
+
throw err;
|
|
71
|
+
await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
|
|
72
|
+
return grabAll();
|
|
73
|
+
});
|
|
61
74
|
const url = page?.url() || (await this.actor.grabCurrentUrl?.());
|
|
62
75
|
let screenshotFile = undefined;
|
|
63
76
|
if (includeScreenshot) {
|
|
64
|
-
const filename = `${stateHash}_${timestamp}.png
|
|
77
|
+
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
65
78
|
screenshotFile = await this.actor
|
|
66
79
|
.saveScreenshot(filename)
|
|
67
80
|
.then(() => filename)
|
|
@@ -73,12 +86,12 @@ class Action {
|
|
|
73
86
|
// Save HTML to file
|
|
74
87
|
const statesDir = outputPath('states');
|
|
75
88
|
fs.mkdirSync(statesDir, { recursive: true });
|
|
76
|
-
const htmlFile = `${stateHash}_${timestamp}.html
|
|
89
|
+
const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
|
|
77
90
|
const htmlPath = join(statesDir, htmlFile);
|
|
78
91
|
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
79
92
|
debugLog('Captured page state');
|
|
80
93
|
// Save logs to file
|
|
81
|
-
const logFile = `${stateHash}_${timestamp}.log
|
|
94
|
+
const logFile = safeFilename(`${stateHash}_${timestamp}`, '.log');
|
|
82
95
|
const logPath = join(statesDir, logFile);
|
|
83
96
|
const formattedLogs = browserLogs.map((log) => {
|
|
84
97
|
const logTimestamp = new Date().toISOString();
|
|
@@ -100,7 +113,7 @@ class Action {
|
|
|
100
113
|
debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
|
|
101
114
|
}
|
|
102
115
|
if (ariaSnapshot) {
|
|
103
|
-
const ariaFileName = `${stateHash}_${timestamp}.aria.yaml
|
|
116
|
+
const ariaFileName = safeFilename(`${stateHash}_${timestamp}`, '.aria.yaml');
|
|
104
117
|
const ariaPath = join(statesDir, ariaFileName);
|
|
105
118
|
fs.writeFileSync(ariaPath, ariaSnapshot, 'utf8');
|
|
106
119
|
ariaSnapshotFile = ariaFileName;
|
|
@@ -183,7 +196,10 @@ class Action {
|
|
|
183
196
|
setActivity('🔎 Browsing...', 'action');
|
|
184
197
|
let codeString = code.replace(/^\(I\) => /, '').trim();
|
|
185
198
|
const executedSteps = [];
|
|
186
|
-
|
|
199
|
+
const assertionSteps = [];
|
|
200
|
+
const stepListener = attachStepLogger(executedSteps, assertionSteps);
|
|
201
|
+
const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
|
|
202
|
+
this.playwrightGroupId = groupId;
|
|
187
203
|
const activeSpan = Observability.getSpan();
|
|
188
204
|
const tracer = trace.getTracer('ai');
|
|
189
205
|
const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
|
|
@@ -213,6 +229,7 @@ class Action {
|
|
|
213
229
|
}
|
|
214
230
|
this.stateManager.updateState(pageState, codeString);
|
|
215
231
|
this.actionResult = pageState;
|
|
232
|
+
this.assertionSteps = assertionSteps;
|
|
216
233
|
}
|
|
217
234
|
catch (err) {
|
|
218
235
|
debugLog('Action error', errorToString(err));
|
|
@@ -221,10 +238,13 @@ class Action {
|
|
|
221
238
|
await recorder.reset();
|
|
222
239
|
await recorder.start();
|
|
223
240
|
}
|
|
241
|
+
this.assertionSteps = [];
|
|
224
242
|
throw err;
|
|
225
243
|
}
|
|
226
244
|
finally {
|
|
227
|
-
|
|
245
|
+
if (groupId)
|
|
246
|
+
await this.recorder.endAction();
|
|
247
|
+
detachStepLogger(stepListener);
|
|
228
248
|
if (stepSpan) {
|
|
229
249
|
stepSpan.end();
|
|
230
250
|
}
|
|
@@ -350,39 +370,28 @@ function hasPlaywrightCommands(code) {
|
|
|
350
370
|
function sleep(ms) {
|
|
351
371
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
352
372
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (stepLoggerRegistered) {
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
stepLoggerRegistered = true;
|
|
377
|
-
codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, stepLogger);
|
|
378
|
-
codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, stepLogger);
|
|
373
|
+
const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeElement', 'seeInField', 'dontSeeInField', 'seeInCurrentUrl', 'dontSeeInCurrentUrl']);
|
|
374
|
+
const attachStepLogger = (target, assertionsTarget) => {
|
|
375
|
+
const listener = (step, error) => {
|
|
376
|
+
if (!step?.toCode)
|
|
377
|
+
return;
|
|
378
|
+
if (step.name?.startsWith('grab'))
|
|
379
|
+
return;
|
|
380
|
+
target.push(step.toCode());
|
|
381
|
+
if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
|
|
382
|
+
assertionsTarget.push({ name: step.name, args: step.args || [] });
|
|
383
|
+
}
|
|
384
|
+
if (error) {
|
|
385
|
+
tag('step').log(step, error);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
tag('step').log(step);
|
|
389
|
+
};
|
|
390
|
+
codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
|
|
391
|
+
codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
|
|
392
|
+
return listener;
|
|
379
393
|
};
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
stepLoggerRegistered = false;
|
|
386
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, stepLogger);
|
|
387
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, stepLogger);
|
|
394
|
+
const detachStepLogger = (listener) => {
|
|
395
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
|
|
396
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
|
|
388
397
|
};
|
package/dist/src/ai/bosun.js
CHANGED
|
@@ -5,9 +5,11 @@ import { ActionResult } from "../action-result.js";
|
|
|
5
5
|
import { setActivity } from "../activity.js";
|
|
6
6
|
import { Observability } from "../observability.js";
|
|
7
7
|
import { Plan, Task, Test, TestResult } from "../test-plan.js";
|
|
8
|
+
import { getCliName } from "../utils/cli-name.js";
|
|
8
9
|
import { HooksRunner } from "../utils/hooks-runner.js";
|
|
9
10
|
import { createDebug, tag } from "../utils/logger.js";
|
|
10
11
|
import { loop, pause } from "../utils/loop.js";
|
|
12
|
+
import { printNextSteps } from "../utils/next-steps.js";
|
|
11
13
|
import { locatorRule } from "./rules.js";
|
|
12
14
|
import { TaskAgent, isInteractive } from "./task-agent.js";
|
|
13
15
|
import { createCodeceptJSTools } from "./tools.js";
|
|
@@ -391,7 +393,15 @@ export class Bosun extends TaskAgent {
|
|
|
391
393
|
}
|
|
392
394
|
const content = this.generateKnowledgeContent(state, successfulInteractions);
|
|
393
395
|
const result = knowledgeTracker.addKnowledge(knowledgePath, content);
|
|
394
|
-
|
|
396
|
+
const cli = getCliName();
|
|
397
|
+
const sections = [
|
|
398
|
+
{
|
|
399
|
+
label: 'Knowledge',
|
|
400
|
+
path: result.filePath,
|
|
401
|
+
commands: [{ label: 'View matches', command: `${cli} knows ${knowledgePath}` }],
|
|
402
|
+
},
|
|
403
|
+
];
|
|
404
|
+
printNextSteps(sections);
|
|
395
405
|
}
|
|
396
406
|
generateKnowledgeContent(state, interactions) {
|
|
397
407
|
const lines = [];
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export function toolExecutionLabel(input) {
|
|
2
2
|
return input?.explanation || input?.assertion || input?.reason || input?.request || '';
|
|
3
3
|
}
|
|
4
|
+
const AUTO_COMPACT_ARIA_CHANGES_CUTOFF = 500;
|
|
5
|
+
const AUTO_COMPACT_TARGETED_HTML_CUTOFF = 500;
|
|
4
6
|
export class Conversation {
|
|
5
7
|
id;
|
|
6
8
|
messages;
|
|
@@ -105,6 +107,43 @@ export class Conversation {
|
|
|
105
107
|
autoTrimTag(tagName, maxLength) {
|
|
106
108
|
this.autoTrimRules.set(tagName, maxLength);
|
|
107
109
|
}
|
|
110
|
+
compactToolResults(keepLastN) {
|
|
111
|
+
const toolMessageIndexes = [];
|
|
112
|
+
for (let i = 0; i < this.messages.length; i++) {
|
|
113
|
+
if (this.messages[i].role === 'tool')
|
|
114
|
+
toolMessageIndexes.push(i);
|
|
115
|
+
}
|
|
116
|
+
const compactUpTo = toolMessageIndexes.length - Math.max(0, keepLastN);
|
|
117
|
+
for (let k = 0; k < compactUpTo; k++) {
|
|
118
|
+
const message = this.messages[toolMessageIndexes[k]];
|
|
119
|
+
if (!Array.isArray(message.content))
|
|
120
|
+
continue;
|
|
121
|
+
for (const part of message.content) {
|
|
122
|
+
if (part.type !== 'tool-result')
|
|
123
|
+
continue;
|
|
124
|
+
const rawOutput = part.output;
|
|
125
|
+
if (!rawOutput || rawOutput.type !== 'json' || !rawOutput.value || typeof rawOutput.value !== 'object')
|
|
126
|
+
continue;
|
|
127
|
+
const value = rawOutput.value;
|
|
128
|
+
if (value.pageDiff && typeof value.pageDiff === 'object') {
|
|
129
|
+
const pageDiff = value.pageDiff;
|
|
130
|
+
if (Array.isArray(pageDiff.htmlParts)) {
|
|
131
|
+
pageDiff.htmlParts = undefined;
|
|
132
|
+
pageDiff.compacted = true;
|
|
133
|
+
}
|
|
134
|
+
if (typeof pageDiff.ariaChanges === 'string' && pageDiff.ariaChanges.length > AUTO_COMPACT_ARIA_CHANGES_CUTOFF) {
|
|
135
|
+
pageDiff.ariaChanges = `${pageDiff.ariaChanges.slice(0, AUTO_COMPACT_ARIA_CHANGES_CUTOFF)}...`;
|
|
136
|
+
}
|
|
137
|
+
if (typeof pageDiff.iframes === 'string') {
|
|
138
|
+
pageDiff.iframes = undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (typeof value.targetedHtml === 'string' && value.targetedHtml.length > AUTO_COMPACT_TARGETED_HTML_CUTOFF) {
|
|
142
|
+
value.targetedHtml = `${value.targetedHtml.slice(0, AUTO_COMPACT_TARGETED_HTML_CUTOFF)}...`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
108
147
|
hasTag(tagName, lastN) {
|
|
109
148
|
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
110
149
|
const regex = new RegExp(`<${escapedTag}>`, 'g');
|