explorbot 0.5.10 → 0.6.0

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 (151) hide show
  1. package/bin/explorbot-cli.ts +494 -482
  2. package/boat/api-tester/src/ai/curler.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +4 -4
  4. package/boat/doc-collector/src/screenshots.ts +1 -1
  5. package/dist/bin/explorbot-cli.js +455 -444
  6. package/dist/boat/api-tester/src/ai/curler.js +1 -1
  7. package/dist/boat/api-tester/src/cli.js +4 -4
  8. package/dist/models.json +1 -1
  9. package/dist/package.json +2 -1
  10. package/dist/rules/navigator/output.md +2 -8
  11. package/dist/rules/navigator/verification-actions.md +6 -2
  12. package/dist/rules/planner/ignored-pages.md +1 -0
  13. package/dist/src/action-result.js +1 -1
  14. package/dist/src/action.d.ts +0 -1
  15. package/dist/src/action.js +0 -4
  16. package/dist/src/ai/captain/idle-mode.js +42 -26
  17. package/dist/src/ai/captain/mixin.d.ts +2 -0
  18. package/dist/src/ai/captain/web-mode.js +11 -0
  19. package/dist/src/ai/captain.d.ts +6 -4
  20. package/dist/src/ai/captain.js +25 -1
  21. package/dist/src/ai/driller.js +1 -1
  22. package/dist/src/ai/experience-compactor.js +2 -2
  23. package/dist/src/ai/judge.d.ts +7 -0
  24. package/dist/src/ai/judge.js +8 -1
  25. package/dist/src/ai/navigator.d.ts +1 -0
  26. package/dist/src/ai/navigator.js +11 -7
  27. package/dist/src/ai/planner/subpages.js +1 -1
  28. package/dist/src/ai/planner.d.ts +12 -5
  29. package/dist/src/ai/planner.js +138 -86
  30. package/dist/src/ai/provider.d.ts +1 -1
  31. package/dist/src/ai/provider.js +16 -5
  32. package/dist/src/ai/rerunner.js +1 -1
  33. package/dist/src/ai/researcher/cache.js +14 -6
  34. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
  35. package/dist/src/ai/researcher/deep-analysis.js +7 -2
  36. package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
  37. package/dist/src/ai/researcher.js +44 -24
  38. package/dist/src/ai/rules.js +6 -7
  39. package/dist/src/ai/scout.d.ts +12 -2
  40. package/dist/src/ai/scout.js +103 -8
  41. package/dist/src/ai/tester.d.ts +9 -5
  42. package/dist/src/ai/tester.js +22 -42
  43. package/dist/src/ai/tools.js +10 -8
  44. package/dist/src/application-spec.d.ts +2 -0
  45. package/dist/src/application-spec.js +17 -12
  46. package/dist/src/commands/context-experience-command.js +0 -1
  47. package/dist/src/commands/context-knowledge-command.js +0 -1
  48. package/dist/src/commands/explore-command.d.ts +3 -2
  49. package/dist/src/commands/explore-command.js +35 -23
  50. package/dist/src/commands/help-json-command.js +3 -1
  51. package/dist/src/commands/init-command.js +4 -4
  52. package/dist/src/commands/learn-command.js +0 -2
  53. package/dist/src/commands/plan-command.d.ts +3 -0
  54. package/dist/src/commands/plan-command.js +43 -17
  55. package/dist/src/commands/plan-save-command.js +6 -17
  56. package/dist/src/commands/plans-command.js +1 -1
  57. package/dist/src/commands/test-command.js +12 -2
  58. package/dist/src/components/App.d.ts +1 -2
  59. package/dist/src/components/App.js +1 -3
  60. package/dist/src/components/InputPane.js +0 -5
  61. package/dist/src/config.d.ts +3 -2
  62. package/dist/src/config.js +2 -12
  63. package/dist/src/explorbot.d.ts +3 -2
  64. package/dist/src/explorbot.js +32 -10
  65. package/dist/src/explorer.js +6 -7
  66. package/dist/src/knowledge-tracker.d.ts +2 -2
  67. package/dist/src/knowledge-tracker.js +18 -6
  68. package/dist/src/playwright-recorder.d.ts +0 -1
  69. package/dist/src/playwright-recorder.js +0 -2
  70. package/dist/src/reporter.d.ts +1 -1
  71. package/dist/src/reporter.js +3 -3
  72. package/dist/src/test-plan.js +1 -1
  73. package/dist/src/utils/error-page.d.ts +17 -0
  74. package/dist/src/utils/error-page.js +53 -2
  75. package/dist/src/utils/html-diff.d.ts +1 -2
  76. package/dist/src/utils/html-diff.js +4 -44
  77. package/dist/src/utils/logger.js +27 -10
  78. package/dist/src/utils/markdown-terminal.js +1 -2
  79. package/dist/src/utils/next-steps.js +20 -16
  80. package/dist/src/utils/page-readiness.d.ts +2 -1
  81. package/dist/src/utils/page-readiness.js +24 -5
  82. package/dist/src/utils/test-plan-markdown.d.ts +3 -0
  83. package/dist/src/utils/test-plan-markdown.js +35 -4
  84. package/dist/src/utils/url-matcher.d.ts +1 -0
  85. package/dist/src/utils/url-matcher.js +5 -0
  86. package/docs/basics/providers.md +1 -1
  87. package/docs/contributing/regression-tests.md +19 -4
  88. package/docs/reference/commands.md +6 -2
  89. package/docs/reference/configuration.md +1 -1
  90. package/docs/web-testing/agents.md +8 -0
  91. package/docs/web-testing/customization.md +2 -0
  92. package/docs/web-testing/researcher.md +1 -1
  93. package/models.json +1 -1
  94. package/package.json +2 -1
  95. package/rules/navigator/output.md +2 -8
  96. package/rules/navigator/verification-actions.md +6 -2
  97. package/rules/planner/ignored-pages.md +1 -0
  98. package/src/action-result.ts +1 -2
  99. package/src/action.ts +0 -5
  100. package/src/ai/captain/idle-mode.ts +38 -26
  101. package/src/ai/captain/mixin.ts +2 -1
  102. package/src/ai/captain/web-mode.ts +12 -0
  103. package/src/ai/captain.ts +25 -7
  104. package/src/ai/driller.ts +1 -2
  105. package/src/ai/experience-compactor.ts +2 -2
  106. package/src/ai/judge.ts +10 -1
  107. package/src/ai/navigator.ts +12 -7
  108. package/src/ai/planner/subpages.ts +1 -2
  109. package/src/ai/planner.ts +140 -92
  110. package/src/ai/provider.ts +15 -6
  111. package/src/ai/rerunner.ts +1 -2
  112. package/src/ai/researcher/cache.ts +16 -7
  113. package/src/ai/researcher/deep-analysis.ts +7 -2
  114. package/src/ai/researcher/fingerprint-worker.ts +5 -5
  115. package/src/ai/researcher.ts +44 -28
  116. package/src/ai/rules.ts +6 -7
  117. package/src/ai/scout.ts +98 -10
  118. package/src/ai/tester.ts +29 -47
  119. package/src/ai/tools.ts +11 -9
  120. package/src/application-spec.ts +19 -13
  121. package/src/commands/clean-command.ts +1 -1
  122. package/src/commands/context-command.ts +0 -1
  123. package/src/commands/context-experience-command.ts +0 -1
  124. package/src/commands/context-knowledge-command.ts +0 -1
  125. package/src/commands/explore-command.ts +36 -23
  126. package/src/commands/help-json-command.ts +8 -6
  127. package/src/commands/init-command.ts +5 -3
  128. package/src/commands/learn-command.ts +0 -2
  129. package/src/commands/plan-command.ts +44 -17
  130. package/src/commands/plan-save-command.ts +6 -17
  131. package/src/commands/plans-command.ts +1 -1
  132. package/src/commands/test-command.ts +11 -2
  133. package/src/components/App.tsx +2 -6
  134. package/src/components/InputPane.tsx +0 -5
  135. package/src/config.ts +4 -16
  136. package/src/experience-tracker.ts +1 -1
  137. package/src/explorbot.ts +31 -11
  138. package/src/explorer.ts +6 -7
  139. package/src/knowledge-tracker.ts +18 -7
  140. package/src/playwright-recorder.ts +0 -2
  141. package/src/reporter.ts +3 -4
  142. package/src/test-plan.ts +1 -2
  143. package/src/utils/error-page.ts +64 -2
  144. package/src/utils/html-diff.ts +4 -56
  145. package/src/utils/html.ts +0 -1
  146. package/src/utils/logger.ts +30 -12
  147. package/src/utils/markdown-terminal.ts +1 -2
  148. package/src/utils/next-steps.ts +19 -17
  149. package/src/utils/page-readiness.ts +33 -7
  150. package/src/utils/test-plan-markdown.ts +36 -4
  151. package/src/utils/url-matcher.ts +6 -0
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import chalk from 'chalk';
3
4
  import figureSet from 'figures';
@@ -17,8 +18,12 @@ export class PlanCommand extends BaseCommand {
17
18
  options = [
18
19
  { flags: '--fresh', description: 'Regenerate plan from scratch' },
19
20
  { flags: '--clear', description: 'Clear plan before regenerating' },
21
+ { flags: '--append', description: 'Add tests to existing plan file' },
20
22
  { flags: '--style <name>', description: 'Planning style (normal, curious, psycho, performer)' },
21
23
  { flags: '--focus <feature>', description: 'Focus area for test planning' },
24
+ { flags: '--tests <count>', description: 'Exact number of tests to generate' },
25
+ { flags: '--min-tests <count>', description: 'Minimum number of tests to generate' },
26
+ { flags: '--max-tests <count>', description: 'Maximum number of tests to generate' },
22
27
  ];
23
28
 
24
29
  async execute(args: string): Promise<void> {
@@ -30,11 +35,20 @@ export class PlanCommand extends BaseCommand {
30
35
  tag('success').log('Plan cleared');
31
36
  }
32
37
 
38
+ if (opts.append) {
39
+ const existingPlanPath = path.join(this.explorBot.getPlansDir(), this.explorBot.generatePlanFilename());
40
+ if (existsSync(existingPlanPath)) this.explorBot.loadPlan(existingPlanPath);
41
+ }
42
+
33
43
  if (focus) {
34
44
  tag('info').log(`Planning focus: ${focus}`);
35
45
  }
36
46
 
37
- await this.explorBot.plan(focus, { fresh: !!(opts.fresh || opts.clear), style: opts.style as string });
47
+ const testsCount = opts.tests ? Number.parseInt(opts.tests as string, 10) : undefined;
48
+ const minTests = testsCount ?? (opts.minTests ? Number.parseInt(opts.minTests as string, 10) : undefined);
49
+ const maxTests = testsCount ?? (opts.maxTests ? Number.parseInt(opts.maxTests as string, 10) : undefined);
50
+
51
+ await this.explorBot.plan(focus, { fresh: !!(opts.fresh || opts.clear), style: opts.style as string, minTests, maxTests });
38
52
 
39
53
  const plan = this.explorBot.getCurrentPlan();
40
54
  if (!plan?.tests.length) {
@@ -46,29 +60,34 @@ export class PlanCommand extends BaseCommand {
46
60
  this.printNextSteps();
47
61
  }
48
62
 
63
+ static planSection(planPath: string, cliSuffix = ''): NextStepSection {
64
+ const cli = getCliName();
65
+ const planName = path.basename(planPath, '.md');
66
+ return {
67
+ label: 'Plan',
68
+ path: planPath,
69
+ commands: [
70
+ { label: 'Run all tests', command: `${cli} test ${planName}${cliSuffix}` },
71
+ { label: 'Run one test', command: `${cli} test ${planName} 1${cliSuffix}` },
72
+ { label: 'Run a range', command: `${cli} test ${planName} 1-3${cliSuffix}` },
73
+ ],
74
+ };
75
+ }
76
+
49
77
  private printNextSteps(): void {
50
78
  const savedPath = this.explorBot.lastSavedPlanPath;
51
79
  if (!savedPath) return;
52
80
 
53
81
  const cli = getCliName();
54
- const relPlan = relativeToCwd(savedPath);
55
- const sections: NextStepSection[] = [
56
- {
57
- label: 'Plan',
58
- path: savedPath,
59
- commands: [
60
- { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
61
- { label: 'Run all', command: `${cli} test ${relPlan} *` },
62
- { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
63
- { label: 'Reload', command: `/plan:load ${relPlan}` },
64
- ],
65
- },
66
- ];
82
+ const cliSuffix = this.cliSuffix();
83
+ const section = PlanCommand.planSection(savedPath, cliSuffix);
84
+ section.commands?.push({ label: 'Reload', command: `/plan:load ${path.basename(savedPath, '.md')}` });
85
+ const sections: NextStepSection[] = [section];
67
86
 
68
87
  const suite = this.explorBot.getSuite();
69
88
  const files = suite && suite.automatedTestCount > 0 ? suite.getAutomatedTestFiles() : [];
70
89
  if (files.length > 0) {
71
- const commands = files.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
90
+ const commands = files.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}${cliSuffix}` }));
72
91
  sections.push({
73
92
  label: `Automated tests (${files.length})`,
74
93
  commands,
@@ -78,6 +97,14 @@ export class PlanCommand extends BaseCommand {
78
97
  printNextSteps(sections);
79
98
  }
80
99
 
100
+ private cliSuffix(): string {
101
+ const options = this.explorBot.getOptions();
102
+ let suffix = '';
103
+ if (options.path) suffix += ` --path ${options.path}`;
104
+ if (options.session) suffix += ' --session';
105
+ return suffix;
106
+ }
107
+
81
108
  private printPlanSummary(): void {
82
109
  const suite = this.explorBot.getSuite();
83
110
  const plan = this.explorBot.getCurrentPlan();
@@ -91,10 +118,10 @@ export class PlanCommand extends BaseCommand {
91
118
  }
92
119
 
93
120
  if (plan?.tests.length) {
94
- console.log(`\n${chalk.bold.cyan(`New test scenarios (${plan.tests.length})`)}`);
121
+ console.log(`\n${chalk.bold.cyan(`New test scenarios (${plan.tests.length})`)}\n`);
95
122
  for (let i = 0; i < plan.tests.length; i++) {
96
123
  const t = plan.tests[i];
97
- console.log(` ${chalk.dim(`${i + 1}.`)} ${chalk.green(figureSet.pointer)} ${t.scenario} ${chalk.dim(`[${t.priority}]`)}`);
124
+ console.log(` ${chalk.cyan(`${i + 1}.`)} ${t.scenario}`);
98
125
  }
99
126
  }
100
127
  }
@@ -1,5 +1,6 @@
1
- import { getCliName } from '../utils/cli-name.ts';
2
- import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
1
+ import path from 'node:path';
2
+ import { printNextSteps } from '../utils/next-steps.ts';
3
+ import { PlanCommand } from './plan-command.js';
3
4
  import { BaseCommand, type Suggestion } from './base-command.js';
4
5
 
5
6
  export class PlanSaveCommand extends BaseCommand {
@@ -17,20 +18,8 @@ export class PlanSaveCommand extends BaseCommand {
17
18
  const savedPath = this.explorBot.savePlan(filename);
18
19
  if (!savedPath) return;
19
20
 
20
- const cli = getCliName();
21
- const relPlan = relativeToCwd(savedPath);
22
- const sections: NextStepSection[] = [
23
- {
24
- label: 'Plan',
25
- path: savedPath,
26
- commands: [
27
- { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
28
- { label: 'Run all', command: `${cli} test ${relPlan} *` },
29
- { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
30
- { label: 'Reload', command: `/plan:load ${relPlan}` },
31
- ],
32
- },
33
- ];
34
- printNextSteps(sections);
21
+ const section = PlanCommand.planSection(savedPath);
22
+ section.commands?.push({ label: 'Reload', command: `/plan:load ${path.basename(savedPath, '.md')}` });
23
+ printNextSteps([section]);
35
24
  }
36
25
  }
@@ -52,7 +52,7 @@ export class PlansCommand extends BaseCommand {
52
52
  }
53
53
  tag('info').log('');
54
54
  tag('info').log('Run test from this plan as:');
55
- tag('info').log(`${getCliName()} test 1 --from-plan ${file.name}`);
55
+ tag('info').log(`${getCliName()} test ${file.name} 1`);
56
56
  }
57
57
 
58
58
  private resolvePlanFile(target: string, files: PlanFile[]): { plan: Plan; file: PlanFile } {
@@ -1,5 +1,6 @@
1
1
  import { Stats } from '../stats.js';
2
2
  import { Plan, Test } from '../test-plan.js';
3
+ import { BlockedError } from '../utils/error-page.ts';
3
4
  import { tag } from '../utils/logger.js';
4
5
  import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
5
6
 
@@ -18,6 +19,11 @@ export class TestCommand extends BaseCommand {
18
19
  this.explorBot.loadPlan(String(opts.fromPlan));
19
20
  }
20
21
 
22
+ if (!opts.fromPlan && remaining[0] && Plan.resolveFile(remaining[0], this.explorBot.getPlansDir())) {
23
+ this.explorBot.loadPlan(remaining.shift()!);
24
+ if (remaining.length === 0) remaining.push('*');
25
+ }
26
+
21
27
  const plan = this.explorBot.getCurrentPlan();
22
28
  const selector = remaining.join(' ');
23
29
  Stats.mode = 'test';
@@ -71,13 +77,16 @@ export class TestCommand extends BaseCommand {
71
77
  const tester = this.explorBot.agentTester();
72
78
  for (const [index, test] of toExecute.entries()) {
73
79
  tag('info').log(`Starting test ${index + 1}/${toExecute.length}: ${test.scenario}`);
74
- await tester.test(test);
80
+ await tester.test(test).catch((err) => {
81
+ if (!(err instanceof BlockedError)) throw err;
82
+ tag('warning').log(`Skipping test: ${err.message}`);
83
+ });
75
84
  }
76
85
  tag('success').log('Test execution finished');
77
86
  }
78
87
 
79
88
  completeArguments(): ArgumentCompletion[] {
80
- return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: `--from-plan ${file.name}`, display: file.label }));
89
+ return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: file.name, display: file.label }));
81
90
  }
82
91
  }
83
92
 
@@ -3,14 +3,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { CommandHandler } from '../command-handler.js';
4
4
  import type { ExplorbotConfig } from '../config.js';
5
5
  import { executionController } from '../execution-controller.ts';
6
- import type { ExplorBot, ExplorBotOptions } from '../explorbot.ts';
6
+ import type { ExplorBot } from '../explorbot.ts';
7
7
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
8
8
  import type { StateTransition, WebPageState } from '../state-manager.js';
9
9
  import { Test } from '../test-plan.ts';
10
10
  import { tag } from '../utils/logger.js';
11
11
  import ActivityPane from './ActivityPane.js';
12
12
  import Autocomplete from './Autocomplete.js';
13
- import InputPane from './InputPane.js';
14
13
  import InputReadline from './InputReadline.js';
15
14
  import LogPane from './LogPane.js';
16
15
  import PlanEditor from './PlanEditor.js';
@@ -23,17 +22,15 @@ import WelcomeCommands from './WelcomeCommands.js';
23
22
  interface AppProps {
24
23
  explorBot: ExplorBot;
25
24
  initialShowInput?: boolean;
26
- exitOnEmptyInput?: boolean;
27
25
  }
28
26
 
29
- export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = false }: AppProps) {
27
+ export function App({ explorBot, initialShowInput = false }: AppProps) {
30
28
  const sessionStartedAtRef = useRef<number>(Date.now());
31
29
  const InputComponent = InputReadline;
32
30
  const [showSessionTimer, setShowSessionTimer] = useState(false);
33
31
  const [showInput, setShowInput] = useState(initialShowInput);
34
32
  const [interruptPrompt, setInterruptPrompt] = useState<string | null>(null);
35
33
  const [currentState, setCurrentState] = useState<WebPageState | null>(null);
36
- const [lastTransition, setLastTransition] = useState<StateTransition | null>(null);
37
34
  const [tasks, setTasks] = useState<Test[]>([]);
38
35
  const tasksRef = useRef<Test[]>([]);
39
36
  const [showPlanEditor, setShowPlanEditor] = useState(false);
@@ -72,7 +69,6 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
72
69
 
73
70
  unsubscribe = manager.onStateChange((transition: StateTransition) => {
74
71
  if (mounted) {
75
- setLastTransition(transition);
76
72
  setCurrentState(transition.toState);
77
73
  }
78
74
  });
@@ -19,7 +19,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
19
19
  const [cursorPosition, setCursorPosition] = useState(0);
20
20
  const [showAutocomplete, setShowAutocomplete] = useState(false);
21
21
  const [selectedIndex, setSelectedIndex] = useState(0);
22
- const [autoCompleteTriggered, setAutoCompleteTriggered] = useState(false);
23
22
  const inputRef = useRef(inputValue);
24
23
  const cursorRef = useRef(cursorPosition);
25
24
 
@@ -166,7 +165,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
166
165
  setShowAutocomplete(false);
167
166
  setSelectedIndex(0);
168
167
  setCursorPosition(selectedCommand.length);
169
- setAutoCompleteTriggered(true);
170
168
  }
171
169
  return;
172
170
  }
@@ -182,7 +180,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
182
180
  setInputValue(newValue);
183
181
  setCursorPosition(nextCursor);
184
182
  setSelectedIndex(0);
185
- setAutoCompleteTriggered(false);
186
183
  setShowAutocomplete(shouldShowAutocomplete(newValue));
187
184
  }
188
185
  return;
@@ -198,7 +195,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
198
195
  setInputValue(newValue);
199
196
  setCursorPosition(nextCursor);
200
197
  setSelectedIndex(0);
201
- setAutoCompleteTriggered(false);
202
198
  setShowAutocomplete(shouldShowAutocomplete(newValue));
203
199
  }
204
200
  },
@@ -247,7 +243,6 @@ const InputPane: React.FC<InputPaneProps> = ({ commandHandler, exitOnEmptyInput
247
243
  setShowAutocomplete(false);
248
244
  setSelectedIndex(0);
249
245
  setCursorPosition(selectedCommand.length);
250
- setAutoCompleteTriggered(true);
251
246
  }
252
247
  }}
253
248
  visible={showAutocomplete}
package/src/config.ts CHANGED
@@ -132,6 +132,8 @@ interface PlannerAgentConfig extends AgentConfig {
132
132
  styles?: string[];
133
133
  stylesDir?: string;
134
134
  docsWeight?: number;
135
+ minTests?: number;
136
+ maxTests?: number;
135
137
  }
136
138
 
137
139
  interface ScoutAgentConfig extends AgentConfig {
@@ -245,7 +247,7 @@ interface ExplorbotConfig {
245
247
  knowledge: string;
246
248
  experience: string;
247
249
  output: string;
248
- spec?: string;
250
+ spec?: string | string[];
249
251
  };
250
252
  experience?: {
251
253
  maxReadLines?: number;
@@ -258,17 +260,6 @@ interface ExplorbotConfig {
258
260
  dynamicPageRegex?: string;
259
261
  }
260
262
 
261
- const config: ExplorbotConfig = {
262
- playwright: {
263
- browser: 'chromium',
264
- url: 'http://localhost:3000',
265
- },
266
-
267
- ai: {
268
- model: null as any,
269
- },
270
- };
271
-
272
263
  type RuleEntry = string | Record<string, string>;
273
264
 
274
265
  export { EXPLORBOT_CONFIG_PATHS };
@@ -763,10 +754,6 @@ export class ConfigParser {
763
754
  }
764
755
  }
765
756
 
766
- private getNestedValue(obj: any, path: string): any {
767
- return path.split('.').reduce((current, key) => current?.[key], obj);
768
- }
769
-
770
757
  public mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig {
771
758
  const defaults = {
772
759
  playwright: {
@@ -879,6 +866,7 @@ export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>
879
866
  const models: Record<string, ConfiguredModel> = { model: describe(ai.model) };
880
867
  if (ai.agenticModel) models.agenticModel = describe(ai.agenticModel);
881
868
  if (ai.visionModel) models.visionModel = describe(ai.visionModel);
869
+ if (ai.decisionModel) models.decisionModel = { name: ai.decisionModel.model, provider: ai.decisionModel.provider };
882
870
  for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
883
871
  if (agentConfig?.model) models[agent] = describe(agentConfig.model);
884
872
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
3
  import matter from 'gray-matter';
4
4
  import type { ActionResult } from './action-result.js';
package/src/explorbot.ts CHANGED
@@ -55,7 +55,7 @@ export interface ExplorBotOptions {
55
55
  optionalAi?: boolean;
56
56
  reporter?: ReporterConfig;
57
57
  attachedBrowser?: Browser;
58
- applicationSpec?: string;
58
+ applicationSpec?: string | string[];
59
59
  }
60
60
 
61
61
  export type UserResolveFunction = (error?: Error, showWelcome?: boolean) => Promise<string | null>;
@@ -156,7 +156,13 @@ export class ExplorBot {
156
156
  }
157
157
 
158
158
  async visit(url: string): Promise<void> {
159
- await this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
159
+ const target = this.configParser.resolveTargetPath(url);
160
+ await this.agentNavigator()
161
+ .visit(target)
162
+ .catch(async (err) => {
163
+ if (await this.agentCaptain().resolveBlocker(err, () => this.agentNavigator().isOn(target))) return;
164
+ throw err;
165
+ });
160
166
  }
161
167
 
162
168
  async openTab(): Promise<void> {
@@ -274,7 +280,7 @@ export class ExplorBot {
274
280
  }
275
281
  if (this.isHistorianEnabled()) this.agents.tester.setHistorian(this.agentHistorian());
276
282
  this.agents.tester.setPilot(this.agentPilot());
277
- this.agents.tester.setCaptain(this.agentCaptain());
283
+ this.agents.tester.setSupervisor((input: string, test: Test) => this.agentCaptain().processSupervisorInterrupt(input, test));
278
284
 
279
285
  const fisherman = this.agentFisherman();
280
286
  if (fisherman) this.agentPilot().setFisherman(fisherman);
@@ -377,9 +383,14 @@ export class ExplorBot {
377
383
 
378
384
  agentScout(): Scout | null {
379
385
  const scoutConfig = this.config.ai?.agents?.scout;
380
- if (scoutConfig?.enabled !== true) return null;
381
-
382
386
  const dirs = this.scoutCorpusDirs();
387
+ const hasSpec = dirs.length > 0;
388
+ const explicitlyDisabled = scoutConfig?.enabled === false;
389
+ const explicitlyEnabled = scoutConfig?.enabled === true;
390
+
391
+ if (explicitlyDisabled) return null;
392
+ if (!explicitlyEnabled && !hasSpec) return null;
393
+
383
394
  if (dirs.length === 0) {
384
395
  tag('warning').log('Scout enabled but no documentation found — set --spec or ai.agents.scout.dirs');
385
396
  return null;
@@ -391,11 +402,10 @@ export class ExplorBot {
391
402
  private scoutCorpusDirs(): string[] {
392
403
  const dirs: string[] = [];
393
404
 
394
- const specPath = this.options.applicationSpec || this.config.dirs?.spec;
395
- if (specPath) {
405
+ const specPaths = normalizeSpecPaths(this.options.applicationSpec || this.config.dirs?.spec);
406
+ for (const specPath of specPaths) {
396
407
  const bundle = resolveSpecBundlePath(specPath);
397
- const pagesDir = bundle && path.join(bundle, 'pages');
398
- if (pagesDir && existsSync(pagesDir)) dirs.push(pagesDir);
408
+ if (bundle && existsSync(bundle)) dirs.push(bundle);
399
409
  }
400
410
 
401
411
  for (const dir of this.config.ai?.agents?.scout?.dirs || []) {
@@ -423,7 +433,7 @@ export class ExplorBot {
423
433
  this.agents.planner = undefined;
424
434
  }
425
435
 
426
- async plan(feature?: string, opts: { fresh?: boolean; style?: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean } = {}) {
436
+ async plan(feature?: string, opts: { fresh?: boolean; style?: string; extend?: Plan; noSave?: boolean; minTests?: number; maxTests?: number } = {}) {
427
437
  this.planFeature = feature;
428
438
 
429
439
  if (opts.fresh) {
@@ -440,13 +450,18 @@ export class ExplorBot {
440
450
 
441
451
  const planner = this.agentPlanner();
442
452
  planner.freshStart = !!opts.fresh;
453
+ if (opts.minTests !== undefined || opts.maxTests !== undefined) {
454
+ planner.setTestCount({ minTests: opts.minTests, maxTests: opts.maxTests });
455
+ }
443
456
  if (this.currentPlan) {
444
457
  planner.setPlan(this.currentPlan);
445
458
  }
446
459
  this.lastPlanError = null;
460
+ const before = new Set(this.currentPlan?.tests ?? []);
447
461
  try {
448
- this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend, opts.completedPlans));
462
+ this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend));
449
463
  } catch (err) {
464
+ if (await this.agentCaptain().resolveBlocker(err, () => !!this.currentPlan?.tests.some((t) => !before.has(t)))) return this.currentPlan;
450
465
  this.lastPlanError = err instanceof Error ? err : new Error(String(err));
451
466
  tag('warning').log(`Planning failed: ${this.lastPlanError.message}`);
452
467
  if (!this.currentPlan) return undefined;
@@ -575,3 +590,8 @@ export class ExplorBot {
575
590
  }
576
591
  }
577
592
  }
593
+
594
+ function normalizeSpecPaths(spec: string | string[] | undefined): string[] {
595
+ if (!spec) return [];
596
+ return Array.isArray(spec) ? spec : [spec];
597
+ }
package/src/explorer.ts CHANGED
@@ -17,7 +17,7 @@ import type { KnowledgeTracker } from './knowledge-tracker.js';
17
17
  import type { PlaywrightRecorder } from './playwright-recorder.ts';
18
18
  import type { Reporter } from './reporter.ts';
19
19
  import type { StateManager } from './state-manager.js';
20
- import { Test, TestResult } from './test-plan.ts';
20
+ import { Test } from './test-plan.ts';
21
21
  import { BrowserRecoveryError, browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
22
22
  import { createDebug, log, tag } from './utils/logger.js';
23
23
  import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
@@ -190,7 +190,6 @@ class Explorer {
190
190
  if (!(await this.recoverOrRestart())) {
191
191
  return { ok: false, action: 'stop', message: 'Browser page could not be recovered' };
192
192
  }
193
- this.watchActiveTestPage();
194
193
  return { ok: true, action: 'continue', recovered: true, message: 'Browser page was recovered' };
195
194
  }
196
195
 
@@ -201,7 +200,7 @@ class Explorer {
201
200
 
202
201
  this._activeTest = test;
203
202
  test.start();
204
- await this.reporter.reportTestStart(test);
203
+ await this.reporter.reportTestStart();
205
204
  await this.closeOtherTabs();
206
205
  this.stateManager.otherTabs = [];
207
206
 
@@ -414,7 +413,7 @@ class Explorer {
414
413
 
415
414
  private async runWithRecovery<T>(label: string, operation: () => Promise<T>): Promise<T> {
416
415
  if (!this.page && !(await this.recoverOrRestart())) {
417
- throw new Error(`Browser page is unavailable before ${label}`);
416
+ throw new BrowserRecoveryError(label, new Error('Browser page is unavailable'), false);
418
417
  }
419
418
 
420
419
  try {
@@ -516,7 +515,6 @@ class Explorer {
516
515
  return { ok: false, action: 'stop', recovered: false, message: `Browser could not be recovered after fatal error: ${message}` };
517
516
  }
518
517
 
519
- this.watchActiveTestPage();
520
518
  return {
521
519
  ok: true,
522
520
  action: 'continue',
@@ -531,8 +529,9 @@ class Explorer {
531
529
  }
532
530
 
533
531
  private async recoverOrRestart(): Promise<boolean> {
534
- if (await this.recoverPage()) return true;
535
- return this.restartBrowser();
532
+ if (!(await this.recoverPage()) && !(await this.restartBrowser())) return false;
533
+ this.watchActiveTestPage();
534
+ return true;
536
535
  }
537
536
 
538
537
  private async recoverPage(): Promise<boolean> {
@@ -22,7 +22,7 @@ export class KnowledgeTracker {
22
22
  private knowledgeFiles: Knowledge[] = [];
23
23
  private sessionKnowledge: Knowledge[] = [];
24
24
  private isLoaded = false;
25
- private applicationSpec?: ApplicationSpec;
25
+ private applicationSpecs: ApplicationSpec[] = [];
26
26
 
27
27
  static appendSessionKnowledge(text: string): void {
28
28
  sessionEntries.push(text);
@@ -49,9 +49,14 @@ export class KnowledgeTracker {
49
49
  mkdirSync(this.knowledgeDir, { recursive: true });
50
50
  }
51
51
 
52
- if (specPath) {
53
- this.applicationSpec = new ApplicationSpec(specPath);
54
- tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
52
+ for (const p of normalizeSpecPaths(specPath)) {
53
+ const spec = new ApplicationSpec(p);
54
+ this.applicationSpecs.push(spec);
55
+ if (spec.pageCount > 0) {
56
+ tag('info').log(`Loaded application spec with ${spec.pageCount} documented pages`);
57
+ } else if (spec.markdownFileCount > 0) {
58
+ tag('info').log(`Loaded spec corpus with ${spec.markdownFileCount} markdown files`);
59
+ }
55
60
  }
56
61
 
57
62
  this.sessionKnowledge = sessionEntries.map((entry, index) => {
@@ -99,11 +104,12 @@ export class KnowledgeTracker {
99
104
  }
100
105
 
101
106
  renderApplicationSpec(state: ActionResult): string {
102
- return this.applicationSpec?.renderFor(state) || '';
107
+ const parts = this.applicationSpecs.map((spec) => spec.renderFor(state)).filter(Boolean);
108
+ return parts.join('\n\n');
103
109
  }
104
110
 
105
111
  applicationSpecUrls(state: ActionResult): string[] {
106
- return this.applicationSpec?.matchedUrls(state) || [];
112
+ return this.applicationSpecs.flatMap((spec) => spec.matchedUrls(state));
107
113
  }
108
114
 
109
115
  addKnowledge(urlPattern: string, description: string, opts?: { replace?: boolean }): { filename: string; filePath: string; isNewFile: boolean } {
@@ -293,6 +299,11 @@ export class KnowledgeTracker {
293
299
  }
294
300
  }
295
301
 
302
+ function normalizeSpecPaths(spec: string | string[] | undefined): string[] {
303
+ if (!spec) return [];
304
+ return Array.isArray(spec) ? spec : [spec];
305
+ }
306
+
296
307
  export interface Knowledge {
297
308
  filePath: string;
298
309
  url?: string;
@@ -302,6 +313,6 @@ export interface Knowledge {
302
313
  }
303
314
 
304
315
  export interface KnowledgeTrackerOptions {
305
- applicationSpec?: string;
316
+ applicationSpec?: string | string[];
306
317
  knowledgeDir?: string;
307
318
  }
@@ -39,7 +39,6 @@ export interface VerificationStep {
39
39
  }
40
40
 
41
41
  export class PlaywrightRecorder {
42
- private context: any = null;
43
42
  private tracing: any = null;
44
43
  private active = false;
45
44
  private nextGroupId = 0;
@@ -68,7 +67,6 @@ export class PlaywrightRecorder {
68
67
  debugLog('start: no tracing on browserContext, recorder inactive');
69
68
  return;
70
69
  }
71
- this.context = browserContext;
72
70
  this.tracing = browserContext.tracing;
73
71
  try {
74
72
  await this.tracing.start({});
package/src/reporter.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { join } from 'node:path';
2
1
  import { Client } from '@testomatio/reporter';
3
2
  import type { Step } from '@testomatio/reporter/types/types.js';
4
3
  import { outputPath } from './config.js';
@@ -132,14 +131,14 @@ export class Reporter {
132
131
  process.env.TESTOMATIO_STEPS_PASSED = 'true';
133
132
  }
134
133
 
135
- async reportTestStart(test: Test): Promise<void> {
134
+ async reportTestStart(): Promise<void> {
136
135
  await this.startRun();
137
136
  }
138
137
 
139
138
  protected combineStepsAndNotes(test: Test, lastScreenshotFile?: string): Step[] {
140
139
  const noteEntries = Object.entries(test.notes)
141
140
  .filter(([, note]) => !note.observation)
142
- .map(([timestampKey, note]) => ({
141
+ .map(([, note]) => ({
143
142
  startTime: note.startTime,
144
143
  endTime: note.endTime,
145
144
  message: note.message,
@@ -150,7 +149,7 @@ export class Reporter {
150
149
  .sort((a, b) => a.startTime - b.startTime);
151
150
 
152
151
  const stepEntries = Object.entries(test.steps)
153
- .map(([timestampKey, stepData]) => ({
152
+ .map(([, stepData]) => ({
154
153
  noteStartTime: stepData.noteStartTime,
155
154
  text: stepData.text,
156
155
  duration: stepData.duration,
package/src/test-plan.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, readdirSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import figures from 'figures';
5
4
  import type { ActionResult } from './action-result.ts';
6
5
  import { listSitePlanDirs } from './global-config.ts';
7
6
  import { WebPageState } from './state-manager.ts';
@@ -545,7 +544,7 @@ export class Plan {
545
544
  if (!plansDir) dirs.push(...listSitePlanDirs());
546
545
 
547
546
  for (const dir of dirs) {
548
- const filePath = names.map((name) => path.resolve(dir, name)).find(existsSync);
547
+ const filePath = names.map((name) => path.resolve(dir, name)).find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
549
548
  if (filePath) return filePath;
550
549
  }
551
550
  return null;