explorbot 0.5.6 → 0.5.8

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 (43) hide show
  1. package/bin/explorbot-cli.ts +17 -4
  2. package/boat/doc-collector/src/cli.ts +9 -3
  3. package/boat/doc-collector/src/docbot.ts +11 -2
  4. package/dist/bin/explorbot-cli.js +19 -5
  5. package/dist/boat/doc-collector/src/cli.js +9 -3
  6. package/dist/boat/doc-collector/src/docbot.js +13 -2
  7. package/dist/package.json +1 -1
  8. package/dist/src/ai/navigator.d.ts +2 -0
  9. package/dist/src/ai/navigator.js +10 -0
  10. package/dist/src/ai/pilot.js +9 -2
  11. package/dist/src/commands/explore-command.d.ts +5 -0
  12. package/dist/src/commands/explore-command.js +60 -23
  13. package/dist/src/commands/freesail-command.js +9 -1
  14. package/dist/src/commands/init-command.d.ts +1 -0
  15. package/dist/src/commands/init-command.js +87 -36
  16. package/dist/src/components/InitWizard.d.ts +5 -1
  17. package/dist/src/components/InitWizard.js +29 -11
  18. package/dist/src/config.d.ts +2 -0
  19. package/dist/src/config.js +29 -7
  20. package/dist/src/test-plan.d.ts +1 -0
  21. package/dist/src/test-plan.js +5 -6
  22. package/dist/src/utils/sitemap.d.ts +11 -0
  23. package/dist/src/utils/sitemap.js +106 -0
  24. package/dist/src/utils/spec.d.ts +5 -0
  25. package/dist/src/utils/spec.js +16 -0
  26. package/dist/src/utils/url-filter.d.ts +10 -0
  27. package/dist/src/utils/url-filter.js +52 -0
  28. package/docs/basics/getting-started.md +23 -10
  29. package/docs/basics/providers.md +2 -0
  30. package/docs/reference/commands.md +5 -2
  31. package/docs/reference/configuration.md +1 -0
  32. package/package.json +1 -1
  33. package/src/ai/navigator.ts +13 -1
  34. package/src/ai/pilot.ts +9 -2
  35. package/src/commands/explore-command.ts +56 -23
  36. package/src/commands/freesail-command.ts +10 -1
  37. package/src/commands/init-command.ts +105 -38
  38. package/src/components/InitWizard.tsx +43 -11
  39. package/src/config.ts +32 -7
  40. package/src/test-plan.ts +6 -6
  41. package/src/utils/sitemap.ts +108 -0
  42. package/src/utils/spec.ts +21 -0
  43. package/src/utils/url-filter.ts +53 -0
@@ -23,6 +23,8 @@ import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode, tag } from '.
23
23
  import { jsonToTable } from '../src/utils/markdown-parser.js';
24
24
  import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js';
25
25
  import { type NextStepSection, printNextSteps, relativeToCwd } from '../src/utils/next-steps.ts';
26
+ import { readCliSitemap } from '../src/utils/sitemap.ts';
27
+ import { UrlFilter } from '../src/utils/url-filter.ts';
26
28
 
27
29
  const program = new Command();
28
30
  const cli = getCliName();
@@ -153,8 +155,10 @@ addCommonOptions(program.command('start [path]').description('Start web explorat
153
155
 
154
156
  addCommonOptions(
155
157
  program
156
- .command('explore <path>')
158
+ .command('explore [path]')
157
159
  .description('Explore a page autonomously and run invented scenarios')
160
+ .option('--sitemap <source>', 'Explore every page listed in a sitemap: file path, URL, or - for stdin (a piped stdin is read automatically)')
161
+ .option('--url-filter <spec>', 'Pick pages to explore: keys filter|exclude|limit, plus sort:priority|lastmod for a sitemap, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"')
158
162
  .option('--max-tests <count>', 'Maximum number of tests to run')
159
163
  .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run; wraps up before the limit is hit')
160
164
  .option('--focus <feature>', 'Focus area for exploration')
@@ -162,11 +166,15 @@ addCommonOptions(
162
166
  .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')
163
167
  ).action(async (explorePath, options) => {
164
168
  try {
165
- const explorBot = new ExplorBot(buildExplorBotOptions(explorePath, options));
169
+ const urlFilter = new UrlFilter(options.urlFilter);
170
+ const sitemapUrls = await readCliSitemap(explorePath, options.sitemap, urlFilter);
171
+ const explorBot = new ExplorBot(buildExplorBotOptions(explorePath || sitemapUrls?.[0], options));
166
172
  await explorBot.start();
167
- await explorBot.visit(explorePath);
173
+ if (explorePath) await explorBot.visit(explorePath);
168
174
  const { ExploreCommand } = await import('../src/commands/explore-command.js');
169
175
  const cmd = new ExploreCommand(explorBot);
176
+ cmd.sitemapUrls = sitemapUrls;
177
+ cmd.urlFilter = urlFilter;
170
178
  if (options.maxTests) cmd.maxTests = Number.parseInt(options.maxTests, 10);
171
179
  if (options.maxDuration) cmd.maxDurationMinutes = Number.parseInt(options.maxDuration, 10);
172
180
  else if (process.env.EXPLORBOT_MAX_DURATION) cmd.maxDurationMinutes = Number.parseInt(process.env.EXPLORBOT_MAX_DURATION, 10);
@@ -454,10 +462,13 @@ addCommonOptions(
454
462
  .option('--scope <prefix>', 'Restrict navigation to URL prefix')
455
463
  .option('--max-tests <count>', 'Maximum number of tests to run')
456
464
  .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run')
465
+ .option('--url-filter <spec>', 'Pick pages to explore: keys filter|exclude|limit, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"')
457
466
  ).action(async (startUrl, options) => {
458
467
  const explorBot = new ExplorBot(buildExplorBotOptions(startUrl || '/', options));
459
468
  await explorBot.start();
460
- 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(' ');
469
+ 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}`, options.urlFilter && `--url-filter "${options.urlFilter}"`]
470
+ .filter(Boolean)
471
+ .join(' ');
461
472
  const { FreesailCommand } = await import('../src/commands/freesail-command.js');
462
473
  const cmd = new FreesailCommand(explorBot);
463
474
  await cmd.execute(args);
@@ -491,6 +502,7 @@ program
491
502
  .option('-g, --global', 'Configure explorbot in ~/.explorbot to run from anywhere')
492
503
  .option('--provider <name>', `AI provider for the global config: ${Object.keys(PROVIDERS).join(', ')}`)
493
504
  .option('--api-key <key>', 'API key stored in ~/.explorbot/.env')
505
+ .option('--url <url>', 'Web application URL written to web.url in the local config')
494
506
  .action(async (options) => {
495
507
  try {
496
508
  const { runInit } = await import('../src/commands/init-command.js');
@@ -501,6 +513,7 @@ program
501
513
  global: options.global,
502
514
  provider: options.provider,
503
515
  apiKey: options.apiKey,
516
+ url: options.url,
504
517
  });
505
518
  } catch (error) {
506
519
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
@@ -5,6 +5,8 @@ import { ConfigCommand } from '../../../src/commands/config-command.ts';
5
5
  import { RecommendedModelsCommand } from '../../../src/commands/recommended-models-command.ts';
6
6
  import { remote } from '../../../src/remote.ts';
7
7
  import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from '../../../src/utils/logger.ts';
8
+ import { readCliSitemap } from '../../../src/utils/sitemap.ts';
9
+ import { UrlFilter } from '../../../src/utils/url-filter.ts';
8
10
  import { DocBot, type DocbotOptions } from './docbot.ts';
9
11
 
10
12
  function buildOptions(options: any): DocbotOptions {
@@ -41,15 +43,19 @@ export function createDocsCommands(name = 'docs'): Command {
41
43
 
42
44
  addCommonOptions(
43
45
  cmd
44
- .command('collect <path>')
46
+ .command('collect [path]')
45
47
  .description('Crawl pages and generate documentation spec')
48
+ .option('--sitemap <source>', 'Document only the pages listed in a sitemap: file path, URL, or - for stdin (a piped stdin is read automatically)')
49
+ .option('--url-filter <spec>', 'Pick sitemap pages: keys filter|exclude|sort|limit, e.g. "filter:/admin/*;exclude:/admin/logs/*;sort:priority;limit:20"')
46
50
  .option('--max-pages <count>', 'Maximum number of pages to document')
47
51
  .option('--no-collapse-template-pages', 'Visit every page even when its layout matches a documented page')
48
52
  .option('--template-similarity <percent>', 'Structural similarity percent that counts pages as the same layout (default 90)')
49
- ).action(async (startPath, options) => {
53
+ ).action(async (pathArg, options) => {
50
54
  setPreserveConsoleLogs(true);
51
55
 
52
56
  try {
57
+ const sitemapUrls = await readCliSitemap(pathArg, options.sitemap, new UrlFilter(options.urlFilter));
58
+ const startPath = pathArg || sitemapUrls![0];
53
59
  const bot = new DocBot({
54
60
  ...buildOptions(options),
55
61
  startUrl: startPath,
@@ -65,7 +71,7 @@ export function createDocsCommands(name = 'docs'): Command {
65
71
  templateSimilarity = Number.parseInt(options.templateSimilarity, 10);
66
72
  }
67
73
 
68
- const result = await bot.collect(startPath, { maxPages, collapseTemplatePages: options.collapseTemplatePages, templateSimilarity });
74
+ const result = await bot.collect(startPath, { maxPages, collapseTemplatePages: options.collapseTemplatePages, templateSimilarity, sitemapUrls });
69
75
 
70
76
  console.log(`\nDocumented ${result.pages.length} page(s)`);
71
77
  console.log(`Skipped ${result.skipped.length} page(s)`);
@@ -5,6 +5,7 @@ import type { Link, WebPageState } from '../../../src/state-manager.ts';
5
5
  import { normalizeUrl } from '../../../src/state-manager.ts';
6
6
  import { tag } from '../../../src/utils/logger.ts';
7
7
  import { sanitizeFilename } from '../../../src/utils/strings.ts';
8
+ import { keepSiteUrls } from '../../../src/utils/sitemap.ts';
8
9
  import { Documentarian, type PageDocumentation } from './ai/documentarian.ts';
9
10
  import type { DocStateTransition } from './ai/tools.ts';
10
11
  import { type DocbotConfig, DocbotConfigParser } from './config.ts';
@@ -66,7 +67,12 @@ class DocBot {
66
67
  const skipped: SkippedPage[] = [];
67
68
  const baseUrl = this.explorBot.getConfig().playwright.url;
68
69
 
69
- this.enqueuePath(effectiveStartPath, queue, queued);
70
+ const followLinks = !opts.sitemapUrls;
71
+ let seeds = [effectiveStartPath];
72
+ if (opts.sitemapUrls) seeds = keepSiteUrls(opts.sitemapUrls, baseUrl).filter((url) => shouldCrawlDocPath(url, this.config));
73
+ for (const seed of seeds) {
74
+ this.enqueuePath(seed, queue, queued);
75
+ }
70
76
 
71
77
  while (queue.length > 0 && pages.length < effectiveMaxPages) {
72
78
  const target = queue.shift();
@@ -80,7 +86,7 @@ class DocBot {
80
86
  }
81
87
 
82
88
  const stateManager = this.explorBot.stateManager();
83
- if (stateManager.hasVisitedState(target)) {
89
+ if (followLinks && stateManager.hasVisitedState(target)) {
84
90
  continue;
85
91
  }
86
92
 
@@ -110,6 +116,7 @@ class DocBot {
110
116
  reason: `same layout as ${templateUrl} (only content differs)`,
111
117
  });
112
118
  documented.add(pageKey);
119
+ if (!followLinks) continue;
113
120
  for (const nextPath of this.extractNextPaths(state, baseUrl, '')) {
114
121
  if (documented.has(this.getPageKey(nextPath)) || stateManager.hasVisitedState(nextPath)) continue;
115
122
  this.enqueuePath(nextPath, queue, queued);
@@ -166,6 +173,7 @@ class DocBot {
166
173
 
167
174
  const templateRecord = buildTemplateRecord(state.url, state.ariaSnapshot ?? null);
168
175
  if (templateRecord) templates.push(templateRecord);
176
+ if (!followLinks) continue;
169
177
 
170
178
  const nextPaths = this.extractNextPaths(state, baseUrl, research, documentation);
171
179
  const interactionPriorityPaths = new Set(this.extractInteractionPaths(baseUrl, documentation));
@@ -559,6 +567,7 @@ interface CollectOptions {
559
567
  maxPages?: number;
560
568
  collapseTemplatePages?: boolean;
561
569
  templateSimilarity?: number;
570
+ sitemapUrls?: string[];
562
571
  }
563
572
 
564
573
  interface CollectionResult {
@@ -23,6 +23,8 @@ import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode, tag } from '.
23
23
  import { jsonToTable } from '../src/utils/markdown-parser.js';
24
24
  import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js';
25
25
  import { printNextSteps, relativeToCwd } from "../src/utils/next-steps.js";
26
+ import { readCliSitemap } from "../src/utils/sitemap.js";
27
+ import { UrlFilter } from "../src/utils/url-filter.js";
26
28
  const program = new Command();
27
29
  const cli = getCliName();
28
30
  const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../package.json');
@@ -119,19 +121,26 @@ addCommonOptions(program.command('start [path]').description('Start web explorat
119
121
  await startTUI(explorBot);
120
122
  });
121
123
  addCommonOptions(program
122
- .command('explore <path>')
124
+ .command('explore [path]')
123
125
  .description('Explore a page autonomously and run invented scenarios')
126
+ .option('--sitemap <source>', 'Explore every page listed in a sitemap: file path, URL, or - for stdin (a piped stdin is read automatically)')
127
+ .option('--url-filter <spec>', 'Pick pages to explore: keys filter|exclude|limit, plus sort:priority|lastmod for a sitemap, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"')
124
128
  .option('--max-tests <count>', 'Maximum number of tests to run')
125
129
  .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run; wraps up before the limit is hit')
126
130
  .option('--focus <feature>', 'Focus area for exploration')
127
131
  .option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
128
132
  .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')).action(async (explorePath, options) => {
129
133
  try {
130
- const explorBot = new ExplorBot(buildExplorBotOptions(explorePath, options));
134
+ const urlFilter = new UrlFilter(options.urlFilter);
135
+ const sitemapUrls = await readCliSitemap(explorePath, options.sitemap, urlFilter);
136
+ const explorBot = new ExplorBot(buildExplorBotOptions(explorePath || sitemapUrls?.[0], options));
131
137
  await explorBot.start();
132
- await explorBot.visit(explorePath);
138
+ if (explorePath)
139
+ await explorBot.visit(explorePath);
133
140
  const { ExploreCommand } = await import('../src/commands/explore-command.js');
134
141
  const cmd = new ExploreCommand(explorBot);
142
+ cmd.sitemapUrls = sitemapUrls;
143
+ cmd.urlFilter = urlFilter;
135
144
  if (options.maxTests)
136
145
  cmd.maxTests = Number.parseInt(options.maxTests, 10);
137
146
  if (options.maxDuration)
@@ -405,10 +414,13 @@ addCommonOptions(program
405
414
  .option('--shallow', 'Breadth-first: pick globally least-visited page')
406
415
  .option('--scope <prefix>', 'Restrict navigation to URL prefix')
407
416
  .option('--max-tests <count>', 'Maximum number of tests to run')
408
- .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run')).action(async (startUrl, options) => {
417
+ .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run')
418
+ .option('--url-filter <spec>', 'Pick pages to explore: keys filter|exclude|limit, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"')).action(async (startUrl, options) => {
409
419
  const explorBot = new ExplorBot(buildExplorBotOptions(startUrl || '/', options));
410
420
  await explorBot.start();
411
- 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(' ');
421
+ 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}`, options.urlFilter && `--url-filter "${options.urlFilter}"`]
422
+ .filter(Boolean)
423
+ .join(' ');
412
424
  const { FreesailCommand } = await import('../src/commands/freesail-command.js');
413
425
  const cmd = new FreesailCommand(explorBot);
414
426
  await cmd.execute(args);
@@ -440,6 +452,7 @@ program
440
452
  .option('-g, --global', 'Configure explorbot in ~/.explorbot to run from anywhere')
441
453
  .option('--provider <name>', `AI provider for the global config: ${Object.keys(PROVIDERS).join(', ')}`)
442
454
  .option('--api-key <key>', 'API key stored in ~/.explorbot/.env')
455
+ .option('--url <url>', 'Web application URL written to web.url in the local config')
443
456
  .action(async (options) => {
444
457
  try {
445
458
  const { runInit } = await import('../src/commands/init-command.js');
@@ -450,6 +463,7 @@ program
450
463
  global: options.global,
451
464
  provider: options.provider,
452
465
  apiKey: options.apiKey,
466
+ url: options.url,
453
467
  });
454
468
  }
455
469
  catch (error) {
@@ -5,6 +5,8 @@ import { ConfigCommand } from "../../../src/commands/config-command.js";
5
5
  import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
6
6
  import { remote } from "../../../src/remote.js";
7
7
  import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from "../../../src/utils/logger.js";
8
+ import { readCliSitemap } from "../../../src/utils/sitemap.js";
9
+ import { UrlFilter } from "../../../src/utils/url-filter.js";
8
10
  import { DocBot } from "./docbot.js";
9
11
  function buildOptions(options) {
10
12
  return {
@@ -36,13 +38,17 @@ export function createDocsCommands(name = 'docs') {
36
38
  const cmd = new Command(name);
37
39
  cmd.description('AI-powered website documentation collector');
38
40
  addCommonOptions(cmd
39
- .command('collect <path>')
41
+ .command('collect [path]')
40
42
  .description('Crawl pages and generate documentation spec')
43
+ .option('--sitemap <source>', 'Document only the pages listed in a sitemap: file path, URL, or - for stdin (a piped stdin is read automatically)')
44
+ .option('--url-filter <spec>', 'Pick sitemap pages: keys filter|exclude|sort|limit, e.g. "filter:/admin/*;exclude:/admin/logs/*;sort:priority;limit:20"')
41
45
  .option('--max-pages <count>', 'Maximum number of pages to document')
42
46
  .option('--no-collapse-template-pages', 'Visit every page even when its layout matches a documented page')
43
- .option('--template-similarity <percent>', 'Structural similarity percent that counts pages as the same layout (default 90)')).action(async (startPath, options) => {
47
+ .option('--template-similarity <percent>', 'Structural similarity percent that counts pages as the same layout (default 90)')).action(async (pathArg, options) => {
44
48
  setPreserveConsoleLogs(true);
45
49
  try {
50
+ const sitemapUrls = await readCliSitemap(pathArg, options.sitemap, new UrlFilter(options.urlFilter));
51
+ const startPath = pathArg || sitemapUrls[0];
46
52
  const bot = new DocBot({
47
53
  ...buildOptions(options),
48
54
  startUrl: startPath,
@@ -56,7 +62,7 @@ export function createDocsCommands(name = 'docs') {
56
62
  if (options.templateSimilarity) {
57
63
  templateSimilarity = Number.parseInt(options.templateSimilarity, 10);
58
64
  }
59
- const result = await bot.collect(startPath, { maxPages, collapseTemplatePages: options.collapseTemplatePages, templateSimilarity });
65
+ const result = await bot.collect(startPath, { maxPages, collapseTemplatePages: options.collapseTemplatePages, templateSimilarity, sitemapUrls });
60
66
  console.log(`\nDocumented ${result.pages.length} page(s)`);
61
67
  console.log(`Skipped ${result.skipped.length} page(s)`);
62
68
  console.log(`Spec index: ${result.indexPath}`);
@@ -4,6 +4,7 @@ import { ExplorBot } from "../../../src/explorbot.js";
4
4
  import { normalizeUrl } from "../../../src/state-manager.js";
5
5
  import { tag } from "../../../src/utils/logger.js";
6
6
  import { sanitizeFilename } from "../../../src/utils/strings.js";
7
+ import { keepSiteUrls } from "../../../src/utils/sitemap.js";
7
8
  import { Documentarian } from "./ai/documentarian.js";
8
9
  import { DocbotConfigParser } from "./config.js";
9
10
  import { renderPageDocumentation, renderSpecIndex } from "./docs-renderer.js";
@@ -58,7 +59,13 @@ class DocBot {
58
59
  const pages = [];
59
60
  const skipped = [];
60
61
  const baseUrl = this.explorBot.getConfig().playwright.url;
61
- this.enqueuePath(effectiveStartPath, queue, queued);
62
+ const followLinks = !opts.sitemapUrls;
63
+ let seeds = [effectiveStartPath];
64
+ if (opts.sitemapUrls)
65
+ seeds = keepSiteUrls(opts.sitemapUrls, baseUrl).filter((url) => shouldCrawlDocPath(url, this.config));
66
+ for (const seed of seeds) {
67
+ this.enqueuePath(seed, queue, queued);
68
+ }
62
69
  while (queue.length > 0 && pages.length < effectiveMaxPages) {
63
70
  const target = queue.shift();
64
71
  if (!target) {
@@ -69,7 +76,7 @@ class DocBot {
69
76
  continue;
70
77
  }
71
78
  const stateManager = this.explorBot.stateManager();
72
- if (stateManager.hasVisitedState(target)) {
79
+ if (followLinks && stateManager.hasVisitedState(target)) {
73
80
  continue;
74
81
  }
75
82
  try {
@@ -95,6 +102,8 @@ class DocBot {
95
102
  reason: `same layout as ${templateUrl} (only content differs)`,
96
103
  });
97
104
  documented.add(pageKey);
105
+ if (!followLinks)
106
+ continue;
98
107
  for (const nextPath of this.extractNextPaths(state, baseUrl, '')) {
99
108
  if (documented.has(this.getPageKey(nextPath)) || stateManager.hasVisitedState(nextPath))
100
109
  continue;
@@ -149,6 +158,8 @@ class DocBot {
149
158
  const templateRecord = buildTemplateRecord(state.url, state.ariaSnapshot ?? null);
150
159
  if (templateRecord)
151
160
  templates.push(templateRecord);
161
+ if (!followLinks)
162
+ continue;
152
163
  const nextPaths = this.extractNextPaths(state, baseUrl, research, documentation);
153
164
  const interactionPriorityPaths = new Set(this.extractInteractionPaths(baseUrl, documentation));
154
165
  for (const nextPath of nextPaths) {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -7,6 +7,7 @@ import Explorer from '../explorer.js';
7
7
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
8
8
  import { type StateManager } from '../state-manager.js';
9
9
  import { HooksRunner } from '../utils/hooks-runner.js';
10
+ import type { UrlFilter } from '../utils/url-filter.js';
10
11
  import type { Agent, AgentDeps } from './agent.js';
11
12
  import { type Decision, type Judge } from './judge.js';
12
13
  import type { Provider } from './provider.js';
@@ -72,6 +73,7 @@ declare class Navigator implements Agent {
72
73
  strategy?: 'deep' | 'shallow';
73
74
  scope?: string;
74
75
  visitedUrls?: Set<string>;
76
+ urlFilter?: UrlFilter;
75
77
  }, actionResult?: ActionResult): Promise<{
76
78
  target: string;
77
79
  reason: string;
@@ -555,6 +555,12 @@ class Navigator {
555
555
  if (opts?.scope) {
556
556
  scopeInstruction = `IMPORTANT: Only suggest URLs that start with "${opts.scope}". Do not suggest URLs outside this scope.`;
557
557
  }
558
+ if (opts?.urlFilter?.include.length) {
559
+ scopeInstruction += `\nOnly suggest URLs matching one of these patterns: ${opts.urlFilter.include.join(', ')}`;
560
+ }
561
+ if (opts?.urlFilter?.exclude.length) {
562
+ scopeInstruction += `\nNever suggest URLs matching these patterns: ${opts.urlFilter.exclude.join(', ')}`;
563
+ }
558
564
  const prompt = dedent `
559
565
  <research>
560
566
  ${research || 'No cached research available'}
@@ -599,6 +605,10 @@ class Navigator {
599
605
  conversation.addUserText(`"${target}" is outside scope "${opts.scope}". Suggest a URL within scope.`);
600
606
  return;
601
607
  }
608
+ if (opts?.urlFilter && !opts.urlFilter.allows(target)) {
609
+ conversation.addUserText(`"${target}" does not pass the URL filter. Suggest a URL that does.`);
610
+ return;
611
+ }
602
612
  if (!target.startsWith('/') && !target.startsWith('http')) {
603
613
  conversation.addUserText(`"${target}" is not a valid URL path. Suggest a URL path starting with "/".`);
604
614
  return;
@@ -537,7 +537,7 @@ export class Pilot {
537
537
  if (finalState?.screenshot && this.provider.hasVision())
538
538
  image = finalState.screenshot;
539
539
  const decided = (text) => {
540
- if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text))
540
+ if (task.hasAchieved(text))
541
541
  return 'passed';
542
542
  return 'failed';
543
543
  };
@@ -553,7 +553,7 @@ export class Pilot {
553
553
  const schema = z.object({
554
554
  outcomes: z.array(z.object({
555
555
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
556
- status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
556
+ status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened to the thing it names, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
557
557
  evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
558
558
  })),
559
559
  });
@@ -576,6 +576,10 @@ export class Pilot {
576
576
  const userContent = dedent `
577
577
  A test run has finished. Decide, for each expected outcome, what the run established about it.
578
578
 
579
+ <scenario>
580
+ ${task.scenario}
581
+ </scenario>
582
+
579
583
  <expected_outcomes>
580
584
  ${undecided.map((text) => `- ${text}`).join('\n')}
581
585
  </expected_outcomes>
@@ -588,6 +592,9 @@ export class Pilot {
588
592
 
589
593
  The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
590
594
  differently. Judge by what the steps show happened, not by whether the wording matches.
595
+ Wording may differ, the subject may not. An outcome refers to what the scenario names or chooses; it holds
596
+ only when that same thing appears. A different route or a different thing reaching a similar state is "failed",
597
+ and so is an outcome about something the run never managed to choose or create.
591
598
  Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
592
599
  that is a statement about the run, not about the application.
593
600
  `;
@@ -1,4 +1,5 @@
1
1
  import { type Plan, type Test } from '../test-plan.js';
2
+ import { UrlFilter } from '../utils/url-filter.js';
2
3
  import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
3
4
  export declare const DEADLINE_RESERVE_MS: number;
4
5
  export declare const DEADLINE_TEST_ALLOWANCE_MS: number;
@@ -14,6 +15,8 @@ export declare class ExploreCommand extends BaseCommand {
14
15
  maxDurationMinutes?: number;
15
16
  hardDeadlineAt?: number;
16
17
  dryRun: boolean;
18
+ sitemapUrls?: string[];
19
+ urlFilter: UrlFilter;
17
20
  testsRun: number;
18
21
  deadlineLogged: boolean;
19
22
  completedPlans: Plan[];
@@ -26,8 +29,10 @@ export declare class ExploreCommand extends BaseCommand {
26
29
  originLabel(test: Test): string;
27
30
  printPreview(label: string, tests: Test[]): void;
28
31
  runFreshMode(mainUrl: string | undefined, feature: string | undefined, styles?: string[]): Promise<void>;
32
+ runSitemapMode(sitemapUrls: string[], feature: string | undefined, styles?: string[]): Promise<void>;
29
33
  runReuseMode(mainUrl: string | undefined, feature: string | undefined, cfg: ConfigureSpec): Promise<void>;
30
34
  discoverNewSubPages(mainPlan: Plan, mainUrl: string | undefined, styles: string[] | undefined, knownUrls: Set<string>): Promise<void>;
35
+ explorePage(url: string, feature: string | undefined, parentPlan: Plan | undefined, styles?: string[]): Promise<boolean>;
31
36
  replanAndRun(pageUrl: string | undefined, feature: string | undefined, existingPlan: Plan, styles?: string[]): Promise<void>;
32
37
  runAllStyles(pageUrl?: string, feature?: string, parentPlan?: Plan, completedPlans?: Plan[], styles?: string[]): Promise<void>;
33
38
  rememberCurrentPlan(): void;
@@ -9,7 +9,10 @@ import { getCliName } from "../utils/cli-name.js";
9
9
  import { ErrorPageError, getStateErrorPageError } from "../utils/error-page.js";
10
10
  import { tag } from '../utils/logger.js';
11
11
  import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
12
+ import { keepSiteUrls, readSitemap } from "../utils/sitemap.js";
13
+ import { parseSpecPairs } from "../utils/spec.js";
12
14
  import { safeFilename } from "../utils/strings.js";
15
+ import { UrlFilter } from "../utils/url-filter.js";
13
16
  import { BaseCommand } from './base-command.js';
14
17
  const MAX_SUB_PAGE_ATTEMPTS = 30;
15
18
  const PRIORITY_ORDER = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
@@ -24,6 +27,8 @@ export class ExploreCommand extends BaseCommand {
24
27
  { flags: '--focus <feature>', description: 'Focus area for exploration' },
25
28
  { flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
26
29
  { flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
30
+ { flags: '--sitemap <source>', description: 'Explore every page listed in a sitemap (file path, URL, or - for stdin) instead of discovering sub-pages' },
31
+ { flags: '--url-filter <spec>', description: 'Pick pages to explore: keys filter|exclude|limit, plus sort:priority|lastmod for a sitemap, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"' },
27
32
  ];
28
33
  suggestions = [
29
34
  { command: 'navigate <page>', hint: 'go to another page' },
@@ -34,6 +39,8 @@ export class ExploreCommand extends BaseCommand {
34
39
  maxDurationMinutes;
35
40
  hardDeadlineAt;
36
41
  dryRun = false;
42
+ sitemapUrls;
43
+ urlFilter = new UrlFilter();
37
44
  testsRun = 0;
38
45
  deadlineLogged = false;
39
46
  completedPlans = [];
@@ -52,6 +59,10 @@ export class ExploreCommand extends BaseCommand {
52
59
  if (opts.maxDuration) {
53
60
  this.maxDurationMinutes = Number.parseInt(opts.maxDuration, 10);
54
61
  }
62
+ if (opts.urlFilter)
63
+ this.urlFilter = new UrlFilter(opts.urlFilter);
64
+ if (opts.sitemap)
65
+ this.sitemapUrls = await readSitemap(opts.sitemap, this.urlFilter);
55
66
  if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
56
67
  this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
57
68
  }
@@ -72,12 +83,15 @@ export class ExploreCommand extends BaseCommand {
72
83
  Stats.focus ??= feature;
73
84
  const mainUrl = this.getCurrentPageUrl();
74
85
  const error = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
75
- if (error) {
86
+ if (error && !this.sitemapUrls) {
76
87
  tag('warning').log(error.message);
77
88
  return;
78
89
  }
79
90
  try {
80
- if (cfg.enabled) {
91
+ if (this.sitemapUrls) {
92
+ await this.runSitemapMode(this.sitemapUrls, feature, cfg.styles);
93
+ }
94
+ else if (cfg.enabled) {
81
95
  await this.runReuseMode(mainUrl, feature, cfg);
82
96
  }
83
97
  else {
@@ -130,6 +144,21 @@ export class ExploreCommand extends BaseCommand {
130
144
  return;
131
145
  await this.discoverNewSubPages(mainPlan, mainUrl, styles, new Set());
132
146
  }
147
+ async runSitemapMode(sitemapUrls, feature, styles) {
148
+ const urls = keepSiteUrls(sitemapUrls, this.explorBot.getConfig().playwright.url);
149
+ tag('info').log(`Sitemap: ${urls.length} page(s) to explore`);
150
+ for (const url of urls) {
151
+ if (this.isLimitReached())
152
+ break;
153
+ tag('info').log(`Exploring sitemap page: ${url}`);
154
+ try {
155
+ await this.explorePage(url, feature, undefined, styles);
156
+ }
157
+ catch (err) {
158
+ tag('warning').log(`Sitemap page exploration failed: ${err instanceof Error ? err.message : err}`);
159
+ }
160
+ }
161
+ }
133
162
  async runReuseMode(mainUrl, feature, cfg) {
134
163
  const filename = cfg.fromPath || this.explorBot.generatePlanFilename(feature);
135
164
  let loadedPlans = [];
@@ -226,6 +255,8 @@ export class ExploreCommand extends BaseCommand {
226
255
  break;
227
256
  if (!subPlan.url)
228
257
  continue;
258
+ if (!this.urlFilter.admit(subPlan.url))
259
+ continue;
229
260
  try {
230
261
  if (!this.dryRun)
231
262
  await this.explorBot.visit(subPlan.url);
@@ -257,27 +288,22 @@ export class ExploreCommand extends BaseCommand {
257
288
  break;
258
289
  const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/').filter((c) => {
259
290
  const norm = normalizeUrl(c.url);
260
- return !this.failedSubPages.has(norm) && !knownUrls.has(norm);
291
+ return !this.failedSubPages.has(norm) && !knownUrls.has(norm) && this.urlFilter.allows(c.url);
261
292
  });
262
293
  if (candidates.length === 0)
263
294
  break;
264
295
  const pick = await planner.pickNextSubPage(candidates);
265
296
  if (!pick)
266
297
  break;
298
+ if (!this.urlFilter.admit(pick.url))
299
+ break;
267
300
  tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
268
301
  try {
269
- await this.explorBot.visit(pick.url);
270
- const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
271
- if (errorPage) {
272
- tag('warning').log(`Skipping sub-page: ${errorPage.message}`);
302
+ const explored = await this.explorePage(pick.url, undefined, mainPlan, styles);
303
+ if (!explored) {
273
304
  this.failedSubPages.add(normalizeUrl(pick.url));
274
305
  continue;
275
306
  }
276
- await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
277
- const subPlan = this.explorBot.getCurrentPlan();
278
- if (subPlan?.tests.length && !this.completedPlans.includes(subPlan)) {
279
- this.completedPlans.push(subPlan);
280
- }
281
307
  knownUrls.add(normalizeUrl(pick.url));
282
308
  }
283
309
  catch (err) {
@@ -286,6 +312,20 @@ export class ExploreCommand extends BaseCommand {
286
312
  }
287
313
  }
288
314
  }
315
+ async explorePage(url, feature, parentPlan, styles) {
316
+ await this.explorBot.visit(url);
317
+ const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
318
+ if (errorPage) {
319
+ tag('warning').log(`Skipping page: ${errorPage.message}`);
320
+ return false;
321
+ }
322
+ await this.runAllStyles(url, feature, parentPlan, this.completedPlans, styles);
323
+ const plan = this.explorBot.getCurrentPlan();
324
+ if (plan?.tests.length && !this.completedPlans.includes(plan)) {
325
+ this.completedPlans.push(plan);
326
+ }
327
+ return true;
328
+ }
289
329
  async replanAndRun(pageUrl, feature, existingPlan, styles) {
290
330
  const styleList = styles ?? Object.keys(getStyles());
291
331
  for (const style of styleList) {
@@ -366,17 +406,7 @@ export class ExploreCommand extends BaseCommand {
366
406
  const allStyles = Object.keys(getStyles());
367
407
  const validSubpages = new Set(['none', 'same', 'new', 'both']);
368
408
  let hasReuseSignal = false;
369
- for (const pair of raw.split(';')) {
370
- const trimmed = pair.trim();
371
- if (!trimmed)
372
- continue;
373
- const sepMatch = trimmed.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
374
- if (!sepMatch) {
375
- tag('warning').log(`Ignoring malformed configure pair: ${trimmed}`);
376
- continue;
377
- }
378
- const key = sepMatch[1].trim().toLowerCase();
379
- const value = sepMatch[2].trim();
409
+ for (const { key, value } of parseSpecPairs(raw)) {
380
410
  if (key === 'new') {
381
411
  const ratio = parseRatio(value);
382
412
  if (ratio == null) {
@@ -546,6 +576,13 @@ export class ExploreCommand extends BaseCommand {
546
576
  commands,
547
577
  });
548
578
  }
579
+ sections.push({
580
+ label: 'Explore more',
581
+ commands: [
582
+ { label: 'Whole site', command: `${cli} explore /` },
583
+ { label: 'Docs', command: 'https://testomat.ai/docs/explorbot' },
584
+ ],
585
+ });
549
586
  printNextSteps(sections);
550
587
  }
551
588
  isLimitReached() {