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
@@ -1,8 +1,8 @@
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
  import { detectFocusArea } from './utils/aria.js';
5
- import { createDebug } from './utils/logger.js';
5
+ import { createDebug, tag } from './utils/logger.js';
6
6
  import { slugify } from './utils/strings.js';
7
7
  import { extractStatePath } from './utils/url-matcher.js';
8
8
 
@@ -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
  }
@@ -116,6 +117,9 @@ export class StateManager {
116
117
  * Emit state change event to all listeners
117
118
  */
118
119
  private emitStateChange(event: StateTransition): void {
120
+ const state = event.toState;
121
+ tag('data').log('state', { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 });
122
+
119
123
  this.stateChangeListeners.forEach((listener) => {
120
124
  try {
121
125
  listener(event);
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
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import figures from 'figures';
3
3
  import { WebPageState } from './state-manager.ts';
4
+ import { tag } from './utils/logger.ts';
4
5
  import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts';
5
6
  import { uniqSessionName } from './utils/unique-names.ts';
6
7
 
@@ -27,6 +28,7 @@ export interface Note {
27
28
  endTime: number;
28
29
  screenshot?: string;
29
30
  log?: string;
31
+ observation?: boolean;
30
32
  }
31
33
 
32
34
  export class ActiveNote {
@@ -131,6 +133,14 @@ export class Task {
131
133
  this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
132
134
  }
133
135
 
136
+ addObservation(message: string): void {
137
+ const isDuplicate = Object.values(this.notes).some((note) => note.message === message);
138
+ if (isDuplicate) return;
139
+
140
+ const now = performance.now();
141
+ this.notes[`${now}_${this.timestampCounter++}`] = { message, status: TestResult.FAILED, startTime: now, endTime: now, observation: true };
142
+ }
143
+
134
144
  addUrlNote(state: UrlNoteState, prevState?: { title?: string; h1?: string; h2?: string }): void {
135
145
  const fullUrl = state.fullUrl || state.url;
136
146
  if (!fullUrl) return;
@@ -308,6 +318,7 @@ export class Test extends Task {
308
318
  this.startTime = performance.now();
309
319
  this.addNote(`Test started. Session name: ${this.sessionName}`);
310
320
  this.plan?.notifyChange();
321
+ this.reportStatus();
311
322
  }
312
323
 
313
324
  finish(result: TestResultType = TestResult.FAILED): void {
@@ -315,6 +326,7 @@ export class Test extends Task {
315
326
  this.result = result;
316
327
  this.endTime = performance.now();
317
328
  this.plan?.notifyChange();
329
+ this.reportStatus();
318
330
  }
319
331
 
320
332
  getDurationMs(): number | null {
@@ -327,6 +339,18 @@ export class Test extends Task {
327
339
  return this.expected.filter((e) => !achieved.includes(e));
328
340
  }
329
341
 
342
+ private reportStatus(): void {
343
+ tag('data').log('test', {
344
+ scenario: this.scenario,
345
+ status: this.status,
346
+ result: this.result,
347
+ priority: this.priority,
348
+ sessionName: this.sessionName,
349
+ url: this.startUrl,
350
+ plan: this.plan?.title,
351
+ });
352
+ }
353
+
330
354
  override getLog(): Array<{ type: 'step' | 'note' | 'artifact'; content: string; timestamp: number }> {
331
355
  const merged: Record<string, { type: 'step' | 'note' | 'artifact'; content: string }> = {};
332
356
 
@@ -398,6 +422,11 @@ export class Plan {
398
422
  for (const listener of this.changeListeners) {
399
423
  listener(this.tests);
400
424
  }
425
+ tag('data').log('plan', {
426
+ title: this.title,
427
+ url: this.url,
428
+ tests: this.tests.map((test) => ({ scenario: test.scenario, status: test.status, result: test.result, priority: test.priority })),
429
+ });
401
430
  }
402
431
 
403
432
  getAllTests(): Test[] {
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
 
@@ -11,7 +11,7 @@ import { Observability } from '../observability.ts';
11
11
  import { RecentStepFilter } from './log-filters.ts';
12
12
  import { parseMarkdownToTerminal } from './markdown-terminal.ts';
13
13
 
14
- export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input';
14
+ export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input' | 'data';
15
15
 
16
16
  export interface TaggedLogEntry {
17
17
  type: LogType;
@@ -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
  }
@@ -440,6 +458,14 @@ class Logger {
440
458
  return;
441
459
  }
442
460
 
461
+ if (type === 'data') {
462
+ const entry: TaggedLogEntry = { type, content: String(args[0]), timestamp: new Date(), originalArgs: args };
463
+ for (const destination of this.extra) {
464
+ if (destination.isEnabled()) destination.write(entry);
465
+ }
466
+ return;
467
+ }
468
+
443
469
  const options = this.extractLogOptions(type, args);
444
470
  let content = this.processArgs(args);
445
471
  if (type === 'step' && args[0]?.toCode) {
@@ -456,6 +482,9 @@ class Logger {
456
482
  if (this.file.isEnabled()) this.file.write(entry);
457
483
  if (this.span.isEnabled()) this.span.write(entry);
458
484
  if (this.captain.isEnabled()) this.captain.write(entry);
485
+ for (const destination of this.extra) {
486
+ if (destination.isEnabled()) destination.write(entry);
487
+ }
459
488
  if (process.env.INK_RUNNING) {
460
489
  this.react.write(entry);
461
490
  } else if (this.console.isEnabled()) {
@@ -537,10 +566,12 @@ export const startLogCapture = () => logger.captain.startCapture();
537
566
  export const stopLogCapture = () => logger.captain.stopCapture();
538
567
  export const setVerboseMode = (enabled: boolean) => logger.setVerboseMode(enabled);
539
568
  export const setPreserveConsoleLogs = (enabled: boolean) => logger.setPreserveConsoleLogs(enabled);
569
+ export const setQuietMode = (enabled: boolean) => logger.setQuietMode(enabled);
540
570
  export const isVerboseMode = () => logger.isVerboseMode();
541
571
  export const setDebugMode = (enabled: boolean) => logger.setDebugMode(enabled);
542
572
  export const isDebugMode = () => logger.isDebugMode();
543
573
 
574
+ export const addDestination = (destination: LogDestination) => logger.addDestination(destination);
544
575
  export const registerLogPane = (addLog: (entry: LogEntry) => void) => logger.registerLogPane(addLog);
545
576
  export const unregisterLogPane = (addLog: (entry: LogEntry) => void) => logger.unregisterLogPane(addLog);
546
577
  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 {