explorbot 0.2.4 → 0.3.0

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 (113) hide show
  1. package/bin/explorbot-cli.ts +19 -7
  2. package/boat/api-tester/src/cli.ts +17 -0
  3. package/boat/doc-collector/src/cli.ts +14 -1
  4. package/boat/prima/README.md +96 -0
  5. package/boat/prima/package.json +14 -10
  6. package/boat/prima/src/cli.ts +29 -12
  7. package/boat/prima/src/envelope.ts +35 -13
  8. package/boat/prima/src/prima.ts +78 -45
  9. package/dist/bin/explorbot-cli.js +19 -7
  10. package/dist/boat/api-tester/src/cli.js +17 -0
  11. package/dist/boat/doc-collector/src/cli.js +14 -1
  12. package/dist/boat/prima/src/cli.js +26 -7
  13. package/dist/boat/prima/src/envelope.js +32 -8
  14. package/dist/boat/prima/src/prima.js +75 -43
  15. package/dist/models.json +4 -4
  16. package/dist/package.json +6 -2
  17. package/dist/src/action-result.d.ts +13 -0
  18. package/dist/src/action-result.js +46 -15
  19. package/dist/src/action.d.ts +5 -2
  20. package/dist/src/action.js +53 -18
  21. package/dist/src/ai/captain/web-mode.js +1 -2
  22. package/dist/src/ai/captain.d.ts +20 -0
  23. package/dist/src/ai/captain.js +10 -1
  24. package/dist/src/ai/driller.js +6 -2
  25. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  26. package/dist/src/ai/fisherman-tools.js +39 -0
  27. package/dist/src/ai/fisherman.js +2 -1
  28. package/dist/src/ai/navigator.d.ts +28 -0
  29. package/dist/src/ai/navigator.js +223 -175
  30. package/dist/src/ai/pilot.d.ts +7 -4
  31. package/dist/src/ai/pilot.js +89 -30
  32. package/dist/src/ai/planner/subpages.js +2 -16
  33. package/dist/src/ai/planner.js +1 -1
  34. package/dist/src/ai/provider.d.ts +2 -2
  35. package/dist/src/ai/provider.js +28 -22
  36. package/dist/src/ai/researcher/cache.d.ts +10 -3
  37. package/dist/src/ai/researcher/cache.js +23 -10
  38. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  39. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  40. package/dist/src/ai/researcher.js +6 -4
  41. package/dist/src/ai/rules.js +1 -5
  42. package/dist/src/ai/session-analyst.js +2 -0
  43. package/dist/src/ai/tester.d.ts +6 -3
  44. package/dist/src/ai/tester.js +30 -35
  45. package/dist/src/ai/tools.d.ts +8 -5
  46. package/dist/src/ai/tools.js +83 -57
  47. package/dist/src/commands/config-command.d.ts +51 -0
  48. package/dist/src/commands/config-command.js +117 -0
  49. package/dist/src/commands/index.js +2 -0
  50. package/dist/src/commands/init-command.js +13 -20
  51. package/dist/src/config.d.ts +8 -1
  52. package/dist/src/config.js +43 -1
  53. package/dist/src/experience-tracker.d.ts +2 -0
  54. package/dist/src/experience-tracker.js +12 -0
  55. package/dist/src/explorbot.js +5 -2
  56. package/dist/src/playwright-recorder.js +6 -12
  57. package/dist/src/remote.d.ts +3 -2
  58. package/dist/src/remote.js +8 -2
  59. package/dist/src/state-manager.d.ts +1 -1
  60. package/dist/src/state-manager.js +3 -1
  61. package/dist/src/test-plan.d.ts +9 -0
  62. package/dist/src/test-plan.js +30 -0
  63. package/dist/src/utils/html-diff.d.ts +5 -0
  64. package/dist/src/utils/html-diff.js +65 -6
  65. package/dist/src/utils/logger.d.ts +1 -1
  66. package/dist/src/utils/logger.js +8 -0
  67. package/dist/src/utils/strings.d.ts +2 -0
  68. package/dist/src/utils/strings.js +32 -0
  69. package/dist/src/utils/url-matcher.d.ts +1 -0
  70. package/dist/src/utils/url-matcher.js +31 -2
  71. package/docs/basics/getting-started.md +33 -10
  72. package/docs/basics/providers.md +6 -4
  73. package/docs/contributing/npm-package.md +73 -4
  74. package/docs/index.json +2 -1
  75. package/docs/reference/commands.md +3 -0
  76. package/docs/reference/websocket.md +50 -0
  77. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  78. package/models.json +4 -4
  79. package/package.json +6 -2
  80. package/src/action-result.ts +61 -16
  81. package/src/action.ts +56 -18
  82. package/src/ai/captain/web-mode.ts +1 -2
  83. package/src/ai/captain.ts +9 -1
  84. package/src/ai/driller.ts +6 -2
  85. package/src/ai/fisherman-tools.ts +35 -0
  86. package/src/ai/fisherman.ts +2 -1
  87. package/src/ai/navigator.ts +238 -179
  88. package/src/ai/pilot.ts +104 -36
  89. package/src/ai/planner/subpages.ts +2 -13
  90. package/src/ai/planner.ts +1 -1
  91. package/src/ai/provider.ts +29 -21
  92. package/src/ai/researcher/cache.ts +29 -11
  93. package/src/ai/researcher/deep-analysis.ts +1 -1
  94. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  95. package/src/ai/researcher.ts +6 -4
  96. package/src/ai/rules.ts +1 -5
  97. package/src/ai/session-analyst.ts +2 -0
  98. package/src/ai/tester.ts +33 -34
  99. package/src/ai/tools.ts +88 -61
  100. package/src/commands/config-command.ts +146 -0
  101. package/src/commands/index.ts +2 -0
  102. package/src/commands/init-command.ts +14 -20
  103. package/src/config.ts +47 -2
  104. package/src/experience-tracker.ts +13 -0
  105. package/src/explorbot.ts +4 -2
  106. package/src/playwright-recorder.ts +6 -11
  107. package/src/remote.ts +8 -2
  108. package/src/state-manager.ts +5 -2
  109. package/src/test-plan.ts +38 -0
  110. package/src/utils/html-diff.ts +72 -7
  111. package/src/utils/logger.ts +9 -1
  112. package/src/utils/strings.ts +36 -0
  113. package/src/utils/url-matcher.ts +27 -2
@@ -157,7 +157,7 @@ export class ConfigParser {
157
157
  const config = this.getConfig();
158
158
  if (!this.configPath)
159
159
  throw new Error('Config path not found');
160
- return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
160
+ return this.resolveProjectDir(config.dirs?.output || 'output');
161
161
  }
162
162
  getProjectRoot() {
163
163
  if (this.site)
@@ -168,6 +168,8 @@ export class ConfigParser {
168
168
  return process.cwd();
169
169
  }
170
170
  resolveProjectDir(relativeDir) {
171
+ if (path.isAbsolute(relativeDir))
172
+ return relativeDir;
171
173
  if (!this.configPath)
172
174
  return relativeDir;
173
175
  return path.join(this.getProjectRoot(), relativeDir);
@@ -231,6 +233,7 @@ export class ConfigParser {
231
233
  model: { modelId: 'test-model', provider: 'test' },
232
234
  config: {},
233
235
  vision: false,
236
+ langfuse: { enabled: false },
234
237
  },
235
238
  dirs: {
236
239
  knowledge: join(testBaseDir, 'knowledge'),
@@ -358,6 +361,17 @@ export class ConfigParser {
358
361
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
359
362
  config.playwright.url = options.baseUrl;
360
363
  }
364
+ if (config.ai) {
365
+ const langfuse = config.ai.langfuse;
366
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
367
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
368
+ config.ai.langfuse = {
369
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
370
+ publicKey,
371
+ secretKey,
372
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
373
+ };
374
+ }
361
375
  return config;
362
376
  }
363
377
  validateConfig(config) {
@@ -454,6 +468,34 @@ export function missingConfigMessage(configFile = 'explorbot.config.js') {
454
468
  Providers: ${Object.keys(PROVIDERS).join(', ')}
455
469
  `;
456
470
  }
471
+ export function modelName(model) {
472
+ if (typeof model === 'string')
473
+ return model;
474
+ return model?.modelId || model?.model || 'unknown';
475
+ }
476
+ export function modelProvider(model) {
477
+ const provider = model?.provider;
478
+ if (typeof provider === 'string')
479
+ return provider.split('.')[0];
480
+ if (typeof model === 'string')
481
+ return model.split('/')[0];
482
+ return '';
483
+ }
484
+ export function configuredModels(ai) {
485
+ if (!ai?.model)
486
+ return {};
487
+ const describe = (model) => ({ name: modelName(model), provider: modelProvider(model) });
488
+ const models = { model: describe(ai.model) };
489
+ if (ai.agenticModel)
490
+ models.agenticModel = describe(ai.agenticModel);
491
+ if (ai.visionModel)
492
+ models.visionModel = describe(ai.visionModel);
493
+ for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
494
+ if (agentConfig?.model)
495
+ models[agent] = describe(agentConfig.model);
496
+ }
497
+ return models;
498
+ }
457
499
  export async function resolveConfigModels(ai) {
458
500
  if (!ai)
459
501
  return;
@@ -53,6 +53,7 @@ export declare class ExperienceTracker {
53
53
  getExperienceTableOfContents(state: ActionResult, options?: {
54
54
  includeDescendantExperience?: boolean;
55
55
  }): ExperienceTocEntry[];
56
+ renderExperienceFor(state: ActionResult): string;
56
57
  renderExperienceTocFor(state: ActionResult): string;
57
58
  getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: {
58
59
  includeDescendantExperience?: boolean;
@@ -77,6 +78,7 @@ export declare class ExperienceTracker {
77
78
  fileHash: string;
78
79
  } | null;
79
80
  }
81
+ export declare function renderExperienceRecipes(recipes: string[]): string;
80
82
  export declare function renderExperienceToc(toc: ExperienceTocEntry[]): string;
81
83
  export interface ExperienceFile {
82
84
  filePath: string;
@@ -257,6 +257,13 @@ export class ExperienceTracker {
257
257
  });
258
258
  return this.buildToc(sorted);
259
259
  }
260
+ renderExperienceFor(state) {
261
+ const successful = this.getSuccessfulExperience(state);
262
+ if (!successful.length)
263
+ return '';
264
+ tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${state.url}`);
265
+ return renderExperienceRecipes(successful);
266
+ }
260
267
  renderExperienceTocFor(state) {
261
268
  const toc = this.getExperienceTableOfContents(state);
262
269
  if (toc.length === 0)
@@ -381,6 +388,11 @@ function indexToLetters(index) {
381
388
  }
382
389
  return result;
383
390
  }
391
+ export function renderExperienceRecipes(recipes) {
392
+ if (recipes.length === 0)
393
+ return '';
394
+ return `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${recipes.join('\n\n')}\n</experience>`;
395
+ }
384
396
  export function renderExperienceToc(toc) {
385
397
  if (toc.length === 0)
386
398
  return '';
@@ -199,7 +199,7 @@ export class ExplorBot {
199
199
  this.agents.tester = this.createAgent((deps) => {
200
200
  const researcher = this.agentResearcher();
201
201
  const navigator = this.agentNavigator();
202
- const tools = createAgentTools({ ...deps, researcher, navigator });
202
+ const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
203
203
  return new Tester(deps, researcher, navigator, tools);
204
204
  });
205
205
  const qm = this.agentQuartermaster();
@@ -418,9 +418,12 @@ export class ExplorBot {
418
418
  }
419
419
  setCurrentPlan(plan) {
420
420
  this.currentPlan = plan;
421
- if (plan && !this.sessionPlans.includes(plan)) {
421
+ if (!plan)
422
+ return;
423
+ if (!this.sessionPlans.includes(plan)) {
422
424
  this.sessionPlans.push(plan);
423
425
  }
426
+ plan.notifyChange();
424
427
  }
425
428
  getSessionTests() {
426
429
  return this.sessionPlans.flatMap((p) => p.tests.filter((t) => t.startTime != null));
@@ -6,7 +6,7 @@ const RECORDABLE = {
6
6
  Frame: new Set(['click', 'dblclick', 'fill', 'selectOption', 'press', 'type', 'check', 'uncheck', 'hover', 'tap', 'focus', 'setInputFiles', 'scrollIntoViewIfNeeded', 'dragTo', 'goto', 'setContent']),
7
7
  Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
8
8
  };
9
- const PLAYWRIGHT_INCOMPATIBLE = "Playwright output is not compatible with this Playwright version (playwright-core/lib/utils does not expose asLocator). Use output.framework: 'codeceptjs' instead, or pin Playwright to a version shipping lib/utils/isomorphic/locatorGenerators.js.";
9
+ const PLAYWRIGHT_INCOMPATIBLE = "Playwright output requires playwright-core 1.62 or newer (lib/coreBundle does not expose iso.asLocator). Use output.framework: 'codeceptjs' instead.";
10
10
  let cachedAsLocator = null;
11
11
  let asLocatorLoadAttempted = false;
12
12
  const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url);
@@ -16,17 +16,11 @@ function getAsLocator() {
16
16
  if (asLocatorLoadAttempted)
17
17
  throw new Error(PLAYWRIGHT_INCOMPATIBLE);
18
18
  asLocatorLoadAttempted = true;
19
- try {
20
- const mod = nodeRequire('playwright-core/lib/utils');
21
- if (typeof mod?.asLocator === 'function') {
22
- cachedAsLocator = mod.asLocator;
23
- return cachedAsLocator;
24
- }
25
- }
26
- catch {
27
- // Module not exported or not found
28
- }
29
- throw new Error(PLAYWRIGHT_INCOMPATIBLE);
19
+ const asLocator = nodeRequire('playwright-core/lib/coreBundle')?.iso?.asLocator;
20
+ if (typeof asLocator !== 'function')
21
+ throw new Error(PLAYWRIGHT_INCOMPATIBLE);
22
+ cachedAsLocator = asLocator;
23
+ return cachedAsLocator;
30
24
  }
31
25
  export class PlaywrightRecorder {
32
26
  context = null;
@@ -6,8 +6,9 @@ import { type LogDestination, type TaggedLogEntry } from './utils/logger.js';
6
6
  * process and a CI bot are the same case.
7
7
  *
8
8
  * It **is** a LogDestination — that is the whole integration on the logger's
9
- * side — and it answers asks by installing itself as the execution
10
- * controller's input callback. Nothing else in explorbot knows it exists.
9
+ * side, messages and `data` alike — and it answers asks by installing itself
10
+ * as the execution controller's input callback. Nothing else in explorbot
11
+ * knows it exists.
11
12
  */
12
13
  export declare class Remote implements LogDestination {
13
14
  url: string | null;
@@ -13,8 +13,9 @@ const FLUSH_TIMEOUT_MS = 3000;
13
13
  * process and a CI bot are the same case.
14
14
  *
15
15
  * It **is** a LogDestination — that is the whole integration on the logger's
16
- * side — and it answers asks by installing itself as the execution
17
- * controller's input callback. Nothing else in explorbot knows it exists.
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.
18
19
  */
19
20
  export class Remote {
20
21
  url = null;
@@ -105,6 +106,11 @@ export class Remote {
105
106
  write(entry) {
106
107
  if (entry.type === 'html')
107
108
  return;
109
+ if (entry.type === 'data') {
110
+ const [kind, payload] = entry.originalArgs || [];
111
+ this.send(String(kind), payload);
112
+ return;
113
+ }
108
114
  let content = stripAnsi(entry.content ?? '');
109
115
  if (content.length > CONTENT_CAP)
110
116
  content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
@@ -1,4 +1,4 @@
1
- import { type FocusedElement, 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 {
@@ -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);
@@ -1,3 +1,4 @@
1
+ import type { ActionResult } from './action-result.js';
1
2
  import { WebPageState } from './state-manager.js';
2
3
  export declare const TestResult: {
3
4
  readonly PASSED: "passed";
@@ -94,10 +95,13 @@ export declare class Test extends Task {
94
95
  startTime?: number;
95
96
  endTime?: number;
96
97
  resetCount: number;
98
+ appliedExperience: AppliedExperience[];
97
99
  constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps?: string[]);
98
100
  getVisitedUrls({ localOnly }?: {
99
101
  localOnly?: boolean;
100
102
  }): string[];
103
+ applyExperience(recipes: AppliedExperience[]): void;
104
+ getAppliedExperience(state: ActionResult): string[];
101
105
  addArtifact(artifact?: string): void;
102
106
  get hasFinished(): boolean;
103
107
  get isSuccessful(): boolean;
@@ -113,6 +117,7 @@ export declare class Test extends Task {
113
117
  finish(result?: TestResultType): void;
114
118
  getDurationMs(): number | null;
115
119
  getRemainingExpectations(): string[];
120
+ reportStatus(): void;
116
121
  getLog(): Array<{
117
122
  type: 'step' | 'note' | 'artifact';
118
123
  content: string;
@@ -165,4 +170,8 @@ interface UrlNoteState {
165
170
  h2?: string;
166
171
  screenshotFile?: string;
167
172
  }
173
+ interface AppliedExperience {
174
+ url: string;
175
+ content: string;
176
+ }
168
177
  export {};
@@ -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 = {
@@ -189,6 +190,7 @@ export class Test extends Task {
189
190
  startTime;
190
191
  endTime;
191
192
  resetCount = 0;
193
+ appliedExperience = [];
192
194
  constructor(scenario, priority, expectedOutcome, startUrl, plannedSteps = []) {
193
195
  super(scenario, startUrl);
194
196
  this.scenario = scenario;
@@ -207,6 +209,16 @@ export class Test extends Task {
207
209
  }
208
210
  return [...new Set([this.startUrl, ...this.states.map((s) => s.url)].filter((value) => Boolean(value) && value.trim() !== ''))];
209
211
  }
212
+ applyExperience(recipes) {
213
+ for (const recipe of recipes) {
214
+ if (this.appliedExperience.some((applied) => applied.content === recipe.content))
215
+ continue;
216
+ this.appliedExperience.push(recipe);
217
+ }
218
+ }
219
+ getAppliedExperience(state) {
220
+ return this.appliedExperience.filter((recipe) => state.isRelevantExperienceRecord({ url: recipe.url })).map((recipe) => recipe.content);
221
+ }
210
222
  addArtifact(artifact) {
211
223
  if (!artifact)
212
224
  return;
@@ -260,12 +272,14 @@ export class Test extends Task {
260
272
  this.startTime = performance.now();
261
273
  this.addNote(`Test started. Session name: ${this.sessionName}`);
262
274
  this.plan?.notifyChange();
275
+ this.reportStatus();
263
276
  }
264
277
  finish(result = TestResult.FAILED) {
265
278
  this.status = TestStatus.DONE;
266
279
  this.result = result;
267
280
  this.endTime = performance.now();
268
281
  this.plan?.notifyChange();
282
+ this.reportStatus();
269
283
  }
270
284
  getDurationMs() {
271
285
  if (this.startTime != null && this.endTime != null)
@@ -276,6 +290,17 @@ export class Test extends Task {
276
290
  const achieved = this.getCheckedExpectations();
277
291
  return this.expected.filter((e) => !achieved.includes(e));
278
292
  }
293
+ reportStatus() {
294
+ tag('data').log('test', {
295
+ scenario: this.scenario,
296
+ status: this.status,
297
+ result: this.result,
298
+ priority: this.priority,
299
+ sessionName: this.sessionName,
300
+ url: this.startUrl,
301
+ plan: this.plan?.title,
302
+ });
303
+ }
279
304
  getLog() {
280
305
  const merged = {};
281
306
  for (const [key, stepData] of Object.entries(this.steps)) {
@@ -338,6 +363,11 @@ export class Plan {
338
363
  for (const listener of this.changeListeners) {
339
364
  listener(this.tests);
340
365
  }
366
+ tag('data').log('plan', {
367
+ title: this.title,
368
+ url: this.url,
369
+ tests: this.tests.map((test) => ({ scenario: test.scenario, status: test.status, result: test.result, priority: test.priority })),
370
+ });
341
371
  }
342
372
  getAllTests() {
343
373
  if (!this.parentPlan)
@@ -11,9 +11,14 @@ export interface HtmlDiffResult {
11
11
  removed: string[];
12
12
  similarity: number;
13
13
  summary: string;
14
+ messages: string[];
14
15
  }
15
16
  export declare function computeHtmlFingerprint(html: string): string[];
16
17
  /**
17
18
  * Compares two HTML documents and returns differences along with a diff subtree.
18
19
  */
19
20
  export declare function htmlDiff(originalHtml: string, modifiedHtml: string, htmlConfig?: HtmlConfig): Promise<HtmlDiffResult>;
21
+ /**
22
+ * Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
23
+ */
24
+ export declare function liveRegionMessages(originalHtml: string, modifiedHtml: string): string[];
@@ -2,6 +2,10 @@ import { parse, serialize } from 'parse5';
2
2
  import { TAILWIND_CLASS_PATTERNS, TRASH_HTML_CLASSES, minifyHtml } from "./html.js";
3
3
  import { isDynamicId, isGenericClass } from "./xpath.js";
4
4
  const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
5
+ const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
6
+ const TEXT_LINE_PREFIX = 'TEXT:';
7
+ const MESSAGE_MAX_LENGTH = 200;
8
+ const MESSAGE_LIMIT = 8;
5
9
  /**
6
10
  * Get text content from an element node.
7
11
  */
@@ -141,7 +145,9 @@ export async function htmlDiff(originalHtml, modifiedHtml, htmlConfig) {
141
145
  const modifiedLines = flattenHtml(modifiedRoot);
142
146
  const similarity = calculateSimilarity(originalLines, modifiedLines);
143
147
  const { added, removed } = findDifferences(originalLines, modifiedLines);
144
- const parts = await buildDiffParts(originalDocument, modifiedDocument);
148
+ const originalMap = collectElementMap(originalDocument);
149
+ const modifiedMap = collectElementMap(modifiedDocument);
150
+ const parts = await buildDiffParts(originalMap, modifiedMap);
145
151
  const structuralAdditions = parts.flatMap((p) => p.added.filter((a) => a.startsWith('ELEMENT:')));
146
152
  const allAdded = [...added, ...structuralAdditions];
147
153
  const totalChanges = allAdded.length + removed.length;
@@ -152,8 +158,63 @@ export async function htmlDiff(originalHtml, modifiedHtml, htmlConfig) {
152
158
  removed,
153
159
  similarity,
154
160
  summary,
161
+ messages: collectMessages(originalMap, modifiedMap, allAdded),
155
162
  };
156
163
  }
164
+ /**
165
+ * Text the app announced while the page stayed the same: live region content first, then any other text that appeared.
166
+ */
167
+ function collectMessages(originalMap, modifiedMap, added) {
168
+ const appearedText = added.filter((line) => line.startsWith(TEXT_LINE_PREFIX)).map((line) => line.slice(TEXT_LINE_PREFIX.length));
169
+ return limitMessages([...collectLiveRegionTexts(originalMap, modifiedMap), ...appearedText]);
170
+ }
171
+ /**
172
+ * Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
173
+ */
174
+ export function liveRegionMessages(originalHtml, modifiedHtml) {
175
+ const originalMap = collectElementMap(parseDocument(originalHtml));
176
+ const modifiedMap = collectElementMap(parseDocument(modifiedHtml));
177
+ return limitMessages(collectLiveRegionTexts(originalMap, modifiedMap));
178
+ }
179
+ function limitMessages(candidates) {
180
+ const messages = [];
181
+ for (const candidate of candidates) {
182
+ const text = candidate.replace(/\s+/g, ' ').trim().slice(0, MESSAGE_MAX_LENGTH);
183
+ if (!text)
184
+ continue;
185
+ if (messages.some((message) => message.includes(text)))
186
+ continue;
187
+ messages.push(text);
188
+ if (messages.length === MESSAGE_LIMIT)
189
+ break;
190
+ }
191
+ return messages;
192
+ }
193
+ function collectLiveRegionTexts(originalMap, modifiedMap) {
194
+ const texts = [];
195
+ for (const [path, element] of modifiedMap) {
196
+ if (!isLiveRegion(element))
197
+ continue;
198
+ const text = getTextContent(element).trim();
199
+ if (!text)
200
+ continue;
201
+ const previous = originalMap.get(path);
202
+ if (previous && getTextContent(previous).trim() === text)
203
+ continue;
204
+ texts.push(text);
205
+ }
206
+ return texts;
207
+ }
208
+ function isLiveRegion(element) {
209
+ if (element.tagName?.toLowerCase() === 'output')
210
+ return true;
211
+ const attrs = element.attrs ?? [];
212
+ const role = attrs.find((attr) => attr.name === 'role')?.value.toLowerCase();
213
+ if (role && LIVE_REGION_ROLES.has(role))
214
+ return true;
215
+ const live = attrs.find((attr) => attr.name === 'aria-live')?.value.toLowerCase();
216
+ return live === 'polite' || live === 'assertive';
217
+ }
157
218
  /**
158
219
  * Parse HTML into a document, wrapping fragments with html/body for consistency.
159
220
  * Uses custom sanitization that removes iframes for diff purposes.
@@ -354,9 +415,7 @@ function findStableContainer(topLevelPath, originalMap, modifiedMap) {
354
415
  }
355
416
  return { path: 'html[1]/body[1]', selector: 'body' };
356
417
  }
357
- async function buildDiffParts(originalDocument, modifiedDocument) {
358
- const originalMap = collectElementMap(originalDocument);
359
- const modifiedMap = collectElementMap(modifiedDocument);
418
+ async function buildDiffParts(originalMap, modifiedMap) {
360
419
  const addedPaths = [];
361
420
  const changedPaths = [];
362
421
  for (const [path, element] of modifiedMap.entries()) {
@@ -624,7 +683,7 @@ function flattenHtml(node) {
624
683
  function process(n) {
625
684
  if (n.type === 'text' && n.content) {
626
685
  if (n.content.length >= 5) {
627
- lines.push(`TEXT:${n.content}`);
686
+ lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
628
687
  }
629
688
  return;
630
689
  }
@@ -643,7 +702,7 @@ function flattenHtml(node) {
643
702
  return;
644
703
  }
645
704
  if (n.content && n.content.length >= 5) {
646
- lines.push(`TEXT:${n.content}`);
705
+ lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
647
706
  }
648
707
  if (n.children) {
649
708
  n.children.forEach((child) => process(child));
@@ -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;
@@ -406,6 +406,14 @@ class Logger {
406
406
  }
407
407
  return;
408
408
  }
409
+ if (type === 'data') {
410
+ const entry = { type, content: String(args[0]), timestamp: new Date(), originalArgs: args };
411
+ for (const destination of this.extra) {
412
+ if (destination.isEnabled())
413
+ destination.write(entry);
414
+ }
415
+ return;
416
+ }
409
417
  const options = this.extractLogOptions(type, args);
410
418
  let content = this.processArgs(args);
411
419
  if (type === 'step' && args[0]?.toCode) {
@@ -3,3 +3,5 @@ export declare function slugify(text: string): string;
3
3
  export declare function normalizeInlineText(text: string): string;
4
4
  export declare function sanitizeFilename(name: string): string;
5
5
  export declare function safeFilename(name: string, ext?: string, maxBytes?: number): string;
6
+ export declare function truncate(text: string, max: number): string;
7
+ export declare function compactErrorMessage(error: unknown): string;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import stripAnsi from 'strip-ansi';
2
3
  export function truncateJson(input) {
3
4
  if (!input)
4
5
  return '';
@@ -36,3 +37,34 @@ export function safeFilename(name, ext = '', maxBytes = 240) {
36
37
  }
37
38
  return truncated + suffix + ext;
38
39
  }
40
+ export function truncate(text, max) {
41
+ if (text.length <= max)
42
+ return text;
43
+ return `${text.slice(0, max - 3)}...`;
44
+ }
45
+ const MAX_COMPACT_ERROR = 400;
46
+ export function compactErrorMessage(error) {
47
+ let text = stripAnsi(String(error));
48
+ for (const strip of STRIP_STRATEGIES) {
49
+ text = strip(text);
50
+ }
51
+ return truncate(text, MAX_COMPACT_ERROR);
52
+ }
53
+ function stripCallLog(text) {
54
+ const CALL_LOG = 'Call log:';
55
+ const NOISE = ['attempting', 'retrying', 'waiting'];
56
+ const [headline, ...log] = text.split(CALL_LOG);
57
+ if (!log.length)
58
+ return text;
59
+ const lines = new Set();
60
+ for (const line of log.join(CALL_LOG).split('\n')) {
61
+ const cleaned = normalizeInlineText(line);
62
+ if (!cleaned)
63
+ continue;
64
+ if (NOISE.some((noise) => cleaned.includes(noise)))
65
+ continue;
66
+ lines.add(cleaned);
67
+ }
68
+ return [headline.trim(), ...lines].join(' ');
69
+ }
70
+ const STRIP_STRATEGIES = [stripCallLog];
@@ -1,5 +1,6 @@
1
1
  export declare function isDynamicSegment(segment: string): boolean;
2
2
  export declare function hasDynamicUrlSegment(url: string): boolean;
3
+ export declare function isSamePageFamily(urlA: string, urlB: string): boolean;
3
4
  export declare function generalizeSegment(segment: string): string;
4
5
  export declare function generalizeUrl(url: string): string;
5
6
  export declare function matchesUrl(pattern: string, path: string): boolean;
@@ -32,6 +32,23 @@ export function isDynamicSegment(segment) {
32
32
  export function hasDynamicUrlSegment(url) {
33
33
  return url.split('/').some((seg) => seg.length > 0 && isDynamicSegment(seg));
34
34
  }
35
+ export function isSamePageFamily(urlA, urlB) {
36
+ const partsA = new URL(urlA, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
37
+ const partsB = new URL(urlB, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
38
+ if (partsA.length !== partsB.length)
39
+ return false;
40
+ let diffCount = 0;
41
+ for (let i = 0; i < partsA.length; i++) {
42
+ if (partsA[i] === partsB[i])
43
+ continue;
44
+ diffCount++;
45
+ if (diffCount > 1)
46
+ return false;
47
+ if (!isDynamicSegment(partsA[i]) || !isDynamicSegment(partsB[i]))
48
+ return false;
49
+ }
50
+ return true;
51
+ }
35
52
  export function generalizeSegment(segment) {
36
53
  if (/^\d+$/.test(segment))
37
54
  return '\\d+';
@@ -111,6 +128,18 @@ export function matchesNavigationUrl(expected, current) {
111
128
  if (!expectedPath.includes('?')) {
112
129
  currentPath = currentPath.split('?')[0];
113
130
  }
114
- const normalize = (value) => value.replace(/^\/+|\/+$/g, '').toLowerCase();
115
- return normalize(expectedPath) === normalize(currentPath);
131
+ const normalize = (value) => value.replace(/^\/+|\/+$/g, '');
132
+ const expectedNormalized = normalize(expectedPath);
133
+ const currentNormalized = normalize(currentPath);
134
+ const expectedKey = expectedNormalized.toLowerCase();
135
+ const currentKey = currentNormalized.toLowerCase();
136
+ if (expectedKey === currentKey)
137
+ return true;
138
+ if (!currentKey.startsWith(`${expectedKey}/`))
139
+ return false;
140
+ const recordSegments = currentNormalized
141
+ .slice(expectedKey.length + 1)
142
+ .split('/')
143
+ .filter(Boolean);
144
+ return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
116
145
  }