explorbot 0.2.2 → 0.2.4
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 +59 -38
- package/boat/api-tester/src/apibot.ts +4 -2
- package/boat/api-tester/src/cli.ts +2 -2
- package/boat/api-tester/src/config.ts +43 -10
- 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 +1 -0
- package/boat/doc-collector/src/config.ts +4 -2
- package/boat/doc-collector/src/docs-renderer.ts +18 -4
- package/boat/doc-collector/src/state-diagram.ts +61 -14
- package/boat/prima/bin/prima-cli.ts +5 -0
- package/boat/prima/package.json +16 -0
- package/boat/prima/src/activity-line.ts +33 -0
- package/boat/prima/src/cli.ts +251 -0
- package/boat/prima/src/envelope.ts +169 -0
- package/boat/prima/src/prima.ts +1124 -0
- package/boat/prima/src/pw-parser.ts +27 -0
- package/boat/prima/src/pw-registry.ts +74 -0
- package/boat/prima/src/session-log.ts +126 -0
- package/dist/bin/explorbot-cli.js +51 -32
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
- package/dist/boat/api-tester/src/apibot.js +3 -2
- package/dist/boat/api-tester/src/cli.js +2 -2
- package/dist/boat/api-tester/src/config.js +40 -10
- 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 +1 -0
- package/dist/boat/doc-collector/src/config.js +4 -2
- package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
- package/dist/boat/doc-collector/src/state-diagram.js +57 -13
- package/dist/boat/prima/bin/prima-cli.js +4 -0
- package/dist/boat/prima/src/activity-line.js +30 -0
- package/dist/boat/prima/src/cli.js +220 -0
- package/dist/boat/prima/src/envelope.js +142 -0
- package/dist/boat/prima/src/prima.js +1031 -0
- package/dist/boat/prima/src/pw-parser.js +30 -0
- package/dist/boat/prima/src/pw-registry.js +65 -0
- package/dist/boat/prima/src/session-log.js +108 -0
- package/dist/models.json +3 -0
- package/dist/package.json +8 -3
- 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 +7 -2
- package/dist/src/action.js +41 -6
- package/dist/src/ai/captain/mixin.js +3 -4
- package/dist/src/ai/captain/web-mode.js +7 -4
- package/dist/src/ai/captain.js +2 -0
- package/dist/src/ai/navigator.d.ts +11 -0
- package/dist/src/ai/navigator.js +21 -12
- package/dist/src/ai/pilot.d.ts +4 -0
- package/dist/src/ai/pilot.js +48 -2
- package/dist/src/ai/planner.d.ts +1 -0
- package/dist/src/ai/planner.js +6 -0
- package/dist/src/ai/provider.js +2 -2
- package/dist/src/ai/rerunner.js +2 -1
- package/dist/src/ai/researcher.js +2 -2
- package/dist/src/ai/rules.js +17 -10
- package/dist/src/ai/task-agent.js +5 -2
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +33 -20
- package/dist/src/ai/tools.d.ts +14 -0
- package/dist/src/ai/tools.js +113 -36
- package/dist/src/application-spec-contract.d.ts +8 -0
- package/dist/src/application-spec-contract.js +8 -0
- package/dist/src/application-spec.d.ts +15 -0
- package/dist/src/application-spec.js +71 -0
- package/dist/src/browser-server.d.ts +12 -6
- package/dist/src/browser-server.js +74 -19
- package/dist/src/commands/clean-command.js +2 -7
- package/dist/src/commands/init-command.d.ts +5 -0
- package/dist/src/commands/init-command.js +119 -1
- package/dist/src/commands/navigate-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/sites-command.d.ts +6 -0
- package/dist/src/commands/sites-command.js +23 -0
- package/dist/src/components/InitWizard.d.ts +10 -0
- package/dist/src/components/InitWizard.js +133 -0
- package/dist/src/components/InputReadline.d.ts +1 -0
- package/dist/src/components/InputReadline.js +7 -4
- package/dist/src/config.d.ts +25 -5
- package/dist/src/config.js +158 -40
- package/dist/src/execution-controller.d.ts +2 -0
- package/dist/src/execution-controller.js +6 -0
- package/dist/src/explorbot.d.ts +11 -1
- package/dist/src/explorbot.js +27 -6
- package/dist/src/explorer.d.ts +4 -1
- package/dist/src/explorer.js +42 -9
- package/dist/src/global-config.d.ts +22 -0
- package/dist/src/global-config.js +117 -0
- package/dist/src/knowledge-tracker.d.ts +5 -1
- package/dist/src/knowledge-tracker.js +14 -1
- package/dist/src/playwright-recorder.js +30 -0
- package/dist/src/remote.d.ts +54 -0
- package/dist/src/remote.js +229 -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/stats.d.ts +1 -0
- package/dist/src/stats.js +1 -0
- package/dist/src/test-plan.d.ts +2 -0
- package/dist/src/test-plan.js +7 -0
- package/dist/src/utils/aria.d.ts +2 -8
- package/dist/src/utils/aria.js +69 -40
- package/dist/src/utils/cli-name.js +6 -2
- package/dist/src/utils/html.js +1 -0
- package/dist/src/utils/logger.d.ts +6 -0
- package/dist/src/utils/logger.js +24 -0
- package/dist/src/utils/page-readiness.js +18 -1
- package/dist/src/utils/test-files.js +1 -2
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +12 -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 +86 -0
- package/docs/reference/commands.md +994 -0
- package/docs/reference/configuration.md +569 -0
- package/docs/reference/scripting.md +303 -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/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/models.json +3 -0
- package/package.json +8 -3
- package/rules/navigator/verification-actions.md +20 -0
- package/src/action-result.ts +11 -0
- package/src/action.ts +47 -7
- package/src/ai/captain/mixin.ts +3 -3
- package/src/ai/captain/web-mode.ts +7 -4
- package/src/ai/captain.ts +3 -0
- package/src/ai/navigator.ts +26 -15
- package/src/ai/pilot.ts +53 -2
- package/src/ai/planner.ts +7 -0
- package/src/ai/provider.ts +2 -4
- package/src/ai/rerunner.ts +2 -1
- package/src/ai/researcher.ts +2 -2
- package/src/ai/rules.ts +17 -10
- package/src/ai/task-agent.ts +4 -2
- package/src/ai/tester.ts +32 -18
- package/src/ai/tools.ts +132 -36
- package/src/application-spec-contract.ts +10 -0
- package/src/application-spec.ts +87 -0
- package/src/browser-server.ts +74 -19
- package/src/commands/clean-command.ts +1 -6
- package/src/commands/init-command.ts +146 -1
- package/src/commands/navigate-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/sites-command.ts +27 -0
- package/src/components/InitWizard.tsx +166 -0
- package/src/components/InputReadline.tsx +8 -4
- package/src/config.ts +177 -43
- package/src/execution-controller.ts +8 -0
- package/src/explorbot.ts +34 -7
- package/src/explorer.ts +47 -9
- package/src/global-config.ts +148 -0
- package/src/knowledge-tracker.ts +17 -1
- package/src/playwright-recorder.ts +23 -0
- package/src/remote.ts +238 -0
- package/src/reporter.ts +7 -1
- package/src/state-manager.ts +2 -1
- package/src/stats.ts +1 -0
- package/src/test-plan.ts +9 -0
- package/src/utils/aria.ts +65 -45
- package/src/utils/cli-name.ts +5 -2
- package/src/utils/html.ts +1 -0
- package/src/utils/logger.ts +24 -1
- package/src/utils/page-readiness.ts +24 -1
- package/src/utils/test-files.ts +1 -2
- package/src/utils/url-matcher.ts +13 -0
- package/src/utils/web-element.ts +9 -0
- package/src/utils/web-sandbox.ts +3 -4
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
|
|
6
|
+
const SITE_DIRS = ['knowledge', 'experience', 'output'];
|
|
7
|
+
|
|
8
|
+
export function globalDir(): string {
|
|
9
|
+
return join(os.homedir(), '.explorbot');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function globalEnvPath(): string {
|
|
13
|
+
return join(globalDir(), '.env');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function globalConfigPath(): string {
|
|
17
|
+
return join(globalDir(), 'config.js');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findGlobalConfig(): string | null {
|
|
21
|
+
for (const name of GLOBAL_CONFIG_NAMES) {
|
|
22
|
+
const fullPath = join(globalDir(), name);
|
|
23
|
+
if (existsSync(fullPath)) return fullPath;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isGlobalConfigPath(configPath: string): boolean {
|
|
29
|
+
return GLOBAL_CONFIG_NAMES.some((name) => join(globalDir(), name) === configPath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function sitesDir(): string {
|
|
33
|
+
return join(globalDir(), 'sites');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function siteFolderName(url: string): string {
|
|
37
|
+
return new URL(url).host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function listSites(): SiteRecord[] {
|
|
41
|
+
if (!existsSync(sitesDir())) return [];
|
|
42
|
+
|
|
43
|
+
return readdirSync(sitesDir(), { withFileTypes: true })
|
|
44
|
+
.filter((entry) => entry.isDirectory())
|
|
45
|
+
.map((entry) => readSite(entry.name))
|
|
46
|
+
.filter((site): site is SiteRecord => !!site)
|
|
47
|
+
.sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function registerSite(baseUrl: string): SiteRecord {
|
|
51
|
+
const folder = siteFolderName(baseUrl);
|
|
52
|
+
const dir = join(sitesDir(), folder);
|
|
53
|
+
for (const subDir of SITE_DIRS) {
|
|
54
|
+
mkdirSync(join(dir, subDir), { recursive: true, mode: 0o700 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
const meta = {
|
|
59
|
+
url: baseUrl,
|
|
60
|
+
createdAt: readSite(folder)?.createdAt || now,
|
|
61
|
+
lastRunAt: now,
|
|
62
|
+
};
|
|
63
|
+
writeFileSync(join(dir, 'site.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
|
|
64
|
+
|
|
65
|
+
return { folder, dir, ...meta };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveSiteTarget(target?: string, defaultBaseUrl?: string): SiteTarget {
|
|
69
|
+
const raw = (target || process.env.EXPLORBOT_URL || defaultBaseUrl || '').trim();
|
|
70
|
+
if (!raw) {
|
|
71
|
+
throw new Error(withSites('No site to explore. Pass a URL to the command or set EXPLORBOT_URL.'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
|
75
|
+
const url = new URL(raw);
|
|
76
|
+
return { baseUrl: url.origin, path: `${url.pathname}${url.search}${url.hash}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (raw.startsWith('/')) {
|
|
80
|
+
const base = defaultBaseUrl || process.env.EXPLORBOT_URL;
|
|
81
|
+
if (!base) {
|
|
82
|
+
throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
|
|
83
|
+
}
|
|
84
|
+
return { baseUrl: new URL(base).origin, path: raw };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let reference = raw;
|
|
88
|
+
let path = '/';
|
|
89
|
+
const separator = raw.indexOf('/');
|
|
90
|
+
if (separator > -1) {
|
|
91
|
+
reference = raw.slice(0, separator);
|
|
92
|
+
path = raw.slice(separator);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const site = findSite(reference);
|
|
96
|
+
if (!site) {
|
|
97
|
+
throw new Error(withSites(`Unknown site "${reference}".`));
|
|
98
|
+
}
|
|
99
|
+
return { baseUrl: site.url, path };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readSite(folder: string): SiteRecord | null {
|
|
103
|
+
const dir = join(sitesDir(), folder);
|
|
104
|
+
const metaPath = join(dir, 'site.json');
|
|
105
|
+
if (!existsSync(metaPath)) return null;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
109
|
+
new URL(meta.url);
|
|
110
|
+
return { folder, dir, url: meta.url, createdAt: meta.createdAt, lastRunAt: meta.lastRunAt };
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findSite(reference: string): SiteRecord | null {
|
|
117
|
+
const normalized = reference.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
|
|
118
|
+
const sites = listSites();
|
|
119
|
+
const byFolder = sites.find((site) => site.folder === normalized);
|
|
120
|
+
if (byFolder) return byFolder;
|
|
121
|
+
return sites.find((site) => new URL(site.url).host.toLowerCase() === reference.toLowerCase()) || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function withSites(message: string): string {
|
|
125
|
+
const lines = [message];
|
|
126
|
+
const sites = listSites();
|
|
127
|
+
if (sites.length) {
|
|
128
|
+
lines.push('Registered sites:');
|
|
129
|
+
for (const site of sites) lines.push(` ${site.folder} → ${site.url}`);
|
|
130
|
+
}
|
|
131
|
+
lines.push('Explore a new site by passing its full URL, e.g. https://app.example.com/login');
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface SiteRecord {
|
|
136
|
+
folder: string;
|
|
137
|
+
dir: string;
|
|
138
|
+
url: string;
|
|
139
|
+
createdAt: string;
|
|
140
|
+
lastRunAt: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface SiteTarget {
|
|
144
|
+
baseUrl: string;
|
|
145
|
+
path: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type { SiteRecord, SiteTarget };
|
package/src/knowledge-tracker.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import dedent from 'dedent';
|
|
4
4
|
import matter from 'gray-matter';
|
|
5
5
|
import { ActionResult } from './action-result.js';
|
|
6
|
+
import { ApplicationSpec } from './application-spec.ts';
|
|
6
7
|
import { ConfigParser } from './config.js';
|
|
7
8
|
import { getCliName } from './utils/cli-name.ts';
|
|
8
9
|
import { createDebug, pluralize, tag } from './utils/logger.js';
|
|
@@ -24,8 +25,9 @@ export class KnowledgeTracker {
|
|
|
24
25
|
private knowledgeDir: string;
|
|
25
26
|
private knowledgeFiles: Knowledge[] = [];
|
|
26
27
|
private isLoaded = false;
|
|
28
|
+
private applicationSpec?: ApplicationSpec;
|
|
27
29
|
|
|
28
|
-
constructor() {
|
|
30
|
+
constructor(applicationSpecPath?: string) {
|
|
29
31
|
const configParser = ConfigParser.getInstance();
|
|
30
32
|
const config = configParser.getConfig();
|
|
31
33
|
this.knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
|
|
@@ -33,6 +35,12 @@ export class KnowledgeTracker {
|
|
|
33
35
|
if (!existsSync(this.knowledgeDir)) {
|
|
34
36
|
mkdirSync(this.knowledgeDir, { recursive: true });
|
|
35
37
|
}
|
|
38
|
+
|
|
39
|
+
const specPath = applicationSpecPath || config.dirs?.spec;
|
|
40
|
+
if (specPath) {
|
|
41
|
+
this.applicationSpec = new ApplicationSpec(specPath);
|
|
42
|
+
tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
|
|
43
|
+
}
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
private loadKnowledgeFiles(): void {
|
|
@@ -79,6 +87,14 @@ export class KnowledgeTracker {
|
|
|
79
87
|
`;
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
renderRelevantContext(state: ActionResult): string {
|
|
91
|
+
return [this.renderRelevantKnowledge(state), this.renderApplicationSpec(state)].filter(Boolean).join('\n\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
renderApplicationSpec(state: ActionResult): string {
|
|
95
|
+
return this.applicationSpec?.renderFor(state) || '';
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
addKnowledge(urlPattern: string, description: string): { filename: string; filePath: string; isNewFile: boolean } {
|
|
83
99
|
const configParser = ConfigParser.getInstance();
|
|
84
100
|
const configPath = configParser.getConfigPath();
|
|
@@ -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,238 @@
|
|
|
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 — and it answers asks by installing itself as the execution
|
|
20
|
+
* controller's input callback. Nothing else in explorbot knows it exists.
|
|
21
|
+
*/
|
|
22
|
+
export class Remote implements LogDestination {
|
|
23
|
+
private url: string | null = null;
|
|
24
|
+
private socket: WebSocket | null = null;
|
|
25
|
+
private queue: Frame[] = [];
|
|
26
|
+
private reconnectDelay = RECONNECT_BASE_MS;
|
|
27
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
28
|
+
private asks = new Map<string, (value: string | null) => void>();
|
|
29
|
+
private askCounter = 0;
|
|
30
|
+
private lastActivity: string | null = null;
|
|
31
|
+
|
|
32
|
+
registerOption(program: Command): void {
|
|
33
|
+
program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
|
|
34
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
35
|
+
const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
|
|
36
|
+
if (!url) return;
|
|
37
|
+
this.attach(String(url), this.commandPath(actionCommand));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
attach(url: string, command: string): void {
|
|
42
|
+
if (this.url) return;
|
|
43
|
+
this.url = url;
|
|
44
|
+
this.connect();
|
|
45
|
+
|
|
46
|
+
this.send('hello', { command, cwd: process.cwd(), pid: process.pid });
|
|
47
|
+
addDestination(this);
|
|
48
|
+
executionController.setInputCallback((prompt) => this.ask(prompt));
|
|
49
|
+
addActivityListener((activity) => this.reportActivity(activity));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isAttached(): boolean {
|
|
53
|
+
return !!this.url;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
send(type: string, data: Record<string, unknown> = {}): void {
|
|
57
|
+
if (!this.url) return;
|
|
58
|
+
const frame: Frame = { type, ts: Date.now(), ...data };
|
|
59
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
60
|
+
this.push(frame);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.queue.push(frame);
|
|
64
|
+
if (this.queue.length > QUEUE_CAP) this.queue.splice(0, this.queue.length - QUEUE_CAP);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ask(prompt: string): Promise<string | null> {
|
|
68
|
+
if (!this.url) return Promise.resolve(null);
|
|
69
|
+
this.askCounter++;
|
|
70
|
+
const askId = `ask-${this.askCounter}`;
|
|
71
|
+
this.send('ask', { askId, prompt });
|
|
72
|
+
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
this.asks.delete(askId);
|
|
76
|
+
resolve(null);
|
|
77
|
+
}, ASK_TIMEOUT_MS);
|
|
78
|
+
this.asks.set(askId, (value) => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
resolve(value);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async close(exitCode: number): Promise<void> {
|
|
86
|
+
if (!this.url) return;
|
|
87
|
+
this.send('result', { ok: exitCode === 0, exitCode });
|
|
88
|
+
await this.flush();
|
|
89
|
+
|
|
90
|
+
this.url = null;
|
|
91
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
92
|
+
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
93
|
+
// route them into a closed socket and park them until the ask times out.
|
|
94
|
+
executionController.clearInputCallback();
|
|
95
|
+
for (const resolve of this.asks.values()) resolve(null);
|
|
96
|
+
this.asks.clear();
|
|
97
|
+
this.queue = [];
|
|
98
|
+
this.socket?.close();
|
|
99
|
+
this.socket = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
isEnabled(): boolean {
|
|
103
|
+
return this.isAttached();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The entry as the logger already built it, so the `LogType` reaches the UI
|
|
108
|
+
* as the frame's level and each kind is styled there instead of being scraped
|
|
109
|
+
* back out of flattened text.
|
|
110
|
+
*/
|
|
111
|
+
write(entry: TaggedLogEntry): void {
|
|
112
|
+
if (entry.type === 'html') return;
|
|
113
|
+
let content = stripAnsi(entry.content ?? '');
|
|
114
|
+
if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
|
|
115
|
+
this.send('log', {
|
|
116
|
+
level: entry.type,
|
|
117
|
+
content,
|
|
118
|
+
namespace: entry.namespace,
|
|
119
|
+
error: this.errorOf(entry.originalArgs),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private connect(): void {
|
|
124
|
+
if (!this.url) return;
|
|
125
|
+
|
|
126
|
+
let opening: WebSocket;
|
|
127
|
+
try {
|
|
128
|
+
opening = new WebSocket(this.url);
|
|
129
|
+
} catch {
|
|
130
|
+
this.reconnect();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.socket = opening;
|
|
134
|
+
|
|
135
|
+
opening.addEventListener('open', () => {
|
|
136
|
+
this.reconnectDelay = RECONNECT_BASE_MS;
|
|
137
|
+
this.drain();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
opening.addEventListener('message', (event: MessageEvent) => this.receive(String(event.data)));
|
|
141
|
+
|
|
142
|
+
opening.addEventListener('close', () => {
|
|
143
|
+
if (this.socket !== opening) return;
|
|
144
|
+
this.socket = null;
|
|
145
|
+
this.reconnect();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// A failed connection always closes too, which is where the retry is scheduled.
|
|
149
|
+
opening.addEventListener('error', () => {});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private reconnect(): void {
|
|
153
|
+
if (!this.url || this.reconnectTimer) return;
|
|
154
|
+
const delay = this.reconnectDelay;
|
|
155
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
156
|
+
this.reconnectTimer = setTimeout(() => {
|
|
157
|
+
this.reconnectTimer = null;
|
|
158
|
+
this.connect();
|
|
159
|
+
}, delay);
|
|
160
|
+
this.reconnectTimer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async flush(): Promise<void> {
|
|
164
|
+
const deadline = Date.now() + FLUSH_TIMEOUT_MS;
|
|
165
|
+
while (Date.now() < deadline) {
|
|
166
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
167
|
+
this.drain();
|
|
168
|
+
if (!this.queue.length && !this.socket.bufferedAmount) return;
|
|
169
|
+
}
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private drain(): void {
|
|
175
|
+
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
176
|
+
const pending = this.queue;
|
|
177
|
+
this.queue = [];
|
|
178
|
+
for (const frame of pending) this.push(frame);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private push(frame: Frame): void {
|
|
182
|
+
try {
|
|
183
|
+
this.socket?.send(JSON.stringify(frame));
|
|
184
|
+
} catch {
|
|
185
|
+
this.queue.push(frame);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private receive(raw: string): void {
|
|
190
|
+
let frame: Frame;
|
|
191
|
+
try {
|
|
192
|
+
frame = JSON.parse(raw);
|
|
193
|
+
} catch {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (frame.type === 'interrupt') {
|
|
197
|
+
executionController.interrupt();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const resolve = this.asks.get(String(frame.askId));
|
|
201
|
+
if (!resolve) return;
|
|
202
|
+
this.asks.delete(String(frame.askId));
|
|
203
|
+
resolve((frame.value as string | null) ?? null);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A new listener is primed with the current activity, which at attach time is
|
|
207
|
+
* nothing — so skip repeats, the priming null included. */
|
|
208
|
+
private reportActivity(activity: ActivityEntry | null): void {
|
|
209
|
+
const message = activity?.message ?? null;
|
|
210
|
+
if (message === this.lastActivity) return;
|
|
211
|
+
this.lastActivity = message;
|
|
212
|
+
this.send('activity', { message, kind: activity?.type });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private errorOf(args: any[] | undefined): string | undefined {
|
|
216
|
+
const failure = args?.[1] ?? args?.[0]?.error;
|
|
217
|
+
if (!failure) return undefined;
|
|
218
|
+
if (typeof failure === 'string') return failure;
|
|
219
|
+
if (typeof failure.message === 'string') return failure.message;
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private commandPath(command: Command): string {
|
|
224
|
+
const parts: string[] = [];
|
|
225
|
+
let node: Command | null = command;
|
|
226
|
+
while (node) {
|
|
227
|
+
parts.unshift(node.name());
|
|
228
|
+
node = node.parent;
|
|
229
|
+
}
|
|
230
|
+
return parts.slice(1).join(' ') || parts.join(' ');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const remote = new Remote();
|
|
235
|
+
|
|
236
|
+
/** Whatever the run wants to say. Not a schema — the UI renders what it knows
|
|
237
|
+
* and ignores the rest, so either side can start sending more at any time. */
|
|
238
|
+
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;
|
package/src/state-manager.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ActionResult } from './action-result.js';
|
|
1
|
+
import { type FocusedElement, ActionResult } from './action-result.js';
|
|
2
2
|
import type { ExperienceTracker } from './experience-tracker.js';
|
|
3
3
|
import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
|
|
4
4
|
import { detectFocusArea } from './utils/aria.js';
|
|
@@ -46,6 +46,7 @@ export interface WebPageState {
|
|
|
46
46
|
h4?: string;
|
|
47
47
|
ariaSnapshot?: string | null;
|
|
48
48
|
ariaSnapshotFile?: string;
|
|
49
|
+
focusedElement?: FocusedElement | null;
|
|
49
50
|
links?: Link[];
|
|
50
51
|
verifications?: Record<string, boolean>;
|
|
51
52
|
}
|
package/src/stats.ts
CHANGED
|
@@ -17,6 +17,7 @@ export class Stats {
|
|
|
17
17
|
static plans = 0;
|
|
18
18
|
static mode?: ExplorbotMode;
|
|
19
19
|
static focus?: string;
|
|
20
|
+
static visionDisabled = false;
|
|
20
21
|
static models: Record<string, TokenUsage> = {};
|
|
21
22
|
|
|
22
23
|
static recordTokens(_agent: string, model: string, usage: TokenUsage): void {
|
package/src/test-plan.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface Note {
|
|
|
27
27
|
endTime: number;
|
|
28
28
|
screenshot?: string;
|
|
29
29
|
log?: string;
|
|
30
|
+
observation?: boolean;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export class ActiveNote {
|
|
@@ -131,6 +132,14 @@ export class Task {
|
|
|
131
132
|
this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
addObservation(message: string): void {
|
|
136
|
+
const isDuplicate = Object.values(this.notes).some((note) => note.message === message);
|
|
137
|
+
if (isDuplicate) return;
|
|
138
|
+
|
|
139
|
+
const now = performance.now();
|
|
140
|
+
this.notes[`${now}_${this.timestampCounter++}`] = { message, status: TestResult.FAILED, startTime: now, endTime: now, observation: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
134
143
|
addUrlNote(state: UrlNoteState, prevState?: { title?: string; h1?: string; h2?: string }): void {
|
|
135
144
|
const fullUrl = state.fullUrl || state.url;
|
|
136
145
|
if (!fullUrl) return;
|