explorbot 0.1.11 → 0.1.13
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/bin/explorbot-cli.ts +21 -21
- package/dist/bin/explorbot-cli.js +3 -3
- package/dist/package.json +4 -3
- package/dist/rules/researcher/container-rules.md +2 -0
- package/dist/src/action-result.js +2 -1
- package/dist/src/action.js +5 -10
- package/dist/src/ai/captain.js +0 -2
- package/dist/src/ai/driller.js +1108 -0
- 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/ai/pilot.js +31 -22
- package/dist/src/ai/rules.js +3 -5
- package/dist/src/ai/session-analyst.js +117 -0
- package/dist/src/ai/tester.js +13 -2
- package/dist/src/commands/base-command.js +6 -6
- package/dist/src/commands/drill-command.js +3 -2
- package/dist/src/commands/exit-command.js +1 -0
- package/dist/src/commands/explore-command.js +20 -3
- package/dist/src/components/AddRule.js +1 -1
- package/dist/src/explorbot.js +52 -9
- package/dist/src/explorer.js +11 -9
- package/dist/src/reporter.js +68 -4
- package/dist/src/state-manager.js +4 -3
- package/dist/src/stats.js +5 -0
- package/dist/src/utils/aria.js +354 -529
- package/dist/src/utils/hooks-runner.js +2 -8
- package/dist/src/utils/html.js +371 -0
- package/dist/src/utils/strings.js +15 -0
- package/dist/src/utils/unique-names.js +12 -1
- package/dist/src/utils/url-matcher.js +6 -1
- package/dist/src/utils/web-element.js +27 -24
- package/dist/src/utils/xpath.js +1 -1
- package/package.json +4 -3
- package/rules/researcher/container-rules.md +2 -0
- package/src/action-result.ts +2 -1
- package/src/action.ts +5 -12
- package/src/ai/captain.ts +0 -2
- package/src/ai/driller.ts +1194 -0
- 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/ai/pilot.ts +31 -21
- package/src/ai/rules.ts +3 -5
- package/src/ai/session-analyst.ts +133 -0
- package/src/ai/tester.ts +15 -2
- package/src/commands/base-command.ts +6 -6
- package/src/commands/drill-command.ts +3 -2
- package/src/commands/exit-command.ts +1 -0
- package/src/commands/explore-command.ts +22 -3
- package/src/components/AddRule.tsx +1 -1
- package/src/config.ts +10 -0
- package/src/explorbot.ts +59 -11
- package/src/explorer.ts +11 -9
- package/src/reporter.ts +68 -4
- package/src/state-manager.ts +4 -3
- package/src/stats.ts +7 -0
- package/src/utils/aria.ts +367 -537
- package/src/utils/hooks-runner.ts +2 -6
- package/src/utils/html.ts +381 -0
- package/src/utils/strings.ts +17 -0
- package/src/utils/unique-names.ts +13 -0
- package/src/utils/url-matcher.ts +5 -1
- package/src/utils/web-element.ts +31 -28
- package/src/utils/xpath.ts +1 -1
- package/dist/src/ai/bosun.js +0 -456
- package/src/ai/bosun.ts +0 -571
|
@@ -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';
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -69,16 +69,17 @@ export class Pilot {
|
|
|
69
69
|
const stateContext = this.buildStateContext(currentState);
|
|
70
70
|
const notes = task.notesToString() || 'No notes recorded.';
|
|
71
71
|
let visualAnalysis = '';
|
|
72
|
+
let screenshotState = null;
|
|
72
73
|
if (this.provider.hasVision()) {
|
|
73
74
|
try {
|
|
74
75
|
const action = this.explorer.createAction();
|
|
75
|
-
|
|
76
|
+
screenshotState = await action.caputrePageWithScreenshot();
|
|
76
77
|
if (screenshotState.screenshot) {
|
|
77
78
|
visualAnalysis = (await this.researcher.answerQuestionAboutScreenshot(screenshotState, `Describe current page state relevant to: ${task.scenario}`)) || '';
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
catch {
|
|
81
|
-
|
|
82
|
+
screenshotState = null;
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
const schema = z.object({
|
|
@@ -140,28 +141,24 @@ export class Pilot {
|
|
|
140
141
|
task.finish(TestResult.FAILED);
|
|
141
142
|
return false;
|
|
142
143
|
}
|
|
143
|
-
if (result.requestVerification && navigator) {
|
|
144
|
+
if (result.decision === 'pass' && result.requestVerification && navigator) {
|
|
144
145
|
tag('substep').log(`Pilot requesting verification: ${result.requestVerification}`);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (verifyResult.
|
|
148
|
-
|
|
149
|
-
this.explorer.getPlaywrightRecorder().recordVerification(verifyResult.assertionSteps);
|
|
150
|
-
}
|
|
151
|
-
tag('substep').log(`Pilot verified: ${result.requestVerification}`);
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
tag('substep').log(`Pilot verification failed: ${result.requestVerification}`);
|
|
155
|
-
if (result.decision === 'pass') {
|
|
156
|
-
const flipMessage = `Verification "${result.requestVerification}" did not match the page. Adjust approach and re-verify before finishing.`;
|
|
157
|
-
result.decision = 'continue';
|
|
158
|
-
result.reason = flipMessage;
|
|
159
|
-
result.guidance = result.guidance ?? flipMessage;
|
|
160
|
-
}
|
|
146
|
+
const verifyResult = await navigator.verifyState(result.requestVerification, currentState).catch(() => null);
|
|
147
|
+
if (verifyResult?.verified) {
|
|
148
|
+
if (verifyResult.assertionSteps?.length) {
|
|
149
|
+
this.explorer.getPlaywrightRecorder().recordVerification(verifyResult.assertionSteps);
|
|
161
150
|
}
|
|
162
151
|
}
|
|
163
|
-
|
|
164
|
-
|
|
152
|
+
else {
|
|
153
|
+
let answer = null;
|
|
154
|
+
if (screenshotState?.screenshot) {
|
|
155
|
+
answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, `Does the screen confirm: "${result.requestVerification}"? Answer YES or NO only.`);
|
|
156
|
+
}
|
|
157
|
+
if (!(answer || '').trim().toUpperCase().startsWith('YES')) {
|
|
158
|
+
task.addNote(`Pilot: verification failed — ${result.requestVerification}`, TestResult.FAILED);
|
|
159
|
+
task.finish(TestResult.FAILED);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
165
162
|
}
|
|
166
163
|
}
|
|
167
164
|
tag('info').log(`Pilot: ${result.decision} — ${result.reason}`);
|
|
@@ -348,6 +345,8 @@ export class Pilot {
|
|
|
348
345
|
- If no verification was done → prefer "continue" with guidance telling tester what to verify.
|
|
349
346
|
- If verify assertion describes a state that was ALREADY TRUE before the test started, the verification proves nothing — reject with "continue".
|
|
350
347
|
|
|
348
|
+
requestVerification — pick assertions DOM can actually express. Some content is not assertable via DOM (iframe text, canvas, custom widgets, Monaco/CodeMirror editors). When the scenario goal lives in such a region, target a STABLE LANDMARK (container element, ARIA role, the parent that wraps the widget) rather than literal text inside it. Your "pass" verdict is honored even if the DOM assertion can't be made — pick the strongest landmark you can.
|
|
349
|
+
|
|
351
350
|
GUIDANCE FIELD: When decision is "continue", you MUST provide "guidance" — a specific actionable instruction:
|
|
352
351
|
- If evidence is insufficient: tell tester to verify with see()/verify(), specify WHAT to check
|
|
353
352
|
- If approach was wrong: tell tester to try a different method, suggest which one
|
|
@@ -420,7 +419,7 @@ export class Pilot {
|
|
|
420
419
|
Be concise and specific. Tester will follow your plan.
|
|
421
420
|
`, 'pilot.planTest', { tools: true, planningOnly: true, maxToolRoundtrips: 3, task });
|
|
422
421
|
}
|
|
423
|
-
async reviewNewPage(task, currentState) {
|
|
422
|
+
async reviewNewPage(task, currentState, testerConversation) {
|
|
424
423
|
if (!this.conversation)
|
|
425
424
|
return '';
|
|
426
425
|
tag('substep').log('Pilot reviewing new page...');
|
|
@@ -430,7 +429,13 @@ export class Pilot {
|
|
|
430
429
|
if (!pageSummary)
|
|
431
430
|
return '';
|
|
432
431
|
const stateContext = this.buildStateContext(currentState);
|
|
432
|
+
const toolCalls = testerConversation
|
|
433
|
+
.getToolExecutions()
|
|
434
|
+
.filter((t) => t.wasSuccessful)
|
|
435
|
+
.slice(-this.stepsToReview);
|
|
436
|
+
const actionsContext = this.formatActions(toolCalls);
|
|
433
437
|
this.conversation.cleanupTag('page_summary', '...trimmed...', 1);
|
|
438
|
+
this.conversation.cleanupTag('recent_actions', '...trimmed...', 2);
|
|
434
439
|
return this.sendToPilot(dedent `
|
|
435
440
|
Navigated to new page.
|
|
436
441
|
START URL: ${task.startUrl}
|
|
@@ -443,6 +448,10 @@ export class Pilot {
|
|
|
443
448
|
${pageSummary}
|
|
444
449
|
</page_summary>
|
|
445
450
|
|
|
451
|
+
<recent_actions>
|
|
452
|
+
${actionsContext || 'None'}
|
|
453
|
+
</recent_actions>
|
|
454
|
+
|
|
446
455
|
${this.formatExpectations(task)}
|
|
447
456
|
|
|
448
457
|
First: evaluate whether this navigation makes sense for the scenario goal. If the page is unrelated, instruct Tester to back() or reset(). Then plan next steps.
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -272,11 +272,9 @@ export const actionRule = dedent `
|
|
|
272
272
|
I.fillField('Description', 'Hello world', '.editor'); // works for rich text / code editors too
|
|
273
273
|
</example>
|
|
274
274
|
|
|
275
|
-
I.fillField handles plain inputs, textareas, contenteditable regions, and rich text / code editors
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
Do NOT open the editor with raw JS (executeScript, page.evaluate), do NOT dispatch synthetic events,
|
|
279
|
-
do NOT call the editor's own API (monaco.editor.setValue, view.dispatch, etc.) to write text.
|
|
275
|
+
I.fillField handles plain inputs, textareas, contenteditable regions, and rich text / code editors transparently.
|
|
276
|
+
ALWAYS use I.fillField for rich text / code editors — target the editor container or its nearest label/heading with a normal locator.
|
|
277
|
+
If I.fillField does not work, I.type into the focused element is the fallback.
|
|
280
278
|
|
|
281
279
|
### I.type
|
|
282
280
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import dedent from 'dedent';
|
|
4
|
+
import { outputPath } from "../config.js";
|
|
5
|
+
import { Stats } from "../stats.js";
|
|
6
|
+
export class SessionAnalyst {
|
|
7
|
+
emoji = '🧐';
|
|
8
|
+
provider;
|
|
9
|
+
constructor(provider) {
|
|
10
|
+
this.provider = provider;
|
|
11
|
+
}
|
|
12
|
+
async analyze(tests) {
|
|
13
|
+
const eligible = tests.filter((t) => t.startTime != null);
|
|
14
|
+
if (eligible.length === 0)
|
|
15
|
+
return '';
|
|
16
|
+
const model = this.provider.getModelForAgent('analyst');
|
|
17
|
+
const customPrompt = this.provider.getSystemPromptForAgent('analyst', undefined);
|
|
18
|
+
const systemPrompt = dedent `
|
|
19
|
+
You write a brief end-of-session report after autonomous exploratory testing. Your reader is a developer who needs to know in seconds: what is broken, how to reproduce it, and which results were inconclusive.
|
|
20
|
+
|
|
21
|
+
Output MARKDOWN. No JSON, no preamble, no closing remarks. Start with the heading.
|
|
22
|
+
|
|
23
|
+
## Clustering
|
|
24
|
+
Group by ROOT CAUSE, not by scenario. If three tests fail for the same dropdown, that is ONE defect listing all three test refs (#3, #5, #7). Do not produce one cluster per test.
|
|
25
|
+
|
|
26
|
+
## Bucketing
|
|
27
|
+
Use the FINAL verdict (the test's \`result\` field) as the starting point. Mid-test errors that the automation recovered from do NOT make a passed test unreliable.
|
|
28
|
+
|
|
29
|
+
- **Defect** — real product bug. \`result: failed\` AND the failure reflects the app misbehaving (not the automation). The automation completed its interactions, the app contradicted the expected outcome. Severity required.
|
|
30
|
+
- **UX issue** — app works but the UI is ambiguous, controls are hidden, or labels are unclear. Worth flagging to design.
|
|
31
|
+
- **Execution issue** — the FINAL verdict is unreliable. Only two cases:
|
|
32
|
+
1. \`result: failed\` AND the failure was automation, environment, or UI/UX (locator missing, timeout, AI loop, navigation stuck, modal trapped focus, no accessible label) — i.e. the test could not conclude whether the app works.
|
|
33
|
+
2. \`result: passed\` AND clear evidence in the log shows the user-visible goal was NOT achieved (no confirmation visible, no state change verified, the assertion was vacuous).
|
|
34
|
+
|
|
35
|
+
A test that passed and shows no contrary evidence belongs in NO section. Do not list passed tests just because the log contains intermediate retries or recovered failures.
|
|
36
|
+
|
|
37
|
+
## Severity emoji (defects only)
|
|
38
|
+
- 🔴 critical or high — core flow blocked, data loss, security
|
|
39
|
+
- 🟡 medium — partial breakage with workaround
|
|
40
|
+
- 🟢 low — cosmetic
|
|
41
|
+
|
|
42
|
+
## Required format
|
|
43
|
+
|
|
44
|
+
# Session Analysis
|
|
45
|
+
|
|
46
|
+
<one sentence: total tests, defect count, headline finding>
|
|
47
|
+
|
|
48
|
+
## Defects
|
|
49
|
+
|
|
50
|
+
### 🔴 <plain-English title of the BUG, not the scenario name>
|
|
51
|
+
Affects: #3, #5, #7
|
|
52
|
+
Reproduce:
|
|
53
|
+
1. <concrete UI step a person can replay>
|
|
54
|
+
2. <next step>
|
|
55
|
+
Evidence: <one short observation from the test log>
|
|
56
|
+
|
|
57
|
+
### 🟡 <next defect>
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
## UX issues
|
|
61
|
+
|
|
62
|
+
- **<title>** — #4
|
|
63
|
+
<one short evidence line>
|
|
64
|
+
|
|
65
|
+
## Execution Issues
|
|
66
|
+
|
|
67
|
+
- **<short test name or scenario phrase>** — <plain-English one-liner: what made the result unreliable>
|
|
68
|
+
- **<…>** — <…>
|
|
69
|
+
|
|
70
|
+
## Rules
|
|
71
|
+
- Defects first, sorted by severity descending. Omit any section that has zero entries.
|
|
72
|
+
- Defect title describes the BUG ("Run-type dropdown does not filter"), never the scenario name.
|
|
73
|
+
- Reproduce steps are concrete UI actions derived from the log: URL + clicks + inputs. Imperative, one short line each.
|
|
74
|
+
- Evidence is the smallest factual observation from notes/steps that supports the claim — what was OBSERVED in the page (HTML, message, missing element). Never quote the test's \`result\` field as evidence; that is a tautology.
|
|
75
|
+
- **Execution Issues** entries must explain what actually went wrong in concrete terms a human understands: "could not find a Submit button after navigation", "page reloaded before the assertion ran", "passed without ever seeing a confirmation message", "marked failed but the new item appears in the list", "modal trapped focus and tests could not click outside", "ARIA tree had no labelled controls". Avoid jargon like "locator failed" without context. Never write category prefixes ("execution:", "false-positive:") — the section header already says it. No emoji on these entries.
|
|
76
|
+
- Do NOT include a passed test in any section unless evidence proves its goal was not achieved. Intermediate retries or recovered errors in the log are not grounds for listing a passed test.
|
|
77
|
+
- No editorialising, no restating the scenario verbatim, no closing summary.
|
|
78
|
+
|
|
79
|
+
${customPrompt || ''}
|
|
80
|
+
`;
|
|
81
|
+
const userPayload = dedent `
|
|
82
|
+
${eligible.length} tests were executed in this session.
|
|
83
|
+
|
|
84
|
+
${eligible.map((t, i) => this.serializeTest(t, i + 1)).join('\n\n')}
|
|
85
|
+
`;
|
|
86
|
+
const response = await this.provider.chat([
|
|
87
|
+
{ role: 'system', content: systemPrompt },
|
|
88
|
+
{ role: 'user', content: userPayload },
|
|
89
|
+
], model, { agentName: 'analyst' });
|
|
90
|
+
return (response?.text || '').trim();
|
|
91
|
+
}
|
|
92
|
+
writeReport(markdown) {
|
|
93
|
+
const filePath = outputPath('reports', `${Stats.sessionLabel()}.md`);
|
|
94
|
+
const dir = path.dirname(filePath);
|
|
95
|
+
if (!existsSync(dir))
|
|
96
|
+
mkdirSync(dir, { recursive: true });
|
|
97
|
+
writeFileSync(filePath, markdown);
|
|
98
|
+
return filePath;
|
|
99
|
+
}
|
|
100
|
+
serializeTest(test, ref) {
|
|
101
|
+
const log = test
|
|
102
|
+
.getLog()
|
|
103
|
+
.slice(-30)
|
|
104
|
+
.map((entry) => ` - [${entry.type}] ${entry.content}`)
|
|
105
|
+
.join('\n');
|
|
106
|
+
return dedent `
|
|
107
|
+
<test ref="#${ref}">
|
|
108
|
+
url: ${test.startUrl || '/'}
|
|
109
|
+
scenario: ${test.scenario}
|
|
110
|
+
result: ${test.result || 'unknown'}
|
|
111
|
+
expected: ${test.expected.join(' | ') || '(none)'}
|
|
112
|
+
log:
|
|
113
|
+
${log}
|
|
114
|
+
</test>
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -216,7 +216,7 @@ export class Tester extends TaskAgent {
|
|
|
216
216
|
nextStep += await this.reinjectContextIfNeeded(iteration, currentState);
|
|
217
217
|
nextStep += await this.prepareInstructionsForNextStep(task);
|
|
218
218
|
if (isNewPage && this.pilot) {
|
|
219
|
-
const guidance = await this.pilot.reviewNewPage(task, currentState);
|
|
219
|
+
const guidance = await this.pilot.reviewNewPage(task, currentState, conversation);
|
|
220
220
|
if (guidance)
|
|
221
221
|
nextStep += `\n\n${guidance}`;
|
|
222
222
|
}
|
|
@@ -388,6 +388,7 @@ export class Tester extends TaskAgent {
|
|
|
388
388
|
this.previousUrl = currentUrl;
|
|
389
389
|
this.previousStateHash = currentStateHash;
|
|
390
390
|
let context = '';
|
|
391
|
+
const focusArea = detectFocusArea(currentState.ariaSnapshot);
|
|
391
392
|
const focusedElement = extractFocusedElement(currentState.ariaSnapshot);
|
|
392
393
|
if (focusedElement) {
|
|
393
394
|
const isTextInput = ['textbox', 'combobox', 'searchbox'].includes(focusedElement.role);
|
|
@@ -403,6 +404,17 @@ export class Tester extends TaskAgent {
|
|
|
403
404
|
<no_focus>
|
|
404
405
|
No element is focused
|
|
405
406
|
</no_focus>
|
|
407
|
+
`;
|
|
408
|
+
}
|
|
409
|
+
if (focusArea.detected) {
|
|
410
|
+
const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
|
|
411
|
+
context += dedent `
|
|
412
|
+
<focus_scope>
|
|
413
|
+
A ${focusArea.type}${areaName} is currently open above the page.
|
|
414
|
+
Scope all interactions to elements inside this ${focusArea.type}.
|
|
415
|
+
Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the ${focusArea.type}.
|
|
416
|
+
Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
|
|
417
|
+
</focus_scope>
|
|
406
418
|
`;
|
|
407
419
|
}
|
|
408
420
|
if (currentState.isInsideIframe) {
|
|
@@ -462,7 +474,6 @@ export class Tester extends TaskAgent {
|
|
|
462
474
|
`;
|
|
463
475
|
return context;
|
|
464
476
|
}
|
|
465
|
-
const focusArea = detectFocusArea(currentState.ariaSnapshot);
|
|
466
477
|
if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
|
|
467
478
|
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
|
|
468
479
|
if (overlaySection) {
|
|
@@ -19,17 +19,17 @@ export class BaseCommand {
|
|
|
19
19
|
if (this.suggestions.length === 0)
|
|
20
20
|
return;
|
|
21
21
|
const prefix = isInteractive() ? '/' : `${getCliName()} `;
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
const commandWidth = this.suggestions.reduce((max, s) => (s.command ? Math.max(max, prefix.length + s.command.length) : max), 0);
|
|
23
|
+
const lines = [chalk.bold('Suggested:')];
|
|
24
24
|
for (const { command, hint } of this.suggestions) {
|
|
25
|
-
tag('info').log('');
|
|
26
25
|
if (!command) {
|
|
27
|
-
|
|
26
|
+
lines.push(` ${chalk.dim(hint)}`);
|
|
28
27
|
continue;
|
|
29
28
|
}
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
const cmd = `${prefix}${command}`.padEnd(commandWidth);
|
|
30
|
+
lines.push(` ${chalk.yellow(cmd)} ${chalk.dim(hint)}`);
|
|
32
31
|
}
|
|
32
|
+
tag('info').log(lines.join('\n'));
|
|
33
33
|
}
|
|
34
34
|
parseArgs(args) {
|
|
35
35
|
const cmd = new Command();
|
|
@@ -2,6 +2,7 @@ import { BaseCommand } from './base-command.js';
|
|
|
2
2
|
export class DrillCommand extends BaseCommand {
|
|
3
3
|
name = 'drill';
|
|
4
4
|
description = 'Drill all components on current page to learn interactions';
|
|
5
|
+
aliases = ['driller'];
|
|
5
6
|
suggestions = [
|
|
6
7
|
{ command: 'research', hint: 'see UI map first' },
|
|
7
8
|
{ command: 'navigate <page>', hint: 'go to another page' },
|
|
@@ -13,7 +14,7 @@ export class DrillCommand extends BaseCommand {
|
|
|
13
14
|
if (!state) {
|
|
14
15
|
throw new Error('No active page to drill');
|
|
15
16
|
}
|
|
16
|
-
await this.explorBot.
|
|
17
|
+
await this.explorBot.agentDriller().drill({
|
|
17
18
|
knowledgePath,
|
|
18
19
|
maxComponents,
|
|
19
20
|
interactive: true,
|
|
@@ -24,7 +25,7 @@ export class DrillCommand extends BaseCommand {
|
|
|
24
25
|
return match ? match[1] : undefined;
|
|
25
26
|
}
|
|
26
27
|
parseMaxArg(args) {
|
|
27
|
-
const match = args.match(/--max\s+(\d+)/);
|
|
28
|
+
const match = args.match(/--max-components\s+(\d+)/);
|
|
28
29
|
return match ? Number.parseInt(match[1], 10) : undefined;
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -8,6 +8,7 @@ export class ExitCommand extends BaseCommand {
|
|
|
8
8
|
description = 'Exit the application';
|
|
9
9
|
aliases = ['quit'];
|
|
10
10
|
async execute(_args) {
|
|
11
|
+
await this.explorBot.printSessionAnalysis();
|
|
11
12
|
await this.explorBot.getExplorer().stop();
|
|
12
13
|
if (Stats.hasActivity()) {
|
|
13
14
|
await new Promise((resolve) => {
|
|
@@ -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';
|
|
@@ -66,6 +68,7 @@ export class ExploreCommand extends BaseCommand {
|
|
|
66
68
|
await this.explorBot.visit(mainUrl);
|
|
67
69
|
const savedPath = this.explorBot.savePlans(this.completedPlans);
|
|
68
70
|
this.printResults();
|
|
71
|
+
await this.explorBot.printSessionAnalysis();
|
|
69
72
|
this.printNextSteps(savedPath);
|
|
70
73
|
}
|
|
71
74
|
async runAllStyles(pageUrl, feature, parentPlan, completedPlans) {
|
|
@@ -145,11 +148,25 @@ export class ExploreCommand extends BaseCommand {
|
|
|
145
148
|
});
|
|
146
149
|
}
|
|
147
150
|
const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
const screencasts = savedFiles.filter((f) => f.endsWith('.webm'));
|
|
152
|
+
const testFiles = savedFiles.filter((f) => !f.endsWith('.webm'));
|
|
153
|
+
if (testFiles.length > 0) {
|
|
154
|
+
const commands = testFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
|
|
150
155
|
commands.push({ label: 'List tests', command: `${cli} runs` });
|
|
151
156
|
sections.push({
|
|
152
|
-
label: `Generated tests (${
|
|
157
|
+
label: `Generated tests (${testFiles.length})`,
|
|
158
|
+
commands,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (screencasts.length > 0) {
|
|
162
|
+
const commands = screencasts.map((f) => ({ label: '', command: relativeToCwd(f) }));
|
|
163
|
+
const screencastDir = relativeToCwd(outputPath('screencasts'));
|
|
164
|
+
const planSlugs = [...new Set(this.completedPlans.map((p) => safeFilename(p.title)).filter(Boolean))];
|
|
165
|
+
for (const slug of planSlugs) {
|
|
166
|
+
commands.push({ label: 'Browse plan', command: `ls ${screencastDir}/${slug}-*` });
|
|
167
|
+
}
|
|
168
|
+
sections.push({
|
|
169
|
+
label: `Screencasts (${screencasts.length})`,
|
|
153
170
|
commands,
|
|
154
171
|
});
|
|
155
172
|
}
|
|
@@ -4,7 +4,7 @@ import { Box, Text, useInput } from 'ink';
|
|
|
4
4
|
import React, { useEffect, useState } from 'react';
|
|
5
5
|
import { AddRuleCommand } from '../commands/add-rule-command.js';
|
|
6
6
|
import InputReadline from './InputReadline.js';
|
|
7
|
-
const KNOWN_AGENTS = ['researcher', 'tester', 'planner', 'pilot', 'captain', '
|
|
7
|
+
const KNOWN_AGENTS = ['researcher', 'tester', 'planner', 'pilot', 'captain', 'driller', 'navigator'];
|
|
8
8
|
const AddRule = ({ initialAgent = '', initialName = '', onComplete, onCancel }) => {
|
|
9
9
|
const [agent, setAgent] = useState(initialAgent);
|
|
10
10
|
const [ruleName, setRuleName] = useState(initialName);
|