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.
Files changed (174) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +26 -8
  3. package/boat/api-tester/src/cli.ts +17 -0
  4. package/boat/api-tester/src/config.ts +4 -2
  5. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  6. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  7. package/boat/doc-collector/src/cli.ts +14 -1
  8. package/boat/doc-collector/src/config.ts +4 -2
  9. package/boat/prima/bin/prima-cli.ts +0 -0
  10. package/boat/prima/src/activity-line.ts +33 -0
  11. package/boat/prima/src/cli.ts +127 -86
  12. package/boat/prima/src/envelope.ts +102 -52
  13. package/boat/prima/src/prima.ts +567 -128
  14. package/boat/prima/src/pw-parser.ts +11 -1
  15. package/boat/prima/src/pw-registry.ts +4 -5
  16. package/boat/prima/src/session-log.ts +126 -0
  17. package/dist/bin/explorbot-cli.js +26 -8
  18. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  19. package/dist/boat/api-tester/src/cli.js +17 -0
  20. package/dist/boat/api-tester/src/config.js +4 -2
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  22. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  23. package/dist/boat/doc-collector/src/cli.js +14 -1
  24. package/dist/boat/doc-collector/src/config.js +4 -2
  25. package/dist/boat/prima/src/activity-line.js +30 -0
  26. package/dist/boat/prima/src/cli.js +109 -77
  27. package/dist/boat/prima/src/envelope.js +94 -44
  28. package/dist/boat/prima/src/prima.js +533 -119
  29. package/dist/boat/prima/src/pw-parser.js +13 -1
  30. package/dist/boat/prima/src/pw-registry.js +4 -5
  31. package/dist/boat/prima/src/session-log.js +108 -0
  32. package/dist/package.json +3 -2
  33. package/dist/rules/navigator/verification-actions.md +20 -0
  34. package/dist/src/action-result.d.ts +7 -0
  35. package/dist/src/action-result.js +4 -0
  36. package/dist/src/action.d.ts +2 -0
  37. package/dist/src/action.js +41 -2
  38. package/dist/src/ai/captain/web-mode.js +6 -3
  39. package/dist/src/ai/captain.js +2 -0
  40. package/dist/src/ai/navigator.d.ts +34 -0
  41. package/dist/src/ai/navigator.js +237 -181
  42. package/dist/src/ai/pilot.d.ts +7 -0
  43. package/dist/src/ai/pilot.js +90 -2
  44. package/dist/src/ai/provider.d.ts +2 -2
  45. package/dist/src/ai/provider.js +14 -23
  46. package/dist/src/ai/rerunner.js +2 -1
  47. package/dist/src/ai/researcher/cache.d.ts +2 -0
  48. package/dist/src/ai/researcher/cache.js +10 -2
  49. package/dist/src/ai/researcher.js +3 -2
  50. package/dist/src/ai/rules.js +17 -10
  51. package/dist/src/ai/session-analyst.js +2 -0
  52. package/dist/src/ai/task-agent.js +4 -1
  53. package/dist/src/ai/tester.d.ts +6 -3
  54. package/dist/src/ai/tester.js +50 -46
  55. package/dist/src/ai/tools.d.ts +14 -0
  56. package/dist/src/ai/tools.js +117 -37
  57. package/dist/src/commands/config-command.d.ts +51 -0
  58. package/dist/src/commands/config-command.js +117 -0
  59. package/dist/src/commands/index.js +2 -0
  60. package/dist/src/config.d.ts +9 -1
  61. package/dist/src/config.js +53 -4
  62. package/dist/src/execution-controller.d.ts +2 -0
  63. package/dist/src/execution-controller.js +6 -0
  64. package/dist/src/explorbot.d.ts +2 -1
  65. package/dist/src/explorbot.js +7 -2
  66. package/dist/src/explorer.js +2 -3
  67. package/dist/src/playwright-recorder.js +30 -0
  68. package/dist/src/remote.d.ts +55 -0
  69. package/dist/src/remote.js +235 -0
  70. package/dist/src/reporter.d.ts +1 -0
  71. package/dist/src/reporter.js +7 -1
  72. package/dist/src/state-manager.d.ts +2 -1
  73. package/dist/src/state-manager.js +3 -1
  74. package/dist/src/stats.d.ts +1 -0
  75. package/dist/src/stats.js +1 -0
  76. package/dist/src/test-plan.d.ts +3 -0
  77. package/dist/src/test-plan.js +26 -0
  78. package/dist/src/utils/aria.d.ts +2 -8
  79. package/dist/src/utils/aria.js +69 -40
  80. package/dist/src/utils/html.js +1 -0
  81. package/dist/src/utils/logger.d.ts +7 -1
  82. package/dist/src/utils/logger.js +32 -0
  83. package/dist/src/utils/page-readiness.js +18 -1
  84. package/dist/src/utils/url-matcher.js +3 -0
  85. package/dist/src/utils/web-element.d.ts +2 -0
  86. package/dist/src/utils/web-element.js +8 -0
  87. package/dist/src/utils/web-sandbox.d.ts +1 -1
  88. package/dist/src/utils/web-sandbox.js +2 -3
  89. package/docs/api-testing/basics.md +90 -0
  90. package/docs/api-testing/planning.md +57 -0
  91. package/docs/api-testing/running-tests.md +55 -0
  92. package/docs/assets/cloud-report.png +0 -0
  93. package/docs/assets/html-report.png +0 -0
  94. package/docs/assets/langfuse-trace.png +0 -0
  95. package/docs/assets/successful-explore-run.png +0 -0
  96. package/docs/basics/getting-started.md +140 -0
  97. package/docs/basics/prerequisites.md +63 -0
  98. package/docs/basics/providers.md +362 -0
  99. package/docs/basics/running.md +78 -0
  100. package/docs/contributing/ai-integration-tests.md +57 -0
  101. package/docs/contributing/contributing.md +90 -0
  102. package/docs/contributing/demo-videos.md +36 -0
  103. package/docs/contributing/npm-package.md +138 -0
  104. package/docs/contributing/observability.md +227 -0
  105. package/docs/contributing/regression-tests.md +103 -0
  106. package/docs/contributing/testing.md +95 -0
  107. package/docs/doc-collection/basics.md +128 -0
  108. package/docs/doc-collection/crawling.md +67 -0
  109. package/docs/doc-collection/interactive-mode.md +99 -0
  110. package/docs/index.json +87 -0
  111. package/docs/reference/commands.md +997 -0
  112. package/docs/reference/configuration.md +569 -0
  113. package/docs/reference/scripting.md +303 -0
  114. package/docs/reference/websocket.md +50 -0
  115. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  116. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  117. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  118. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  119. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  120. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  121. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  122. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  123. package/docs/web-testing/agents.md +158 -0
  124. package/docs/web-testing/automated-tests.md +134 -0
  125. package/docs/web-testing/basics.md +91 -0
  126. package/docs/web-testing/customization.md +131 -0
  127. package/docs/web-testing/hooks.md +238 -0
  128. package/docs/web-testing/page-interaction.md +84 -0
  129. package/docs/web-testing/planner.md +122 -0
  130. package/docs/web-testing/rerun.md +164 -0
  131. package/docs/web-testing/researcher.md +380 -0
  132. package/docs/workflow/agentic-usage.md +233 -0
  133. package/docs/workflow/application-spec.md +73 -0
  134. package/docs/workflow/ci.md +202 -0
  135. package/docs/workflow/knowledge.md +310 -0
  136. package/docs/workflow/planning-styles.md +67 -0
  137. package/docs/workflow/reporting.md +133 -0
  138. package/docs/workflow/test-plans.md +90 -0
  139. package/package.json +3 -2
  140. package/rules/navigator/verification-actions.md +20 -0
  141. package/src/action-result.ts +11 -0
  142. package/src/action.ts +43 -3
  143. package/src/ai/captain/web-mode.ts +6 -3
  144. package/src/ai/captain.ts +3 -0
  145. package/src/ai/navigator.ts +255 -186
  146. package/src/ai/pilot.ts +104 -2
  147. package/src/ai/provider.ts +14 -24
  148. package/src/ai/rerunner.ts +2 -1
  149. package/src/ai/researcher/cache.ts +12 -2
  150. package/src/ai/researcher.ts +3 -2
  151. package/src/ai/rules.ts +17 -10
  152. package/src/ai/session-analyst.ts +2 -0
  153. package/src/ai/task-agent.ts +3 -1
  154. package/src/ai/tester.ts +52 -45
  155. package/src/ai/tools.ts +136 -37
  156. package/src/commands/config-command.ts +146 -0
  157. package/src/commands/index.ts +2 -0
  158. package/src/config.ts +60 -5
  159. package/src/execution-controller.ts +8 -0
  160. package/src/explorbot.ts +7 -3
  161. package/src/explorer.ts +2 -2
  162. package/src/playwright-recorder.ts +23 -0
  163. package/src/remote.ts +244 -0
  164. package/src/reporter.ts +7 -1
  165. package/src/state-manager.ts +6 -2
  166. package/src/stats.ts +1 -0
  167. package/src/test-plan.ts +29 -0
  168. package/src/utils/aria.ts +65 -45
  169. package/src/utils/html.ts +1 -0
  170. package/src/utils/logger.ts +33 -2
  171. package/src/utils/page-readiness.ts +24 -1
  172. package/src/utils/url-matcher.ts +3 -0
  173. package/src/utils/web-element.ts +9 -0
  174. 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();
@@ -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>;
@@ -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.client.addTestRun(status, testData);
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);
@@ -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
@@ -7,6 +7,7 @@ export class Stats {
7
7
  static plans = 0;
8
8
  static mode;
9
9
  static focus;
10
+ static visionDisabled = false;
10
11
  static models = {};
11
12
  static recordTokens(_agent, model, usage) {
12
13
  if (!Stats.models[model]) {
@@ -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;
@@ -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)
@@ -3,17 +3,11 @@ export interface FocusAreaResult {
3
3
  type: 'dialog' | 'modal' | null;
4
4
  name: string | null;
5
5
  }
6
- export declare const compactAriaSnapshot: (snapshot: string | null, keepNamed?: boolean) => string;
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;
@@ -78,8 +78,7 @@ const parseLabel = (label) => {
78
78
  rest = rest.slice(nameMatch[0].length);
79
79
  }
80
80
  const attributes = {};
81
- const attrMatch = rest.match(/\[([^\]]*)\]/);
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 formatNode = (node) => {
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 prev = prevAll.filter((e) => !togglePaths.has(e.path));
488
- const curr = currAll.filter((e) => !togglePaths.has(e.path));
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
- export function extractFocusedElement(ariaSnapshot) {
517
- if (!ariaSnapshot)
518
- return null;
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 === '"-"')
@@ -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;