explorbot 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/bin/explorbot-cli.ts +17 -4
  2. package/boat/doc-collector/src/cli.ts +9 -3
  3. package/boat/doc-collector/src/docbot.ts +11 -2
  4. package/dist/bin/explorbot-cli.js +19 -5
  5. package/dist/boat/doc-collector/src/cli.js +9 -3
  6. package/dist/boat/doc-collector/src/docbot.js +13 -2
  7. package/dist/package.json +1 -1
  8. package/dist/src/ai/navigator.d.ts +2 -0
  9. package/dist/src/ai/navigator.js +10 -0
  10. package/dist/src/ai/pilot.js +9 -2
  11. package/dist/src/commands/explore-command.d.ts +5 -0
  12. package/dist/src/commands/explore-command.js +60 -23
  13. package/dist/src/commands/freesail-command.js +9 -1
  14. package/dist/src/commands/init-command.d.ts +1 -0
  15. package/dist/src/commands/init-command.js +87 -36
  16. package/dist/src/components/InitWizard.d.ts +5 -1
  17. package/dist/src/components/InitWizard.js +29 -11
  18. package/dist/src/config.d.ts +2 -0
  19. package/dist/src/config.js +29 -7
  20. package/dist/src/test-plan.d.ts +1 -0
  21. package/dist/src/test-plan.js +5 -6
  22. package/dist/src/utils/sitemap.d.ts +11 -0
  23. package/dist/src/utils/sitemap.js +106 -0
  24. package/dist/src/utils/spec.d.ts +5 -0
  25. package/dist/src/utils/spec.js +16 -0
  26. package/dist/src/utils/url-filter.d.ts +10 -0
  27. package/dist/src/utils/url-filter.js +52 -0
  28. package/docs/basics/getting-started.md +23 -10
  29. package/docs/basics/providers.md +2 -0
  30. package/docs/reference/commands.md +5 -2
  31. package/docs/reference/configuration.md +1 -0
  32. package/package.json +1 -1
  33. package/src/ai/navigator.ts +13 -1
  34. package/src/ai/pilot.ts +9 -2
  35. package/src/commands/explore-command.ts +56 -23
  36. package/src/commands/freesail-command.ts +10 -1
  37. package/src/commands/init-command.ts +105 -38
  38. package/src/components/InitWizard.tsx +43 -11
  39. package/src/config.ts +32 -7
  40. package/src/test-plan.ts +6 -6
  41. package/src/utils/sitemap.ts +108 -0
  42. package/src/utils/spec.ts +21 -0
  43. package/src/utils/url-filter.ts +53 -0
@@ -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`);
@@ -11,5 +11,6 @@ type InitCommandOptions = {
11
11
  global?: boolean;
12
12
  provider?: string;
13
13
  apiKey?: string;
14
+ url?: string;
14
15
  };
15
16
  export {};
@@ -1,24 +1,29 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, extname, join, resolve } from 'node:path';
3
3
  import chalk from 'chalk';
4
+ import figureSet from 'figures';
4
5
  import { ConfigParser, PROVIDERS, missingModelRoles } from "../config.js";
5
6
  import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "../global-config.js";
6
7
  import { getCliName } from "../utils/cli-name.js";
7
8
  import { log, tag } from '../utils/logger.js';
8
- import { relativeToCwd } from "../utils/next-steps.js";
9
- function defaultConfigTemplate(provider) {
9
+ import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
10
+ function defaultConfigTemplate(provider, url) {
11
+ const appUrl = url || 'http://<your-app-host-here>';
10
12
  return `// 'provider/model-id' uses a bundled provider.
11
13
  // It is also possible to import provider as a module from Vercel AI SDK.
12
- // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
14
+ // https://testomat.ai/docs/explorbot
13
15
 
14
16
  const config = {
15
17
  web: {
16
18
  // use application host without path prefix (e.g., http://localhost:3000)
17
- url: 'http://<your-app-host-here>',
19
+ url: '${appUrl}',
18
20
  },
19
21
 
20
22
  ai: {
21
- ${modelLines(provider)}
23
+ // every role (model, visionModel, agenticModel) takes the recommended ${provider} models bundled with this version
24
+ // override a single role as 'provider/model-id':
25
+ // model: '${provider}/<model-id>',
26
+ recommendedModels: '${provider}',
22
27
  },
23
28
 
24
29
  reporter: {
@@ -35,9 +40,10 @@ export default config;
35
40
  `;
36
41
  }
37
42
  export function envTemplate(provider) {
43
+ const { keyUrl } = PROVIDERS[provider];
38
44
  const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
39
45
  if (name === provider)
40
- return `${envKey}=`;
46
+ return `# Create a key at ${keyUrl} and paste it after '='\n${envKey}=`;
41
47
  return `# ${envKey}=`;
42
48
  });
43
49
  return `# AI provider API keys
@@ -64,10 +70,10 @@ export async function runInit(options) {
64
70
  const choice = await renderInitWizard('choose');
65
71
  if (choice !== 'local')
66
72
  return;
67
- const provider = await renderLocalProviderWizard();
68
- if (!provider)
73
+ const setup = await renderLocalSetupWizard();
74
+ if (!setup)
69
75
  return;
70
- runInitCommand({ ...options, provider });
76
+ runInitCommand({ ...options, ...setup });
71
77
  }
72
78
  export function writeGlobalConfig(provider, apiKey) {
73
79
  if (!PROVIDERS[provider]) {
@@ -75,21 +81,25 @@ export function writeGlobalConfig(provider, apiKey) {
75
81
  }
76
82
  mkdirSync(globalDir(), { recursive: true });
77
83
  writeFileSync(globalConfigPath(), globalConfigTemplate(provider), 'utf8');
78
- log(`Created global config: ${globalConfigPath()}`);
84
+ tag('success').log(`${figureSet.tick} Created global config: ${globalConfigPath()}`);
79
85
  const envKey = PROVIDERS[provider].envKey;
80
86
  writeEnvKey(envKey, apiKey || '');
81
- log(`Stored ${envKey} in ${globalEnvPath()}`);
87
+ tag('success').log(`${figureSet.tick} Stored ${envKey} in ${globalEnvPath()}`);
82
88
  const missing = missingModelRoles(provider);
83
89
  if (missing.length) {
84
90
  tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${globalConfigPath()}`);
85
91
  }
86
92
  if (!apiKey && !process.env[envKey]) {
87
- tag('warning').log(`Add your API key to ${globalEnvPath()}`);
93
+ printApiKeySteps(provider, globalEnvPath());
88
94
  }
89
95
  log('');
90
- log('Explorbot now runs from any directory:');
91
- tag('substep').log(chalk.yellow(`${getCliName()} explore https://your-app.example.com`));
92
- tag('substep').log(chalk.yellow(`${getCliName()} sites`));
96
+ log(`${figureSet.star} What's next:`);
97
+ printInitNextSteps({
98
+ siteCommands: [
99
+ { label: 'Any site', command: `${getCliName()} explore https://your-app.example.com` },
100
+ { label: 'Saved sites', command: `${getCliName()} sites` },
101
+ ],
102
+ });
93
103
  }
94
104
  export function runInitCommand(options) {
95
105
  const provider = options.provider || 'openrouter';
@@ -125,34 +135,38 @@ export function runInitCommand(options) {
125
135
  log('Use --force to overwrite existing file');
126
136
  process.exit(1);
127
137
  }
128
- writeFileSync(outPath, defaultConfigTemplate(provider), 'utf8');
129
- log(`Created config file: ${relativeToCwd(outPath)}`);
138
+ writeFileSync(outPath, defaultConfigTemplate(provider, options.url), 'utf8');
139
+ tag('success').log(`${figureSet.tick} Created config file: ${relativeToCwd(outPath)}`);
130
140
  const envPath = resolve(process.cwd(), '.env');
131
141
  if (!existsSync(envPath)) {
132
142
  writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
133
- log(`Created env file: ${relativeToCwd(envPath)}`);
143
+ tag('success').log(`${figureSet.tick} Created env file: ${relativeToCwd(envPath)}`);
134
144
  }
135
145
  else {
136
146
  log(`Env file already exists: ${relativeToCwd(envPath)}`);
137
147
  }
148
+ const envKey = PROVIDERS[provider].envKey;
149
+ if (options.apiKey) {
150
+ writeEnvKey(envKey, options.apiKey, envPath);
151
+ tag('success').log(`${figureSet.tick} Stored ${envKey} in ${relativeToCwd(envPath)}`);
152
+ }
153
+ if (!options.apiKey && !process.env[envKey]) {
154
+ printApiKeySteps(provider, relativeToCwd(envPath));
155
+ }
156
+ if (!options.url) {
157
+ tag('warning').log(`Set your application URL in web.url: ${relativeToCwd(outPath)}`);
158
+ }
138
159
  const missing = missingModelRoles(provider);
139
160
  if (missing.length) {
140
161
  tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
141
162
  }
142
- log('');
143
- log('Next steps:');
144
- log('1. Configure AI provider in .env');
145
- log('2. Set AI models config file');
146
- log('3. Set web application URL in the config file');
147
- log('4. Add initial knowledge (how to authorize to the application, etc.)');
148
- tag('substep').log(chalk.yellow(`${getCliName()} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
149
- tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
150
- log('5. Launch application on a relative URL');
151
- tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
152
163
  if (!existsSync('./output')) {
153
164
  mkdirSync('./output', { recursive: true });
154
- log('Created directory: output');
165
+ tag('success').log(`${figureSet.tick} Created directory: output`);
155
166
  }
167
+ log('');
168
+ log(`${figureSet.star} What's next:`);
169
+ printInitNextSteps({ url: options.url });
156
170
  }
157
171
  catch (error) {
158
172
  log('Failed to create config file:', error);
@@ -198,12 +212,12 @@ async function renderInitWizard(mode) {
198
212
  }), { exitOnCtrlC: false, patchConsole: false });
199
213
  });
200
214
  }
201
- async function renderLocalProviderWizard() {
215
+ async function renderLocalSetupWizard() {
202
216
  const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
203
217
  return new Promise((resolve) => {
204
- const finish = (provider) => {
218
+ const finish = (setup) => {
205
219
  unmount();
206
- resolve(provider);
220
+ resolve(setup);
207
221
  };
208
222
  const { unmount } = render(React.createElement(InitWizard, {
209
223
  mode: 'local',
@@ -211,7 +225,7 @@ async function renderLocalProviderWizard() {
211
225
  onLocal: () => finish(null),
212
226
  onComplete: () => finish(null),
213
227
  onCancel: () => finish(null),
214
- onLocalProvider: (provider) => finish(provider),
228
+ onLocalSetup: (setup) => finish(setup),
215
229
  }), { exitOnCtrlC: false, patchConsole: false });
216
230
  });
217
231
  }
@@ -238,7 +252,7 @@ function globalConfigTemplate(provider) {
238
252
  // Models are written as 'provider/model-id' so they resolve without a local node_modules.
239
253
  // The key is read from ${envKey} in ~/.explorbot/.env
240
254
  // Model ids are snapshotted from the recommendations of this Explorbot version.
241
- // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
255
+ // https://testomat.ai/docs/explorbot
242
256
  const config = {
243
257
  ai: {
244
258
  ${modelLines(provider)}
@@ -255,8 +269,39 @@ ${modelLines(provider)}
255
269
  export default config;
256
270
  `;
257
271
  }
258
- function writeEnvKey(key, value) {
259
- const envPath = globalEnvPath();
272
+ function printInitNextSteps(options) {
273
+ const cli = getCliName();
274
+ const sections = [
275
+ {
276
+ label: 'Try the demo on a sample app',
277
+ commands: [{ command: `${cli} explore https://todomvc.com/examples/react/dist/` }],
278
+ },
279
+ ];
280
+ if (options.url) {
281
+ sections.push({
282
+ label: 'Explore your app',
283
+ commands: [
284
+ { label: 'Whole site', command: `${cli} explore ${options.url}` },
285
+ { label: 'One page', command: `${cli} explore /dashboard` },
286
+ ],
287
+ });
288
+ }
289
+ if (options.siteCommands)
290
+ sections.push({ label: 'Explore any site', commands: options.siteCommands });
291
+ sections.push({
292
+ label: 'Run it from VS Code',
293
+ commands: [{ command: 'https://testomat.ai/explorbot' }],
294
+ }, {
295
+ label: 'Docs',
296
+ commands: [{ command: 'https://testomat.ai/docs/explorbot' }],
297
+ });
298
+ printNextSteps(sections);
299
+ log('');
300
+ log('Add knowledge (logins, credentials) — created on first use in ./knowledge:');
301
+ tag('substep').log(chalk.yellow(`${cli} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
302
+ tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
303
+ }
304
+ function writeEnvKey(key, value, envPath = globalEnvPath()) {
260
305
  let content = '# AI provider API keys';
261
306
  if (existsSync(envPath))
262
307
  content = readFileSync(envPath, 'utf8').trimEnd();
@@ -268,3 +313,9 @@ function writeEnvKey(key, value) {
268
313
  lines[index] = `${key}=${value}`;
269
314
  writeFileSync(envPath, `${lines.join('\n').trimEnd()}\n`, 'utf8');
270
315
  }
316
+ function printApiKeySteps(provider, envPath) {
317
+ const { envKey, keyUrl } = PROVIDERS[provider];
318
+ tag('warning').log(`No API key yet — explorbot needs one to connect to ${provider}`);
319
+ tag('substep').log(`1. Create a key at ${keyUrl}`);
320
+ tag('substep').log(`2. Paste it into ${envPath}: ${envKey}=<your-key>`);
321
+ }
@@ -5,7 +5,11 @@ interface InitWizardProps {
5
5
  onLocal: () => void;
6
6
  onComplete: () => void;
7
7
  onCancel: () => void;
8
- onLocalProvider?: (provider: string) => void;
8
+ onLocalSetup?: (setup: {
9
+ provider: string;
10
+ apiKey: string;
11
+ url: string;
12
+ }) => void;
9
13
  }
10
14
  declare const InitWizard: React.FC<InitWizardProps>;
11
15
  export default InitWizard;
@@ -7,16 +7,23 @@ import { ConfigParser, PROVIDERS, createModel } from '../config.js';
7
7
  import { globalDir } from '../global-config.js';
8
8
  import InputReadline from './InputReadline.js';
9
9
  const PROVIDER_NAMES = Object.keys(PROVIDERS);
10
- const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalProvider }) => {
10
+ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalSetup }) => {
11
11
  const [step, setStep] = useState(mode === 'choose' ? 'target' : 'provider');
12
12
  const [targetIndex, setTargetIndex] = useState(0);
13
13
  const [providerIndex, setProviderIndex] = useState(0);
14
14
  const [apiKey, setApiKey] = useState('');
15
+ const [url, setUrl] = useState('');
16
+ const [urlError, setUrlError] = useState('');
15
17
  const [status, setStatus] = useState('');
16
18
  const [error, setError] = useState('');
17
19
  const provider = PROVIDER_NAMES[providerIndex];
18
20
  const envKey = PROVIDERS[provider].envKey;
19
21
  const save = () => {
22
+ if (mode === 'local') {
23
+ setStatus('');
24
+ setStep('url');
25
+ return;
26
+ }
20
27
  writeGlobalConfig(provider, apiKey.trim());
21
28
  onComplete();
22
29
  };
@@ -45,7 +52,7 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
45
52
  onCancel();
46
53
  return;
47
54
  }
48
- if (step === 'key')
55
+ if (step === 'key' || step === 'url')
49
56
  return;
50
57
  if (key.escape) {
51
58
  onCancel();
@@ -67,12 +74,8 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
67
74
  setProviderIndex((index) => Math.max(0, index - 1));
68
75
  if (key.downArrow)
69
76
  setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
70
- if (key.return) {
71
- if (mode === 'local')
72
- onLocalProvider?.(provider);
73
- else
74
- setStep('key');
75
- }
77
+ if (key.return)
78
+ setStep('key');
76
79
  return;
77
80
  }
78
81
  if (status)
@@ -109,10 +112,12 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
109
112
  React.createElement(Text, null,
110
113
  "Enter the API key for ",
111
114
  provider,
112
- " ",
115
+ ' ',
113
116
  React.createElement(Text, { dimColor: true },
114
117
  "(stored as ",
115
118
  envKey,
119
+ ", create one at ",
120
+ PROVIDERS[provider].keyUrl,
116
121
  ")")),
117
122
  React.createElement(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1 },
118
123
  React.createElement(InputReadline, { value: apiKey, onChange: setApiKey, onSubmit: () => setStep('validate'), placeholder: process.env[envKey] ? 'leave empty to keep the key from your environment' : 'paste the key', isActive: true, showPrompt: false, mask: true })))),
@@ -121,13 +126,26 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
121
126
  status && React.createElement(Text, { color: "yellow" }, status),
122
127
  error && (React.createElement(Box, { flexDirection: "column" },
123
128
  React.createElement(Text, { color: "red" }, error),
124
- React.createElement(Text, { dimColor: true }, "r: re-enter the key | n: save anyway"))))),
129
+ React.createElement(Text, { dimColor: true }, "r: re-enter the key | n: continue anyway"))))),
130
+ step === 'url' && (React.createElement(Box, { flexDirection: "column" },
131
+ React.createElement(Text, null,
132
+ "What is the URL of the web application to test? ",
133
+ React.createElement(Text, { dimColor: true }, "(written to web.url in the config)")),
134
+ React.createElement(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1 },
135
+ React.createElement(InputReadline, { value: url, onChange: setUrl, onSubmit: () => {
136
+ if (!URL.canParse(url.trim())) {
137
+ setUrlError('Enter a full URL, e.g. http://localhost:3000');
138
+ return;
139
+ }
140
+ onLocalSetup?.({ provider, apiKey: apiKey.trim(), url: url.trim() });
141
+ }, placeholder: "http://localhost:3000", isActive: true, showPrompt: false })),
142
+ urlError && React.createElement(Text, { color: "red" }, urlError))),
125
143
  React.createElement(Box, { marginTop: 1 },
126
144
  React.createElement(Text, { dimColor: true },
127
145
  "Config goes to ",
128
146
  mode === 'local' ? 'the current directory' : globalDir(),
129
147
  " | ",
130
- step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
148
+ step === 'key' || step === 'url' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
131
149
  " | Ctrl+C: exit"))));
132
150
  };
133
151
  function defaultModelId(provider) {
@@ -141,6 +141,7 @@ interface AgentsConfig {
141
141
  }
142
142
  interface AIConfig {
143
143
  model: any;
144
+ recommendedModels?: string;
144
145
  apiKey?: string;
145
146
  config?: Record<string, any>;
146
147
  langfuse?: {
@@ -315,6 +316,7 @@ interface ConfiguredModel {
315
316
  }
316
317
  interface ProviderInfo {
317
318
  envKey: string;
319
+ keyUrl: string;
318
320
  load: () => Promise<(modelId: string) => any>;
319
321
  }
320
322
  interface EnvVar {
@@ -18,13 +18,13 @@ import { getCliName } from './utils/cli-name.js';
18
18
  import { deepMerge } from './utils/merge.js';
19
19
  import { log, tag } from './utils/logger.js';
20
20
  export const PROVIDERS = {
21
- openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
22
- anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
23
- google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
24
- groq: { envKey: 'GROQ_API_KEY', load: async () => (await import('@ai-sdk/groq')).createGroq() },
25
- mistral: { envKey: 'MISTRAL_API_KEY', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
26
- openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
27
- sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
21
+ openai: { envKey: 'OPENAI_API_KEY', keyUrl: 'https://platform.openai.com/api-keys', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
22
+ anthropic: { envKey: 'ANTHROPIC_API_KEY', keyUrl: 'https://console.anthropic.com/settings/keys', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
23
+ google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', keyUrl: 'https://aistudio.google.com/apikey', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
24
+ groq: { envKey: 'GROQ_API_KEY', keyUrl: 'https://console.groq.com/keys', load: async () => (await import('@ai-sdk/groq')).createGroq() },
25
+ mistral: { envKey: 'MISTRAL_API_KEY', keyUrl: 'https://console.mistral.ai/api-keys', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
26
+ openrouter: { envKey: 'OPENROUTER_API_KEY', keyUrl: 'https://openrouter.ai/keys', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
27
+ sambanova: { envKey: 'SAMBANOVA_API_KEY', keyUrl: 'https://cloud.sambanova.ai/apis', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
28
28
  };
29
29
  export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
30
30
  let cachedOutputRoot = null;
@@ -572,11 +572,33 @@ export async function resolveConfigModels(ai) {
572
572
  if (typeof ai[role] === 'string')
573
573
  ai[role] = await resolveModel(ai[role], role);
574
574
  }
575
+ await fillRecommendedModels(ai);
575
576
  for (const agent of Object.values(ai.agents || {})) {
576
577
  if (typeof agent?.model === 'string')
577
578
  agent.model = await resolveModel(agent.model);
578
579
  }
579
580
  }
581
+ async function fillRecommendedModels(ai) {
582
+ const provider = ai.recommendedModels;
583
+ if (!provider)
584
+ return;
585
+ const recommended = ConfigParser.recommendedModels()[provider];
586
+ if (!recommended) {
587
+ throw new Error(`No recommended models for "${provider}". Use a provider with recommendations: ${Object.keys(ConfigParser.recommendedModels()).join(', ')}`);
588
+ }
589
+ for (const role of MODEL_ROLES) {
590
+ if (ai[role])
591
+ continue;
592
+ if (!recommended[role])
593
+ continue;
594
+ ai[role] = await resolveModel(provider, role);
595
+ }
596
+ if (!ai.decisionModel && recommended.decisionModel)
597
+ ai.decisionModel = { provider, model: recommended.decisionModel };
598
+ const missing = missingModelRoles(provider);
599
+ if (missing.length)
600
+ tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set them explicitly as "provider/model-id"`);
601
+ }
580
602
  export function resolveLangfuse(ai) {
581
603
  if (!ai)
582
604
  return;
@@ -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;
@@ -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
+ }