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
package/src/ai/tester.ts CHANGED
@@ -11,16 +11,16 @@ import { Observability } from '../observability.ts';
11
11
  import { type StateTransition, normalizeUrl } from '../state-manager.ts';
12
12
  import { Stats } from '../stats.ts';
13
13
  import { type Test, TestResult, type TestResultType } from '../test-plan.ts';
14
- import { ErrorPageError, isErrorPage } from '../utils/error-page.ts';
14
+ import { ErrorPageError, TestStartBlockedError, isErrorPage } from '../utils/error-page.ts';
15
15
  import { createDebug, tag } from '../utils/logger.ts';
16
16
  import { loop } from '../utils/loop.ts';
17
17
  import { compactErrorMessage } from '../utils/strings.ts';
18
+ import { isSamePageIgnoringQuery } from '../utils/url-matcher.ts';
18
19
  import type { Agent, AgentDeps } from './agent.ts';
19
- import type { Captain } from './captain.ts';
20
20
  import type { Conversation } from './conversation.ts';
21
+ import { pageEvidence } from './judge.ts';
21
22
  import { Navigator } from './navigator.ts';
22
23
  import type { Pilot } from './pilot.ts';
23
- import { Provider } from './provider.ts';
24
24
  import { Researcher } from './researcher.ts';
25
25
  import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from './rules.ts';
26
26
  import { TaskAgent } from './task-agent.ts';
@@ -48,7 +48,7 @@ export class Tester extends TaskAgent implements Agent {
48
48
  private testRun: TestRun | null = null;
49
49
  private currentConversation: Conversation | null = null;
50
50
  private pilot: Pilot | null = null;
51
- private captain: Captain | null = null;
51
+ private supervisor: Supervisor | null = null;
52
52
 
53
53
  MAX_ITERATIONS = 30;
54
54
  MAX_EXTENSIONS = 2;
@@ -90,8 +90,8 @@ export class Tester extends TaskAgent implements Agent {
90
90
  this.pilot = pilot;
91
91
  }
92
92
 
93
- setCaptain(captain: Captain): void {
94
- this.captain = captain;
93
+ setSupervisor(supervisor: Supervisor): void {
94
+ this.supervisor = supervisor;
95
95
  }
96
96
 
97
97
  private getCurrentState(): ActionResult {
@@ -124,7 +124,6 @@ export class Tester extends TaskAgent implements Agent {
124
124
  this.previousRegionPresent = null;
125
125
  this.regionTransitioned = false;
126
126
  this.stateManager.clearHistory();
127
- this.resetFailureCount();
128
127
  this.pilot?.reset();
129
128
 
130
129
  const requestStore = this.requestStore;
@@ -154,6 +153,11 @@ export class Tester extends TaskAgent implements Agent {
154
153
  offFailedRequest?.();
155
154
  return await this.abortStartedTestOnErrorPage(task, initialState);
156
155
  }
156
+ const startEvidence = { ...pageEvidence(initialState), scenario: task.scenario, expected: task.expected, plannedSteps: task.plannedSteps, notes: task.notesToString() };
157
+ if ((await this.judge?.assert('This is the right page to start the test', startEvidence)) === false) {
158
+ offFailedRequest?.();
159
+ throw new TestStartBlockedError(task.scenario, task.startUrl, initialState.url || '');
160
+ }
157
161
 
158
162
  const conversation = this.provider.startConversation(this.getSystemMessage(), 'tester');
159
163
  conversation.markLastMessageCacheable();
@@ -259,7 +263,7 @@ export class Tester extends TaskAgent implements Agent {
259
263
  shouldContinue = false;
260
264
 
261
265
  await loop(
262
- async ({ stop, pause, iteration, userInput }) => {
266
+ async ({ stop, iteration, userInput }) => {
263
267
  if (opts.deadline != null && Date.now() >= opts.deadline) {
264
268
  deadlineReached = true;
265
269
  task.addNote('Time budget reached. Stopped');
@@ -267,17 +271,11 @@ export class Tester extends TaskAgent implements Agent {
267
271
  return;
268
272
  }
269
273
  debugLog('iteration', iteration);
270
- if (!(await this.explorer.recover()).ok) {
271
- task.addNote('Browser page is unavailable');
272
- task.finish(TestResult.FAILED);
273
- stop();
274
- return;
275
- }
276
274
  const currentState = this.getCurrentState();
277
275
 
278
276
  const tools = {
279
277
  ...codeceptjsTools,
280
- ...this.createTestFlowTools(task, currentState, conversation),
278
+ ...this.createTestFlowTools(task, conversation),
281
279
  ...this.agentTools,
282
280
  };
283
281
  if (currentState.isInsideIframe) {
@@ -315,7 +313,7 @@ export class Tester extends TaskAgent implements Agent {
315
313
  conversation.compactToolResults(2);
316
314
 
317
315
  if (iteration > 1) {
318
- const isNewPage = this.previousUrl !== null && this.previousUrl !== currentState.url;
316
+ const isNewPage = this.previousUrl !== null && !isSamePageIgnoringQuery(this.previousUrl, currentState.url);
319
317
  let nextStep = '';
320
318
  nextStep += await this.reinjectContextIfNeeded(iteration, currentState);
321
319
  nextStep += await this.prepareInstructionsForNextStep(task);
@@ -331,7 +329,6 @@ export class Tester extends TaskAgent implements Agent {
331
329
  guidance = await this.pilot.periodicAnalyzeProgress(task, currentState, conversation);
332
330
  }
333
331
  if (guidance) nextStep += `\n\n${guidance}`;
334
- this.consecutiveFailures = 0;
335
332
  this.lastAnalyzedStateHash = currentState.hash;
336
333
  }
337
334
  conversation.addUserText(nextStep);
@@ -357,14 +354,6 @@ export class Tester extends TaskAgent implements Agent {
357
354
  assertionPerformed = !!successfulToolNames.find((toolName: string) => this.ASSERTION_TOOLS.includes(toolName));
358
355
  const wasSuccessful = result?.toolExecutions?.every((execution: any) => execution.wasSuccessful);
359
356
 
360
- this.trackToolExecutions(result?.toolExecutions || []);
361
-
362
- if (this.consecutiveEmptyResults >= 5) {
363
- task.addNote('AI model is not responding with actions. Stopped');
364
- stop();
365
- return;
366
- }
367
-
368
357
  if (actionPerformed && !wasSuccessful) {
369
358
  const noted = new Set(Object.values(task.notes).map((note) => note.message));
370
359
  result?.toolExecutions
@@ -419,10 +408,10 @@ export class Tester extends TaskAgent implements Agent {
419
408
  {
420
409
  maxAttempts: this.MAX_ITERATIONS,
421
410
  interruptPrompt: 'Test interrupted. Enter new instruction (or "stop" to cancel):',
422
- onInterrupt: this.captain
411
+ onInterrupt: this.supervisor
423
412
  ? async (userInput, context) => {
424
413
  if (!userInput) return;
425
- const result = await this.captain!.processSupervisorInterrupt(userInput, task);
414
+ const result = await this.supervisor!(userInput, task);
426
415
  tag('info').log(`🧑‍✈️ Supervisor: ${result.action} — ${result.message}`);
427
416
 
428
417
  const terminalResults: Record<string, (typeof TestResult)[keyof typeof TestResult]> = {
@@ -500,12 +489,10 @@ export class Tester extends TaskAgent implements Agent {
500
489
  }
501
490
 
502
491
  private get isStruggling(): boolean {
503
- return this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2;
492
+ return this.stalledIterations >= this.MAX_STALLED_ITERATIONS - 1;
504
493
  }
505
494
 
506
495
  private shouldStopForStalledExecution(task: Test, previousState: ActionResult, toolExecutions: any[]): boolean {
507
- if (task.hasFinished) return false;
508
-
509
496
  const currentState = this.getCurrentState();
510
497
  const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
511
498
  const actionTools = [...this.ACTION_TOOLS, ...this.DELEGATED_ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
@@ -517,14 +504,7 @@ export class Tester extends TaskAgent implements Agent {
517
504
  return false;
518
505
  }
519
506
 
520
- const hasNoBrowserProgress = toolExecutions.length === 0 || toolExecutions.every((execution) => !actionTools.includes(execution.toolName) || !execution.wasSuccessful);
521
- if (!hasNoBrowserProgress) return false;
522
-
523
- this.stalledIterations++;
524
- if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
525
-
526
- task.addNote('No further browser progress on unchanged page; requesting final review');
527
- return true;
507
+ return this.shouldStopAfterStall(task, 'No further browser progress on unchanged page; requesting final review');
528
508
  }
529
509
 
530
510
  private async prepareInstructionsForNextStep(task: Test): Promise<string> {
@@ -948,9 +928,8 @@ export class Tester extends TaskAgent implements Agent {
948
928
  return '';
949
929
  }
950
930
 
951
- private createTestFlowTools(task: Test, currentState: ActionResult, conversation: Conversation) {
931
+ private createTestFlowTools(task: Test, conversation: Conversation) {
952
932
  const resetUrl = task.startUrl;
953
- const visitedUrls = task.getVisitedUrls();
954
933
  return {
955
934
  reset: tool({
956
935
  description: dedent`
@@ -1161,9 +1140,6 @@ export class Tester extends TaskAgent implements Agent {
1161
1140
  }
1162
1141
  }
1163
1142
 
1164
- const remainingExpectations = task.getRemainingExpectations();
1165
- // const suggestion = input.status !== null && remainingExpectations.length > 0 ? `Continue testing to check the remaining expected outcomes: ${remainingExpectations.join(', ')}` : 'Continue with your testing strategy based on these findings.';
1166
-
1167
1143
  return {
1168
1144
  success: true,
1169
1145
  action: 'record',
@@ -1190,11 +1166,10 @@ export class Tester extends TaskAgent implements Agent {
1190
1166
  }
1191
1167
 
1192
1168
  if (result.recovered) {
1193
- this.resetFailureCount();
1194
1169
  this.previousUrl = null;
1195
1170
  this.previousStateHash = null;
1196
1171
  this.stalledIterations = 0;
1197
- } else if (this.shouldStopAfterStalledLoopError(task)) {
1172
+ } else if (this.shouldStopAfterStall(task, 'No browser progress after repeated execution errors; requesting final review')) {
1198
1173
  return 'stop';
1199
1174
  }
1200
1175
 
@@ -1202,13 +1177,13 @@ export class Tester extends TaskAgent implements Agent {
1202
1177
  return 'continue';
1203
1178
  }
1204
1179
 
1205
- private shouldStopAfterStalledLoopError(task: Test): boolean {
1180
+ private shouldStopAfterStall(task: Test, note: string): boolean {
1206
1181
  if (task.hasFinished) return false;
1207
1182
 
1208
1183
  this.stalledIterations++;
1209
1184
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
1210
1185
 
1211
- task.addNote('No browser progress after repeated execution errors; requesting final review');
1186
+ task.addNote(note);
1212
1187
  return true;
1213
1188
  }
1214
1189
 
@@ -1226,6 +1201,13 @@ interface TestSessionHandlers {
1226
1201
  offFailedRequest?: () => void;
1227
1202
  }
1228
1203
 
1204
+ export type Supervisor = (input: string, test: Test) => Promise<SupervisorAction>;
1205
+
1206
+ export interface SupervisorAction {
1207
+ action: 'inject' | 'stop' | 'pass' | 'skip';
1208
+ message: string;
1209
+ }
1210
+
1229
1211
  export interface TestOptions {
1230
1212
  startOnCurrentPage?: boolean;
1231
1213
  deadline?: number;
package/src/ai/tools.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
- import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action-result.ts';
4
+ import { ActionResult, type PageDiff } from '../action-result.ts';
5
5
  import type { ExecutedStep } from '../action.ts';
6
6
  import { type ExperienceTracker, renderExperienceRecipes } from '../experience-tracker.ts';
7
7
  import { Stats } from '../stats.ts';
@@ -65,8 +65,8 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
65
65
  Never mix different elements — use separate click() calls instead.
66
66
  REQUIRED: include at least one command WITHOUT a container — a wrong or stale container always fails.
67
67
  Order by reliability:
68
- 1. I.click(text, container) - when the container is verified - e.g. I.click("Save", ".modal")
69
- 2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
68
+ 1. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
69
+ 2. I.click(text, container) - when the container is verified - e.g. I.click("Save", ".modal")
70
70
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
71
71
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
72
72
  After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
@@ -292,7 +292,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
292
292
  const normalizedKey = key.toLowerCase();
293
293
  const matchingStandardKey = Array.from(standardKeys).find((sk) => sk.toLowerCase() === normalizedKey);
294
294
  const isStandardKey = !!matchingStandardKey;
295
- const keyToUse = matchingStandardKey || key;
295
+ const keyToUse = KEY_ALIASES[matchingStandardKey || ''] || matchingStandardKey || key;
296
296
 
297
297
  if (!isSingleChar && !isStandardKey) {
298
298
  const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
@@ -480,7 +480,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
480
480
  commandsExecuted: lines.length,
481
481
  attempts: action.executedSteps,
482
482
  code: codeBlock,
483
- suggestion: 'Verify the form was filled in correctly using see() tool. If needed to submit: try click() tool or form() with I.pressKey("Enter").',
483
+ suggestion: 'Check the filled values with verify() before submitting, then submit with click().',
484
484
  },
485
485
  action
486
486
  );
@@ -678,7 +678,7 @@ export function createAgentTools({ explorer, stateManager, ai, judge, researcher
678
678
  inputSchema: z.object({
679
679
  reason: z.string().describe('Why do you need fresh context? Required to prevent overuse.'),
680
680
  }),
681
- execute: async ({ reason }) => {
681
+ execute: async () => {
682
682
  try {
683
683
  const currentState = stateManager.getCurrentState();
684
684
 
@@ -760,6 +760,7 @@ export function createAgentTools({ explorer, stateManager, ai, judge, researcher
760
760
  }
761
761
 
762
762
  return failedToolResult('verify', `Verification failed: ${assertion}`, {
763
+ attempts: result.results.map(({ code, passed, error }) => ({ code, passed, error })),
763
764
  suggestion: 'The assertion could not be verified. Check if the condition is actually present on the page or try a different assertion.',
764
765
  });
765
766
  } catch (error) {
@@ -793,7 +794,7 @@ export function createAgentTools({ explorer, stateManager, ai, judge, researcher
793
794
  inputSchema: z.object({
794
795
  reason: z.string().describe('Why do you need research? What information is missing from existing context?'),
795
796
  }),
796
- execute: async ({ reason }) => {
797
+ execute: async () => {
797
798
  try {
798
799
  const currentState = stateManager.getCurrentState();
799
800
 
@@ -907,7 +908,7 @@ export function createAgentTools({ explorer, stateManager, ai, judge, researcher
907
908
  element: z.string().describe('Visual description of element to click (e.g., "blue Submit button in the modal footer", "Settings gear icon in top right")'),
908
909
  context: z.string().describe('What you already tried and why it failed - helps with accurate identification'),
909
910
  }),
910
- execute: async ({ element, context }) => {
911
+ execute: async ({ element }) => {
911
912
  if (Stats.visionDisabled) {
912
913
  return failedToolResult('visualClick', 'Vision tools are disabled for this session. Use xpathCheck() to find the element, then click() with the discovered locator.');
913
914
  }
@@ -1058,7 +1059,7 @@ export function createAgentTools({ explorer, stateManager, ai, judge, researcher
1058
1059
  xpath: z.string().describe('XPath expression — use broad patterns to avoid missing the element'),
1059
1060
  reason: z.string().describe('What element you are looking for and why'),
1060
1061
  }),
1061
- execute: async ({ xpath, reason }) => {
1062
+ execute: async ({ xpath }) => {
1062
1063
  const currentState = stateManager.getCurrentState();
1063
1064
 
1064
1065
  if (!currentState) {
@@ -1202,6 +1203,7 @@ const ARIA_OUTPUT_CAP = 4000;
1202
1203
  const HTML_OUTPUT_CAP = 6000;
1203
1204
  const ANALYSIS_OUTPUT_CAP = 2000;
1204
1205
  const JUDGE_RECENT_ACTIONS_LIMIT = 8;
1206
+ const KEY_ALIASES: Record<string, string> = { Esc: 'Escape', Del: 'Delete' };
1205
1207
 
1206
1208
  function cap(text: string | undefined | null, max: number): string {
1207
1209
  if (!text) return '';
@@ -10,6 +10,7 @@ import { loadMarkdownFiles } from './utils/markdown-files.ts';
10
10
  export class ApplicationSpec {
11
11
  private pages: ApplicationSpecPage[] = [];
12
12
  readonly sourcePath: string;
13
+ private totalMarkdownFiles = 0;
13
14
 
14
15
  constructor(sourcePath: string) {
15
16
  this.sourcePath = this.resolveSourcePath(sourcePath);
@@ -38,28 +39,27 @@ export class ApplicationSpec {
38
39
  return this.pages.length;
39
40
  }
40
41
 
42
+ get markdownFileCount(): number {
43
+ return this.totalMarkdownFiles;
44
+ }
45
+
41
46
  private load(): void {
42
47
  if (!existsSync(this.sourcePath)) {
43
48
  throw new Error(`Application spec not found: ${this.sourcePath}`);
44
49
  }
45
50
 
46
51
  const isDirectory = statSync(this.sourcePath).isDirectory();
47
- if (!isDirectory && path.basename(this.sourcePath).toLowerCase() !== 'index.md') {
48
- throw new Error(`Application spec file must be index.md: ${this.sourcePath}`);
49
- }
50
-
51
52
  const bundlePath = isDirectory ? this.sourcePath : path.dirname(this.sourcePath);
53
+
54
+ const allFiles = loadMarkdownFiles(bundlePath, { recursive: true });
52
55
  const indexPath = path.join(bundlePath, 'index.md');
53
- if (!existsSync(indexPath)) {
54
- throw new Error(`Application spec index not found: ${indexPath}`);
55
- }
56
+ this.totalMarkdownFiles = allFiles.filter((file) => path.resolve(file.filePath) !== path.resolve(indexPath)).length;
56
57
 
57
- const pagesPath = path.join(bundlePath, 'pages');
58
- if (!existsSync(pagesPath)) {
59
- throw new Error(`Application spec pages directory not found: ${pagesPath}`);
60
- }
58
+ for (const file of allFiles) {
59
+ if (path.resolve(file.filePath) === path.resolve(indexPath)) continue;
60
+
61
+ if (!file.data.url && file.data.format === undefined && file.data.version === undefined) continue;
61
62
 
62
- for (const file of loadMarkdownFiles(pagesPath, { recursive: true })) {
63
63
  const parsed = APPLICATION_SPEC_PAGE_SCHEMA.safeParse(file.data);
64
64
  if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'format')) {
65
65
  throw new Error(`Invalid application spec format in ${file.filePath}`);
@@ -73,8 +73,14 @@ export class ApplicationSpec {
73
73
  this.pages.push({ url: parsed.data.url, content: file.content.trim() });
74
74
  }
75
75
 
76
+ if (this.totalMarkdownFiles === 0) {
77
+ tag('warning').log(`No markdown files found in spec: ${bundlePath}`);
78
+ return;
79
+ }
80
+
76
81
  if (this.pages.length === 0) {
77
- throw new Error(`Application spec contains no documented pages: ${pagesPath}`);
82
+ tag('debug').log(`${this.totalMarkdownFiles} spec files found in ${bundlePath}`);
83
+ return;
78
84
  }
79
85
  }
80
86
 
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { join } from 'node:path';
3
3
  import { ConfigParser, outputPath } from '../config.js';
4
4
  import { tag } from '../utils/logger.js';
5
5
  import { BaseCommand, type Suggestion } from './base-command.js';
@@ -1,4 +1,3 @@
1
- import { ActionResult } from '../action-result.js';
2
1
  import { Researcher } from '../ai/researcher.js';
3
2
  import { visuallyAnnotateContainers } from '../ai/researcher/coordinates.js';
4
3
  import { outputPath } from '../config.js';
@@ -9,7 +9,6 @@ export class ContextExperienceCommand extends BaseCommand {
9
9
  description = 'Print all matching experience for current page';
10
10
 
11
11
  async execute(_args: string): Promise<void> {
12
- const explorer = this.explorBot.getExplorer();
13
12
  const state = this.explorBot.stateManager().getCurrentState();
14
13
 
15
14
  if (!state) {
@@ -9,7 +9,6 @@ export class ContextKnowledgeCommand extends BaseCommand {
9
9
  description = 'Print all matching knowledge for current page';
10
10
 
11
11
  async execute(_args: string): Promise<void> {
12
- const explorer = this.explorBot.getExplorer();
13
12
  const state = this.explorBot.stateManager().getCurrentState();
14
13
 
15
14
  if (!state) {
@@ -6,7 +6,7 @@ import { Stats } from '../stats.js';
6
6
  import { type Plan, type Test, TestResult } from '../test-plan.js';
7
7
  import { browserErrorMessage } from '../utils/browser-errors.ts';
8
8
  import { getCliName } from '../utils/cli-name.ts';
9
- import { ErrorPageError, getStateErrorPageError } from '../utils/error-page.ts';
9
+ import { BlockedError, ErrorPageError, getStateErrorPageError } from '../utils/error-page.ts';
10
10
  import { tag } from '../utils/logger.js';
11
11
  import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
12
12
  import { keepSiteUrls, readSitemap } from '../utils/sitemap.ts';
@@ -14,6 +14,7 @@ import { parseSpecPairs } from '../utils/spec.ts';
14
14
  import { safeFilename } from '../utils/strings.ts';
15
15
  import { UrlFilter } from '../utils/url-filter.ts';
16
16
  import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
17
+ import { PlanCommand } from './plan-command.js';
17
18
 
18
19
  const MAX_SUB_PAGE_ATTEMPTS = 30;
19
20
  const PRIORITY_ORDER: Record<string, number> = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
@@ -136,7 +137,7 @@ export class ExploreCommand extends BaseCommand {
136
137
  }
137
138
 
138
139
  private async runFreshMode(mainUrl: string | undefined, feature: string | undefined, styles?: string[]): Promise<void> {
139
- await this.runAllStyles(mainUrl, feature, undefined, undefined, styles);
140
+ await this.runAllStyles(mainUrl, feature, undefined, styles);
140
141
  this.rememberCurrentPlan();
141
142
  const mainPlan = this.explorBot.getCurrentPlan();
142
143
  if (!mainPlan) return;
@@ -320,7 +321,7 @@ export class ExploreCommand extends BaseCommand {
320
321
  tag('warning').log(`Skipping page: ${errorPage.message}`);
321
322
  return false;
322
323
  }
323
- await this.runAllStyles(url, feature, parentPlan, this.completedPlans, styles);
324
+ await this.runAllStyles(url, feature, parentPlan, styles);
324
325
  const plan = this.explorBot.getCurrentPlan();
325
326
  if (plan?.tests.length && !this.completedPlans.includes(plan)) {
326
327
  this.completedPlans.push(plan);
@@ -333,14 +334,14 @@ export class ExploreCommand extends BaseCommand {
333
334
  for (const style of styleList) {
334
335
  if (this.isLimitReached()) break;
335
336
  this.explorBot.setCurrentPlan(existingPlan);
336
- const opts: { fresh: boolean; style: string; completedPlans?: Plan[]; noSave?: boolean } = { fresh: false, style, completedPlans: this.completedPlans };
337
+ const opts: { fresh: boolean; style: string; noSave?: boolean } = { fresh: false, style };
337
338
  if (this.dryRun) opts.noSave = true;
338
339
  await this.planWithRetry(feature, opts, pageUrl);
339
340
  await this.runPendingTests();
340
341
  }
341
342
  }
342
343
 
343
- private async runAllStyles(pageUrl?: string, feature?: string, parentPlan?: Plan, completedPlans?: Plan[], styles?: string[]): Promise<void> {
344
+ private async runAllStyles(pageUrl?: string, feature?: string, parentPlan?: Plan, styles?: string[]): Promise<void> {
344
345
  const styleList = styles ?? Object.keys(getStyles());
345
346
  let fresh = true;
346
347
  for (const style of styleList) {
@@ -348,7 +349,7 @@ export class ExploreCommand extends BaseCommand {
348
349
  if (!fresh && pageUrl && !this.dryRun) {
349
350
  await this.explorBot.visit(pageUrl);
350
351
  }
351
- const opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean } = { fresh, style, completedPlans };
352
+ const opts: { fresh: boolean; style: string; extend?: Plan; noSave?: boolean } = { fresh, style };
352
353
  if (fresh && parentPlan) opts.extend = parentPlan;
353
354
  if (this.dryRun) opts.noSave = true;
354
355
  await this.planWithRetry(feature, opts, pageUrl);
@@ -371,10 +372,14 @@ export class ExploreCommand extends BaseCommand {
371
372
  this.completedPlans.push(plan);
372
373
  }
373
374
 
374
- private async planWithRetry(feature: string | undefined, opts: { fresh: boolean; style: string; extend?: Plan; completedPlans?: Plan[]; noSave?: boolean }, pageUrl?: string): Promise<void> {
375
+ private async planWithRetry(feature: string | undefined, opts: { fresh: boolean; style: string; extend?: Plan; noSave?: boolean }, pageUrl?: string): Promise<void> {
375
376
  const before = new Set(this.explorBot.getCurrentPlan()?.tests ?? []);
376
377
 
377
378
  await this.explorBot.plan(feature, opts);
379
+ if (this.explorBot.lastPlanError instanceof BlockedError) {
380
+ tag('warning').log(`Skipping page: ${this.explorBot.lastPlanError.message}`);
381
+ return;
382
+ }
378
383
  if (this.explorBot.lastPlanError) {
379
384
  if (this.explorBot.lastPlanError instanceof ErrorPageError) {
380
385
  throw this.explorBot.lastPlanError;
@@ -388,12 +393,15 @@ export class ExploreCommand extends BaseCommand {
388
393
  }
389
394
  }
390
395
 
391
- const planAfter = this.explorBot.getCurrentPlan();
392
- if (!planAfter) return;
393
- const added = planAfter.tests.filter((t) => !before.has(t));
394
- if (added.length === 0) return;
396
+ this.printAddedTests(before, opts.style, pageUrl);
397
+ }
398
+
399
+ private printAddedTests(before: Set<Test>, style: string, pageUrl?: string): number {
400
+ const added = this.explorBot.getCurrentPlan()?.tests.filter((t) => !before.has(t)) ?? [];
401
+ if (added.length === 0) return 0;
395
402
  const urlNote = pageUrl ? ` for ${pageUrl}` : '';
396
- this.printPreview(`Planner added ${added.length} new test(s) [style=${opts.style}]${urlNote}:`, added);
403
+ this.printPreview(`Planner added ${added.length} new test(s) [style=${style}]${urlNote}:`, added);
404
+ return added.length;
397
405
  }
398
406
 
399
407
  private parseConfigure(raw: string | undefined): ConfigureSpec {
@@ -545,16 +553,7 @@ export class ExploreCommand extends BaseCommand {
545
553
  const sections: NextStepSection[] = [];
546
554
 
547
555
  if (savedPlanPath) {
548
- const relPlan = relativeToCwd(savedPlanPath);
549
- sections.push({
550
- label: 'Plan',
551
- path: savedPlanPath,
552
- commands: [
553
- { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
554
- { label: 'Run all', command: `${cli} test ${relPlan} *` },
555
- { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
556
- ],
557
- });
556
+ sections.push(PlanCommand.planSection(savedPlanPath));
558
557
  }
559
558
 
560
559
  const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
@@ -594,6 +593,14 @@ export class ExploreCommand extends BaseCommand {
594
593
  printNextSteps(sections);
595
594
  }
596
595
 
596
+ private skipBlockedTest(test: Test, error: unknown): void {
597
+ if (!(error instanceof BlockedError)) throw error;
598
+ tag('warning').log(`Skipping test: ${error.message}`);
599
+ test.start();
600
+ test.addNote(error.message, TestResult.SKIPPED);
601
+ test.finish(TestResult.SKIPPED);
602
+ }
603
+
597
604
  private isLimitReached(): boolean {
598
605
  if (this.maxTests != null && this.testsRun >= this.maxTests) return true;
599
606
  return this.isDeadlineReached();
@@ -628,7 +635,13 @@ export class ExploreCommand extends BaseCommand {
628
635
  test.start();
629
636
  test.finish(TestResult.SKIPPED);
630
637
  } else {
631
- await this.explorBot.agentTester().test(test, { deadline: this.hardDeadlineAt });
638
+ const run = () => this.explorBot.agentTester().test(test, { deadline: this.hardDeadlineAt });
639
+ await run()
640
+ .catch(async (err) => {
641
+ if (!(await this.explorBot.agentCaptain().resolveBlocker(err, () => this.explorBot.agentNavigator().isOn(test.startUrl)))) throw err;
642
+ return run();
643
+ })
644
+ .catch((err) => this.skipBlockedTest(test, err));
632
645
  }
633
646
  this.testsRun++;
634
647
  }
@@ -31,12 +31,14 @@ export class HelpJsonCommand {
31
31
  required: argument.required,
32
32
  variadic: argument.variadic,
33
33
  })),
34
- options: helper.visibleOptions(cmd).map((option) => ({
35
- flags: option.flags,
36
- description: option.description,
37
- default: option.defaultValue,
38
- choices: option.argChoices,
39
- })),
34
+ options: cmd.options
35
+ .filter((option) => !option.hidden)
36
+ .map((option) => ({
37
+ flags: option.flags,
38
+ description: option.description,
39
+ default: option.defaultValue,
40
+ choices: option.argChoices,
41
+ })),
40
42
  commands: helper.visibleCommands(cmd).map((sub) => HelpJsonCommand.data(sub)),
41
43
  };
42
44
 
@@ -343,11 +343,13 @@ function printInitNextSteps(options: { url?: string; siteCommands?: NextStepComm
343
343
  }
344
344
  );
345
345
 
346
+ sections.push({
347
+ label: 'Add knowledge (logins, credentials) — created on first use in ./knowledge',
348
+ commands: [{ command: `${cli} learn '*' 'to authorize use these credentials: admin@example.com / secret123'` }],
349
+ });
350
+
346
351
  printNextSteps(sections);
347
352
 
348
- log('');
349
- log('Add knowledge (logins, credentials) — created on first use in ./knowledge:');
350
- tag('substep').log(chalk.yellow(`${cli} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
351
353
  tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
352
354
  }
353
355
 
@@ -13,7 +13,6 @@ export class LearnCommand extends BaseCommand {
13
13
 
14
14
  if (!note) {
15
15
  const AddKnowledge = (await import('../components/AddKnowledge.js')).default;
16
- const explorer = this.explorBot.getExplorer();
17
16
  const state = this.explorBot.stateManager().getCurrentState();
18
17
  const initialUrl = state?.url || '';
19
18
 
@@ -32,7 +31,6 @@ export class LearnCommand extends BaseCommand {
32
31
  return;
33
32
  }
34
33
 
35
- const explorer = this.explorBot.getExplorer();
36
34
  const state = this.explorBot.stateManager().getCurrentState();
37
35
 
38
36
  if (!state) {