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 +12 -2
- package/dist/package.json +2 -2
- package/dist/src/action.js +5 -4
- package/dist/src/ai/bosun.js +1 -1
- package/dist/src/ai/historian/codeceptjs.js +2 -2
- package/dist/src/ai/historian/experience.js +1 -0
- package/dist/src/ai/historian/playwright.js +4 -4
- package/dist/src/ai/historian/screencast.js +121 -0
- package/dist/src/ai/historian.js +5 -3
- package/dist/src/commands/explore-command.js +19 -3
- package/dist/src/explorbot.js +4 -1
- package/dist/src/explorer.js +2 -1
- package/dist/src/reporter.js +4 -1
- package/dist/src/utils/strings.js +15 -0
- package/package.json +2 -2
- package/src/action.ts +5 -4
- package/src/ai/bosun.ts +1 -1
- package/src/ai/historian/codeceptjs.ts +2 -2
- package/src/ai/historian/experience.ts +3 -2
- package/src/ai/historian/playwright.ts +5 -5
- package/src/ai/historian/screencast.ts +133 -0
- package/src/ai/historian.ts +7 -5
- package/src/commands/explore-command.ts +21 -3
- package/src/config.ts +6 -0
- package/src/explorbot.ts +4 -1
- package/src/explorer.ts +2 -1
- package/src/reporter.ts +4 -1
- package/src/utils/strings.ts +17 -0
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.
|
|
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: {
|
|
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.
|
|
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.
|
|
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/dist/src/action.js
CHANGED
|
@@ -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;
|
package/dist/src/ai/bosun.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/dist/src/ai/historian.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
149
|
-
|
|
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 (${
|
|
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
|
}
|
package/dist/src/explorbot.js
CHANGED
|
@@ -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,
|
|
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() {
|
package/dist/src/explorer.js
CHANGED
|
@@ -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({
|
|
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) {
|
package/dist/src/reporter.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/ai/historian.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
156
|
-
|
|
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 (${
|
|
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,
|
|
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({
|
|
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);
|
package/src/utils/strings.ts
CHANGED
|
@@ -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
|
+
}
|