explorbot 0.3.2 → 0.3.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 (51) hide show
  1. package/bin/explorbot-cli.ts +14 -3
  2. package/dist/bin/explorbot-cli.js +12 -3
  3. package/dist/models.json +2 -0
  4. package/dist/package.json +1 -1
  5. package/dist/src/action-result.d.ts +1 -0
  6. package/dist/src/action-result.js +17 -12
  7. package/dist/src/action.d.ts +10 -0
  8. package/dist/src/action.js +16 -10
  9. package/dist/src/ai/conversation.d.ts +2 -1
  10. package/dist/src/ai/conversation.js +9 -4
  11. package/dist/src/ai/navigator.d.ts +3 -0
  12. package/dist/src/ai/navigator.js +20 -4
  13. package/dist/src/ai/pilot.js +9 -0
  14. package/dist/src/ai/planner.js +1 -1
  15. package/dist/src/ai/provider.js +28 -2
  16. package/dist/src/ai/researcher/deep-analysis.js +5 -1
  17. package/dist/src/ai/researcher/sections.js +0 -1
  18. package/dist/src/ai/researcher.js +0 -1
  19. package/dist/src/ai/rules.js +0 -1
  20. package/dist/src/ai/tester.d.ts +1 -0
  21. package/dist/src/ai/tester.js +8 -9
  22. package/dist/src/ai/tools.d.ts +2 -0
  23. package/dist/src/ai/tools.js +21 -4
  24. package/dist/src/commands/init-command.js +74 -24
  25. package/dist/src/components/InitWizard.d.ts +2 -1
  26. package/dist/src/components/InitWizard.js +8 -4
  27. package/dist/src/explorer.js +1 -0
  28. package/dist/src/knowledge-tracker.d.ts +3 -1
  29. package/dist/src/knowledge-tracker.js +4 -4
  30. package/dist/src/utils/aria.js +1 -1
  31. package/docs/basics/providers.md +2 -4
  32. package/models.json +2 -0
  33. package/package.json +1 -1
  34. package/src/action-result.ts +20 -15
  35. package/src/action.ts +21 -12
  36. package/src/ai/conversation.ts +11 -5
  37. package/src/ai/navigator.ts +22 -4
  38. package/src/ai/pilot.ts +9 -0
  39. package/src/ai/planner.ts +1 -1
  40. package/src/ai/provider.ts +28 -2
  41. package/src/ai/researcher/deep-analysis.ts +6 -1
  42. package/src/ai/researcher/sections.ts +0 -1
  43. package/src/ai/researcher.ts +0 -1
  44. package/src/ai/rules.ts +0 -1
  45. package/src/ai/tester.ts +8 -7
  46. package/src/ai/tools.ts +20 -4
  47. package/src/commands/init-command.ts +81 -22
  48. package/src/components/InitWizard.tsx +8 -4
  49. package/src/explorer.ts +1 -0
  50. package/src/knowledge-tracker.ts +4 -4
  51. package/src/utils/aria.ts +1 -1
@@ -343,16 +343,17 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
343
343
  if (action.lastError) {
344
344
  const message = errorText(action.lastError);
345
345
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
346
- let formSuggestion = 'Look into error message and identify which commands passed and which failed. Continue execution using step-by-step approach using click() and form() tools.';
346
+ let formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
347
347
  if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
348
348
  const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
349
349
  if (disambiguated) {
350
350
  formSuggestion = `Multiple elements matched. Add step.opts({ elementIndex: ${disambiguated.position} }) to the failing command. Fallback locator: ${disambiguated.xpath}`;
351
351
  }
352
352
  }
353
- return failedToolResult('form', `Form execution FAILED! ${message}`, {
353
+ return failedToolResult('form', `Form execution FAILED! ${message}\n${formatExecutedSteps(action.executedSteps, codeLines.length)}`, {
354
354
  ...toolResult,
355
355
  code: codeBlock,
356
+ attempts: action.executedSteps,
356
357
  suggestion: formSuggestion,
357
358
  }, action.lastError);
358
359
  }
@@ -369,6 +370,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
369
370
  ...toolResult,
370
371
  message: `Form completed successfully with ${lines.length} commands.`,
371
372
  commandsExecuted: lines.length,
373
+ attempts: action.executedSteps,
372
374
  code: codeBlock,
373
375
  suggestion: 'Verify the form was filled in correctly using see() tool. If needed to submit: try click() tool or form() with I.pressKey("Enter").',
374
376
  }, action);
@@ -698,18 +700,24 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
698
700
  const actionResult = ActionResult.fromState(currentState);
699
701
  const experience = renderExperienceRecipes(explorer.activeTest?.getAppliedExperience(actionResult) ?? []);
700
702
  const success = await navigator.resolveState(instruction, actionResult, { experience });
703
+ const attempts = navigator.executedSteps;
704
+ let stepReport = '';
705
+ if (attempts.length)
706
+ stepReport = `\n${formatExecutedSteps(attempts)}`;
701
707
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, instruction);
702
708
  if (success) {
703
709
  return successToolResult('interact', {
704
710
  ...toolResult,
705
- message: `Successfully executed: ${instruction}`,
711
+ message: `Successfully executed: ${instruction}${stepReport}`,
712
+ attempts,
706
713
  });
707
714
  }
708
715
  let reason = '';
709
716
  if (navigator.lastFailureReason)
710
717
  reason = `: ${navigator.lastFailureReason}`;
711
- return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
718
+ return failedToolResult('interact', `Failed to execute: ${instruction}${reason}${stepReport}`, {
712
719
  ...toolResult,
720
+ attempts,
713
721
  suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
714
722
  });
715
723
  }
@@ -1071,6 +1079,15 @@ export function isMajorPageChange(pageDiff) {
1071
1079
  export function hasFailedRequest(pageDiff) {
1072
1080
  return (pageDiff.requests ?? []).some((request) => request.status >= 400);
1073
1081
  }
1082
+ export function formatExecutedSteps(steps, requestedCount = steps.length) {
1083
+ if (!steps.length)
1084
+ return `No command ran of ${requestedCount} requested.`;
1085
+ const lines = steps.map((step) => ` ${step.success ? 'OK' : 'FAILED'} ${step.command}`);
1086
+ const notRun = requestedCount - steps.length;
1087
+ if (notRun > 0)
1088
+ lines.push(` NOT RUN ${notRun} more`);
1089
+ return lines.join('\n');
1090
+ }
1074
1091
  function hasObservablePageChange(data) {
1075
1092
  if (!data?.pageDiff)
1076
1093
  return false;
@@ -1,15 +1,17 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, extname, join, resolve } from 'node:path';
3
3
  import chalk from 'chalk';
4
- import dedent from 'dedent';
5
4
  import { ConfigParser, PROVIDERS } from "../config.js";
6
5
  import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "../global-config.js";
7
6
  import { getCliName } from "../utils/cli-name.js";
8
7
  import { log, tag } from '../utils/logger.js';
9
8
  import { relativeToCwd } from "../utils/next-steps.js";
10
- function defaultConfigTemplate() {
9
+ function defaultConfigTemplate(provider, esm) {
10
+ let moduleExport = 'module.exports = config;';
11
+ if (esm)
12
+ moduleExport = 'export default config;';
11
13
  return `// 'provider/model-id' uses a bundled provider.
12
- // It is also possible to import provider as a module from Vercel AI SDK.
14
+ // It is also possible to import provider as a module from Vercel AI SDK.
13
15
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
14
16
 
15
17
  const config = {
@@ -19,7 +21,7 @@ const config = {
19
21
  },
20
22
 
21
23
  ai: {
22
- ${modelLines('openrouter')}
24
+ ${modelLines(provider)}
23
25
  },
24
26
 
25
27
  reporter: {
@@ -32,16 +34,17 @@ ${modelLines('openrouter')}
32
34
  },
33
35
  };
34
36
 
35
- export default config;
37
+ ${moduleExport}
36
38
  `;
37
39
  }
38
- const DEFAULT_ENV_TEMPLATE = dedent `
39
- # AI provider API keys
40
- OPENROUTER_API_KEY=
41
-
42
- # OPENAI_API_KEY=
43
- # ANTHROPIC_API_KEY=
44
- # GROQ_API_KEY=
40
+ function envTemplate(provider) {
41
+ const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
42
+ if (name === provider)
43
+ return `${envKey}=`;
44
+ return `# ${envKey}=`;
45
+ });
46
+ return `# AI provider API keys
47
+ ${keyLines.join('\n')}
45
48
 
46
49
  # Langfuse Tracing
47
50
  LANGFUSE_SECRET_KEY=
@@ -49,10 +52,11 @@ LANGFUSE_PUBLIC_KEY=
49
52
  LANGFUSE_BASE_URL=
50
53
 
51
54
  # Testomat.io API key to publish run results
52
- TESTOMATIO=
53
- `;
55
+ TESTOMATIO=`;
56
+ }
54
57
  export async function runInit(options) {
55
- if (options.global || options.provider) {
58
+ const localRequested = !!(options.configPath || options.path);
59
+ if (options.global || (options.provider && !localRequested)) {
56
60
  await runGlobalInit(options);
57
61
  return;
58
62
  }
@@ -61,8 +65,12 @@ export async function runInit(options) {
61
65
  return;
62
66
  }
63
67
  const choice = await renderInitWizard('choose');
64
- if (choice === 'local')
65
- runInitCommand(options);
68
+ if (choice !== 'local')
69
+ return;
70
+ const provider = await renderLocalProviderWizard();
71
+ if (!provider)
72
+ return;
73
+ runInitCommand({ ...options, provider });
66
74
  }
67
75
  export function writeGlobalConfig(provider, apiKey) {
68
76
  if (!PROVIDERS[provider]) {
@@ -87,7 +95,7 @@ export function writeGlobalConfig(provider, apiKey) {
87
95
  tag('substep').log(chalk.yellow(`${getCliName()} sites`));
88
96
  }
89
97
  export function runInitCommand(options) {
90
- const configPath = options.configPath ?? './explorbot.config.js';
98
+ const provider = options.provider || 'openrouter';
91
99
  const force = options.force ?? false;
92
100
  const customPath = options.path;
93
101
  const originalCwd = process.cwd();
@@ -100,13 +108,15 @@ export function runInitCommand(options) {
100
108
  process.chdir(dir);
101
109
  log(`Working in directory: ${relativeToCwd(dir)}`);
102
110
  }
111
+ const configName = 'explorbot.config.js';
112
+ const configPath = options.configPath ?? `./${configName}`;
103
113
  try {
104
114
  let outPath = resolve(configPath);
105
115
  if (existsSync(outPath) && statSync(outPath).isDirectory()) {
106
- outPath = join(outPath, 'explorbot.config.js');
116
+ outPath = join(outPath, configName);
107
117
  }
108
118
  else if (!extname(outPath)) {
109
- outPath = join(outPath, 'explorbot.config.js');
119
+ outPath = join(outPath, configName);
110
120
  }
111
121
  const dir = dirname(outPath);
112
122
  if (!existsSync(dir)) {
@@ -118,23 +128,28 @@ export function runInitCommand(options) {
118
128
  log('Use --force to overwrite existing file');
119
129
  process.exit(1);
120
130
  }
121
- writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
131
+ const esm = extname(outPath) !== '.js' || isModuleProject(dirname(outPath));
132
+ writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
122
133
  log(`Created config file: ${relativeToCwd(outPath)}`);
123
134
  const envPath = resolve(process.cwd(), '.env');
124
135
  if (!existsSync(envPath)) {
125
- writeFileSync(envPath, `${DEFAULT_ENV_TEMPLATE}\n`, 'utf8');
136
+ writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
126
137
  log(`Created env file: ${relativeToCwd(envPath)}`);
127
138
  }
128
139
  else {
129
140
  log(`Env file already exists: ${relativeToCwd(envPath)}`);
130
141
  }
142
+ const missing = missingRoles(provider);
143
+ if (missing.length) {
144
+ tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
145
+ }
131
146
  log('');
132
147
  log('Next steps:');
133
148
  log('1. Configure AI provider in .env');
134
149
  log('2. Set AI models config file');
135
150
  log('3. Set web application URL in the config file');
136
151
  log('4. Add initial knowledge (how to authorize to the application, etc.)');
137
- tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to aurhorize use these credentials: admin@example.com / secret123'`));
152
+ tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to authorize use these credentials: admin@example.com / secret123'`));
138
153
  tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
139
154
  log('5. Launch application on a relative URL');
140
155
  tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
@@ -187,6 +202,23 @@ async function renderInitWizard(mode) {
187
202
  }), { exitOnCtrlC: false, patchConsole: false });
188
203
  });
189
204
  }
205
+ async function renderLocalProviderWizard() {
206
+ const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
207
+ return new Promise((resolve) => {
208
+ const finish = (provider) => {
209
+ unmount();
210
+ resolve(provider);
211
+ };
212
+ const { unmount } = render(React.createElement(InitWizard, {
213
+ mode: 'local',
214
+ globalConfigExists: !!findGlobalConfig(),
215
+ onLocal: () => finish(null),
216
+ onComplete: () => finish(null),
217
+ onCancel: () => finish(null),
218
+ onLocalProvider: (provider) => finish(provider),
219
+ }), { exitOnCtrlC: false, patchConsole: false });
220
+ });
221
+ }
190
222
  function modelLines(provider) {
191
223
  const recommended = ConfigParser.recommendedModels()[provider] || {};
192
224
  const roles = [
@@ -216,9 +248,27 @@ ${modelLines(provider)}
216
248
  },
217
249
  };
218
250
 
219
- export default config;
251
+ module.exports = config;
220
252
  `;
221
253
  }
254
+ function isModuleProject(configDir) {
255
+ let currentDir = resolve(configDir);
256
+ while (true) {
257
+ const packagePath = join(currentDir, 'package.json');
258
+ if (existsSync(packagePath)) {
259
+ try {
260
+ return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
266
+ const parentDir = dirname(currentDir);
267
+ if (parentDir === currentDir)
268
+ return false;
269
+ currentDir = parentDir;
270
+ }
271
+ }
222
272
  function missingRoles(provider) {
223
273
  const recommended = ConfigParser.recommendedModels()[provider] || {};
224
274
  return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
@@ -1,10 +1,11 @@
1
1
  import React from 'react';
2
2
  interface InitWizardProps {
3
- mode: 'choose' | 'global';
3
+ mode: 'choose' | 'global' | 'local';
4
4
  globalConfigExists: boolean;
5
5
  onLocal: () => void;
6
6
  onComplete: () => void;
7
7
  onCancel: () => void;
8
+ onLocalProvider?: (provider: string) => void;
8
9
  }
9
10
  declare const InitWizard: React.FC<InitWizardProps>;
10
11
  export default InitWizard;
@@ -7,7 +7,7 @@ import { ConfigParser, PROVIDERS, createModel } from '../config.js';
7
7
  import { globalDir } from '../global-config.js';
8
8
  import InputReadline from './InputReadline.js';
9
9
  const PROVIDER_NAMES = Object.keys(PROVIDERS);
10
- const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel }) => {
10
+ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalProvider }) => {
11
11
  const [step, setStep] = useState(mode === 'choose' ? 'target' : 'provider');
12
12
  const [targetIndex, setTargetIndex] = useState(0);
13
13
  const [providerIndex, setProviderIndex] = useState(0);
@@ -67,8 +67,12 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel })
67
67
  setProviderIndex((index) => Math.max(0, index - 1));
68
68
  if (key.downArrow)
69
69
  setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
70
- if (key.return)
71
- setStep('key');
70
+ if (key.return) {
71
+ if (mode === 'local')
72
+ onLocalProvider?.(provider);
73
+ else
74
+ setStep('key');
75
+ }
72
76
  return;
73
77
  }
74
78
  if (status)
@@ -121,7 +125,7 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel })
121
125
  React.createElement(Box, { marginTop: 1 },
122
126
  React.createElement(Text, { dimColor: true },
123
127
  "Config goes to ",
124
- globalDir(),
128
+ mode === 'local' ? 'the current directory' : globalDir(),
125
129
  " | ",
126
130
  step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
127
131
  " | Ctrl+C: exit"))));
@@ -203,6 +203,7 @@ class Explorer {
203
203
  const projectRoot = configParser.getProjectRoot();
204
204
  global.output_dir = configParser.getStatesDir();
205
205
  global.codecept_dir = projectRoot;
206
+ global.codeceptjs = codeceptjs;
206
207
  configParser.validateConfig(this.config);
207
208
  codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
208
209
  }
@@ -17,7 +17,9 @@ export declare class KnowledgeTracker {
17
17
  renderRelevantKnowledge(state: ActionResult): string;
18
18
  renderRelevantContext(state: ActionResult): string;
19
19
  renderApplicationSpec(state: ActionResult): string;
20
- addKnowledge(urlPattern: string, description: string): {
20
+ addKnowledge(urlPattern: string, description: string, opts?: {
21
+ replace?: boolean;
22
+ }): {
21
23
  filename: string;
22
24
  filePath: string;
23
25
  isNewFile: boolean;
@@ -73,7 +73,7 @@ export class KnowledgeTracker {
73
73
  renderApplicationSpec(state) {
74
74
  return this.applicationSpec?.renderFor(state) || '';
75
75
  }
76
- addKnowledge(urlPattern, description) {
76
+ addKnowledge(urlPattern, description, opts) {
77
77
  const configParser = ConfigParser.getInstance();
78
78
  const configPath = configParser.getConfigPath();
79
79
  if (!configPath) {
@@ -102,11 +102,11 @@ export class KnowledgeTracker {
102
102
  const existingDescription = parsed.content.trim();
103
103
  // Append new knowledge with separator
104
104
  let newContent;
105
- if (existingDescription) {
106
- newContent = `${existingDescription}\n\n---\n\n${description}`;
105
+ if (opts?.replace || !existingDescription) {
106
+ newContent = description;
107
107
  }
108
108
  else {
109
- newContent = description;
109
+ newContent = `${existingDescription}\n\n---\n\n${description}`;
110
110
  }
111
111
  const fileContent = matter.stringify(newContent, frontmatter);
112
112
  writeFileSync(filePath, fileContent, 'utf8');
@@ -569,7 +569,7 @@ export function parseAriaLocator(ariaStr) {
569
569
  const trimmed = ariaStr.trim();
570
570
  if (trimmed === '-' || trimmed === '' || trimmed === '"-"')
571
571
  return null;
572
- const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?text["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
572
+ const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?(?:text|name)["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
573
573
  if (!match)
574
574
  return null;
575
575
  return { role: match[1], text: match[2] };
@@ -154,14 +154,12 @@ Set the recommended model in the exported config:
154
154
  ```javascript
155
155
  export default {
156
156
  ai: {
157
+ model: anthropic('claude-haiku-4-5-20251001'),
158
+ visionModel: anthropic('claude-haiku-4-5-20251001'),
157
159
  agenticModel: anthropic('claude-haiku-4-5-20251001'),
158
160
  },
159
161
  };
160
162
  ```
161
-
162
- > [!NOTE]
163
- > This provider currently doesn't serve `model` and `visionModel`, which is required for Explorbot to run at optimal cost and speed.
164
- > It is recommended to pair it with another AI provider.
165
163
  <!-- END provider:anthropic -->
166
164
 
167
165
  ### Azure OpenAI
package/models.json CHANGED
@@ -18,6 +18,8 @@
18
18
  "agenticModel": "gpt-5.6-luna"
19
19
  },
20
20
  "anthropic": {
21
+ "model": "claude-haiku-4-5-20251001",
22
+ "visionModel": "claude-haiku-4-5-20251001",
21
23
  "agenticModel": "claude-haiku-4-5-20251001"
22
24
  },
23
25
  "mistral": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.2",
3
+ "version": "0.3.5",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -550,17 +550,9 @@ export class ActionResult implements ActionResultData {
550
550
  }
551
551
 
552
552
  if (diff.htmlParts.length > 0) {
553
- const htmlConfig = this.normalizeHtmlConfig();
554
- const processedParts: HtmlDiffPart[] = [];
555
- for (const part of diff.htmlParts) {
556
- const filteredHtml = htmlCombinedSnapshot(part.subtree, htmlConfig?.combined);
557
- const minified = await minifyHtml(filteredHtml);
558
- if (minified) {
559
- processedParts.push({ ...part, subtree: minified });
560
- }
561
- }
562
- if (processedParts.length > 0) {
563
- pageDiff.htmlParts = collapseHtmlParts(processedParts);
553
+ const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
554
+ if (collapsed.length > 0) {
555
+ pageDiff.htmlParts = collapsed;
564
556
  }
565
557
  }
566
558
 
@@ -601,10 +593,12 @@ function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
601
593
  const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
602
594
 
603
595
  if (fullPageReRender) {
604
- return parts.map((part) => ({
605
- ...part,
606
- subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
607
- }));
596
+ return parts
597
+ .filter((part) => part.added.length > 0 || part.removed.length > 0)
598
+ .map((part) => ({
599
+ ...part,
600
+ subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
601
+ }));
608
602
  }
609
603
 
610
604
  return parts.map((part) => {
@@ -660,6 +654,17 @@ export class Diff {
660
654
  return this._htmlDiffResult.parts;
661
655
  }
662
656
 
657
+ async cleanedHtmlParts(): Promise<HtmlDiffPart[]> {
658
+ const htmlConfig = ConfigParser.getInstance().getConfig().html;
659
+ const cleaned: HtmlDiffPart[] = [];
660
+ for (const part of this.htmlParts) {
661
+ const minified = await minifyHtml(htmlCombinedSnapshot(part.subtree, htmlConfig?.combined));
662
+ if (!minified) continue;
663
+ cleaned.push({ ...part, subtree: minified });
664
+ }
665
+ return cleaned;
666
+ }
667
+
663
668
  get ariaChanged(): string | null {
664
669
  return this._ariaDiffResult;
665
670
  }
package/src/action.ts CHANGED
@@ -37,6 +37,7 @@ class Action {
37
37
  public playwrightHelper: any;
38
38
  public playwrightGroupId: string | null = null;
39
39
  public assertionSteps: Array<{ name: string; args: any[] }> = [];
40
+ public executedSteps: ExecutedStep[] = [];
40
41
  public lastValue: unknown;
41
42
  private recorder?: PlaywrightRecorder;
42
43
  private recovery: RecoveryRunner;
@@ -329,9 +330,9 @@ class Action {
329
330
 
330
331
  let codeString = code.replace(/^\(I\) => /, '').trim();
331
332
 
332
- const executedSteps: string[] = [];
333
+ const executedSteps: ExecutedStep[] = [];
333
334
  const assertionSteps: Array<{ name: string; args: any[] }> = [];
334
- const stepListener = attachStepLogger(executedSteps, assertionSteps);
335
+ const detachSteps = attachStepLogger(executedSteps, assertionSteps);
335
336
  const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
336
337
  this.playwrightGroupId = groupId;
337
338
  const detachResponses = this.captureResponses();
@@ -360,12 +361,13 @@ class Action {
360
361
  await recorder.add(() => sleep(this.config.action?.delay || 500));
361
362
  await recorder.promise();
362
363
  this.lastValue = await returned;
364
+ if (!recorder.isRunning()) throw new Error('CodeceptJS recorder is stopped, commands were skipped and never reached the browser');
363
365
  }
364
366
 
365
367
  this.restorePageTimeout();
366
368
 
367
369
  if (executedSteps.length > 0) {
368
- codeString = executedSteps.join('\n');
370
+ codeString = executedSteps.map((step) => step.command).join('\n');
369
371
  }
370
372
 
371
373
  const pageState = await this.captureOnce({ codeBlock: codeString });
@@ -382,10 +384,11 @@ class Action {
382
384
  this.assertionSteps = [];
383
385
  throw err;
384
386
  } finally {
387
+ this.executedSteps = executedSteps;
385
388
  this.restorePageTimeout();
386
389
  detachResponses();
387
390
  if (groupId) await this.recorder!.endAction();
388
- detachStepLogger(stepListener);
391
+ detachSteps();
389
392
  if (stepSpan) {
390
393
  stepSpan.end();
391
394
  }
@@ -487,11 +490,13 @@ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeEl
487
490
 
488
491
  type StepListener = (step: any, error?: any) => void;
489
492
 
490
- const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: string; args: any[] }>): StepListener => {
493
+ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Array<{ name: string; args: any[] }>): (() => void) => {
491
494
  const listener: StepListener = (step, error) => {
492
495
  if (!step?.toCode) return;
493
496
  if (step.name?.startsWith('grab')) return;
494
- target.push(step.toCode());
497
+ const executed: ExecutedStep = { command: step.toCode(), success: !error };
498
+ if (error) executed.error = errorToString(error);
499
+ target.push(executed);
495
500
  if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
496
501
  assertionsTarget.push({ name: step.name, args: step.args || [] });
497
502
  }
@@ -503,12 +508,10 @@ const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: str
503
508
  };
504
509
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
505
510
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
506
- return listener;
507
- };
508
-
509
- const detachStepLogger = (listener: StepListener) => {
510
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
511
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
511
+ return () => {
512
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
513
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
514
+ };
512
515
  };
513
516
 
514
517
  const readFocusedElement = () => {
@@ -530,3 +533,9 @@ const readFocusedElement = () => {
530
533
  if (typeof value === 'string' && value) focused.value = value.slice(0, 200);
531
534
  return focused;
532
535
  };
536
+
537
+ export interface ExecutedStep {
538
+ command: string;
539
+ success: boolean;
540
+ error?: string;
541
+ }
@@ -5,12 +5,13 @@ export interface ToolExecution {
5
5
  input: any;
6
6
  output: any;
7
7
  wasSuccessful: boolean;
8
+ reasoning?: string;
8
9
  }
9
10
 
10
- export function toToolExecution(toolName: string, input: any, rawOutput: any): ToolExecution {
11
+ export function toToolExecution(toolName: string, input: any, rawOutput: any, reasoning?: string): ToolExecution {
11
12
  let output = rawOutput;
12
13
  if (rawOutput?.type === 'json' && rawOutput?.value) output = rawOutput.value;
13
- return { toolName, input, output, wasSuccessful: output?.success !== false };
14
+ return { toolName, input, output, wasSuccessful: output?.success !== false, reasoning };
14
15
  }
15
16
 
16
17
  export function toolExecutionLabel(input: Record<string, any> | undefined): string {
@@ -213,13 +214,17 @@ export class Conversation {
213
214
  }
214
215
 
215
216
  getToolExecutions(): ToolExecution[] {
216
- const toolCalls = new Map<string, any>();
217
+ const toolCalls = new Map<string, { input: any; reasoning?: string }>();
217
218
  for (const message of this.messages) {
218
219
  if (message.role !== 'assistant') continue;
219
220
  if (!Array.isArray(message.content)) continue;
221
+ const reasoning = message.content
222
+ .filter((part: any) => part.type === 'reasoning' && part.text?.trim())
223
+ .map((part: any) => part.text.trim())
224
+ .join('\n');
220
225
  for (const part of message.content) {
221
226
  if (part.type !== 'tool-call') continue;
222
- toolCalls.set(part.toolCallId, part.input);
227
+ toolCalls.set(part.toolCallId, { input: part.input, reasoning });
223
228
  }
224
229
  }
225
230
 
@@ -230,7 +235,8 @@ export class Conversation {
230
235
  for (const part of message.content) {
231
236
  if (part.type !== 'tool-result') continue;
232
237
  if (part.toolName === NARRATION_TOOL) continue;
233
- executions.push(toToolExecution(part.toolName, toolCalls.get(part.toolCallId) || {}, part.output));
238
+ const call = toolCalls.get(part.toolCallId);
239
+ executions.push(toToolExecution(part.toolName, call?.input || {}, part.output, call?.reasoning));
234
240
  }
235
241
  }
236
242