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,22 @@
|
|
|
1
|
+
export declare function globalDir(): string;
|
|
2
|
+
export declare function globalEnvPath(): string;
|
|
3
|
+
export declare function globalConfigPath(): string;
|
|
4
|
+
export declare function findGlobalConfig(): string | null;
|
|
5
|
+
export declare function isGlobalConfigPath(configPath: string): boolean;
|
|
6
|
+
export declare function sitesDir(): string;
|
|
7
|
+
export declare function siteFolderName(url: string): string;
|
|
8
|
+
export declare function listSites(): SiteRecord[];
|
|
9
|
+
export declare function registerSite(baseUrl: string): SiteRecord;
|
|
10
|
+
export declare function resolveSiteTarget(target?: string, defaultBaseUrl?: string): SiteTarget;
|
|
11
|
+
interface SiteRecord {
|
|
12
|
+
folder: string;
|
|
13
|
+
dir: string;
|
|
14
|
+
url: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
lastRunAt: string;
|
|
17
|
+
}
|
|
18
|
+
interface SiteTarget {
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
22
|
+
export type { SiteRecord, SiteTarget };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
|
|
5
|
+
const SITE_DIRS = ['knowledge', 'experience', 'output'];
|
|
6
|
+
export function globalDir() {
|
|
7
|
+
return join(os.homedir(), '.explorbot');
|
|
8
|
+
}
|
|
9
|
+
export function globalEnvPath() {
|
|
10
|
+
return join(globalDir(), '.env');
|
|
11
|
+
}
|
|
12
|
+
export function globalConfigPath() {
|
|
13
|
+
return join(globalDir(), 'config.js');
|
|
14
|
+
}
|
|
15
|
+
export function findGlobalConfig() {
|
|
16
|
+
for (const name of GLOBAL_CONFIG_NAMES) {
|
|
17
|
+
const fullPath = join(globalDir(), name);
|
|
18
|
+
if (existsSync(fullPath))
|
|
19
|
+
return fullPath;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export function isGlobalConfigPath(configPath) {
|
|
24
|
+
return GLOBAL_CONFIG_NAMES.some((name) => join(globalDir(), name) === configPath);
|
|
25
|
+
}
|
|
26
|
+
export function sitesDir() {
|
|
27
|
+
return join(globalDir(), 'sites');
|
|
28
|
+
}
|
|
29
|
+
export function siteFolderName(url) {
|
|
30
|
+
return new URL(url).host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
|
|
31
|
+
}
|
|
32
|
+
export function listSites() {
|
|
33
|
+
if (!existsSync(sitesDir()))
|
|
34
|
+
return [];
|
|
35
|
+
return readdirSync(sitesDir(), { withFileTypes: true })
|
|
36
|
+
.filter((entry) => entry.isDirectory())
|
|
37
|
+
.map((entry) => readSite(entry.name))
|
|
38
|
+
.filter((site) => !!site)
|
|
39
|
+
.sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
|
|
40
|
+
}
|
|
41
|
+
export function registerSite(baseUrl) {
|
|
42
|
+
const folder = siteFolderName(baseUrl);
|
|
43
|
+
const dir = join(sitesDir(), folder);
|
|
44
|
+
for (const subDir of SITE_DIRS) {
|
|
45
|
+
mkdirSync(join(dir, subDir), { recursive: true, mode: 0o700 });
|
|
46
|
+
}
|
|
47
|
+
const now = new Date().toISOString();
|
|
48
|
+
const meta = {
|
|
49
|
+
url: baseUrl,
|
|
50
|
+
createdAt: readSite(folder)?.createdAt || now,
|
|
51
|
+
lastRunAt: now,
|
|
52
|
+
};
|
|
53
|
+
writeFileSync(join(dir, 'site.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
|
|
54
|
+
return { folder, dir, ...meta };
|
|
55
|
+
}
|
|
56
|
+
export function resolveSiteTarget(target, defaultBaseUrl) {
|
|
57
|
+
const raw = (target || process.env.EXPLORBOT_URL || defaultBaseUrl || '').trim();
|
|
58
|
+
if (!raw) {
|
|
59
|
+
throw new Error(withSites('No site to explore. Pass a URL to the command or set EXPLORBOT_URL.'));
|
|
60
|
+
}
|
|
61
|
+
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
|
62
|
+
const url = new URL(raw);
|
|
63
|
+
return { baseUrl: url.origin, path: `${url.pathname}${url.search}${url.hash}` };
|
|
64
|
+
}
|
|
65
|
+
if (raw.startsWith('/')) {
|
|
66
|
+
const base = defaultBaseUrl || process.env.EXPLORBOT_URL;
|
|
67
|
+
if (!base) {
|
|
68
|
+
throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
|
|
69
|
+
}
|
|
70
|
+
return { baseUrl: new URL(base).origin, path: raw };
|
|
71
|
+
}
|
|
72
|
+
let reference = raw;
|
|
73
|
+
let path = '/';
|
|
74
|
+
const separator = raw.indexOf('/');
|
|
75
|
+
if (separator > -1) {
|
|
76
|
+
reference = raw.slice(0, separator);
|
|
77
|
+
path = raw.slice(separator);
|
|
78
|
+
}
|
|
79
|
+
const site = findSite(reference);
|
|
80
|
+
if (!site) {
|
|
81
|
+
throw new Error(withSites(`Unknown site "${reference}".`));
|
|
82
|
+
}
|
|
83
|
+
return { baseUrl: site.url, path };
|
|
84
|
+
}
|
|
85
|
+
function readSite(folder) {
|
|
86
|
+
const dir = join(sitesDir(), folder);
|
|
87
|
+
const metaPath = join(dir, 'site.json');
|
|
88
|
+
if (!existsSync(metaPath))
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
92
|
+
new URL(meta.url);
|
|
93
|
+
return { folder, dir, url: meta.url, createdAt: meta.createdAt, lastRunAt: meta.lastRunAt };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function findSite(reference) {
|
|
100
|
+
const normalized = reference.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
|
|
101
|
+
const sites = listSites();
|
|
102
|
+
const byFolder = sites.find((site) => site.folder === normalized);
|
|
103
|
+
if (byFolder)
|
|
104
|
+
return byFolder;
|
|
105
|
+
return sites.find((site) => new URL(site.url).host.toLowerCase() === reference.toLowerCase()) || null;
|
|
106
|
+
}
|
|
107
|
+
function withSites(message) {
|
|
108
|
+
const lines = [message];
|
|
109
|
+
const sites = listSites();
|
|
110
|
+
if (sites.length) {
|
|
111
|
+
lines.push('Registered sites:');
|
|
112
|
+
for (const site of sites)
|
|
113
|
+
lines.push(` ${site.folder} → ${site.url}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push('Explore a new site by passing its full URL, e.g. https://app.example.com/login');
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ActionResult } from './action-result.js';
|
|
2
|
+
import { ApplicationSpec } from './application-spec.js';
|
|
2
3
|
export interface Knowledge {
|
|
3
4
|
filePath: string;
|
|
4
5
|
url: string;
|
|
@@ -9,10 +10,13 @@ export declare class KnowledgeTracker {
|
|
|
9
10
|
knowledgeDir: string;
|
|
10
11
|
knowledgeFiles: Knowledge[];
|
|
11
12
|
isLoaded: boolean;
|
|
12
|
-
|
|
13
|
+
applicationSpec?: ApplicationSpec;
|
|
14
|
+
constructor(applicationSpecPath?: string);
|
|
13
15
|
loadKnowledgeFiles(): void;
|
|
14
16
|
getRelevantKnowledge(state: ActionResult): Knowledge[];
|
|
15
17
|
renderRelevantKnowledge(state: ActionResult): string;
|
|
18
|
+
renderRelevantContext(state: ActionResult): string;
|
|
19
|
+
renderApplicationSpec(state: ActionResult): string;
|
|
16
20
|
addKnowledge(urlPattern: string, description: string): {
|
|
17
21
|
filename: string;
|
|
18
22
|
filePath: string;
|
|
@@ -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.js";
|
|
6
7
|
import { ConfigParser } from './config.js';
|
|
7
8
|
import { getCliName } from "./utils/cli-name.js";
|
|
8
9
|
import { createDebug, pluralize, tag } from './utils/logger.js';
|
|
@@ -15,13 +16,19 @@ export class KnowledgeTracker {
|
|
|
15
16
|
knowledgeDir;
|
|
16
17
|
knowledgeFiles = [];
|
|
17
18
|
isLoaded = false;
|
|
18
|
-
|
|
19
|
+
applicationSpec;
|
|
20
|
+
constructor(applicationSpecPath) {
|
|
19
21
|
const configParser = ConfigParser.getInstance();
|
|
20
22
|
const config = configParser.getConfig();
|
|
21
23
|
this.knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
|
|
22
24
|
if (!existsSync(this.knowledgeDir)) {
|
|
23
25
|
mkdirSync(this.knowledgeDir, { recursive: true });
|
|
24
26
|
}
|
|
27
|
+
const specPath = applicationSpecPath || config.dirs?.spec;
|
|
28
|
+
if (specPath) {
|
|
29
|
+
this.applicationSpec = new ApplicationSpec(specPath);
|
|
30
|
+
tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
|
|
31
|
+
}
|
|
25
32
|
}
|
|
26
33
|
loadKnowledgeFiles() {
|
|
27
34
|
if (this.isLoaded)
|
|
@@ -60,6 +67,12 @@ export class KnowledgeTracker {
|
|
|
60
67
|
</knowledge>
|
|
61
68
|
`;
|
|
62
69
|
}
|
|
70
|
+
renderRelevantContext(state) {
|
|
71
|
+
return [this.renderRelevantKnowledge(state), this.renderApplicationSpec(state)].filter(Boolean).join('\n\n');
|
|
72
|
+
}
|
|
73
|
+
renderApplicationSpec(state) {
|
|
74
|
+
return this.applicationSpec?.renderFor(state) || '';
|
|
75
|
+
}
|
|
63
76
|
addKnowledge(urlPattern, description) {
|
|
64
77
|
const configParser = ConfigParser.getInstance();
|
|
65
78
|
const configPath = configParser.getConfigPath();
|
|
@@ -292,8 +292,38 @@ function formatSelectOption(options) {
|
|
|
292
292
|
return quote(values[0]);
|
|
293
293
|
return `[${values.map((v) => quote(v)).join(', ')}]`;
|
|
294
294
|
}
|
|
295
|
+
function assertionLocator(target) {
|
|
296
|
+
if (typeof target === 'string')
|
|
297
|
+
return `page.locator(${JSON.stringify(target)})`;
|
|
298
|
+
if (!target || typeof target !== 'object')
|
|
299
|
+
return null;
|
|
300
|
+
const role = target.role || target.aria;
|
|
301
|
+
const name = target.text ?? target.name ?? target.label;
|
|
302
|
+
if (role && name)
|
|
303
|
+
return `page.getByRole(${JSON.stringify(String(role))}, { name: ${JSON.stringify(String(name))} })`;
|
|
304
|
+
if (role)
|
|
305
|
+
return `page.getByRole(${JSON.stringify(String(role))})`;
|
|
306
|
+
if (name)
|
|
307
|
+
return `page.getByText(${JSON.stringify(String(name))})`;
|
|
308
|
+
if (target.css)
|
|
309
|
+
return `page.locator(${JSON.stringify(String(target.css))})`;
|
|
310
|
+
if (target.xpath)
|
|
311
|
+
return `page.locator(${JSON.stringify(`xpath=${target.xpath}`)})`;
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
295
314
|
export function renderAssertion(assertion) {
|
|
296
315
|
const args = assertion.args;
|
|
316
|
+
const target = assertionLocator(args[0]);
|
|
317
|
+
if (target) {
|
|
318
|
+
if (assertion.name === 'seeElement')
|
|
319
|
+
return `await expect(${target}).toBeVisible();`;
|
|
320
|
+
if (assertion.name === 'dontSeeElement')
|
|
321
|
+
return `await expect(${target}).toBeHidden();`;
|
|
322
|
+
if (assertion.name === 'seeInField' && args[1] !== undefined)
|
|
323
|
+
return `await expect(${target}).toHaveValue(${JSON.stringify(String(args[1]))});`;
|
|
324
|
+
if (assertion.name === 'dontSeeInField' && args[1] !== undefined)
|
|
325
|
+
return `await expect(${target}).not.toHaveValue(${JSON.stringify(String(args[1]))});`;
|
|
326
|
+
}
|
|
297
327
|
if (assertion.name === 'see' && typeof args[0] === 'string') {
|
|
298
328
|
return `await expect(page).toContainText(${JSON.stringify(args[0])});`;
|
|
299
329
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { type ActivityEntry } from './activity.js';
|
|
3
|
+
import { type LogDestination, type TaggedLogEntry } from './utils/logger.js';
|
|
4
|
+
/**
|
|
5
|
+
* Streams a run to a remote UI over one WebSocket, dialling out so a child
|
|
6
|
+
* process and a CI bot are the same case.
|
|
7
|
+
*
|
|
8
|
+
* It **is** a LogDestination — that is the whole integration on the logger's
|
|
9
|
+
* side — and it answers asks by installing itself as the execution
|
|
10
|
+
* controller's input callback. Nothing else in explorbot knows it exists.
|
|
11
|
+
*/
|
|
12
|
+
export declare class Remote implements LogDestination {
|
|
13
|
+
url: string | null;
|
|
14
|
+
socket: WebSocket | null;
|
|
15
|
+
queue: Frame[];
|
|
16
|
+
reconnectDelay: number;
|
|
17
|
+
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
|
18
|
+
asks: Map<string, (value: string | null) => void>;
|
|
19
|
+
askCounter: number;
|
|
20
|
+
lastActivity: string | null;
|
|
21
|
+
registerOption(program: Command): void;
|
|
22
|
+
attach(url: string, command: string): void;
|
|
23
|
+
isAttached(): boolean;
|
|
24
|
+
send(type: string, data?: Record<string, unknown>): void;
|
|
25
|
+
ask(prompt: string): Promise<string | null>;
|
|
26
|
+
close(exitCode: number): Promise<void>;
|
|
27
|
+
isEnabled(): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The entry as the logger already built it, so the `LogType` reaches the UI
|
|
30
|
+
* as the frame's level and each kind is styled there instead of being scraped
|
|
31
|
+
* back out of flattened text.
|
|
32
|
+
*/
|
|
33
|
+
write(entry: TaggedLogEntry): void;
|
|
34
|
+
connect(): void;
|
|
35
|
+
reconnect(): void;
|
|
36
|
+
flush(): Promise<void>;
|
|
37
|
+
drain(): void;
|
|
38
|
+
push(frame: Frame): void;
|
|
39
|
+
receive(raw: string): void;
|
|
40
|
+
/** A new listener is primed with the current activity, which at attach time is
|
|
41
|
+
* nothing — so skip repeats, the priming null included. */
|
|
42
|
+
reportActivity(activity: ActivityEntry | null): void;
|
|
43
|
+
errorOf(args: any[] | undefined): string | undefined;
|
|
44
|
+
commandPath(command: Command): string;
|
|
45
|
+
}
|
|
46
|
+
export declare const remote: Remote;
|
|
47
|
+
/** Whatever the run wants to say. Not a schema — the UI renders what it knows
|
|
48
|
+
* and ignores the rest, so either side can start sending more at any time. */
|
|
49
|
+
type Frame = {
|
|
50
|
+
type: string;
|
|
51
|
+
ts: number;
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import stripAnsi from 'strip-ansi';
|
|
2
|
+
import { addActivityListener } from "./activity.js";
|
|
3
|
+
import { executionController } from "./execution-controller.js";
|
|
4
|
+
import { addDestination } from "./utils/logger.js";
|
|
5
|
+
const QUEUE_CAP = 1000;
|
|
6
|
+
const CONTENT_CAP = 8000;
|
|
7
|
+
const RECONNECT_BASE_MS = 500;
|
|
8
|
+
const RECONNECT_MAX_MS = 10_000;
|
|
9
|
+
const ASK_TIMEOUT_MS = 15 * 60_000;
|
|
10
|
+
const FLUSH_TIMEOUT_MS = 3000;
|
|
11
|
+
/**
|
|
12
|
+
* Streams a run to a remote UI over one WebSocket, dialling out so a child
|
|
13
|
+
* process and a CI bot are the same case.
|
|
14
|
+
*
|
|
15
|
+
* It **is** a LogDestination — that is the whole integration on the logger's
|
|
16
|
+
* side — and it answers asks by installing itself as the execution
|
|
17
|
+
* controller's input callback. Nothing else in explorbot knows it exists.
|
|
18
|
+
*/
|
|
19
|
+
export class Remote {
|
|
20
|
+
url = null;
|
|
21
|
+
socket = null;
|
|
22
|
+
queue = [];
|
|
23
|
+
reconnectDelay = RECONNECT_BASE_MS;
|
|
24
|
+
reconnectTimer = null;
|
|
25
|
+
asks = new Map();
|
|
26
|
+
askCounter = 0;
|
|
27
|
+
lastActivity = null;
|
|
28
|
+
registerOption(program) {
|
|
29
|
+
program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
|
|
30
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
31
|
+
const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
|
|
32
|
+
if (!url)
|
|
33
|
+
return;
|
|
34
|
+
this.attach(String(url), this.commandPath(actionCommand));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
attach(url, command) {
|
|
38
|
+
if (this.url)
|
|
39
|
+
return;
|
|
40
|
+
this.url = url;
|
|
41
|
+
this.connect();
|
|
42
|
+
this.send('hello', { command, cwd: process.cwd(), pid: process.pid });
|
|
43
|
+
addDestination(this);
|
|
44
|
+
executionController.setInputCallback((prompt) => this.ask(prompt));
|
|
45
|
+
addActivityListener((activity) => this.reportActivity(activity));
|
|
46
|
+
}
|
|
47
|
+
isAttached() {
|
|
48
|
+
return !!this.url;
|
|
49
|
+
}
|
|
50
|
+
send(type, data = {}) {
|
|
51
|
+
if (!this.url)
|
|
52
|
+
return;
|
|
53
|
+
const frame = { type, ts: Date.now(), ...data };
|
|
54
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
55
|
+
this.push(frame);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.queue.push(frame);
|
|
59
|
+
if (this.queue.length > QUEUE_CAP)
|
|
60
|
+
this.queue.splice(0, this.queue.length - QUEUE_CAP);
|
|
61
|
+
}
|
|
62
|
+
ask(prompt) {
|
|
63
|
+
if (!this.url)
|
|
64
|
+
return Promise.resolve(null);
|
|
65
|
+
this.askCounter++;
|
|
66
|
+
const askId = `ask-${this.askCounter}`;
|
|
67
|
+
this.send('ask', { askId, prompt });
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
this.asks.delete(askId);
|
|
71
|
+
resolve(null);
|
|
72
|
+
}, ASK_TIMEOUT_MS);
|
|
73
|
+
this.asks.set(askId, (value) => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
resolve(value);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async close(exitCode) {
|
|
80
|
+
if (!this.url)
|
|
81
|
+
return;
|
|
82
|
+
this.send('result', { ok: exitCode === 0, exitCode });
|
|
83
|
+
await this.flush();
|
|
84
|
+
this.url = null;
|
|
85
|
+
if (this.reconnectTimer)
|
|
86
|
+
clearTimeout(this.reconnectTimer);
|
|
87
|
+
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
88
|
+
// route them into a closed socket and park them until the ask times out.
|
|
89
|
+
executionController.clearInputCallback();
|
|
90
|
+
for (const resolve of this.asks.values())
|
|
91
|
+
resolve(null);
|
|
92
|
+
this.asks.clear();
|
|
93
|
+
this.queue = [];
|
|
94
|
+
this.socket?.close();
|
|
95
|
+
this.socket = null;
|
|
96
|
+
}
|
|
97
|
+
isEnabled() {
|
|
98
|
+
return this.isAttached();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The entry as the logger already built it, so the `LogType` reaches the UI
|
|
102
|
+
* as the frame's level and each kind is styled there instead of being scraped
|
|
103
|
+
* back out of flattened text.
|
|
104
|
+
*/
|
|
105
|
+
write(entry) {
|
|
106
|
+
if (entry.type === 'html')
|
|
107
|
+
return;
|
|
108
|
+
let content = stripAnsi(entry.content ?? '');
|
|
109
|
+
if (content.length > CONTENT_CAP)
|
|
110
|
+
content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
|
|
111
|
+
this.send('log', {
|
|
112
|
+
level: entry.type,
|
|
113
|
+
content,
|
|
114
|
+
namespace: entry.namespace,
|
|
115
|
+
error: this.errorOf(entry.originalArgs),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
connect() {
|
|
119
|
+
if (!this.url)
|
|
120
|
+
return;
|
|
121
|
+
let opening;
|
|
122
|
+
try {
|
|
123
|
+
opening = new WebSocket(this.url);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
this.reconnect();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.socket = opening;
|
|
130
|
+
opening.addEventListener('open', () => {
|
|
131
|
+
this.reconnectDelay = RECONNECT_BASE_MS;
|
|
132
|
+
this.drain();
|
|
133
|
+
});
|
|
134
|
+
opening.addEventListener('message', (event) => this.receive(String(event.data)));
|
|
135
|
+
opening.addEventListener('close', () => {
|
|
136
|
+
if (this.socket !== opening)
|
|
137
|
+
return;
|
|
138
|
+
this.socket = null;
|
|
139
|
+
this.reconnect();
|
|
140
|
+
});
|
|
141
|
+
// A failed connection always closes too, which is where the retry is scheduled.
|
|
142
|
+
opening.addEventListener('error', () => { });
|
|
143
|
+
}
|
|
144
|
+
reconnect() {
|
|
145
|
+
if (!this.url || this.reconnectTimer)
|
|
146
|
+
return;
|
|
147
|
+
const delay = this.reconnectDelay;
|
|
148
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
149
|
+
this.reconnectTimer = setTimeout(() => {
|
|
150
|
+
this.reconnectTimer = null;
|
|
151
|
+
this.connect();
|
|
152
|
+
}, delay);
|
|
153
|
+
this.reconnectTimer.unref?.();
|
|
154
|
+
}
|
|
155
|
+
async flush() {
|
|
156
|
+
const deadline = Date.now() + FLUSH_TIMEOUT_MS;
|
|
157
|
+
while (Date.now() < deadline) {
|
|
158
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
159
|
+
this.drain();
|
|
160
|
+
if (!this.queue.length && !this.socket.bufferedAmount)
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
drain() {
|
|
167
|
+
if (this.socket?.readyState !== WebSocket.OPEN)
|
|
168
|
+
return;
|
|
169
|
+
const pending = this.queue;
|
|
170
|
+
this.queue = [];
|
|
171
|
+
for (const frame of pending)
|
|
172
|
+
this.push(frame);
|
|
173
|
+
}
|
|
174
|
+
push(frame) {
|
|
175
|
+
try {
|
|
176
|
+
this.socket?.send(JSON.stringify(frame));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
this.queue.push(frame);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
receive(raw) {
|
|
183
|
+
let frame;
|
|
184
|
+
try {
|
|
185
|
+
frame = JSON.parse(raw);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (frame.type === 'interrupt') {
|
|
191
|
+
executionController.interrupt();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const resolve = this.asks.get(String(frame.askId));
|
|
195
|
+
if (!resolve)
|
|
196
|
+
return;
|
|
197
|
+
this.asks.delete(String(frame.askId));
|
|
198
|
+
resolve(frame.value ?? null);
|
|
199
|
+
}
|
|
200
|
+
/** A new listener is primed with the current activity, which at attach time is
|
|
201
|
+
* nothing — so skip repeats, the priming null included. */
|
|
202
|
+
reportActivity(activity) {
|
|
203
|
+
const message = activity?.message ?? null;
|
|
204
|
+
if (message === this.lastActivity)
|
|
205
|
+
return;
|
|
206
|
+
this.lastActivity = message;
|
|
207
|
+
this.send('activity', { message, kind: activity?.type });
|
|
208
|
+
}
|
|
209
|
+
errorOf(args) {
|
|
210
|
+
const failure = args?.[1] ?? args?.[0]?.error;
|
|
211
|
+
if (!failure)
|
|
212
|
+
return undefined;
|
|
213
|
+
if (typeof failure === 'string')
|
|
214
|
+
return failure;
|
|
215
|
+
if (typeof failure.message === 'string')
|
|
216
|
+
return failure.message;
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
commandPath(command) {
|
|
220
|
+
const parts = [];
|
|
221
|
+
let node = command;
|
|
222
|
+
while (node) {
|
|
223
|
+
parts.unshift(node.name());
|
|
224
|
+
node = node.parent;
|
|
225
|
+
}
|
|
226
|
+
return parts.slice(1).join(' ') || parts.join(' ');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export const remote = new Remote();
|
package/dist/src/reporter.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export declare class Reporter {
|
|
|
26
26
|
combineStepsAndNotes(test: Test, lastScreenshotFile?: string): Step[];
|
|
27
27
|
buildVerificationStep(test: Test, lastScreenshotFile?: string): Step | undefined;
|
|
28
28
|
reportTest(test: Test, meta?: ReporterMeta): Promise<void>;
|
|
29
|
+
reportTestData(status: string | null, testData: Record<string, unknown>): Promise<void>;
|
|
29
30
|
finishRun(): Promise<void>;
|
|
30
31
|
isEnabled(): boolean;
|
|
31
32
|
setRunDescription(text: string): Promise<void>;
|
package/dist/src/reporter.js
CHANGED
|
@@ -242,13 +242,19 @@ export class Reporter {
|
|
|
242
242
|
time: durationMs != null ? Math.round(durationMs) : 0,
|
|
243
243
|
};
|
|
244
244
|
debugLog(testData);
|
|
245
|
-
await this.
|
|
245
|
+
await this.reportTestData(status, testData);
|
|
246
246
|
debugLog(`Test reported: ${test.scenario} - ${status}`);
|
|
247
247
|
}
|
|
248
248
|
catch (error) {
|
|
249
249
|
debugLog('Failed to report test:', error);
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
|
+
async reportTestData(status, testData) {
|
|
253
|
+
await this.startRun();
|
|
254
|
+
if (!this.isRunStarted)
|
|
255
|
+
return;
|
|
256
|
+
await this.client.addTestRun(status, testData);
|
|
257
|
+
}
|
|
252
258
|
async finishRun() {
|
|
253
259
|
if (!this.isRunStarted) {
|
|
254
260
|
return;
|
|
@@ -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
|
export interface Link {
|
|
@@ -37,6 +37,7 @@ export interface WebPageState {
|
|
|
37
37
|
h4?: string;
|
|
38
38
|
ariaSnapshot?: string | null;
|
|
39
39
|
ariaSnapshotFile?: string;
|
|
40
|
+
focusedElement?: FocusedElement | null;
|
|
40
41
|
links?: Link[];
|
|
41
42
|
verifications?: Record<string, boolean>;
|
|
42
43
|
}
|
package/dist/src/stats.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export declare class Stats {
|
|
|
13
13
|
static plans: number;
|
|
14
14
|
static mode?: ExplorbotMode;
|
|
15
15
|
static focus?: string;
|
|
16
|
+
static visionDisabled: boolean;
|
|
16
17
|
static models: Record<string, TokenUsage>;
|
|
17
18
|
static recordTokens(_agent: string, model: string, usage: TokenUsage): void;
|
|
18
19
|
static getElapsedTime(): string;
|
package/dist/src/stats.js
CHANGED
package/dist/src/test-plan.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface Note {
|
|
|
18
18
|
endTime: number;
|
|
19
19
|
screenshot?: string;
|
|
20
20
|
log?: string;
|
|
21
|
+
observation?: boolean;
|
|
21
22
|
}
|
|
22
23
|
export declare class ActiveNote {
|
|
23
24
|
task: Task;
|
|
@@ -56,6 +57,7 @@ export declare class Task {
|
|
|
56
57
|
getPrintableNotes(): string[];
|
|
57
58
|
notesToString(): string;
|
|
58
59
|
addNote(message: string, status?: TestResultType, screenshot?: string, log?: string): void;
|
|
60
|
+
addObservation(message: string): void;
|
|
59
61
|
addUrlNote(state: UrlNoteState, prevState?: {
|
|
60
62
|
title?: string;
|
|
61
63
|
h1?: string;
|
package/dist/src/test-plan.js
CHANGED
|
@@ -91,6 +91,13 @@ export class Task {
|
|
|
91
91
|
const timestamp = `${now}_${this.timestampCounter++}`;
|
|
92
92
|
this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
|
|
93
93
|
}
|
|
94
|
+
addObservation(message) {
|
|
95
|
+
const isDuplicate = Object.values(this.notes).some((note) => note.message === message);
|
|
96
|
+
if (isDuplicate)
|
|
97
|
+
return;
|
|
98
|
+
const now = performance.now();
|
|
99
|
+
this.notes[`${now}_${this.timestampCounter++}`] = { message, status: TestResult.FAILED, startTime: now, endTime: now, observation: true };
|
|
100
|
+
}
|
|
94
101
|
addUrlNote(state, prevState) {
|
|
95
102
|
const fullUrl = state.fullUrl || state.url;
|
|
96
103
|
if (!fullUrl)
|
package/dist/src/utils/aria.d.ts
CHANGED
|
@@ -3,17 +3,11 @@ export interface FocusAreaResult {
|
|
|
3
3
|
type: 'dialog' | 'modal' | null;
|
|
4
4
|
name: string | null;
|
|
5
5
|
}
|
|
6
|
-
export
|
|
6
|
+
export type ValueOffload = (value: string) => string | undefined;
|
|
7
|
+
export declare const compactAriaSnapshot: (snapshot: string | null, keepNamed?: boolean, offload?: ValueOffload) => string;
|
|
7
8
|
export declare const diffAriaSnapshots: (previous: string | null, current: string | null) => AriaDiff;
|
|
8
9
|
export declare const detectFocusArea: (snapshot: string | null) => FocusAreaResult;
|
|
9
10
|
export declare const collectInteractiveNodes: (snapshot: string | null) => Array<Record<string, unknown>>;
|
|
10
|
-
export interface FocusedElementInfo {
|
|
11
|
-
role: string;
|
|
12
|
-
name: string;
|
|
13
|
-
value?: string;
|
|
14
|
-
attributes?: string[];
|
|
15
|
-
}
|
|
16
|
-
export declare function extractFocusedElement(ariaSnapshot: string | null): FocusedElementInfo | null;
|
|
17
11
|
export declare function parseAriaLocator(ariaStr: string): {
|
|
18
12
|
role: string;
|
|
19
13
|
text: string;
|