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.
Files changed (90) hide show
  1. package/README.md +37 -1
  2. package/bin/explorbot-cli.ts +27 -18
  3. package/dist/bin/explorbot-cli.js +26 -18
  4. package/dist/package.json +3 -3
  5. package/dist/rules/navigator/output.md +9 -0
  6. package/dist/rules/navigator/verification-actions.md +2 -0
  7. package/dist/src/action-result.js +23 -1
  8. package/dist/src/action.js +51 -42
  9. package/dist/src/ai/bosun.js +11 -1
  10. package/dist/src/ai/conversation.js +39 -0
  11. package/dist/src/ai/historian/codeceptjs.js +109 -0
  12. package/dist/src/ai/historian/experience.js +321 -0
  13. package/dist/src/ai/historian/mixin.js +2 -0
  14. package/dist/src/ai/historian/playwright.js +145 -0
  15. package/dist/src/ai/historian/screencast.js +121 -0
  16. package/dist/src/ai/historian/utils.js +18 -0
  17. package/dist/src/ai/historian.js +21 -405
  18. package/dist/src/ai/navigator.js +82 -29
  19. package/dist/src/ai/pilot.js +232 -13
  20. package/dist/src/ai/planner.js +29 -9
  21. package/dist/src/ai/provider.js +54 -17
  22. package/dist/src/ai/researcher.js +41 -32
  23. package/dist/src/ai/rules.js +26 -14
  24. package/dist/src/ai/tester.js +90 -26
  25. package/dist/src/ai/tools.js +13 -7
  26. package/dist/src/browser-server.js +16 -3
  27. package/dist/src/commands/add-rule-command.js +11 -8
  28. package/dist/src/commands/clean-command.js +2 -1
  29. package/dist/src/commands/explore-command.js +43 -15
  30. package/dist/src/commands/init-command.js +9 -8
  31. package/dist/src/commands/plan-command.js +32 -0
  32. package/dist/src/commands/plan-save-command.js +19 -7
  33. package/dist/src/commands/rerun-command.js +4 -0
  34. package/dist/src/components/App.js +15 -5
  35. package/dist/src/execution-controller.js +13 -2
  36. package/dist/src/experience-tracker.js +20 -64
  37. package/dist/src/explorbot.js +8 -8
  38. package/dist/src/explorer.js +11 -3
  39. package/dist/src/observability.js +50 -99
  40. package/dist/src/playwright-recorder.js +309 -0
  41. package/dist/src/reporter.js +4 -1
  42. package/dist/src/test-plan.js +12 -0
  43. package/dist/src/utils/aria.js +37 -1
  44. package/dist/src/utils/error-page.js +20 -7
  45. package/dist/src/utils/next-steps.js +37 -0
  46. package/dist/src/utils/strings.js +15 -0
  47. package/package.json +3 -3
  48. package/rules/navigator/output.md +9 -0
  49. package/rules/navigator/verification-actions.md +2 -0
  50. package/src/action-result.ts +26 -1
  51. package/src/action.ts +49 -41
  52. package/src/ai/bosun.ts +11 -1
  53. package/src/ai/conversation.ts +37 -0
  54. package/src/ai/historian/codeceptjs.ts +130 -0
  55. package/src/ai/historian/experience.ts +384 -0
  56. package/src/ai/historian/mixin.ts +4 -0
  57. package/src/ai/historian/playwright.ts +169 -0
  58. package/src/ai/historian/screencast.ts +133 -0
  59. package/src/ai/historian/utils.ts +23 -0
  60. package/src/ai/historian.ts +37 -473
  61. package/src/ai/navigator.ts +82 -29
  62. package/src/ai/pilot.ts +237 -14
  63. package/src/ai/planner.ts +29 -9
  64. package/src/ai/provider.ts +51 -17
  65. package/src/ai/researcher.ts +45 -33
  66. package/src/ai/rules.ts +27 -14
  67. package/src/ai/tester.ts +94 -26
  68. package/src/ai/tools.ts +47 -25
  69. package/src/browser-server.ts +17 -3
  70. package/src/commands/add-rule-command.ts +11 -7
  71. package/src/commands/clean-command.ts +2 -1
  72. package/src/commands/explore-command.ts +46 -14
  73. package/src/commands/init-command.ts +9 -8
  74. package/src/commands/plan-command.ts +35 -0
  75. package/src/commands/plan-save-command.ts +18 -7
  76. package/src/commands/rerun-command.ts +5 -0
  77. package/src/components/App.tsx +16 -5
  78. package/src/config.ts +12 -1
  79. package/src/execution-controller.ts +14 -3
  80. package/src/experience-tracker.ts +21 -72
  81. package/src/explorbot.ts +8 -8
  82. package/src/explorer.ts +13 -3
  83. package/src/observability.ts +50 -109
  84. package/src/playwright-recorder.ts +305 -0
  85. package/src/reporter.ts +4 -1
  86. package/src/test-plan.ts +12 -0
  87. package/src/utils/aria.ts +38 -1
  88. package/src/utils/error-page.ts +22 -7
  89. package/src/utils/next-steps.ts +51 -0
  90. 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` | CodeceptJS tests you can run independently |
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:
@@ -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
- program.hook('preAction', () => {
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 { PlanCommand } = await import('../src/commands/plan-command.js');
197
- const planCommand = new PlanCommand(explorBot);
198
- planCommand.suggestions = [
199
- { command: `test ${planFile} 1${cliSuffix}`, hint: 'run first new test' },
200
- { command: `test ${planFile} *${cliSuffix}`, hint: 'run all new tests' },
201
- ];
202
- if (suite && suite.automatedTestCount > 0) {
203
- for (const f of suite.getAutomatedTestFiles()) {
204
- planCommand.suggestions.push({
205
- command: `rerun ${path.relative(process.cwd(), f)}${cliSuffix}`,
206
- hint: 're-run automated tests',
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
- planCommand.printSuggestions();
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
- program.hook('preAction', () => {
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 { PlanCommand } = await import('../src/commands/plan-command.js');
162
- const planCommand = new PlanCommand(explorBot);
163
- planCommand.suggestions = [
164
- { command: `test ${planFile} 1${cliSuffix}`, hint: 'run first new test' },
165
- { command: `test ${planFile} *${cliSuffix}`, hint: 'run all new tests' },
166
- ];
167
- if (suite && suite.automatedTestCount > 0) {
168
- for (const f of suite.getAutomatedTestFiles()) {
169
- planCommand.suggestions.push({
170
- command: `rerun ${path.relative(process.cwd(), f)}${cliSuffix}`,
171
- hint: 're-run automated tests',
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
- planCommand.printSuggestions();
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.10",
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.11",
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.40.0",
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;
@@ -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
- constructor(actor, stateManager) {
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
- const [html, title, browserLogs] = await Promise.all([this.actor.grabSource(), this.actor.grabTitle(), this.captureBrowserLogs()]);
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
- registerStepLogger(executedSteps);
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
- unregisterStepLogger();
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
- let stepLoggerRegistered = false;
354
- let stepLoggerTarget = null;
355
- const stepLogger = (step, error) => {
356
- if (!step?.toCode) {
357
- return;
358
- }
359
- if (step.name?.startsWith('grab'))
360
- return;
361
- const stepCode = step.toCode();
362
- if (stepLoggerTarget) {
363
- stepLoggerTarget.push(stepCode);
364
- }
365
- if (error) {
366
- tag('step').log(step, error);
367
- return;
368
- }
369
- tag('step').log(step);
370
- };
371
- const registerStepLogger = (target) => {
372
- stepLoggerTarget = target;
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 unregisterStepLogger = () => {
381
- stepLoggerTarget = null;
382
- if (!stepLoggerRegistered) {
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
  };
@@ -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
- tag('success').log(`Knowledge saved to: ${result.filePath}`);
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');