explorbot 0.1.10 → 0.1.11

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 (84) hide show
  1. package/README.md +27 -1
  2. package/bin/explorbot-cli.ts +27 -18
  3. package/dist/bin/explorbot-cli.js +26 -18
  4. package/dist/package.json +2 -2
  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 +46 -38
  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 +320 -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/utils.js +18 -0
  16. package/dist/src/ai/historian.js +19 -405
  17. package/dist/src/ai/navigator.js +82 -29
  18. package/dist/src/ai/pilot.js +232 -13
  19. package/dist/src/ai/planner.js +29 -9
  20. package/dist/src/ai/provider.js +54 -17
  21. package/dist/src/ai/researcher.js +41 -32
  22. package/dist/src/ai/rules.js +26 -14
  23. package/dist/src/ai/tester.js +90 -26
  24. package/dist/src/ai/tools.js +13 -7
  25. package/dist/src/browser-server.js +16 -3
  26. package/dist/src/commands/add-rule-command.js +11 -8
  27. package/dist/src/commands/clean-command.js +2 -1
  28. package/dist/src/commands/explore-command.js +27 -15
  29. package/dist/src/commands/init-command.js +9 -8
  30. package/dist/src/commands/plan-command.js +32 -0
  31. package/dist/src/commands/plan-save-command.js +19 -7
  32. package/dist/src/commands/rerun-command.js +4 -0
  33. package/dist/src/components/App.js +15 -5
  34. package/dist/src/execution-controller.js +13 -2
  35. package/dist/src/experience-tracker.js +20 -64
  36. package/dist/src/explorbot.js +5 -8
  37. package/dist/src/explorer.js +9 -2
  38. package/dist/src/observability.js +50 -99
  39. package/dist/src/playwright-recorder.js +309 -0
  40. package/dist/src/test-plan.js +12 -0
  41. package/dist/src/utils/aria.js +37 -1
  42. package/dist/src/utils/error-page.js +20 -7
  43. package/dist/src/utils/next-steps.js +37 -0
  44. package/package.json +2 -2
  45. package/rules/navigator/output.md +9 -0
  46. package/rules/navigator/verification-actions.md +2 -0
  47. package/src/action-result.ts +26 -1
  48. package/src/action.ts +44 -37
  49. package/src/ai/bosun.ts +11 -1
  50. package/src/ai/conversation.ts +37 -0
  51. package/src/ai/historian/codeceptjs.ts +130 -0
  52. package/src/ai/historian/experience.ts +383 -0
  53. package/src/ai/historian/mixin.ts +4 -0
  54. package/src/ai/historian/playwright.ts +169 -0
  55. package/src/ai/historian/utils.ts +23 -0
  56. package/src/ai/historian.ts +35 -473
  57. package/src/ai/navigator.ts +82 -29
  58. package/src/ai/pilot.ts +237 -14
  59. package/src/ai/planner.ts +29 -9
  60. package/src/ai/provider.ts +51 -17
  61. package/src/ai/researcher.ts +45 -33
  62. package/src/ai/rules.ts +27 -14
  63. package/src/ai/tester.ts +94 -26
  64. package/src/ai/tools.ts +47 -25
  65. package/src/browser-server.ts +17 -3
  66. package/src/commands/add-rule-command.ts +11 -7
  67. package/src/commands/clean-command.ts +2 -1
  68. package/src/commands/explore-command.ts +29 -15
  69. package/src/commands/init-command.ts +9 -8
  70. package/src/commands/plan-command.ts +35 -0
  71. package/src/commands/plan-save-command.ts +18 -7
  72. package/src/commands/rerun-command.ts +5 -0
  73. package/src/components/App.tsx +16 -5
  74. package/src/config.ts +6 -1
  75. package/src/execution-controller.ts +14 -3
  76. package/src/experience-tracker.ts +21 -72
  77. package/src/explorbot.ts +5 -8
  78. package/src/explorer.ts +11 -2
  79. package/src/observability.ts +50 -109
  80. package/src/playwright-recorder.ts +305 -0
  81. package/src/test-plan.ts +12 -0
  82. package/src/utils/aria.ts +38 -1
  83. package/src/utils/error-page.ts +22 -7
  84. package/src/utils/next-steps.ts +51 -0
package/README.md CHANGED
@@ -169,10 +169,36 @@ 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. Choose the framework in your config:
177
+
178
+ ```js
179
+ ai: { agents: { historian: { framework: 'playwright' } } } // or 'codeceptjs' (default)
180
+ ```
181
+
182
+ 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:
183
+
184
+ ```ts
185
+ test('Create a new manual plan', async ({ page }) => {
186
+ await test.step("Click the 'New plan' button in toolbar", async () => {
187
+ await page.getByRole('button', { name: 'New plan' }).first().click();
188
+ });
189
+
190
+ await test.step('Select Manual plan type in modal', async () => {
191
+ await page.locator('#portal-container').getByRole('button', { name: 'Manual' }).click();
192
+ });
193
+
194
+ await test.step('Verification', async () => {
195
+ await expect(page).toContainText('Test Plan UI Creation 001');
196
+ });
197
+ });
198
+ ```
199
+
200
+ See [Automated Tests](docs/automated-tests.md) for the CodeceptJS version and how failed or unfinished scenarios are handled.
201
+
176
202
  ## Two Ways to Run
177
203
 
178
204
  **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.11",
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",
@@ -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;
@@ -24,11 +24,15 @@ class Action {
24
24
  expectation = null;
25
25
  lastError = null;
26
26
  playwrightHelper;
27
- constructor(actor, stateManager) {
27
+ playwrightGroupId = null;
28
+ assertionSteps = [];
29
+ recorder;
30
+ constructor(actor, stateManager, recorder) {
28
31
  this.actor = actor;
29
32
  this.stateManager = stateManager;
30
33
  this.config = ConfigParser.getInstance().getConfig();
31
34
  this.playwrightHelper = container.helpers('Playwright');
35
+ this.recorder = recorder;
32
36
  }
33
37
  async caputrePageWithScreenshot() {
34
38
  return this.capturePageState({ includeScreenshot: true });
@@ -57,7 +61,15 @@ class Action {
57
61
  const timestamp = Date.now();
58
62
  const page = this.playwrightHelper.page;
59
63
  const frame = this.playwrightHelper.frame;
60
- const [html, title, browserLogs] = await Promise.all([this.actor.grabSource(), this.actor.grabTitle(), this.captureBrowserLogs()]);
64
+ await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
65
+ const grabAll = () => Promise.all([this.actor.grabSource(), this.actor.grabTitle(), this.captureBrowserLogs()]);
66
+ const [html, title, browserLogs] = await grabAll().catch(async (err) => {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ if (!/navigating and changing the content/i.test(msg))
69
+ throw err;
70
+ await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
71
+ return grabAll();
72
+ });
61
73
  const url = page?.url() || (await this.actor.grabCurrentUrl?.());
62
74
  let screenshotFile = undefined;
63
75
  if (includeScreenshot) {
@@ -183,7 +195,10 @@ class Action {
183
195
  setActivity('🔎 Browsing...', 'action');
184
196
  let codeString = code.replace(/^\(I\) => /, '').trim();
185
197
  const executedSteps = [];
186
- registerStepLogger(executedSteps);
198
+ const assertionSteps = [];
199
+ const stepListener = attachStepLogger(executedSteps, assertionSteps);
200
+ const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
201
+ this.playwrightGroupId = groupId;
187
202
  const activeSpan = Observability.getSpan();
188
203
  const tracer = trace.getTracer('ai');
189
204
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
@@ -213,6 +228,7 @@ class Action {
213
228
  }
214
229
  this.stateManager.updateState(pageState, codeString);
215
230
  this.actionResult = pageState;
231
+ this.assertionSteps = assertionSteps;
216
232
  }
217
233
  catch (err) {
218
234
  debugLog('Action error', errorToString(err));
@@ -221,10 +237,13 @@ class Action {
221
237
  await recorder.reset();
222
238
  await recorder.start();
223
239
  }
240
+ this.assertionSteps = [];
224
241
  throw err;
225
242
  }
226
243
  finally {
227
- unregisterStepLogger();
244
+ if (groupId)
245
+ await this.recorder.endAction();
246
+ detachStepLogger(stepListener);
228
247
  if (stepSpan) {
229
248
  stepSpan.end();
230
249
  }
@@ -350,39 +369,28 @@ function hasPlaywrightCommands(code) {
350
369
  function sleep(ms) {
351
370
  return new Promise((resolve) => setTimeout(resolve, ms));
352
371
  }
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);
372
+ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeElement', 'seeInField', 'dontSeeInField', 'seeInCurrentUrl', 'dontSeeInCurrentUrl']);
373
+ const attachStepLogger = (target, assertionsTarget) => {
374
+ const listener = (step, error) => {
375
+ if (!step?.toCode)
376
+ return;
377
+ if (step.name?.startsWith('grab'))
378
+ return;
379
+ target.push(step.toCode());
380
+ if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
381
+ assertionsTarget.push({ name: step.name, args: step.args || [] });
382
+ }
383
+ if (error) {
384
+ tag('step').log(step, error);
385
+ return;
386
+ }
387
+ tag('step').log(step);
388
+ };
389
+ codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
390
+ codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
391
+ return listener;
379
392
  };
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);
393
+ const detachStepLogger = (listener) => {
394
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
395
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
388
396
  };
@@ -6,8 +6,10 @@ import { setActivity } from "../activity.js";
6
6
  import { Observability } from "../observability.js";
7
7
  import { Plan, Task, Test, TestResult } from "../test-plan.js";
8
8
  import { HooksRunner } from "../utils/hooks-runner.js";
9
+ import { getCliName } from "../utils/cli-name.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');
@@ -0,0 +1,109 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ActionResult } from "../../action-result.js";
4
+ import { ConfigParser } from "../../config.js";
5
+ import { KnowledgeTracker } from "../../knowledge-tracker.js";
6
+ import { tag } from "../../utils/logger.js";
7
+ import { relativeToCwd } from "../../utils/next-steps.js";
8
+ import { ASSERTION_TOOLS, CODECEPT_TOOLS } from "../tools.js";
9
+ import { escapeString, getExecutionLabel, isNonReusableCode, stripComments } from "./utils.js";
10
+ export function WithCodeceptJS(Base) {
11
+ return class extends Base {
12
+ toCode(conversation, scenario) {
13
+ const toolExecutions = conversation.getToolExecutions();
14
+ const TRACKABLE_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
15
+ const successfulSteps = toolExecutions.filter((exec) => exec.wasSuccessful && TRACKABLE_TOOLS.includes(exec.toolName) && exec.output?.code);
16
+ if (successfulSteps.length === 0) {
17
+ return '';
18
+ }
19
+ const lines = [];
20
+ lines.push(`Scenario('${escapeString(scenario)}', ({ I }) => {`);
21
+ for (const exec of successfulSteps) {
22
+ if (isNonReusableCode(exec.output.code))
23
+ continue;
24
+ const explanation = getExecutionLabel(exec);
25
+ if (explanation) {
26
+ lines.push('');
27
+ lines.push(` Section('${escapeString(explanation)}');`);
28
+ }
29
+ const code = stripComments(exec.output.code);
30
+ const codeLines = code.includes('\n') ? code.split('\n') : code.split('; ');
31
+ for (const codeLine of codeLines) {
32
+ const trimmed = codeLine.trim();
33
+ if (trimmed) {
34
+ lines.push(` ${trimmed}`);
35
+ }
36
+ }
37
+ }
38
+ lines.push('});');
39
+ return lines.join('\n');
40
+ }
41
+ saveCodeceptPlanToFile(plan) {
42
+ const lines = [];
43
+ lines.push(`import step, { Section } from 'codeceptjs/steps';`);
44
+ lines.push('');
45
+ lines.push(`Feature('${escapeString(plan.title)}')`);
46
+ lines.push('');
47
+ const startUrl = plan.url || plan.tests[0]?.startUrl;
48
+ if (startUrl) {
49
+ lines.push('Before(({ I }) => {');
50
+ lines.push(` I.amOnPage('${escapeString(startUrl)}');`);
51
+ lines.push(...this.getKnowledgeLines(startUrl));
52
+ lines.push('});');
53
+ lines.push('');
54
+ }
55
+ for (const test of plan.tests) {
56
+ if (test.generatedCode) {
57
+ if (test.isSuccessful) {
58
+ lines.push(test.generatedCode);
59
+ }
60
+ else {
61
+ lines.push(`// FAILED: ${test.scenario}`);
62
+ lines.push(test.generatedCode.replace(/Scenario\(/, 'Scenario.skip('));
63
+ }
64
+ lines.push('');
65
+ continue;
66
+ }
67
+ lines.push(`Scenario.todo('${escapeString(test.scenario)}', ({ I }) => {`);
68
+ if (test.plannedSteps.length > 0) {
69
+ for (const step of test.plannedSteps) {
70
+ lines.push(` // ${step}`);
71
+ }
72
+ }
73
+ else {
74
+ lines.push(` // ${test.scenario}`);
75
+ }
76
+ lines.push('});');
77
+ lines.push('');
78
+ }
79
+ const testsDir = ConfigParser.getInstance().getTestsDir();
80
+ mkdirSync(testsDir, { recursive: true });
81
+ const filename = plan.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
82
+ const filePath = join(testsDir, `${filename}.js`);
83
+ writeFileSync(filePath, lines.join('\n'));
84
+ this.savedFiles.add(filePath);
85
+ tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
86
+ return filePath;
87
+ }
88
+ getKnowledgeLines(url, indent = ' ') {
89
+ const knowledgeTracker = new KnowledgeTracker();
90
+ const state = new ActionResult({ url });
91
+ const { wait, waitForElement, code } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement', 'code']);
92
+ const lines = [];
93
+ if (wait !== undefined) {
94
+ lines.push(`${indent}I.wait(${wait});`);
95
+ }
96
+ if (waitForElement) {
97
+ lines.push(`${indent}I.waitForElement(${JSON.stringify(waitForElement)});`);
98
+ }
99
+ if (code) {
100
+ for (const codeLine of code.split('\n')) {
101
+ const trimmed = codeLine.trim();
102
+ if (trimmed)
103
+ lines.push(`${indent}${trimmed}`);
104
+ }
105
+ }
106
+ return lines;
107
+ }
108
+ };
109
+ }