explorbot 0.5.9 → 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 (167) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +495 -482
  3. package/boat/api-tester/src/ai/curler.ts +1 -1
  4. package/boat/api-tester/src/cli.ts +4 -4
  5. package/boat/api-tester/src/commands/init-command.ts +2 -0
  6. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -1
  7. package/boat/doc-collector/src/screenshots.ts +1 -1
  8. package/boat/prima/bin/prima-cli.ts +2 -1
  9. package/dist/bin/explorbot-cli.js +456 -444
  10. package/dist/boat/api-tester/bin/apibot-cli.js +2 -1
  11. package/dist/boat/api-tester/src/ai/curler.js +1 -1
  12. package/dist/boat/api-tester/src/cli.js +4 -4
  13. package/dist/boat/api-tester/src/commands/init-command.js +2 -0
  14. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -1
  15. package/dist/boat/prima/bin/prima-cli.js +2 -1
  16. package/dist/models.json +1 -1
  17. package/dist/package.json +2 -1
  18. package/dist/rules/navigator/output.md +2 -8
  19. package/dist/rules/navigator/verification-actions.md +6 -2
  20. package/dist/rules/planner/ignored-pages.md +1 -0
  21. package/dist/src/action-result.js +1 -1
  22. package/dist/src/action.d.ts +0 -1
  23. package/dist/src/action.js +0 -4
  24. package/dist/src/ai/captain/idle-mode.js +42 -26
  25. package/dist/src/ai/captain/mixin.d.ts +2 -0
  26. package/dist/src/ai/captain/web-mode.js +11 -0
  27. package/dist/src/ai/captain.d.ts +6 -4
  28. package/dist/src/ai/captain.js +25 -1
  29. package/dist/src/ai/driller.js +1 -1
  30. package/dist/src/ai/experience-compactor.js +2 -2
  31. package/dist/src/ai/judge.d.ts +7 -0
  32. package/dist/src/ai/judge.js +8 -1
  33. package/dist/src/ai/navigator.d.ts +1 -0
  34. package/dist/src/ai/navigator.js +11 -7
  35. package/dist/src/ai/planner/subpages.js +1 -1
  36. package/dist/src/ai/planner.d.ts +12 -5
  37. package/dist/src/ai/planner.js +138 -86
  38. package/dist/src/ai/provider.d.ts +1 -1
  39. package/dist/src/ai/provider.js +16 -5
  40. package/dist/src/ai/rerunner.js +1 -1
  41. package/dist/src/ai/researcher/cache.js +14 -6
  42. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
  43. package/dist/src/ai/researcher/deep-analysis.js +7 -2
  44. package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
  45. package/dist/src/ai/researcher.js +44 -24
  46. package/dist/src/ai/rules.js +6 -7
  47. package/dist/src/ai/scout.d.ts +12 -2
  48. package/dist/src/ai/scout.js +103 -8
  49. package/dist/src/ai/tester.d.ts +9 -5
  50. package/dist/src/ai/tester.js +22 -42
  51. package/dist/src/ai/tools.js +10 -8
  52. package/dist/src/application-spec.d.ts +2 -0
  53. package/dist/src/application-spec.js +17 -12
  54. package/dist/src/commands/context-experience-command.js +0 -1
  55. package/dist/src/commands/context-knowledge-command.js +0 -1
  56. package/dist/src/commands/explore-command.d.ts +3 -2
  57. package/dist/src/commands/explore-command.js +35 -23
  58. package/dist/src/commands/help-json-command.js +3 -1
  59. package/dist/src/commands/init-command.js +8 -4
  60. package/dist/src/commands/learn-command.js +0 -2
  61. package/dist/src/commands/options/auth-option.d.ts +6 -0
  62. package/dist/src/commands/options/auth-option.js +14 -0
  63. package/dist/src/commands/options/index.d.ts +2 -0
  64. package/dist/src/commands/options/index.js +2 -0
  65. package/dist/src/commands/plan-command.d.ts +3 -0
  66. package/dist/src/commands/plan-command.js +43 -17
  67. package/dist/src/commands/plan-save-command.js +6 -17
  68. package/dist/src/commands/plans-command.js +1 -1
  69. package/dist/src/commands/test-command.js +12 -2
  70. package/dist/src/components/App.d.ts +1 -2
  71. package/dist/src/components/App.js +1 -3
  72. package/dist/src/components/InputPane.js +0 -5
  73. package/dist/src/config.d.ts +3 -2
  74. package/dist/src/config.js +3 -12
  75. package/dist/src/explorbot.d.ts +3 -2
  76. package/dist/src/explorbot.js +32 -10
  77. package/dist/src/explorer.js +6 -7
  78. package/dist/src/knowledge-tracker.d.ts +2 -2
  79. package/dist/src/knowledge-tracker.js +18 -6
  80. package/dist/src/playwright-recorder.d.ts +0 -1
  81. package/dist/src/playwright-recorder.js +0 -2
  82. package/dist/src/reporter.d.ts +1 -1
  83. package/dist/src/reporter.js +3 -3
  84. package/dist/src/test-plan.js +1 -1
  85. package/dist/src/utils/error-page.d.ts +17 -0
  86. package/dist/src/utils/error-page.js +53 -2
  87. package/dist/src/utils/html-diff.d.ts +1 -2
  88. package/dist/src/utils/html-diff.js +4 -44
  89. package/dist/src/utils/logger.js +27 -10
  90. package/dist/src/utils/markdown-terminal.js +1 -2
  91. package/dist/src/utils/next-steps.js +20 -16
  92. package/dist/src/utils/page-readiness.d.ts +2 -1
  93. package/dist/src/utils/page-readiness.js +24 -5
  94. package/dist/src/utils/test-plan-markdown.d.ts +3 -0
  95. package/dist/src/utils/test-plan-markdown.js +35 -4
  96. package/dist/src/utils/url-matcher.d.ts +1 -0
  97. package/dist/src/utils/url-matcher.js +5 -0
  98. package/docs/basics/providers.md +1 -1
  99. package/docs/contributing/regression-tests.md +19 -4
  100. package/docs/reference/commands.md +18 -2
  101. package/docs/reference/configuration.md +1 -1
  102. package/docs/web-testing/agents.md +8 -0
  103. package/docs/web-testing/customization.md +2 -0
  104. package/docs/web-testing/researcher.md +1 -1
  105. package/docs/workflow/agentic-usage.md +7 -0
  106. package/docs/workflow/knowledge.md +6 -0
  107. package/models.json +1 -1
  108. package/package.json +2 -1
  109. package/rules/navigator/output.md +2 -8
  110. package/rules/navigator/verification-actions.md +6 -2
  111. package/rules/planner/ignored-pages.md +1 -0
  112. package/src/action-result.ts +1 -2
  113. package/src/action.ts +0 -5
  114. package/src/ai/captain/idle-mode.ts +38 -26
  115. package/src/ai/captain/mixin.ts +2 -1
  116. package/src/ai/captain/web-mode.ts +12 -0
  117. package/src/ai/captain.ts +25 -7
  118. package/src/ai/driller.ts +1 -2
  119. package/src/ai/experience-compactor.ts +2 -2
  120. package/src/ai/judge.ts +10 -1
  121. package/src/ai/navigator.ts +12 -7
  122. package/src/ai/planner/subpages.ts +1 -2
  123. package/src/ai/planner.ts +140 -92
  124. package/src/ai/provider.ts +15 -6
  125. package/src/ai/rerunner.ts +1 -2
  126. package/src/ai/researcher/cache.ts +16 -7
  127. package/src/ai/researcher/deep-analysis.ts +7 -2
  128. package/src/ai/researcher/fingerprint-worker.ts +5 -5
  129. package/src/ai/researcher.ts +44 -28
  130. package/src/ai/rules.ts +6 -7
  131. package/src/ai/scout.ts +98 -10
  132. package/src/ai/tester.ts +29 -47
  133. package/src/ai/tools.ts +11 -9
  134. package/src/application-spec.ts +19 -13
  135. package/src/commands/clean-command.ts +1 -1
  136. package/src/commands/context-command.ts +0 -1
  137. package/src/commands/context-experience-command.ts +0 -1
  138. package/src/commands/context-knowledge-command.ts +0 -1
  139. package/src/commands/explore-command.ts +36 -23
  140. package/src/commands/help-json-command.ts +8 -6
  141. package/src/commands/init-command.ts +10 -3
  142. package/src/commands/learn-command.ts +0 -2
  143. package/src/commands/options/auth-option.ts +15 -0
  144. package/src/commands/options/index.ts +2 -0
  145. package/src/commands/plan-command.ts +44 -17
  146. package/src/commands/plan-save-command.ts +6 -17
  147. package/src/commands/plans-command.ts +1 -1
  148. package/src/commands/test-command.ts +11 -2
  149. package/src/components/App.tsx +2 -6
  150. package/src/components/InputPane.tsx +0 -5
  151. package/src/config.ts +5 -16
  152. package/src/experience-tracker.ts +1 -1
  153. package/src/explorbot.ts +31 -11
  154. package/src/explorer.ts +6 -7
  155. package/src/knowledge-tracker.ts +18 -7
  156. package/src/playwright-recorder.ts +0 -2
  157. package/src/reporter.ts +3 -4
  158. package/src/test-plan.ts +1 -2
  159. package/src/utils/error-page.ts +64 -2
  160. package/src/utils/html-diff.ts +4 -56
  161. package/src/utils/html.ts +0 -1
  162. package/src/utils/logger.ts +30 -12
  163. package/src/utils/markdown-terminal.ts +1 -2
  164. package/src/utils/next-steps.ts +19 -17
  165. package/src/utils/page-readiness.ts +33 -7
  166. package/src/utils/test-plan-markdown.ts +36 -4
  167. package/src/utils/url-matcher.ts +6 -0
@@ -3,7 +3,6 @@ import type { ExplorBot } from '../explorbot.js';
3
3
  interface AppProps {
4
4
  explorBot: ExplorBot;
5
5
  initialShowInput?: boolean;
6
- exitOnEmptyInput?: boolean;
7
6
  }
8
- export declare function App({ explorBot, initialShowInput, exitOnEmptyInput }: AppProps): React.JSX.Element;
7
+ export declare function App({ explorBot, initialShowInput }: AppProps): React.JSX.Element;
9
8
  export {};
@@ -13,14 +13,13 @@ import StateTransitionPane from './StateTransitionPane.js';
13
13
  import TaskPane, { WINDOW_SIZE } from './TaskPane.js';
14
14
  import WelcomeChecklist from './WelcomeChecklist.js';
15
15
  import WelcomeCommands from './WelcomeCommands.js';
16
- export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = false }) {
16
+ export function App({ explorBot, initialShowInput = false }) {
17
17
  const sessionStartedAtRef = useRef(Date.now());
18
18
  const InputComponent = InputReadline;
19
19
  const [showSessionTimer, setShowSessionTimer] = useState(false);
20
20
  const [showInput, setShowInput] = useState(initialShowInput);
21
21
  const [interruptPrompt, setInterruptPrompt] = useState(null);
22
22
  const [currentState, setCurrentState] = useState(null);
23
- const [lastTransition, setLastTransition] = useState(null);
24
23
  const [tasks, setTasks] = useState([]);
25
24
  const tasksRef = useRef([]);
26
25
  const [showPlanEditor, setShowPlanEditor] = useState(false);
@@ -50,7 +49,6 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
50
49
  const manager = explorBot.stateManager();
51
50
  unsubscribe = manager.onStateChange((transition) => {
52
51
  if (mounted) {
53
- setLastTransition(transition);
54
52
  setCurrentState(transition.toState);
55
53
  }
56
54
  });
@@ -7,7 +7,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
7
7
  const [cursorPosition, setCursorPosition] = useState(0);
8
8
  const [showAutocomplete, setShowAutocomplete] = useState(false);
9
9
  const [selectedIndex, setSelectedIndex] = useState(0);
10
- const [autoCompleteTriggered, setAutoCompleteTriggered] = useState(false);
11
10
  const inputRef = useRef(inputValue);
12
11
  const cursorRef = useRef(cursorPosition);
13
12
  const addLog = useCallback((entry) => {
@@ -137,7 +136,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
137
136
  setShowAutocomplete(false);
138
137
  setSelectedIndex(0);
139
138
  setCursorPosition(selectedCommand.length);
140
- setAutoCompleteTriggered(true);
141
139
  }
142
140
  return;
143
141
  }
@@ -152,7 +150,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
152
150
  setInputValue(newValue);
153
151
  setCursorPosition(nextCursor);
154
152
  setSelectedIndex(0);
155
- setAutoCompleteTriggered(false);
156
153
  setShowAutocomplete(shouldShowAutocomplete(newValue));
157
154
  }
158
155
  return;
@@ -167,7 +164,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
167
164
  setInputValue(newValue);
168
165
  setCursorPosition(nextCursor);
169
166
  setSelectedIndex(0);
170
- setAutoCompleteTriggered(false);
171
167
  setShowAutocomplete(shouldShowAutocomplete(newValue));
172
168
  }
173
169
  }, { isActive });
@@ -199,7 +195,6 @@ const InputPane = ({ commandHandler, exitOnEmptyInput = false, onSubmit, onComma
199
195
  setShowAutocomplete(false);
200
196
  setSelectedIndex(0);
201
197
  setCursorPosition(selectedCommand.length);
202
- setAutoCompleteTriggered(true);
203
198
  }
204
199
  }, visible: showAutocomplete })));
205
200
  };
@@ -106,6 +106,8 @@ interface PlannerAgentConfig extends AgentConfig {
106
106
  styles?: string[];
107
107
  stylesDir?: string;
108
108
  docsWeight?: number;
109
+ minTests?: number;
110
+ maxTests?: number;
109
111
  }
110
112
  interface ScoutAgentConfig extends AgentConfig {
111
113
  dirs?: string[];
@@ -219,7 +221,7 @@ interface ExplorbotConfig {
219
221
  knowledge: string;
220
222
  experience: string;
221
223
  output: string;
222
- spec?: string;
224
+ spec?: string | string[];
223
225
  };
224
226
  experience?: {
225
227
  maxReadLines?: number;
@@ -282,7 +284,6 @@ export declare class ConfigParser {
282
284
  loadConfigModule(configPath: string): Promise<any>;
283
285
  resolveConfig(config: ExplorbotConfig): ExplorbotConfig;
284
286
  validateConfig(config: ExplorbotConfig): void;
285
- getNestedValue(obj: any, path: string): any;
286
287
  mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig;
287
288
  ensureDirectory(path: string): void;
288
289
  }
@@ -29,15 +29,6 @@ export const PROVIDERS = {
29
29
  export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
30
30
  let cachedOutputRoot = null;
31
31
  let runOutputDir = null;
32
- const config = {
33
- playwright: {
34
- browser: 'chromium',
35
- url: 'http://localhost:3000',
36
- },
37
- ai: {
38
- model: null,
39
- },
40
- };
41
32
  export { EXPLORBOT_CONFIG_PATHS };
42
33
  export const EXPLORBOT_ENV_VARS = [
43
34
  { name: 'EXPLORBOT_AI_PROVIDER', required: true, description: 'Provider name; fills every model role from its recommended models. Turns on config-free mode' },
@@ -50,6 +41,7 @@ export const EXPLORBOT_ENV_VARS = [
50
41
  { name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
51
42
  { name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
52
43
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
44
+ { name: 'EXPLORBOT_AUTH', description: 'Auth details in free form (credentials, a token), kept in memory for the run and never written to disk; same as --auth', secret: true },
53
45
  { name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
54
46
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
55
47
  { name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
@@ -454,9 +446,6 @@ export class ConfigParser {
454
446
  throw new Error(`Invalid URL in configuration: ${url}`);
455
447
  }
456
448
  }
457
- getNestedValue(obj, path) {
458
- return path.split('.').reduce((current, key) => current?.[key], obj);
459
- }
460
449
  mergeWithDefaults(config) {
461
450
  const defaults = {
462
451
  playwright: {
@@ -559,6 +548,8 @@ export function configuredModels(ai) {
559
548
  models.agenticModel = describe(ai.agenticModel);
560
549
  if (ai.visionModel)
561
550
  models.visionModel = describe(ai.visionModel);
551
+ if (ai.decisionModel)
552
+ models.decisionModel = { name: ai.decisionModel.model, provider: ai.decisionModel.provider };
562
553
  for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
563
554
  if (agentConfig?.model)
564
555
  models[agent] = describe(agentConfig.model);
@@ -41,7 +41,7 @@ export interface ExplorBotOptions {
41
41
  optionalAi?: boolean;
42
42
  reporter?: ReporterConfig;
43
43
  attachedBrowser?: Browser;
44
- applicationSpec?: string;
44
+ applicationSpec?: string | string[];
45
45
  }
46
46
  export type UserResolveFunction = (error?: Error, showWelcome?: boolean) => Promise<string | null>;
47
47
  export declare class ExplorBot {
@@ -113,8 +113,9 @@ export declare class ExplorBot {
113
113
  fresh?: boolean;
114
114
  style?: string;
115
115
  extend?: Plan;
116
- completedPlans?: Plan[];
117
116
  noSave?: boolean;
117
+ minTests?: number;
118
+ maxTests?: number;
118
119
  }): Promise<Plan>;
119
120
  getPlansDir(): string;
120
121
  savePlan(filename?: string): string | null;
@@ -126,7 +126,14 @@ export class ExplorBot {
126
126
  await this.visit(this.options.from || '/');
127
127
  }
128
128
  async visit(url) {
129
- await this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
129
+ const target = this.configParser.resolveTargetPath(url);
130
+ await this.agentNavigator()
131
+ .visit(target)
132
+ .catch(async (err) => {
133
+ if (await this.agentCaptain().resolveBlocker(err, () => this.agentNavigator().isOn(target)))
134
+ return;
135
+ throw err;
136
+ });
130
137
  }
131
138
  async openTab() {
132
139
  await this.explorer.openTab();
@@ -226,7 +233,7 @@ export class ExplorBot {
226
233
  if (this.isHistorianEnabled())
227
234
  this.agents.tester.setHistorian(this.agentHistorian());
228
235
  this.agents.tester.setPilot(this.agentPilot());
229
- this.agents.tester.setCaptain(this.agentCaptain());
236
+ this.agents.tester.setSupervisor((input, test) => this.agentCaptain().processSupervisorInterrupt(input, test));
230
237
  const fisherman = this.agentFisherman();
231
238
  if (fisherman)
232
239
  this.agentPilot().setFisherman(fisherman);
@@ -320,9 +327,14 @@ export class ExplorBot {
320
327
  }
321
328
  agentScout() {
322
329
  const scoutConfig = this.config.ai?.agents?.scout;
323
- if (scoutConfig?.enabled !== true)
324
- return null;
325
330
  const dirs = this.scoutCorpusDirs();
331
+ const hasSpec = dirs.length > 0;
332
+ const explicitlyDisabled = scoutConfig?.enabled === false;
333
+ const explicitlyEnabled = scoutConfig?.enabled === true;
334
+ if (explicitlyDisabled)
335
+ return null;
336
+ if (!explicitlyEnabled && !hasSpec)
337
+ return null;
326
338
  if (dirs.length === 0) {
327
339
  tag('warning').log('Scout enabled but no documentation found — set --spec or ai.agents.scout.dirs');
328
340
  return null;
@@ -331,12 +343,11 @@ export class ExplorBot {
331
343
  }
332
344
  scoutCorpusDirs() {
333
345
  const dirs = [];
334
- const specPath = this.options.applicationSpec || this.config.dirs?.spec;
335
- if (specPath) {
346
+ const specPaths = normalizeSpecPaths(this.options.applicationSpec || this.config.dirs?.spec);
347
+ for (const specPath of specPaths) {
336
348
  const bundle = resolveSpecBundlePath(specPath);
337
- const pagesDir = bundle && path.join(bundle, 'pages');
338
- if (pagesDir && existsSync(pagesDir))
339
- dirs.push(pagesDir);
349
+ if (bundle && existsSync(bundle))
350
+ dirs.push(bundle);
340
351
  }
341
352
  for (const dir of this.config.ai?.agents?.scout?.dirs || []) {
342
353
  const resolved = this.configParser.resolveProjectDir(dir);
@@ -372,14 +383,20 @@ export class ExplorBot {
372
383
  }
373
384
  const planner = this.agentPlanner();
374
385
  planner.freshStart = !!opts.fresh;
386
+ if (opts.minTests !== undefined || opts.maxTests !== undefined) {
387
+ planner.setTestCount({ minTests: opts.minTests, maxTests: opts.maxTests });
388
+ }
375
389
  if (this.currentPlan) {
376
390
  planner.setPlan(this.currentPlan);
377
391
  }
378
392
  this.lastPlanError = null;
393
+ const before = new Set(this.currentPlan?.tests ?? []);
379
394
  try {
380
- this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend, opts.completedPlans));
395
+ this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend));
381
396
  }
382
397
  catch (err) {
398
+ if (await this.agentCaptain().resolveBlocker(err, () => !!this.currentPlan?.tests.some((t) => !before.has(t))))
399
+ return this.currentPlan;
383
400
  this.lastPlanError = err instanceof Error ? err : new Error(String(err));
384
401
  tag('warning').log(`Planning failed: ${this.lastPlanError.message}`);
385
402
  if (!this.currentPlan)
@@ -504,3 +521,8 @@ export class ExplorBot {
504
521
  }
505
522
  }
506
523
  }
524
+ function normalizeSpecPaths(spec) {
525
+ if (!spec)
526
+ return [];
527
+ return Array.isArray(spec) ? spec : [spec];
528
+ }
@@ -148,7 +148,6 @@ class Explorer {
148
148
  if (!(await this.recoverOrRestart())) {
149
149
  return { ok: false, action: 'stop', message: 'Browser page could not be recovered' };
150
150
  }
151
- this.watchActiveTestPage();
152
151
  return { ok: true, action: 'continue', recovered: true, message: 'Browser page was recovered' };
153
152
  }
154
153
  async beginTest(test) {
@@ -157,7 +156,7 @@ class Explorer {
157
156
  }
158
157
  this._activeTest = test;
159
158
  test.start();
160
- await this.reporter.reportTestStart(test);
159
+ await this.reporter.reportTestStart();
161
160
  await this.closeOtherTabs();
162
161
  this.stateManager.otherTabs = [];
163
162
  const codeceptjsTest = toCodeceptjsTest(test);
@@ -340,7 +339,7 @@ class Explorer {
340
339
  }
341
340
  async runWithRecovery(label, operation) {
342
341
  if (!this.page && !(await this.recoverOrRestart())) {
343
- throw new Error(`Browser page is unavailable before ${label}`);
342
+ throw new BrowserRecoveryError(label, new Error('Browser page is unavailable'), false);
344
343
  }
345
344
  try {
346
345
  return await operation();
@@ -432,7 +431,6 @@ class Explorer {
432
431
  if (!(await this.recoverOrRestart())) {
433
432
  return { ok: false, action: 'stop', recovered: false, message: `Browser could not be recovered after fatal error: ${message}` };
434
433
  }
435
- this.watchActiveTestPage();
436
434
  return {
437
435
  ok: true,
438
436
  action: 'continue',
@@ -446,9 +444,10 @@ class Explorer {
446
444
  };
447
445
  }
448
446
  async recoverOrRestart() {
449
- if (await this.recoverPage())
450
- return true;
451
- return this.restartBrowser();
447
+ if (!(await this.recoverPage()) && !(await this.restartBrowser()))
448
+ return false;
449
+ this.watchActiveTestPage();
450
+ return true;
452
451
  }
453
452
  async recoverPage() {
454
453
  try {
@@ -5,7 +5,7 @@ export declare class KnowledgeTracker {
5
5
  knowledgeFiles: Knowledge[];
6
6
  sessionKnowledge: Knowledge[];
7
7
  isLoaded: boolean;
8
- applicationSpec?: ApplicationSpec;
8
+ applicationSpecs: ApplicationSpec[];
9
9
  static appendSessionKnowledge(text: string): void;
10
10
  static resetSessionKnowledge(): void;
11
11
  constructor(options?: KnowledgeTrackerOptions);
@@ -48,6 +48,6 @@ export interface Knowledge {
48
48
  [key: string]: any;
49
49
  }
50
50
  export interface KnowledgeTrackerOptions {
51
- applicationSpec?: string;
51
+ applicationSpec?: string | string[];
52
52
  knowledgeDir?: string;
53
53
  }
@@ -19,7 +19,7 @@ export class KnowledgeTracker {
19
19
  knowledgeFiles = [];
20
20
  sessionKnowledge = [];
21
21
  isLoaded = false;
22
- applicationSpec;
22
+ applicationSpecs = [];
23
23
  static appendSessionKnowledge(text) {
24
24
  sessionEntries.push(text);
25
25
  }
@@ -39,9 +39,15 @@ export class KnowledgeTracker {
39
39
  if (!existsSync(this.knowledgeDir)) {
40
40
  mkdirSync(this.knowledgeDir, { recursive: true });
41
41
  }
42
- if (specPath) {
43
- this.applicationSpec = new ApplicationSpec(specPath);
44
- tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
42
+ for (const p of normalizeSpecPaths(specPath)) {
43
+ const spec = new ApplicationSpec(p);
44
+ this.applicationSpecs.push(spec);
45
+ if (spec.pageCount > 0) {
46
+ tag('info').log(`Loaded application spec with ${spec.pageCount} documented pages`);
47
+ }
48
+ else if (spec.markdownFileCount > 0) {
49
+ tag('info').log(`Loaded spec corpus with ${spec.markdownFileCount} markdown files`);
50
+ }
45
51
  }
46
52
  this.sessionKnowledge = sessionEntries.map((entry, index) => {
47
53
  const parsed = matter(entry);
@@ -77,10 +83,11 @@ export class KnowledgeTracker {
77
83
  return [this.renderRelevantKnowledge(state), this.renderApplicationSpec(state)].filter(Boolean).join('\n\n');
78
84
  }
79
85
  renderApplicationSpec(state) {
80
- return this.applicationSpec?.renderFor(state) || '';
86
+ const parts = this.applicationSpecs.map((spec) => spec.renderFor(state)).filter(Boolean);
87
+ return parts.join('\n\n');
81
88
  }
82
89
  applicationSpecUrls(state) {
83
- return this.applicationSpec?.matchedUrls(state) || [];
90
+ return this.applicationSpecs.flatMap((spec) => spec.matchedUrls(state));
84
91
  }
85
92
  addKnowledge(urlPattern, description, opts) {
86
93
  const configParser = ConfigParser.getInstance();
@@ -236,3 +243,8 @@ export class KnowledgeTracker {
236
243
  `;
237
244
  }
238
245
  }
246
+ function normalizeSpecPaths(spec) {
247
+ if (!spec)
248
+ return [];
249
+ return Array.isArray(spec) ? spec : [spec];
250
+ }
@@ -8,7 +8,6 @@ export interface VerificationStep {
8
8
  args: any[];
9
9
  }
10
10
  export declare class PlaywrightRecorder {
11
- context: any;
12
11
  tracing: any;
13
12
  active: boolean;
14
13
  nextGroupId: number;
@@ -23,7 +23,6 @@ function getAsLocator() {
23
23
  return cachedAsLocator;
24
24
  }
25
25
  export class PlaywrightRecorder {
26
- context = null;
27
26
  tracing = null;
28
27
  active = false;
29
28
  nextGroupId = 0;
@@ -52,7 +51,6 @@ export class PlaywrightRecorder {
52
51
  debugLog('start: no tracing on browserContext, recorder inactive');
53
52
  return;
54
53
  }
55
- this.context = browserContext;
56
54
  this.tracing = browserContext.tracing;
57
55
  try {
58
56
  await this.tracing.start({});
@@ -22,7 +22,7 @@ export declare class Reporter {
22
22
  configureMarkdownPipe(): void;
23
23
  configureRunGroup(runGroup: string | null | undefined): void;
24
24
  startRun(): Promise<void>;
25
- reportTestStart(test: Test): Promise<void>;
25
+ reportTestStart(): Promise<void>;
26
26
  combineStepsAndNotes(test: Test, lastScreenshotFile?: string): Step[];
27
27
  buildVerificationStep(test: Test, lastScreenshotFile?: string): Step | undefined;
28
28
  reportTest(test: Test, meta?: ReporterMeta): Promise<void>;
@@ -106,13 +106,13 @@ export class Reporter {
106
106
  process.env.TESTOMATIO_STACK_PASSED = 'true';
107
107
  process.env.TESTOMATIO_STEPS_PASSED = 'true';
108
108
  }
109
- async reportTestStart(test) {
109
+ async reportTestStart() {
110
110
  await this.startRun();
111
111
  }
112
112
  combineStepsAndNotes(test, lastScreenshotFile) {
113
113
  const noteEntries = Object.entries(test.notes)
114
114
  .filter(([, note]) => !note.observation)
115
- .map(([timestampKey, note]) => ({
115
+ .map(([, note]) => ({
116
116
  startTime: note.startTime,
117
117
  endTime: note.endTime,
118
118
  message: note.message,
@@ -122,7 +122,7 @@ export class Reporter {
122
122
  }))
123
123
  .sort((a, b) => a.startTime - b.startTime);
124
124
  const stepEntries = Object.entries(test.steps)
125
- .map(([timestampKey, stepData]) => ({
125
+ .map(([, stepData]) => ({
126
126
  noteStartTime: stepData.noteStartTime,
127
127
  text: stepData.text,
128
128
  duration: stepData.duration,
@@ -461,7 +461,7 @@ export class Plan {
461
461
  if (!plansDir)
462
462
  dirs.push(...listSitePlanDirs());
463
463
  for (const dir of dirs) {
464
- const filePath = names.map((name) => path.resolve(dir, name)).find(existsSync);
464
+ const filePath = names.map((name) => path.resolve(dir, name)).find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
465
465
  if (filePath)
466
466
  return filePath;
467
467
  }
@@ -11,3 +11,20 @@ export declare class ErrorPageError extends Error {
11
11
  readonly httpStatus?: number;
12
12
  constructor(url: string, title?: string, httpStatus?: number);
13
13
  }
14
+ export declare class BlockedError extends Error {
15
+ readonly url: string;
16
+ readonly blocker: string;
17
+ readonly request: string;
18
+ constructor(message: string, url: string, blocker: string, request: string);
19
+ }
20
+ export declare class PlanningBlockedError extends BlockedError {
21
+ readonly feature?: string;
22
+ readonly style?: string;
23
+ constructor(url: string, prerequisite: string, feature?: string, style?: string);
24
+ }
25
+ export declare class NavigationBlockedError extends BlockedError {
26
+ constructor(url: string, currentUrl: string, reason: string, tried: string[]);
27
+ }
28
+ export declare class TestStartBlockedError extends BlockedError {
29
+ constructor(scenario: string, url: string, currentUrl: string);
30
+ }
@@ -1,5 +1,6 @@
1
+ import dedent from 'dedent';
1
2
  import { ActionResult } from '../action-result.js';
2
- import { isBodyEmpty } from './html.js';
3
+ import { isBodyEmpty, sanitizeHtmlString } from './html.js';
3
4
  import { extractStatePath } from './url-matcher.js';
4
5
  const HTTP_ERRORS = ['400 Bad Request', '401 Unauthorized', '403 Forbidden', '404 Not Found', '405 Method Not Allowed', '408 Request Timeout', '500 Internal Server Error', '502 Bad Gateway', '503 Service Unavailable', '504 Gateway Timeout'];
5
6
  const SMALL_PAGE_THRESHOLD = 500;
@@ -27,7 +28,7 @@ export function detectPageCondition(actionResult) {
27
28
  }
28
29
  if (!actionResult.html || isBodyEmpty(actionResult.html))
29
30
  return 'loading';
30
- const bodyMatch = actionResult.html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
31
+ const bodyMatch = sanitizeHtmlString(actionResult.html).match(/<body[^>]*>([\s\S]*?)<\/body>/i);
31
32
  if (bodyMatch && bodyMatch[1].trim().length < SMALL_PAGE_THRESHOLD)
32
33
  return 'loading';
33
34
  return 'ok';
@@ -62,3 +63,53 @@ export class ErrorPageError extends Error {
62
63
  this.name = 'ErrorPageError';
63
64
  }
64
65
  }
66
+ export class BlockedError extends Error {
67
+ url;
68
+ blocker;
69
+ request;
70
+ constructor(message, url, blocker, request) {
71
+ super(message);
72
+ this.url = url;
73
+ this.blocker = blocker;
74
+ this.request = request;
75
+ this.name = 'BlockedError';
76
+ }
77
+ }
78
+ export class PlanningBlockedError extends BlockedError {
79
+ feature;
80
+ style;
81
+ constructor(url, prerequisite, feature, style) {
82
+ let focus = '';
83
+ if (feature)
84
+ focus = ` focused on "${feature}"`;
85
+ let styleNote = '';
86
+ if (style)
87
+ styleNote = ` in ${style} style`;
88
+ super(`Planner could not plan ${url}: ${prerequisite}`, url, prerequisite, dedent `
89
+ Planner was asked to plan tests for ${url}${focus}${styleNote} but stopped: ${prerequisite}.
90
+ Look at the current page to find out what blocks planning, resolve it, then call plan() to re-plan this page. If it cannot be resolved, finish without planning.
91
+ `);
92
+ this.feature = feature;
93
+ this.style = style;
94
+ this.name = 'PlanningBlockedError';
95
+ }
96
+ }
97
+ export class NavigationBlockedError extends BlockedError {
98
+ constructor(url, currentUrl, reason, tried) {
99
+ const lines = [`Navigator was asked to open ${url} but stopped on ${currentUrl}: ${reason}.`];
100
+ if (tried.length)
101
+ lines.push(`Already tried: ${tried.join('; ')}.`);
102
+ lines.push(`Look at the current page to find out what keeps it from ${url}, resolve it, then open ${url}. If it cannot be resolved, finish without opening it.`);
103
+ super(`Navigation to ${url} failed: ${reason}`, url, reason, lines.join('\n'));
104
+ this.name = 'NavigationBlockedError';
105
+ }
106
+ }
107
+ export class TestStartBlockedError extends BlockedError {
108
+ constructor(scenario, url, currentUrl) {
109
+ super(`Test "${scenario}" could not start: ${currentUrl} is not the page it starts on`, url, 'not the start page of this test', dedent `
110
+ Tester was asked to start the test "${scenario}" at ${url}, but the page open at ${currentUrl} is not where this test can start.
111
+ Look at the current page to find out what keeps the test from starting, resolve it, then open ${url}. When you finish, the test runs again from ${url}. If it cannot be resolved, finish and the test is skipped.
112
+ `);
113
+ this.name = 'TestStartBlockedError';
114
+ }
115
+ }
@@ -1,4 +1,3 @@
1
- import type { HtmlConfig } from '../config.js';
2
1
  export interface HtmlDiffPart {
3
2
  container: string;
4
3
  subtree: string;
@@ -21,7 +20,7 @@ export declare function computeHtmlFingerprint(html: string): string[];
21
20
  /**
22
21
  * Compares two HTML documents and returns differences along with a diff subtree.
23
22
  */
24
- export declare function htmlDiff(originalHtml: string, modifiedHtml: string, htmlConfig?: HtmlConfig): Promise<HtmlDiffResult>;
23
+ export declare function htmlDiff(originalHtml: string, modifiedHtml: string): Promise<HtmlDiffResult>;
25
24
  /**
26
25
  * Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
27
26
  */
@@ -138,9 +138,9 @@ export function computeHtmlFingerprint(html) {
138
138
  /**
139
139
  * Compares two HTML documents and returns differences along with a diff subtree.
140
140
  */
141
- export async function htmlDiff(originalHtml, modifiedHtml, htmlConfig) {
142
- const originalDocument = parseDocument(originalHtml, htmlConfig);
143
- const modifiedDocument = parseDocument(modifiedHtml, htmlConfig);
141
+ export async function htmlDiff(originalHtml, modifiedHtml) {
142
+ const originalDocument = parseDocument(originalHtml);
143
+ const modifiedDocument = parseDocument(modifiedHtml);
144
144
  const originalRoot = getRootNodeForFlatten(originalDocument);
145
145
  const modifiedRoot = getRootNodeForFlatten(modifiedDocument);
146
146
  const originalLines = flattenHtml(originalRoot);
@@ -229,7 +229,7 @@ function isLiveRegion(element) {
229
229
  * Parse HTML into a document, wrapping fragments with html/body for consistency.
230
230
  * Uses custom sanitization that removes iframes for diff purposes.
231
231
  */
232
- function parseDocument(html, htmlConfig) {
232
+ function parseDocument(html) {
233
233
  const document = parse(html);
234
234
  const documentTitle = getDocumentTitle(document);
235
235
  sanitizeDocumentTreeForDiff(document);
@@ -336,34 +336,6 @@ const attributesDiffer = (current, previous) => {
336
336
  }
337
337
  return false;
338
338
  };
339
- const getDirectTextValues = (element) => {
340
- if (!element.childNodes || element.childNodes.length === 0) {
341
- return [];
342
- }
343
- const values = [];
344
- for (const child of element.childNodes) {
345
- if (child.nodeName === '#text') {
346
- const value = child.value.trim();
347
- if (value.length > 0) {
348
- values.push(value);
349
- }
350
- }
351
- }
352
- return values;
353
- };
354
- const directTextDiffer = (current, previous) => {
355
- const currentText = getDirectTextValues(current);
356
- const previousText = getDirectTextValues(previous);
357
- if (currentText.length !== previousText.length) {
358
- return true;
359
- }
360
- for (let i = 0; i < currentText.length; i++) {
361
- if (currentText[i] !== previousText[i]) {
362
- return true;
363
- }
364
- }
365
- return false;
366
- };
367
339
  function filterContainerClasses(classes) {
368
340
  return classes
369
341
  .filter((cls) => !/\d/.test(cls))
@@ -654,18 +626,6 @@ function findHtmlElement(document) {
654
626
  }
655
627
  return null;
656
628
  }
657
- function findHeadElement(document) {
658
- const html = findHtmlElement(document);
659
- if (!html || !html.childNodes) {
660
- return null;
661
- }
662
- for (const node of html.childNodes) {
663
- if ('tagName' in node && node.tagName && node.tagName.toLowerCase() === 'head') {
664
- return node;
665
- }
666
- }
667
- return null;
668
- }
669
629
  function findBodyElement(document) {
670
630
  const html = findHtmlElement(document);
671
631
  if (!html || !html.childNodes) {