explorbot 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +26 -8
  3. package/boat/api-tester/src/cli.ts +17 -0
  4. package/boat/api-tester/src/config.ts +4 -2
  5. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  6. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  7. package/boat/doc-collector/src/cli.ts +14 -1
  8. package/boat/doc-collector/src/config.ts +4 -2
  9. package/boat/prima/bin/prima-cli.ts +0 -0
  10. package/boat/prima/src/activity-line.ts +33 -0
  11. package/boat/prima/src/cli.ts +127 -86
  12. package/boat/prima/src/envelope.ts +102 -52
  13. package/boat/prima/src/prima.ts +567 -128
  14. package/boat/prima/src/pw-parser.ts +11 -1
  15. package/boat/prima/src/pw-registry.ts +4 -5
  16. package/boat/prima/src/session-log.ts +126 -0
  17. package/dist/bin/explorbot-cli.js +26 -8
  18. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  19. package/dist/boat/api-tester/src/cli.js +17 -0
  20. package/dist/boat/api-tester/src/config.js +4 -2
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  22. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  23. package/dist/boat/doc-collector/src/cli.js +14 -1
  24. package/dist/boat/doc-collector/src/config.js +4 -2
  25. package/dist/boat/prima/src/activity-line.js +30 -0
  26. package/dist/boat/prima/src/cli.js +109 -77
  27. package/dist/boat/prima/src/envelope.js +94 -44
  28. package/dist/boat/prima/src/prima.js +533 -119
  29. package/dist/boat/prima/src/pw-parser.js +13 -1
  30. package/dist/boat/prima/src/pw-registry.js +4 -5
  31. package/dist/boat/prima/src/session-log.js +108 -0
  32. package/dist/package.json +3 -2
  33. package/dist/rules/navigator/verification-actions.md +20 -0
  34. package/dist/src/action-result.d.ts +7 -0
  35. package/dist/src/action-result.js +4 -0
  36. package/dist/src/action.d.ts +2 -0
  37. package/dist/src/action.js +41 -2
  38. package/dist/src/ai/captain/web-mode.js +6 -3
  39. package/dist/src/ai/captain.js +2 -0
  40. package/dist/src/ai/navigator.d.ts +34 -0
  41. package/dist/src/ai/navigator.js +237 -181
  42. package/dist/src/ai/pilot.d.ts +7 -0
  43. package/dist/src/ai/pilot.js +90 -2
  44. package/dist/src/ai/provider.d.ts +2 -2
  45. package/dist/src/ai/provider.js +14 -23
  46. package/dist/src/ai/rerunner.js +2 -1
  47. package/dist/src/ai/researcher/cache.d.ts +2 -0
  48. package/dist/src/ai/researcher/cache.js +10 -2
  49. package/dist/src/ai/researcher.js +3 -2
  50. package/dist/src/ai/rules.js +17 -10
  51. package/dist/src/ai/session-analyst.js +2 -0
  52. package/dist/src/ai/task-agent.js +4 -1
  53. package/dist/src/ai/tester.d.ts +6 -3
  54. package/dist/src/ai/tester.js +50 -46
  55. package/dist/src/ai/tools.d.ts +14 -0
  56. package/dist/src/ai/tools.js +117 -37
  57. package/dist/src/commands/config-command.d.ts +51 -0
  58. package/dist/src/commands/config-command.js +117 -0
  59. package/dist/src/commands/index.js +2 -0
  60. package/dist/src/config.d.ts +9 -1
  61. package/dist/src/config.js +53 -4
  62. package/dist/src/execution-controller.d.ts +2 -0
  63. package/dist/src/execution-controller.js +6 -0
  64. package/dist/src/explorbot.d.ts +2 -1
  65. package/dist/src/explorbot.js +7 -2
  66. package/dist/src/explorer.js +2 -3
  67. package/dist/src/playwright-recorder.js +30 -0
  68. package/dist/src/remote.d.ts +55 -0
  69. package/dist/src/remote.js +235 -0
  70. package/dist/src/reporter.d.ts +1 -0
  71. package/dist/src/reporter.js +7 -1
  72. package/dist/src/state-manager.d.ts +2 -1
  73. package/dist/src/state-manager.js +3 -1
  74. package/dist/src/stats.d.ts +1 -0
  75. package/dist/src/stats.js +1 -0
  76. package/dist/src/test-plan.d.ts +3 -0
  77. package/dist/src/test-plan.js +26 -0
  78. package/dist/src/utils/aria.d.ts +2 -8
  79. package/dist/src/utils/aria.js +69 -40
  80. package/dist/src/utils/html.js +1 -0
  81. package/dist/src/utils/logger.d.ts +7 -1
  82. package/dist/src/utils/logger.js +32 -0
  83. package/dist/src/utils/page-readiness.js +18 -1
  84. package/dist/src/utils/url-matcher.js +3 -0
  85. package/dist/src/utils/web-element.d.ts +2 -0
  86. package/dist/src/utils/web-element.js +8 -0
  87. package/dist/src/utils/web-sandbox.d.ts +1 -1
  88. package/dist/src/utils/web-sandbox.js +2 -3
  89. package/docs/api-testing/basics.md +90 -0
  90. package/docs/api-testing/planning.md +57 -0
  91. package/docs/api-testing/running-tests.md +55 -0
  92. package/docs/assets/cloud-report.png +0 -0
  93. package/docs/assets/html-report.png +0 -0
  94. package/docs/assets/langfuse-trace.png +0 -0
  95. package/docs/assets/successful-explore-run.png +0 -0
  96. package/docs/basics/getting-started.md +140 -0
  97. package/docs/basics/prerequisites.md +63 -0
  98. package/docs/basics/providers.md +362 -0
  99. package/docs/basics/running.md +78 -0
  100. package/docs/contributing/ai-integration-tests.md +57 -0
  101. package/docs/contributing/contributing.md +90 -0
  102. package/docs/contributing/demo-videos.md +36 -0
  103. package/docs/contributing/npm-package.md +138 -0
  104. package/docs/contributing/observability.md +227 -0
  105. package/docs/contributing/regression-tests.md +103 -0
  106. package/docs/contributing/testing.md +95 -0
  107. package/docs/doc-collection/basics.md +128 -0
  108. package/docs/doc-collection/crawling.md +67 -0
  109. package/docs/doc-collection/interactive-mode.md +99 -0
  110. package/docs/index.json +87 -0
  111. package/docs/reference/commands.md +997 -0
  112. package/docs/reference/configuration.md +569 -0
  113. package/docs/reference/scripting.md +303 -0
  114. package/docs/reference/websocket.md +50 -0
  115. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  116. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  117. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  118. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  119. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  120. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  121. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  122. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  123. package/docs/web-testing/agents.md +158 -0
  124. package/docs/web-testing/automated-tests.md +134 -0
  125. package/docs/web-testing/basics.md +91 -0
  126. package/docs/web-testing/customization.md +131 -0
  127. package/docs/web-testing/hooks.md +238 -0
  128. package/docs/web-testing/page-interaction.md +84 -0
  129. package/docs/web-testing/planner.md +122 -0
  130. package/docs/web-testing/rerun.md +164 -0
  131. package/docs/web-testing/researcher.md +380 -0
  132. package/docs/workflow/agentic-usage.md +233 -0
  133. package/docs/workflow/application-spec.md +73 -0
  134. package/docs/workflow/ci.md +202 -0
  135. package/docs/workflow/knowledge.md +310 -0
  136. package/docs/workflow/planning-styles.md +67 -0
  137. package/docs/workflow/reporting.md +133 -0
  138. package/docs/workflow/test-plans.md +90 -0
  139. package/package.json +3 -2
  140. package/rules/navigator/verification-actions.md +20 -0
  141. package/src/action-result.ts +11 -0
  142. package/src/action.ts +43 -3
  143. package/src/ai/captain/web-mode.ts +6 -3
  144. package/src/ai/captain.ts +3 -0
  145. package/src/ai/navigator.ts +255 -186
  146. package/src/ai/pilot.ts +104 -2
  147. package/src/ai/provider.ts +14 -24
  148. package/src/ai/rerunner.ts +2 -1
  149. package/src/ai/researcher/cache.ts +12 -2
  150. package/src/ai/researcher.ts +3 -2
  151. package/src/ai/rules.ts +17 -10
  152. package/src/ai/session-analyst.ts +2 -0
  153. package/src/ai/task-agent.ts +3 -1
  154. package/src/ai/tester.ts +52 -45
  155. package/src/ai/tools.ts +136 -37
  156. package/src/commands/config-command.ts +146 -0
  157. package/src/commands/index.ts +2 -0
  158. package/src/config.ts +60 -5
  159. package/src/execution-controller.ts +8 -0
  160. package/src/explorbot.ts +7 -3
  161. package/src/explorer.ts +2 -2
  162. package/src/playwright-recorder.ts +23 -0
  163. package/src/remote.ts +244 -0
  164. package/src/reporter.ts +7 -1
  165. package/src/state-manager.ts +6 -2
  166. package/src/stats.ts +1 -0
  167. package/src/test-plan.ts +29 -0
  168. package/src/utils/aria.ts +65 -45
  169. package/src/utils/html.ts +1 -0
  170. package/src/utils/logger.ts +33 -2
  171. package/src/utils/page-readiness.ts +24 -1
  172. package/src/utils/url-matcher.ts +3 -0
  173. package/src/utils/web-element.ts +9 -0
  174. package/src/utils/web-sandbox.ts +3 -4
@@ -0,0 +1,146 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { type AIConfig, ConfigParser, EXPLORBOT_ENV_VARS, type ReporterConfig, configuredModels } from '../config.js';
5
+ import { listSites } from '../global-config.js';
6
+ import { Reporter } from '../reporter.js';
7
+ import { getCliName } from '../utils/cli-name.js';
8
+ import { tag } from '../utils/logger.js';
9
+ import { BaseCommand } from './base-command.js';
10
+
11
+ export class ConfigCommand extends BaseCommand {
12
+ name = 'config';
13
+ description = 'Show models, config file and paths used by this run';
14
+
15
+ async execute(): Promise<void> {
16
+ const parser = ConfigParser.getInstance();
17
+ tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
18
+ }
19
+
20
+ static async summary(options: { config?: string; path?: string; url?: string; json?: boolean } = {}): Promise<string> {
21
+ const parser = ConfigParser.getInstance();
22
+ const [site] = listSites();
23
+ const load = (baseUrl?: string) => parser.loadConfig({ config: options.config, path: options.path, baseUrl });
24
+
25
+ const config = await load(options.url).catch((error) => {
26
+ if (!site) throw error;
27
+ return load(site.url);
28
+ });
29
+
30
+ return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
31
+ }
32
+
33
+ static data(config: SummarizedConfig, options: ConfigSummaryOptions = {}): ConfigData {
34
+ let configPath = '';
35
+ if (options.configPath && existsSync(options.configPath)) configPath = options.configPath;
36
+
37
+ const dirs: Record<string, string> = {};
38
+ if (options.root) {
39
+ for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
40
+ dirs[name] = path.join(options.root, dir);
41
+ }
42
+ }
43
+
44
+ const env: Record<string, string> = {};
45
+ for (const variable of EXPLORBOT_ENV_VARS) {
46
+ const value = process.env[variable.name];
47
+ if (value) env[variable.name] = value;
48
+ }
49
+
50
+ const models: Record<string, string> = {};
51
+ const providers: Record<string, string> = {};
52
+ for (const [role, model] of Object.entries(configuredModels(config.ai))) {
53
+ models[role] = model.name;
54
+ if (model.provider) providers[role] = model.provider;
55
+ }
56
+
57
+ return {
58
+ config: configPath,
59
+ url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
60
+ browser: config.playwright?.browser || '',
61
+ headless: !config.playwright?.show,
62
+ dirs,
63
+ models,
64
+ providers,
65
+ integrations: {
66
+ langfuse: !!config.ai?.langfuse?.enabled,
67
+ testomatio: Reporter.resolveEnabled(config.reporter),
68
+ },
69
+ env,
70
+ };
71
+ }
72
+
73
+ static render(config: SummarizedConfig, options: ConfigSummaryOptions = {}): string {
74
+ const data = ConfigCommand.data(config, options);
75
+ if (options.json) return JSON.stringify(data, null, 2);
76
+
77
+ const lines: string[] = [];
78
+ const section = (title: string, entries: [string, string][]) => {
79
+ if (!entries.length) return;
80
+ const width = Math.max(...entries.map(([label]) => label.length));
81
+ lines.push(chalk.bold(title));
82
+ for (const [label, value] of entries) lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
83
+ lines.push('');
84
+ };
85
+
86
+ const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
87
+ if (data.url) general.push(['url', data.url]);
88
+ if (data.browser) {
89
+ let window = 'visible';
90
+ if (data.headless) window = 'headless';
91
+ general.push(['browser', `${data.browser}, ${window}`]);
92
+ }
93
+ for (const [name, dir] of Object.entries(data.dirs)) general.push([name, dir]);
94
+ section('Config', general);
95
+
96
+ const providerWidth = Math.max(0, ...Object.values(data.providers).map((provider) => provider.length));
97
+ const models: [string, string][] = Object.entries(data.models).map(([role, model]) => {
98
+ if (!providerWidth) return [role, model];
99
+ return [role, `${chalk.dim((data.providers[role] || '').padEnd(providerWidth))} ${model}`];
100
+ });
101
+ if (!models.length) models.push(['model', chalk.red(`not configured — run ${getCliName()} init`)]);
102
+ section('Models', models);
103
+
104
+ const integrations: [string, string][] = [];
105
+ if (data.integrations.langfuse) integrations.push(['langfuse', 'traces sent']);
106
+ if (data.integrations.testomatio) integrations.push(['testomatio', 'runs reported']);
107
+ section('Integrations', integrations);
108
+
109
+ const env: [string, string][] = Object.entries(data.env).map(([name, value]) => {
110
+ let shown = value;
111
+ if (shown.length > 60) shown = `${shown.slice(0, 57)}...`;
112
+ return [name, shown];
113
+ });
114
+ section('Environment', env);
115
+
116
+ lines.push(chalk.dim(`Every EXPLORBOT_* variable: ${getCliName()} --help`));
117
+ return lines.join('\n');
118
+ }
119
+ }
120
+
121
+ interface ConfigSummaryOptions {
122
+ configPath?: string | null;
123
+ root?: string;
124
+ json?: boolean;
125
+ }
126
+
127
+ export interface ConfigData {
128
+ config: string;
129
+ url: string;
130
+ browser: string;
131
+ headless: boolean;
132
+ dirs: Record<string, string>;
133
+ models: Record<string, string>;
134
+ providers: Record<string, string>;
135
+ integrations: { langfuse: boolean; testomatio: boolean };
136
+ env: Record<string, string>;
137
+ }
138
+
139
+ interface SummarizedConfig {
140
+ ai?: AIConfig;
141
+ playwright?: { url?: string; browser?: string; show?: boolean };
142
+ web?: { url?: string };
143
+ api?: { baseEndpoint?: string };
144
+ dirs?: Record<string, string>;
145
+ reporter?: ReporterConfig;
146
+ }
@@ -3,6 +3,7 @@ import { AddRuleCommand } from './add-rule-command.js';
3
3
  import type { BaseCommand } from './base-command.js';
4
4
  import { CleanCommand } from './clean-command.js';
5
5
  import { CompactCommand } from './compact-command.js';
6
+ import { ConfigCommand } from './config-command.js';
6
7
  import { ContextAriaCommand } from './context-aria-command.js';
7
8
  import { ContextCommand } from './context-command.js';
8
9
  import { ContextDataCommand } from './context-data-command.js';
@@ -70,6 +71,7 @@ const commandClasses: CommandClass[] = [
70
71
  RunsCommand,
71
72
  RerunCommand,
72
73
  StatusCommand,
74
+ ConfigCommand,
73
75
  DebugCommand,
74
76
  ExitCommand,
75
77
  ];
package/src/config.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import path, { basename, dirname, join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
4
5
  import { parseEnv } from 'node:util';
5
6
  import dedent from 'dedent';
6
7
  import matter from 'gray-matter';
7
8
  import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
8
9
  import { getCliName } from './utils/cli-name.js';
9
- import { log } from './utils/logger.js';
10
+ import { log, tag } from './utils/logger.js';
10
11
 
11
12
  export const PROVIDERS: Record<string, ProviderInfo> = {
12
13
  openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
@@ -197,6 +198,7 @@ interface HtmlConfig {
197
198
  interface ActionConfig {
198
199
  delay?: number;
199
200
  retries?: number;
201
+ timeout?: number;
200
202
  }
201
203
 
202
204
  interface ReporterConfig {
@@ -455,7 +457,15 @@ export class ConfigParser {
455
457
  }
456
458
 
457
459
  public resolveTargetPath(target?: string): string {
458
- if (!this.site) return target || '/';
460
+ if (!this.site) {
461
+ const configured = this.config?.playwright?.url || this.config?.web?.url;
462
+ const targetOrigin = target ? URL.parse(target)?.origin : null;
463
+ const baseOrigin = configured ? URL.parse(configured)?.origin : null;
464
+ if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
465
+ 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.`);
466
+ }
467
+ return target || '/';
468
+ }
459
469
  if (!target) return this.siteStartPath;
460
470
 
461
471
  const resolved = resolveSiteTarget(target, this.site.url);
@@ -504,6 +514,7 @@ export class ConfigParser {
504
514
  model: { modelId: 'test-model', provider: 'test' },
505
515
  config: {},
506
516
  vision: false,
517
+ langfuse: { enabled: false },
507
518
  },
508
519
  dirs: {
509
520
  knowledge: join(testBaseDir, 'knowledge'),
@@ -610,17 +621,18 @@ export class ConfigParser {
610
621
 
611
622
  private async loadConfigModule(configPath: string): Promise<any> {
612
623
  const ext = configPath.split('.').pop();
624
+ const moduleUrl = pathToFileURL(resolve(configPath)).href;
613
625
 
614
626
  if (ext === 'ts') {
615
627
  try {
616
- const module = await import(configPath);
628
+ const module = await import(moduleUrl);
617
629
  return module;
618
630
  } catch (error) {
619
631
  const require = (await import('node:module')).createRequire(import.meta.url);
620
632
  return require(configPath);
621
633
  }
622
634
  } else if (ext === 'js' || ext === 'mjs') {
623
- const module = await import(configPath);
635
+ const module = await import(moduleUrl);
624
636
  return module;
625
637
  } else {
626
638
  const content = readFileSync(configPath, 'utf8');
@@ -639,6 +651,18 @@ export class ConfigParser {
639
651
  config.playwright.url = options.baseUrl;
640
652
  }
641
653
 
654
+ if (config.ai) {
655
+ const langfuse = config.ai.langfuse;
656
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
657
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
658
+ config.ai.langfuse = {
659
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
660
+ publicKey,
661
+ secretKey,
662
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
663
+ };
664
+ }
665
+
642
666
  return config;
643
667
  }
644
668
 
@@ -751,6 +775,32 @@ export function missingConfigMessage(configFile = 'explorbot.config.js'): string
751
775
  `;
752
776
  }
753
777
 
778
+ export function modelName(model: unknown): string {
779
+ if (typeof model === 'string') return model;
780
+ return (model as any)?.modelId || (model as any)?.model || 'unknown';
781
+ }
782
+
783
+ export function modelProvider(model: unknown): string {
784
+ const provider = (model as any)?.provider;
785
+ if (typeof provider === 'string') return provider.split('.')[0];
786
+ if (typeof model === 'string') return model.split('/')[0];
787
+ return '';
788
+ }
789
+
790
+ export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel> {
791
+ if (!ai?.model) return {};
792
+
793
+ const describe = (model: unknown): ConfiguredModel => ({ name: modelName(model), provider: modelProvider(model) });
794
+
795
+ const models: Record<string, ConfiguredModel> = { model: describe(ai.model) };
796
+ if (ai.agenticModel) models.agenticModel = describe(ai.agenticModel);
797
+ if (ai.visionModel) models.visionModel = describe(ai.visionModel);
798
+ for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
799
+ if (agentConfig?.model) models[agent] = describe(agentConfig.model);
800
+ }
801
+ return models;
802
+ }
803
+
754
804
  export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
755
805
  if (!ai) return;
756
806
 
@@ -827,6 +877,11 @@ export async function createModel(provider: string, modelId: string): Promise<an
827
877
 
828
878
  type ModelRole = 'model' | 'visionModel' | 'agenticModel';
829
879
 
880
+ interface ConfiguredModel {
881
+ name: string;
882
+ provider: string;
883
+ }
884
+
830
885
  interface ProviderInfo {
831
886
  envKey: string;
832
887
  load: () => Promise<(modelId: string) => any>;
@@ -838,4 +893,4 @@ interface EnvVar {
838
893
  required?: boolean;
839
894
  }
840
895
 
841
- export type { ModelRole, EnvVar, ProviderInfo };
896
+ export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
@@ -27,6 +27,14 @@ export class ExecutionController extends EventEmitter {
27
27
  this.inputCallback = callback;
28
28
  }
29
29
 
30
+ hasInputCallback(): boolean {
31
+ return !!this.inputCallback;
32
+ }
33
+
34
+ clearInputCallback(): void {
35
+ this.inputCallback = null;
36
+ }
37
+
30
38
  startExecution(): void {
31
39
  this.interrupted = false;
32
40
  this.abortController = new AbortController();
package/src/explorbot.ts CHANGED
@@ -20,7 +20,7 @@ import { createAgentTools } from './ai/tools.ts';
20
20
  import { ApiClient } from './api/api-client.ts';
21
21
  import { RequestStore } from './api/request-store.ts';
22
22
  import { loadSpec } from './api/spec-reader.ts';
23
- import type { ExplorbotConfig } from './config.js';
23
+ import type { ExplorbotConfig, ReporterConfig } from './config.js';
24
24
  import { ConfigParser } from './config.ts';
25
25
  import { ExperienceTracker } from './experience-tracker.ts';
26
26
  import Explorer from './explorer.ts';
@@ -49,6 +49,7 @@ export interface ExplorBotOptions {
49
49
  session?: string | boolean;
50
50
  instance?: string;
51
51
  optionalAi?: boolean;
52
+ reporter?: ReporterConfig;
52
53
  attachedBrowser?: Browser;
53
54
  applicationSpec?: string;
54
55
  }
@@ -128,6 +129,7 @@ export class ExplorBot {
128
129
  if (this.provider) return;
129
130
  this.config = await this.configParser.loadConfig(this.options);
130
131
  if (this.options.session === true) this.options.session = path.join(this.configParser.getOutputDir(), 'session.json');
132
+ if (typeof this.options.session === 'string') this.options.session = path.resolve(this.options.path || '.', this.options.session);
131
133
  if (this.options.optionalAi) return this.bootstrapOptionalProvider();
132
134
  this.provider = new AIProvider(this.config.ai);
133
135
  await this.provider.validateConnection();
@@ -175,7 +177,7 @@ export class ExplorBot {
175
177
  }
176
178
 
177
179
  reporter(): Reporter {
178
- return (this._reporter ||= new Reporter(this.config.reporter, this.stateManager()));
180
+ return (this._reporter ||= new Reporter(this.options.reporter || this.config.reporter, this.stateManager()));
179
181
  }
180
182
 
181
183
  requestStore(): RequestStore {
@@ -485,9 +487,11 @@ export class ExplorBot {
485
487
 
486
488
  setCurrentPlan(plan?: Plan): void {
487
489
  this.currentPlan = plan;
488
- if (plan && !this.sessionPlans.includes(plan)) {
490
+ if (!plan) return;
491
+ if (!this.sessionPlans.includes(plan)) {
489
492
  this.sessionPlans.push(plan);
490
493
  }
494
+ plan.notifyChange();
491
495
  }
492
496
 
493
497
  getSessionTests(): Test[] {
package/src/explorer.ts CHANGED
@@ -721,11 +721,11 @@ class Explorer {
721
721
  if (this.observedTestPages.has(page)) return;
722
722
 
723
723
  this.testPageErrorHandler ||= (err: Error) => {
724
- this._activeTest?.addNote(`Console error: ${err.message}`, TestResult.FAILED);
724
+ this._activeTest?.addObservation(`Console error: ${err.message}`);
725
725
  };
726
726
  this.testConsoleHandler ||= (msg: any) => {
727
727
  if (msg.type() !== 'error') return;
728
- this._activeTest?.addNote(`Console error: ${msg.text()}`, TestResult.FAILED);
728
+ this._activeTest?.addObservation(`Console error: ${msg.text()}`);
729
729
  };
730
730
  this.testDialogHandler ||= (dialog: any) => {
731
731
  const dialogType = dialog.type();
@@ -287,8 +287,31 @@ function formatSelectOption(options: any): string {
287
287
  return `[${values.map((v) => quote(v)).join(', ')}]`;
288
288
  }
289
289
 
290
+ function assertionLocator(target: any): string | null {
291
+ if (typeof target === 'string') return `page.locator(${JSON.stringify(target)})`;
292
+ if (!target || typeof target !== 'object') return null;
293
+
294
+ const role = target.role || target.aria;
295
+ const name = target.text ?? target.name ?? target.label;
296
+ if (role && name) return `page.getByRole(${JSON.stringify(String(role))}, { name: ${JSON.stringify(String(name))} })`;
297
+ if (role) return `page.getByRole(${JSON.stringify(String(role))})`;
298
+ if (name) return `page.getByText(${JSON.stringify(String(name))})`;
299
+ if (target.css) return `page.locator(${JSON.stringify(String(target.css))})`;
300
+ if (target.xpath) return `page.locator(${JSON.stringify(`xpath=${target.xpath}`)})`;
301
+ return null;
302
+ }
303
+
290
304
  export function renderAssertion(assertion: { name: string; args: any[] }): string {
291
305
  const args = assertion.args;
306
+ const target = assertionLocator(args[0]);
307
+
308
+ if (target) {
309
+ if (assertion.name === 'seeElement') return `await expect(${target}).toBeVisible();`;
310
+ if (assertion.name === 'dontSeeElement') return `await expect(${target}).toBeHidden();`;
311
+ if (assertion.name === 'seeInField' && args[1] !== undefined) return `await expect(${target}).toHaveValue(${JSON.stringify(String(args[1]))});`;
312
+ if (assertion.name === 'dontSeeInField' && args[1] !== undefined) return `await expect(${target}).not.toHaveValue(${JSON.stringify(String(args[1]))});`;
313
+ }
314
+
292
315
  if (assertion.name === 'see' && typeof args[0] === 'string') {
293
316
  return `await expect(page).toContainText(${JSON.stringify(args[0])});`;
294
317
  }
package/src/remote.ts ADDED
@@ -0,0 +1,244 @@
1
+ import type { Command } from 'commander';
2
+ import stripAnsi from 'strip-ansi';
3
+ import { type ActivityEntry, addActivityListener } from './activity.ts';
4
+ import { executionController } from './execution-controller.ts';
5
+ import { type LogDestination, type TaggedLogEntry, addDestination } from './utils/logger.ts';
6
+
7
+ const QUEUE_CAP = 1000;
8
+ const CONTENT_CAP = 8000;
9
+ const RECONNECT_BASE_MS = 500;
10
+ const RECONNECT_MAX_MS = 10_000;
11
+ const ASK_TIMEOUT_MS = 15 * 60_000;
12
+ const FLUSH_TIMEOUT_MS = 3000;
13
+
14
+ /**
15
+ * Streams a run to a remote UI over one WebSocket, dialling out so a child
16
+ * process and a CI bot are the same case.
17
+ *
18
+ * It **is** a LogDestination — that is the whole integration on the logger's
19
+ * side, messages and `data` alike — and it answers asks by installing itself
20
+ * as the execution controller's input callback. Nothing else in explorbot
21
+ * knows it exists.
22
+ */
23
+ export class Remote implements LogDestination {
24
+ private url: string | null = null;
25
+ private socket: WebSocket | null = null;
26
+ private queue: Frame[] = [];
27
+ private reconnectDelay = RECONNECT_BASE_MS;
28
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
29
+ private asks = new Map<string, (value: string | null) => void>();
30
+ private askCounter = 0;
31
+ private lastActivity: string | null = null;
32
+
33
+ registerOption(program: Command): void {
34
+ program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
35
+ program.hook('preAction', (_thisCommand, actionCommand) => {
36
+ const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
37
+ if (!url) return;
38
+ this.attach(String(url), this.commandPath(actionCommand));
39
+ });
40
+ }
41
+
42
+ attach(url: string, command: string): void {
43
+ if (this.url) return;
44
+ this.url = url;
45
+ this.connect();
46
+
47
+ this.send('hello', { command, cwd: process.cwd(), pid: process.pid });
48
+ addDestination(this);
49
+ executionController.setInputCallback((prompt) => this.ask(prompt));
50
+ addActivityListener((activity) => this.reportActivity(activity));
51
+ }
52
+
53
+ isAttached(): boolean {
54
+ return !!this.url;
55
+ }
56
+
57
+ send(type: string, data: Record<string, unknown> = {}): void {
58
+ if (!this.url) return;
59
+ const frame: Frame = { type, ts: Date.now(), ...data };
60
+ if (this.socket?.readyState === WebSocket.OPEN) {
61
+ this.push(frame);
62
+ return;
63
+ }
64
+ this.queue.push(frame);
65
+ if (this.queue.length > QUEUE_CAP) this.queue.splice(0, this.queue.length - QUEUE_CAP);
66
+ }
67
+
68
+ ask(prompt: string): Promise<string | null> {
69
+ if (!this.url) return Promise.resolve(null);
70
+ this.askCounter++;
71
+ const askId = `ask-${this.askCounter}`;
72
+ this.send('ask', { askId, prompt });
73
+
74
+ return new Promise((resolve) => {
75
+ const timer = setTimeout(() => {
76
+ this.asks.delete(askId);
77
+ resolve(null);
78
+ }, ASK_TIMEOUT_MS);
79
+ this.asks.set(askId, (value) => {
80
+ clearTimeout(timer);
81
+ resolve(value);
82
+ });
83
+ });
84
+ }
85
+
86
+ async close(exitCode: number): Promise<void> {
87
+ if (!this.url) return;
88
+ this.send('result', { ok: exitCode === 0, exitCode });
89
+ await this.flush();
90
+
91
+ this.url = null;
92
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
93
+ // Whoever asks next has nobody to ask — leaving the callback installed would
94
+ // route them into a closed socket and park them until the ask times out.
95
+ executionController.clearInputCallback();
96
+ for (const resolve of this.asks.values()) resolve(null);
97
+ this.asks.clear();
98
+ this.queue = [];
99
+ this.socket?.close();
100
+ this.socket = null;
101
+ }
102
+
103
+ isEnabled(): boolean {
104
+ return this.isAttached();
105
+ }
106
+
107
+ /**
108
+ * The entry as the logger already built it, so the `LogType` reaches the UI
109
+ * as the frame's level and each kind is styled there instead of being scraped
110
+ * back out of flattened text.
111
+ */
112
+ write(entry: TaggedLogEntry): void {
113
+ if (entry.type === 'html') return;
114
+ if (entry.type === 'data') {
115
+ const [kind, payload] = entry.originalArgs || [];
116
+ this.send(String(kind), payload);
117
+ return;
118
+ }
119
+ let content = stripAnsi(entry.content ?? '');
120
+ if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
121
+ this.send('log', {
122
+ level: entry.type,
123
+ content,
124
+ namespace: entry.namespace,
125
+ error: this.errorOf(entry.originalArgs),
126
+ });
127
+ }
128
+
129
+ private connect(): void {
130
+ if (!this.url) return;
131
+
132
+ let opening: WebSocket;
133
+ try {
134
+ opening = new WebSocket(this.url);
135
+ } catch {
136
+ this.reconnect();
137
+ return;
138
+ }
139
+ this.socket = opening;
140
+
141
+ opening.addEventListener('open', () => {
142
+ this.reconnectDelay = RECONNECT_BASE_MS;
143
+ this.drain();
144
+ });
145
+
146
+ opening.addEventListener('message', (event: MessageEvent) => this.receive(String(event.data)));
147
+
148
+ opening.addEventListener('close', () => {
149
+ if (this.socket !== opening) return;
150
+ this.socket = null;
151
+ this.reconnect();
152
+ });
153
+
154
+ // A failed connection always closes too, which is where the retry is scheduled.
155
+ opening.addEventListener('error', () => {});
156
+ }
157
+
158
+ private reconnect(): void {
159
+ if (!this.url || this.reconnectTimer) return;
160
+ const delay = this.reconnectDelay;
161
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
162
+ this.reconnectTimer = setTimeout(() => {
163
+ this.reconnectTimer = null;
164
+ this.connect();
165
+ }, delay);
166
+ this.reconnectTimer.unref?.();
167
+ }
168
+
169
+ private async flush(): Promise<void> {
170
+ const deadline = Date.now() + FLUSH_TIMEOUT_MS;
171
+ while (Date.now() < deadline) {
172
+ if (this.socket?.readyState === WebSocket.OPEN) {
173
+ this.drain();
174
+ if (!this.queue.length && !this.socket.bufferedAmount) return;
175
+ }
176
+ await new Promise((resolve) => setTimeout(resolve, 25));
177
+ }
178
+ }
179
+
180
+ private drain(): void {
181
+ if (this.socket?.readyState !== WebSocket.OPEN) return;
182
+ const pending = this.queue;
183
+ this.queue = [];
184
+ for (const frame of pending) this.push(frame);
185
+ }
186
+
187
+ private push(frame: Frame): void {
188
+ try {
189
+ this.socket?.send(JSON.stringify(frame));
190
+ } catch {
191
+ this.queue.push(frame);
192
+ }
193
+ }
194
+
195
+ private receive(raw: string): void {
196
+ let frame: Frame;
197
+ try {
198
+ frame = JSON.parse(raw);
199
+ } catch {
200
+ return;
201
+ }
202
+ if (frame.type === 'interrupt') {
203
+ executionController.interrupt();
204
+ return;
205
+ }
206
+ const resolve = this.asks.get(String(frame.askId));
207
+ if (!resolve) return;
208
+ this.asks.delete(String(frame.askId));
209
+ resolve((frame.value as string | null) ?? null);
210
+ }
211
+
212
+ /** A new listener is primed with the current activity, which at attach time is
213
+ * nothing — so skip repeats, the priming null included. */
214
+ private reportActivity(activity: ActivityEntry | null): void {
215
+ const message = activity?.message ?? null;
216
+ if (message === this.lastActivity) return;
217
+ this.lastActivity = message;
218
+ this.send('activity', { message, kind: activity?.type });
219
+ }
220
+
221
+ private errorOf(args: any[] | undefined): string | undefined {
222
+ const failure = args?.[1] ?? args?.[0]?.error;
223
+ if (!failure) return undefined;
224
+ if (typeof failure === 'string') return failure;
225
+ if (typeof failure.message === 'string') return failure.message;
226
+ return undefined;
227
+ }
228
+
229
+ private commandPath(command: Command): string {
230
+ const parts: string[] = [];
231
+ let node: Command | null = command;
232
+ while (node) {
233
+ parts.unshift(node.name());
234
+ node = node.parent;
235
+ }
236
+ return parts.slice(1).join(' ') || parts.join(' ');
237
+ }
238
+ }
239
+
240
+ export const remote = new Remote();
241
+
242
+ /** Whatever the run wants to say. Not a schema — the UI renders what it knows
243
+ * and ignores the rest, so either side can start sending more at any time. */
244
+ type Frame = { type: string; ts: number; [key: string]: unknown };
package/src/reporter.ts CHANGED
@@ -283,13 +283,19 @@ export class Reporter {
283
283
 
284
284
  debugLog(testData);
285
285
 
286
- await this.client.addTestRun(status, testData);
286
+ await this.reportTestData(status, testData);
287
287
  debugLog(`Test reported: ${test.scenario} - ${status}`);
288
288
  } catch (error) {
289
289
  debugLog('Failed to report test:', error);
290
290
  }
291
291
  }
292
292
 
293
+ async reportTestData(status: string | null, testData: Record<string, unknown>): Promise<void> {
294
+ await this.startRun();
295
+ if (!this.isRunStarted) return;
296
+ await this.client.addTestRun(status, testData);
297
+ }
298
+
293
299
  async finishRun(): Promise<void> {
294
300
  if (!this.isRunStarted) {
295
301
  return;