explorbot 0.2.3 → 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 +7 -1
- 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/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 +115 -86
- package/boat/prima/src/envelope.ts +80 -52
- package/boat/prima/src/prima.ts +547 -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 +7 -1
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -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/config.js +4 -2
- package/dist/boat/prima/src/activity-line.js +30 -0
- package/dist/boat/prima/src/cli.js +97 -77
- package/dist/boat/prima/src/envelope.js +70 -44
- package/dist/boat/prima/src/prima.js +515 -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 +36 -1
- 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 +7 -0
- package/dist/src/ai/navigator.js +10 -6
- package/dist/src/ai/pilot.d.ts +4 -0
- package/dist/src/ai/pilot.js +48 -2
- package/dist/src/ai/provider.js +2 -2
- package/dist/src/ai/rerunner.js +2 -1
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/rules.js +17 -10
- package/dist/src/ai/task-agent.js +4 -1
- package/dist/src/ai/tester.d.ts +1 -1
- package/dist/src/ai/tester.js +33 -33
- package/dist/src/ai/tools.d.ts +14 -0
- package/dist/src/ai/tools.js +113 -36
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +13 -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 +3 -1
- package/dist/src/explorer.js +2 -3
- 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/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/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 +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/package.json +3 -2
- package/rules/navigator/verification-actions.md +20 -0
- package/src/action-result.ts +11 -0
- package/src/action.ts +38 -2
- package/src/ai/captain/web-mode.ts +6 -3
- package/src/ai/captain.ts +3 -0
- package/src/ai/navigator.ts +14 -8
- package/src/ai/pilot.ts +53 -2
- package/src/ai/provider.ts +2 -4
- package/src/ai/rerunner.ts +2 -1
- package/src/ai/researcher.ts +1 -1
- package/src/ai/rules.ts +17 -10
- package/src/ai/task-agent.ts +3 -1
- package/src/ai/tester.ts +32 -33
- package/src/ai/tools.ts +132 -36
- package/src/config.ts +15 -4
- package/src/execution-controller.ts +8 -0
- package/src/explorbot.ts +4 -2
- package/src/explorer.ts +2 -2
- 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/html.ts +1 -0
- package/src/utils/logger.ts +24 -1
- 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
package/dist/src/config.js
CHANGED
|
@@ -9,12 +9,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import path, { basename, dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
12
13
|
import { parseEnv } from 'node:util';
|
|
13
14
|
import dedent from 'dedent';
|
|
14
15
|
import matter from 'gray-matter';
|
|
15
16
|
import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
|
|
16
17
|
import { getCliName } from './utils/cli-name.js';
|
|
17
|
-
import { log } from './utils/logger.js';
|
|
18
|
+
import { log, tag } from './utils/logger.js';
|
|
18
19
|
export const PROVIDERS = {
|
|
19
20
|
openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
20
21
|
anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
|
|
@@ -178,8 +179,15 @@ export class ConfigParser {
|
|
|
178
179
|
return this.site;
|
|
179
180
|
}
|
|
180
181
|
resolveTargetPath(target) {
|
|
181
|
-
if (!this.site)
|
|
182
|
+
if (!this.site) {
|
|
183
|
+
const configured = this.config?.playwright?.url || this.config?.web?.url;
|
|
184
|
+
const targetOrigin = target ? URL.parse(target)?.origin : null;
|
|
185
|
+
const baseOrigin = configured ? URL.parse(configured)?.origin : null;
|
|
186
|
+
if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
|
|
187
|
+
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.`);
|
|
188
|
+
}
|
|
182
189
|
return target || '/';
|
|
190
|
+
}
|
|
183
191
|
if (!target)
|
|
184
192
|
return this.siteStartPath;
|
|
185
193
|
const resolved = resolveSiteTarget(target, this.site.url);
|
|
@@ -321,9 +329,10 @@ export class ConfigParser {
|
|
|
321
329
|
}
|
|
322
330
|
async loadConfigModule(configPath) {
|
|
323
331
|
const ext = configPath.split('.').pop();
|
|
332
|
+
const moduleUrl = pathToFileURL(resolve(configPath)).href;
|
|
324
333
|
if (ext === 'ts') {
|
|
325
334
|
try {
|
|
326
|
-
const module = await import(__rewriteRelativeImportExtension(
|
|
335
|
+
const module = await import(__rewriteRelativeImportExtension(moduleUrl));
|
|
327
336
|
return module;
|
|
328
337
|
}
|
|
329
338
|
catch (error) {
|
|
@@ -332,7 +341,7 @@ export class ConfigParser {
|
|
|
332
341
|
}
|
|
333
342
|
}
|
|
334
343
|
else if (ext === 'js' || ext === 'mjs') {
|
|
335
|
-
const module = await import(__rewriteRelativeImportExtension(
|
|
344
|
+
const module = await import(__rewriteRelativeImportExtension(moduleUrl));
|
|
336
345
|
return module;
|
|
337
346
|
}
|
|
338
347
|
else {
|
|
@@ -10,6 +10,8 @@ export declare class ExecutionController extends EventEmitter {
|
|
|
10
10
|
constructor();
|
|
11
11
|
static getInstance(): ExecutionController;
|
|
12
12
|
setInputCallback(callback: InputCallback): void;
|
|
13
|
+
hasInputCallback(): boolean;
|
|
14
|
+
clearInputCallback(): void;
|
|
13
15
|
startExecution(): void;
|
|
14
16
|
getAbortSignal(): AbortSignal | undefined;
|
|
15
17
|
interrupt(): void;
|
|
@@ -20,6 +20,12 @@ export class ExecutionController extends EventEmitter {
|
|
|
20
20
|
setInputCallback(callback) {
|
|
21
21
|
this.inputCallback = callback;
|
|
22
22
|
}
|
|
23
|
+
hasInputCallback() {
|
|
24
|
+
return !!this.inputCallback;
|
|
25
|
+
}
|
|
26
|
+
clearInputCallback() {
|
|
27
|
+
this.inputCallback = null;
|
|
28
|
+
}
|
|
23
29
|
startExecution() {
|
|
24
30
|
this.interrupted = false;
|
|
25
31
|
this.abortController = new AbortController();
|
package/dist/src/explorbot.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { Researcher } from './ai/researcher.js';
|
|
|
15
15
|
import { SessionAnalyst } from './ai/session-analyst.js';
|
|
16
16
|
import { Tester } from './ai/tester.js';
|
|
17
17
|
import { RequestStore } from './api/request-store.js';
|
|
18
|
-
import type { ExplorbotConfig } from './config.js';
|
|
18
|
+
import type { ExplorbotConfig, ReporterConfig } from './config.js';
|
|
19
19
|
import { ConfigParser } from './config.js';
|
|
20
20
|
import { ExperienceTracker } from './experience-tracker.js';
|
|
21
21
|
import Explorer from './explorer.js';
|
|
@@ -37,6 +37,7 @@ export interface ExplorBotOptions {
|
|
|
37
37
|
session?: string | boolean;
|
|
38
38
|
instance?: string;
|
|
39
39
|
optionalAi?: boolean;
|
|
40
|
+
reporter?: ReporterConfig;
|
|
40
41
|
attachedBrowser?: Browser;
|
|
41
42
|
applicationSpec?: string;
|
|
42
43
|
}
|
package/dist/src/explorbot.js
CHANGED
|
@@ -101,6 +101,8 @@ export class ExplorBot {
|
|
|
101
101
|
this.config = await this.configParser.loadConfig(this.options);
|
|
102
102
|
if (this.options.session === true)
|
|
103
103
|
this.options.session = path.join(this.configParser.getOutputDir(), 'session.json');
|
|
104
|
+
if (typeof this.options.session === 'string')
|
|
105
|
+
this.options.session = path.resolve(this.options.path || '.', this.options.session);
|
|
104
106
|
if (this.options.optionalAi)
|
|
105
107
|
return this.bootstrapOptionalProvider();
|
|
106
108
|
this.provider = new AIProvider(this.config.ai);
|
|
@@ -138,7 +140,7 @@ export class ExplorBot {
|
|
|
138
140
|
return (this._stateManager ||= new StateManager(this.experienceTracker(), this.knowledgeTracker()));
|
|
139
141
|
}
|
|
140
142
|
reporter() {
|
|
141
|
-
return (this._reporter ||= new Reporter(this.config.reporter, this.stateManager()));
|
|
143
|
+
return (this._reporter ||= new Reporter(this.options.reporter || this.config.reporter, this.stateManager()));
|
|
142
144
|
}
|
|
143
145
|
requestStore() {
|
|
144
146
|
return (this._requestStore ||= new RequestStore(this.configParser.getOutputDir()));
|
package/dist/src/explorer.js
CHANGED
|
@@ -10,7 +10,6 @@ import { ActionResult } from "./action-result.js";
|
|
|
10
10
|
import Action from './action.js';
|
|
11
11
|
import { XhrCapture } from "./api/xhr-capture.js";
|
|
12
12
|
import { ConfigParser } from './config.js';
|
|
13
|
-
import { TestResult } from "./test-plan.js";
|
|
14
13
|
import { BrowserRecoveryError, browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
|
|
15
14
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
16
15
|
import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
|
|
@@ -624,12 +623,12 @@ class Explorer {
|
|
|
624
623
|
if (this.observedTestPages.has(page))
|
|
625
624
|
return;
|
|
626
625
|
this.testPageErrorHandler ||= (err) => {
|
|
627
|
-
this._activeTest?.
|
|
626
|
+
this._activeTest?.addObservation(`Console error: ${err.message}`);
|
|
628
627
|
};
|
|
629
628
|
this.testConsoleHandler ||= (msg) => {
|
|
630
629
|
if (msg.type() !== 'error')
|
|
631
630
|
return;
|
|
632
|
-
this._activeTest?.
|
|
631
|
+
this._activeTest?.addObservation(`Console error: ${msg.text()}`);
|
|
633
632
|
};
|
|
634
633
|
this.testDialogHandler ||= (dialog) => {
|
|
635
634
|
const dialogType = dialog.type();
|
|
@@ -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;
|