explorbot 0.5.6 → 0.5.7
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 +15 -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 +17 -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 +53 -23
- package/dist/src/commands/freesail-command.js +9 -1
- 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/package.json +1 -1
- package/src/ai/navigator.ts +13 -1
- package/src/ai/pilot.ts +9 -2
- package/src/commands/explore-command.ts +48 -23
- package/src/commands/freesail-command.ts +10 -1
- 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);
|
|
@@ -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);
|
|
@@ -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) {
|
|
@@ -4,6 +4,7 @@ import { normalizeUrl } from '../state-manager.js';
|
|
|
4
4
|
import { Stats } from '../stats.js';
|
|
5
5
|
import { tag } from '../utils/logger.js';
|
|
6
6
|
import { loop } from '../utils/loop.js';
|
|
7
|
+
import { UrlFilter } from "../utils/url-filter.js";
|
|
7
8
|
import { BaseCommand } from './base-command.js';
|
|
8
9
|
import { DEADLINE_RESERVE_MS, DEADLINE_TEST_ALLOWANCE_MS, ExploreCommand } from './explore-command.js';
|
|
9
10
|
export class FreesailCommand extends BaseCommand {
|
|
@@ -17,6 +18,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
17
18
|
{ flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
|
|
18
19
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
19
20
|
{ flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
|
|
21
|
+
{ flags: '--url-filter <spec>', description: 'Pick pages to explore: keys filter|exclude|limit, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"' },
|
|
20
22
|
];
|
|
21
23
|
async execute(args) {
|
|
22
24
|
Stats.mode = 'freesail';
|
|
@@ -27,6 +29,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
27
29
|
if (opts.shallow)
|
|
28
30
|
strategy = 'shallow';
|
|
29
31
|
const scope = opts.scope;
|
|
32
|
+
const urlFilter = new UrlFilter(opts.urlFilter);
|
|
30
33
|
const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests, 10) : undefined;
|
|
31
34
|
const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration, 10) : undefined;
|
|
32
35
|
let hardDeadlineAt;
|
|
@@ -54,6 +57,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
54
57
|
exploreCmd.maxTests = maxTests - testsRun;
|
|
55
58
|
if (hardDeadlineAt != null)
|
|
56
59
|
exploreCmd.hardDeadlineAt = hardDeadlineAt;
|
|
60
|
+
exploreCmd.urlFilter = urlFilter;
|
|
57
61
|
await exploreCmd.execute('');
|
|
58
62
|
const plan = this.explorBot.getCurrentPlan();
|
|
59
63
|
if (plan)
|
|
@@ -63,7 +67,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
63
67
|
ctx.stop();
|
|
64
68
|
const navigator = this.explorBot.agentNavigator();
|
|
65
69
|
const visitedUrls = stateManager.getAllVisitedUrls();
|
|
66
|
-
const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls });
|
|
70
|
+
const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls, urlFilter });
|
|
67
71
|
if (!suggestion) {
|
|
68
72
|
tag('info').log('No navigation suggestion available');
|
|
69
73
|
ctx.stop();
|
|
@@ -72,6 +76,10 @@ export class FreesailCommand extends BaseCommand {
|
|
|
72
76
|
tag('warning').log(`Suggestion ${suggestion.target} is outside scope ${scope}, skipping`);
|
|
73
77
|
ctx.stop();
|
|
74
78
|
}
|
|
79
|
+
if (!urlFilter.admit(suggestion.target)) {
|
|
80
|
+
tag('info').log(`URL filter allows no more pages (${suggestion.target} not admitted); stopping exploration`);
|
|
81
|
+
ctx.stop();
|
|
82
|
+
}
|
|
75
83
|
const currentUrl = stateManager.getCurrentState()?.url;
|
|
76
84
|
if (currentUrl && normalizeUrl(suggestion.target) === normalizeUrl(currentUrl)) {
|
|
77
85
|
tag('info').log(`No new navigation target available after ${currentUrl}; stopping exploration`);
|
package/dist/src/test-plan.d.ts
CHANGED
|
@@ -121,6 +121,7 @@ export declare class Test extends Task {
|
|
|
121
121
|
get isSkipped(): boolean;
|
|
122
122
|
getCheckedNotes(): Note[];
|
|
123
123
|
getCheckedExpectations(): string[];
|
|
124
|
+
hasAchieved(expectation: string): boolean;
|
|
124
125
|
hasAchievedAny(): boolean;
|
|
125
126
|
getRunResult(): 'success' | 'partial' | 'failed';
|
|
126
127
|
hasAchievedAll(): boolean;
|