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.
Files changed (156) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +7 -1
  3. package/boat/api-tester/src/config.ts +4 -2
  4. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  5. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  6. package/boat/doc-collector/src/config.ts +4 -2
  7. package/boat/prima/bin/prima-cli.ts +0 -0
  8. package/boat/prima/src/activity-line.ts +33 -0
  9. package/boat/prima/src/cli.ts +115 -86
  10. package/boat/prima/src/envelope.ts +80 -52
  11. package/boat/prima/src/prima.ts +547 -128
  12. package/boat/prima/src/pw-parser.ts +11 -1
  13. package/boat/prima/src/pw-registry.ts +4 -5
  14. package/boat/prima/src/session-log.ts +126 -0
  15. package/dist/bin/explorbot-cli.js +7 -1
  16. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  17. package/dist/boat/api-tester/src/config.js +4 -2
  18. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  19. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  20. package/dist/boat/doc-collector/src/config.js +4 -2
  21. package/dist/boat/prima/src/activity-line.js +30 -0
  22. package/dist/boat/prima/src/cli.js +97 -77
  23. package/dist/boat/prima/src/envelope.js +70 -44
  24. package/dist/boat/prima/src/prima.js +515 -119
  25. package/dist/boat/prima/src/pw-parser.js +13 -1
  26. package/dist/boat/prima/src/pw-registry.js +4 -5
  27. package/dist/boat/prima/src/session-log.js +108 -0
  28. package/dist/package.json +3 -2
  29. package/dist/rules/navigator/verification-actions.md +20 -0
  30. package/dist/src/action-result.d.ts +7 -0
  31. package/dist/src/action-result.js +4 -0
  32. package/dist/src/action.d.ts +2 -0
  33. package/dist/src/action.js +36 -1
  34. package/dist/src/ai/captain/web-mode.js +6 -3
  35. package/dist/src/ai/captain.js +2 -0
  36. package/dist/src/ai/navigator.d.ts +7 -0
  37. package/dist/src/ai/navigator.js +10 -6
  38. package/dist/src/ai/pilot.d.ts +4 -0
  39. package/dist/src/ai/pilot.js +48 -2
  40. package/dist/src/ai/provider.js +2 -2
  41. package/dist/src/ai/rerunner.js +2 -1
  42. package/dist/src/ai/researcher.js +1 -1
  43. package/dist/src/ai/rules.js +17 -10
  44. package/dist/src/ai/task-agent.js +4 -1
  45. package/dist/src/ai/tester.d.ts +1 -1
  46. package/dist/src/ai/tester.js +33 -33
  47. package/dist/src/ai/tools.d.ts +14 -0
  48. package/dist/src/ai/tools.js +113 -36
  49. package/dist/src/config.d.ts +1 -0
  50. package/dist/src/config.js +13 -4
  51. package/dist/src/execution-controller.d.ts +2 -0
  52. package/dist/src/execution-controller.js +6 -0
  53. package/dist/src/explorbot.d.ts +2 -1
  54. package/dist/src/explorbot.js +3 -1
  55. package/dist/src/explorer.js +2 -3
  56. package/dist/src/playwright-recorder.js +30 -0
  57. package/dist/src/remote.d.ts +54 -0
  58. package/dist/src/remote.js +229 -0
  59. package/dist/src/reporter.d.ts +1 -0
  60. package/dist/src/reporter.js +7 -1
  61. package/dist/src/state-manager.d.ts +2 -1
  62. package/dist/src/stats.d.ts +1 -0
  63. package/dist/src/stats.js +1 -0
  64. package/dist/src/test-plan.d.ts +2 -0
  65. package/dist/src/test-plan.js +7 -0
  66. package/dist/src/utils/aria.d.ts +2 -8
  67. package/dist/src/utils/aria.js +69 -40
  68. package/dist/src/utils/html.js +1 -0
  69. package/dist/src/utils/logger.d.ts +6 -0
  70. package/dist/src/utils/logger.js +24 -0
  71. package/dist/src/utils/page-readiness.js +18 -1
  72. package/dist/src/utils/url-matcher.js +3 -0
  73. package/dist/src/utils/web-element.d.ts +2 -0
  74. package/dist/src/utils/web-element.js +8 -0
  75. package/dist/src/utils/web-sandbox.d.ts +1 -1
  76. package/dist/src/utils/web-sandbox.js +2 -3
  77. package/docs/api-testing/basics.md +90 -0
  78. package/docs/api-testing/planning.md +57 -0
  79. package/docs/api-testing/running-tests.md +55 -0
  80. package/docs/assets/cloud-report.png +0 -0
  81. package/docs/assets/html-report.png +0 -0
  82. package/docs/assets/langfuse-trace.png +0 -0
  83. package/docs/assets/successful-explore-run.png +0 -0
  84. package/docs/basics/getting-started.md +140 -0
  85. package/docs/basics/prerequisites.md +63 -0
  86. package/docs/basics/providers.md +362 -0
  87. package/docs/basics/running.md +78 -0
  88. package/docs/contributing/ai-integration-tests.md +57 -0
  89. package/docs/contributing/contributing.md +90 -0
  90. package/docs/contributing/demo-videos.md +36 -0
  91. package/docs/contributing/npm-package.md +138 -0
  92. package/docs/contributing/observability.md +227 -0
  93. package/docs/contributing/regression-tests.md +103 -0
  94. package/docs/contributing/testing.md +95 -0
  95. package/docs/doc-collection/basics.md +128 -0
  96. package/docs/doc-collection/crawling.md +67 -0
  97. package/docs/doc-collection/interactive-mode.md +99 -0
  98. package/docs/index.json +86 -0
  99. package/docs/reference/commands.md +994 -0
  100. package/docs/reference/configuration.md +569 -0
  101. package/docs/reference/scripting.md +303 -0
  102. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  103. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  104. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  105. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  106. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  107. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  108. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  109. package/docs/web-testing/agents.md +158 -0
  110. package/docs/web-testing/automated-tests.md +134 -0
  111. package/docs/web-testing/basics.md +91 -0
  112. package/docs/web-testing/customization.md +131 -0
  113. package/docs/web-testing/hooks.md +238 -0
  114. package/docs/web-testing/page-interaction.md +84 -0
  115. package/docs/web-testing/planner.md +122 -0
  116. package/docs/web-testing/rerun.md +164 -0
  117. package/docs/web-testing/researcher.md +380 -0
  118. package/docs/workflow/agentic-usage.md +233 -0
  119. package/docs/workflow/application-spec.md +73 -0
  120. package/docs/workflow/ci.md +202 -0
  121. package/docs/workflow/knowledge.md +310 -0
  122. package/docs/workflow/planning-styles.md +67 -0
  123. package/docs/workflow/reporting.md +133 -0
  124. package/docs/workflow/test-plans.md +90 -0
  125. package/package.json +3 -2
  126. package/rules/navigator/verification-actions.md +20 -0
  127. package/src/action-result.ts +11 -0
  128. package/src/action.ts +38 -2
  129. package/src/ai/captain/web-mode.ts +6 -3
  130. package/src/ai/captain.ts +3 -0
  131. package/src/ai/navigator.ts +14 -8
  132. package/src/ai/pilot.ts +53 -2
  133. package/src/ai/provider.ts +2 -4
  134. package/src/ai/rerunner.ts +2 -1
  135. package/src/ai/researcher.ts +1 -1
  136. package/src/ai/rules.ts +17 -10
  137. package/src/ai/task-agent.ts +3 -1
  138. package/src/ai/tester.ts +32 -33
  139. package/src/ai/tools.ts +132 -36
  140. package/src/config.ts +15 -4
  141. package/src/execution-controller.ts +8 -0
  142. package/src/explorbot.ts +4 -2
  143. package/src/explorer.ts +2 -2
  144. package/src/playwright-recorder.ts +23 -0
  145. package/src/remote.ts +238 -0
  146. package/src/reporter.ts +7 -1
  147. package/src/state-manager.ts +2 -1
  148. package/src/stats.ts +1 -0
  149. package/src/test-plan.ts +9 -0
  150. package/src/utils/aria.ts +65 -45
  151. package/src/utils/html.ts +1 -0
  152. package/src/utils/logger.ts +24 -1
  153. package/src/utils/page-readiness.ts +24 -1
  154. package/src/utils/url-matcher.ts +3 -0
  155. package/src/utils/web-element.ts +9 -0
  156. package/src/utils/web-sandbox.ts +3 -4
package/src/remote.ts ADDED
@@ -0,0 +1,238 @@
1
+ import type { Command } from 'commander';
2
+ import stripAnsi from 'strip-ansi';
3
+ import { type ActivityEntry, addActivityListener } from './activity.ts';
4
+ import { executionController } from './execution-controller.ts';
5
+ import { type LogDestination, type TaggedLogEntry, addDestination } from './utils/logger.ts';
6
+
7
+ const QUEUE_CAP = 1000;
8
+ const CONTENT_CAP = 8000;
9
+ const RECONNECT_BASE_MS = 500;
10
+ const RECONNECT_MAX_MS = 10_000;
11
+ const ASK_TIMEOUT_MS = 15 * 60_000;
12
+ const FLUSH_TIMEOUT_MS = 3000;
13
+
14
+ /**
15
+ * Streams a run to a remote UI over one WebSocket, dialling out so a child
16
+ * process and a CI bot are the same case.
17
+ *
18
+ * It **is** a LogDestination — that is the whole integration on the logger's
19
+ * side — and it answers asks by installing itself as the execution
20
+ * controller's input callback. Nothing else in explorbot knows it exists.
21
+ */
22
+ export class Remote implements LogDestination {
23
+ private url: string | null = null;
24
+ private socket: WebSocket | null = null;
25
+ private queue: Frame[] = [];
26
+ private reconnectDelay = RECONNECT_BASE_MS;
27
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
28
+ private asks = new Map<string, (value: string | null) => void>();
29
+ private askCounter = 0;
30
+ private lastActivity: string | null = null;
31
+
32
+ registerOption(program: Command): void {
33
+ program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
34
+ program.hook('preAction', (_thisCommand, actionCommand) => {
35
+ const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
36
+ if (!url) return;
37
+ this.attach(String(url), this.commandPath(actionCommand));
38
+ });
39
+ }
40
+
41
+ attach(url: string, command: string): void {
42
+ if (this.url) return;
43
+ this.url = url;
44
+ this.connect();
45
+
46
+ this.send('hello', { command, cwd: process.cwd(), pid: process.pid });
47
+ addDestination(this);
48
+ executionController.setInputCallback((prompt) => this.ask(prompt));
49
+ addActivityListener((activity) => this.reportActivity(activity));
50
+ }
51
+
52
+ isAttached(): boolean {
53
+ return !!this.url;
54
+ }
55
+
56
+ send(type: string, data: Record<string, unknown> = {}): void {
57
+ if (!this.url) return;
58
+ const frame: Frame = { type, ts: Date.now(), ...data };
59
+ if (this.socket?.readyState === WebSocket.OPEN) {
60
+ this.push(frame);
61
+ return;
62
+ }
63
+ this.queue.push(frame);
64
+ if (this.queue.length > QUEUE_CAP) this.queue.splice(0, this.queue.length - QUEUE_CAP);
65
+ }
66
+
67
+ ask(prompt: string): Promise<string | null> {
68
+ if (!this.url) return Promise.resolve(null);
69
+ this.askCounter++;
70
+ const askId = `ask-${this.askCounter}`;
71
+ this.send('ask', { askId, prompt });
72
+
73
+ return new Promise((resolve) => {
74
+ const timer = setTimeout(() => {
75
+ this.asks.delete(askId);
76
+ resolve(null);
77
+ }, ASK_TIMEOUT_MS);
78
+ this.asks.set(askId, (value) => {
79
+ clearTimeout(timer);
80
+ resolve(value);
81
+ });
82
+ });
83
+ }
84
+
85
+ async close(exitCode: number): Promise<void> {
86
+ if (!this.url) return;
87
+ this.send('result', { ok: exitCode === 0, exitCode });
88
+ await this.flush();
89
+
90
+ this.url = null;
91
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
92
+ // Whoever asks next has nobody to ask — leaving the callback installed would
93
+ // route them into a closed socket and park them until the ask times out.
94
+ executionController.clearInputCallback();
95
+ for (const resolve of this.asks.values()) resolve(null);
96
+ this.asks.clear();
97
+ this.queue = [];
98
+ this.socket?.close();
99
+ this.socket = null;
100
+ }
101
+
102
+ isEnabled(): boolean {
103
+ return this.isAttached();
104
+ }
105
+
106
+ /**
107
+ * The entry as the logger already built it, so the `LogType` reaches the UI
108
+ * as the frame's level and each kind is styled there instead of being scraped
109
+ * back out of flattened text.
110
+ */
111
+ write(entry: TaggedLogEntry): void {
112
+ if (entry.type === 'html') return;
113
+ let content = stripAnsi(entry.content ?? '');
114
+ if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
115
+ this.send('log', {
116
+ level: entry.type,
117
+ content,
118
+ namespace: entry.namespace,
119
+ error: this.errorOf(entry.originalArgs),
120
+ });
121
+ }
122
+
123
+ private connect(): void {
124
+ if (!this.url) return;
125
+
126
+ let opening: WebSocket;
127
+ try {
128
+ opening = new WebSocket(this.url);
129
+ } catch {
130
+ this.reconnect();
131
+ return;
132
+ }
133
+ this.socket = opening;
134
+
135
+ opening.addEventListener('open', () => {
136
+ this.reconnectDelay = RECONNECT_BASE_MS;
137
+ this.drain();
138
+ });
139
+
140
+ opening.addEventListener('message', (event: MessageEvent) => this.receive(String(event.data)));
141
+
142
+ opening.addEventListener('close', () => {
143
+ if (this.socket !== opening) return;
144
+ this.socket = null;
145
+ this.reconnect();
146
+ });
147
+
148
+ // A failed connection always closes too, which is where the retry is scheduled.
149
+ opening.addEventListener('error', () => {});
150
+ }
151
+
152
+ private reconnect(): void {
153
+ if (!this.url || this.reconnectTimer) return;
154
+ const delay = this.reconnectDelay;
155
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
156
+ this.reconnectTimer = setTimeout(() => {
157
+ this.reconnectTimer = null;
158
+ this.connect();
159
+ }, delay);
160
+ this.reconnectTimer.unref?.();
161
+ }
162
+
163
+ private async flush(): Promise<void> {
164
+ const deadline = Date.now() + FLUSH_TIMEOUT_MS;
165
+ while (Date.now() < deadline) {
166
+ if (this.socket?.readyState === WebSocket.OPEN) {
167
+ this.drain();
168
+ if (!this.queue.length && !this.socket.bufferedAmount) return;
169
+ }
170
+ await new Promise((resolve) => setTimeout(resolve, 25));
171
+ }
172
+ }
173
+
174
+ private drain(): void {
175
+ if (this.socket?.readyState !== WebSocket.OPEN) return;
176
+ const pending = this.queue;
177
+ this.queue = [];
178
+ for (const frame of pending) this.push(frame);
179
+ }
180
+
181
+ private push(frame: Frame): void {
182
+ try {
183
+ this.socket?.send(JSON.stringify(frame));
184
+ } catch {
185
+ this.queue.push(frame);
186
+ }
187
+ }
188
+
189
+ private receive(raw: string): void {
190
+ let frame: Frame;
191
+ try {
192
+ frame = JSON.parse(raw);
193
+ } catch {
194
+ return;
195
+ }
196
+ if (frame.type === 'interrupt') {
197
+ executionController.interrupt();
198
+ return;
199
+ }
200
+ const resolve = this.asks.get(String(frame.askId));
201
+ if (!resolve) return;
202
+ this.asks.delete(String(frame.askId));
203
+ resolve((frame.value as string | null) ?? null);
204
+ }
205
+
206
+ /** A new listener is primed with the current activity, which at attach time is
207
+ * nothing — so skip repeats, the priming null included. */
208
+ private reportActivity(activity: ActivityEntry | null): void {
209
+ const message = activity?.message ?? null;
210
+ if (message === this.lastActivity) return;
211
+ this.lastActivity = message;
212
+ this.send('activity', { message, kind: activity?.type });
213
+ }
214
+
215
+ private errorOf(args: any[] | undefined): string | undefined {
216
+ const failure = args?.[1] ?? args?.[0]?.error;
217
+ if (!failure) return undefined;
218
+ if (typeof failure === 'string') return failure;
219
+ if (typeof failure.message === 'string') return failure.message;
220
+ return undefined;
221
+ }
222
+
223
+ private commandPath(command: Command): string {
224
+ const parts: string[] = [];
225
+ let node: Command | null = command;
226
+ while (node) {
227
+ parts.unshift(node.name());
228
+ node = node.parent;
229
+ }
230
+ return parts.slice(1).join(' ') || parts.join(' ');
231
+ }
232
+ }
233
+
234
+ export const remote = new Remote();
235
+
236
+ /** Whatever the run wants to say. Not a schema — the UI renders what it knows
237
+ * and ignores the rest, so either side can start sending more at any time. */
238
+ type Frame = { type: string; ts: number; [key: string]: unknown };
package/src/reporter.ts CHANGED
@@ -283,13 +283,19 @@ export class Reporter {
283
283
 
284
284
  debugLog(testData);
285
285
 
286
- await this.client.addTestRun(status, testData);
286
+ await this.reportTestData(status, testData);
287
287
  debugLog(`Test reported: ${test.scenario} - ${status}`);
288
288
  } catch (error) {
289
289
  debugLog('Failed to report test:', error);
290
290
  }
291
291
  }
292
292
 
293
+ async reportTestData(status: string | null, testData: Record<string, unknown>): Promise<void> {
294
+ await this.startRun();
295
+ if (!this.isRunStarted) return;
296
+ await this.client.addTestRun(status, testData);
297
+ }
298
+
293
299
  async finishRun(): Promise<void> {
294
300
  if (!this.isRunStarted) {
295
301
  return;
@@ -1,4 +1,4 @@
1
- import { ActionResult } from './action-result.js';
1
+ import { type FocusedElement, ActionResult } from './action-result.js';
2
2
  import type { ExperienceTracker } from './experience-tracker.js';
3
3
  import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
4
4
  import { detectFocusArea } from './utils/aria.js';
@@ -46,6 +46,7 @@ export interface WebPageState {
46
46
  h4?: string;
47
47
  ariaSnapshot?: string | null;
48
48
  ariaSnapshotFile?: string;
49
+ focusedElement?: FocusedElement | null;
49
50
  links?: Link[];
50
51
  verifications?: Record<string, boolean>;
51
52
  }
package/src/stats.ts CHANGED
@@ -17,6 +17,7 @@ export class Stats {
17
17
  static plans = 0;
18
18
  static mode?: ExplorbotMode;
19
19
  static focus?: string;
20
+ static visionDisabled = false;
20
21
  static models: Record<string, TokenUsage> = {};
21
22
 
22
23
  static recordTokens(_agent: string, model: string, usage: TokenUsage): void {
package/src/test-plan.ts CHANGED
@@ -27,6 +27,7 @@ export interface Note {
27
27
  endTime: number;
28
28
  screenshot?: string;
29
29
  log?: string;
30
+ observation?: boolean;
30
31
  }
31
32
 
32
33
  export class ActiveNote {
@@ -131,6 +132,14 @@ export class Task {
131
132
  this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
132
133
  }
133
134
 
135
+ addObservation(message: string): void {
136
+ const isDuplicate = Object.values(this.notes).some((note) => note.message === message);
137
+ if (isDuplicate) return;
138
+
139
+ const now = performance.now();
140
+ this.notes[`${now}_${this.timestampCounter++}`] = { message, status: TestResult.FAILED, startTime: now, endTime: now, observation: true };
141
+ }
142
+
134
143
  addUrlNote(state: UrlNoteState, prevState?: { title?: string; h1?: string; h2?: string }): void {
135
144
  const fullUrl = state.fullUrl || state.url;
136
145
  if (!fullUrl) return;
package/src/utils/aria.ts CHANGED
@@ -85,8 +85,7 @@ const parseLabel = (label: string): { role: string; name?: string; attributes: R
85
85
  }
86
86
 
87
87
  const attributes: Record<string, string | boolean | null> = {};
88
- const attrMatch = rest.match(/\[([^\]]*)\]/);
89
- if (attrMatch) {
88
+ for (const attrMatch of rest.matchAll(/\[([^\]]*)\]/g)) {
90
89
  for (const tok of attrMatch[1].split(/[\s,]+/).filter(Boolean)) {
91
90
  const eq = tok.indexOf('=');
92
91
  if (eq === -1) {
@@ -192,7 +191,9 @@ const dropEmpty = (nodes: AriaNode[], opts: { keepNamed?: boolean } = {}): AriaN
192
191
  // ─────────────────────────────────────────────────────────────────
193
192
 
194
193
  // One-line representation of a node. Stable attr order so diff comparisons are deterministic.
195
- const formatNode = (node: AriaNode): string => {
194
+ const MAX_INLINE_VALUE = 400;
195
+
196
+ const formatNode = (node: AriaNode, offload?: ValueOffload): string => {
196
197
  let line = node.role;
197
198
  if (node.name?.trim()) line += ` "${node.name.trim()}"`;
198
199
  const attrStr = Object.keys(node.attributes)
@@ -208,11 +209,19 @@ const formatNode = (node: AriaNode): string => {
208
209
  if (attrStr) line += ` [${attrStr}]`;
209
210
  if (node.value !== undefined && node.value !== null) {
210
211
  const text = String(node.value).trim();
211
- if (text) line += `: ${text}`;
212
+ if (text && text.length <= MAX_INLINE_VALUE) line += `: ${text}`;
213
+ if (text && text.length > MAX_INLINE_VALUE) line += `: ${offloadValue(text, offload)}`;
212
214
  }
213
215
  return line;
214
216
  };
215
217
 
218
+ const offloadValue = (text: string, offload?: ValueOffload): string => {
219
+ const head = `${text.slice(0, MAX_INLINE_VALUE)}…`;
220
+ const reference = offload?.(text);
221
+ if (!reference) return `${head} (${text.length} chars, truncated)`;
222
+ return `${head} (${text.length} chars) [Full Text: ${reference}]`;
223
+ };
224
+
216
225
  // Group consecutive same-role siblings. [a,a,b,a,a,a] → [[a,a],[b],[a,a,a]]
217
226
  const groupByConsecutiveRole = (nodes: AriaNode[]): AriaNode[][] =>
218
227
  nodes.reduce<AriaNode[][]>((groups, node) => {
@@ -239,15 +248,15 @@ const collapseGroup = (group: AriaNode[], depth: number): RenderEntry[] => {
239
248
  const collapseSiblingGroups = (nodes: AriaNode[], depth: number): RenderEntry[] => groupByConsecutiveRole(nodes).flatMap((group) => collapseGroup(group, depth));
240
249
 
241
250
  // Tree → indented YAML text.
242
- const renderTree = (nodes: AriaNode[], depth = 0): string =>
251
+ const renderTree = (nodes: AriaNode[], depth = 0, offload?: ValueOffload): string =>
243
252
  collapseSiblingGroups(nodes, depth)
244
253
  .map((entry) => {
245
254
  if ('placeholder' in entry) return entry.placeholder;
246
255
  const { node } = entry;
247
256
  const indent = ' '.repeat(depth);
248
- const head = `${indent}- ${formatNode(node)}`;
257
+ const head = `${indent}- ${formatNode(node, offload)}`;
249
258
  if (node.children.length === 0) return head;
250
- return `${head}:\n${renderTree(node.children, depth + 1)}`;
259
+ return `${head}:\n${renderTree(node.children, depth + 1, offload)}`;
251
260
  })
252
261
  .join('\n');
253
262
 
@@ -382,6 +391,40 @@ const detectToggles = (prev: FlatEntry[], curr: FlatEntry[]): { toggled: string[
382
391
  return { toggled, togglePaths };
383
392
  };
384
393
 
394
+ const detectValueChanges = (prev: FlatEntry[], curr: FlatEntry[]): { typed: string[]; typedPaths: Set<string> } => {
395
+ const typed: string[] = [];
396
+ const typedPaths = new Set<string>();
397
+ const currByPath = new Map(curr.map((e) => [e.path, e]));
398
+
399
+ for (const before of prev) {
400
+ const after = currByPath.get(before.path);
401
+ if (!after) continue;
402
+ if (before.entry.role !== after.entry.role) continue;
403
+ if (before.entry.name !== after.entry.name) continue;
404
+
405
+ const was = valueWord(before.entry.value);
406
+ const now = valueWord(after.entry.value);
407
+ if (was === now) continue;
408
+
409
+ typedPaths.add(before.path);
410
+ let label = String(after.entry.role);
411
+ const name = after.entry.name;
412
+ if (typeof name === 'string' && name.trim()) label += ` "${name.trim()}"`;
413
+ typed.push(`${label}: ${was} -> ${now}`);
414
+ }
415
+ return { typed, typedPaths };
416
+ };
417
+
418
+ const VALUE_EXCERPT = 60;
419
+
420
+ const valueWord = (value: unknown): string => {
421
+ if (value === undefined || value === null) return 'empty';
422
+ const text = String(value).trim();
423
+ if (!text) return 'empty';
424
+ if (text.length <= VALUE_EXCERPT) return JSON.stringify(text);
425
+ return `${JSON.stringify(text.slice(0, VALUE_EXCERPT))}… (${text.length} chars)`;
426
+ };
427
+
385
428
  const TOP_DIFF_ITEMS = 10;
386
429
 
387
430
  const formatDiffSection = (label: string, items: string[]): string[] => {
@@ -406,9 +449,13 @@ const formatDiffSection = (label: string, items: string[]): string[] => {
406
449
  return lines;
407
450
  };
408
451
 
409
- const formatDiff = (added: string[], removed: string[], toggled: string[]): string | null => {
410
- if (added.length === 0 && removed.length === 0 && toggled.length === 0) return null;
452
+ const formatDiff = (added: string[], removed: string[], toggled: string[], typed: string[] = []): string | null => {
453
+ if (added.length === 0 && removed.length === 0 && toggled.length === 0 && typed.length === 0) return null;
411
454
  const sections = ['ariaDiff:'];
455
+ if (typed.length > 0) {
456
+ sections.push(' typed:');
457
+ for (const line of typed) sections.push(` - ${line}`);
458
+ }
412
459
  if (toggled.length > 0) {
413
460
  sections.push(' toggled:');
414
461
  for (const line of toggled) sections.push(` - ${line}`);
@@ -474,13 +521,15 @@ const findDialogOrModal = (nodes: AriaNode[]): FocusAreaResult | null => {
474
521
  // Public API — pipelines composed visibly, top-to-bottom
475
522
  // ─────────────────────────────────────────────────────────────────
476
523
 
477
- export const compactAriaSnapshot = (snapshot: string | null, keepNamed = false): string => {
524
+ export type ValueOffload = (value: string) => string | undefined;
525
+
526
+ export const compactAriaSnapshot = (snapshot: string | null, keepNamed = false, offload?: ValueOffload): string => {
478
527
  if (!snapshot) return '';
479
528
  let tree = parseSnapshot(snapshot);
480
529
  tree = unwrapIgnored(tree);
481
530
  tree = nameIconButtons(tree);
482
531
  tree = dropEmpty(tree, { keepNamed });
483
- return renderTree(tree);
532
+ return renderTree(tree, 0, offload);
484
533
  };
485
534
 
486
535
  export const diffAriaSnapshots = (previous: string | null, current: string | null): AriaDiff => {
@@ -494,15 +543,17 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
494
543
  const prevAll = flat(previous);
495
544
  const currAll = flat(current);
496
545
  const { toggled, togglePaths } = detectToggles(prevAll, currAll);
497
- const prev = prevAll.filter((e) => !togglePaths.has(e.path));
498
- const curr = currAll.filter((e) => !togglePaths.has(e.path));
546
+ const { typed, typedPaths } = detectValueChanges(prevAll, currAll);
547
+ const skip = (entry: FlatEntry) => togglePaths.has(entry.path) || typedPaths.has(entry.path);
548
+ const prev = prevAll.filter((e) => !skip(e));
549
+ const curr = currAll.filter((e) => !skip(e));
499
550
  const prevTotals = countBy(prev.map((e) => e.summary));
500
551
  const currTotals = countBy(curr.map((e) => e.summary));
501
552
  const byCount = diffByCount(prevTotals, currTotals);
502
553
  const renames = detectRenames(prev, curr, prevTotals, currTotals);
503
554
  const added = [...byCount.added, ...renames.added];
504
555
  const removed = [...byCount.removed, ...renames.removed];
505
- return { text: formatDiff(added, removed, toggled), count: added.length + removed.length + toggled.length };
556
+ return { text: formatDiff(added, removed, toggled, typed), count: added.length + removed.length + toggled.length + typed.length };
506
557
  };
507
558
 
508
559
  export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
@@ -531,37 +582,6 @@ export const collectInteractiveNodes = (snapshot: string | null): Array<Record<s
531
582
  // Standalone helpers (regex on raw strings — not part of the pipeline)
532
583
  // ─────────────────────────────────────────────────────────────────
533
584
 
534
- export interface FocusedElementInfo {
535
- role: string;
536
- name: string;
537
- value?: string;
538
- attributes?: string[];
539
- }
540
-
541
- export function extractFocusedElement(ariaSnapshot: string | null): FocusedElementInfo | null {
542
- if (!ariaSnapshot) return null;
543
-
544
- const focusedMatch = ariaSnapshot.match(/-\s*(\w+)\s+"([^"]*)"([^:\n]*)\[focused\](?::\s*(.*))?/);
545
- if (!focusedMatch) return null;
546
-
547
- const [, role, name, attributesStr, value] = focusedMatch;
548
-
549
- const attributes: string[] = [];
550
- if (attributesStr) {
551
- const attrMatches = attributesStr.matchAll(/\[([^\]]+)\]/g);
552
- for (const match of attrMatches) {
553
- if (match[1] !== 'focused') {
554
- attributes.push(match[1]);
555
- }
556
- }
557
- }
558
-
559
- const result: FocusedElementInfo = { role, name };
560
- if (value) result.value = value.trim();
561
- if (attributes.length > 0) result.attributes = attributes;
562
- return result;
563
- }
564
-
565
585
  export function parseAriaLocator(ariaStr: string): { role: string; text: string } | null {
566
586
  const trimmed = ariaStr.trim();
567
587
  if (trimmed === '-' || trimmed === '' || trimmed === '"-"') return null;
package/src/utils/html.ts CHANGED
@@ -1518,6 +1518,7 @@ function cleanElement(element: parse5TreeAdapter.Element): void {
1518
1518
  attr.value = attr.value
1519
1519
  .split(/\s+/)
1520
1520
  .filter((className) => !/\d/.test(className))
1521
+ .filter((className) => !className.includes(':'))
1521
1522
  .filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
1522
1523
  .join(' ');
1523
1524
 
@@ -24,7 +24,7 @@ export interface TaggedLogEntry {
24
24
 
25
25
  type LogEntry = TaggedLogEntry;
26
26
 
27
- interface LogDestination {
27
+ export interface LogDestination {
28
28
  isEnabled(): boolean;
29
29
  write(entry: TaggedLogEntry): void;
30
30
  }
@@ -79,9 +79,11 @@ const debugFilter = new DebugFilter();
79
79
  class ConsoleDestination implements LogDestination {
80
80
  private verboseMode = false;
81
81
  private forceEnabled = false;
82
+ private quiet = false;
82
83
  private recentSteps = new RecentStepFilter();
83
84
 
84
85
  isEnabled(): boolean {
86
+ if (this.quiet) return false;
85
87
  return this.forceEnabled || !process.env.INK_RUNNING;
86
88
  }
87
89
 
@@ -89,6 +91,10 @@ class ConsoleDestination implements LogDestination {
89
91
  this.forceEnabled = enabled;
90
92
  }
91
93
 
94
+ setQuiet(enabled: boolean): void {
95
+ this.quiet = enabled;
96
+ }
97
+
92
98
  setVerboseMode(enabled: boolean): void {
93
99
  this.verboseMode = enabled;
94
100
  }
@@ -136,6 +142,8 @@ class DebugDestination implements LogDestination {
136
142
 
137
143
  setVerboseMode(enabled: boolean): void {
138
144
  this.verboseMode = enabled;
145
+ // the debug package reads DEBUG when it loads, so a namespace turned on later needs enabling by hand
146
+ if (enabled) debug.enable(process.env.DEBUG || 'explorbot:*');
139
147
  }
140
148
 
141
149
  write(namespace: string, ...args: any[]): void {
@@ -332,6 +340,7 @@ class Logger {
332
340
  private span = new SpanDestination();
333
341
  public react = new ReactDestination();
334
342
  public captain = new CaptainDestination();
343
+ private extra: LogDestination[] = [];
335
344
  private truncateTags: string[] = ['page_html'];
336
345
 
337
346
  private constructor() {}
@@ -360,10 +369,19 @@ class Logger {
360
369
  this.console.forceEnable(enabled);
361
370
  }
362
371
 
372
+ setQuietMode(enabled: boolean): void {
373
+ this.console.setQuiet(enabled);
374
+ }
375
+
363
376
  isVerboseMode(): boolean {
364
377
  return this.debugDestination.isEnabled();
365
378
  }
366
379
 
380
+ addDestination(destination: LogDestination): void {
381
+ if (this.extra.includes(destination)) return;
382
+ this.extra.push(destination);
383
+ }
384
+
367
385
  registerLogPane(addLog: (entry: LogEntry) => void): void {
368
386
  this.react.registerLogPane(addLog);
369
387
  }
@@ -456,6 +474,9 @@ class Logger {
456
474
  if (this.file.isEnabled()) this.file.write(entry);
457
475
  if (this.span.isEnabled()) this.span.write(entry);
458
476
  if (this.captain.isEnabled()) this.captain.write(entry);
477
+ for (const destination of this.extra) {
478
+ if (destination.isEnabled()) destination.write(entry);
479
+ }
459
480
  if (process.env.INK_RUNNING) {
460
481
  this.react.write(entry);
461
482
  } else if (this.console.isEnabled()) {
@@ -537,10 +558,12 @@ export const startLogCapture = () => logger.captain.startCapture();
537
558
  export const stopLogCapture = () => logger.captain.stopCapture();
538
559
  export const setVerboseMode = (enabled: boolean) => logger.setVerboseMode(enabled);
539
560
  export const setPreserveConsoleLogs = (enabled: boolean) => logger.setPreserveConsoleLogs(enabled);
561
+ export const setQuietMode = (enabled: boolean) => logger.setQuietMode(enabled);
540
562
  export const isVerboseMode = () => logger.isVerboseMode();
541
563
  export const setDebugMode = (enabled: boolean) => logger.setDebugMode(enabled);
542
564
  export const isDebugMode = () => logger.isDebugMode();
543
565
 
566
+ export const addDestination = (destination: LogDestination) => logger.addDestination(destination);
544
567
  export const registerLogPane = (addLog: (entry: LogEntry) => void) => logger.registerLogPane(addLog);
545
568
  export const unregisterLogPane = (addLog: (entry: LogEntry) => void) => logger.unregisterLogPane(addLog);
546
569
  export const addTruncateTag = (tagName: string) => logger.addTruncateTag(tagName);
@@ -4,10 +4,33 @@ export async function waitForPageReadiness(page: any, options: PageReadinessOpti
4
4
  const timeout = options.timeout ?? 6000;
5
5
  await page.waitForLoadState?.('domcontentloaded', { timeout })?.catch(() => {});
6
6
 
7
- await Promise.race([waitForNetworkIdle(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => {});
7
+ await Promise.race([waitForNetworkIdle(page, timeout), waitForDomQuiet(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => {});
8
8
  await waitForPageBodyContent(page, timeout);
9
9
  }
10
10
 
11
+ const DOM_QUIET_MS = 350;
12
+
13
+ function waitForDomQuiet(page: any, timeout: number): Promise<void> {
14
+ if (!page?.waitForFunction) return new Promise(() => {});
15
+
16
+ return page
17
+ .waitForFunction(
18
+ (quiet: number) => {
19
+ const store = window as any;
20
+ if (!store.__explorbotDomQuiet) {
21
+ store.__explorbotDomQuiet = { last: Date.now() };
22
+ new MutationObserver(() => {
23
+ store.__explorbotDomQuiet.last = Date.now();
24
+ }).observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
25
+ }
26
+ return Date.now() - store.__explorbotDomQuiet.last >= quiet;
27
+ },
28
+ DOM_QUIET_MS,
29
+ { timeout, polling: 100 }
30
+ )
31
+ .catch(() => {});
32
+ }
33
+
11
34
  function waitForNetworkIdle(page: any, timeout: number): Promise<void> {
12
35
  if (!page?.waitForLoadState) return Promise.resolve();
13
36
  return page.waitForLoadState('networkidle', { timeout }).catch(() => {});
@@ -100,6 +100,9 @@ export function matchesNavigationUrl(expected: string, current: string): boolean
100
100
  if (!expectedPath.includes('#')) {
101
101
  currentPath = currentPath.split('#')[0];
102
102
  }
103
+ if (!expectedPath.includes('?')) {
104
+ currentPath = currentPath.split('?')[0];
105
+ }
103
106
  const normalize = (value: string) => value.replace(/^\/+|\/+$/g, '').toLowerCase();
104
107
  return normalize(expectedPath) === normalize(currentPath);
105
108
  }
@@ -125,6 +125,15 @@ export class WebElement {
125
125
  return WebElement.fromPlaywrightLocator(page.locator(`[${EXPLORBOT_ATTRS.eidx}="${eidx}"]`));
126
126
  }
127
127
 
128
+ static isAriaRef(ref: string): boolean {
129
+ return /^(f\d+)?e\d+$/i.test(ref);
130
+ }
131
+
132
+ static async fromAriaRef(page: any, ref: string): Promise<WebElement | null> {
133
+ if (!WebElement.isAriaRef(ref)) return null;
134
+ return WebElement.fromPlaywrightLocator(page.locator(`aria-ref=${ref}`));
135
+ }
136
+
128
137
  static async fromEidxList(page: any, eidxList: string[]): Promise<WebElement[]> {
129
138
  const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
130
139
  if (validEidxList.length === 0) return [];
@@ -28,13 +28,12 @@ export function playwrightSandbox(page: any, code: string): Promise<any> {
28
28
  return run(page);
29
29
  }
30
30
 
31
- export function codeceptJSSandbox(actor: any, codeOrFn: string | ((...args: any[]) => void)): void {
31
+ export function codeceptJSSandbox(actor: any, codeOrFn: string | ((...args: any[]) => void)): any {
32
32
  if (typeof codeOrFn === 'function') {
33
- codeOrFn(actor, tryTo, retryTo, within, hopeThat, step, faker);
34
- return;
33
+ return codeOrFn(actor, tryTo, retryTo, within, hopeThat, step, faker);
35
34
  }
36
35
  const run = createSandbox(CODECEPT_ARG_NAMES, codeOrFn);
37
- run(actor, tryTo, retryTo, within, hopeThat, step, faker);
36
+ return run(actor, tryTo, retryTo, within, hopeThat, step, faker);
38
37
  }
39
38
 
40
39
  function createSandbox(argNames: string[], body: string): (...args: any[]) => any {