explorbot 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/explorbot-cli.ts +26 -8
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/api-tester/src/config.ts +4 -2
- package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
- package/boat/doc-collector/src/ai/documentarian.ts +61 -31
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/doc-collector/src/config.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +0 -0
- package/boat/prima/src/activity-line.ts +33 -0
- package/boat/prima/src/cli.ts +127 -86
- package/boat/prima/src/envelope.ts +102 -52
- package/boat/prima/src/prima.ts +567 -128
- package/boat/prima/src/pw-parser.ts +11 -1
- package/boat/prima/src/pw-registry.ts +4 -5
- package/boat/prima/src/session-log.ts +126 -0
- package/dist/bin/explorbot-cli.js +26 -8
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/api-tester/src/config.js +4 -2
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/doc-collector/src/config.js +4 -2
- package/dist/boat/prima/src/activity-line.js +30 -0
- package/dist/boat/prima/src/cli.js +109 -77
- package/dist/boat/prima/src/envelope.js +94 -44
- package/dist/boat/prima/src/prima.js +533 -119
- package/dist/boat/prima/src/pw-parser.js +13 -1
- package/dist/boat/prima/src/pw-registry.js +4 -5
- package/dist/boat/prima/src/session-log.js +108 -0
- package/dist/package.json +3 -2
- package/dist/rules/navigator/verification-actions.md +20 -0
- package/dist/src/action-result.d.ts +7 -0
- package/dist/src/action-result.js +4 -0
- package/dist/src/action.d.ts +2 -0
- package/dist/src/action.js +41 -2
- package/dist/src/ai/captain/web-mode.js +6 -3
- package/dist/src/ai/captain.js +2 -0
- package/dist/src/ai/navigator.d.ts +34 -0
- package/dist/src/ai/navigator.js +237 -181
- package/dist/src/ai/pilot.d.ts +7 -0
- package/dist/src/ai/pilot.js +90 -2
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +14 -23
- package/dist/src/ai/rerunner.js +2 -1
- package/dist/src/ai/researcher/cache.d.ts +2 -0
- package/dist/src/ai/researcher/cache.js +10 -2
- package/dist/src/ai/researcher.js +3 -2
- package/dist/src/ai/rules.js +17 -10
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/task-agent.js +4 -1
- package/dist/src/ai/tester.d.ts +6 -3
- package/dist/src/ai/tester.js +50 -46
- package/dist/src/ai/tools.d.ts +14 -0
- package/dist/src/ai/tools.js +117 -37
- package/dist/src/commands/config-command.d.ts +51 -0
- package/dist/src/commands/config-command.js +117 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/config.d.ts +9 -1
- package/dist/src/config.js +53 -4
- package/dist/src/execution-controller.d.ts +2 -0
- package/dist/src/execution-controller.js +6 -0
- package/dist/src/explorbot.d.ts +2 -1
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +2 -3
- package/dist/src/playwright-recorder.js +30 -0
- package/dist/src/remote.d.ts +55 -0
- package/dist/src/remote.js +235 -0
- package/dist/src/reporter.d.ts +1 -0
- package/dist/src/reporter.js +7 -1
- package/dist/src/state-manager.d.ts +2 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/stats.d.ts +1 -0
- package/dist/src/stats.js +1 -0
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +26 -0
- package/dist/src/utils/aria.d.ts +2 -8
- package/dist/src/utils/aria.js +69 -40
- package/dist/src/utils/html.js +1 -0
- package/dist/src/utils/logger.d.ts +7 -1
- package/dist/src/utils/logger.js +32 -0
- package/dist/src/utils/page-readiness.js +18 -1
- package/dist/src/utils/url-matcher.js +3 -0
- package/dist/src/utils/web-element.d.ts +2 -0
- package/dist/src/utils/web-element.js +8 -0
- package/dist/src/utils/web-sandbox.d.ts +1 -1
- package/dist/src/utils/web-sandbox.js +2 -3
- package/docs/api-testing/basics.md +90 -0
- package/docs/api-testing/planning.md +57 -0
- package/docs/api-testing/running-tests.md +55 -0
- package/docs/assets/cloud-report.png +0 -0
- package/docs/assets/html-report.png +0 -0
- package/docs/assets/langfuse-trace.png +0 -0
- package/docs/assets/successful-explore-run.png +0 -0
- package/docs/basics/getting-started.md +140 -0
- package/docs/basics/prerequisites.md +63 -0
- package/docs/basics/providers.md +362 -0
- package/docs/basics/running.md +78 -0
- package/docs/contributing/ai-integration-tests.md +57 -0
- package/docs/contributing/contributing.md +90 -0
- package/docs/contributing/demo-videos.md +36 -0
- package/docs/contributing/npm-package.md +138 -0
- package/docs/contributing/observability.md +227 -0
- package/docs/contributing/regression-tests.md +103 -0
- package/docs/contributing/testing.md +95 -0
- package/docs/doc-collection/basics.md +128 -0
- package/docs/doc-collection/crawling.md +67 -0
- package/docs/doc-collection/interactive-mode.md +99 -0
- package/docs/index.json +87 -0
- package/docs/reference/commands.md +997 -0
- package/docs/reference/configuration.md +569 -0
- package/docs/reference/scripting.md +303 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
- package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
- package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
- package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
- package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
- package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
- package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/docs/web-testing/agents.md +158 -0
- package/docs/web-testing/automated-tests.md +134 -0
- package/docs/web-testing/basics.md +91 -0
- package/docs/web-testing/customization.md +131 -0
- package/docs/web-testing/hooks.md +238 -0
- package/docs/web-testing/page-interaction.md +84 -0
- package/docs/web-testing/planner.md +122 -0
- package/docs/web-testing/rerun.md +164 -0
- package/docs/web-testing/researcher.md +380 -0
- package/docs/workflow/agentic-usage.md +233 -0
- package/docs/workflow/application-spec.md +73 -0
- package/docs/workflow/ci.md +202 -0
- package/docs/workflow/knowledge.md +310 -0
- package/docs/workflow/planning-styles.md +67 -0
- package/docs/workflow/reporting.md +133 -0
- package/docs/workflow/test-plans.md +90 -0
- package/package.json +3 -2
- package/rules/navigator/verification-actions.md +20 -0
- package/src/action-result.ts +11 -0
- package/src/action.ts +43 -3
- package/src/ai/captain/web-mode.ts +6 -3
- package/src/ai/captain.ts +3 -0
- package/src/ai/navigator.ts +255 -186
- package/src/ai/pilot.ts +104 -2
- package/src/ai/provider.ts +14 -24
- package/src/ai/rerunner.ts +2 -1
- package/src/ai/researcher/cache.ts +12 -2
- package/src/ai/researcher.ts +3 -2
- package/src/ai/rules.ts +17 -10
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/task-agent.ts +3 -1
- package/src/ai/tester.ts +52 -45
- package/src/ai/tools.ts +136 -37
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +60 -5
- package/src/execution-controller.ts +8 -0
- package/src/explorbot.ts +7 -3
- package/src/explorer.ts +2 -2
- package/src/playwright-recorder.ts +23 -0
- package/src/remote.ts +244 -0
- package/src/reporter.ts +7 -1
- package/src/state-manager.ts +6 -2
- package/src/stats.ts +1 -0
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +65 -45
- package/src/utils/html.ts +1 -0
- package/src/utils/logger.ts +33 -2
- package/src/utils/page-readiness.ts +24 -1
- package/src/utils/url-matcher.ts +3 -0
- package/src/utils/web-element.ts +9 -0
- package/src/utils/web-sandbox.ts +3 -4
|
@@ -0,0 +1,235 @@
|
|
|
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, messages and `data` alike — and it answers asks by installing itself
|
|
17
|
+
* as the execution controller's input callback. Nothing else in explorbot
|
|
18
|
+
* knows it exists.
|
|
19
|
+
*/
|
|
20
|
+
export class Remote {
|
|
21
|
+
url = null;
|
|
22
|
+
socket = null;
|
|
23
|
+
queue = [];
|
|
24
|
+
reconnectDelay = RECONNECT_BASE_MS;
|
|
25
|
+
reconnectTimer = null;
|
|
26
|
+
asks = new Map();
|
|
27
|
+
askCounter = 0;
|
|
28
|
+
lastActivity = null;
|
|
29
|
+
registerOption(program) {
|
|
30
|
+
program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
|
|
31
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
32
|
+
const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
|
|
33
|
+
if (!url)
|
|
34
|
+
return;
|
|
35
|
+
this.attach(String(url), this.commandPath(actionCommand));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
attach(url, command) {
|
|
39
|
+
if (this.url)
|
|
40
|
+
return;
|
|
41
|
+
this.url = url;
|
|
42
|
+
this.connect();
|
|
43
|
+
this.send('hello', { command, cwd: process.cwd(), pid: process.pid });
|
|
44
|
+
addDestination(this);
|
|
45
|
+
executionController.setInputCallback((prompt) => this.ask(prompt));
|
|
46
|
+
addActivityListener((activity) => this.reportActivity(activity));
|
|
47
|
+
}
|
|
48
|
+
isAttached() {
|
|
49
|
+
return !!this.url;
|
|
50
|
+
}
|
|
51
|
+
send(type, data = {}) {
|
|
52
|
+
if (!this.url)
|
|
53
|
+
return;
|
|
54
|
+
const frame = { type, ts: Date.now(), ...data };
|
|
55
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
56
|
+
this.push(frame);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.queue.push(frame);
|
|
60
|
+
if (this.queue.length > QUEUE_CAP)
|
|
61
|
+
this.queue.splice(0, this.queue.length - QUEUE_CAP);
|
|
62
|
+
}
|
|
63
|
+
ask(prompt) {
|
|
64
|
+
if (!this.url)
|
|
65
|
+
return Promise.resolve(null);
|
|
66
|
+
this.askCounter++;
|
|
67
|
+
const askId = `ask-${this.askCounter}`;
|
|
68
|
+
this.send('ask', { askId, prompt });
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
this.asks.delete(askId);
|
|
72
|
+
resolve(null);
|
|
73
|
+
}, ASK_TIMEOUT_MS);
|
|
74
|
+
this.asks.set(askId, (value) => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
resolve(value);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async close(exitCode) {
|
|
81
|
+
if (!this.url)
|
|
82
|
+
return;
|
|
83
|
+
this.send('result', { ok: exitCode === 0, exitCode });
|
|
84
|
+
await this.flush();
|
|
85
|
+
this.url = null;
|
|
86
|
+
if (this.reconnectTimer)
|
|
87
|
+
clearTimeout(this.reconnectTimer);
|
|
88
|
+
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
89
|
+
// route them into a closed socket and park them until the ask times out.
|
|
90
|
+
executionController.clearInputCallback();
|
|
91
|
+
for (const resolve of this.asks.values())
|
|
92
|
+
resolve(null);
|
|
93
|
+
this.asks.clear();
|
|
94
|
+
this.queue = [];
|
|
95
|
+
this.socket?.close();
|
|
96
|
+
this.socket = null;
|
|
97
|
+
}
|
|
98
|
+
isEnabled() {
|
|
99
|
+
return this.isAttached();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The entry as the logger already built it, so the `LogType` reaches the UI
|
|
103
|
+
* as the frame's level and each kind is styled there instead of being scraped
|
|
104
|
+
* back out of flattened text.
|
|
105
|
+
*/
|
|
106
|
+
write(entry) {
|
|
107
|
+
if (entry.type === 'html')
|
|
108
|
+
return;
|
|
109
|
+
if (entry.type === 'data') {
|
|
110
|
+
const [kind, payload] = entry.originalArgs || [];
|
|
111
|
+
this.send(String(kind), payload);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
let content = stripAnsi(entry.content ?? '');
|
|
115
|
+
if (content.length > CONTENT_CAP)
|
|
116
|
+
content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
|
|
117
|
+
this.send('log', {
|
|
118
|
+
level: entry.type,
|
|
119
|
+
content,
|
|
120
|
+
namespace: entry.namespace,
|
|
121
|
+
error: this.errorOf(entry.originalArgs),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
connect() {
|
|
125
|
+
if (!this.url)
|
|
126
|
+
return;
|
|
127
|
+
let opening;
|
|
128
|
+
try {
|
|
129
|
+
opening = new WebSocket(this.url);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
this.reconnect();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.socket = opening;
|
|
136
|
+
opening.addEventListener('open', () => {
|
|
137
|
+
this.reconnectDelay = RECONNECT_BASE_MS;
|
|
138
|
+
this.drain();
|
|
139
|
+
});
|
|
140
|
+
opening.addEventListener('message', (event) => this.receive(String(event.data)));
|
|
141
|
+
opening.addEventListener('close', () => {
|
|
142
|
+
if (this.socket !== opening)
|
|
143
|
+
return;
|
|
144
|
+
this.socket = null;
|
|
145
|
+
this.reconnect();
|
|
146
|
+
});
|
|
147
|
+
// A failed connection always closes too, which is where the retry is scheduled.
|
|
148
|
+
opening.addEventListener('error', () => { });
|
|
149
|
+
}
|
|
150
|
+
reconnect() {
|
|
151
|
+
if (!this.url || this.reconnectTimer)
|
|
152
|
+
return;
|
|
153
|
+
const delay = this.reconnectDelay;
|
|
154
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
155
|
+
this.reconnectTimer = setTimeout(() => {
|
|
156
|
+
this.reconnectTimer = null;
|
|
157
|
+
this.connect();
|
|
158
|
+
}, delay);
|
|
159
|
+
this.reconnectTimer.unref?.();
|
|
160
|
+
}
|
|
161
|
+
async flush() {
|
|
162
|
+
const deadline = Date.now() + FLUSH_TIMEOUT_MS;
|
|
163
|
+
while (Date.now() < deadline) {
|
|
164
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
165
|
+
this.drain();
|
|
166
|
+
if (!this.queue.length && !this.socket.bufferedAmount)
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
drain() {
|
|
173
|
+
if (this.socket?.readyState !== WebSocket.OPEN)
|
|
174
|
+
return;
|
|
175
|
+
const pending = this.queue;
|
|
176
|
+
this.queue = [];
|
|
177
|
+
for (const frame of pending)
|
|
178
|
+
this.push(frame);
|
|
179
|
+
}
|
|
180
|
+
push(frame) {
|
|
181
|
+
try {
|
|
182
|
+
this.socket?.send(JSON.stringify(frame));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
this.queue.push(frame);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
receive(raw) {
|
|
189
|
+
let frame;
|
|
190
|
+
try {
|
|
191
|
+
frame = JSON.parse(raw);
|
|
192
|
+
}
|
|
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)
|
|
202
|
+
return;
|
|
203
|
+
this.asks.delete(String(frame.askId));
|
|
204
|
+
resolve(frame.value ?? null);
|
|
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
|
+
reportActivity(activity) {
|
|
209
|
+
const message = activity?.message ?? null;
|
|
210
|
+
if (message === this.lastActivity)
|
|
211
|
+
return;
|
|
212
|
+
this.lastActivity = message;
|
|
213
|
+
this.send('activity', { message, kind: activity?.type });
|
|
214
|
+
}
|
|
215
|
+
errorOf(args) {
|
|
216
|
+
const failure = args?.[1] ?? args?.[0]?.error;
|
|
217
|
+
if (!failure)
|
|
218
|
+
return undefined;
|
|
219
|
+
if (typeof failure === 'string')
|
|
220
|
+
return failure;
|
|
221
|
+
if (typeof failure.message === 'string')
|
|
222
|
+
return failure.message;
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
commandPath(command) {
|
|
226
|
+
const parts = [];
|
|
227
|
+
let node = command;
|
|
228
|
+
while (node) {
|
|
229
|
+
parts.unshift(node.name());
|
|
230
|
+
node = node.parent;
|
|
231
|
+
}
|
|
232
|
+
return parts.slice(1).join(' ') || parts.join(' ');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
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 { ActionResult, type FocusedElement } 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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ActionResult } from './action-result.js';
|
|
2
2
|
import { detectFocusArea } from './utils/aria.js';
|
|
3
|
-
import { createDebug } from './utils/logger.js';
|
|
3
|
+
import { createDebug, tag } from './utils/logger.js';
|
|
4
4
|
import { slugify } from './utils/strings.js';
|
|
5
5
|
import { extractStatePath } from './utils/url-matcher.js';
|
|
6
6
|
const debugLog = createDebug('explorbot:state');
|
|
@@ -40,6 +40,8 @@ export class StateManager {
|
|
|
40
40
|
* Emit state change event to all listeners
|
|
41
41
|
*/
|
|
42
42
|
emitStateChange(event) {
|
|
43
|
+
const state = event.toState;
|
|
44
|
+
tag('data').log('state', { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 });
|
|
43
45
|
this.stateChangeListeners.forEach((listener) => {
|
|
44
46
|
try {
|
|
45
47
|
listener(event);
|
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;
|
|
@@ -111,6 +113,7 @@ export declare class Test extends Task {
|
|
|
111
113
|
finish(result?: TestResultType): void;
|
|
112
114
|
getDurationMs(): number | null;
|
|
113
115
|
getRemainingExpectations(): string[];
|
|
116
|
+
reportStatus(): void;
|
|
114
117
|
getLog(): Array<{
|
|
115
118
|
type: 'step' | 'note' | 'artifact';
|
|
116
119
|
content: string;
|
package/dist/src/test-plan.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { tag } from "./utils/logger.js";
|
|
2
3
|
import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from "./utils/test-plan-markdown.js";
|
|
3
4
|
import { uniqSessionName } from "./utils/unique-names.js";
|
|
4
5
|
export const TestResult = {
|
|
@@ -91,6 +92,13 @@ export class Task {
|
|
|
91
92
|
const timestamp = `${now}_${this.timestampCounter++}`;
|
|
92
93
|
this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
|
|
93
94
|
}
|
|
95
|
+
addObservation(message) {
|
|
96
|
+
const isDuplicate = Object.values(this.notes).some((note) => note.message === message);
|
|
97
|
+
if (isDuplicate)
|
|
98
|
+
return;
|
|
99
|
+
const now = performance.now();
|
|
100
|
+
this.notes[`${now}_${this.timestampCounter++}`] = { message, status: TestResult.FAILED, startTime: now, endTime: now, observation: true };
|
|
101
|
+
}
|
|
94
102
|
addUrlNote(state, prevState) {
|
|
95
103
|
const fullUrl = state.fullUrl || state.url;
|
|
96
104
|
if (!fullUrl)
|
|
@@ -253,12 +261,14 @@ export class Test extends Task {
|
|
|
253
261
|
this.startTime = performance.now();
|
|
254
262
|
this.addNote(`Test started. Session name: ${this.sessionName}`);
|
|
255
263
|
this.plan?.notifyChange();
|
|
264
|
+
this.reportStatus();
|
|
256
265
|
}
|
|
257
266
|
finish(result = TestResult.FAILED) {
|
|
258
267
|
this.status = TestStatus.DONE;
|
|
259
268
|
this.result = result;
|
|
260
269
|
this.endTime = performance.now();
|
|
261
270
|
this.plan?.notifyChange();
|
|
271
|
+
this.reportStatus();
|
|
262
272
|
}
|
|
263
273
|
getDurationMs() {
|
|
264
274
|
if (this.startTime != null && this.endTime != null)
|
|
@@ -269,6 +279,17 @@ export class Test extends Task {
|
|
|
269
279
|
const achieved = this.getCheckedExpectations();
|
|
270
280
|
return this.expected.filter((e) => !achieved.includes(e));
|
|
271
281
|
}
|
|
282
|
+
reportStatus() {
|
|
283
|
+
tag('data').log('test', {
|
|
284
|
+
scenario: this.scenario,
|
|
285
|
+
status: this.status,
|
|
286
|
+
result: this.result,
|
|
287
|
+
priority: this.priority,
|
|
288
|
+
sessionName: this.sessionName,
|
|
289
|
+
url: this.startUrl,
|
|
290
|
+
plan: this.plan?.title,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
272
293
|
getLog() {
|
|
273
294
|
const merged = {};
|
|
274
295
|
for (const [key, stepData] of Object.entries(this.steps)) {
|
|
@@ -331,6 +352,11 @@ export class Plan {
|
|
|
331
352
|
for (const listener of this.changeListeners) {
|
|
332
353
|
listener(this.tests);
|
|
333
354
|
}
|
|
355
|
+
tag('data').log('plan', {
|
|
356
|
+
title: this.title,
|
|
357
|
+
url: this.url,
|
|
358
|
+
tests: this.tests.map((test) => ({ scenario: test.scenario, status: test.status, result: test.result, priority: test.priority })),
|
|
359
|
+
});
|
|
334
360
|
}
|
|
335
361
|
getAllTests() {
|
|
336
362
|
if (!this.parentPlan)
|
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;
|
package/dist/src/utils/aria.js
CHANGED
|
@@ -78,8 +78,7 @@ const parseLabel = (label) => {
|
|
|
78
78
|
rest = rest.slice(nameMatch[0].length);
|
|
79
79
|
}
|
|
80
80
|
const attributes = {};
|
|
81
|
-
const attrMatch
|
|
82
|
-
if (attrMatch) {
|
|
81
|
+
for (const attrMatch of rest.matchAll(/\[([^\]]*)\]/g)) {
|
|
83
82
|
for (const tok of attrMatch[1].split(/[\s,]+/).filter(Boolean)) {
|
|
84
83
|
const eq = tok.indexOf('=');
|
|
85
84
|
if (eq === -1) {
|
|
@@ -189,7 +188,8 @@ const dropEmpty = (nodes, opts = {}) => nodes.flatMap((node) => {
|
|
|
189
188
|
// STEP 3 · Render: AriaNode[] → text or flat entries
|
|
190
189
|
// ─────────────────────────────────────────────────────────────────
|
|
191
190
|
// One-line representation of a node. Stable attr order so diff comparisons are deterministic.
|
|
192
|
-
const
|
|
191
|
+
const MAX_INLINE_VALUE = 400;
|
|
192
|
+
const formatNode = (node, offload) => {
|
|
193
193
|
let line = node.role;
|
|
194
194
|
if (node.name?.trim())
|
|
195
195
|
line += ` "${node.name.trim()}"`;
|
|
@@ -209,11 +209,20 @@ const formatNode = (node) => {
|
|
|
209
209
|
line += ` [${attrStr}]`;
|
|
210
210
|
if (node.value !== undefined && node.value !== null) {
|
|
211
211
|
const text = String(node.value).trim();
|
|
212
|
-
if (text)
|
|
212
|
+
if (text && text.length <= MAX_INLINE_VALUE)
|
|
213
213
|
line += `: ${text}`;
|
|
214
|
+
if (text && text.length > MAX_INLINE_VALUE)
|
|
215
|
+
line += `: ${offloadValue(text, offload)}`;
|
|
214
216
|
}
|
|
215
217
|
return line;
|
|
216
218
|
};
|
|
219
|
+
const offloadValue = (text, offload) => {
|
|
220
|
+
const head = `${text.slice(0, MAX_INLINE_VALUE)}…`;
|
|
221
|
+
const reference = offload?.(text);
|
|
222
|
+
if (!reference)
|
|
223
|
+
return `${head} (${text.length} chars, truncated)`;
|
|
224
|
+
return `${head} (${text.length} chars) [Full Text: ${reference}]`;
|
|
225
|
+
};
|
|
217
226
|
// Group consecutive same-role siblings. [a,a,b,a,a,a] → [[a,a],[b],[a,a,a]]
|
|
218
227
|
const groupByConsecutiveRole = (nodes) => nodes.reduce((groups, node) => {
|
|
219
228
|
const last = groups[groups.length - 1];
|
|
@@ -236,16 +245,16 @@ const collapseGroup = (group, depth) => {
|
|
|
236
245
|
};
|
|
237
246
|
const collapseSiblingGroups = (nodes, depth) => groupByConsecutiveRole(nodes).flatMap((group) => collapseGroup(group, depth));
|
|
238
247
|
// Tree → indented YAML text.
|
|
239
|
-
const renderTree = (nodes, depth = 0) => collapseSiblingGroups(nodes, depth)
|
|
248
|
+
const renderTree = (nodes, depth = 0, offload) => collapseSiblingGroups(nodes, depth)
|
|
240
249
|
.map((entry) => {
|
|
241
250
|
if ('placeholder' in entry)
|
|
242
251
|
return entry.placeholder;
|
|
243
252
|
const { node } = entry;
|
|
244
253
|
const indent = ' '.repeat(depth);
|
|
245
|
-
const head = `${indent}- ${formatNode(node)}`;
|
|
254
|
+
const head = `${indent}- ${formatNode(node, offload)}`;
|
|
246
255
|
if (node.children.length === 0)
|
|
247
256
|
return head;
|
|
248
|
-
return `${head}:\n${renderTree(node.children, depth + 1)}`;
|
|
257
|
+
return `${head}:\n${renderTree(node.children, depth + 1, offload)}`;
|
|
249
258
|
})
|
|
250
259
|
.join('\n');
|
|
251
260
|
// Build the structured "entry" object for an interactive node, or null if not worth keeping.
|
|
@@ -384,6 +393,42 @@ const detectToggles = (prev, curr) => {
|
|
|
384
393
|
}
|
|
385
394
|
return { toggled, togglePaths };
|
|
386
395
|
};
|
|
396
|
+
const detectValueChanges = (prev, curr) => {
|
|
397
|
+
const typed = [];
|
|
398
|
+
const typedPaths = new Set();
|
|
399
|
+
const currByPath = new Map(curr.map((e) => [e.path, e]));
|
|
400
|
+
for (const before of prev) {
|
|
401
|
+
const after = currByPath.get(before.path);
|
|
402
|
+
if (!after)
|
|
403
|
+
continue;
|
|
404
|
+
if (before.entry.role !== after.entry.role)
|
|
405
|
+
continue;
|
|
406
|
+
if (before.entry.name !== after.entry.name)
|
|
407
|
+
continue;
|
|
408
|
+
const was = valueWord(before.entry.value);
|
|
409
|
+
const now = valueWord(after.entry.value);
|
|
410
|
+
if (was === now)
|
|
411
|
+
continue;
|
|
412
|
+
typedPaths.add(before.path);
|
|
413
|
+
let label = String(after.entry.role);
|
|
414
|
+
const name = after.entry.name;
|
|
415
|
+
if (typeof name === 'string' && name.trim())
|
|
416
|
+
label += ` "${name.trim()}"`;
|
|
417
|
+
typed.push(`${label}: ${was} -> ${now}`);
|
|
418
|
+
}
|
|
419
|
+
return { typed, typedPaths };
|
|
420
|
+
};
|
|
421
|
+
const VALUE_EXCERPT = 60;
|
|
422
|
+
const valueWord = (value) => {
|
|
423
|
+
if (value === undefined || value === null)
|
|
424
|
+
return 'empty';
|
|
425
|
+
const text = String(value).trim();
|
|
426
|
+
if (!text)
|
|
427
|
+
return 'empty';
|
|
428
|
+
if (text.length <= VALUE_EXCERPT)
|
|
429
|
+
return JSON.stringify(text);
|
|
430
|
+
return `${JSON.stringify(text.slice(0, VALUE_EXCERPT))}… (${text.length} chars)`;
|
|
431
|
+
};
|
|
387
432
|
const TOP_DIFF_ITEMS = 10;
|
|
388
433
|
const formatDiffSection = (label, items) => {
|
|
389
434
|
const summary = countBy(items);
|
|
@@ -407,10 +452,15 @@ const formatDiffSection = (label, items) => {
|
|
|
407
452
|
}
|
|
408
453
|
return lines;
|
|
409
454
|
};
|
|
410
|
-
const formatDiff = (added, removed, toggled) => {
|
|
411
|
-
if (added.length === 0 && removed.length === 0 && toggled.length === 0)
|
|
455
|
+
const formatDiff = (added, removed, toggled, typed = []) => {
|
|
456
|
+
if (added.length === 0 && removed.length === 0 && toggled.length === 0 && typed.length === 0)
|
|
412
457
|
return null;
|
|
413
458
|
const sections = ['ariaDiff:'];
|
|
459
|
+
if (typed.length > 0) {
|
|
460
|
+
sections.push(' typed:');
|
|
461
|
+
for (const line of typed)
|
|
462
|
+
sections.push(` - ${line}`);
|
|
463
|
+
}
|
|
414
464
|
if (toggled.length > 0) {
|
|
415
465
|
sections.push(' toggled:');
|
|
416
466
|
for (const line of toggled)
|
|
@@ -461,17 +511,14 @@ const findDialogOrModal = (nodes) => {
|
|
|
461
511
|
}
|
|
462
512
|
return null;
|
|
463
513
|
};
|
|
464
|
-
|
|
465
|
-
// Public API — pipelines composed visibly, top-to-bottom
|
|
466
|
-
// ─────────────────────────────────────────────────────────────────
|
|
467
|
-
export const compactAriaSnapshot = (snapshot, keepNamed = false) => {
|
|
514
|
+
export const compactAriaSnapshot = (snapshot, keepNamed = false, offload) => {
|
|
468
515
|
if (!snapshot)
|
|
469
516
|
return '';
|
|
470
517
|
let tree = parseSnapshot(snapshot);
|
|
471
518
|
tree = unwrapIgnored(tree);
|
|
472
519
|
tree = nameIconButtons(tree);
|
|
473
520
|
tree = dropEmpty(tree, { keepNamed });
|
|
474
|
-
return renderTree(tree);
|
|
521
|
+
return renderTree(tree, 0, offload);
|
|
475
522
|
};
|
|
476
523
|
export const diffAriaSnapshots = (previous, current) => {
|
|
477
524
|
const flat = (snap) => {
|
|
@@ -484,15 +531,17 @@ export const diffAriaSnapshots = (previous, current) => {
|
|
|
484
531
|
const prevAll = flat(previous);
|
|
485
532
|
const currAll = flat(current);
|
|
486
533
|
const { toggled, togglePaths } = detectToggles(prevAll, currAll);
|
|
487
|
-
const
|
|
488
|
-
const
|
|
534
|
+
const { typed, typedPaths } = detectValueChanges(prevAll, currAll);
|
|
535
|
+
const skip = (entry) => togglePaths.has(entry.path) || typedPaths.has(entry.path);
|
|
536
|
+
const prev = prevAll.filter((e) => !skip(e));
|
|
537
|
+
const curr = currAll.filter((e) => !skip(e));
|
|
489
538
|
const prevTotals = countBy(prev.map((e) => e.summary));
|
|
490
539
|
const currTotals = countBy(curr.map((e) => e.summary));
|
|
491
540
|
const byCount = diffByCount(prevTotals, currTotals);
|
|
492
541
|
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
493
542
|
const added = [...byCount.added, ...renames.added];
|
|
494
543
|
const removed = [...byCount.removed, ...renames.removed];
|
|
495
|
-
return { text: formatDiff(added, removed, toggled), count: added.length + removed.length + toggled.length };
|
|
544
|
+
return { text: formatDiff(added, removed, toggled, typed), count: added.length + removed.length + toggled.length + typed.length };
|
|
496
545
|
};
|
|
497
546
|
export const detectFocusArea = (snapshot) => {
|
|
498
547
|
let tree = parseSnapshot(snapshot);
|
|
@@ -513,29 +562,9 @@ export const collectInteractiveNodes = (snapshot) => {
|
|
|
513
562
|
tree = dropEmpty(tree);
|
|
514
563
|
return flatten(tree).map((e) => e.entry);
|
|
515
564
|
};
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const focusedMatch = ariaSnapshot.match(/-\s*(\w+)\s+"([^"]*)"([^:\n]*)\[focused\](?::\s*(.*))?/);
|
|
520
|
-
if (!focusedMatch)
|
|
521
|
-
return null;
|
|
522
|
-
const [, role, name, attributesStr, value] = focusedMatch;
|
|
523
|
-
const attributes = [];
|
|
524
|
-
if (attributesStr) {
|
|
525
|
-
const attrMatches = attributesStr.matchAll(/\[([^\]]+)\]/g);
|
|
526
|
-
for (const match of attrMatches) {
|
|
527
|
-
if (match[1] !== 'focused') {
|
|
528
|
-
attributes.push(match[1]);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
const result = { role, name };
|
|
533
|
-
if (value)
|
|
534
|
-
result.value = value.trim();
|
|
535
|
-
if (attributes.length > 0)
|
|
536
|
-
result.attributes = attributes;
|
|
537
|
-
return result;
|
|
538
|
-
}
|
|
565
|
+
// ─────────────────────────────────────────────────────────────────
|
|
566
|
+
// Standalone helpers (regex on raw strings — not part of the pipeline)
|
|
567
|
+
// ─────────────────────────────────────────────────────────────────
|
|
539
568
|
export function parseAriaLocator(ariaStr) {
|
|
540
569
|
const trimmed = ariaStr.trim();
|
|
541
570
|
if (trimmed === '-' || trimmed === '' || trimmed === '"-"')
|
package/dist/src/utils/html.js
CHANGED
|
@@ -1376,6 +1376,7 @@ function cleanElement(element) {
|
|
|
1376
1376
|
attr.value = attr.value
|
|
1377
1377
|
.split(/\s+/)
|
|
1378
1378
|
.filter((className) => !/\d/.test(className))
|
|
1379
|
+
.filter((className) => !className.includes(':'))
|
|
1379
1380
|
.filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
|
|
1380
1381
|
.join(' ');
|
|
1381
1382
|
if (!attr.value) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Span } from '@opentelemetry/api';
|
|
2
|
-
export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input';
|
|
2
|
+
export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input' | 'data';
|
|
3
3
|
export interface TaggedLogEntry {
|
|
4
4
|
type: LogType;
|
|
5
5
|
content: string;
|
|
@@ -9,6 +9,10 @@ export interface TaggedLogEntry {
|
|
|
9
9
|
maxLines?: number;
|
|
10
10
|
}
|
|
11
11
|
type LogEntry = TaggedLogEntry;
|
|
12
|
+
export interface LogDestination {
|
|
13
|
+
isEnabled(): boolean;
|
|
14
|
+
write(entry: TaggedLogEntry): void;
|
|
15
|
+
}
|
|
12
16
|
export declare const tag: (type: LogType) => {
|
|
13
17
|
log: (...args: any[]) => void;
|
|
14
18
|
};
|
|
@@ -23,9 +27,11 @@ export declare const startLogCapture: () => void;
|
|
|
23
27
|
export declare const stopLogCapture: () => string[];
|
|
24
28
|
export declare const setVerboseMode: (enabled: boolean) => void;
|
|
25
29
|
export declare const setPreserveConsoleLogs: (enabled: boolean) => void;
|
|
30
|
+
export declare const setQuietMode: (enabled: boolean) => void;
|
|
26
31
|
export declare const isVerboseMode: () => boolean;
|
|
27
32
|
export declare const setDebugMode: (enabled: boolean) => void;
|
|
28
33
|
export declare const isDebugMode: () => boolean;
|
|
34
|
+
export declare const addDestination: (destination: LogDestination) => void;
|
|
29
35
|
export declare const registerLogPane: (addLog: (entry: LogEntry) => void) => void;
|
|
30
36
|
export declare const unregisterLogPane: (addLog: (entry: LogEntry) => void) => void;
|
|
31
37
|
export declare const addTruncateTag: (tagName: string) => void;
|