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.
@@ -272,10 +272,11 @@ export class Test extends Task {
272
272
  return Object.values(this.notes).some((note) => note.message === expectation && !!note.status);
273
273
  });
274
274
  }
275
+ hasAchieved(expectation) {
276
+ return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
277
+ }
275
278
  hasAchievedAny() {
276
- return this.expected.some((expectation) => {
277
- return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
278
- });
279
+ return this.expected.some((expectation) => this.hasAchieved(expectation));
279
280
  }
280
281
  getRunResult() {
281
282
  if (this.isSuccessful)
@@ -285,9 +286,7 @@ export class Test extends Task {
285
286
  return super.getRunResult();
286
287
  }
287
288
  hasAchievedAll() {
288
- return this.expected.every((expectation) => {
289
- return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
290
- });
289
+ return this.expected.every((expectation) => this.hasAchieved(expectation));
291
290
  }
292
291
  isComplete() {
293
292
  return this.expected.every((expectation) => {
@@ -0,0 +1,11 @@
1
+ import type { UrlFilter } from './url-filter.js';
2
+ export declare function readSitemap(source: string, filter?: UrlFilter): Promise<string[]>;
3
+ export declare function readCliSitemap(path: string | undefined, source: string | undefined, filter?: UrlFilter): Promise<string[] | undefined>;
4
+ export declare function keepSiteUrls(urls: string[], siteUrl: string): string[];
5
+ export declare function extractEntries(content: string): SitemapEntry[];
6
+ interface SitemapEntry {
7
+ url: string;
8
+ priority: number;
9
+ lastmod?: number;
10
+ }
11
+ export {};
@@ -0,0 +1,106 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { tag } from './logger.js';
3
+ const ENTRY_PATTERN = /<(url|sitemap)\b[^>]*>(.*?)<\/\1>/gis;
4
+ const XML_ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
5
+ const MAX_NESTED_SITEMAPS = 50;
6
+ const DEFAULT_PRIORITY = 0.5;
7
+ const STDIN_SOURCE = '-';
8
+ export async function readSitemap(source, filter) {
9
+ const entries = [];
10
+ const seen = new Set();
11
+ const pending = [source];
12
+ let fetched = 0;
13
+ while (pending.length > 0) {
14
+ if (fetched >= MAX_NESTED_SITEMAPS) {
15
+ tag('warning').log(`Sitemap index lists more than ${MAX_NESTED_SITEMAPS} sitemaps, the rest are ignored`);
16
+ break;
17
+ }
18
+ const next = pending.shift();
19
+ fetched++;
20
+ const content = await readSource(next);
21
+ const isIndex = content.includes('<sitemapindex');
22
+ for (const entry of extractEntries(content)) {
23
+ if (seen.has(entry.url))
24
+ continue;
25
+ seen.add(entry.url);
26
+ if (isIndex) {
27
+ pending.push(entry.url);
28
+ continue;
29
+ }
30
+ entries.push(entry);
31
+ }
32
+ }
33
+ return applyFilter(entries, filter).map((entry) => entry.url);
34
+ }
35
+ export async function readCliSitemap(path, source, filter) {
36
+ const usage = 'Pass a page path, --sitemap <source>, or pipe a sitemap to stdin';
37
+ let resolved = source;
38
+ if (!resolved && !path && !process.stdin.isTTY)
39
+ resolved = STDIN_SOURCE;
40
+ if (!resolved && !path)
41
+ throw new Error(usage);
42
+ if (!resolved)
43
+ return undefined;
44
+ const urls = await readSitemap(resolved, filter);
45
+ if (urls.length === 0 && !source)
46
+ throw new Error(usage);
47
+ if (urls.length === 0)
48
+ throw new Error(`No URLs left in sitemap ${resolved}`);
49
+ return urls;
50
+ }
51
+ export function keepSiteUrls(urls, siteUrl) {
52
+ const siteOrigin = URL.parse(siteUrl)?.origin;
53
+ const kept = urls.filter((url) => url.startsWith('/') || URL.parse(url)?.origin === siteOrigin);
54
+ if (kept.length < urls.length)
55
+ tag('warning').log(`Skipped ${urls.length - kept.length} sitemap URL(s) outside ${siteOrigin}`);
56
+ return kept;
57
+ }
58
+ export function extractEntries(content) {
59
+ if (!content.trimStart().startsWith('<')) {
60
+ return content
61
+ .split('\n')
62
+ .map((line) => line.trim())
63
+ .filter((line) => line.startsWith('http://') || line.startsWith('https://') || line.startsWith('/'))
64
+ .map((url) => ({ url, priority: DEFAULT_PRIORITY }));
65
+ }
66
+ const entries = [];
67
+ for (const match of content.matchAll(ENTRY_PATTERN)) {
68
+ const url = tagValue(match[2], 'loc');
69
+ if (!url)
70
+ continue;
71
+ const entry = { url, priority: DEFAULT_PRIORITY };
72
+ const priority = Number.parseFloat(tagValue(match[2], 'priority') || '');
73
+ if (!Number.isNaN(priority))
74
+ entry.priority = priority;
75
+ const lastmod = Date.parse(tagValue(match[2], 'lastmod') || '');
76
+ if (!Number.isNaN(lastmod))
77
+ entry.lastmod = lastmod;
78
+ entries.push(entry);
79
+ }
80
+ return entries;
81
+ }
82
+ function applyFilter(entries, filter) {
83
+ if (!filter)
84
+ return entries;
85
+ let result = entries.filter((entry) => filter.allows(entry.url));
86
+ if (filter.sort === 'priority')
87
+ result = result.toSorted((a, b) => b.priority - a.priority);
88
+ if (filter.sort === 'lastmod')
89
+ result = result.toSorted((a, b) => (b.lastmod ?? 0) - (a.lastmod ?? 0));
90
+ return result.slice(0, filter.limit);
91
+ }
92
+ function tagValue(block, name) {
93
+ const match = block.match(new RegExp(`<${name}>\\s*(?:<!\\[CDATA\\[)?\\s*(.*?)\\s*(?:\\]\\]>)?\\s*</${name}>`, 'is'));
94
+ return match?.[1].replace(/&(amp|lt|gt|quot|apos);/g, (entity) => XML_ENTITIES[entity]) || undefined;
95
+ }
96
+ async function readSource(source) {
97
+ if (source === STDIN_SOURCE)
98
+ return readFileSync(0, 'utf8');
99
+ if (source.startsWith('http://') || source.startsWith('https://')) {
100
+ const response = await fetch(source);
101
+ if (!response.ok)
102
+ throw new Error(`Sitemap ${source} returned HTTP ${response.status}`);
103
+ return await response.text();
104
+ }
105
+ return readFileSync(source, 'utf8');
106
+ }
@@ -0,0 +1,5 @@
1
+ export declare function parseSpecPairs(raw: string | undefined): SpecPair[];
2
+ export interface SpecPair {
3
+ key: string;
4
+ value: string;
5
+ }
@@ -0,0 +1,16 @@
1
+ import { tag } from './logger.js';
2
+ export function parseSpecPairs(raw) {
3
+ const pairs = [];
4
+ for (const pair of (raw || '').split(';')) {
5
+ const trimmed = pair.trim();
6
+ if (!trimmed)
7
+ continue;
8
+ const sepMatch = trimmed.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
9
+ if (!sepMatch) {
10
+ tag('warning').log(`Ignoring malformed spec pair: ${trimmed}`);
11
+ continue;
12
+ }
13
+ pairs.push({ key: sepMatch[1].trim().toLowerCase(), value: sepMatch[2].trim() });
14
+ }
15
+ return pairs;
16
+ }
@@ -0,0 +1,10 @@
1
+ export declare class UrlFilter {
2
+ readonly include: string[];
3
+ readonly exclude: string[];
4
+ sort?: 'priority' | 'lastmod';
5
+ limit: number;
6
+ admitted: Set<string>;
7
+ constructor(spec?: string);
8
+ allows(url: string): boolean;
9
+ admit(url: string): boolean;
10
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -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> }, actionResult?: ActionResult): Promise<{ target: string; reason: string } | null> {
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.hasAchievedAny() && !task.getRemainingExpectations().includes(text)) return 'passed';
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 (cfg.enabled) {
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.explorBot.visit(pick.url);
277
- const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
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 pair of raw.split(';')) {
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) {
@@ -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`);
package/src/test-plan.ts CHANGED
@@ -331,10 +331,12 @@ export class Test extends Task {
331
331
  });
332
332
  }
333
333
 
334
+ hasAchieved(expectation: string): boolean {
335
+ return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
336
+ }
337
+
334
338
  hasAchievedAny(): boolean {
335
- return this.expected.some((expectation) => {
336
- return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
337
- });
339
+ return this.expected.some((expectation) => this.hasAchieved(expectation));
338
340
  }
339
341
 
340
342
  getRunResult(): 'success' | 'partial' | 'failed' {
@@ -344,9 +346,7 @@ export class Test extends Task {
344
346
  }
345
347
 
346
348
  hasAchievedAll(): boolean {
347
- return this.expected.every((expectation) => {
348
- return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
349
- });
349
+ return this.expected.every((expectation) => this.hasAchieved(expectation));
350
350
  }
351
351
 
352
352
  isComplete(): boolean {
@@ -0,0 +1,108 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { tag } from './logger.js';
3
+ import type { UrlFilter } from './url-filter.ts';
4
+
5
+ const ENTRY_PATTERN = /<(url|sitemap)\b[^>]*>(.*?)<\/\1>/gis;
6
+ const XML_ENTITIES: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
7
+ const MAX_NESTED_SITEMAPS = 50;
8
+ const DEFAULT_PRIORITY = 0.5;
9
+ const STDIN_SOURCE = '-';
10
+
11
+ export async function readSitemap(source: string, filter?: UrlFilter): Promise<string[]> {
12
+ const entries: SitemapEntry[] = [];
13
+ const seen = new Set<string>();
14
+ const pending = [source];
15
+ let fetched = 0;
16
+
17
+ while (pending.length > 0) {
18
+ if (fetched >= MAX_NESTED_SITEMAPS) {
19
+ tag('warning').log(`Sitemap index lists more than ${MAX_NESTED_SITEMAPS} sitemaps, the rest are ignored`);
20
+ break;
21
+ }
22
+ const next = pending.shift()!;
23
+ fetched++;
24
+ const content = await readSource(next);
25
+ const isIndex = content.includes('<sitemapindex');
26
+ for (const entry of extractEntries(content)) {
27
+ if (seen.has(entry.url)) continue;
28
+ seen.add(entry.url);
29
+ if (isIndex) {
30
+ pending.push(entry.url);
31
+ continue;
32
+ }
33
+ entries.push(entry);
34
+ }
35
+ }
36
+
37
+ return applyFilter(entries, filter).map((entry) => entry.url);
38
+ }
39
+
40
+ export async function readCliSitemap(path: string | undefined, source: string | undefined, filter?: UrlFilter): Promise<string[] | undefined> {
41
+ const usage = 'Pass a page path, --sitemap <source>, or pipe a sitemap to stdin';
42
+ let resolved = source;
43
+ if (!resolved && !path && !process.stdin.isTTY) resolved = STDIN_SOURCE;
44
+ if (!resolved && !path) throw new Error(usage);
45
+ if (!resolved) return undefined;
46
+ const urls = await readSitemap(resolved, filter);
47
+ if (urls.length === 0 && !source) throw new Error(usage);
48
+ if (urls.length === 0) throw new Error(`No URLs left in sitemap ${resolved}`);
49
+ return urls;
50
+ }
51
+
52
+ export function keepSiteUrls(urls: string[], siteUrl: string): string[] {
53
+ const siteOrigin = URL.parse(siteUrl)?.origin;
54
+ const kept = urls.filter((url) => url.startsWith('/') || URL.parse(url)?.origin === siteOrigin);
55
+ if (kept.length < urls.length) tag('warning').log(`Skipped ${urls.length - kept.length} sitemap URL(s) outside ${siteOrigin}`);
56
+ return kept;
57
+ }
58
+
59
+ export function extractEntries(content: string): SitemapEntry[] {
60
+ if (!content.trimStart().startsWith('<')) {
61
+ return content
62
+ .split('\n')
63
+ .map((line) => line.trim())
64
+ .filter((line) => line.startsWith('http://') || line.startsWith('https://') || line.startsWith('/'))
65
+ .map((url) => ({ url, priority: DEFAULT_PRIORITY }));
66
+ }
67
+ const entries: SitemapEntry[] = [];
68
+ for (const match of content.matchAll(ENTRY_PATTERN)) {
69
+ const url = tagValue(match[2], 'loc');
70
+ if (!url) continue;
71
+ const entry: SitemapEntry = { url, priority: DEFAULT_PRIORITY };
72
+ const priority = Number.parseFloat(tagValue(match[2], 'priority') || '');
73
+ if (!Number.isNaN(priority)) entry.priority = priority;
74
+ const lastmod = Date.parse(tagValue(match[2], 'lastmod') || '');
75
+ if (!Number.isNaN(lastmod)) entry.lastmod = lastmod;
76
+ entries.push(entry);
77
+ }
78
+ return entries;
79
+ }
80
+
81
+ function applyFilter(entries: SitemapEntry[], filter: UrlFilter | undefined): SitemapEntry[] {
82
+ if (!filter) return entries;
83
+ let result = entries.filter((entry) => filter.allows(entry.url));
84
+ if (filter.sort === 'priority') result = result.toSorted((a, b) => b.priority - a.priority);
85
+ if (filter.sort === 'lastmod') result = result.toSorted((a, b) => (b.lastmod ?? 0) - (a.lastmod ?? 0));
86
+ return result.slice(0, filter.limit);
87
+ }
88
+
89
+ function tagValue(block: string, name: string): string | undefined {
90
+ const match = block.match(new RegExp(`<${name}>\\s*(?:<!\\[CDATA\\[)?\\s*(.*?)\\s*(?:\\]\\]>)?\\s*</${name}>`, 'is'));
91
+ return match?.[1].replace(/&(amp|lt|gt|quot|apos);/g, (entity) => XML_ENTITIES[entity]) || undefined;
92
+ }
93
+
94
+ async function readSource(source: string): Promise<string> {
95
+ if (source === STDIN_SOURCE) return readFileSync(0, 'utf8');
96
+ if (source.startsWith('http://') || source.startsWith('https://')) {
97
+ const response = await fetch(source);
98
+ if (!response.ok) throw new Error(`Sitemap ${source} returned HTTP ${response.status}`);
99
+ return await response.text();
100
+ }
101
+ return readFileSync(source, 'utf8');
102
+ }
103
+
104
+ interface SitemapEntry {
105
+ url: string;
106
+ priority: number;
107
+ lastmod?: number;
108
+ }