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
@@ -9,12 +9,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import path, { basename, dirname, join, resolve } from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
12
13
  import { parseEnv } from 'node:util';
13
14
  import dedent from 'dedent';
14
15
  import matter from 'gray-matter';
15
16
  import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
16
17
  import { getCliName } from './utils/cli-name.js';
17
- import { log } from './utils/logger.js';
18
+ import { log, tag } from './utils/logger.js';
18
19
  export const PROVIDERS = {
19
20
  openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
20
21
  anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
@@ -178,8 +179,15 @@ export class ConfigParser {
178
179
  return this.site;
179
180
  }
180
181
  resolveTargetPath(target) {
181
- if (!this.site)
182
+ if (!this.site) {
183
+ const configured = this.config?.playwright?.url || this.config?.web?.url;
184
+ const targetOrigin = target ? URL.parse(target)?.origin : null;
185
+ const baseOrigin = configured ? URL.parse(configured)?.origin : null;
186
+ if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
187
+ tag('warning').log(`Exploring ${targetOrigin} but base URL is ${baseOrigin}. Relative navigation resolves against the base URL — set web.url to ${targetOrigin} to avoid it.`);
188
+ }
182
189
  return target || '/';
190
+ }
183
191
  if (!target)
184
192
  return this.siteStartPath;
185
193
  const resolved = resolveSiteTarget(target, this.site.url);
@@ -321,9 +329,10 @@ export class ConfigParser {
321
329
  }
322
330
  async loadConfigModule(configPath) {
323
331
  const ext = configPath.split('.').pop();
332
+ const moduleUrl = pathToFileURL(resolve(configPath)).href;
324
333
  if (ext === 'ts') {
325
334
  try {
326
- const module = await import(__rewriteRelativeImportExtension(configPath));
335
+ const module = await import(__rewriteRelativeImportExtension(moduleUrl));
327
336
  return module;
328
337
  }
329
338
  catch (error) {
@@ -332,7 +341,7 @@ export class ConfigParser {
332
341
  }
333
342
  }
334
343
  else if (ext === 'js' || ext === 'mjs') {
335
- const module = await import(__rewriteRelativeImportExtension(configPath));
344
+ const module = await import(__rewriteRelativeImportExtension(moduleUrl));
336
345
  return module;
337
346
  }
338
347
  else {
@@ -10,6 +10,8 @@ export declare class ExecutionController extends EventEmitter {
10
10
  constructor();
11
11
  static getInstance(): ExecutionController;
12
12
  setInputCallback(callback: InputCallback): void;
13
+ hasInputCallback(): boolean;
14
+ clearInputCallback(): void;
13
15
  startExecution(): void;
14
16
  getAbortSignal(): AbortSignal | undefined;
15
17
  interrupt(): void;
@@ -20,6 +20,12 @@ export class ExecutionController extends EventEmitter {
20
20
  setInputCallback(callback) {
21
21
  this.inputCallback = callback;
22
22
  }
23
+ hasInputCallback() {
24
+ return !!this.inputCallback;
25
+ }
26
+ clearInputCallback() {
27
+ this.inputCallback = null;
28
+ }
23
29
  startExecution() {
24
30
  this.interrupted = false;
25
31
  this.abortController = new AbortController();
@@ -15,7 +15,7 @@ import { Researcher } from './ai/researcher.js';
15
15
  import { SessionAnalyst } from './ai/session-analyst.js';
16
16
  import { Tester } from './ai/tester.js';
17
17
  import { RequestStore } from './api/request-store.js';
18
- import type { ExplorbotConfig } from './config.js';
18
+ import type { ExplorbotConfig, ReporterConfig } from './config.js';
19
19
  import { ConfigParser } from './config.js';
20
20
  import { ExperienceTracker } from './experience-tracker.js';
21
21
  import Explorer from './explorer.js';
@@ -37,6 +37,7 @@ export interface ExplorBotOptions {
37
37
  session?: string | boolean;
38
38
  instance?: string;
39
39
  optionalAi?: boolean;
40
+ reporter?: ReporterConfig;
40
41
  attachedBrowser?: Browser;
41
42
  applicationSpec?: string;
42
43
  }
@@ -101,6 +101,8 @@ export class ExplorBot {
101
101
  this.config = await this.configParser.loadConfig(this.options);
102
102
  if (this.options.session === true)
103
103
  this.options.session = path.join(this.configParser.getOutputDir(), 'session.json');
104
+ if (typeof this.options.session === 'string')
105
+ this.options.session = path.resolve(this.options.path || '.', this.options.session);
104
106
  if (this.options.optionalAi)
105
107
  return this.bootstrapOptionalProvider();
106
108
  this.provider = new AIProvider(this.config.ai);
@@ -138,7 +140,7 @@ export class ExplorBot {
138
140
  return (this._stateManager ||= new StateManager(this.experienceTracker(), this.knowledgeTracker()));
139
141
  }
140
142
  reporter() {
141
- return (this._reporter ||= new Reporter(this.config.reporter, this.stateManager()));
143
+ return (this._reporter ||= new Reporter(this.options.reporter || this.config.reporter, this.stateManager()));
142
144
  }
143
145
  requestStore() {
144
146
  return (this._requestStore ||= new RequestStore(this.configParser.getOutputDir()));
@@ -10,7 +10,6 @@ import { ActionResult } from "./action-result.js";
10
10
  import Action from './action.js';
11
11
  import { XhrCapture } from "./api/xhr-capture.js";
12
12
  import { ConfigParser } from './config.js';
13
- import { TestResult } from "./test-plan.js";
14
13
  import { BrowserRecoveryError, browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
15
14
  import { createDebug, log, tag } from './utils/logger.js';
16
15
  import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
@@ -624,12 +623,12 @@ class Explorer {
624
623
  if (this.observedTestPages.has(page))
625
624
  return;
626
625
  this.testPageErrorHandler ||= (err) => {
627
- this._activeTest?.addNote(`Console error: ${err.message}`, TestResult.FAILED);
626
+ this._activeTest?.addObservation(`Console error: ${err.message}`);
628
627
  };
629
628
  this.testConsoleHandler ||= (msg) => {
630
629
  if (msg.type() !== 'error')
631
630
  return;
632
- this._activeTest?.addNote(`Console error: ${msg.text()}`, TestResult.FAILED);
631
+ this._activeTest?.addObservation(`Console error: ${msg.text()}`);
633
632
  };
634
633
  this.testDialogHandler ||= (dialog) => {
635
634
  const dialogType = dialog.type();
@@ -292,8 +292,38 @@ function formatSelectOption(options) {
292
292
  return quote(values[0]);
293
293
  return `[${values.map((v) => quote(v)).join(', ')}]`;
294
294
  }
295
+ function assertionLocator(target) {
296
+ if (typeof target === 'string')
297
+ return `page.locator(${JSON.stringify(target)})`;
298
+ if (!target || typeof target !== 'object')
299
+ return null;
300
+ const role = target.role || target.aria;
301
+ const name = target.text ?? target.name ?? target.label;
302
+ if (role && name)
303
+ return `page.getByRole(${JSON.stringify(String(role))}, { name: ${JSON.stringify(String(name))} })`;
304
+ if (role)
305
+ return `page.getByRole(${JSON.stringify(String(role))})`;
306
+ if (name)
307
+ return `page.getByText(${JSON.stringify(String(name))})`;
308
+ if (target.css)
309
+ return `page.locator(${JSON.stringify(String(target.css))})`;
310
+ if (target.xpath)
311
+ return `page.locator(${JSON.stringify(`xpath=${target.xpath}`)})`;
312
+ return null;
313
+ }
295
314
  export function renderAssertion(assertion) {
296
315
  const args = assertion.args;
316
+ const target = assertionLocator(args[0]);
317
+ if (target) {
318
+ if (assertion.name === 'seeElement')
319
+ return `await expect(${target}).toBeVisible();`;
320
+ if (assertion.name === 'dontSeeElement')
321
+ return `await expect(${target}).toBeHidden();`;
322
+ if (assertion.name === 'seeInField' && args[1] !== undefined)
323
+ return `await expect(${target}).toHaveValue(${JSON.stringify(String(args[1]))});`;
324
+ if (assertion.name === 'dontSeeInField' && args[1] !== undefined)
325
+ return `await expect(${target}).not.toHaveValue(${JSON.stringify(String(args[1]))});`;
326
+ }
297
327
  if (assertion.name === 'see' && typeof args[0] === 'string') {
298
328
  return `await expect(page).toContainText(${JSON.stringify(args[0])});`;
299
329
  }
@@ -0,0 +1,54 @@
1
+ import type { Command } from 'commander';
2
+ import { type ActivityEntry } from './activity.js';
3
+ import { type LogDestination, type TaggedLogEntry } from './utils/logger.js';
4
+ /**
5
+ * Streams a run to a remote UI over one WebSocket, dialling out so a child
6
+ * process and a CI bot are the same case.
7
+ *
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.
11
+ */
12
+ export declare class Remote implements LogDestination {
13
+ url: string | null;
14
+ socket: WebSocket | null;
15
+ queue: Frame[];
16
+ reconnectDelay: number;
17
+ reconnectTimer: ReturnType<typeof setTimeout> | null;
18
+ asks: Map<string, (value: string | null) => void>;
19
+ askCounter: number;
20
+ lastActivity: string | null;
21
+ registerOption(program: Command): void;
22
+ attach(url: string, command: string): void;
23
+ isAttached(): boolean;
24
+ send(type: string, data?: Record<string, unknown>): void;
25
+ ask(prompt: string): Promise<string | null>;
26
+ close(exitCode: number): Promise<void>;
27
+ isEnabled(): boolean;
28
+ /**
29
+ * The entry as the logger already built it, so the `LogType` reaches the UI
30
+ * as the frame's level and each kind is styled there instead of being scraped
31
+ * back out of flattened text.
32
+ */
33
+ write(entry: TaggedLogEntry): void;
34
+ connect(): void;
35
+ reconnect(): void;
36
+ flush(): Promise<void>;
37
+ drain(): void;
38
+ push(frame: Frame): void;
39
+ receive(raw: string): void;
40
+ /** A new listener is primed with the current activity, which at attach time is
41
+ * nothing — so skip repeats, the priming null included. */
42
+ reportActivity(activity: ActivityEntry | null): void;
43
+ errorOf(args: any[] | undefined): string | undefined;
44
+ commandPath(command: Command): string;
45
+ }
46
+ export declare const remote: Remote;
47
+ /** Whatever the run wants to say. Not a schema — the UI renders what it knows
48
+ * and ignores the rest, so either side can start sending more at any time. */
49
+ type Frame = {
50
+ type: string;
51
+ ts: number;
52
+ [key: string]: unknown;
53
+ };
54
+ export {};
@@ -0,0 +1,229 @@
1
+ import stripAnsi from 'strip-ansi';
2
+ import { addActivityListener } from "./activity.js";
3
+ import { executionController } from "./execution-controller.js";
4
+ import { addDestination } from "./utils/logger.js";
5
+ const QUEUE_CAP = 1000;
6
+ const CONTENT_CAP = 8000;
7
+ const RECONNECT_BASE_MS = 500;
8
+ const RECONNECT_MAX_MS = 10_000;
9
+ const ASK_TIMEOUT_MS = 15 * 60_000;
10
+ const FLUSH_TIMEOUT_MS = 3000;
11
+ /**
12
+ * Streams a run to a remote UI over one WebSocket, dialling out so a child
13
+ * process and a CI bot are the same case.
14
+ *
15
+ * It **is** a LogDestination — that is the whole integration on the logger's
16
+ * side — and it answers asks by installing itself as the execution
17
+ * controller's input callback. Nothing else in explorbot knows it exists.
18
+ */
19
+ export class Remote {
20
+ url = null;
21
+ socket = null;
22
+ queue = [];
23
+ reconnectDelay = RECONNECT_BASE_MS;
24
+ reconnectTimer = null;
25
+ asks = new Map();
26
+ askCounter = 0;
27
+ lastActivity = null;
28
+ registerOption(program) {
29
+ program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
30
+ program.hook('preAction', (_thisCommand, actionCommand) => {
31
+ const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
32
+ if (!url)
33
+ return;
34
+ this.attach(String(url), this.commandPath(actionCommand));
35
+ });
36
+ }
37
+ attach(url, command) {
38
+ if (this.url)
39
+ return;
40
+ this.url = url;
41
+ this.connect();
42
+ this.send('hello', { command, cwd: process.cwd(), pid: process.pid });
43
+ addDestination(this);
44
+ executionController.setInputCallback((prompt) => this.ask(prompt));
45
+ addActivityListener((activity) => this.reportActivity(activity));
46
+ }
47
+ isAttached() {
48
+ return !!this.url;
49
+ }
50
+ send(type, data = {}) {
51
+ if (!this.url)
52
+ return;
53
+ const frame = { type, ts: Date.now(), ...data };
54
+ if (this.socket?.readyState === WebSocket.OPEN) {
55
+ this.push(frame);
56
+ return;
57
+ }
58
+ this.queue.push(frame);
59
+ if (this.queue.length > QUEUE_CAP)
60
+ this.queue.splice(0, this.queue.length - QUEUE_CAP);
61
+ }
62
+ ask(prompt) {
63
+ if (!this.url)
64
+ return Promise.resolve(null);
65
+ this.askCounter++;
66
+ const askId = `ask-${this.askCounter}`;
67
+ this.send('ask', { askId, prompt });
68
+ return new Promise((resolve) => {
69
+ const timer = setTimeout(() => {
70
+ this.asks.delete(askId);
71
+ resolve(null);
72
+ }, ASK_TIMEOUT_MS);
73
+ this.asks.set(askId, (value) => {
74
+ clearTimeout(timer);
75
+ resolve(value);
76
+ });
77
+ });
78
+ }
79
+ async close(exitCode) {
80
+ if (!this.url)
81
+ return;
82
+ this.send('result', { ok: exitCode === 0, exitCode });
83
+ await this.flush();
84
+ this.url = null;
85
+ if (this.reconnectTimer)
86
+ clearTimeout(this.reconnectTimer);
87
+ // Whoever asks next has nobody to ask — leaving the callback installed would
88
+ // route them into a closed socket and park them until the ask times out.
89
+ executionController.clearInputCallback();
90
+ for (const resolve of this.asks.values())
91
+ resolve(null);
92
+ this.asks.clear();
93
+ this.queue = [];
94
+ this.socket?.close();
95
+ this.socket = null;
96
+ }
97
+ isEnabled() {
98
+ return this.isAttached();
99
+ }
100
+ /**
101
+ * The entry as the logger already built it, so the `LogType` reaches the UI
102
+ * as the frame's level and each kind is styled there instead of being scraped
103
+ * back out of flattened text.
104
+ */
105
+ write(entry) {
106
+ if (entry.type === 'html')
107
+ return;
108
+ let content = stripAnsi(entry.content ?? '');
109
+ if (content.length > CONTENT_CAP)
110
+ content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
111
+ this.send('log', {
112
+ level: entry.type,
113
+ content,
114
+ namespace: entry.namespace,
115
+ error: this.errorOf(entry.originalArgs),
116
+ });
117
+ }
118
+ connect() {
119
+ if (!this.url)
120
+ return;
121
+ let opening;
122
+ try {
123
+ opening = new WebSocket(this.url);
124
+ }
125
+ catch {
126
+ this.reconnect();
127
+ return;
128
+ }
129
+ this.socket = opening;
130
+ opening.addEventListener('open', () => {
131
+ this.reconnectDelay = RECONNECT_BASE_MS;
132
+ this.drain();
133
+ });
134
+ opening.addEventListener('message', (event) => this.receive(String(event.data)));
135
+ opening.addEventListener('close', () => {
136
+ if (this.socket !== opening)
137
+ return;
138
+ this.socket = null;
139
+ this.reconnect();
140
+ });
141
+ // A failed connection always closes too, which is where the retry is scheduled.
142
+ opening.addEventListener('error', () => { });
143
+ }
144
+ reconnect() {
145
+ if (!this.url || this.reconnectTimer)
146
+ return;
147
+ const delay = this.reconnectDelay;
148
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
149
+ this.reconnectTimer = setTimeout(() => {
150
+ this.reconnectTimer = null;
151
+ this.connect();
152
+ }, delay);
153
+ this.reconnectTimer.unref?.();
154
+ }
155
+ async flush() {
156
+ const deadline = Date.now() + FLUSH_TIMEOUT_MS;
157
+ while (Date.now() < deadline) {
158
+ if (this.socket?.readyState === WebSocket.OPEN) {
159
+ this.drain();
160
+ if (!this.queue.length && !this.socket.bufferedAmount)
161
+ return;
162
+ }
163
+ await new Promise((resolve) => setTimeout(resolve, 25));
164
+ }
165
+ }
166
+ drain() {
167
+ if (this.socket?.readyState !== WebSocket.OPEN)
168
+ return;
169
+ const pending = this.queue;
170
+ this.queue = [];
171
+ for (const frame of pending)
172
+ this.push(frame);
173
+ }
174
+ push(frame) {
175
+ try {
176
+ this.socket?.send(JSON.stringify(frame));
177
+ }
178
+ catch {
179
+ this.queue.push(frame);
180
+ }
181
+ }
182
+ receive(raw) {
183
+ let frame;
184
+ try {
185
+ frame = JSON.parse(raw);
186
+ }
187
+ catch {
188
+ return;
189
+ }
190
+ if (frame.type === 'interrupt') {
191
+ executionController.interrupt();
192
+ return;
193
+ }
194
+ const resolve = this.asks.get(String(frame.askId));
195
+ if (!resolve)
196
+ return;
197
+ this.asks.delete(String(frame.askId));
198
+ resolve(frame.value ?? null);
199
+ }
200
+ /** A new listener is primed with the current activity, which at attach time is
201
+ * nothing — so skip repeats, the priming null included. */
202
+ reportActivity(activity) {
203
+ const message = activity?.message ?? null;
204
+ if (message === this.lastActivity)
205
+ return;
206
+ this.lastActivity = message;
207
+ this.send('activity', { message, kind: activity?.type });
208
+ }
209
+ errorOf(args) {
210
+ const failure = args?.[1] ?? args?.[0]?.error;
211
+ if (!failure)
212
+ return undefined;
213
+ if (typeof failure === 'string')
214
+ return failure;
215
+ if (typeof failure.message === 'string')
216
+ return failure.message;
217
+ return undefined;
218
+ }
219
+ commandPath(command) {
220
+ const parts = [];
221
+ let node = command;
222
+ while (node) {
223
+ parts.unshift(node.name());
224
+ node = node.parent;
225
+ }
226
+ return parts.slice(1).join(' ') || parts.join(' ');
227
+ }
228
+ }
229
+ export const remote = new Remote();
@@ -26,6 +26,7 @@ export declare class Reporter {
26
26
  combineStepsAndNotes(test: Test, lastScreenshotFile?: string): Step[];
27
27
  buildVerificationStep(test: Test, lastScreenshotFile?: string): Step | undefined;
28
28
  reportTest(test: Test, meta?: ReporterMeta): Promise<void>;
29
+ reportTestData(status: string | null, testData: Record<string, unknown>): Promise<void>;
29
30
  finishRun(): Promise<void>;
30
31
  isEnabled(): boolean;
31
32
  setRunDescription(text: string): Promise<void>;
@@ -242,13 +242,19 @@ export class Reporter {
242
242
  time: durationMs != null ? Math.round(durationMs) : 0,
243
243
  };
244
244
  debugLog(testData);
245
- await this.client.addTestRun(status, testData);
245
+ await this.reportTestData(status, testData);
246
246
  debugLog(`Test reported: ${test.scenario} - ${status}`);
247
247
  }
248
248
  catch (error) {
249
249
  debugLog('Failed to report test:', error);
250
250
  }
251
251
  }
252
+ async reportTestData(status, testData) {
253
+ await this.startRun();
254
+ if (!this.isRunStarted)
255
+ return;
256
+ await this.client.addTestRun(status, testData);
257
+ }
252
258
  async finishRun() {
253
259
  if (!this.isRunStarted) {
254
260
  return;
@@ -1,4 +1,4 @@
1
- import { ActionResult } from './action-result.js';
1
+ import { 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
  export interface Link {
@@ -37,6 +37,7 @@ export interface WebPageState {
37
37
  h4?: string;
38
38
  ariaSnapshot?: string | null;
39
39
  ariaSnapshotFile?: string;
40
+ focusedElement?: FocusedElement | null;
40
41
  links?: Link[];
41
42
  verifications?: Record<string, boolean>;
42
43
  }
@@ -13,6 +13,7 @@ export declare class Stats {
13
13
  static plans: number;
14
14
  static mode?: ExplorbotMode;
15
15
  static focus?: string;
16
+ static visionDisabled: boolean;
16
17
  static models: Record<string, TokenUsage>;
17
18
  static recordTokens(_agent: string, model: string, usage: TokenUsage): void;
18
19
  static getElapsedTime(): string;
package/dist/src/stats.js CHANGED
@@ -7,6 +7,7 @@ export class Stats {
7
7
  static plans = 0;
8
8
  static mode;
9
9
  static focus;
10
+ static visionDisabled = false;
10
11
  static models = {};
11
12
  static recordTokens(_agent, model, usage) {
12
13
  if (!Stats.models[model]) {
@@ -18,6 +18,7 @@ export interface Note {
18
18
  endTime: number;
19
19
  screenshot?: string;
20
20
  log?: string;
21
+ observation?: boolean;
21
22
  }
22
23
  export declare class ActiveNote {
23
24
  task: Task;
@@ -56,6 +57,7 @@ export declare class Task {
56
57
  getPrintableNotes(): string[];
57
58
  notesToString(): string;
58
59
  addNote(message: string, status?: TestResultType, screenshot?: string, log?: string): void;
60
+ addObservation(message: string): void;
59
61
  addUrlNote(state: UrlNoteState, prevState?: {
60
62
  title?: string;
61
63
  h1?: string;
@@ -91,6 +91,13 @@ export class Task {
91
91
  const timestamp = `${now}_${this.timestampCounter++}`;
92
92
  this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
93
93
  }
94
+ addObservation(message) {
95
+ const isDuplicate = Object.values(this.notes).some((note) => note.message === message);
96
+ if (isDuplicate)
97
+ return;
98
+ const now = performance.now();
99
+ this.notes[`${now}_${this.timestampCounter++}`] = { message, status: TestResult.FAILED, startTime: now, endTime: now, observation: true };
100
+ }
94
101
  addUrlNote(state, prevState) {
95
102
  const fullUrl = state.fullUrl || state.url;
96
103
  if (!fullUrl)
@@ -3,17 +3,11 @@ export interface FocusAreaResult {
3
3
  type: 'dialog' | 'modal' | null;
4
4
  name: string | null;
5
5
  }
6
- export declare const compactAriaSnapshot: (snapshot: string | null, keepNamed?: boolean) => string;
6
+ export type ValueOffload = (value: string) => string | undefined;
7
+ export declare const compactAriaSnapshot: (snapshot: string | null, keepNamed?: boolean, offload?: ValueOffload) => string;
7
8
  export declare const diffAriaSnapshots: (previous: string | null, current: string | null) => AriaDiff;
8
9
  export declare const detectFocusArea: (snapshot: string | null) => FocusAreaResult;
9
10
  export declare const collectInteractiveNodes: (snapshot: string | null) => Array<Record<string, unknown>>;
10
- export interface FocusedElementInfo {
11
- role: string;
12
- name: string;
13
- value?: string;
14
- attributes?: string[];
15
- }
16
- export declare function extractFocusedElement(ariaSnapshot: string | null): FocusedElementInfo | null;
17
11
  export declare function parseAriaLocator(ariaStr: string): {
18
12
  role: string;
19
13
  text: string;