explorbot 0.3.4 → 0.4.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 (104) hide show
  1. package/bin/explorbot-cli.ts +18 -13
  2. package/boat/doc-collector/src/cli.ts +3 -0
  3. package/boat/doc-collector/src/docbot.ts +3 -1
  4. package/boat/prima/src/cli.ts +21 -8
  5. package/boat/prima/src/envelope.ts +35 -9
  6. package/boat/prima/src/prima.ts +23 -10
  7. package/dist/bin/explorbot-cli.js +19 -13
  8. package/dist/boat/doc-collector/src/cli.js +3 -0
  9. package/dist/boat/doc-collector/src/docbot.js +3 -1
  10. package/dist/boat/prima/src/cli.js +19 -8
  11. package/dist/boat/prima/src/envelope.js +24 -6
  12. package/dist/boat/prima/src/prima.js +23 -11
  13. package/dist/package.json +2 -2
  14. package/dist/src/action-result.d.ts +9 -1
  15. package/dist/src/action-result.js +57 -18
  16. package/dist/src/action.d.ts +1 -1
  17. package/dist/src/action.js +87 -12
  18. package/dist/src/ai/driller.d.ts +0 -1
  19. package/dist/src/ai/driller.js +8 -20
  20. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  21. package/dist/src/ai/fisherman-tools.js +52 -6
  22. package/dist/src/ai/fisherman.d.ts +4 -2
  23. package/dist/src/ai/fisherman.js +48 -27
  24. package/dist/src/ai/historian/codeceptjs.js +1 -1
  25. package/dist/src/ai/historian/playwright.js +1 -1
  26. package/dist/src/ai/pilot.d.ts +1 -0
  27. package/dist/src/ai/pilot.js +15 -1
  28. package/dist/src/ai/planner.js +1 -1
  29. package/dist/src/ai/provider.js +47 -4
  30. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  31. package/dist/src/ai/researcher.js +3 -3
  32. package/dist/src/ai/tester.d.ts +3 -0
  33. package/dist/src/ai/tester.js +40 -3
  34. package/dist/src/ai/tools.d.ts +1 -0
  35. package/dist/src/ai/tools.js +13 -6
  36. package/dist/src/api/request-result.d.ts +2 -0
  37. package/dist/src/api/request-result.js +8 -2
  38. package/dist/src/api/request-store.d.ts +3 -2
  39. package/dist/src/api/request-store.js +66 -14
  40. package/dist/src/commands/explore-command.d.ts +6 -0
  41. package/dist/src/commands/explore-command.js +27 -2
  42. package/dist/src/commands/freesail-command.js +10 -1
  43. package/dist/src/commands/plans-command.js +6 -6
  44. package/dist/src/config.js +1 -0
  45. package/dist/src/experience-tracker.js +5 -0
  46. package/dist/src/explorbot.d.ts +0 -1
  47. package/dist/src/explorbot.js +23 -36
  48. package/dist/src/state-manager.d.ts +5 -1
  49. package/dist/src/state-manager.js +10 -7
  50. package/dist/src/test-plan.d.ts +3 -0
  51. package/dist/src/test-plan.js +27 -0
  52. package/dist/src/utils/aria.d.ts +1 -1
  53. package/dist/src/utils/aria.js +6 -42
  54. package/dist/src/utils/html-diff.d.ts +4 -0
  55. package/dist/src/utils/html-diff.js +62 -7
  56. package/dist/src/utils/html.d.ts +5 -15
  57. package/dist/src/utils/html.js +14 -85
  58. package/dist/src/utils/overlay.d.ts +56 -11
  59. package/dist/src/utils/overlay.js +191 -21
  60. package/dist/src/utils/request-map.d.ts +7 -0
  61. package/dist/src/utils/request-map.js +16 -0
  62. package/dist/src/utils/url-matcher.js +4 -2
  63. package/docs/reference/commands.md +8 -1
  64. package/docs/reference/websocket.md +1 -0
  65. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  66. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  67. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  68. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  69. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  70. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  71. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  72. package/docs/workflow/agentic-usage.md +1 -0
  73. package/docs/workflow/ci.md +1 -0
  74. package/package.json +2 -2
  75. package/src/action-result.ts +61 -22
  76. package/src/action.ts +87 -14
  77. package/src/ai/driller.ts +7 -39
  78. package/src/ai/fisherman-tools.ts +56 -7
  79. package/src/ai/fisherman.ts +48 -28
  80. package/src/ai/historian/codeceptjs.ts +1 -1
  81. package/src/ai/historian/playwright.ts +1 -1
  82. package/src/ai/pilot.ts +11 -1
  83. package/src/ai/planner.ts +1 -1
  84. package/src/ai/provider.ts +48 -4
  85. package/src/ai/researcher/deep-analysis.ts +1 -2
  86. package/src/ai/researcher.ts +3 -3
  87. package/src/ai/tester.ts +40 -3
  88. package/src/ai/tools.ts +17 -9
  89. package/src/api/request-result.ts +10 -2
  90. package/src/api/request-store.ts +60 -13
  91. package/src/commands/explore-command.ts +25 -2
  92. package/src/commands/freesail-command.ts +7 -1
  93. package/src/commands/plans-command.ts +6 -6
  94. package/src/config.ts +1 -0
  95. package/src/experience-tracker.ts +5 -1
  96. package/src/explorbot.ts +20 -36
  97. package/src/state-manager.ts +13 -7
  98. package/src/test-plan.ts +29 -0
  99. package/src/utils/aria.ts +7 -44
  100. package/src/utils/html-diff.ts +62 -7
  101. package/src/utils/html.ts +14 -91
  102. package/src/utils/overlay.ts +226 -23
  103. package/src/utils/request-map.ts +19 -0
  104. package/src/utils/url-matcher.ts +3 -2
@@ -146,6 +146,7 @@ addCommonOptions(
146
146
  .command('explore <path>')
147
147
  .description('Explore a page autonomously and run invented scenarios')
148
148
  .option('--max-tests <count>', 'Maximum number of tests to run')
149
+ .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run; wraps up before the limit is hit')
149
150
  .option('--focus <feature>', 'Focus area for exploration')
150
151
  .option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
151
152
  .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')
@@ -157,6 +158,8 @@ addCommonOptions(
157
158
  const { ExploreCommand } = await import('../src/commands/explore-command.js');
158
159
  const cmd = new ExploreCommand(explorBot);
159
160
  if (options.maxTests) cmd.maxTests = Number.parseInt(options.maxTests, 10);
161
+ if (options.maxDuration) cmd.maxDurationMinutes = Number.parseInt(options.maxDuration, 10);
162
+ else if (process.env.EXPLORBOT_MAX_DURATION) cmd.maxDurationMinutes = Number.parseInt(process.env.EXPLORBOT_MAX_DURATION, 10);
160
163
  if (options.dryRun) cmd.dryRun = true;
161
164
  const execArgs: string[] = [];
162
165
  if (options.focus) execArgs.push('--focus', `"${options.focus}"`);
@@ -257,14 +260,13 @@ addCommonOptions(program.command('plan <path>').description('Generate test plan
257
260
 
258
261
  addCommonOptions(program.command('plan:load <planfile> [index]').description('Load a plan file and display its tests. Pass index to see test details.')).action(async (planfile: string, index: string | undefined) => {
259
262
  try {
260
- const resolvedPath = path.resolve(planfile);
261
- if (!fs.existsSync(resolvedPath)) {
262
- console.error(`Plan file not found: ${resolvedPath}`);
263
+ const plan = Plan.loadFromFile(planfile);
264
+ if (!plan?.filePath) {
265
+ console.error(`Plan file not found: ${planfile}`);
263
266
  process.exit(1);
264
267
  }
265
268
 
266
- const plan = Plan.fromMarkdown(resolvedPath);
267
- const planFile = path.basename(resolvedPath);
269
+ const planFile = path.basename(plan.filePath);
268
270
 
269
271
  if (index) {
270
272
  const idx = Number.parseInt(index, 10);
@@ -276,7 +278,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
276
278
  const lines: string[] = [];
277
279
  lines.push(`## #${idx} ${test.scenario}\n`);
278
280
  lines.push(`**Priority:** ${test.priority}`);
279
- const planUrl = plan.url || plan.tests[0]?.startUrl;
281
+ const planUrl = plan.startUrl;
280
282
  if (planUrl) lines.push(`**Plan URL:** ${planUrl}`);
281
283
  if (test.startUrl && test.startUrl !== planUrl) lines.push(`**Test URL:** ${test.startUrl}`);
282
284
  if (test.plannedSteps.length) {
@@ -293,7 +295,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
293
295
  return;
294
296
  }
295
297
 
296
- const planUrl = plan.url || plan.tests[0]?.startUrl;
298
+ const planUrl = plan.startUrl;
297
299
  const lines: string[] = [`**${plan.title}** (${plan.tests.length} tests)\n`];
298
300
  if (planUrl) {
299
301
  lines.push(`URL: ${planUrl}\n`);
@@ -325,9 +327,6 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
325
327
  addCommonOptions(program.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1,3, 1-5, *, all').option('--grep <pattern>', 'Run tests matching pattern').option('--from-plan <file>', 'Load plan file when the first argument is a test index')).action(
326
328
  async (planfile, index, options) => {
327
329
  try {
328
- const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
329
- await explorBot.start();
330
-
331
330
  let planfileArg = planfile;
332
331
  let indexArg = index;
333
332
  if (options.fromPlan) {
@@ -335,11 +334,16 @@ addCommonOptions(program.command('test <planfile> [index]').description('Execute
335
334
  indexArg = planfile;
336
335
  }
337
336
 
337
+ const planTarget = Plan.loadFromFile(planfileArg)?.startUrl;
338
+
339
+ const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options));
340
+ await explorBot.start();
341
+
338
342
  const plan = explorBot.loadPlan(planfileArg);
339
343
  const pending = plan.getPendingTests();
340
344
  log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
341
345
 
342
- const startUrl = plan.url || pending[0]?.startUrl;
346
+ const startUrl = plan.startUrl;
343
347
  if (!startUrl) {
344
348
  throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
345
349
  }
@@ -347,7 +351,7 @@ addCommonOptions(program.command('test <planfile> [index]').description('Execute
347
351
  log(`Navigating to ${startUrl}`);
348
352
  await explorBot.visit(startUrl);
349
353
 
350
- let args = '';
354
+ let args = '*';
351
355
  if (indexArg) args = indexArg;
352
356
  else if (options.grep) args = options.grep;
353
357
 
@@ -437,10 +441,11 @@ addCommonOptions(
437
441
  .option('--shallow', 'Breadth-first: pick globally least-visited page')
438
442
  .option('--scope <prefix>', 'Restrict navigation to URL prefix')
439
443
  .option('--max-tests <count>', 'Maximum number of tests to run')
444
+ .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run')
440
445
  ).action(async (startUrl, options) => {
441
446
  const explorBot = new ExplorBot(buildExplorBotOptions(startUrl || '/', options));
442
447
  await explorBot.start();
443
- const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`].filter(Boolean).join(' ');
448
+ const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`, options.maxDuration && `--max-duration ${options.maxDuration}`].filter(Boolean).join(' ');
444
449
  const { FreesailCommand } = await import('../src/commands/freesail-command.js');
445
450
  const cmd = new FreesailCommand(explorBot);
446
451
  await cmd.execute(args);
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Command } from 'commander';
4
4
  import { ConfigCommand } from '../../../src/commands/config-command.ts';
5
+ import { remote } from '../../../src/remote.ts';
5
6
  import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from '../../../src/utils/logger.ts';
6
7
  import { DocBot, type DocbotOptions } from './docbot.ts';
7
8
 
@@ -59,9 +60,11 @@ export function createDocsCommands(name = 'docs'): Command {
59
60
  console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);
60
61
 
61
62
  await bot.stop();
63
+ await remote.close(0);
62
64
  process.exit(0);
63
65
  } catch (error) {
64
66
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
67
+ await remote.close(1);
65
68
  process.exit(1);
66
69
  }
67
70
  });
@@ -465,7 +465,9 @@ class DocBot {
465
465
  private saveIndex(startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): { indexPath: string; diagramPath: string } {
466
466
  const outputDir = this.configParser.getOutputDir();
467
467
  const indexPath = path.join(outputDir, 'index.md');
468
- writeFileSync(indexPath, renderSpecIndex(outputDir, startPath, pages, skipped, maxPages), 'utf8');
468
+ const index = renderSpecIndex(outputDir, startPath, pages, skipped, maxPages);
469
+ writeFileSync(indexPath, index, 'utf8');
470
+ tag('data').log('docs', { path: indexPath, content: index });
469
471
  const diagramPath = path.join(outputDir, 'state-diagram.mmd');
470
472
  writeFileSync(diagramPath, renderMermaidBody(outputDir, pages), 'utf8');
471
473
  return { indexPath, diagramPath };
@@ -31,9 +31,9 @@ const checkHelp = dedent`
31
31
  as false.
32
32
  Outcomes are settled against a screenshot of the whole page: what a user can see is
33
33
  the proof, and the run log only says what was done. CONTRADICTION means the two
34
- disagree - reported with both sides rather than settled one way, and ### Artifacts
35
- then names the html, aria and screenshot on disk so you can judge it yourself. Not
36
- finding something in the picture is not enough on its own; that is "not verified".
34
+ disagree - reported with both sides rather than settled one way, so read the html,
35
+ aria and screenshot named under ### Artifacts and judge it yourself. Not finding
36
+ something in the picture is not enough on its own; that is "not verified".
37
37
  ok: follows those outcomes - false when one FAILED or CONTRADICTED, or when the run
38
38
  could not complete, which is reported as such rather than as an app failure.
39
39
  Page problems seen on the way appear under ### Answer, not as step failures.
@@ -53,6 +53,13 @@ const verifyHelp = dedent`
53
53
  could not be expressed, which is not the same as false.
54
54
  `;
55
55
 
56
+ const statusHelp = dedent`
57
+ Reads the files a command recorded, so it needs no browser and outlives the session.
58
+ The hash is looked up across every recorded site. ### Artifacts names every file kept
59
+ under it: the aria tree, the html, the screenshot and network log when they were
60
+ captured, and the per-step captures of a do run.
61
+ `;
62
+
56
63
  const reportHelp = dedent`
57
64
  Commands are logged as they run, so the report needs no browser and outlives the session.
58
65
  The most recent session is reported unless --pw-session names another.
@@ -122,7 +129,7 @@ function primaFor(options: any): Prima {
122
129
  return new Prima(buildOptions(options));
123
130
  }
124
131
 
125
- async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>, record = true): Promise<void> {
132
+ async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>): Promise<void> {
126
133
  setQuietMode(!isVerboseMode());
127
134
  trackActivityLine();
128
135
  const prima = primaFor(options);
@@ -136,7 +143,7 @@ async function runPrima(options: any, command: string, run: (prima: Prima) => Pr
136
143
  envelope = await prima.toolFailureEnvelope(command, error);
137
144
  }
138
145
 
139
- if (record) prima.record(envelope, Date.now() - startedAt);
146
+ prima.record(envelope, Date.now() - startedAt);
140
147
  clearActivityLine();
141
148
  console.log(renderEnvelope(envelope));
142
149
  await prima.stop().catch(() => {});
@@ -211,9 +218,15 @@ export function createPrimaCommands(name = 'prima'): Command {
211
218
  process.exit(0);
212
219
  });
213
220
 
214
- addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command')).action(async (hash, options) => {
215
- await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), false);
216
- });
221
+ addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
222
+ .addHelpText('after', `\n${statusHelp}`)
223
+ .action(async (hash, options) => {
224
+ setQuietMode(!isVerboseMode());
225
+ const prima = primaFor(options);
226
+ const envelope = await prima.status(hash).catch((error: unknown) => prima.toolFailureEnvelope(`status ${hash}`, error));
227
+ console.log(renderEnvelope(envelope));
228
+ process.exit(envelope.ok ? 0 : 1);
229
+ });
217
230
 
218
231
  addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report'))
219
232
  .addHelpText('after', `\n${reportHelp}`)
@@ -1,6 +1,10 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ export const STATUS_FILE = 'status.json';
5
+
6
+ const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' };
7
+
4
8
  const EXPECTATION_LABELS = {
5
9
  passed: 'PASSED ',
6
10
  failed: 'FAILED ',
@@ -33,7 +37,7 @@ export interface EnvelopeData {
33
37
  failure?: { error: string; compactAria?: string };
34
38
  instance: InstanceInfo;
35
39
  status?: string;
36
- artifacts?: { aria: string; html: string; screenshot?: string; network?: string };
40
+ artifacts?: ArtifactPaths;
37
41
  }
38
42
 
39
43
  export function renderEnvelope(data: EnvelopeData): string {
@@ -41,27 +45,40 @@ export function renderEnvelope(data: EnvelopeData): string {
41
45
  return sections.filter((section) => section).join('\n\n');
42
46
  }
43
47
 
44
- export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; screenshot?: Buffer; requests: unknown[] }): { aria: string; html: string; screenshot?: string; network?: string } {
48
+ export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; screenshot?: Buffer; requests: unknown[] }): ArtifactPaths {
45
49
  mkdirSync(dir, { recursive: true });
46
- const paths: { aria: string; html: string; screenshot?: string; network?: string } = {
47
- aria: path.resolve(dir, 'aria.yml'),
48
- html: path.resolve(dir, 'page.html'),
50
+ const paths: ArtifactPaths = {
51
+ aria: path.resolve(dir, ARTIFACT_FILES.aria),
52
+ html: path.resolve(dir, ARTIFACT_FILES.html),
49
53
  };
50
54
  writeFileSync(paths.aria, snapshot.aria ?? '', 'utf-8');
51
55
  writeFileSync(paths.html, snapshot.html ?? '', 'utf-8');
52
56
 
53
57
  if (snapshot.screenshot) {
54
- paths.screenshot = path.resolve(dir, 'page.png');
58
+ paths.screenshot = path.resolve(dir, ARTIFACT_FILES.screenshot);
55
59
  writeFileSync(paths.screenshot, snapshot.screenshot);
56
60
  }
57
61
 
58
62
  if (!snapshot.requests.length) return paths;
59
63
 
60
- paths.network = path.resolve(dir, 'network.jsonl');
64
+ paths.network = path.resolve(dir, ARTIFACT_FILES.network);
61
65
  writeFileSync(paths.network, snapshot.requests.map((request) => `${JSON.stringify(request)}\n`).join(''), 'utf-8');
62
66
  return paths;
63
67
  }
64
68
 
69
+ export function readArtifacts(dir: string): ArtifactPaths {
70
+ const paths: ArtifactPaths = { aria: path.resolve(dir, ARTIFACT_FILES.aria), html: path.resolve(dir, ARTIFACT_FILES.html) };
71
+ if (existsSync(path.resolve(dir, ARTIFACT_FILES.screenshot))) paths.screenshot = path.resolve(dir, ARTIFACT_FILES.screenshot);
72
+ if (existsSync(path.resolve(dir, ARTIFACT_FILES.network))) paths.network = path.resolve(dir, ARTIFACT_FILES.network);
73
+
74
+ const recorded = [...Object.values(ARTIFACT_FILES), STATUS_FILE];
75
+ const files = readdirSync(dir)
76
+ .filter((entry) => !recorded.includes(entry))
77
+ .sort();
78
+ if (files.length) paths.files = files;
79
+ return paths;
80
+ }
81
+
65
82
  function renderResult(data: EnvelopeData): string {
66
83
  const lines = [`ok: ${data.ok}`, `command: ${data.command}`];
67
84
  if (data.used?.length) lines.push(`used: ${data.used.join('; ')}`);
@@ -100,7 +117,7 @@ function renderSteps(data: EnvelopeData): string | null {
100
117
  lines.push(`${index + 1}. ${mark} ${step.label}`);
101
118
  for (const line of (step.proof || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
102
119
  });
103
- if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}`);
120
+ if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{aria.yaml,html,diff.yaml}`);
104
121
  return section('Steps', lines.join('\n'));
105
122
  }
106
123
 
@@ -178,6 +195,7 @@ export function renderArtifacts(data: EnvelopeData): string | null {
178
195
  const lines = [`aria: ${data.artifacts.aria}`, `html: ${data.artifacts.html}`];
179
196
  if (data.artifacts.screenshot) lines.push(`screenshot: ${data.artifacts.screenshot}`);
180
197
  if (data.artifacts.network) lines.push(`network: ${data.artifacts.network}`);
198
+ if (data.artifacts.files?.length) lines.push(`also here: ${data.artifacts.files.join(', ')}`);
181
199
  return section('Artifacts', lines.join('\n'));
182
200
  }
183
201
 
@@ -189,3 +207,11 @@ function align(label: string, marker: string, width: number): string {
189
207
  function section(title: string, body: string): string {
190
208
  return `### ${title}\n${body}`;
191
209
  }
210
+
211
+ export interface ArtifactPaths {
212
+ aria: string;
213
+ html: string;
214
+ screenshot?: string;
215
+ network?: string;
216
+ files?: string[];
217
+ }
@@ -25,7 +25,7 @@ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
25
25
  import { pluralize } from '../../../src/utils/logger.ts';
26
26
  import { mdq } from '../../../src/utils/markdown-query.ts';
27
27
  import { safeFilename } from '../../../src/utils/strings.ts';
28
- import { type EnvelopeData, type InstanceInfo, writeArtifacts } from './envelope.ts';
28
+ import { type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts';
29
29
  import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts';
30
30
  import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
31
31
  import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
@@ -376,7 +376,6 @@ export class Prima {
376
376
 
377
377
  const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
378
378
  if (problems.length) envelope.failure = { error: problems.join('\n') };
379
- if (contradicted.length) envelope.artifacts = this.artifacts;
380
379
 
381
380
  if (!test.hasFinished || test.isSkipped) {
382
381
  envelope.ok = false;
@@ -982,14 +981,17 @@ export class Prima {
982
981
  }
983
982
 
984
983
  private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
984
+ const changes = await this.pageChanges(result, previousState, used[0]);
985
+ const status = await this.saveStatus(result);
985
986
  return {
986
987
  ok: true,
987
988
  command,
988
989
  used,
989
990
  page: this.pageBlock(result, previousState),
990
- changes: await this.pageChanges(result, previousState, used[0]),
991
+ changes,
991
992
  instance: await this.instanceInfo(),
992
- status: await this.saveStatus(result),
993
+ status,
994
+ artifacts: this.artifacts,
993
995
  };
994
996
  }
995
997
 
@@ -998,24 +1000,28 @@ export class Prima {
998
1000
  const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) };
999
1001
  if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
1000
1002
 
1003
+ const status = await this.saveStatus(result);
1001
1004
  return {
1002
1005
  ok: false,
1003
1006
  command,
1004
1007
  page: this.pageBlock(result, previousState),
1005
1008
  failure,
1006
1009
  instance: await this.instanceInfo(),
1007
- status: await this.saveStatus(result),
1010
+ status,
1011
+ artifacts: this.artifacts,
1008
1012
  };
1009
1013
  }
1010
1014
 
1011
1015
  private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial<EnvelopeData>): Promise<EnvelopeData> {
1016
+ const status = await this.saveStatus(result);
1012
1017
  return {
1013
1018
  ok: true,
1014
1019
  command,
1015
1020
  page: this.pageBlock(result, previousState),
1016
1021
  ...outcome,
1017
1022
  instance: await this.instanceInfo(),
1018
- status: await this.saveStatus(result),
1023
+ status,
1024
+ artifacts: this.artifacts,
1019
1025
  };
1020
1026
  }
1021
1027
 
@@ -1060,9 +1066,16 @@ export class Prima {
1060
1066
  }
1061
1067
 
1062
1068
  async status(hash: string): Promise<EnvelopeData> {
1069
+ if (!this.artifactsDir) {
1070
+ const sites = listSites();
1071
+ const site = sites.find((candidate) => existsSync(path.join(candidate.dir, 'output', 'prima', hash))) || sites[0];
1072
+ if (site && !this.configBaseUrl()) this.sessionUrl = site.url;
1073
+ await this.loadConfig();
1074
+ }
1075
+
1063
1076
  const dir = this.statusDir(hash);
1064
- const statusFile = path.join(dir, 'status.json');
1065
- if (!existsSync(statusFile)) return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${hash}. Every envelope prints its own hash on the Instance line.`);
1077
+ const statusFile = path.join(dir, STATUS_FILE);
1078
+ if (!existsSync(statusFile)) return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${dir}. Every envelope prints its own hash on the Instance line.`);
1066
1079
 
1067
1080
  const saved = JSON.parse(readFileSync(statusFile, 'utf-8'));
1068
1081
  return {
@@ -1070,14 +1083,14 @@ export class Prima {
1070
1083
  command: `status ${hash}`,
1071
1084
  page: saved.page,
1072
1085
  instance: await this.instanceInfo(),
1073
- artifacts: { aria: path.join(dir, 'aria.yml'), html: path.join(dir, 'page.html') },
1086
+ artifacts: readArtifacts(dir),
1074
1087
  };
1075
1088
  }
1076
1089
 
1077
1090
  private async saveStatus(result: ActionResult): Promise<string> {
1078
1091
  const hash = this.statusHash();
1079
1092
  await this.writeSnapshot(result);
1080
- writeFileSync(path.join(this.statusDir(hash), 'status.json'), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
1093
+ writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
1081
1094
  return hash;
1082
1095
  }
1083
1096
 
@@ -114,6 +114,7 @@ addCommonOptions(program
114
114
  .command('explore <path>')
115
115
  .description('Explore a page autonomously and run invented scenarios')
116
116
  .option('--max-tests <count>', 'Maximum number of tests to run')
117
+ .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run; wraps up before the limit is hit')
117
118
  .option('--focus <feature>', 'Focus area for exploration')
118
119
  .option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
119
120
  .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')).action(async (explorePath, options) => {
@@ -125,6 +126,10 @@ addCommonOptions(program
125
126
  const cmd = new ExploreCommand(explorBot);
126
127
  if (options.maxTests)
127
128
  cmd.maxTests = Number.parseInt(options.maxTests, 10);
129
+ if (options.maxDuration)
130
+ cmd.maxDurationMinutes = Number.parseInt(options.maxDuration, 10);
131
+ else if (process.env.EXPLORBOT_MAX_DURATION)
132
+ cmd.maxDurationMinutes = Number.parseInt(process.env.EXPLORBOT_MAX_DURATION, 10);
128
133
  if (options.dryRun)
129
134
  cmd.dryRun = true;
130
135
  const execArgs = [];
@@ -219,13 +224,12 @@ addCommonOptions(program.command('plan <path>').description('Generate test plan
219
224
  });
220
225
  addCommonOptions(program.command('plan:load <planfile> [index]').description('Load a plan file and display its tests. Pass index to see test details.')).action(async (planfile, index) => {
221
226
  try {
222
- const resolvedPath = path.resolve(planfile);
223
- if (!fs.existsSync(resolvedPath)) {
224
- console.error(`Plan file not found: ${resolvedPath}`);
227
+ const plan = Plan.loadFromFile(planfile);
228
+ if (!plan?.filePath) {
229
+ console.error(`Plan file not found: ${planfile}`);
225
230
  process.exit(1);
226
231
  }
227
- const plan = Plan.fromMarkdown(resolvedPath);
228
- const planFile = path.basename(resolvedPath);
232
+ const planFile = path.basename(plan.filePath);
229
233
  if (index) {
230
234
  const idx = Number.parseInt(index, 10);
231
235
  if (Number.isNaN(idx) || idx < 1 || idx > plan.tests.length) {
@@ -236,7 +240,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
236
240
  const lines = [];
237
241
  lines.push(`## #${idx} ${test.scenario}\n`);
238
242
  lines.push(`**Priority:** ${test.priority}`);
239
- const planUrl = plan.url || plan.tests[0]?.startUrl;
243
+ const planUrl = plan.startUrl;
240
244
  if (planUrl)
241
245
  lines.push(`**Plan URL:** ${planUrl}`);
242
246
  if (test.startUrl && test.startUrl !== planUrl)
@@ -256,7 +260,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
256
260
  console.log(parseMarkdownToTerminal(lines.join('\n')));
257
261
  return;
258
262
  }
259
- const planUrl = plan.url || plan.tests[0]?.startUrl;
263
+ const planUrl = plan.startUrl;
260
264
  const lines = [`**${plan.title}** (${plan.tests.length} tests)\n`];
261
265
  if (planUrl) {
262
266
  lines.push(`URL: ${planUrl}\n`);
@@ -284,24 +288,25 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
284
288
  });
285
289
  addCommonOptions(program.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1,3, 1-5, *, all').option('--grep <pattern>', 'Run tests matching pattern').option('--from-plan <file>', 'Load plan file when the first argument is a test index')).action(async (planfile, index, options) => {
286
290
  try {
287
- const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
288
- await explorBot.start();
289
291
  let planfileArg = planfile;
290
292
  let indexArg = index;
291
293
  if (options.fromPlan) {
292
294
  planfileArg = options.fromPlan;
293
295
  indexArg = planfile;
294
296
  }
297
+ const planTarget = Plan.loadFromFile(planfileArg)?.startUrl;
298
+ const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options));
299
+ await explorBot.start();
295
300
  const plan = explorBot.loadPlan(planfileArg);
296
301
  const pending = plan.getPendingTests();
297
302
  log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
298
- const startUrl = plan.url || pending[0]?.startUrl;
303
+ const startUrl = plan.startUrl;
299
304
  if (!startUrl) {
300
305
  throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
301
306
  }
302
307
  log(`Navigating to ${startUrl}`);
303
308
  await explorBot.visit(startUrl);
304
- let args = '';
309
+ let args = '*';
305
310
  if (indexArg)
306
311
  args = indexArg;
307
312
  else if (options.grep)
@@ -387,10 +392,11 @@ addCommonOptions(program
387
392
  .option('--deep', 'Depth-first: prioritize newly discovered pages')
388
393
  .option('--shallow', 'Breadth-first: pick globally least-visited page')
389
394
  .option('--scope <prefix>', 'Restrict navigation to URL prefix')
390
- .option('--max-tests <count>', 'Maximum number of tests to run')).action(async (startUrl, options) => {
395
+ .option('--max-tests <count>', 'Maximum number of tests to run')
396
+ .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run')).action(async (startUrl, options) => {
391
397
  const explorBot = new ExplorBot(buildExplorBotOptions(startUrl || '/', options));
392
398
  await explorBot.start();
393
- const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`].filter(Boolean).join(' ');
399
+ const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`, options.maxDuration && `--max-duration ${options.maxDuration}`].filter(Boolean).join(' ');
394
400
  const { FreesailCommand } = await import('../src/commands/freesail-command.js');
395
401
  const cmd = new FreesailCommand(explorBot);
396
402
  await cmd.execute(args);
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Command } from 'commander';
4
4
  import { ConfigCommand } from "../../../src/commands/config-command.js";
5
+ import { remote } from "../../../src/remote.js";
5
6
  import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from "../../../src/utils/logger.js";
6
7
  import { DocBot } from "./docbot.js";
7
8
  function buildOptions(options) {
@@ -50,10 +51,12 @@ export function createDocsCommands(name = 'docs') {
50
51
  console.log(`Pages dir: ${path.join(result.outputDir, 'pages')}`);
51
52
  console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);
52
53
  await bot.stop();
54
+ await remote.close(0);
53
55
  process.exit(0);
54
56
  }
55
57
  catch (error) {
56
58
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
59
+ await remote.close(1);
57
60
  process.exit(1);
58
61
  }
59
62
  });
@@ -409,7 +409,9 @@ class DocBot {
409
409
  saveIndex(startPath, pages, skipped, maxPages) {
410
410
  const outputDir = this.configParser.getOutputDir();
411
411
  const indexPath = path.join(outputDir, 'index.md');
412
- writeFileSync(indexPath, renderSpecIndex(outputDir, startPath, pages, skipped, maxPages), 'utf8');
412
+ const index = renderSpecIndex(outputDir, startPath, pages, skipped, maxPages);
413
+ writeFileSync(indexPath, index, 'utf8');
414
+ tag('data').log('docs', { path: indexPath, content: index });
413
415
  const diagramPath = path.join(outputDir, 'state-diagram.mmd');
414
416
  writeFileSync(diagramPath, renderMermaidBody(outputDir, pages), 'utf8');
415
417
  return { indexPath, diagramPath };
@@ -29,9 +29,9 @@ const checkHelp = dedent `
29
29
  as false.
30
30
  Outcomes are settled against a screenshot of the whole page: what a user can see is
31
31
  the proof, and the run log only says what was done. CONTRADICTION means the two
32
- disagree - reported with both sides rather than settled one way, and ### Artifacts
33
- then names the html, aria and screenshot on disk so you can judge it yourself. Not
34
- finding something in the picture is not enough on its own; that is "not verified".
32
+ disagree - reported with both sides rather than settled one way, so read the html,
33
+ aria and screenshot named under ### Artifacts and judge it yourself. Not finding
34
+ something in the picture is not enough on its own; that is "not verified".
35
35
  ok: follows those outcomes - false when one FAILED or CONTRADICTED, or when the run
36
36
  could not complete, which is reported as such rather than as an app failure.
37
37
  Page problems seen on the way appear under ### Answer, not as step failures.
@@ -48,6 +48,12 @@ const verifyHelp = dedent `
48
48
  and gives no overall verdict - read the lines and decide. "none ran" means the claim
49
49
  could not be expressed, which is not the same as false.
50
50
  `;
51
+ const statusHelp = dedent `
52
+ Reads the files a command recorded, so it needs no browser and outlives the session.
53
+ The hash is looked up across every recorded site. ### Artifacts names every file kept
54
+ under it: the aria tree, the html, the screenshot and network log when they were
55
+ captured, and the per-step captures of a do run.
56
+ `;
51
57
  const reportHelp = dedent `
52
58
  Commands are logged as they run, so the report needs no browser and outlives the session.
53
59
  The most recent session is reported unless --pw-session names another.
@@ -114,7 +120,7 @@ function primaFor(options) {
114
120
  process.env.EXPLORBOT_VISION_MODEL = options.visionModel;
115
121
  return new Prima(buildOptions(options));
116
122
  }
117
- async function runPrima(options, command, run, record = true) {
123
+ async function runPrima(options, command, run) {
118
124
  setQuietMode(!isVerboseMode());
119
125
  trackActivityLine();
120
126
  const prima = primaFor(options);
@@ -127,8 +133,7 @@ async function runPrima(options, command, run, record = true) {
127
133
  catch (error) {
128
134
  envelope = await prima.toolFailureEnvelope(command, error);
129
135
  }
130
- if (record)
131
- prima.record(envelope, Date.now() - startedAt);
136
+ prima.record(envelope, Date.now() - startedAt);
132
137
  clearActivityLine();
133
138
  console.log(renderEnvelope(envelope));
134
139
  await prima.stop().catch(() => { });
@@ -191,8 +196,14 @@ export function createPrimaCommands(name = 'prima') {
191
196
  await prima.stop().catch(() => { });
192
197
  process.exit(0);
193
198
  });
194
- addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command')).action(async (hash, options) => {
195
- await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), false);
199
+ addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
200
+ .addHelpText('after', `\n${statusHelp}`)
201
+ .action(async (hash, options) => {
202
+ setQuietMode(!isVerboseMode());
203
+ const prima = primaFor(options);
204
+ const envelope = await prima.status(hash).catch((error) => prima.toolFailureEnvelope(`status ${hash}`, error));
205
+ console.log(renderEnvelope(envelope));
206
+ process.exit(envelope.ok ? 0 : 1);
196
207
  });
197
208
  addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report'))
198
209
  .addHelpText('after', `\n${reportHelp}`)