explorbot 0.1.9 → 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 (157) hide show
  1. package/README.md +27 -1
  2. package/bin/explorbot-cli.ts +86 -15
  3. package/boat/api-tester/src/ai/curler-tools.ts +3 -3
  4. package/boat/api-tester/src/ai/curler.ts +1 -1
  5. package/boat/api-tester/src/apibot.ts +2 -2
  6. package/boat/api-tester/src/config.ts +1 -1
  7. package/dist/bin/explorbot-cli.js +85 -14
  8. package/dist/boat/api-tester/src/ai/curler-tools.js +2 -2
  9. package/dist/boat/api-tester/src/apibot.js +2 -2
  10. package/dist/package.json +2 -2
  11. package/dist/rules/navigator/output.md +9 -0
  12. package/dist/rules/navigator/verification-actions.md +2 -0
  13. package/dist/src/action-result.js +23 -1
  14. package/dist/src/action.js +46 -38
  15. package/dist/src/ai/bosun.js +16 -2
  16. package/dist/src/ai/conversation.js +39 -0
  17. package/dist/src/ai/experience-compactor.js +235 -50
  18. package/dist/src/ai/historian/codeceptjs.js +109 -0
  19. package/dist/src/ai/historian/experience.js +320 -0
  20. package/dist/src/ai/historian/mixin.js +2 -0
  21. package/dist/src/ai/historian/playwright.js +145 -0
  22. package/dist/src/ai/historian/utils.js +18 -0
  23. package/dist/src/ai/historian.js +19 -398
  24. package/dist/src/ai/navigator.js +133 -80
  25. package/dist/src/ai/pilot.js +254 -13
  26. package/dist/src/ai/planner/subpages.js +1 -30
  27. package/dist/src/ai/planner.js +33 -13
  28. package/dist/src/ai/provider.js +55 -18
  29. package/dist/src/ai/rerunner.js +3 -3
  30. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  31. package/dist/src/ai/researcher/fingerprint-worker.js +1 -1
  32. package/dist/src/ai/researcher/locators.js +1 -1
  33. package/dist/src/ai/researcher/sections.js +8 -1
  34. package/dist/src/ai/researcher.js +43 -41
  35. package/dist/src/ai/rules.js +26 -14
  36. package/dist/src/ai/tester.js +90 -26
  37. package/dist/src/ai/tools.js +18 -10
  38. package/dist/src/api/request-store.js +20 -0
  39. package/dist/src/api/xhr-capture.js +19 -3
  40. package/dist/src/browser-server.js +16 -3
  41. package/dist/src/command-handler.js +1 -1
  42. package/dist/src/commands/add-rule-command.js +12 -9
  43. package/dist/src/commands/base-command.js +20 -0
  44. package/dist/src/commands/clean-command.js +3 -2
  45. package/dist/src/commands/compact-command.js +138 -0
  46. package/dist/src/commands/context-command.js +7 -1
  47. package/dist/src/commands/drill-command.js +4 -1
  48. package/dist/src/commands/experience-command.js +104 -0
  49. package/dist/src/commands/explore-command.js +54 -19
  50. package/dist/src/commands/freesail-command.js +2 -0
  51. package/dist/src/commands/index.js +7 -3
  52. package/dist/src/commands/init-command.js +11 -10
  53. package/dist/src/commands/learn-command.js +1 -1
  54. package/dist/src/commands/navigate-command.js +4 -1
  55. package/dist/src/commands/plan-clear-command.js +4 -1
  56. package/dist/src/commands/plan-command.js +43 -4
  57. package/dist/src/commands/plan-edit-command.js +1 -1
  58. package/dist/src/commands/plan-load-command.js +4 -1
  59. package/dist/src/commands/plan-reload-command.js +4 -1
  60. package/dist/src/commands/plan-save-command.js +20 -8
  61. package/dist/src/commands/rerun-command.js +4 -0
  62. package/dist/src/commands/research-command.js +5 -2
  63. package/dist/src/commands/start-command.js +5 -1
  64. package/dist/src/commands/test-command.js +7 -1
  65. package/dist/src/components/App.js +15 -5
  66. package/dist/src/execution-controller.js +13 -2
  67. package/dist/src/experience-tracker.js +174 -83
  68. package/dist/src/explorbot.js +31 -22
  69. package/dist/src/explorer.js +12 -5
  70. package/dist/src/observability.js +50 -99
  71. package/dist/src/playwright-recorder.js +309 -0
  72. package/dist/src/reporter.js +17 -2
  73. package/dist/src/stats.js +2 -0
  74. package/dist/src/suite.js +1 -1
  75. package/dist/src/test-plan.js +12 -0
  76. package/dist/src/utils/aria.js +37 -1
  77. package/dist/src/utils/error-page.js +30 -7
  78. package/dist/src/utils/logger.js +1 -1
  79. package/dist/src/utils/next-steps.js +37 -0
  80. package/dist/src/utils/rules-loader.js +1 -1
  81. package/dist/src/utils/test-files.js +1 -1
  82. package/dist/src/utils/url-matcher.js +50 -0
  83. package/package.json +2 -2
  84. package/rules/navigator/output.md +9 -0
  85. package/rules/navigator/verification-actions.md +2 -0
  86. package/src/action-result.ts +26 -1
  87. package/src/action.ts +44 -37
  88. package/src/ai/bosun.ts +16 -2
  89. package/src/ai/conversation.ts +37 -0
  90. package/src/ai/experience-compactor.ts +270 -63
  91. package/src/ai/historian/codeceptjs.ts +130 -0
  92. package/src/ai/historian/experience.ts +383 -0
  93. package/src/ai/historian/mixin.ts +4 -0
  94. package/src/ai/historian/playwright.ts +169 -0
  95. package/src/ai/historian/utils.ts +23 -0
  96. package/src/ai/historian.ts +35 -468
  97. package/src/ai/navigator.ts +140 -85
  98. package/src/ai/pilot.ts +259 -14
  99. package/src/ai/planner/subpages.ts +1 -24
  100. package/src/ai/planner.ts +34 -14
  101. package/src/ai/provider.ts +52 -18
  102. package/src/ai/rerunner.ts +3 -3
  103. package/src/ai/researcher/deep-analysis.ts +1 -1
  104. package/src/ai/researcher/fingerprint-worker.ts +1 -1
  105. package/src/ai/researcher/locators.ts +2 -2
  106. package/src/ai/researcher/sections.ts +7 -1
  107. package/src/ai/researcher.ts +47 -42
  108. package/src/ai/rules.ts +27 -14
  109. package/src/ai/task-agent.ts +1 -1
  110. package/src/ai/tester.ts +94 -26
  111. package/src/ai/tools.ts +53 -29
  112. package/src/api/request-store.ts +22 -0
  113. package/src/api/xhr-capture.ts +21 -3
  114. package/src/browser-server.ts +17 -3
  115. package/src/command-handler.ts +1 -1
  116. package/src/commands/add-rule-command.ts +13 -9
  117. package/src/commands/base-command.ts +26 -1
  118. package/src/commands/clean-command.ts +4 -3
  119. package/src/commands/compact-command.ts +156 -0
  120. package/src/commands/context-command.ts +8 -2
  121. package/src/commands/drill-command.ts +5 -2
  122. package/src/commands/experience-command.ts +125 -0
  123. package/src/commands/explore-command.ts +58 -21
  124. package/src/commands/freesail-command.ts +2 -0
  125. package/src/commands/index.ts +7 -3
  126. package/src/commands/init-command.ts +11 -10
  127. package/src/commands/learn-command.ts +2 -2
  128. package/src/commands/navigate-command.ts +5 -2
  129. package/src/commands/plan-clear-command.ts +5 -2
  130. package/src/commands/plan-command.ts +47 -5
  131. package/src/commands/plan-edit-command.ts +2 -2
  132. package/src/commands/plan-load-command.ts +5 -2
  133. package/src/commands/plan-reload-command.ts +5 -2
  134. package/src/commands/plan-save-command.ts +20 -9
  135. package/src/commands/rerun-command.ts +5 -0
  136. package/src/commands/research-command.ts +6 -3
  137. package/src/commands/start-command.ts +6 -2
  138. package/src/commands/test-command.ts +8 -2
  139. package/src/components/App.tsx +16 -5
  140. package/src/config.ts +6 -1
  141. package/src/execution-controller.ts +14 -3
  142. package/src/experience-tracker.ts +198 -100
  143. package/src/explorbot.ts +33 -23
  144. package/src/explorer.ts +14 -5
  145. package/src/observability.ts +50 -109
  146. package/src/playwright-recorder.ts +305 -0
  147. package/src/reporter.ts +17 -3
  148. package/src/stats.ts +4 -0
  149. package/src/suite.ts +1 -1
  150. package/src/test-plan.ts +12 -0
  151. package/src/utils/aria.ts +38 -1
  152. package/src/utils/error-page.ts +32 -7
  153. package/src/utils/logger.ts +1 -1
  154. package/src/utils/next-steps.ts +51 -0
  155. package/src/utils/rules-loader.ts +1 -1
  156. package/src/utils/test-files.ts +1 -1
  157. package/src/utils/url-matcher.ts +43 -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
- console.log(chalk.dim(`${cli} v${pkgVersion}`));
30
- });
29
+
30
+ if (!process.env.EXPLORBOT_NO_BANNER) {
31
+ console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
32
+ }
31
33
 
32
34
  interface CLIOptions {
33
35
  verbose?: boolean;
@@ -188,22 +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 lines: string[] = [];
197
- lines.push('Run commands:');
198
- lines.push(`\`${cli} test ${planFile} 1${cliSuffix}\` → run first new test`);
199
- lines.push(`\`${cli} test ${planFile} *${cliSuffix}\` → run all new tests`);
200
- if (suite && suite.automatedTestCount > 0) {
201
- for (const f of suite.getAutomatedTestFiles()) {
202
- lines.push(`\`${cli} rerun ${path.relative(process.cwd(), f)}${cliSuffix}\` → re-run automated tests`);
203
- }
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
+ });
204
209
  }
205
-
206
- log(parseMarkdownToTerminal(lines.join('\n')));
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
+ });
217
+ }
218
+ printNextSteps(sections);
207
219
 
208
220
  await explorBot.stop();
209
221
  await showStatsAndExit(0);
@@ -383,7 +395,7 @@ program
383
395
 
384
396
  program
385
397
  .command('clean [target]')
386
- .description('Clean files: states, research, plans, experiences, output (default: output + experiences)')
398
+ .description('Clean files: states, research, plans, tests, experiences, output (default: output + experiences)')
387
399
  .option('-p, --path <path>', 'Custom path to clean')
388
400
  .action(async (target, options) => {
389
401
  const customPath = options.path;
@@ -394,6 +406,7 @@ program
394
406
  states: { description: 'page states', dir: path.join(basePath, 'output', 'states') },
395
407
  research: { description: 'research cache', dir: path.join(basePath, 'output', 'research') },
396
408
  plans: { description: 'test plans', dir: path.join(basePath, 'output', 'plans') },
409
+ tests: { description: 'generated tests', dir: path.join(basePath, 'output', 'tests') },
397
410
  experiences: { description: 'experience files', dir: path.join(basePath, 'experience') },
398
411
  output: { description: 'all output files', dir: path.join(basePath, 'output') },
399
412
  };
@@ -492,6 +505,64 @@ program
492
505
  }
493
506
  });
494
507
 
508
+ program
509
+ .command('compact [target]')
510
+ .description('Compact stored experience files; pass filename or URL substring to limit scope')
511
+ .option('--dry-run', 'Preview without running AI or writing files')
512
+ .option('--no-merge', 'Skip the cross-URL merge step when compacting all')
513
+ .option('-p, --path <path>', 'Working directory path')
514
+ .option('-c, --config <path>', 'Path to configuration file')
515
+ .option('-v, --verbose', 'Enable verbose logging')
516
+ .action(async (target, options) => {
517
+ try {
518
+ const explorBot = new ExplorBot({
519
+ path: options.path,
520
+ config: options.config,
521
+ verbose: options.verbose,
522
+ });
523
+ await explorBot.startProviderOnly();
524
+
525
+ const { CompactCommand } = await import('../src/commands/compact-command.js');
526
+ const argParts = [target, options.dryRun && '--dry-run', options.merge === false && '--no-merge'].filter(Boolean).join(' ');
527
+ const command = new CompactCommand(explorBot);
528
+ await command.execute(argParts);
529
+ command.printSuggestions();
530
+
531
+ await showStatsAndExit(0);
532
+ } catch (error) {
533
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
534
+ await showStatsAndExit(1);
535
+ }
536
+ });
537
+
538
+ program
539
+ .command('experience [filter] [index]')
540
+ .description('List stored experiences grouped by URL; pass URL substring to filter; pass tag (A.1) or index to expand a section')
541
+ .option('-p, --path <path>', 'Working directory path')
542
+ .option('-c, --config <path>', 'Path to configuration file')
543
+ .option('--recent', 'Only files modified within the last 30 days')
544
+ .option('--old', 'Only files modified more than 30 days ago')
545
+ .action(async (filter, index, options) => {
546
+ try {
547
+ await ConfigParser.getInstance().loadConfig({
548
+ config: options.config,
549
+ path: options.path || process.cwd(),
550
+ });
551
+ const { ExperienceCommand } = await import('../src/commands/experience-command.js');
552
+ const explorBot = new ExplorBot({ path: options.path });
553
+ const command = new ExperienceCommand(explorBot);
554
+ const flags: string[] = [];
555
+ if (options.recent) flags.push('--recent');
556
+ if (options.old) flags.push('--old');
557
+ const args = [...flags, filter, index].filter(Boolean).join(' ');
558
+ await command.execute(args);
559
+ command.printSuggestions();
560
+ } catch (error) {
561
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
562
+ process.exit(1);
563
+ }
564
+ });
565
+
495
566
  addCommonOptions(program.command('research <url>').description('Research a page and print UI analysis').option('--data', 'Include data extraction in research').option('--deep', 'Enable deep analysis (expand hidden elements)').option('--no-fix', 'Skip locator fix cycle (for debugging)')).action(
496
567
  async (url, options) => {
497
568
  try {
@@ -1,13 +1,13 @@
1
- import { tool } from 'ai';
2
- import { expect } from 'expect';
3
1
  import { readFileSync } from 'node:fs';
2
+ import { tool } from 'ai';
4
3
  import dedent from 'dedent';
4
+ import { expect } from 'expect';
5
5
  import { z } from 'zod';
6
+ import type { RequestStore } from '../../../../src/api/request-store.ts';
6
7
  import type { Test, TestResultType } from '../../../../src/test-plan.ts';
7
8
  import { TestResult } from '../../../../src/test-plan.ts';
8
9
  import { tag } from '../../../../src/utils/logger.ts';
9
10
  import type { ApiClient } from '../api-client.ts';
10
- import type { RequestStore } from '../../../../src/api/request-store.ts';
11
11
 
12
12
  const readResponseData = (responseFile: string) => {
13
13
  return JSON.parse(readFileSync(responseFile, 'utf8'));
@@ -1,12 +1,12 @@
1
1
  import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
3
  import type { AIProvider } from '../../../../src/ai/provider.ts';
4
+ import type { RequestStore } from '../../../../src/api/request-store.ts';
4
5
  import type { Reporter } from '../../../../src/reporter.ts';
5
6
  import { type Test, TestResult } from '../../../../src/test-plan.ts';
6
7
  import { createDebug, tag } from '../../../../src/utils/logger.ts';
7
8
  import { loop } from '../../../../src/utils/loop.ts';
8
9
  import type { ApiClient } from '../api-client.ts';
9
- import type { RequestStore } from '../../../../src/api/request-store.ts';
10
10
  import { createCurlerTools } from './curler-tools.ts';
11
11
 
12
12
  const debugLog = createDebug('explorbot:curler');
@@ -1,6 +1,8 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { AIProvider } from '../../../src/ai/provider.ts';
4
+ import { RequestStore } from '../../../src/api/request-store.ts';
5
+ import { extractEndpointDefinition, loadSpec, searchEndpoints, validateSpecs } from '../../../src/api/spec-reader.ts';
4
6
  import { Reporter } from '../../../src/reporter.ts';
5
7
  import { Plan } from '../../../src/test-plan.ts';
6
8
  import { setVerboseMode, tag } from '../../../src/utils/logger.ts';
@@ -8,8 +10,6 @@ import { Chief } from './ai/chief.ts';
8
10
  import { Curler } from './ai/curler.ts';
9
11
  import { ApiClient } from './api-client.ts';
10
12
  import { type ApibotConfig, ApibotConfigParser } from './config.ts';
11
- import { RequestStore } from '../../../src/api/request-store.ts';
12
- import { extractEndpointDefinition, loadSpec, searchEndpoints, validateSpecs } from '../../../src/api/spec-reader.ts';
13
13
 
14
14
  export class ApiBot {
15
15
  private configParser: ApibotConfigParser;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import path, { resolve } from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
- import { EXPLORBOT_CONFIG_PATHS, type AIConfig, type ApiConfig as BaseApiConfig, type ApiHookFn } from '../../../src/config.ts';
4
+ import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, EXPLORBOT_CONFIG_PATHS } from '../../../src/config.ts';
5
5
 
6
6
  export type { AIConfig };
7
7
 
@@ -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
- console.log(chalk.dim(`${cli} v${pkgVersion}`));
27
- });
26
+ if (!process.env.EXPLORBOT_NO_BANNER) {
27
+ console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
28
+ }
28
29
  function buildExplorBotOptions(from, options) {
29
30
  return {
30
31
  from,
@@ -155,19 +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 lines = [];
162
- lines.push('Run commands:');
163
- lines.push(`\`${cli} test ${planFile} 1${cliSuffix}\` → run first new test`);
164
- lines.push(`\`${cli} test ${planFile} *${cliSuffix}\` → run all new tests`);
165
- if (suite && suite.automatedTestCount > 0) {
166
- for (const f of suite.getAutomatedTestFiles()) {
167
- lines.push(`\`${cli} rerun ${path.relative(process.cwd(), f)}${cliSuffix}\` → re-run automated tests`);
168
- }
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
+ });
169
173
  }
170
- log(parseMarkdownToTerminal(lines.join('\n')));
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
+ });
181
+ }
182
+ printNextSteps(sections);
171
183
  await explorBot.stop();
172
184
  await showStatsAndExit(0);
173
185
  }
@@ -336,7 +348,7 @@ program
336
348
  });
337
349
  program
338
350
  .command('clean [target]')
339
- .description('Clean files: states, research, plans, experiences, output (default: output + experiences)')
351
+ .description('Clean files: states, research, plans, tests, experiences, output (default: output + experiences)')
340
352
  .option('-p, --path <path>', 'Custom path to clean')
341
353
  .action(async (target, options) => {
342
354
  const customPath = options.path;
@@ -346,6 +358,7 @@ program
346
358
  states: { description: 'page states', dir: path.join(basePath, 'output', 'states') },
347
359
  research: { description: 'research cache', dir: path.join(basePath, 'output', 'research') },
348
360
  plans: { description: 'test plans', dir: path.join(basePath, 'output', 'plans') },
361
+ tests: { description: 'generated tests', dir: path.join(basePath, 'output', 'tests') },
349
362
  experiences: { description: 'experience files', dir: path.join(basePath, 'experience') },
350
363
  output: { description: 'all output files', dir: path.join(basePath, 'output') },
351
364
  };
@@ -439,6 +452,64 @@ program
439
452
  process.exit(1);
440
453
  }
441
454
  });
455
+ program
456
+ .command('compact [target]')
457
+ .description('Compact stored experience files; pass filename or URL substring to limit scope')
458
+ .option('--dry-run', 'Preview without running AI or writing files')
459
+ .option('--no-merge', 'Skip the cross-URL merge step when compacting all')
460
+ .option('-p, --path <path>', 'Working directory path')
461
+ .option('-c, --config <path>', 'Path to configuration file')
462
+ .option('-v, --verbose', 'Enable verbose logging')
463
+ .action(async (target, options) => {
464
+ try {
465
+ const explorBot = new ExplorBot({
466
+ path: options.path,
467
+ config: options.config,
468
+ verbose: options.verbose,
469
+ });
470
+ await explorBot.startProviderOnly();
471
+ const { CompactCommand } = await import('../src/commands/compact-command.js');
472
+ const argParts = [target, options.dryRun && '--dry-run', options.merge === false && '--no-merge'].filter(Boolean).join(' ');
473
+ const command = new CompactCommand(explorBot);
474
+ await command.execute(argParts);
475
+ command.printSuggestions();
476
+ await showStatsAndExit(0);
477
+ }
478
+ catch (error) {
479
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
480
+ await showStatsAndExit(1);
481
+ }
482
+ });
483
+ program
484
+ .command('experience [filter] [index]')
485
+ .description('List stored experiences grouped by URL; pass URL substring to filter; pass tag (A.1) or index to expand a section')
486
+ .option('-p, --path <path>', 'Working directory path')
487
+ .option('-c, --config <path>', 'Path to configuration file')
488
+ .option('--recent', 'Only files modified within the last 30 days')
489
+ .option('--old', 'Only files modified more than 30 days ago')
490
+ .action(async (filter, index, options) => {
491
+ try {
492
+ await ConfigParser.getInstance().loadConfig({
493
+ config: options.config,
494
+ path: options.path || process.cwd(),
495
+ });
496
+ const { ExperienceCommand } = await import('../src/commands/experience-command.js');
497
+ const explorBot = new ExplorBot({ path: options.path });
498
+ const command = new ExperienceCommand(explorBot);
499
+ const flags = [];
500
+ if (options.recent)
501
+ flags.push('--recent');
502
+ if (options.old)
503
+ flags.push('--old');
504
+ const args = [...flags, filter, index].filter(Boolean).join(' ');
505
+ await command.execute(args);
506
+ command.printSuggestions();
507
+ }
508
+ catch (error) {
509
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
510
+ process.exit(1);
511
+ }
512
+ });
442
513
  addCommonOptions(program.command('research <url>').description('Research a page and print UI analysis').option('--data', 'Include data extraction in research').option('--deep', 'Enable deep analysis (expand hidden elements)').option('--no-fix', 'Skip locator fix cycle (for debugging)')).action(async (url, options) => {
443
514
  try {
444
515
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
@@ -1,7 +1,7 @@
1
- import { tool } from 'ai';
2
- import { expect } from 'expect';
3
1
  import { readFileSync } from 'node:fs';
2
+ import { tool } from 'ai';
4
3
  import dedent from 'dedent';
4
+ import { expect } from 'expect';
5
5
  import { z } from 'zod';
6
6
  import { TestResult } from "../../../../src/test-plan.js";
7
7
  import { tag } from "../../../../src/utils/logger.js";
@@ -1,6 +1,8 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { AIProvider } from "../../../src/ai/provider.js";
4
+ import { RequestStore } from "../../../src/api/request-store.js";
5
+ import { extractEndpointDefinition, loadSpec, searchEndpoints, validateSpecs } from "../../../src/api/spec-reader.js";
4
6
  import { Reporter } from "../../../src/reporter.js";
5
7
  import { Plan } from "../../../src/test-plan.js";
6
8
  import { setVerboseMode, tag } from "../../../src/utils/logger.js";
@@ -8,8 +10,6 @@ import { Chief } from "./ai/chief.js";
8
10
  import { Curler } from "./ai/curler.js";
9
11
  import { ApiClient } from "./api-client.js";
10
12
  import { ApibotConfigParser } from "./config.js";
11
- import { RequestStore } from "../../../src/api/request-store.js";
12
- import { extractEndpointDefinition, loadSpec, searchEndpoints, validateSpecs } from "../../../src/api/spec-reader.js";
13
13
  export class ApiBot {
14
14
  configParser;
15
15
  provider;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.9",
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
  };