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
|
@@ -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`);
|
|
@@ -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://
|
|
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: '
|
|
19
|
+
url: '${appUrl}',
|
|
18
20
|
},
|
|
19
21
|
|
|
20
22
|
ai: {
|
|
21
|
-
${
|
|
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
|
|
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
|
|
68
|
-
if (!
|
|
73
|
+
const setup = await renderLocalSetupWizard();
|
|
74
|
+
if (!setup)
|
|
69
75
|
return;
|
|
70
|
-
runInitCommand({ ...options,
|
|
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(
|
|
84
|
+
tag('success').log(`${figureSet.tick} Created global config: ${globalConfigPath()}`);
|
|
79
85
|
const envKey = PROVIDERS[provider].envKey;
|
|
80
86
|
writeEnvKey(envKey, apiKey || '');
|
|
81
|
-
log(
|
|
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
|
-
|
|
93
|
+
printApiKeySteps(provider, globalEnvPath());
|
|
88
94
|
}
|
|
89
95
|
log('');
|
|
90
|
-
log('
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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 = (
|
|
218
|
+
const finish = (setup) => {
|
|
205
219
|
unmount();
|
|
206
|
-
resolve(
|
|
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
|
-
|
|
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://
|
|
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
|
|
259
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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) {
|
package/dist/src/config.d.ts
CHANGED
|
@@ -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 {
|
package/dist/src/config.js
CHANGED
|
@@ -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;
|
package/dist/src/test-plan.d.ts
CHANGED
|
@@ -121,6 +121,7 @@ export declare class Test extends Task {
|
|
|
121
121
|
get isSkipped(): boolean;
|
|
122
122
|
getCheckedNotes(): Note[];
|
|
123
123
|
getCheckedExpectations(): string[];
|
|
124
|
+
hasAchieved(expectation: string): boolean;
|
|
124
125
|
hasAchievedAny(): boolean;
|
|
125
126
|
getRunResult(): 'success' | 'partial' | 'failed';
|
|
126
127
|
hasAchievedAll(): boolean;
|
package/dist/src/test-plan.js
CHANGED
|
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
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,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
|
+
}
|