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,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 };
@@ -284,6 +275,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
284
275
  { name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
285
276
  { name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
286
277
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
278
+ { 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 },
287
279
  { name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
288
280
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
289
281
  { name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
@@ -762,10 +754,6 @@ export class ConfigParser {
762
754
  }
763
755
  }
764
756
 
765
- private getNestedValue(obj: any, path: string): any {
766
- return path.split('.').reduce((current, key) => current?.[key], obj);
767
- }
768
-
769
757
  public mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig {
770
758
  const defaults = {
771
759
  playwright: {
@@ -878,6 +866,7 @@ export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>
878
866
  const models: Record<string, ConfiguredModel> = { model: describe(ai.model) };
879
867
  if (ai.agenticModel) models.agenticModel = describe(ai.agenticModel);
880
868
  if (ai.visionModel) models.visionModel = describe(ai.visionModel);
869
+ if (ai.decisionModel) models.decisionModel = { name: ai.decisionModel.model, provider: ai.decisionModel.provider };
881
870
  for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
882
871
  if (agentConfig?.model) models[agent] = describe(agentConfig.model);
883
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;
@@ -1,6 +1,7 @@
1
+ import dedent from 'dedent';
1
2
  import { ActionResult } from '../action-result.js';
2
3
  import type { WebPageState } from '../state-manager.js';
3
- import { isBodyEmpty } from './html.js';
4
+ import { isBodyEmpty, sanitizeHtmlString } from './html.js';
4
5
  import { extractStatePath } from './url-matcher.js';
5
6
 
6
7
  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'];
@@ -33,7 +34,7 @@ export function detectPageCondition(actionResult: ActionResult): PageCondition {
33
34
 
34
35
  if (!actionResult.html || isBodyEmpty(actionResult.html)) return 'loading';
35
36
 
36
- const bodyMatch = actionResult.html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
37
+ const bodyMatch = sanitizeHtmlString(actionResult.html).match(/<body[^>]*>([\s\S]*?)<\/body>/i);
37
38
  if (bodyMatch && bodyMatch[1].trim().length < SMALL_PAGE_THRESHOLD) return 'loading';
38
39
 
39
40
  return 'ok';
@@ -67,3 +68,64 @@ export class ErrorPageError extends Error {
67
68
  this.name = 'ErrorPageError';
68
69
  }
69
70
  }
71
+
72
+ export class BlockedError extends Error {
73
+ constructor(
74
+ message: string,
75
+ public readonly url: string,
76
+ public readonly blocker: string,
77
+ public readonly request: string
78
+ ) {
79
+ super(message);
80
+ this.name = 'BlockedError';
81
+ }
82
+ }
83
+
84
+ export class PlanningBlockedError extends BlockedError {
85
+ constructor(
86
+ url: string,
87
+ prerequisite: string,
88
+ public readonly feature?: string,
89
+ public readonly style?: string
90
+ ) {
91
+ let focus = '';
92
+ if (feature) focus = ` focused on "${feature}"`;
93
+ let styleNote = '';
94
+ if (style) styleNote = ` in ${style} style`;
95
+ super(
96
+ `Planner could not plan ${url}: ${prerequisite}`,
97
+ url,
98
+ prerequisite,
99
+ dedent`
100
+ Planner was asked to plan tests for ${url}${focus}${styleNote} but stopped: ${prerequisite}.
101
+ 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.
102
+ `
103
+ );
104
+ this.name = 'PlanningBlockedError';
105
+ }
106
+ }
107
+
108
+ export class NavigationBlockedError extends BlockedError {
109
+ constructor(url: string, currentUrl: string, reason: string, tried: string[]) {
110
+ const lines = [`Navigator was asked to open ${url} but stopped on ${currentUrl}: ${reason}.`];
111
+ if (tried.length) lines.push(`Already tried: ${tried.join('; ')}.`);
112
+ 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.`);
113
+ super(`Navigation to ${url} failed: ${reason}`, url, reason, lines.join('\n'));
114
+ this.name = 'NavigationBlockedError';
115
+ }
116
+ }
117
+
118
+ export class TestStartBlockedError extends BlockedError {
119
+ constructor(scenario: string, url: string, currentUrl: string) {
120
+ super(
121
+ `Test "${scenario}" could not start: ${currentUrl} is not the page it starts on`,
122
+ url,
123
+ 'not the start page of this test',
124
+ dedent`
125
+ Tester was asked to start the test "${scenario}" at ${url}, but the page open at ${currentUrl} is not where this test can start.
126
+ 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.
127
+ `
128
+ );
129
+ this.name = 'TestStartBlockedError';
130
+ }
131
+ }
@@ -1,6 +1,5 @@
1
1
  import { parse, serialize } from 'parse5';
2
2
  import type * as parse5TreeAdapter from 'parse5/lib/tree-adapters/default';
3
- import type { HtmlConfig } from '../config.ts';
4
3
  import { isTailwindClass, isTrashClass, minifyHtml } from './html.ts';
5
4
  import { isDynamicId, isGenericClass } from './xpath.ts';
6
5
 
@@ -203,9 +202,9 @@ export function computeHtmlFingerprint(html: string): string[] {
203
202
  /**
204
203
  * Compares two HTML documents and returns differences along with a diff subtree.
205
204
  */
206
- export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlConfig?: HtmlConfig): Promise<HtmlDiffResult> {
207
- const originalDocument = parseDocument(originalHtml, htmlConfig);
208
- const modifiedDocument = parseDocument(modifiedHtml, htmlConfig);
205
+ export async function htmlDiff(originalHtml: string, modifiedHtml: string): Promise<HtmlDiffResult> {
206
+ const originalDocument = parseDocument(originalHtml);
207
+ const modifiedDocument = parseDocument(modifiedHtml);
209
208
 
210
209
  const originalRoot = getRootNodeForFlatten(originalDocument);
211
210
  const modifiedRoot = getRootNodeForFlatten(modifiedDocument);
@@ -308,7 +307,7 @@ function isLiveRegion(element: ElementNode): boolean {
308
307
  * Parse HTML into a document, wrapping fragments with html/body for consistency.
309
308
  * Uses custom sanitization that removes iframes for diff purposes.
310
309
  */
311
- function parseDocument(html: string, htmlConfig?: HtmlConfig): DocumentNode {
310
+ function parseDocument(html: string): DocumentNode {
312
311
  const document = parse(html);
313
312
  const documentTitle = getDocumentTitle(document);
314
313
  sanitizeDocumentTreeForDiff(document);
@@ -432,42 +431,6 @@ const attributesDiffer = (current: ElementNode, previous: ElementNode): boolean
432
431
  return false;
433
432
  };
434
433
 
435
- const getDirectTextValues = (element: ElementNode): string[] => {
436
- if (!element.childNodes || element.childNodes.length === 0) {
437
- return [];
438
- }
439
-
440
- const values: string[] = [];
441
-
442
- for (const child of element.childNodes) {
443
- if (child.nodeName === '#text') {
444
- const value = (child as parse5TreeAdapter.TextNode).value.trim();
445
- if (value.length > 0) {
446
- values.push(value);
447
- }
448
- }
449
- }
450
-
451
- return values;
452
- };
453
-
454
- const directTextDiffer = (current: ElementNode, previous: ElementNode): boolean => {
455
- const currentText = getDirectTextValues(current);
456
- const previousText = getDirectTextValues(previous);
457
-
458
- if (currentText.length !== previousText.length) {
459
- return true;
460
- }
461
-
462
- for (let i = 0; i < currentText.length; i++) {
463
- if (currentText[i] !== previousText[i]) {
464
- return true;
465
- }
466
- }
467
-
468
- return false;
469
- };
470
-
471
434
  function filterContainerClasses(classes: string[]): string[] {
472
435
  return classes
473
436
  .filter((cls) => !/\d/.test(cls))
@@ -792,21 +755,6 @@ function findHtmlElement(document: DocumentNode): ElementNode | null {
792
755
  return null;
793
756
  }
794
757
 
795
- function findHeadElement(document: DocumentNode): ElementNode | null {
796
- const html = findHtmlElement(document);
797
- if (!html || !html.childNodes) {
798
- return null;
799
- }
800
-
801
- for (const node of html.childNodes) {
802
- if ('tagName' in node && node.tagName && node.tagName.toLowerCase() === 'head') {
803
- return node as ElementNode;
804
- }
805
- }
806
-
807
- return null;
808
- }
809
-
810
758
  function findBodyElement(document: DocumentNode): ElementNode | null {
811
759
  const html = findHtmlElement(document);
812
760
  if (!html || !html.childNodes) {
package/src/utils/html.ts CHANGED
@@ -1,4 +1,3 @@
1
- import dedent from 'dedent';
2
1
  import { minify } from 'html-minifier-next';
3
2
  import { parse, parseFragment, serialize } from 'parse5';
4
3
  import type * as parse5TreeAdapter from 'parse5/lib/tree-adapters/default';