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.
- package/bin/explorbot-cli.ts +17 -4
- package/boat/doc-collector/src/cli.ts +9 -3
- package/boat/doc-collector/src/docbot.ts +11 -2
- package/dist/bin/explorbot-cli.js +19 -5
- package/dist/boat/doc-collector/src/cli.js +9 -3
- package/dist/boat/doc-collector/src/docbot.js +13 -2
- package/dist/package.json +1 -1
- package/dist/src/ai/navigator.d.ts +2 -0
- package/dist/src/ai/navigator.js +10 -0
- package/dist/src/ai/pilot.js +9 -2
- package/dist/src/commands/explore-command.d.ts +5 -0
- package/dist/src/commands/explore-command.js +60 -23
- package/dist/src/commands/freesail-command.js +9 -1
- package/dist/src/commands/init-command.d.ts +1 -0
- package/dist/src/commands/init-command.js +87 -36
- package/dist/src/components/InitWizard.d.ts +5 -1
- package/dist/src/components/InitWizard.js +29 -11
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +29 -7
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +5 -6
- package/dist/src/utils/sitemap.d.ts +11 -0
- package/dist/src/utils/sitemap.js +106 -0
- package/dist/src/utils/spec.d.ts +5 -0
- package/dist/src/utils/spec.js +16 -0
- package/dist/src/utils/url-filter.d.ts +10 -0
- package/dist/src/utils/url-filter.js +52 -0
- package/docs/basics/getting-started.md +23 -10
- package/docs/basics/providers.md +2 -0
- package/docs/reference/commands.md +5 -2
- package/docs/reference/configuration.md +1 -0
- package/package.json +1 -1
- package/src/ai/navigator.ts +13 -1
- package/src/ai/pilot.ts +9 -2
- package/src/commands/explore-command.ts +56 -23
- package/src/commands/freesail-command.ts +10 -1
- package/src/commands/init-command.ts +105 -38
- package/src/components/InitWizard.tsx +43 -11
- package/src/config.ts +32 -7
- package/src/test-plan.ts +6 -6
- package/src/utils/sitemap.ts +108 -0
- package/src/utils/spec.ts +21 -0
- package/src/utils/url-filter.ts +53 -0
package/bin/explorbot-cli.ts
CHANGED
|
@@ -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
|
|
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
|
|
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}
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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')
|
|
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}
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
@@ -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;
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -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;
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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.
|
|
270
|
-
|
|
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
|
|
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() {
|