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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { tag } from './logger.js';
|
|
2
|
+
import { parseSpecPairs } from "./spec.js";
|
|
3
|
+
import { matchesUrl } from "./url-matcher.js";
|
|
4
|
+
export class UrlFilter {
|
|
5
|
+
include = [];
|
|
6
|
+
exclude = [];
|
|
7
|
+
sort;
|
|
8
|
+
limit = Number.POSITIVE_INFINITY;
|
|
9
|
+
admitted = new Set();
|
|
10
|
+
constructor(spec) {
|
|
11
|
+
for (const { key, value } of parseSpecPairs(spec)) {
|
|
12
|
+
if (key === 'filter') {
|
|
13
|
+
this.include.push(value);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (key === 'exclude') {
|
|
17
|
+
this.exclude.push(value);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (key === 'sort' && (value === 'priority' || value === 'lastmod')) {
|
|
21
|
+
this.sort = value;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (key === 'limit' && Number.parseInt(value, 10) > 0) {
|
|
25
|
+
this.limit = Number.parseInt(value, 10);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
tag('warning').log(`Ignoring URL filter pair ${key}:${value} (use filter|exclude:<url pattern>, sort:priority|lastmod, limit:<n>)`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
allows(url) {
|
|
32
|
+
const path = urlPath(url);
|
|
33
|
+
if (this.include.length > 0 && !this.include.some((pattern) => matchesUrl(pattern, path)))
|
|
34
|
+
return false;
|
|
35
|
+
return !this.exclude.some((pattern) => matchesUrl(pattern, path));
|
|
36
|
+
}
|
|
37
|
+
admit(url) {
|
|
38
|
+
if (!this.allows(url))
|
|
39
|
+
return false;
|
|
40
|
+
const path = urlPath(url);
|
|
41
|
+
if (this.admitted.has(path))
|
|
42
|
+
return true;
|
|
43
|
+
if (this.admitted.size >= this.limit)
|
|
44
|
+
return false;
|
|
45
|
+
this.admitted.add(path);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function urlPath(url) {
|
|
50
|
+
const parsed = new URL(url, 'http://localhost');
|
|
51
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
52
|
+
}
|
|
@@ -15,21 +15,28 @@ You need Node.js 24+ (or Bun), an AI provider key, and a modern terminal — iTe
|
|
|
15
15
|
|
|
16
16
|
## 2. Configure
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Run the setup wizard:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
npx explorbot init
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
It asks for your AI provider, its API key, and your app's URL, then writes everything for you: `explorbot.config.js`, an `.env` file with the key stored, and an `output/` folder. No manual file editing.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
The generated config keeps models simple — one line picks the recommended set for your provider:
|
|
27
27
|
|
|
28
|
-
```
|
|
29
|
-
|
|
28
|
+
```javascript
|
|
29
|
+
export default {
|
|
30
|
+
web: {
|
|
31
|
+
url: 'http://localhost:3000',
|
|
32
|
+
},
|
|
33
|
+
ai: {
|
|
34
|
+
recommendedModels: 'openrouter',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
30
37
|
```
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
`ai.recommendedModels` fills every model role from the provider's bundled recommendations. To pin a single role instead, write it as `'provider/model-id'` — it overrides the recommendation:
|
|
33
40
|
|
|
34
41
|
```javascript
|
|
35
42
|
export default {
|
|
@@ -37,10 +44,8 @@ export default {
|
|
|
37
44
|
url: 'http://localhost:3000',
|
|
38
45
|
},
|
|
39
46
|
ai: {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
agenticModel: 'openrouter/anthropic/claude-haiku-5.5',
|
|
43
|
-
decisionModel: { provider: 'openrouter', model: 'typesafe/jev-latest' },
|
|
47
|
+
recommendedModels: 'openrouter',
|
|
48
|
+
agenticModel: 'groq/openai/gpt-oss-120b',
|
|
44
49
|
},
|
|
45
50
|
};
|
|
46
51
|
```
|
|
@@ -84,6 +89,14 @@ Explorbot uses three models. Pick each one for speed and cost:
|
|
|
84
89
|
|
|
85
90
|
Captain and Pilot barely use tokens, so a smarter `agenticModel` improves results for almost no extra cost. OpenRouter is the simplest start — one key, many models. To use OpenAI, Anthropic, Groq, or others, see [Providers](./providers.md). For every config option, see [Configuration](../reference/configuration.md).
|
|
86
91
|
|
|
92
|
+
Want to see Explorbot work before pointing it at your app? Run it on the sample app:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx explorbot explore https://todomvc.com/examples/react/dist/
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
You can also run it from the [VS Code extension](https://testomat.ai/explorbot) — pick a site, start a bot, and watch it test without leaving the editor. Full documentation lives at [testomat.ai/docs/explorbot](https://testomat.ai/docs/explorbot).
|
|
99
|
+
|
|
87
100
|
## 3. Tell Explorbot how to log in
|
|
88
101
|
|
|
89
102
|
Most apps need a login. Give Explorbot the credentials once, and it signs in on its own:
|
package/docs/basics/providers.md
CHANGED
|
@@ -4,6 +4,8 @@ Explorbot connects to AI providers through the [Vercel AI SDK](https://sdk.verce
|
|
|
4
4
|
|
|
5
5
|
Every provider below is set up the classical way: install its package, import it, build the client. Explorbot bundles some of these packages — for those you can skip the install and name the model as `'provider/model-id'` instead. See [Getting Started](./getting-started.md#2-configure) for that list and the two styles side by side.
|
|
6
6
|
|
|
7
|
+
A third, shortest style names just the provider: `ai: { recommendedModels: 'openrouter' }` fills every unset role from that provider's bundled recommendations. An explicit role set alongside it wins, so `recommendedModels` with a pinned `agenticModel` mixes both styles.
|
|
8
|
+
|
|
7
9
|
> The `export default` config block inside each `<!-- START/END provider -->` marker is generated from [`models.json`](../../models.json). After editing that file, run `bunosh docs:sync`. Everything else — including the import blocks — is hand-written.
|
|
8
10
|
|
|
9
11
|
## Requirements
|
|
@@ -920,7 +920,9 @@ npx explorbot init --config-path ./explorbot.config.js
|
|
|
920
920
|
npx explorbot init --force
|
|
921
921
|
```
|
|
922
922
|
|
|
923
|
-
|
|
923
|
+
The local wizard picks the provider, takes the API key (optionally checked with one test AI call), and asks for your app's URL — then writes everything: the key goes into `.env`, the URL into `web.url`, and the models come from `ai.recommendedModels: '<provider>'`. Nothing is left to edit by hand.
|
|
924
|
+
|
|
925
|
+
Passing `--config-path` or `--path` means local, and so does running outside a terminal (agents, CI): the chooser is skipped and the project config is written with placeholder values instead. Init then prints the exact paths of the files that still need attention, a demo command to try Explorbot on a sample app (`npx explorbot explore https://todomvc.com/examples/react/dist/`), and links to the [VS Code extension](https://testomat.ai/explorbot) and the [docs](https://testomat.ai/docs/explorbot).
|
|
924
926
|
|
|
925
927
|
`--global` runs the global wizard instead — pick a provider, paste the API key, optionally check it with one test AI call. The wizard writes `~/.explorbot/config.js` with the recommended model ids of this Explorbot version and stores the key in `~/.explorbot/.env`.
|
|
926
928
|
|
|
@@ -939,7 +941,8 @@ The global config holds models and keys, never a site: every command names the s
|
|
|
939
941
|
| `-p, --path <path>` | Working directory for initialization |
|
|
940
942
|
| `-g, --global` | Configure `~/.explorbot` to run from anywhere |
|
|
941
943
|
| `--provider <name>` | AI provider for the global config, skips the wizard |
|
|
942
|
-
| `--api-key <key>` | API key stored in `~/.explorbot/.env` |
|
|
944
|
+
| `--api-key <key>` | API key stored in `~/.explorbot/.env` (local init: written to the project `.env`) |
|
|
945
|
+
| `--url <url>` | Web application URL written to `web.url` in the local config |
|
|
943
946
|
|
|
944
947
|
See [Configuration](configuration.md#running-from-anywhere-the-global-installation) for the directory layout and how a site is resolved.
|
|
945
948
|
|
|
@@ -536,6 +536,7 @@ export default {
|
|
|
536
536
|
// AI provider settings
|
|
537
537
|
ai: {
|
|
538
538
|
model: groq('openai/gpt-oss-20b'), // Default model instance (required)
|
|
539
|
+
recommendedModels: 'openrouter', // Fills every unset role from this provider's bundled recommendations; explicit roles win
|
|
539
540
|
visionModel: groq('meta-llama/llama-4-scout-17b-16e-instruct'), // Model for screenshot analysis; setting it enables vision features
|
|
540
541
|
decisionModel: { provider: 'openrouter', model: 'typesafe/jev-1.13' }, // Optional; see providers docs
|
|
541
542
|
config: {}, // Additional provider config
|
package/package.json
CHANGED
package/src/ai/navigator.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { loop, pause } from '../utils/loop.js';
|
|
|
20
20
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
21
21
|
import { normalizeInlineText } from '../utils/strings.ts';
|
|
22
22
|
import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
|
|
23
|
+
import type { UrlFilter } from '../utils/url-filter.ts';
|
|
23
24
|
import type { Agent, AgentDeps } from './agent.js';
|
|
24
25
|
import type { Conversation } from './conversation.js';
|
|
25
26
|
import { type Decision, JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
|
|
@@ -564,7 +565,7 @@ class Navigator implements Agent {
|
|
|
564
565
|
return { learnExperience: createLearnExperienceTool({ getExperienceTracker: () => this.experienceTracker, getState }) };
|
|
565
566
|
}
|
|
566
567
|
|
|
567
|
-
async freeSail(opts?: { strategy?: 'deep' | 'shallow'; scope?: string; visitedUrls?: Set<string
|
|
568
|
+
async freeSail(opts?: { strategy?: 'deep' | 'shallow'; scope?: string; visitedUrls?: Set<string>; urlFilter?: UrlFilter }, actionResult?: ActionResult): Promise<{ target: string; reason: string } | null> {
|
|
568
569
|
const stateManager = this.stateManager;
|
|
569
570
|
const state = stateManager.getCurrentState();
|
|
570
571
|
if (!state) {
|
|
@@ -612,6 +613,12 @@ class Navigator implements Agent {
|
|
|
612
613
|
if (opts?.scope) {
|
|
613
614
|
scopeInstruction = `IMPORTANT: Only suggest URLs that start with "${opts.scope}". Do not suggest URLs outside this scope.`;
|
|
614
615
|
}
|
|
616
|
+
if (opts?.urlFilter?.include.length) {
|
|
617
|
+
scopeInstruction += `\nOnly suggest URLs matching one of these patterns: ${opts.urlFilter.include.join(', ')}`;
|
|
618
|
+
}
|
|
619
|
+
if (opts?.urlFilter?.exclude.length) {
|
|
620
|
+
scopeInstruction += `\nNever suggest URLs matching these patterns: ${opts.urlFilter.exclude.join(', ')}`;
|
|
621
|
+
}
|
|
615
622
|
|
|
616
623
|
const prompt = dedent`
|
|
617
624
|
<research>
|
|
@@ -665,6 +672,11 @@ class Navigator implements Agent {
|
|
|
665
672
|
return;
|
|
666
673
|
}
|
|
667
674
|
|
|
675
|
+
if (opts?.urlFilter && !opts.urlFilter.allows(target)) {
|
|
676
|
+
conversation.addUserText(`"${target}" does not pass the URL filter. Suggest a URL that does.`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
668
680
|
if (!target.startsWith('/') && !target.startsWith('http')) {
|
|
669
681
|
conversation.addUserText(`"${target}" is not a valid URL path. Suggest a URL path starting with "/".`);
|
|
670
682
|
return;
|
package/src/ai/pilot.ts
CHANGED
|
@@ -615,7 +615,7 @@ export class Pilot implements Agent {
|
|
|
615
615
|
if (finalState?.screenshot && this.provider.hasVision()) image = finalState.screenshot;
|
|
616
616
|
|
|
617
617
|
const decided = (text: string): 'passed' | 'failed' => {
|
|
618
|
-
if (task.
|
|
618
|
+
if (task.hasAchieved(text)) return 'passed';
|
|
619
619
|
return 'failed';
|
|
620
620
|
};
|
|
621
621
|
|
|
@@ -631,7 +631,7 @@ export class Pilot implements Agent {
|
|
|
631
631
|
outcomes: z.array(
|
|
632
632
|
z.object({
|
|
633
633
|
expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
|
|
634
|
-
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'),
|
|
634
|
+
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'),
|
|
635
635
|
evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
|
|
636
636
|
})
|
|
637
637
|
),
|
|
@@ -657,6 +657,10 @@ export class Pilot implements Agent {
|
|
|
657
657
|
const userContent = dedent`
|
|
658
658
|
A test run has finished. Decide, for each expected outcome, what the run established about it.
|
|
659
659
|
|
|
660
|
+
<scenario>
|
|
661
|
+
${task.scenario}
|
|
662
|
+
</scenario>
|
|
663
|
+
|
|
660
664
|
<expected_outcomes>
|
|
661
665
|
${undecided.map((text) => `- ${text}`).join('\n')}
|
|
662
666
|
</expected_outcomes>
|
|
@@ -669,6 +673,9 @@ export class Pilot implements Agent {
|
|
|
669
673
|
|
|
670
674
|
The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
|
|
671
675
|
differently. Judge by what the steps show happened, not by whether the wording matches.
|
|
676
|
+
Wording may differ, the subject may not. An outcome refers to what the scenario names or chooses; it holds
|
|
677
|
+
only when that same thing appears. A different route or a different thing reaching a similar state is "failed",
|
|
678
|
+
and so is an outcome about something the run never managed to choose or create.
|
|
672
679
|
Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
|
|
673
680
|
that is a statement about the run, not about the application.
|
|
674
681
|
`;
|
|
@@ -9,7 +9,10 @@ import { getCliName } from '../utils/cli-name.ts';
|
|
|
9
9
|
import { ErrorPageError, getStateErrorPageError } from '../utils/error-page.ts';
|
|
10
10
|
import { tag } from '../utils/logger.js';
|
|
11
11
|
import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
|
|
12
|
+
import { keepSiteUrls, readSitemap } from '../utils/sitemap.ts';
|
|
13
|
+
import { parseSpecPairs } from '../utils/spec.ts';
|
|
12
14
|
import { safeFilename } from '../utils/strings.ts';
|
|
15
|
+
import { UrlFilter } from '../utils/url-filter.ts';
|
|
13
16
|
import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
|
|
14
17
|
|
|
15
18
|
const MAX_SUB_PAGE_ATTEMPTS = 30;
|
|
@@ -26,6 +29,8 @@ export class ExploreCommand extends BaseCommand {
|
|
|
26
29
|
{ flags: '--focus <feature>', description: 'Focus area for exploration' },
|
|
27
30
|
{ flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
|
|
28
31
|
{ flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
|
|
32
|
+
{ flags: '--sitemap <source>', description: 'Explore every page listed in a sitemap (file path, URL, or - for stdin) instead of discovering sub-pages' },
|
|
33
|
+
{ 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"' },
|
|
29
34
|
];
|
|
30
35
|
suggestions: Suggestion[] = [
|
|
31
36
|
{ command: 'navigate <page>', hint: 'go to another page' },
|
|
@@ -37,6 +42,8 @@ export class ExploreCommand extends BaseCommand {
|
|
|
37
42
|
maxDurationMinutes?: number;
|
|
38
43
|
hardDeadlineAt?: number;
|
|
39
44
|
dryRun = false;
|
|
45
|
+
sitemapUrls?: string[];
|
|
46
|
+
urlFilter = new UrlFilter();
|
|
40
47
|
private testsRun = 0;
|
|
41
48
|
private deadlineLogged = false;
|
|
42
49
|
private completedPlans: Plan[] = [];
|
|
@@ -57,6 +64,8 @@ export class ExploreCommand extends BaseCommand {
|
|
|
57
64
|
if (opts.maxDuration) {
|
|
58
65
|
this.maxDurationMinutes = Number.parseInt(opts.maxDuration as string, 10);
|
|
59
66
|
}
|
|
67
|
+
if (opts.urlFilter) this.urlFilter = new UrlFilter(opts.urlFilter as string);
|
|
68
|
+
if (opts.sitemap) this.sitemapUrls = await readSitemap(opts.sitemap as string, this.urlFilter);
|
|
60
69
|
if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
|
|
61
70
|
this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
|
|
62
71
|
}
|
|
@@ -76,13 +85,15 @@ export class ExploreCommand extends BaseCommand {
|
|
|
76
85
|
Stats.focus ??= feature;
|
|
77
86
|
const mainUrl = this.getCurrentPageUrl();
|
|
78
87
|
const error = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
|
|
79
|
-
if (error) {
|
|
88
|
+
if (error && !this.sitemapUrls) {
|
|
80
89
|
tag('warning').log(error.message);
|
|
81
90
|
return;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
try {
|
|
85
|
-
if (
|
|
94
|
+
if (this.sitemapUrls) {
|
|
95
|
+
await this.runSitemapMode(this.sitemapUrls, feature, cfg.styles);
|
|
96
|
+
} else if (cfg.enabled) {
|
|
86
97
|
await this.runReuseMode(mainUrl, feature, cfg);
|
|
87
98
|
} else {
|
|
88
99
|
await this.runFreshMode(mainUrl, feature, cfg.styles);
|
|
@@ -135,6 +146,20 @@ export class ExploreCommand extends BaseCommand {
|
|
|
135
146
|
await this.discoverNewSubPages(mainPlan, mainUrl, styles, new Set());
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
private async runSitemapMode(sitemapUrls: string[], feature: string | undefined, styles?: string[]): Promise<void> {
|
|
150
|
+
const urls = keepSiteUrls(sitemapUrls, this.explorBot.getConfig().playwright.url);
|
|
151
|
+
tag('info').log(`Sitemap: ${urls.length} page(s) to explore`);
|
|
152
|
+
for (const url of urls) {
|
|
153
|
+
if (this.isLimitReached()) break;
|
|
154
|
+
tag('info').log(`Exploring sitemap page: ${url}`);
|
|
155
|
+
try {
|
|
156
|
+
await this.explorePage(url, feature, undefined, styles);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
tag('warning').log(`Sitemap page exploration failed: ${err instanceof Error ? err.message : err}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
private async runReuseMode(mainUrl: string | undefined, feature: string | undefined, cfg: ConfigureSpec): Promise<void> {
|
|
139
164
|
const filename = cfg.fromPath || this.explorBot.generatePlanFilename(feature);
|
|
140
165
|
|
|
@@ -234,6 +259,7 @@ export class ExploreCommand extends BaseCommand {
|
|
|
234
259
|
for (const subPlan of subPlans) {
|
|
235
260
|
if (this.isLimitReached()) break;
|
|
236
261
|
if (!subPlan.url) continue;
|
|
262
|
+
if (!this.urlFilter.admit(subPlan.url)) continue;
|
|
237
263
|
try {
|
|
238
264
|
if (!this.dryRun) await this.explorBot.visit(subPlan.url);
|
|
239
265
|
await this.replanAndRun(subPlan.url, undefined, subPlan, cfg.styles);
|
|
@@ -264,27 +290,21 @@ export class ExploreCommand extends BaseCommand {
|
|
|
264
290
|
|
|
265
291
|
const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/').filter((c) => {
|
|
266
292
|
const norm = normalizeUrl(c.url);
|
|
267
|
-
return !this.failedSubPages.has(norm) && !knownUrls.has(norm);
|
|
293
|
+
return !this.failedSubPages.has(norm) && !knownUrls.has(norm) && this.urlFilter.allows(c.url);
|
|
268
294
|
});
|
|
269
295
|
if (candidates.length === 0) break;
|
|
270
296
|
|
|
271
297
|
const pick = await planner.pickNextSubPage(candidates);
|
|
272
298
|
if (!pick) break;
|
|
299
|
+
if (!this.urlFilter.admit(pick.url)) break;
|
|
273
300
|
|
|
274
301
|
tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
|
|
275
302
|
try {
|
|
276
|
-
await this.
|
|
277
|
-
|
|
278
|
-
if (errorPage) {
|
|
279
|
-
tag('warning').log(`Skipping sub-page: ${errorPage.message}`);
|
|
303
|
+
const explored = await this.explorePage(pick.url, undefined, mainPlan, styles);
|
|
304
|
+
if (!explored) {
|
|
280
305
|
this.failedSubPages.add(normalizeUrl(pick.url));
|
|
281
306
|
continue;
|
|
282
307
|
}
|
|
283
|
-
await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
|
|
284
|
-
const subPlan = this.explorBot.getCurrentPlan();
|
|
285
|
-
if (subPlan?.tests.length && !this.completedPlans.includes(subPlan)) {
|
|
286
|
-
this.completedPlans.push(subPlan);
|
|
287
|
-
}
|
|
288
308
|
knownUrls.add(normalizeUrl(pick.url));
|
|
289
309
|
} catch (err) {
|
|
290
310
|
this.failedSubPages.add(normalizeUrl(pick.url));
|
|
@@ -293,6 +313,21 @@ export class ExploreCommand extends BaseCommand {
|
|
|
293
313
|
}
|
|
294
314
|
}
|
|
295
315
|
|
|
316
|
+
private async explorePage(url: string, feature: string | undefined, parentPlan: Plan | undefined, styles?: string[]): Promise<boolean> {
|
|
317
|
+
await this.explorBot.visit(url);
|
|
318
|
+
const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
|
|
319
|
+
if (errorPage) {
|
|
320
|
+
tag('warning').log(`Skipping page: ${errorPage.message}`);
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
await this.runAllStyles(url, feature, parentPlan, this.completedPlans, styles);
|
|
324
|
+
const plan = this.explorBot.getCurrentPlan();
|
|
325
|
+
if (plan?.tests.length && !this.completedPlans.includes(plan)) {
|
|
326
|
+
this.completedPlans.push(plan);
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
296
331
|
private async replanAndRun(pageUrl: string | undefined, feature: string | undefined, existingPlan: Plan, styles?: string[]): Promise<void> {
|
|
297
332
|
const styleList = styles ?? Object.keys(getStyles());
|
|
298
333
|
for (const style of styleList) {
|
|
@@ -369,17 +404,7 @@ export class ExploreCommand extends BaseCommand {
|
|
|
369
404
|
const validSubpages = new Set(['none', 'same', 'new', 'both']);
|
|
370
405
|
let hasReuseSignal = false;
|
|
371
406
|
|
|
372
|
-
for (const
|
|
373
|
-
const trimmed = pair.trim();
|
|
374
|
-
if (!trimmed) continue;
|
|
375
|
-
const sepMatch = trimmed.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
|
|
376
|
-
if (!sepMatch) {
|
|
377
|
-
tag('warning').log(`Ignoring malformed configure pair: ${trimmed}`);
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
const key = sepMatch[1].trim().toLowerCase();
|
|
381
|
-
const value = sepMatch[2].trim();
|
|
382
|
-
|
|
407
|
+
for (const { key, value } of parseSpecPairs(raw)) {
|
|
383
408
|
if (key === 'new') {
|
|
384
409
|
const ratio = parseRatio(value);
|
|
385
410
|
if (ratio == null) {
|
|
@@ -558,6 +583,14 @@ export class ExploreCommand extends BaseCommand {
|
|
|
558
583
|
});
|
|
559
584
|
}
|
|
560
585
|
|
|
586
|
+
sections.push({
|
|
587
|
+
label: 'Explore more',
|
|
588
|
+
commands: [
|
|
589
|
+
{ label: 'Whole site', command: `${cli} explore /` },
|
|
590
|
+
{ label: 'Docs', command: 'https://testomat.ai/docs/explorbot' },
|
|
591
|
+
],
|
|
592
|
+
});
|
|
593
|
+
|
|
561
594
|
printNextSteps(sections);
|
|
562
595
|
}
|
|
563
596
|
|
|
@@ -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.ts';
|
|
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
|
|
|
@@ -18,6 +19,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
18
19
|
{ flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
|
|
19
20
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
20
21
|
{ flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
|
|
22
|
+
{ flags: '--url-filter <spec>', description: 'Pick pages to explore: keys filter|exclude|limit, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"' },
|
|
21
23
|
];
|
|
22
24
|
|
|
23
25
|
async execute(args: string): Promise<void> {
|
|
@@ -27,6 +29,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
27
29
|
if (opts.deep) strategy = 'deep';
|
|
28
30
|
if (opts.shallow) strategy = 'shallow';
|
|
29
31
|
const scope = opts.scope as string | undefined;
|
|
32
|
+
const urlFilter = new UrlFilter(opts.urlFilter as string | undefined);
|
|
30
33
|
const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests as string, 10) : undefined;
|
|
31
34
|
const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration as string, 10) : undefined;
|
|
32
35
|
let hardDeadlineAt: number | undefined;
|
|
@@ -55,6 +58,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
55
58
|
const exploreCmd = new ExploreCommand(this.explorBot);
|
|
56
59
|
if (maxTests != null) exploreCmd.maxTests = maxTests - testsRun;
|
|
57
60
|
if (hardDeadlineAt != null) exploreCmd.hardDeadlineAt = hardDeadlineAt;
|
|
61
|
+
exploreCmd.urlFilter = urlFilter;
|
|
58
62
|
await exploreCmd.execute('');
|
|
59
63
|
|
|
60
64
|
const plan = this.explorBot.getCurrentPlan();
|
|
@@ -65,7 +69,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
65
69
|
|
|
66
70
|
const navigator = this.explorBot.agentNavigator();
|
|
67
71
|
const visitedUrls = stateManager.getAllVisitedUrls();
|
|
68
|
-
const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls });
|
|
72
|
+
const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls, urlFilter });
|
|
69
73
|
if (!suggestion) {
|
|
70
74
|
tag('info').log('No navigation suggestion available');
|
|
71
75
|
ctx.stop();
|
|
@@ -76,6 +80,11 @@ export class FreesailCommand extends BaseCommand {
|
|
|
76
80
|
ctx.stop();
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
if (!urlFilter.admit(suggestion.target)) {
|
|
84
|
+
tag('info').log(`URL filter allows no more pages (${suggestion.target} not admitted); stopping exploration`);
|
|
85
|
+
ctx.stop();
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
const currentUrl = stateManager.getCurrentState()?.url;
|
|
80
89
|
if (currentUrl && normalizeUrl(suggestion.target) === normalizeUrl(currentUrl)) {
|
|
81
90
|
tag('info').log(`No new navigation target available after ${currentUrl}; stopping exploration`);
|