explorbot 0.1.11 → 0.1.12

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.
package/README.md CHANGED
@@ -173,12 +173,22 @@ See [docs/commands.md](docs/commands.md) for all commands.
173
173
  | Test plans | `output/plans/*.md` | Markdown documentation of scenarios |
174
174
  | Experience | `./experience/` | What Explorbot learned about your app |
175
175
 
176
- Every run is saved as a real Playwright or CodeceptJS test you can commit and run from CI. Choose the framework in your config:
176
+ Every run is saved as a real Playwright or CodeceptJS test you can commit and run from CI. Configure the Historian to choose the output framework, record screencasts of every run, or both:
177
177
 
178
178
  ```js
179
- ai: { agents: { historian: { framework: 'playwright' } } } // or 'codeceptjs' (default)
179
+ ai: {
180
+ agents: {
181
+ historian: {
182
+ framework: 'playwright', // or 'codeceptjs' (default)
183
+ screencast: true, // record .webm video per scenario, chapters labelled with each step
184
+ // screencast: { size: { width: 1280, height: 720 }, quality: 95 }
185
+ },
186
+ },
187
+ }
180
188
  ```
181
189
 
190
+ Screencasts land in `output/screencasts/<plan>-<n>-<scenario>.webm` and are listed alongside generated tests at the end of every run.
191
+
182
192
  Playwright output uses the actual `page.locator(...)` calls executed during the run, with each action wrapped in `test.step` so failures land on a labelled step:
183
193
 
184
194
  ```ts
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -104,7 +104,7 @@
104
104
  "micromatch": "^4.0.8",
105
105
  "ora-classic": "^5.4.2",
106
106
  "parse5": "^8.0.0",
107
- "playwright": "^1.40.0",
107
+ "playwright": "^1.59.0",
108
108
  "react": "^19.1.1",
109
109
  "strip-ansi": "^7.1.2",
110
110
  "turndown": "^7.2.1",
@@ -12,6 +12,7 @@ import { ConfigParser, outputPath } from './config.js';
12
12
  import { Observability } from "./observability.js";
13
13
  import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
14
14
  import { createDebug, log, setStepSpanParent, tag } from './utils/logger.js';
15
+ import { safeFilename } from "./utils/strings.js";
15
16
  const debugLog = createDebug('explorbot:action');
16
17
  const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
17
18
  class Action {
@@ -73,7 +74,7 @@ class Action {
73
74
  const url = page?.url() || (await this.actor.grabCurrentUrl?.());
74
75
  let screenshotFile = undefined;
75
76
  if (includeScreenshot) {
76
- const filename = `${stateHash}_${timestamp}.png`;
77
+ const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
77
78
  screenshotFile = await this.actor
78
79
  .saveScreenshot(filename)
79
80
  .then(() => filename)
@@ -85,12 +86,12 @@ class Action {
85
86
  // Save HTML to file
86
87
  const statesDir = outputPath('states');
87
88
  fs.mkdirSync(statesDir, { recursive: true });
88
- const htmlFile = `${stateHash}_${timestamp}.html`;
89
+ const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
89
90
  const htmlPath = join(statesDir, htmlFile);
90
91
  fs.writeFileSync(htmlPath, html, 'utf8');
91
92
  debugLog('Captured page state');
92
93
  // Save logs to file
93
- const logFile = `${stateHash}_${timestamp}.log`;
94
+ const logFile = safeFilename(`${stateHash}_${timestamp}`, '.log');
94
95
  const logPath = join(statesDir, logFile);
95
96
  const formattedLogs = browserLogs.map((log) => {
96
97
  const logTimestamp = new Date().toISOString();
@@ -112,7 +113,7 @@ class Action {
112
113
  debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
113
114
  }
114
115
  if (ariaSnapshot) {
115
- const ariaFileName = `${stateHash}_${timestamp}.aria.yaml`;
116
+ const ariaFileName = safeFilename(`${stateHash}_${timestamp}`, '.aria.yaml');
116
117
  const ariaPath = join(statesDir, ariaFileName);
117
118
  fs.writeFileSync(ariaPath, ariaSnapshot, 'utf8');
118
119
  ariaSnapshotFile = ariaFileName;
@@ -5,8 +5,8 @@ import { ActionResult } from "../action-result.js";
5
5
  import { setActivity } from "../activity.js";
6
6
  import { Observability } from "../observability.js";
7
7
  import { Plan, Task, Test, TestResult } from "../test-plan.js";
8
- import { HooksRunner } from "../utils/hooks-runner.js";
9
8
  import { getCliName } from "../utils/cli-name.js";
9
+ import { HooksRunner } from "../utils/hooks-runner.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  import { loop, pause } from "../utils/loop.js";
12
12
  import { printNextSteps } from "../utils/next-steps.js";
@@ -5,6 +5,7 @@ import { ConfigParser } from "../../config.js";
5
5
  import { KnowledgeTracker } from "../../knowledge-tracker.js";
6
6
  import { tag } from "../../utils/logger.js";
7
7
  import { relativeToCwd } from "../../utils/next-steps.js";
8
+ import { safeFilename } from "../../utils/strings.js";
8
9
  import { ASSERTION_TOOLS, CODECEPT_TOOLS } from "../tools.js";
9
10
  import { escapeString, getExecutionLabel, isNonReusableCode, stripComments } from "./utils.js";
10
11
  export function WithCodeceptJS(Base) {
@@ -78,8 +79,7 @@ export function WithCodeceptJS(Base) {
78
79
  }
79
80
  const testsDir = ConfigParser.getInstance().getTestsDir();
80
81
  mkdirSync(testsDir, { recursive: true });
81
- const filename = plan.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
82
- const filePath = join(testsDir, `${filename}.js`);
82
+ const filePath = join(testsDir, safeFilename(plan.title, '.js'));
83
83
  writeFileSync(filePath, lines.join('\n'));
84
84
  this.savedFiles.add(filePath);
85
85
  tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
@@ -29,6 +29,7 @@ export function WithExperience(Base) {
29
29
  if (task instanceof Test && result !== 'failed') {
30
30
  await this.reportSession(task, steps);
31
31
  }
32
+ await this.stopScreencast();
32
33
  tag('substep').log(`Historian saved session for: ${task.description}`);
33
34
  }
34
35
  async reportSession(test, steps) {
@@ -6,6 +6,7 @@ import { KnowledgeTracker } from "../../knowledge-tracker.js";
6
6
  import { renderAssertion, renderCall } from "../../playwright-recorder.js";
7
7
  import { tag } from "../../utils/logger.js";
8
8
  import { relativeToCwd } from "../../utils/next-steps.js";
9
+ import { safeFilename } from "../../utils/strings.js";
9
10
  import { ASSERTION_TOOLS, CODECEPT_TOOLS } from "../tools.js";
10
11
  import { escapeString, getExecutionLabel } from "./utils.js";
11
12
  const PLAYWRIGHT_EMITTED_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
@@ -14,7 +15,7 @@ export function WithPlaywright(Base) {
14
15
  async toPlaywrightCode(conversation, scenario) {
15
16
  const toolExecutions = conversation.getToolExecutions();
16
17
  const successfulSteps = toolExecutions.filter((exec) => exec.wasSuccessful && PLAYWRIGHT_EMITTED_TOOLS.includes(exec.toolName));
17
- const callsByGroup = this.recorder ? await this.recorder.exportChunk() : new Map();
18
+ const callsByGroup = this.playwright?.recorder ? await this.playwright.recorder.exportChunk() : new Map();
18
19
  const stepLines = [];
19
20
  for (const exec of successfulSteps) {
20
21
  const explanation = getExecutionLabel(exec);
@@ -46,7 +47,7 @@ export function WithPlaywright(Base) {
46
47
  }
47
48
  }
48
49
  }
49
- const pilotVerifications = this.recorder ? this.recorder.drainVerifications() : [];
50
+ const pilotVerifications = this.playwright?.recorder ? this.playwright.recorder.drainVerifications() : [];
50
51
  if (pilotVerifications.length > 0) {
51
52
  const assertionLines = [];
52
53
  for (const step of pilotVerifications) {
@@ -115,8 +116,7 @@ export function WithPlaywright(Base) {
115
116
  lines.push('});');
116
117
  const testsDir = ConfigParser.getInstance().getTestsDir();
117
118
  mkdirSync(testsDir, { recursive: true });
118
- const filename = plan.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
119
- const filePath = join(testsDir, `${filename}.spec.ts`);
119
+ const filePath = join(testsDir, safeFilename(plan.title, '.spec.ts'));
120
120
  writeFileSync(filePath, lines.join('\n'));
121
121
  this.savedFiles.add(filePath);
122
122
  tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
@@ -0,0 +1,121 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // @ts-ignore
4
+ import * as codeceptjs from 'codeceptjs';
5
+ import { outputPath } from "../../config.js";
6
+ import { tag } from "../../utils/logger.js";
7
+ import { relativeToCwd } from "../../utils/next-steps.js";
8
+ import { safeFilename } from "../../utils/strings.js";
9
+ import { debugLog } from "./mixin.js";
10
+ export function WithScreencast(Base) {
11
+ return class extends Base {
12
+ screencastPage = null;
13
+ screencastActive = false;
14
+ screencastPath = null;
15
+ screencastListenersInstalled = false;
16
+ screencastTask = null;
17
+ screencastLastChapter = null;
18
+ onTestBefore;
19
+ onStepPassed;
20
+ onTestAfter;
21
+ isScreencastActive() {
22
+ return this.screencastActive;
23
+ }
24
+ attachScreencast() {
25
+ if (this.screencastListenersInstalled)
26
+ return;
27
+ if (!this.config?.ai?.agents?.historian?.screencast)
28
+ return;
29
+ if (!this.playwright?.helper)
30
+ return;
31
+ this.onTestBefore = (test) => {
32
+ void this.startScreencast(test);
33
+ };
34
+ this.onStepPassed = (step) => {
35
+ void this.emitChapter(step);
36
+ };
37
+ this.onTestAfter = () => {
38
+ void this.stopScreencast();
39
+ };
40
+ codeceptjs.event.dispatcher.on('test.before', this.onTestBefore);
41
+ codeceptjs.event.dispatcher.on('step.passed', this.onStepPassed);
42
+ codeceptjs.event.dispatcher.on('test.after', this.onTestAfter);
43
+ this.screencastListenersInstalled = true;
44
+ }
45
+ async startScreencast(test) {
46
+ if (this.screencastActive)
47
+ return;
48
+ const page = this.playwright?.helper?.page;
49
+ if (!page?.screencast?.start)
50
+ return;
51
+ const task = test?._explorbotTest;
52
+ const scenarioName = task?.scenario || test?.title || 'scenario';
53
+ const planTitle = task?.plan?.title;
54
+ const planTests = task?.plan?.tests;
55
+ const index = planTests && task ? planTests.indexOf(task) + 1 : 0;
56
+ const parts = [];
57
+ if (planTitle)
58
+ parts.push(safeFilename(planTitle));
59
+ if (index > 0)
60
+ parts.push(String(index));
61
+ parts.push(safeFilename(scenarioName));
62
+ const dir = outputPath('screencasts');
63
+ mkdirSync(dir, { recursive: true });
64
+ const filePath = join(dir, `${parts.join('-')}.webm`);
65
+ const screencastConfig = this.config?.ai?.agents?.historian?.screencast;
66
+ const screencastOpts = typeof screencastConfig === 'object' ? screencastConfig : {};
67
+ const size = screencastOpts.size ?? page.viewportSize?.() ?? undefined;
68
+ const quality = screencastOpts.quality ?? 95;
69
+ try {
70
+ await page.screencast.start({ path: filePath, quality, size });
71
+ await page.screencast.showActions({ position: 'top-left' });
72
+ this.screencastPage = page;
73
+ this.screencastPath = filePath;
74
+ this.screencastActive = true;
75
+ this.screencastTask = test?._explorbotTest || null;
76
+ this.screencastLastChapter = null;
77
+ }
78
+ catch (err) {
79
+ tag('substep').log(`Screencast start failed: ${err.message}`);
80
+ }
81
+ }
82
+ async emitChapter(_step) {
83
+ if (!this.screencastActive)
84
+ return;
85
+ const explanation = this.screencastTask?.activeNote?.getMessage?.();
86
+ if (!explanation)
87
+ return;
88
+ if (explanation === this.screencastLastChapter)
89
+ return;
90
+ this.screencastLastChapter = explanation;
91
+ try {
92
+ await this.screencastPage.screencast.showChapter(explanation);
93
+ }
94
+ catch (err) {
95
+ debugLog('screencast.showChapter failed:', err);
96
+ }
97
+ }
98
+ async stopScreencast() {
99
+ if (!this.screencastActive)
100
+ return;
101
+ const path = this.screencastPath;
102
+ const task = this.screencastTask;
103
+ try {
104
+ await this.screencastPage.screencast.stop();
105
+ }
106
+ catch (err) {
107
+ tag('substep').log(`Screencast stop failed: ${err.message}`);
108
+ }
109
+ this.screencastActive = false;
110
+ this.screencastPage = null;
111
+ this.screencastPath = null;
112
+ this.screencastTask = null;
113
+ this.screencastLastChapter = null;
114
+ if (path) {
115
+ this.savedFiles.add(path);
116
+ task?.addArtifact?.(path);
117
+ tag('substep').log(`Saved screencast: ${relativeToCwd(path)}`);
118
+ }
119
+ }
120
+ };
121
+ }
@@ -5,18 +5,20 @@ import { relativeToCwd } from "../utils/next-steps.js";
5
5
  import { WithCodeceptJS } from "./historian/codeceptjs.js";
6
6
  import { WithExperience } from "./historian/experience.js";
7
7
  import { WithPlaywright } from "./historian/playwright.js";
8
+ import { WithScreencast } from "./historian/screencast.js";
8
9
  export { isNonReusableCode } from "./historian/utils.js";
9
- const HistorianBase = WithPlaywright(WithCodeceptJS(WithExperience(Object)));
10
+ const HistorianBase = WithScreencast(WithPlaywright(WithCodeceptJS(WithExperience(Object))));
10
11
  export class Historian extends HistorianBase {
11
- constructor(provider, experienceTracker, reporter, stateManager, config, recorder) {
12
+ constructor(provider, experienceTracker, reporter, stateManager, config, playwright) {
12
13
  super();
13
14
  this.provider = provider;
14
15
  this.experienceTracker = experienceTracker || new ExperienceTracker();
15
16
  this.reporter = reporter;
16
17
  this.stateManager = stateManager;
17
18
  this.config = config;
18
- this.recorder = recorder;
19
+ this.playwright = playwright;
19
20
  this.savedFiles = new Set();
21
+ this.attachScreencast();
20
22
  }
21
23
  isPlaywrightFramework() {
22
24
  return this.config?.ai?.agents?.historian?.framework === 'playwright';
@@ -1,11 +1,13 @@
1
1
  import figureSet from 'figures';
2
2
  import { getStyles } from '../ai/planner/styles.js';
3
+ import { outputPath } from '../config.js';
3
4
  import { Stats } from '../stats.js';
4
5
  import { getCliName } from "../utils/cli-name.js";
5
6
  import { ErrorPageError } from "../utils/error-page.js";
6
7
  import { tag } from '../utils/logger.js';
7
8
  import { jsonToTable } from '../utils/markdown-parser.js';
8
9
  import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
10
+ import { safeFilename } from "../utils/strings.js";
9
11
  import { BaseCommand } from './base-command.js';
10
12
  export class ExploreCommand extends BaseCommand {
11
13
  name = 'explore';
@@ -145,11 +147,25 @@ export class ExploreCommand extends BaseCommand {
145
147
  });
146
148
  }
147
149
  const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
148
- if (savedFiles.length > 0) {
149
- const commands = savedFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
150
+ const screencasts = savedFiles.filter((f) => f.endsWith('.webm'));
151
+ const testFiles = savedFiles.filter((f) => !f.endsWith('.webm'));
152
+ if (testFiles.length > 0) {
153
+ const commands = testFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
150
154
  commands.push({ label: 'List tests', command: `${cli} runs` });
151
155
  sections.push({
152
- label: `Generated tests (${savedFiles.length})`,
156
+ label: `Generated tests (${testFiles.length})`,
157
+ commands,
158
+ });
159
+ }
160
+ if (screencasts.length > 0) {
161
+ const commands = screencasts.map((f) => ({ label: '', command: relativeToCwd(f) }));
162
+ const screencastDir = relativeToCwd(outputPath('screencasts'));
163
+ const planSlugs = [...new Set(this.completedPlans.map((p) => safeFilename(p.title)).filter(Boolean))];
164
+ for (const slug of planSlugs) {
165
+ commands.push({ label: 'Browse plan', command: `ls ${screencastDir}/${slug}-*` });
166
+ }
167
+ sections.push({
168
+ label: `Screencasts (${screencasts.length})`,
153
169
  commands,
154
170
  });
155
171
  }
@@ -218,7 +218,10 @@ export class ExplorBot {
218
218
  return (this.agents.historian ||= this.createAgent(({ ai, explorer, config }) => {
219
219
  const experienceTracker = explorer.getStateManager().getExperienceTracker();
220
220
  const reporter = explorer.getReporter();
221
- return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, explorer.getPlaywrightRecorder());
221
+ return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, {
222
+ recorder: explorer.getPlaywrightRecorder(),
223
+ helper: explorer.playwrightHelper,
224
+ });
222
225
  }));
223
226
  }
224
227
  agentRerunner() {
@@ -612,6 +612,7 @@ function toCodeceptjsTest(test) {
612
612
  codeceptjsTest.fullTitle = () => `${parent.title} ${test.scenario}`;
613
613
  codeceptjsTest.state = 'pending';
614
614
  codeceptjsTest.notes = test.getPrintableNotes();
615
+ codeceptjsTest._explorbotTest = test;
615
616
  return codeceptjsTest;
616
617
  }
617
618
  const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
@@ -629,7 +630,7 @@ function parseAriaRefs(ariaSnapshot) {
629
630
  return entries;
630
631
  }
631
632
  export async function annotatePageElements(page) {
632
- const ariaSnapshot = await page.locator('body').ariaSnapshot({ forAI: true });
633
+ const ariaSnapshot = await page.locator('body').ariaSnapshot({ mode: 'ai' });
633
634
  const refEntries = parseAriaRefs(ariaSnapshot);
634
635
  const byRole = new Map();
635
636
  for (const { role, name, ref } of refEntries) {
@@ -78,6 +78,7 @@ export class Reporter {
78
78
  const noteEntries = Object.entries(test.notes)
79
79
  .map(([timestampKey, note]) => ({
80
80
  startTime: note.startTime,
81
+ endTime: note.endTime,
81
82
  message: note.message,
82
83
  status: note.status,
83
84
  screenshot: note.screenshot,
@@ -105,7 +106,7 @@ export class Reporter {
105
106
  const step = {
106
107
  category: 'user',
107
108
  title: noteEntry.message,
108
- duration: 0,
109
+ duration: Math.max(0, Math.round(noteEntry.endTime - noteEntry.startTime)),
109
110
  status: noteEntry.status || 'none',
110
111
  steps: noteSteps.length > 0 ? noteSteps : undefined,
111
112
  };
@@ -148,6 +149,7 @@ export class Reporter {
148
149
  meta = Object.fromEntries(Object.entries(meta).filter(([, v]) => v));
149
150
  }
150
151
  const steps = this.combineStepsAndNotes(test, screenshotFile);
152
+ const durationMs = test.getDurationMs();
151
153
  const testData = {
152
154
  rid: test.id,
153
155
  title: test.scenario,
@@ -162,6 +164,7 @@ export class Reporter {
162
164
  files: Object.values(test.artifacts) || [],
163
165
  message: test.summary || this.extractLastNoteMessage(test) || '',
164
166
  meta,
167
+ time: durationMs != null ? Math.round(durationMs) : 0,
165
168
  };
166
169
  debugLog(testData);
167
170
  await this.client.addTestRun(status, testData);
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  export function truncateJson(input) {
2
3
  if (!input)
3
4
  return '';
@@ -11,3 +12,17 @@ export function sanitizeFilename(name) {
11
12
  .replace(/^_+|_+$/g, '')
12
13
  .slice(0, 50);
13
14
  }
15
+ export function safeFilename(name, ext = '', maxBytes = 240) {
16
+ const sanitized = name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
17
+ const extBytes = Buffer.byteLength(ext, 'utf8');
18
+ const budget = maxBytes - extBytes;
19
+ if (Buffer.byteLength(sanitized, 'utf8') <= budget)
20
+ return sanitized + ext;
21
+ const hash = createHash('sha1').update(name).digest('hex').slice(0, 8);
22
+ const suffix = `_${hash}`;
23
+ let truncated = sanitized;
24
+ while (Buffer.byteLength(truncated + suffix, 'utf8') > budget && truncated.length > 0) {
25
+ truncated = truncated.slice(0, -1);
26
+ }
27
+ return truncated + suffix + ext;
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -104,7 +104,7 @@
104
104
  "micromatch": "^4.0.8",
105
105
  "ora-classic": "^5.4.2",
106
106
  "parse5": "^8.0.0",
107
- "playwright": "^1.40.0",
107
+ "playwright": "^1.59.0",
108
108
  "react": "^19.1.1",
109
109
  "strip-ansi": "^7.1.2",
110
110
  "turndown": "^7.2.1",
package/src/action.ts CHANGED
@@ -21,6 +21,7 @@ import type { StateManager } from './state-manager.js';
21
21
  import { extractCodeBlocks } from './utils/code-extractor.js';
22
22
  import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
23
23
  import { createDebug, log, setStepSpanParent, tag } from './utils/logger.js';
24
+ import { safeFilename } from './utils/strings.ts';
24
25
  import { throttle } from './utils/throttle.ts';
25
26
 
26
27
  const debugLog = createDebug('explorbot:action');
@@ -89,7 +90,7 @@ class Action {
89
90
  let screenshotFile: string | undefined = undefined;
90
91
 
91
92
  if (includeScreenshot) {
92
- const filename = `${stateHash}_${timestamp}.png`;
93
+ const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
93
94
  screenshotFile = await (this.actor as any)
94
95
  .saveScreenshot(filename)
95
96
  .then(() => filename)
@@ -102,13 +103,13 @@ class Action {
102
103
  // Save HTML to file
103
104
  const statesDir = outputPath('states');
104
105
  fs.mkdirSync(statesDir, { recursive: true });
105
- const htmlFile = `${stateHash}_${timestamp}.html`;
106
+ const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
106
107
  const htmlPath = join(statesDir, htmlFile);
107
108
  fs.writeFileSync(htmlPath, html, 'utf8');
108
109
 
109
110
  debugLog('Captured page state');
110
111
  // Save logs to file
111
- const logFile = `${stateHash}_${timestamp}.log`;
112
+ const logFile = safeFilename(`${stateHash}_${timestamp}`, '.log');
112
113
  const logPath = join(statesDir, logFile);
113
114
  const formattedLogs = browserLogs.map((log: any) => {
114
115
  const logTimestamp = new Date().toISOString();
@@ -134,7 +135,7 @@ class Action {
134
135
  }
135
136
 
136
137
  if (ariaSnapshot) {
137
- const ariaFileName = `${stateHash}_${timestamp}.aria.yaml`;
138
+ const ariaFileName = safeFilename(`${stateHash}_${timestamp}`, '.aria.yaml');
138
139
  const ariaPath = join(statesDir, ariaFileName);
139
140
  fs.writeFileSync(ariaPath, ariaSnapshot, 'utf8');
140
141
  ariaSnapshotFile = ariaFileName;
package/src/ai/bosun.ts CHANGED
@@ -9,8 +9,8 @@ import type { KnowledgeTracker } from '../knowledge-tracker.ts';
9
9
  import { Observability } from '../observability.ts';
10
10
  import { Plan, Task, Test, TestResult } from '../test-plan.ts';
11
11
  import { diffAriaSnapshots } from '../utils/aria.ts';
12
- import { HooksRunner } from '../utils/hooks-runner.ts';
13
12
  import { getCliName } from '../utils/cli-name.ts';
13
+ import { HooksRunner } from '../utils/hooks-runner.ts';
14
14
  import { createDebug, tag } from '../utils/logger.ts';
15
15
  import { loop, pause } from '../utils/loop.ts';
16
16
  import { type NextStepSection, printNextSteps } from '../utils/next-steps.ts';
@@ -6,6 +6,7 @@ import { KnowledgeTracker } from '../../knowledge-tracker.ts';
6
6
  import type { Plan } from '../../test-plan.ts';
7
7
  import { tag } from '../../utils/logger.ts';
8
8
  import { relativeToCwd } from '../../utils/next-steps.ts';
9
+ import { safeFilename } from '../../utils/strings.ts';
9
10
  import type { Conversation } from '../conversation.ts';
10
11
  import { ASSERTION_TOOLS, CODECEPT_TOOLS } from '../tools.ts';
11
12
  import type { Constructor } from './mixin.ts';
@@ -97,8 +98,7 @@ export function WithCodeceptJS<T extends Constructor>(Base: T) {
97
98
  const testsDir = ConfigParser.getInstance().getTestsDir();
98
99
  mkdirSync(testsDir, { recursive: true });
99
100
 
100
- const filename = plan.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
101
- const filePath = join(testsDir, `${filename}.js`);
101
+ const filePath = join(testsDir, safeFilename(plan.title, '.js'));
102
102
  writeFileSync(filePath, lines.join('\n'));
103
103
  this.savedFiles.add(filePath);
104
104
 
@@ -2,7 +2,6 @@ import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
3
  import { ActionResult } from '../../action-result.ts';
4
4
  import { ExperienceTracker, type SessionStep } from '../../experience-tracker.ts';
5
- import type { PlaywrightRecorder } from '../../playwright-recorder.ts';
6
5
  import type { Reporter, ReporterStep } from '../../reporter.ts';
7
6
  import type { StateManager } from '../../state-manager.ts';
8
7
  import { type Task, Test } from '../../test-plan.ts';
@@ -24,10 +23,10 @@ export function WithExperience<T extends Constructor>(Base: T) {
24
23
  declare experienceTracker: ExperienceTracker;
25
24
  declare reporter: Reporter | undefined;
26
25
  declare stateManager: StateManager | undefined;
27
- declare recorder: PlaywrightRecorder | undefined;
28
26
  declare isPlaywrightFramework: () => boolean;
29
27
  declare toCode: (conversation: Conversation, scenario: string) => string;
30
28
  declare toPlaywrightCode: (conversation: Conversation, scenario: string) => Promise<string>;
29
+ declare stopScreencast: () => Promise<void>;
31
30
 
32
31
  async saveSession(task: Task, initialState: ActionResult, conversation: Conversation): Promise<void> {
33
32
  debugLog('Saving session experience');
@@ -55,6 +54,8 @@ export function WithExperience<T extends Constructor>(Base: T) {
55
54
  await this.reportSession(task, steps);
56
55
  }
57
56
 
57
+ await this.stopScreencast();
58
+
58
59
  tag('substep').log(`Historian saved session for: ${task.description}`);
59
60
  }
60
61
 
@@ -7,6 +7,7 @@ import { type PlaywrightRecorder, type TraceCall, renderAssertion, renderCall }
7
7
  import type { Plan } from '../../test-plan.ts';
8
8
  import { tag } from '../../utils/logger.ts';
9
9
  import { relativeToCwd } from '../../utils/next-steps.ts';
10
+ import { safeFilename } from '../../utils/strings.ts';
10
11
  import type { Conversation } from '../conversation.ts';
11
12
  import { ASSERTION_TOOLS, CODECEPT_TOOLS } from '../tools.ts';
12
13
  import type { Constructor } from './mixin.ts';
@@ -21,14 +22,14 @@ export interface PlaywrightMethods {
21
22
 
22
23
  export function WithPlaywright<T extends Constructor>(Base: T) {
23
24
  return class extends Base {
24
- declare recorder: PlaywrightRecorder | undefined;
25
+ declare playwright: { recorder: PlaywrightRecorder; helper: any } | undefined;
25
26
  declare savedFiles: Set<string>;
26
27
 
27
28
  async toPlaywrightCode(conversation: Conversation, scenario: string): Promise<string> {
28
29
  const toolExecutions = conversation.getToolExecutions();
29
30
  const successfulSteps = toolExecutions.filter((exec) => exec.wasSuccessful && PLAYWRIGHT_EMITTED_TOOLS.includes(exec.toolName as any));
30
31
 
31
- const callsByGroup = this.recorder ? await this.recorder.exportChunk() : new Map<string, TraceCall[]>();
32
+ const callsByGroup = this.playwright?.recorder ? await this.playwright.recorder.exportChunk() : new Map<string, TraceCall[]>();
32
33
 
33
34
  const stepLines: string[] = [];
34
35
  for (const exec of successfulSteps) {
@@ -59,7 +60,7 @@ export function WithPlaywright<T extends Constructor>(Base: T) {
59
60
  }
60
61
  }
61
62
 
62
- const pilotVerifications = this.recorder ? this.recorder.drainVerifications() : [];
63
+ const pilotVerifications = this.playwright?.recorder ? this.playwright.recorder.drainVerifications() : [];
63
64
  if (pilotVerifications.length > 0) {
64
65
  const assertionLines: string[] = [];
65
66
  for (const step of pilotVerifications) {
@@ -135,8 +136,7 @@ export function WithPlaywright<T extends Constructor>(Base: T) {
135
136
  const testsDir = ConfigParser.getInstance().getTestsDir();
136
137
  mkdirSync(testsDir, { recursive: true });
137
138
 
138
- const filename = plan.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
139
- const filePath = join(testsDir, `${filename}.spec.ts`);
139
+ const filePath = join(testsDir, safeFilename(plan.title, '.spec.ts'));
140
140
  writeFileSync(filePath, lines.join('\n'));
141
141
  this.savedFiles.add(filePath);
142
142
 
@@ -0,0 +1,133 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // @ts-ignore
4
+ import * as codeceptjs from 'codeceptjs';
5
+ import { outputPath } from '../../config.ts';
6
+ import type { ExplorbotConfig } from '../../config.ts';
7
+ import type { PlaywrightRecorder } from '../../playwright-recorder.ts';
8
+ import { tag } from '../../utils/logger.ts';
9
+ import { relativeToCwd } from '../../utils/next-steps.ts';
10
+ import { safeFilename } from '../../utils/strings.ts';
11
+ import { type Constructor, debugLog } from './mixin.ts';
12
+
13
+ export interface ScreencastMethods {
14
+ attachScreencast(): void;
15
+ isScreencastActive(): boolean;
16
+ stopScreencast(): Promise<void>;
17
+ }
18
+
19
+ export function WithScreencast<T extends Constructor>(Base: T) {
20
+ return class extends Base {
21
+ declare config: ExplorbotConfig | undefined;
22
+ declare savedFiles: Set<string>;
23
+ declare playwright: { recorder: PlaywrightRecorder; helper: any } | undefined;
24
+
25
+ private screencastPage: any = null;
26
+ private screencastActive = false;
27
+ private screencastPath: string | null = null;
28
+ private screencastListenersInstalled = false;
29
+ private screencastTask: any = null;
30
+ private screencastLastChapter: string | null = null;
31
+ private onTestBefore?: (test: any) => void;
32
+ private onStepPassed?: (step: any) => void;
33
+ private onTestAfter?: () => void;
34
+
35
+ isScreencastActive(): boolean {
36
+ return this.screencastActive;
37
+ }
38
+
39
+ attachScreencast(): void {
40
+ if (this.screencastListenersInstalled) return;
41
+ if (!this.config?.ai?.agents?.historian?.screencast) return;
42
+ if (!this.playwright?.helper) return;
43
+
44
+ this.onTestBefore = (test: any) => {
45
+ void this.startScreencast(test);
46
+ };
47
+ this.onStepPassed = (step: any) => {
48
+ void this.emitChapter(step);
49
+ };
50
+ this.onTestAfter = () => {
51
+ void this.stopScreencast();
52
+ };
53
+
54
+ codeceptjs.event.dispatcher.on('test.before', this.onTestBefore);
55
+ codeceptjs.event.dispatcher.on('step.passed', this.onStepPassed);
56
+ codeceptjs.event.dispatcher.on('test.after', this.onTestAfter);
57
+
58
+ this.screencastListenersInstalled = true;
59
+ }
60
+
61
+ private async startScreencast(test: any): Promise<void> {
62
+ if (this.screencastActive) return;
63
+ const page = this.playwright?.helper?.page;
64
+ if (!page?.screencast?.start) return;
65
+
66
+ const task = test?._explorbotTest;
67
+ const scenarioName = task?.scenario || test?.title || 'scenario';
68
+ const planTitle: string | undefined = task?.plan?.title;
69
+ const planTests: any[] | undefined = task?.plan?.tests;
70
+ const index = planTests && task ? planTests.indexOf(task) + 1 : 0;
71
+
72
+ const parts: string[] = [];
73
+ if (planTitle) parts.push(safeFilename(planTitle));
74
+ if (index > 0) parts.push(String(index));
75
+ parts.push(safeFilename(scenarioName));
76
+
77
+ const dir = outputPath('screencasts');
78
+ mkdirSync(dir, { recursive: true });
79
+ const filePath = join(dir, `${parts.join('-')}.webm`);
80
+
81
+ const screencastConfig = this.config?.ai?.agents?.historian?.screencast;
82
+ const screencastOpts = typeof screencastConfig === 'object' ? screencastConfig : {};
83
+ const size = screencastOpts.size ?? page.viewportSize?.() ?? undefined;
84
+ const quality = screencastOpts.quality ?? 95;
85
+
86
+ try {
87
+ await page.screencast.start({ path: filePath, quality, size });
88
+ await page.screencast.showActions({ position: 'top-left' });
89
+ this.screencastPage = page;
90
+ this.screencastPath = filePath;
91
+ this.screencastActive = true;
92
+ this.screencastTask = test?._explorbotTest || null;
93
+ this.screencastLastChapter = null;
94
+ } catch (err) {
95
+ tag('substep').log(`Screencast start failed: ${(err as Error).message}`);
96
+ }
97
+ }
98
+
99
+ private async emitChapter(_step: any): Promise<void> {
100
+ if (!this.screencastActive) return;
101
+ const explanation = this.screencastTask?.activeNote?.getMessage?.();
102
+ if (!explanation) return;
103
+ if (explanation === this.screencastLastChapter) return;
104
+ this.screencastLastChapter = explanation;
105
+ try {
106
+ await this.screencastPage.screencast.showChapter(explanation);
107
+ } catch (err) {
108
+ debugLog('screencast.showChapter failed:', err);
109
+ }
110
+ }
111
+
112
+ async stopScreencast(): Promise<void> {
113
+ if (!this.screencastActive) return;
114
+ const path = this.screencastPath;
115
+ const task = this.screencastTask;
116
+ try {
117
+ await this.screencastPage.screencast.stop();
118
+ } catch (err) {
119
+ tag('substep').log(`Screencast stop failed: ${(err as Error).message}`);
120
+ }
121
+ this.screencastActive = false;
122
+ this.screencastPage = null;
123
+ this.screencastPath = null;
124
+ this.screencastTask = null;
125
+ this.screencastLastChapter = null;
126
+ if (path) {
127
+ this.savedFiles.add(path);
128
+ task?.addArtifact?.(path);
129
+ tag('substep').log(`Saved screencast: ${relativeToCwd(path)}`);
130
+ }
131
+ }
132
+ };
133
+ }
@@ -10,13 +10,14 @@ import { relativeToCwd } from '../utils/next-steps.ts';
10
10
  import { type CodeceptJSMethods, WithCodeceptJS } from './historian/codeceptjs.ts';
11
11
  import { type ExperienceMethods, WithExperience } from './historian/experience.ts';
12
12
  import { type PlaywrightMethods, WithPlaywright } from './historian/playwright.ts';
13
+ import { type ScreencastMethods, WithScreencast } from './historian/screencast.ts';
13
14
  import type { Provider } from './provider.ts';
14
15
 
15
16
  export { isNonReusableCode } from './historian/utils.ts';
16
17
 
17
- const HistorianBase = WithPlaywright(WithCodeceptJS(WithExperience(Object as unknown as new (...args: any[]) => object)));
18
+ const HistorianBase = WithScreencast(WithPlaywright(WithCodeceptJS(WithExperience(Object as unknown as new (...args: any[]) => object))));
18
19
 
19
- export interface Historian extends ExperienceMethods, CodeceptJSMethods, PlaywrightMethods {}
20
+ export interface Historian extends ExperienceMethods, CodeceptJSMethods, PlaywrightMethods, ScreencastMethods {}
20
21
 
21
22
  export class Historian extends HistorianBase {
22
23
  declare provider: Provider;
@@ -24,18 +25,19 @@ export class Historian extends HistorianBase {
24
25
  declare reporter: Reporter | undefined;
25
26
  declare stateManager: StateManager | undefined;
26
27
  declare config: ExplorbotConfig | undefined;
27
- declare recorder: PlaywrightRecorder | undefined;
28
+ declare playwright: { recorder: PlaywrightRecorder; helper: any } | undefined;
28
29
  declare savedFiles: Set<string>;
29
30
 
30
- constructor(provider: Provider, experienceTracker?: ExperienceTracker, reporter?: Reporter, stateManager?: StateManager, config?: ExplorbotConfig, recorder?: PlaywrightRecorder) {
31
+ constructor(provider: Provider, experienceTracker?: ExperienceTracker, reporter?: Reporter, stateManager?: StateManager, config?: ExplorbotConfig, playwright?: { recorder: PlaywrightRecorder; helper: any }) {
31
32
  super();
32
33
  this.provider = provider;
33
34
  this.experienceTracker = experienceTracker || new ExperienceTracker();
34
35
  this.reporter = reporter;
35
36
  this.stateManager = stateManager;
36
37
  this.config = config;
37
- this.recorder = recorder;
38
+ this.playwright = playwright;
38
39
  this.savedFiles = new Set();
40
+ this.attachScreencast();
39
41
  }
40
42
 
41
43
  isPlaywrightFramework(): boolean {
@@ -1,5 +1,6 @@
1
1
  import figureSet from 'figures';
2
2
  import { getStyles } from '../ai/planner/styles.js';
3
+ import { outputPath } from '../config.js';
3
4
  import { Stats } from '../stats.js';
4
5
  import type { Plan } from '../test-plan.js';
5
6
  import { getCliName } from '../utils/cli-name.ts';
@@ -7,6 +8,7 @@ import { ErrorPageError } from '../utils/error-page.ts';
7
8
  import { tag } from '../utils/logger.js';
8
9
  import { jsonToTable } from '../utils/markdown-parser.js';
9
10
  import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
11
+ import { safeFilename } from '../utils/strings.ts';
10
12
  import { BaseCommand, type Suggestion } from './base-command.js';
11
13
 
12
14
  export class ExploreCommand extends BaseCommand {
@@ -152,11 +154,27 @@ export class ExploreCommand extends BaseCommand {
152
154
  }
153
155
 
154
156
  const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
155
- if (savedFiles.length > 0) {
156
- const commands = savedFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
157
+ const screencasts = savedFiles.filter((f) => f.endsWith('.webm'));
158
+ const testFiles = savedFiles.filter((f) => !f.endsWith('.webm'));
159
+
160
+ if (testFiles.length > 0) {
161
+ const commands = testFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
157
162
  commands.push({ label: 'List tests', command: `${cli} runs` });
158
163
  sections.push({
159
- label: `Generated tests (${savedFiles.length})`,
164
+ label: `Generated tests (${testFiles.length})`,
165
+ commands,
166
+ });
167
+ }
168
+
169
+ if (screencasts.length > 0) {
170
+ const commands = screencasts.map((f) => ({ label: '', command: relativeToCwd(f) }));
171
+ const screencastDir = relativeToCwd(outputPath('screencasts'));
172
+ const planSlugs = [...new Set(this.completedPlans.map((p) => safeFilename(p.title)).filter(Boolean))];
173
+ for (const slug of planSlugs) {
174
+ commands.push({ label: 'Browse plan', command: `ls ${screencastDir}/${slug}-*` });
175
+ }
176
+ sections.push({
177
+ label: `Screencasts (${screencasts.length})`,
160
178
  commands,
161
179
  });
162
180
  }
package/src/config.ts CHANGED
@@ -107,8 +107,14 @@ interface PlannerAgentConfig extends AgentConfig {
107
107
  stylesDir?: string;
108
108
  }
109
109
 
110
+ interface ScreencastConfig {
111
+ size?: { width: number; height: number };
112
+ quality?: number;
113
+ }
114
+
110
115
  interface HistorianAgentConfig extends AgentConfig {
111
116
  framework?: 'codeceptjs' | 'playwright';
117
+ screencast?: boolean | ScreencastConfig;
112
118
  }
113
119
 
114
120
  interface AgentsConfig {
package/src/explorbot.ts CHANGED
@@ -262,7 +262,10 @@ export class ExplorBot {
262
262
  return (this.agents.historian ||= this.createAgent(({ ai, explorer, config }) => {
263
263
  const experienceTracker = explorer.getStateManager().getExperienceTracker();
264
264
  const reporter = explorer.getReporter();
265
- return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, explorer.getPlaywrightRecorder());
265
+ return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, {
266
+ recorder: explorer.getPlaywrightRecorder(),
267
+ helper: explorer.playwrightHelper,
268
+ });
266
269
  }));
267
270
  }
268
271
 
package/src/explorer.ts CHANGED
@@ -714,6 +714,7 @@ function toCodeceptjsTest(test: Test): any {
714
714
  codeceptjsTest.fullTitle = () => `${parent.title} ${test.scenario}`;
715
715
  codeceptjsTest.state = 'pending';
716
716
  codeceptjsTest.notes = test.getPrintableNotes();
717
+ codeceptjsTest._explorbotTest = test;
717
718
  return codeceptjsTest;
718
719
  }
719
720
 
@@ -733,7 +734,7 @@ function parseAriaRefs(ariaSnapshot: string): Array<{ role: string; name: string
733
734
  }
734
735
 
735
736
  export async function annotatePageElements(page: any): Promise<{ ariaSnapshot: string; elements: WebElement[] }> {
736
- const ariaSnapshot: string = await page.locator('body').ariaSnapshot({ forAI: true });
737
+ const ariaSnapshot: string = await page.locator('body').ariaSnapshot({ mode: 'ai' });
737
738
  const refEntries = parseAriaRefs(ariaSnapshot);
738
739
 
739
740
  const byRole = new Map<string, Array<{ name: string; ref: string }>>();
package/src/reporter.ts CHANGED
@@ -104,6 +104,7 @@ export class Reporter {
104
104
  const noteEntries = Object.entries(test.notes)
105
105
  .map(([timestampKey, note]) => ({
106
106
  startTime: note.startTime,
107
+ endTime: note.endTime,
107
108
  message: note.message,
108
109
  status: note.status,
109
110
  screenshot: note.screenshot,
@@ -135,7 +136,7 @@ export class Reporter {
135
136
  const step: Step = {
136
137
  category: 'user',
137
138
  title: noteEntry.message,
138
- duration: 0,
139
+ duration: Math.max(0, Math.round(noteEntry.endTime - noteEntry.startTime)),
139
140
  status: noteEntry.status || 'none',
140
141
  steps: noteSteps.length > 0 ? noteSteps : undefined,
141
142
  };
@@ -182,6 +183,7 @@ export class Reporter {
182
183
  }
183
184
 
184
185
  const steps = this.combineStepsAndNotes(test, screenshotFile);
186
+ const durationMs = test.getDurationMs();
185
187
 
186
188
  const testData = {
187
189
  rid: test.id,
@@ -197,6 +199,7 @@ export class Reporter {
197
199
  files: Object.values(test.artifacts) || [],
198
200
  message: test.summary || this.extractLastNoteMessage(test) || '',
199
201
  meta,
202
+ time: durationMs != null ? Math.round(durationMs) : 0,
200
203
  };
201
204
 
202
205
  debugLog(testData);
@@ -1,3 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  export function truncateJson(input: any): string {
2
4
  if (!input) return '';
3
5
  const str = JSON.stringify(input);
@@ -11,3 +13,18 @@ export function sanitizeFilename(name: string): string {
11
13
  .replace(/^_+|_+$/g, '')
12
14
  .slice(0, 50);
13
15
  }
16
+
17
+ export function safeFilename(name: string, ext = '', maxBytes = 240): string {
18
+ const sanitized = name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
19
+ const extBytes = Buffer.byteLength(ext, 'utf8');
20
+ const budget = maxBytes - extBytes;
21
+ if (Buffer.byteLength(sanitized, 'utf8') <= budget) return sanitized + ext;
22
+
23
+ const hash = createHash('sha1').update(name).digest('hex').slice(0, 8);
24
+ const suffix = `_${hash}`;
25
+ let truncated = sanitized;
26
+ while (Buffer.byteLength(truncated + suffix, 'utf8') > budget && truncated.length > 0) {
27
+ truncated = truncated.slice(0, -1);
28
+ }
29
+ return truncated + suffix + ext;
30
+ }