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
@@ -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
  ];
@@ -8,15 +8,10 @@ import { getCliName } from '../utils/cli-name.ts';
8
8
  import { log, tag } from '../utils/logger.js';
9
9
  import { relativeToCwd } from '../utils/next-steps.ts';
10
10
 
11
- const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
12
- // import { '<your provider here>' } from '<your provider package here>';
13
-
14
- // Vercel AI SDK is used to connect to AI providers.
15
- // Bring your own provider or use OpenRouter (one API key, many providers).
16
- // https://github.com/testomatio/explorbot/blob/main/docs/providers.md
17
- const openrouter = createOpenRouter({
18
- apiKey: process.env.OPENROUTER_API_KEY,
19
- });
11
+ function defaultConfigTemplate(): string {
12
+ return `// 'provider/model-id' uses a bundled provider.
13
+ // It is also possible to import provider as a module from Vercel AI SDK.
14
+ // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
20
15
 
21
16
  const config = {
22
17
  web: {
@@ -25,12 +20,7 @@ const config = {
25
20
  },
26
21
 
27
22
  ai: {
28
- // fast model with tool calling capabilities
29
- model: openrouter('openai/gpt-oss-20b:nitro'),
30
- // vision model for screenshot analysis
31
- visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
32
- // agentic model for decision making
33
- agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
23
+ ${modelLines('openrouter')}
34
24
  },
35
25
 
36
26
  reporter: {
@@ -45,6 +35,7 @@ const config = {
45
35
 
46
36
  export default config;
47
37
  `;
38
+ }
48
39
 
49
40
  const DEFAULT_ENV_TEMPLATE = dedent`
50
41
  # AI provider API keys
@@ -141,7 +132,7 @@ export function runInitCommand(options: InitCommandOptions): void {
141
132
  process.exit(1);
142
133
  }
143
134
 
144
- writeFileSync(outPath, DEFAULT_CONFIG_TEMPLATE, 'utf8');
135
+ writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
145
136
  log(`Created config file: ${relativeToCwd(outPath)}`);
146
137
 
147
138
  const envPath = resolve(process.cwd(), '.env');
@@ -222,8 +213,7 @@ async function renderInitWizard(mode: 'choose' | 'global'): Promise<'local' | 'g
222
213
  });
223
214
  }
224
215
 
225
- function globalConfigTemplate(provider: string): string {
226
- const { envKey } = PROVIDERS[provider];
216
+ function modelLines(provider: string): string {
227
217
  const recommended = ConfigParser.recommendedModels()[provider] || {};
228
218
  const roles: Array<[ModelRoleName, string]> = [
229
219
  ['model', 'fast model with tool calling capabilities'],
@@ -231,7 +221,11 @@ function globalConfigTemplate(provider: string): string {
231
221
  ['agenticModel', 'agentic model for decision making'],
232
222
  ];
233
223
 
234
- const models = roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
224
+ return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
225
+ }
226
+
227
+ function globalConfigTemplate(provider: string): string {
228
+ const { envKey } = PROVIDERS[provider];
235
229
 
236
230
  return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
237
231
  // Models are written as 'provider/model-id' so they resolve without a local node_modules.
@@ -240,7 +234,7 @@ function globalConfigTemplate(provider: string): string {
240
234
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
241
235
  const config = {
242
236
  ai: {
243
- ${models}
237
+ ${modelLines(provider)}
244
238
  },
245
239
 
246
240
  reporter: {
package/src/config.ts CHANGED
@@ -433,7 +433,7 @@ export class ConfigParser {
433
433
  public getOutputDir(): string {
434
434
  const config = this.getConfig();
435
435
  if (!this.configPath) throw new Error('Config path not found');
436
- return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
436
+ return this.resolveProjectDir(config.dirs?.output || 'output');
437
437
  }
438
438
 
439
439
  public getProjectRoot(): string {
@@ -444,6 +444,7 @@ export class ConfigParser {
444
444
  }
445
445
 
446
446
  public resolveProjectDir(relativeDir: string): string {
447
+ if (path.isAbsolute(relativeDir)) return relativeDir;
447
448
  if (!this.configPath) return relativeDir;
448
449
  return path.join(this.getProjectRoot(), relativeDir);
449
450
  }
@@ -514,6 +515,7 @@ export class ConfigParser {
514
515
  model: { modelId: 'test-model', provider: 'test' },
515
516
  config: {},
516
517
  vision: false,
518
+ langfuse: { enabled: false },
517
519
  },
518
520
  dirs: {
519
521
  knowledge: join(testBaseDir, 'knowledge'),
@@ -650,6 +652,18 @@ export class ConfigParser {
650
652
  config.playwright.url = options.baseUrl;
651
653
  }
652
654
 
655
+ if (config.ai) {
656
+ const langfuse = config.ai.langfuse;
657
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
658
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
659
+ config.ai.langfuse = {
660
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
661
+ publicKey,
662
+ secretKey,
663
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
664
+ };
665
+ }
666
+
653
667
  return config;
654
668
  }
655
669
 
@@ -762,6 +776,32 @@ export function missingConfigMessage(configFile = 'explorbot.config.js'): string
762
776
  `;
763
777
  }
764
778
 
779
+ export function modelName(model: unknown): string {
780
+ if (typeof model === 'string') return model;
781
+ return (model as any)?.modelId || (model as any)?.model || 'unknown';
782
+ }
783
+
784
+ export function modelProvider(model: unknown): string {
785
+ const provider = (model as any)?.provider;
786
+ if (typeof provider === 'string') return provider.split('.')[0];
787
+ if (typeof model === 'string') return model.split('/')[0];
788
+ return '';
789
+ }
790
+
791
+ export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel> {
792
+ if (!ai?.model) return {};
793
+
794
+ const describe = (model: unknown): ConfiguredModel => ({ name: modelName(model), provider: modelProvider(model) });
795
+
796
+ const models: Record<string, ConfiguredModel> = { model: describe(ai.model) };
797
+ if (ai.agenticModel) models.agenticModel = describe(ai.agenticModel);
798
+ if (ai.visionModel) models.visionModel = describe(ai.visionModel);
799
+ for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
800
+ if (agentConfig?.model) models[agent] = describe(agentConfig.model);
801
+ }
802
+ return models;
803
+ }
804
+
765
805
  export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
766
806
  if (!ai) return;
767
807
 
@@ -838,6 +878,11 @@ export async function createModel(provider: string, modelId: string): Promise<an
838
878
 
839
879
  type ModelRole = 'model' | 'visionModel' | 'agenticModel';
840
880
 
881
+ interface ConfiguredModel {
882
+ name: string;
883
+ provider: string;
884
+ }
885
+
841
886
  interface ProviderInfo {
842
887
  envKey: string;
843
888
  load: () => Promise<(modelId: string) => any>;
@@ -849,4 +894,4 @@ interface EnvVar {
849
894
  required?: boolean;
850
895
  }
851
896
 
852
- export type { ModelRole, EnvVar, ProviderInfo };
897
+ export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
@@ -304,6 +304,14 @@ export class ExperienceTracker {
304
304
  return this.buildToc(sorted);
305
305
  }
306
306
 
307
+ renderExperienceFor(state: ActionResult): string {
308
+ const successful = this.getSuccessfulExperience(state);
309
+ if (!successful.length) return '';
310
+
311
+ tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${state.url}`);
312
+ return renderExperienceRecipes(successful);
313
+ }
314
+
307
315
  renderExperienceTocFor(state: ActionResult): string {
308
316
  const toc = this.getExperienceTableOfContents(state);
309
317
  if (toc.length === 0) return '';
@@ -437,6 +445,11 @@ function indexToLetters(index: number): string {
437
445
  return result;
438
446
  }
439
447
 
448
+ export function renderExperienceRecipes(recipes: string[]): string {
449
+ if (recipes.length === 0) return '';
450
+ 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>`;
451
+ }
452
+
440
453
  export function renderExperienceToc(toc: ExperienceTocEntry[]): string {
441
454
  if (toc.length === 0) return '';
442
455
 
package/src/explorbot.ts CHANGED
@@ -248,7 +248,7 @@ export class ExplorBot {
248
248
  this.agents.tester = this.createAgent((deps) => {
249
249
  const researcher = this.agentResearcher();
250
250
  const navigator = this.agentNavigator();
251
- const tools = createAgentTools({ ...deps, researcher, navigator });
251
+ const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
252
252
  return new Tester(deps, researcher, navigator, tools);
253
253
  });
254
254
 
@@ -487,9 +487,11 @@ export class ExplorBot {
487
487
 
488
488
  setCurrentPlan(plan?: Plan): void {
489
489
  this.currentPlan = plan;
490
- if (plan && !this.sessionPlans.includes(plan)) {
490
+ if (!plan) return;
491
+ if (!this.sessionPlans.includes(plan)) {
491
492
  this.sessionPlans.push(plan);
492
493
  }
494
+ plan.notifyChange();
493
495
  }
494
496
 
495
497
  getSessionTests(): Test[] {
@@ -9,7 +9,7 @@ const RECORDABLE: Record<string, Set<string>> = {
9
9
  Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
10
10
  };
11
11
 
12
- 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.";
12
+ const PLAYWRIGHT_INCOMPATIBLE = "Playwright output requires playwright-core 1.62 or newer (lib/coreBundle does not expose iso.asLocator). Use output.framework: 'codeceptjs' instead.";
13
13
 
14
14
  let cachedAsLocator: ((lang: string, selector: string) => string) | null = null;
15
15
  let asLocatorLoadAttempted = false;
@@ -20,16 +20,11 @@ function getAsLocator(): (lang: string, selector: string) => string {
20
20
  if (asLocatorLoadAttempted) throw new Error(PLAYWRIGHT_INCOMPATIBLE);
21
21
 
22
22
  asLocatorLoadAttempted = true;
23
- try {
24
- const mod = nodeRequire('playwright-core/lib/utils');
25
- if (typeof (mod as any)?.asLocator === 'function') {
26
- cachedAsLocator = (mod as any).asLocator;
27
- return cachedAsLocator!;
28
- }
29
- } catch {
30
- // Module not exported or not found
31
- }
32
- throw new Error(PLAYWRIGHT_INCOMPATIBLE);
23
+ const asLocator = nodeRequire('playwright-core/lib/coreBundle')?.iso?.asLocator;
24
+ if (typeof asLocator !== 'function') throw new Error(PLAYWRIGHT_INCOMPATIBLE);
25
+
26
+ cachedAsLocator = asLocator;
27
+ return cachedAsLocator;
33
28
  }
34
29
 
35
30
  export interface TraceCall {
package/src/remote.ts CHANGED
@@ -16,8 +16,9 @@ const FLUSH_TIMEOUT_MS = 3000;
16
16
  * process and a CI bot are the same case.
17
17
  *
18
18
  * It **is** a LogDestination — that is the whole integration on the logger's
19
- * side — and it answers asks by installing itself as the execution
20
- * controller's input callback. Nothing else in explorbot knows it exists.
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.
21
22
  */
22
23
  export class Remote implements LogDestination {
23
24
  private url: string | null = null;
@@ -110,6 +111,11 @@ export class Remote implements LogDestination {
110
111
  */
111
112
  write(entry: TaggedLogEntry): void {
112
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
+ }
113
119
  let content = stripAnsi(entry.content ?? '');
114
120
  if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
115
121
  this.send('log', {
@@ -1,8 +1,8 @@
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
  import { detectFocusArea } from './utils/aria.js';
5
- import { createDebug } from './utils/logger.js';
5
+ import { createDebug, tag } from './utils/logger.js';
6
6
  import { slugify } from './utils/strings.js';
7
7
  import { extractStatePath } from './utils/url-matcher.js';
8
8
 
@@ -117,6 +117,9 @@ export class StateManager {
117
117
  * Emit state change event to all listeners
118
118
  */
119
119
  private emitStateChange(event: StateTransition): void {
120
+ const state = event.toState;
121
+ tag('data').log('state', { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 });
122
+
120
123
  this.stateChangeListeners.forEach((listener) => {
121
124
  try {
122
125
  listener(event);
package/src/test-plan.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import figures from 'figures';
3
+ import type { ActionResult } from './action-result.ts';
3
4
  import { WebPageState } from './state-manager.ts';
5
+ import { tag } from './utils/logger.ts';
4
6
  import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts';
5
7
  import { uniqSessionName } from './utils/unique-names.ts';
6
8
 
@@ -235,6 +237,7 @@ export class Test extends Task {
235
237
  startTime?: number;
236
238
  endTime?: number;
237
239
  resetCount = 0;
240
+ appliedExperience: AppliedExperience[] = [];
238
241
 
239
242
  constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps: string[] = []) {
240
243
  super(scenario, startUrl);
@@ -256,6 +259,17 @@ export class Test extends Task {
256
259
  return [...new Set([this.startUrl, ...this.states.map((s) => s.url)].filter((value): value is string => Boolean(value) && value.trim() !== ''))];
257
260
  }
258
261
 
262
+ applyExperience(recipes: AppliedExperience[]): void {
263
+ for (const recipe of recipes) {
264
+ if (this.appliedExperience.some((applied) => applied.content === recipe.content)) continue;
265
+ this.appliedExperience.push(recipe);
266
+ }
267
+ }
268
+
269
+ getAppliedExperience(state: ActionResult): string[] {
270
+ return this.appliedExperience.filter((recipe) => state.isRelevantExperienceRecord({ url: recipe.url })).map((recipe) => recipe.content);
271
+ }
272
+
259
273
  addArtifact(artifact?: string): void {
260
274
  if (!artifact) return;
261
275
  const timestamp = `${performance.now()}_${this.timestampCounter++}`;
@@ -317,6 +331,7 @@ export class Test extends Task {
317
331
  this.startTime = performance.now();
318
332
  this.addNote(`Test started. Session name: ${this.sessionName}`);
319
333
  this.plan?.notifyChange();
334
+ this.reportStatus();
320
335
  }
321
336
 
322
337
  finish(result: TestResultType = TestResult.FAILED): void {
@@ -324,6 +339,7 @@ export class Test extends Task {
324
339
  this.result = result;
325
340
  this.endTime = performance.now();
326
341
  this.plan?.notifyChange();
342
+ this.reportStatus();
327
343
  }
328
344
 
329
345
  getDurationMs(): number | null {
@@ -336,6 +352,18 @@ export class Test extends Task {
336
352
  return this.expected.filter((e) => !achieved.includes(e));
337
353
  }
338
354
 
355
+ private reportStatus(): void {
356
+ tag('data').log('test', {
357
+ scenario: this.scenario,
358
+ status: this.status,
359
+ result: this.result,
360
+ priority: this.priority,
361
+ sessionName: this.sessionName,
362
+ url: this.startUrl,
363
+ plan: this.plan?.title,
364
+ });
365
+ }
366
+
339
367
  override getLog(): Array<{ type: 'step' | 'note' | 'artifact'; content: string; timestamp: number }> {
340
368
  const merged: Record<string, { type: 'step' | 'note' | 'artifact'; content: string }> = {};
341
369
 
@@ -407,6 +435,11 @@ export class Plan {
407
435
  for (const listener of this.changeListeners) {
408
436
  listener(this.tests);
409
437
  }
438
+ tag('data').log('plan', {
439
+ title: this.title,
440
+ url: this.url,
441
+ tests: this.tests.map((test) => ({ scenario: test.scenario, status: test.status, result: test.result, priority: test.priority })),
442
+ });
410
443
  }
411
444
 
412
445
  getAllTests(): Test[] {
@@ -510,3 +543,8 @@ interface UrlNoteState {
510
543
  h2?: string;
511
544
  screenshotFile?: string;
512
545
  }
546
+
547
+ interface AppliedExperience {
548
+ url: string;
549
+ content: string;
550
+ }