explorbot 0.2.3 → 0.2.5
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/README.md +1 -1
- package/bin/explorbot-cli.ts +26 -8
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/api-tester/src/config.ts +4 -2
- package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
- package/boat/doc-collector/src/ai/documentarian.ts +61 -31
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/doc-collector/src/config.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +0 -0
- package/boat/prima/src/activity-line.ts +33 -0
- package/boat/prima/src/cli.ts +127 -86
- package/boat/prima/src/envelope.ts +102 -52
- package/boat/prima/src/prima.ts +567 -128
- package/boat/prima/src/pw-parser.ts +11 -1
- package/boat/prima/src/pw-registry.ts +4 -5
- package/boat/prima/src/session-log.ts +126 -0
- package/dist/bin/explorbot-cli.js +26 -8
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/api-tester/src/config.js +4 -2
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/doc-collector/src/config.js +4 -2
- package/dist/boat/prima/src/activity-line.js +30 -0
- package/dist/boat/prima/src/cli.js +109 -77
- package/dist/boat/prima/src/envelope.js +94 -44
- package/dist/boat/prima/src/prima.js +533 -119
- package/dist/boat/prima/src/pw-parser.js +13 -1
- package/dist/boat/prima/src/pw-registry.js +4 -5
- package/dist/boat/prima/src/session-log.js +108 -0
- package/dist/package.json +3 -2
- package/dist/rules/navigator/verification-actions.md +20 -0
- package/dist/src/action-result.d.ts +7 -0
- package/dist/src/action-result.js +4 -0
- package/dist/src/action.d.ts +2 -0
- package/dist/src/action.js +41 -2
- package/dist/src/ai/captain/web-mode.js +6 -3
- package/dist/src/ai/captain.js +2 -0
- package/dist/src/ai/navigator.d.ts +34 -0
- package/dist/src/ai/navigator.js +237 -181
- package/dist/src/ai/pilot.d.ts +7 -0
- package/dist/src/ai/pilot.js +90 -2
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +14 -23
- package/dist/src/ai/rerunner.js +2 -1
- package/dist/src/ai/researcher/cache.d.ts +2 -0
- package/dist/src/ai/researcher/cache.js +10 -2
- package/dist/src/ai/researcher.js +3 -2
- package/dist/src/ai/rules.js +17 -10
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/task-agent.js +4 -1
- package/dist/src/ai/tester.d.ts +6 -3
- package/dist/src/ai/tester.js +50 -46
- package/dist/src/ai/tools.d.ts +14 -0
- package/dist/src/ai/tools.js +117 -37
- package/dist/src/commands/config-command.d.ts +51 -0
- package/dist/src/commands/config-command.js +117 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/config.d.ts +9 -1
- package/dist/src/config.js +53 -4
- package/dist/src/execution-controller.d.ts +2 -0
- package/dist/src/execution-controller.js +6 -0
- package/dist/src/explorbot.d.ts +2 -1
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +2 -3
- package/dist/src/playwright-recorder.js +30 -0
- package/dist/src/remote.d.ts +55 -0
- package/dist/src/remote.js +235 -0
- package/dist/src/reporter.d.ts +1 -0
- package/dist/src/reporter.js +7 -1
- package/dist/src/state-manager.d.ts +2 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/stats.d.ts +1 -0
- package/dist/src/stats.js +1 -0
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +26 -0
- package/dist/src/utils/aria.d.ts +2 -8
- package/dist/src/utils/aria.js +69 -40
- package/dist/src/utils/html.js +1 -0
- package/dist/src/utils/logger.d.ts +7 -1
- package/dist/src/utils/logger.js +32 -0
- package/dist/src/utils/page-readiness.js +18 -1
- package/dist/src/utils/url-matcher.js +3 -0
- package/dist/src/utils/web-element.d.ts +2 -0
- package/dist/src/utils/web-element.js +8 -0
- package/dist/src/utils/web-sandbox.d.ts +1 -1
- package/dist/src/utils/web-sandbox.js +2 -3
- package/docs/api-testing/basics.md +90 -0
- package/docs/api-testing/planning.md +57 -0
- package/docs/api-testing/running-tests.md +55 -0
- package/docs/assets/cloud-report.png +0 -0
- package/docs/assets/html-report.png +0 -0
- package/docs/assets/langfuse-trace.png +0 -0
- package/docs/assets/successful-explore-run.png +0 -0
- package/docs/basics/getting-started.md +140 -0
- package/docs/basics/prerequisites.md +63 -0
- package/docs/basics/providers.md +362 -0
- package/docs/basics/running.md +78 -0
- package/docs/contributing/ai-integration-tests.md +57 -0
- package/docs/contributing/contributing.md +90 -0
- package/docs/contributing/demo-videos.md +36 -0
- package/docs/contributing/npm-package.md +138 -0
- package/docs/contributing/observability.md +227 -0
- package/docs/contributing/regression-tests.md +103 -0
- package/docs/contributing/testing.md +95 -0
- package/docs/doc-collection/basics.md +128 -0
- package/docs/doc-collection/crawling.md +67 -0
- package/docs/doc-collection/interactive-mode.md +99 -0
- package/docs/index.json +87 -0
- package/docs/reference/commands.md +997 -0
- package/docs/reference/configuration.md +569 -0
- package/docs/reference/scripting.md +303 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
- package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
- package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
- package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
- package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
- package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
- package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/docs/web-testing/agents.md +158 -0
- package/docs/web-testing/automated-tests.md +134 -0
- package/docs/web-testing/basics.md +91 -0
- package/docs/web-testing/customization.md +131 -0
- package/docs/web-testing/hooks.md +238 -0
- package/docs/web-testing/page-interaction.md +84 -0
- package/docs/web-testing/planner.md +122 -0
- package/docs/web-testing/rerun.md +164 -0
- package/docs/web-testing/researcher.md +380 -0
- package/docs/workflow/agentic-usage.md +233 -0
- package/docs/workflow/application-spec.md +73 -0
- package/docs/workflow/ci.md +202 -0
- package/docs/workflow/knowledge.md +310 -0
- package/docs/workflow/planning-styles.md +67 -0
- package/docs/workflow/reporting.md +133 -0
- package/docs/workflow/test-plans.md +90 -0
- package/package.json +3 -2
- package/rules/navigator/verification-actions.md +20 -0
- package/src/action-result.ts +11 -0
- package/src/action.ts +43 -3
- package/src/ai/captain/web-mode.ts +6 -3
- package/src/ai/captain.ts +3 -0
- package/src/ai/navigator.ts +255 -186
- package/src/ai/pilot.ts +104 -2
- package/src/ai/provider.ts +14 -24
- package/src/ai/rerunner.ts +2 -1
- package/src/ai/researcher/cache.ts +12 -2
- package/src/ai/researcher.ts +3 -2
- package/src/ai/rules.ts +17 -10
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/task-agent.ts +3 -1
- package/src/ai/tester.ts +52 -45
- package/src/ai/tools.ts +136 -37
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +60 -5
- package/src/execution-controller.ts +8 -0
- package/src/explorbot.ts +7 -3
- package/src/explorer.ts +2 -2
- package/src/playwright-recorder.ts +23 -0
- package/src/remote.ts +244 -0
- package/src/reporter.ts +7 -1
- package/src/state-manager.ts +6 -2
- package/src/stats.ts +1 -0
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +65 -45
- package/src/utils/html.ts +1 -0
- package/src/utils/logger.ts +33 -2
- package/src/utils/page-readiness.ts +24 -1
- package/src/utils/url-matcher.ts +3 -0
- package/src/utils/web-element.ts +9 -0
- package/src/utils/web-sandbox.ts +3 -4
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { type AIConfig, ConfigParser, EXPLORBOT_ENV_VARS, type ReporterConfig, configuredModels } from '../config.js';
|
|
5
|
+
import { listSites } from '../global-config.js';
|
|
6
|
+
import { Reporter } from '../reporter.js';
|
|
7
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
8
|
+
import { tag } from '../utils/logger.js';
|
|
9
|
+
import { BaseCommand } from './base-command.js';
|
|
10
|
+
|
|
11
|
+
export class ConfigCommand extends BaseCommand {
|
|
12
|
+
name = 'config';
|
|
13
|
+
description = 'Show models, config file and paths used by this run';
|
|
14
|
+
|
|
15
|
+
async execute(): Promise<void> {
|
|
16
|
+
const parser = ConfigParser.getInstance();
|
|
17
|
+
tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static async summary(options: { config?: string; path?: string; url?: string; json?: boolean } = {}): Promise<string> {
|
|
21
|
+
const parser = ConfigParser.getInstance();
|
|
22
|
+
const [site] = listSites();
|
|
23
|
+
const load = (baseUrl?: string) => parser.loadConfig({ config: options.config, path: options.path, baseUrl });
|
|
24
|
+
|
|
25
|
+
const config = await load(options.url).catch((error) => {
|
|
26
|
+
if (!site) throw error;
|
|
27
|
+
return load(site.url);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static data(config: SummarizedConfig, options: ConfigSummaryOptions = {}): ConfigData {
|
|
34
|
+
let configPath = '';
|
|
35
|
+
if (options.configPath && existsSync(options.configPath)) configPath = options.configPath;
|
|
36
|
+
|
|
37
|
+
const dirs: Record<string, string> = {};
|
|
38
|
+
if (options.root) {
|
|
39
|
+
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
40
|
+
dirs[name] = path.join(options.root, dir);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const env: Record<string, string> = {};
|
|
45
|
+
for (const variable of EXPLORBOT_ENV_VARS) {
|
|
46
|
+
const value = process.env[variable.name];
|
|
47
|
+
if (value) env[variable.name] = value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const models: Record<string, string> = {};
|
|
51
|
+
const providers: Record<string, string> = {};
|
|
52
|
+
for (const [role, model] of Object.entries(configuredModels(config.ai))) {
|
|
53
|
+
models[role] = model.name;
|
|
54
|
+
if (model.provider) providers[role] = model.provider;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
config: configPath,
|
|
59
|
+
url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
|
|
60
|
+
browser: config.playwright?.browser || '',
|
|
61
|
+
headless: !config.playwright?.show,
|
|
62
|
+
dirs,
|
|
63
|
+
models,
|
|
64
|
+
providers,
|
|
65
|
+
integrations: {
|
|
66
|
+
langfuse: !!config.ai?.langfuse?.enabled,
|
|
67
|
+
testomatio: Reporter.resolveEnabled(config.reporter),
|
|
68
|
+
},
|
|
69
|
+
env,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static render(config: SummarizedConfig, options: ConfigSummaryOptions = {}): string {
|
|
74
|
+
const data = ConfigCommand.data(config, options);
|
|
75
|
+
if (options.json) return JSON.stringify(data, null, 2);
|
|
76
|
+
|
|
77
|
+
const lines: string[] = [];
|
|
78
|
+
const section = (title: string, entries: [string, string][]) => {
|
|
79
|
+
if (!entries.length) return;
|
|
80
|
+
const width = Math.max(...entries.map(([label]) => label.length));
|
|
81
|
+
lines.push(chalk.bold(title));
|
|
82
|
+
for (const [label, value] of entries) lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
|
|
83
|
+
lines.push('');
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
87
|
+
if (data.url) general.push(['url', data.url]);
|
|
88
|
+
if (data.browser) {
|
|
89
|
+
let window = 'visible';
|
|
90
|
+
if (data.headless) window = 'headless';
|
|
91
|
+
general.push(['browser', `${data.browser}, ${window}`]);
|
|
92
|
+
}
|
|
93
|
+
for (const [name, dir] of Object.entries(data.dirs)) general.push([name, dir]);
|
|
94
|
+
section('Config', general);
|
|
95
|
+
|
|
96
|
+
const providerWidth = Math.max(0, ...Object.values(data.providers).map((provider) => provider.length));
|
|
97
|
+
const models: [string, string][] = Object.entries(data.models).map(([role, model]) => {
|
|
98
|
+
if (!providerWidth) return [role, model];
|
|
99
|
+
return [role, `${chalk.dim((data.providers[role] || '').padEnd(providerWidth))} ${model}`];
|
|
100
|
+
});
|
|
101
|
+
if (!models.length) models.push(['model', chalk.red(`not configured — run ${getCliName()} init`)]);
|
|
102
|
+
section('Models', models);
|
|
103
|
+
|
|
104
|
+
const integrations: [string, string][] = [];
|
|
105
|
+
if (data.integrations.langfuse) integrations.push(['langfuse', 'traces sent']);
|
|
106
|
+
if (data.integrations.testomatio) integrations.push(['testomatio', 'runs reported']);
|
|
107
|
+
section('Integrations', integrations);
|
|
108
|
+
|
|
109
|
+
const env: [string, string][] = Object.entries(data.env).map(([name, value]) => {
|
|
110
|
+
let shown = value;
|
|
111
|
+
if (shown.length > 60) shown = `${shown.slice(0, 57)}...`;
|
|
112
|
+
return [name, shown];
|
|
113
|
+
});
|
|
114
|
+
section('Environment', env);
|
|
115
|
+
|
|
116
|
+
lines.push(chalk.dim(`Every EXPLORBOT_* variable: ${getCliName()} --help`));
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ConfigSummaryOptions {
|
|
122
|
+
configPath?: string | null;
|
|
123
|
+
root?: string;
|
|
124
|
+
json?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ConfigData {
|
|
128
|
+
config: string;
|
|
129
|
+
url: string;
|
|
130
|
+
browser: string;
|
|
131
|
+
headless: boolean;
|
|
132
|
+
dirs: Record<string, string>;
|
|
133
|
+
models: Record<string, string>;
|
|
134
|
+
providers: Record<string, string>;
|
|
135
|
+
integrations: { langfuse: boolean; testomatio: boolean };
|
|
136
|
+
env: Record<string, string>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface SummarizedConfig {
|
|
140
|
+
ai?: AIConfig;
|
|
141
|
+
playwright?: { url?: string; browser?: string; show?: boolean };
|
|
142
|
+
web?: { url?: string };
|
|
143
|
+
api?: { baseEndpoint?: string };
|
|
144
|
+
dirs?: Record<string, string>;
|
|
145
|
+
reporter?: ReporterConfig;
|
|
146
|
+
}
|
package/src/commands/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AddRuleCommand } from './add-rule-command.js';
|
|
|
3
3
|
import type { BaseCommand } from './base-command.js';
|
|
4
4
|
import { CleanCommand } from './clean-command.js';
|
|
5
5
|
import { CompactCommand } from './compact-command.js';
|
|
6
|
+
import { ConfigCommand } from './config-command.js';
|
|
6
7
|
import { ContextAriaCommand } from './context-aria-command.js';
|
|
7
8
|
import { ContextCommand } from './context-command.js';
|
|
8
9
|
import { ContextDataCommand } from './context-data-command.js';
|
|
@@ -70,6 +71,7 @@ const commandClasses: CommandClass[] = [
|
|
|
70
71
|
RunsCommand,
|
|
71
72
|
RerunCommand,
|
|
72
73
|
StatusCommand,
|
|
74
|
+
ConfigCommand,
|
|
73
75
|
DebugCommand,
|
|
74
76
|
ExitCommand,
|
|
75
77
|
];
|
package/src/config.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import path, { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
4
5
|
import { parseEnv } from 'node:util';
|
|
5
6
|
import dedent from 'dedent';
|
|
6
7
|
import matter from 'gray-matter';
|
|
7
8
|
import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
|
|
8
9
|
import { getCliName } from './utils/cli-name.js';
|
|
9
|
-
import { log } from './utils/logger.js';
|
|
10
|
+
import { log, tag } from './utils/logger.js';
|
|
10
11
|
|
|
11
12
|
export const PROVIDERS: Record<string, ProviderInfo> = {
|
|
12
13
|
openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
@@ -197,6 +198,7 @@ interface HtmlConfig {
|
|
|
197
198
|
interface ActionConfig {
|
|
198
199
|
delay?: number;
|
|
199
200
|
retries?: number;
|
|
201
|
+
timeout?: number;
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
interface ReporterConfig {
|
|
@@ -455,7 +457,15 @@ export class ConfigParser {
|
|
|
455
457
|
}
|
|
456
458
|
|
|
457
459
|
public resolveTargetPath(target?: string): string {
|
|
458
|
-
if (!this.site)
|
|
460
|
+
if (!this.site) {
|
|
461
|
+
const configured = this.config?.playwright?.url || this.config?.web?.url;
|
|
462
|
+
const targetOrigin = target ? URL.parse(target)?.origin : null;
|
|
463
|
+
const baseOrigin = configured ? URL.parse(configured)?.origin : null;
|
|
464
|
+
if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
|
|
465
|
+
tag('warning').log(`Exploring ${targetOrigin} but base URL is ${baseOrigin}. Relative navigation resolves against the base URL — set web.url to ${targetOrigin} to avoid it.`);
|
|
466
|
+
}
|
|
467
|
+
return target || '/';
|
|
468
|
+
}
|
|
459
469
|
if (!target) return this.siteStartPath;
|
|
460
470
|
|
|
461
471
|
const resolved = resolveSiteTarget(target, this.site.url);
|
|
@@ -504,6 +514,7 @@ export class ConfigParser {
|
|
|
504
514
|
model: { modelId: 'test-model', provider: 'test' },
|
|
505
515
|
config: {},
|
|
506
516
|
vision: false,
|
|
517
|
+
langfuse: { enabled: false },
|
|
507
518
|
},
|
|
508
519
|
dirs: {
|
|
509
520
|
knowledge: join(testBaseDir, 'knowledge'),
|
|
@@ -610,17 +621,18 @@ export class ConfigParser {
|
|
|
610
621
|
|
|
611
622
|
private async loadConfigModule(configPath: string): Promise<any> {
|
|
612
623
|
const ext = configPath.split('.').pop();
|
|
624
|
+
const moduleUrl = pathToFileURL(resolve(configPath)).href;
|
|
613
625
|
|
|
614
626
|
if (ext === 'ts') {
|
|
615
627
|
try {
|
|
616
|
-
const module = await import(
|
|
628
|
+
const module = await import(moduleUrl);
|
|
617
629
|
return module;
|
|
618
630
|
} catch (error) {
|
|
619
631
|
const require = (await import('node:module')).createRequire(import.meta.url);
|
|
620
632
|
return require(configPath);
|
|
621
633
|
}
|
|
622
634
|
} else if (ext === 'js' || ext === 'mjs') {
|
|
623
|
-
const module = await import(
|
|
635
|
+
const module = await import(moduleUrl);
|
|
624
636
|
return module;
|
|
625
637
|
} else {
|
|
626
638
|
const content = readFileSync(configPath, 'utf8');
|
|
@@ -639,6 +651,18 @@ export class ConfigParser {
|
|
|
639
651
|
config.playwright.url = options.baseUrl;
|
|
640
652
|
}
|
|
641
653
|
|
|
654
|
+
if (config.ai) {
|
|
655
|
+
const langfuse = config.ai.langfuse;
|
|
656
|
+
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
657
|
+
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
658
|
+
config.ai.langfuse = {
|
|
659
|
+
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
660
|
+
publicKey,
|
|
661
|
+
secretKey,
|
|
662
|
+
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
642
666
|
return config;
|
|
643
667
|
}
|
|
644
668
|
|
|
@@ -751,6 +775,32 @@ export function missingConfigMessage(configFile = 'explorbot.config.js'): string
|
|
|
751
775
|
`;
|
|
752
776
|
}
|
|
753
777
|
|
|
778
|
+
export function modelName(model: unknown): string {
|
|
779
|
+
if (typeof model === 'string') return model;
|
|
780
|
+
return (model as any)?.modelId || (model as any)?.model || 'unknown';
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function modelProvider(model: unknown): string {
|
|
784
|
+
const provider = (model as any)?.provider;
|
|
785
|
+
if (typeof provider === 'string') return provider.split('.')[0];
|
|
786
|
+
if (typeof model === 'string') return model.split('/')[0];
|
|
787
|
+
return '';
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel> {
|
|
791
|
+
if (!ai?.model) return {};
|
|
792
|
+
|
|
793
|
+
const describe = (model: unknown): ConfiguredModel => ({ name: modelName(model), provider: modelProvider(model) });
|
|
794
|
+
|
|
795
|
+
const models: Record<string, ConfiguredModel> = { model: describe(ai.model) };
|
|
796
|
+
if (ai.agenticModel) models.agenticModel = describe(ai.agenticModel);
|
|
797
|
+
if (ai.visionModel) models.visionModel = describe(ai.visionModel);
|
|
798
|
+
for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
|
|
799
|
+
if (agentConfig?.model) models[agent] = describe(agentConfig.model);
|
|
800
|
+
}
|
|
801
|
+
return models;
|
|
802
|
+
}
|
|
803
|
+
|
|
754
804
|
export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
|
|
755
805
|
if (!ai) return;
|
|
756
806
|
|
|
@@ -827,6 +877,11 @@ export async function createModel(provider: string, modelId: string): Promise<an
|
|
|
827
877
|
|
|
828
878
|
type ModelRole = 'model' | 'visionModel' | 'agenticModel';
|
|
829
879
|
|
|
880
|
+
interface ConfiguredModel {
|
|
881
|
+
name: string;
|
|
882
|
+
provider: string;
|
|
883
|
+
}
|
|
884
|
+
|
|
830
885
|
interface ProviderInfo {
|
|
831
886
|
envKey: string;
|
|
832
887
|
load: () => Promise<(modelId: string) => any>;
|
|
@@ -838,4 +893,4 @@ interface EnvVar {
|
|
|
838
893
|
required?: boolean;
|
|
839
894
|
}
|
|
840
895
|
|
|
841
|
-
export type { ModelRole, EnvVar, ProviderInfo };
|
|
896
|
+
export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
|
|
@@ -27,6 +27,14 @@ export class ExecutionController extends EventEmitter {
|
|
|
27
27
|
this.inputCallback = callback;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
hasInputCallback(): boolean {
|
|
31
|
+
return !!this.inputCallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
clearInputCallback(): void {
|
|
35
|
+
this.inputCallback = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
startExecution(): void {
|
|
31
39
|
this.interrupted = false;
|
|
32
40
|
this.abortController = new AbortController();
|
package/src/explorbot.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { createAgentTools } from './ai/tools.ts';
|
|
|
20
20
|
import { ApiClient } from './api/api-client.ts';
|
|
21
21
|
import { RequestStore } from './api/request-store.ts';
|
|
22
22
|
import { loadSpec } from './api/spec-reader.ts';
|
|
23
|
-
import type { ExplorbotConfig } from './config.js';
|
|
23
|
+
import type { ExplorbotConfig, ReporterConfig } from './config.js';
|
|
24
24
|
import { ConfigParser } from './config.ts';
|
|
25
25
|
import { ExperienceTracker } from './experience-tracker.ts';
|
|
26
26
|
import Explorer from './explorer.ts';
|
|
@@ -49,6 +49,7 @@ export interface ExplorBotOptions {
|
|
|
49
49
|
session?: string | boolean;
|
|
50
50
|
instance?: string;
|
|
51
51
|
optionalAi?: boolean;
|
|
52
|
+
reporter?: ReporterConfig;
|
|
52
53
|
attachedBrowser?: Browser;
|
|
53
54
|
applicationSpec?: string;
|
|
54
55
|
}
|
|
@@ -128,6 +129,7 @@ export class ExplorBot {
|
|
|
128
129
|
if (this.provider) return;
|
|
129
130
|
this.config = await this.configParser.loadConfig(this.options);
|
|
130
131
|
if (this.options.session === true) this.options.session = path.join(this.configParser.getOutputDir(), 'session.json');
|
|
132
|
+
if (typeof this.options.session === 'string') this.options.session = path.resolve(this.options.path || '.', this.options.session);
|
|
131
133
|
if (this.options.optionalAi) return this.bootstrapOptionalProvider();
|
|
132
134
|
this.provider = new AIProvider(this.config.ai);
|
|
133
135
|
await this.provider.validateConnection();
|
|
@@ -175,7 +177,7 @@ export class ExplorBot {
|
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
reporter(): Reporter {
|
|
178
|
-
return (this._reporter ||= new Reporter(this.config.reporter, this.stateManager()));
|
|
180
|
+
return (this._reporter ||= new Reporter(this.options.reporter || this.config.reporter, this.stateManager()));
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
requestStore(): RequestStore {
|
|
@@ -485,9 +487,11 @@ export class ExplorBot {
|
|
|
485
487
|
|
|
486
488
|
setCurrentPlan(plan?: Plan): void {
|
|
487
489
|
this.currentPlan = plan;
|
|
488
|
-
if (
|
|
490
|
+
if (!plan) return;
|
|
491
|
+
if (!this.sessionPlans.includes(plan)) {
|
|
489
492
|
this.sessionPlans.push(plan);
|
|
490
493
|
}
|
|
494
|
+
plan.notifyChange();
|
|
491
495
|
}
|
|
492
496
|
|
|
493
497
|
getSessionTests(): Test[] {
|
package/src/explorer.ts
CHANGED
|
@@ -721,11 +721,11 @@ class Explorer {
|
|
|
721
721
|
if (this.observedTestPages.has(page)) return;
|
|
722
722
|
|
|
723
723
|
this.testPageErrorHandler ||= (err: Error) => {
|
|
724
|
-
this._activeTest?.
|
|
724
|
+
this._activeTest?.addObservation(`Console error: ${err.message}`);
|
|
725
725
|
};
|
|
726
726
|
this.testConsoleHandler ||= (msg: any) => {
|
|
727
727
|
if (msg.type() !== 'error') return;
|
|
728
|
-
this._activeTest?.
|
|
728
|
+
this._activeTest?.addObservation(`Console error: ${msg.text()}`);
|
|
729
729
|
};
|
|
730
730
|
this.testDialogHandler ||= (dialog: any) => {
|
|
731
731
|
const dialogType = dialog.type();
|
|
@@ -287,8 +287,31 @@ function formatSelectOption(options: any): string {
|
|
|
287
287
|
return `[${values.map((v) => quote(v)).join(', ')}]`;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
function assertionLocator(target: any): string | null {
|
|
291
|
+
if (typeof target === 'string') return `page.locator(${JSON.stringify(target)})`;
|
|
292
|
+
if (!target || typeof target !== 'object') return null;
|
|
293
|
+
|
|
294
|
+
const role = target.role || target.aria;
|
|
295
|
+
const name = target.text ?? target.name ?? target.label;
|
|
296
|
+
if (role && name) return `page.getByRole(${JSON.stringify(String(role))}, { name: ${JSON.stringify(String(name))} })`;
|
|
297
|
+
if (role) return `page.getByRole(${JSON.stringify(String(role))})`;
|
|
298
|
+
if (name) return `page.getByText(${JSON.stringify(String(name))})`;
|
|
299
|
+
if (target.css) return `page.locator(${JSON.stringify(String(target.css))})`;
|
|
300
|
+
if (target.xpath) return `page.locator(${JSON.stringify(`xpath=${target.xpath}`)})`;
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
290
304
|
export function renderAssertion(assertion: { name: string; args: any[] }): string {
|
|
291
305
|
const args = assertion.args;
|
|
306
|
+
const target = assertionLocator(args[0]);
|
|
307
|
+
|
|
308
|
+
if (target) {
|
|
309
|
+
if (assertion.name === 'seeElement') return `await expect(${target}).toBeVisible();`;
|
|
310
|
+
if (assertion.name === 'dontSeeElement') return `await expect(${target}).toBeHidden();`;
|
|
311
|
+
if (assertion.name === 'seeInField' && args[1] !== undefined) return `await expect(${target}).toHaveValue(${JSON.stringify(String(args[1]))});`;
|
|
312
|
+
if (assertion.name === 'dontSeeInField' && args[1] !== undefined) return `await expect(${target}).not.toHaveValue(${JSON.stringify(String(args[1]))});`;
|
|
313
|
+
}
|
|
314
|
+
|
|
292
315
|
if (assertion.name === 'see' && typeof args[0] === 'string') {
|
|
293
316
|
return `await expect(page).toContainText(${JSON.stringify(args[0])});`;
|
|
294
317
|
}
|
package/src/remote.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import stripAnsi from 'strip-ansi';
|
|
3
|
+
import { type ActivityEntry, addActivityListener } from './activity.ts';
|
|
4
|
+
import { executionController } from './execution-controller.ts';
|
|
5
|
+
import { type LogDestination, type TaggedLogEntry, addDestination } from './utils/logger.ts';
|
|
6
|
+
|
|
7
|
+
const QUEUE_CAP = 1000;
|
|
8
|
+
const CONTENT_CAP = 8000;
|
|
9
|
+
const RECONNECT_BASE_MS = 500;
|
|
10
|
+
const RECONNECT_MAX_MS = 10_000;
|
|
11
|
+
const ASK_TIMEOUT_MS = 15 * 60_000;
|
|
12
|
+
const FLUSH_TIMEOUT_MS = 3000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Streams a run to a remote UI over one WebSocket, dialling out so a child
|
|
16
|
+
* process and a CI bot are the same case.
|
|
17
|
+
*
|
|
18
|
+
* It **is** a LogDestination — that is the whole integration on the logger's
|
|
19
|
+
* side, messages and `data` alike — and it answers asks by installing itself
|
|
20
|
+
* as the execution controller's input callback. Nothing else in explorbot
|
|
21
|
+
* knows it exists.
|
|
22
|
+
*/
|
|
23
|
+
export class Remote implements LogDestination {
|
|
24
|
+
private url: string | null = null;
|
|
25
|
+
private socket: WebSocket | null = null;
|
|
26
|
+
private queue: Frame[] = [];
|
|
27
|
+
private reconnectDelay = RECONNECT_BASE_MS;
|
|
28
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
29
|
+
private asks = new Map<string, (value: string | null) => void>();
|
|
30
|
+
private askCounter = 0;
|
|
31
|
+
private lastActivity: string | null = null;
|
|
32
|
+
|
|
33
|
+
registerOption(program: Command): void {
|
|
34
|
+
program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
|
|
35
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
36
|
+
const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
|
|
37
|
+
if (!url) return;
|
|
38
|
+
this.attach(String(url), this.commandPath(actionCommand));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
attach(url: string, command: string): void {
|
|
43
|
+
if (this.url) return;
|
|
44
|
+
this.url = url;
|
|
45
|
+
this.connect();
|
|
46
|
+
|
|
47
|
+
this.send('hello', { command, cwd: process.cwd(), pid: process.pid });
|
|
48
|
+
addDestination(this);
|
|
49
|
+
executionController.setInputCallback((prompt) => this.ask(prompt));
|
|
50
|
+
addActivityListener((activity) => this.reportActivity(activity));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
isAttached(): boolean {
|
|
54
|
+
return !!this.url;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
send(type: string, data: Record<string, unknown> = {}): void {
|
|
58
|
+
if (!this.url) return;
|
|
59
|
+
const frame: Frame = { type, ts: Date.now(), ...data };
|
|
60
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
61
|
+
this.push(frame);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.queue.push(frame);
|
|
65
|
+
if (this.queue.length > QUEUE_CAP) this.queue.splice(0, this.queue.length - QUEUE_CAP);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ask(prompt: string): Promise<string | null> {
|
|
69
|
+
if (!this.url) return Promise.resolve(null);
|
|
70
|
+
this.askCounter++;
|
|
71
|
+
const askId = `ask-${this.askCounter}`;
|
|
72
|
+
this.send('ask', { askId, prompt });
|
|
73
|
+
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
this.asks.delete(askId);
|
|
77
|
+
resolve(null);
|
|
78
|
+
}, ASK_TIMEOUT_MS);
|
|
79
|
+
this.asks.set(askId, (value) => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
resolve(value);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async close(exitCode: number): Promise<void> {
|
|
87
|
+
if (!this.url) return;
|
|
88
|
+
this.send('result', { ok: exitCode === 0, exitCode });
|
|
89
|
+
await this.flush();
|
|
90
|
+
|
|
91
|
+
this.url = null;
|
|
92
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
93
|
+
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
94
|
+
// route them into a closed socket and park them until the ask times out.
|
|
95
|
+
executionController.clearInputCallback();
|
|
96
|
+
for (const resolve of this.asks.values()) resolve(null);
|
|
97
|
+
this.asks.clear();
|
|
98
|
+
this.queue = [];
|
|
99
|
+
this.socket?.close();
|
|
100
|
+
this.socket = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
isEnabled(): boolean {
|
|
104
|
+
return this.isAttached();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The entry as the logger already built it, so the `LogType` reaches the UI
|
|
109
|
+
* as the frame's level and each kind is styled there instead of being scraped
|
|
110
|
+
* back out of flattened text.
|
|
111
|
+
*/
|
|
112
|
+
write(entry: TaggedLogEntry): void {
|
|
113
|
+
if (entry.type === 'html') return;
|
|
114
|
+
if (entry.type === 'data') {
|
|
115
|
+
const [kind, payload] = entry.originalArgs || [];
|
|
116
|
+
this.send(String(kind), payload);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let content = stripAnsi(entry.content ?? '');
|
|
120
|
+
if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
|
|
121
|
+
this.send('log', {
|
|
122
|
+
level: entry.type,
|
|
123
|
+
content,
|
|
124
|
+
namespace: entry.namespace,
|
|
125
|
+
error: this.errorOf(entry.originalArgs),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private connect(): void {
|
|
130
|
+
if (!this.url) return;
|
|
131
|
+
|
|
132
|
+
let opening: WebSocket;
|
|
133
|
+
try {
|
|
134
|
+
opening = new WebSocket(this.url);
|
|
135
|
+
} catch {
|
|
136
|
+
this.reconnect();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.socket = opening;
|
|
140
|
+
|
|
141
|
+
opening.addEventListener('open', () => {
|
|
142
|
+
this.reconnectDelay = RECONNECT_BASE_MS;
|
|
143
|
+
this.drain();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
opening.addEventListener('message', (event: MessageEvent) => this.receive(String(event.data)));
|
|
147
|
+
|
|
148
|
+
opening.addEventListener('close', () => {
|
|
149
|
+
if (this.socket !== opening) return;
|
|
150
|
+
this.socket = null;
|
|
151
|
+
this.reconnect();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// A failed connection always closes too, which is where the retry is scheduled.
|
|
155
|
+
opening.addEventListener('error', () => {});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private reconnect(): void {
|
|
159
|
+
if (!this.url || this.reconnectTimer) return;
|
|
160
|
+
const delay = this.reconnectDelay;
|
|
161
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
162
|
+
this.reconnectTimer = setTimeout(() => {
|
|
163
|
+
this.reconnectTimer = null;
|
|
164
|
+
this.connect();
|
|
165
|
+
}, delay);
|
|
166
|
+
this.reconnectTimer.unref?.();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async flush(): Promise<void> {
|
|
170
|
+
const deadline = Date.now() + FLUSH_TIMEOUT_MS;
|
|
171
|
+
while (Date.now() < deadline) {
|
|
172
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
173
|
+
this.drain();
|
|
174
|
+
if (!this.queue.length && !this.socket.bufferedAmount) return;
|
|
175
|
+
}
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private drain(): void {
|
|
181
|
+
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
182
|
+
const pending = this.queue;
|
|
183
|
+
this.queue = [];
|
|
184
|
+
for (const frame of pending) this.push(frame);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private push(frame: Frame): void {
|
|
188
|
+
try {
|
|
189
|
+
this.socket?.send(JSON.stringify(frame));
|
|
190
|
+
} catch {
|
|
191
|
+
this.queue.push(frame);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private receive(raw: string): void {
|
|
196
|
+
let frame: Frame;
|
|
197
|
+
try {
|
|
198
|
+
frame = JSON.parse(raw);
|
|
199
|
+
} catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (frame.type === 'interrupt') {
|
|
203
|
+
executionController.interrupt();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const resolve = this.asks.get(String(frame.askId));
|
|
207
|
+
if (!resolve) return;
|
|
208
|
+
this.asks.delete(String(frame.askId));
|
|
209
|
+
resolve((frame.value as string | null) ?? null);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A new listener is primed with the current activity, which at attach time is
|
|
213
|
+
* nothing — so skip repeats, the priming null included. */
|
|
214
|
+
private reportActivity(activity: ActivityEntry | null): void {
|
|
215
|
+
const message = activity?.message ?? null;
|
|
216
|
+
if (message === this.lastActivity) return;
|
|
217
|
+
this.lastActivity = message;
|
|
218
|
+
this.send('activity', { message, kind: activity?.type });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private errorOf(args: any[] | undefined): string | undefined {
|
|
222
|
+
const failure = args?.[1] ?? args?.[0]?.error;
|
|
223
|
+
if (!failure) return undefined;
|
|
224
|
+
if (typeof failure === 'string') return failure;
|
|
225
|
+
if (typeof failure.message === 'string') return failure.message;
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private commandPath(command: Command): string {
|
|
230
|
+
const parts: string[] = [];
|
|
231
|
+
let node: Command | null = command;
|
|
232
|
+
while (node) {
|
|
233
|
+
parts.unshift(node.name());
|
|
234
|
+
node = node.parent;
|
|
235
|
+
}
|
|
236
|
+
return parts.slice(1).join(' ') || parts.join(' ');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export const remote = new Remote();
|
|
241
|
+
|
|
242
|
+
/** Whatever the run wants to say. Not a schema — the UI renders what it knows
|
|
243
|
+
* and ignores the rest, so either side can start sending more at any time. */
|
|
244
|
+
type Frame = { type: string; ts: number; [key: string]: unknown };
|
package/src/reporter.ts
CHANGED
|
@@ -283,13 +283,19 @@ export class Reporter {
|
|
|
283
283
|
|
|
284
284
|
debugLog(testData);
|
|
285
285
|
|
|
286
|
-
await this.
|
|
286
|
+
await this.reportTestData(status, testData);
|
|
287
287
|
debugLog(`Test reported: ${test.scenario} - ${status}`);
|
|
288
288
|
} catch (error) {
|
|
289
289
|
debugLog('Failed to report test:', error);
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
async reportTestData(status: string | null, testData: Record<string, unknown>): Promise<void> {
|
|
294
|
+
await this.startRun();
|
|
295
|
+
if (!this.isRunStarted) return;
|
|
296
|
+
await this.client.addTestRun(status, testData);
|
|
297
|
+
}
|
|
298
|
+
|
|
293
299
|
async finishRun(): Promise<void> {
|
|
294
300
|
if (!this.isRunStarted) {
|
|
295
301
|
return;
|