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
@@ -5,6 +5,7 @@ import chalk from 'chalk';
5
5
  import debug from 'debug';
6
6
  import dedent from 'dedent';
7
7
  import stripAnsi from 'strip-ansi';
8
+ import wrapAnsi from 'wrap-ansi';
8
9
  import { ConfigParser } from '../config.js';
9
10
  import { Observability } from "../observability.js";
10
11
  import { RecentStepFilter } from "./log-filters.js";
@@ -54,6 +55,10 @@ class ConsoleDestination {
54
55
  forceEnabled = false;
55
56
  quiet = false;
56
57
  recentSteps = new RecentStepFilter();
58
+ width;
59
+ constructor() {
60
+ this.width = process.stdout.columns || 80;
61
+ }
57
62
  isEnabled() {
58
63
  if (this.quiet)
59
64
  return false;
@@ -87,8 +92,7 @@ class ConsoleDestination {
87
92
  if (entry.type === 'multiline' || entry.type === 'details') {
88
93
  const cleaned = stripAnsi(dedent(entry.content));
89
94
  const parsed = parseMarkdownToTerminal(cleaned);
90
- content = parsed;
91
- content = `${chalk.gray(content)}\n`;
95
+ content = `${chalk.dim('\n ┌'.padEnd(this.width, '─'))}\n${this.prefixLines(parsed, ' │ ', chalk.dim, false)}\n`;
92
96
  }
93
97
  else if (entry.type === 'success') {
94
98
  content = `${chalk.green(content)}\n`;
@@ -100,16 +104,33 @@ class ConsoleDestination {
100
104
  content = chalk.yellow(content);
101
105
  }
102
106
  else if (entry.type === 'step') {
103
- content = chalk.gray(` ${content}`);
107
+ content = this.prefixLines(content, ' ', chalk.gray);
104
108
  }
105
109
  else if (entry.type === 'operation') {
106
- content = chalk.gray(` · ${content}`);
110
+ content = this.prefixLines(content, ' · ', chalk.gray);
107
111
  }
108
112
  else if (entry.type === 'substep') {
109
- content = chalk.gray(` > ${content}`);
113
+ content = this.prefixLines(content, ' > ', chalk.gray);
110
114
  }
111
115
  console.log(content);
112
116
  }
117
+ prefixLines(content, prefix, style, strip = true) {
118
+ const prefixWidth = stripAnsi(prefix).length;
119
+ const available = Math.max(20, this.width - prefixWidth);
120
+ const lines = content
121
+ .split('\n')
122
+ .filter((line) => stripAnsi(line).trim() !== '')
123
+ .flatMap((line) => {
124
+ const text = strip ? stripAnsi(line) : line;
125
+ const wrapped = wrapAnsi(text, available, { hard: true });
126
+ return wrapped.split('\n').map((part) => style(`${prefix}${part}`));
127
+ });
128
+ if (lines.length > 10) {
129
+ const remaining = lines.length - 10;
130
+ return [...lines.slice(0, 10), chalk.gray(` │ --- +${remaining} lines ---`)].join('\n');
131
+ }
132
+ return lines.join('\n');
133
+ }
113
134
  }
114
135
  class DebugDestination {
115
136
  verboseMode = false;
@@ -138,13 +159,9 @@ class DebugDestination {
138
159
  class FileDestination {
139
160
  initialized = false;
140
161
  logFilePath = null;
141
- verboseMode = true;
142
162
  isEnabled() {
143
163
  return true;
144
164
  }
145
- setVerboseMode(enabled) {
146
- this.verboseMode = enabled;
147
- }
148
165
  write(entry) {
149
166
  if (entry.type === 'html')
150
167
  return;
@@ -358,7 +375,7 @@ class Logger {
358
375
  truncateTagContent(content, tagName) {
359
376
  const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
360
377
  const tagRegex = new RegExp(`<${escapedTag}>([\\s\\S]*?)<\\/${escapedTag}>`, 'g');
361
- return content.replace(tagRegex, (match, innerContent) => {
378
+ return content.replace(tagRegex, (_, innerContent) => {
362
379
  const trimmed = innerContent.trim();
363
380
  const totalLength = trimmed.length;
364
381
  if (totalLength <= 100) {
@@ -21,10 +21,9 @@ if (renderer) {
21
21
  const originalListitem = renderer.listitem;
22
22
  renderer.listitem = function (text) {
23
23
  if (typeof text === 'object') {
24
- const item = text;
25
24
  const parser = this.parser;
26
25
  const originalParse = parser.parse.bind(parser);
27
- parser.parse = (tokens, loose) => parser.parseInline(tokens);
26
+ parser.parse = (tokens) => parser.parseInline(tokens);
28
27
  const result = originalListitem.call(this, text);
29
28
  parser.parse = originalParse;
30
29
  return result;
@@ -1,4 +1,6 @@
1
1
  import path from 'node:path';
2
+ import chalk from 'chalk';
3
+ import figures from 'figures';
2
4
  import { tag } from './logger.js';
3
5
  export function relativeToCwd(absPath) {
4
6
  const rel = path.relative(process.cwd(), absPath);
@@ -7,25 +9,27 @@ export function relativeToCwd(absPath) {
7
9
  export function printNextSteps(sections) {
8
10
  if (sections.length === 0)
9
11
  return;
10
- const blocks = [];
12
+ const lines = [''];
13
+ let isDefault = true;
11
14
  for (const section of sections) {
12
- const lines = [];
13
15
  const headerPath = section.path ? relativeToCwd(section.path) : '';
14
- lines.push(headerPath ? `${section.label}: ${headerPath}` : section.label);
15
- const commands = section.commands || [];
16
- if (commands.length > 0) {
17
- const labeled = commands.filter((c) => c.label);
18
- const maxLabel = labeled.length > 0 ? Math.max(...labeled.map((c) => c.label.length)) : 0;
19
- for (const cmd of commands) {
20
- if (!cmd.label) {
21
- lines.push(` ${cmd.command}`);
22
- continue;
23
- }
24
- const padded = `${cmd.label}:`.padEnd(maxLabel + 2);
25
- lines.push(` ${padded} ${cmd.command}`);
16
+ lines.push(headerPath ? `${chalk.bold(section.label)} ${chalk.gray(headerPath)}` : `${chalk.bold(section.label)}`);
17
+ for (const cmd of section.commands || []) {
18
+ if (cmd.label)
19
+ lines.push(BLANK);
20
+ if (isDefault) {
21
+ if (cmd.label)
22
+ lines.push(chalk.bold.yellow(`${figures.pointer} ${cmd.label}`));
23
+ lines.push(chalk.bold(` ${cmd.command}`));
24
+ isDefault = false;
25
+ continue;
26
26
  }
27
+ if (cmd.label)
28
+ lines.push(chalk.dim(` ${cmd.label}`));
29
+ lines.push(` ${cmd.command}`);
27
30
  }
28
- blocks.push(lines.join('\n'));
31
+ lines.push(BLANK);
29
32
  }
30
- tag('multiline').log(blocks.join('\n\n'), { maxLines: 18 });
33
+ tag('info').log(lines.join('\n'));
31
34
  }
35
+ const BLANK = ' ';
@@ -1,4 +1,5 @@
1
- export declare function waitForPageReadiness(page: any, options?: PageReadinessOptions): Promise<void>;
1
+ export declare function waitForPageReadiness(page: any, options?: PageReadinessOptions): Promise<boolean>;
2
+ export declare function waitForDomChange(page: any, timeout: number): Promise<boolean>;
2
3
  export declare function sleep(ms: number): Promise<void>;
3
4
  interface PageReadinessOptions {
4
5
  timeout?: number;
@@ -1,10 +1,27 @@
1
1
  export async function waitForPageReadiness(page, options = {}) {
2
2
  if (!page)
3
- return;
3
+ return false;
4
4
  const timeout = options.timeout ?? 6000;
5
5
  await page.waitForLoadState?.('domcontentloaded', { timeout })?.catch(() => { });
6
6
  await Promise.race([waitForNetworkIdle(page, timeout), waitForDomQuiet(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => { });
7
- await waitForPageBodyContent(page, timeout);
7
+ return waitForPageBodyContent(page, timeout);
8
+ }
9
+ export function waitForDomChange(page, timeout) {
10
+ if (!page?.evaluate)
11
+ return Promise.resolve(false);
12
+ return page
13
+ .evaluate((ms) => new Promise((resolve) => {
14
+ const observer = new MutationObserver(() => {
15
+ observer.disconnect();
16
+ resolve(true);
17
+ });
18
+ observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
19
+ setTimeout(() => {
20
+ observer.disconnect();
21
+ resolve(false);
22
+ }, ms);
23
+ }), timeout)
24
+ .catch(() => true);
8
25
  }
9
26
  const DOM_QUIET_MS = 350;
10
27
  function waitForDomQuiet(page, timeout) {
@@ -50,15 +67,17 @@ async function waitForVisibleSpinnersHidden(page, selectors, timeout) {
50
67
  }
51
68
  function waitForPageBodyContent(page, timeout) {
52
69
  if (!page?.waitForFunction)
53
- return Promise.resolve();
70
+ return Promise.resolve(true);
54
71
  return page
55
72
  .waitForFunction(() => {
56
73
  const body = document.body;
57
74
  if (!body)
58
75
  return false;
59
- return body.children.length > 0 || body.textContent?.trim().length > 0;
76
+ if (body.innerText.trim().length > 0)
77
+ return true;
78
+ return !!body.querySelector('input, button, select, textarea, img, svg, canvas, iframe, video');
60
79
  }, undefined, { timeout })
61
- .catch(() => { });
80
+ .then(() => true, () => false);
62
81
  }
63
82
  export function sleep(ms) {
64
83
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -2,6 +2,9 @@ import { Plan } from '../test-plan.js';
2
2
  export declare function planToCompactAiContext(plan: Plan): string;
3
3
  export declare function parsePlanFromMarkdown(filePath: string): Plan;
4
4
  export declare function parsePlansFromMarkdown(filePath: string): Plan[];
5
+ export declare function parsePlansFromMarkdownText(content: string): Plan[];
6
+ export declare function findPlanMarkdownProblems(markdown: string): string[];
7
+ export declare function readPlanMarkdownComment(markdown: string, key: string): string | null;
5
8
  export declare function savePlanToMarkdown(plan: Plan, filePath: string): void;
6
9
  export declare function savePlansToMarkdown(plans: Plan[], filePath: string): void;
7
10
  export declare function planToAiContext(plan: Plan, options?: {
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { Plan, Test } from "../test-plan.js";
3
3
  import { mdq } from "./markdown-query.js";
4
4
  const NOISE_PREFIXES = ['Test started', 'Finish requested:', 'Session name:'];
5
+ const CURRENT_PAGE = 'Current page';
5
6
  function isNoiseNote(message) {
6
7
  return NOISE_PREFIXES.some((prefix) => message.startsWith(prefix));
7
8
  }
@@ -118,7 +119,9 @@ export function parsePlanFromMarkdown(filePath) {
118
119
  return main;
119
120
  }
120
121
  export function parsePlansFromMarkdown(filePath) {
121
- const content = readFileSync(filePath, 'utf-8');
122
+ return parsePlansFromMarkdownText(readFileSync(filePath, 'utf-8'));
123
+ }
124
+ export function parsePlansFromMarkdownText(content) {
122
125
  const lines = content.split('\n');
123
126
  const plans = [];
124
127
  let currentPlan = null;
@@ -184,7 +187,8 @@ export function parsePlansFromMarkdown(filePath) {
184
187
  continue;
185
188
  }
186
189
  if (currentTest && inRequirements && line && !line.startsWith('##')) {
187
- currentTest.startUrl = line;
190
+ if (line !== CURRENT_PAGE)
191
+ currentTest.startUrl = line;
188
192
  continue;
189
193
  }
190
194
  if (currentTest && inSteps && line.startsWith('* ')) {
@@ -207,7 +211,7 @@ export function parsePlansFromMarkdown(filePath) {
207
211
  continue;
208
212
  const nextSuite = content.indexOf('<!-- suite -->', suiteStart + 1);
209
213
  const suiteContent = nextSuite === -1 ? content.slice(suiteStart) : content.slice(suiteStart, nextSuite);
210
- const firstItem = mdq(suiteContent).query('section[0] item[0]').text().trim();
214
+ const firstItem = mdq(suiteContent).query('section[0] item[0]').nodes()[0]?.text.trim() || '';
211
215
  const urlMatch = firstItem.match(/^URL:\s*(.+)/);
212
216
  if (urlMatch) {
213
217
  plan.url = urlMatch[1].replace(/\*\*|`|\*|_|~~?/g, '').trim();
@@ -219,6 +223,33 @@ export function parsePlansFromMarkdown(filePath) {
219
223
  }
220
224
  return plans;
221
225
  }
226
+ export function findPlanMarkdownProblems(markdown) {
227
+ const doc = mdq(markdown);
228
+ const problems = [];
229
+ const headings = doc.query('h1').nodes();
230
+ if (!doc.query('comment(/^suite/)').exists() || headings.length === 0) {
231
+ problems.push('Missing <!-- suite --> comment followed by the plan name heading');
232
+ }
233
+ const scenarioCount = Math.max(headings.length - 1, 0);
234
+ const testComments = doc.query('comment(/^test/)').count();
235
+ if (testComments !== scenarioCount) {
236
+ problems.push(`Found ${scenarioCount} scenario headings but ${testComments} <!-- test --> comments`);
237
+ }
238
+ for (const section of doc.query('section1').each().slice(1)) {
239
+ const title = section.query('heading').nodes()[0]?.text;
240
+ if (!section.query('section2("Steps") item').exists())
241
+ problems.push(`Scenario "${title}" has no ## Steps items`);
242
+ if (!section.query('section2("Expected") item').exists())
243
+ problems.push(`Scenario "${title}" has no ## Expected items`);
244
+ }
245
+ return problems;
246
+ }
247
+ export function readPlanMarkdownComment(markdown, key) {
248
+ const body = mdq(markdown).query(`comment(/^${key}:/)`).nodes()[0]?.text;
249
+ if (!body)
250
+ return null;
251
+ return body.slice(key.length + 1).trim() || null;
252
+ }
222
253
  function formatPlanSuite(plan) {
223
254
  let content = `<!-- suite -->\n# ${plan.title}\n\n`;
224
255
  if (plan.url) {
@@ -231,7 +262,7 @@ function formatPlanSuite(plan) {
231
262
  content += `<!-- test\npriority: ${test.priority}\n-->\n`;
232
263
  content += `# ${test.scenario}\n\n`;
233
264
  content += '## Requirements\n';
234
- content += `${test.startUrl || 'Current page'}\n\n`;
265
+ content += `${test.startUrl || CURRENT_PAGE}\n\n`;
235
266
  if (test.plannedSteps.length > 0) {
236
267
  content += '## Steps\n';
237
268
  for (const step of test.plannedSteps) {
@@ -1,6 +1,7 @@
1
1
  export declare function isDynamicSegment(segment: string): boolean;
2
2
  export declare function hasDynamicUrlSegment(url: string): boolean;
3
3
  export declare function isSamePageFamily(urlA: string, urlB: string): boolean;
4
+ export declare function isSamePageIgnoringQuery(urlA: string, urlB: string): boolean;
4
5
  export declare function generalizeSegment(segment: string): string;
5
6
  export declare function generalizeUrl(url: string, replaceSegment?: (segment: string) => string): string;
6
7
  export declare function matchesUrl(pattern: string, path: string): boolean;
@@ -51,6 +51,11 @@ export function isSamePageFamily(urlA, urlB) {
51
51
  }
52
52
  return true;
53
53
  }
54
+ export function isSamePageIgnoringQuery(urlA, urlB) {
55
+ const a = new URL(urlA, 'http://localhost');
56
+ const b = new URL(urlB, 'http://localhost');
57
+ return a.pathname === b.pathname && a.hash === b.hash;
58
+ }
54
59
  export function generalizeSegment(segment) {
55
60
  if (/^\d+$/.test(segment))
56
61
  return '\\d+';
@@ -59,7 +59,7 @@ export default {
59
59
  model: openrouter('openai/gpt-oss-20b:nitro'),
60
60
  visionModel: openrouter('openai/gpt-5.6-luna'),
61
61
  agenticModel: openrouter('anthropic/claude-haiku-5.5'),
62
- decisionModel: { provider: 'openrouter', model: 'typesafe/jev-latest' },
62
+ decisionModel: { provider: 'openrouter', model: '~typesafe/jev-latest' },
63
63
  },
64
64
  };
65
65
  ```
@@ -28,6 +28,14 @@ The plan (`tests/regression/seeds/vault-plan.md`) describes *what* to do but nev
28
28
 
29
29
  > The code is carried in a knowledge file, not only experience, on purpose. Knowledge is Explorbot's prominent, deterministic channel for page-specific data — the same mechanism scenario A uses for login credentials. Empirically, cheap models followed an experience-only code recipe only occasionally, so an experience-only gate was a coin-flip; knowledge makes the gate reliable while the experience file still exercises recipe reuse.
30
30
 
31
+ ### Scenario C — late-rendering start page (`regression:slow`)
32
+
33
+ Runs scenario A again with the fixture's slow start on: on the first visit of a session `/issues` arrives as an empty shell and renders client-side 12 seconds later, longer than the page-readiness wait. Scenario A's assertions must still hold, which proves the Researcher waited for the real page instead of researching the blank shell. It executes one test instead of five (`--max-tests 1`, at least one passing): research and planning are what it checks, and scenario A already covers running the plan.
34
+
35
+ ### Scenario D — sign-in form at the start URL (`regression:gated`)
36
+
37
+ Runs scenario A's assertions against `explore /workspace`. Signed out, `/workspace` shows the sign-in form at its own URL instead of redirecting to `/login`, and signing in there keeps you on `/workspace`. The planner reports the page blocked by a sign-in form, so explore must hand the report to Captain, sign in with the credentials in `seeds/knowledge/workspace.md` and plan the page again. Like scenario C, it executes one test.
38
+
31
39
  ## The fixture app — "Trackly"
32
40
 
33
41
  A self-contained issue tracker served in-process by `tests/regression/fixture/server.ts` (`Bun.serve`, ephemeral port, fresh in-memory store per attempt). No external network, no database.
@@ -40,6 +48,7 @@ A self-contained issue tracker served in-process by `tests/regression/fixture/se
40
48
  | `/issues/:id` | yes | detail, change-status menu, comment form, delete modal |
41
49
  | `/settings` | yes | Profile/Preferences tabs + an activity iframe |
42
50
  | `/vault` | no | scenario B access-code gate |
51
+ | `/workspace` | no / yes | scenario D: the sign-in form when signed out, the issue list when signed in |
43
52
  | `/api/*` | cookie | REST API over the same store, `+ /api/openapi.json` |
44
53
 
45
54
  The REST API mirrors the store and is served for a future API-testing scenario; the current scenarios do not exercise it.
@@ -68,7 +77,9 @@ bunx bunosh regression:serve
68
77
  export OPENROUTER_API_KEY=sk-...
69
78
  bunx bunosh regression:experience --retries 0 # cheapest AI path
70
79
  bunx bunosh regression:basic --retries 0
71
- bunx bunosh regression:all # both scenarios, default retries
80
+ bunx bunosh regression:slow --retries 0
81
+ bunx bunosh regression:gated --retries 0
82
+ bunx bunosh regression:all # all scenarios, default retries
72
83
  ```
73
84
 
74
85
  Each attempt runs in an isolated throwaway directory under `tests/regression/.runs/` (gitignored). Inspect `tests/regression/.runs/<scenario>-<variant>-a<n>/output/` for the research, plans, and reporter markdown a run produced, and `tests/regression/.runs/report.md` for the summary.
@@ -83,9 +94,13 @@ bunx bunosh regression:variants --scenario basic --variants native,aria,plain
83
94
 
84
95
  ## CI and the approval gate
85
96
 
86
- `.github/workflows/regression.yml` runs on `pull_request` and `workflow_dispatch`. The job uses the `regression` GitHub environment, which holds `OPENROUTER_API_KEY` behind a **required reviewer**. Every run — including fork PRs — pauses at the environment gate before the secret is exposed. The job runs the PR's own code, so the reviewer must read the PR diff (workflow, Bunoshfile, fixture, lib) before approving; that review is the security boundary. The workflow never uses `pull_request_target`.
97
+ `.github/workflows/regression.yml` runs on `pull_request` and `workflow_dispatch`. `regression:all` runs the scenarios in parallel inside one job, so a run lasts as long as its slowest scenario and needs one approval. The job uses the `regression` GitHub environment, which holds `OPENROUTER_API_KEY` behind a **required reviewer**. Every run — including fork PRs — pauses at the environment gate before the secret is exposed. The job runs the PR's own code, so the reviewer must read the PR diff (workflow, Bunoshfile, fixture, lib) before approving; that review is the security boundary. The workflow never uses `pull_request_target`.
98
+
99
+ Every attempt writes a `record.json` into its run directory. `regression:report` merges those records and posts the report as a sticky PR comment (on `pull_request`) or a `Regression Reports` GitHub Discussion (on `workflow_dispatch`). Fork PRs get a read-only token, so comment posting may fail; the report is also written to the step summary.
100
+
101
+ ### Speed comparison
87
102
 
88
- The report is posted as a sticky PR comment (on `pull_request`) or a `Regression Reports` GitHub Discussion (on `workflow_dispatch`). Fork PRs get a read-only token, so comment posting may fail; the report is also written to the job step summary and uploaded as the `regression-runs` artifact.
103
+ The report's `Time` column is the duration of the passing attempt. `Previous median` is the median of that scenario's passing time over the last 5 runs, and `Change` flags a difference of 20% or more. The job uploads the run's records as the `regression-summary` artifact (the run directories go to `regression-runs`) (kept 90 days), and the next run downloads the latest five to compare against. Which tests the planner picks changes a run's time, so read one flagged run as a hint and a trend over several as a regression.
89
104
 
90
105
  The report also embeds the **Session Analysis** from the fresh-explore run — Explorbot's Analyst agent writes a prose summary of what works, defects, UX issues, and execution issues, which the harness reads from `output/reports/<label>.md` and appends to the comment. The Analyst is enabled in the config template (`ai.agents.analyst.enabled: true`); it adds one AI call per basic run.
91
106
 
@@ -93,7 +108,7 @@ The report also embeds the **Session Analysis** from the fresh-explore run — E
93
108
 
94
109
  - **Thresholds** (`MIN_PLANNED_TESTS`, `MIN_PASSED`, `MIN_KEYWORD_HITS`, `MIN_FEATURE_GROUPS`) live at the top of `tests/regression/lib/assertions.ts`.
95
110
  - **Models** live in `tests/regression/fixture/explorbot.config.js`. If cheap models are too flaky, promote a stronger model to `ai.model` or lower `MIN_PASSED`.
96
- - **Run size** — adjust `--max-tests` in `runBasicScenario` and the per-CLI `timeout` values in `Bunoshfile.js`.
111
+ - **Run size** — adjust `maxTests` / `minPassed` on the scenario constants and the per-CLI `timeout` values in `Bunoshfile.js`.
97
112
 
98
113
  ## Owner prerequisites (one-time)
99
114
 
@@ -34,7 +34,7 @@ Inside the TUI, use the matching slash command: `/explore`, `/research`, `/plan`
34
34
  | List saved plans | `npx explorbot plans [plan]` | `/plans [plan]` | Show plans and their tests |
35
35
  | Navigate to a URL | `npx explorbot navigate <url>` | `/navigate <target>` | Reachability probe + session capture |
36
36
  | Drill page components | `npx explorbot drill <url>` | `/drill [--save-knowledge <path>] [--max-components <n>]` | Learn interactions |
37
- | Execute plan tests | `npx explorbot test <planfile> [index]` | `/test [scenario\|number\|*]` | Run scenarios |
37
+ | Execute plan tests | `npx explorbot test <planfile> [index]` | `/test [planfile] [scenario\|number\|*]` | Run scenarios |
38
38
  | Re-run generated tests | `npx explorbot rerun <file> [index]` | `/rerun <file> [index]` | With AI auto-healing |
39
39
  | List generated tests | `npx explorbot runs [file]` | `/runs [file]` | Index + dry-run |
40
40
  | Store domain knowledge | `npx explorbot learn [url] [note]` | `/learn [note]` | Persisted to `knowledge/` |
@@ -93,6 +93,17 @@ npx explorbot explore /pay --knowledge 'Test card 4111 1111 1111 1111, any futur
93
93
 
94
94
  Like `--ws`, it is a program-level option rather than a per-command one: it works on every command — including `api`, `docs` and `prima` — and can go anywhere on the line. It is listed under `npx explorbot --help` rather than in each command's own help.
95
95
 
96
+ ### `--auth`
97
+
98
+ Passes login details to the run in free form — credentials, a token, whatever the app asks for. It joins the run's knowledge on every page and API endpoint, stays in memory, and is never written to disk. `EXPLORBOT_AUTH` does the same from the environment; the flag wins when both are set.
99
+
100
+ ```bash
101
+ npx explorbot explore /dashboard --auth 'login: admin@example.com password: secret123'
102
+ npx explorbot api explore /orders --auth 'Bearer: eyJhbGciOi...'
103
+ ```
104
+
105
+ Like `--knowledge`, it works on every command of `explorbot`, `api`, `docs` and `prima`, and can go anywhere on the line.
106
+
96
107
  ### `--session`
97
108
 
98
109
  Saves browser state (cookies, localStorage, sessionStorage) to a JSON file. The next run restores the session, so you skip login and setup steps.
@@ -129,6 +140,7 @@ EXPLORBOT_AI_PROVIDER=openrouter \
129
140
  | `EXPLORBOT_EPHEMERAL` | Keep no state between runs — output goes to a fresh temp directory instead of the site dir |
130
141
  | `EXPLORBOT_KNOWLEDGE` | Inline knowledge text, applied to every page |
131
142
  | `EXPLORBOT_KNOWLEDGE_FILE` | Path to a knowledge markdown file |
143
+ | `EXPLORBOT_AUTH` | Auth details in free form (credentials, a token), kept in memory for the run and never written to disk; same as --auth |
132
144
  | `EXPLORBOT_SPEC` | Docbot application spec directory or index.md, used as page knowledge |
133
145
  | `EXPLORBOT_API_SPEC` | OpenAPI spec path for the API boat |
134
146
  | `EXPLORBOT_API_HEADERS` | Headers sent with every API request, one "Name: value" per line |
@@ -442,6 +454,8 @@ Execute test scenarios using the Tester agent.
442
454
  ```bash
443
455
  # CLI
444
456
  npx explorbot test output/plans/login.md # run all enabled tests
457
+ npx explorbot test login # same, plan looked up in output/plans
458
+ npx explorbot test login --path example # plan looked up in example/output/plans
445
459
  npx explorbot test output/plans/login.md 3 # run test #3
446
460
  npx explorbot test output/plans/login.md 1-5 # range
447
461
  npx explorbot test output/plans/login.md 1,3,7 # selection
@@ -451,9 +465,11 @@ npx explorbot test 3 --from-plan output/plans/login.md # index first, plan via
451
465
 
452
466
  ```
453
467
  # TUI
454
- /test # Run next pending test
468
+ /test # Run next pending test of the current plan
455
469
  /test * # Run all pending tests
456
470
  /test 2 # Run test #2 from plan
471
+ /test login.md # Load plan file and run all its tests
472
+ /test login.md 2 # Load plan file and run test #2
457
473
  /test login # Run tests matching "login"
458
474
  /test User can logout successfully # Create and run ad-hoc test
459
475
  ```
@@ -218,7 +218,7 @@ The Researcher takes all standard agent options plus options that control intera
218
218
  | Option | Type | Description |
219
219
  |--------|------|-------------|
220
220
  | `maxExpandableClicks` | `number` | Maximum expandable elements clicked during deep analysis (default: 10) |
221
- | `errorPageTimeout` | `number` | Seconds to wait for a loading page to settle before error-page detection (default: 10, `0` disables the wait) |
221
+ | `errorPageTimeout` | `number` | Seconds to wait for a blank or barely rendered page to finish loading before research; uses the same readiness checks as every action, and the judge when `ai.decisionModel` is set (default: 10, `0` disables the wait) |
222
222
  | `focusSections` | `string[]` | CSS selectors that narrow research to a matching element when present (e.g. an open modal or drawer). First match wins. Applies only to the per-section fallback used after a truncated research response. |
223
223
 
224
224
  ```javascript
@@ -125,6 +125,14 @@ The Captain steps in when a slash command isn't enough — answering questions a
125
125
 
126
126
  When a test hits a fatal browser error, the Captain tries to recover — reload, restart the browser, open a fresh tab, or close extra tabs — before the test is stopped, then tells the Tester how to continue. It runs on explicit TUI requests and on test interrupts (stop, pass, skip, or redirect a running test).
127
127
 
128
+ Wherever Explorbot opens a page, plans a page or starts a test, including in `explore` and `freesail`, the Captain also takes over when another agent reports it is blocked:
129
+
130
+ - the Planner can't plan a page, for example because it shows a sign-in form
131
+ - the Navigator can't reach a page after all its attempts
132
+ - the page open at a test's start isn't where that test can start; this check needs [`ai.decisionModel`](../reference/configuration.md)
133
+
134
+ The Captain gets what the agent was asked to do, where it stopped and why, and what was already tried. It works out from the browser session how to get past the blocker. Then the planning, the page or the test continues. If the Captain can't resolve it, the page or test is skipped, and the same blocker isn't handed to it again in that run.
135
+
128
136
  ## Per-Agent Model Configuration
129
137
 
130
138
  Use different models for different agents to control cost:
@@ -27,6 +27,8 @@ Log in with these credentials:
27
27
 
28
28
  Explorbot reads this when it opens the login page and signs in on its own. See [Knowledge](../workflow/knowledge.md).
29
29
 
30
+ Some apps show the sign-in form at the page you asked for instead of redirecting to `/login`. When the planner reports that the page is blocked by a sign-in form, `explore` hands that report to Captain. Captain looks at the browser session, decides how to get past the form with this knowledge, and re-plans the page itself. If Captain adds no tests, the page is skipped with the planner's reason. Set the knowledge file's `url` to that page so the credentials load there.
31
+
30
32
  ## Stay logged in between runs
31
33
 
32
34
  Logging in on every run is slow. Use `--session` to save cookies and local storage, then restore them next time:
@@ -39,7 +39,7 @@ ai: {
39
39
  | `sections` | `string[]` | all sections | Page sections to identify (order = priority) |
40
40
  | `focusSections` | `string[]` | `[]` | CSS selectors used in the truncated-response fallback, when research is split into per-section requests (first match wins). The matching element becomes the container for the focused section — see [Handling Truncated Responses](#handling-truncated-responses). |
41
41
  | `maxExpandableClicks` | `number` | `10` | Max expandable elements clicked during deep research |
42
- | `errorPageTimeout` | `number` | `10` | Seconds to wait for the page to settle before research; error pages detected during this wait abort research. Set `0` to skip the wait |
42
+ | `errorPageTimeout` | `number` | `10` | Seconds to wait for a blank or barely rendered page to settle before research. Uses the same readiness checks as every action (network idle, quiet DOM, `playwright.spinnerSelectors`, visible content), so a small page that has rendered stops the wait early. With `ai.decisionModel` set, the judge decides whether a page that still looks unfinished is loading, an error page or loaded, and Explorbot waits for the page to change before asking again. Error pages detected during this wait abort research. Set `0` to skip the wait |
43
43
  | `retries` | `number` | `2` | Retries when most locators are broken in Stage 2 |
44
44
  | `reasoning` | `string` | `'low'` | AI SDK v7 reasoning effort: `'none'`, `'minimal'`, `'low'`, `'medium'`, `'high'`, `'xhigh'`, `'provider-default'` |
45
45
  | `providerOptions` | `object` | - | Provider-specific options. Reasoning keys here take precedence over `reasoning`. |
@@ -57,6 +57,7 @@ No `init`, no config file, no project directory, no model IDs to look up. These
57
57
  | `EXPLORBOT_EPHEMERAL` | no | Keep no state between runs — output goes to a fresh temp directory instead of the site dir |
58
58
  | `EXPLORBOT_KNOWLEDGE` | no | Inline knowledge text, applied to every page |
59
59
  | `EXPLORBOT_KNOWLEDGE_FILE` | no | Path to a knowledge markdown file |
60
+ | `EXPLORBOT_AUTH` | no | Auth details in free form (credentials, a token), kept in memory for the run and never written to disk; same as --auth |
60
61
  | `EXPLORBOT_SPEC` | no | Docbot application spec directory or index.md, used as page knowledge |
61
62
  | `EXPLORBOT_API_SPEC` | no | OpenAPI spec path for the API boat |
62
63
  | `EXPLORBOT_API_HEADERS` | no | Headers sent with every API request, one "Name: value" per line |
@@ -133,6 +134,12 @@ EXPLORBOT_AI_PROVIDER=openrouter \
133
134
  npx explorbot explore /admin/users
134
135
  ```
135
136
 
137
+ `EXPLORBOT_AUTH` takes the same free-form credentials but keeps them in memory only — nothing lands in the knowledge directory. `--auth` is its flag twin:
138
+
139
+ ```bash
140
+ EXPLORBOT_AUTH="login: admin@example.com password: secret123" npx explorbot explore /admin/users
141
+ ```
142
+
136
143
  `EXPLORBOT_KNOWLEDGE_FILE` points at a markdown file the agent wrote. Its frontmatter is preserved, so it can target specific URLs — see [Knowledge](./knowledge.md) for the format:
137
144
 
138
145
  ```bash
@@ -88,6 +88,12 @@ Send X-Api-Key: ${env.API_KEY} on every request'
88
88
 
89
89
  For runs driven from the environment — config-free, or on the global configuration — `EXPLORBOT_KNOWLEDGE` does the same job for the length of one run. See [Agentic usage](./agentic-usage.md).
90
90
 
91
+ For credentials alone, `--auth` (or `EXPLORBOT_AUTH`) takes free-form text, applies it everywhere, and never writes it to disk:
92
+
93
+ ```bash
94
+ npx explorbot explore /dashboard --auth 'login: admin@example.com password: secret123'
95
+ ```
96
+
91
97
  ## URL Patterns
92
98
 
93
99
  | Pattern | Matches |
package/models.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "model": "openai/gpt-oss-20b:nitro",
4
4
  "visionModel": "openai/gpt-5.6-luna",
5
5
  "agenticModel": "anthropic/claude-haiku-5.5",
6
- "decisionModel": "typesafe/jev-latest"
6
+ "decisionModel": "~typesafe/jev-latest"
7
7
  },
8
8
  "poolside": {
9
9
  "model": "poolside/laguna-xs-2.1"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -131,6 +131,7 @@
131
131
  "sambanova-ai-provider": "^1.2.2",
132
132
  "strip-ansi": "^7.1.2",
133
133
  "turndown": "^7.2.1",
134
+ "wrap-ansi": "^9.0.2",
134
135
  "unique-names-generator": "^4.7.1",
135
136
  "yaml": "^2.8.3",
136
137
  "yargs": "^17.7.2",
@@ -65,14 +65,8 @@ Trying to fill the form on the page
65
65
  ```
66
66
 
67
67
  ```js
68
- I.fillField('/html/body/div/div/div/form/input[@name="name"]', 'Value');
68
+ I.fillField('//html/body//form//input[@name="name"]', 'Value');
69
69
  ```
70
70
  </example_output>
71
71
 
72
- If you don't know the answer, answer as:
73
-
74
- <example_output>
75
- ```js
76
- throw new Error('No resolution');
77
- ```
78
- </example_output>
72
+ If no solution is possible, call stop() with the reason instead of proposing code.
@@ -26,6 +26,10 @@ Prefer ARIA locators for reliable element detection.
26
26
  I.seeElement('.success-message');
27
27
  </example>
28
28
 
29
+ To assert text inside element via `has-text` CSS, prefer using I.see() instead:
30
+
31
+ Prefer `I.see('Collection', '.users')` over `I.seeElement(".users div:has-text("Collection")")`
32
+
29
33
  ### I.seeInField
30
34
 
31
35
  I.seeInField(<locator>, <value>, <context>)
@@ -141,8 +145,8 @@ NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking
141
145
  NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
142
146
  </verification_rules>
143
147
 
144
- [DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
148
+ [Prefer the commands listed here; use other CodeceptJS I.* commands when none of these fits]
145
149
  [INTERACT ONLY WITH ELEMENTS THAT ARE ON THE PAGE HTML OR ARIA SNAPSHOT]
146
150
  [DO NOT USE WAIT FUNCTIONS]
147
151
 
148
- </actions>
152
+ </actions>
@@ -3,4 +3,5 @@ Do not plan tests for these pages, return an empty scenarios list:
3
3
  - sign in
4
4
  - sign up
5
5
  - password recovery
6
+ If the page shows a sign-in form instead of the content its URL is for, also set prerequisite.
6
7
  </ignored_pages>
@@ -1,5 +1,4 @@
1
1
  import fs from 'node:fs';
2
- import { join } from 'node:path';
3
2
  import { ConfigParser, type HtmlConfig, outputPath } from './config.ts';
4
3
  import type { Link, WebPageState } from './state-manager.ts';
5
4
  import { LARGE_ARIA_CHANGE_THRESHOLD, compactAriaSnapshot, diffAriaSnapshots } from './utils/aria.ts';
@@ -742,7 +741,7 @@ export class Diff {
742
741
  async calculate(): Promise<void> {
743
742
  if (!this.previous) return;
744
743
 
745
- this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
744
+ this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html);
746
745
 
747
746
  if (!this._isSameUrl) {
748
747
  this._messages = liveRegionMessages(this.previous.html, this.current.html);