explorbot 0.1.10 → 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 +37 -1
- package/bin/explorbot-cli.ts +27 -18
- package/dist/bin/explorbot-cli.js +26 -18
- package/dist/package.json +3 -3
- package/dist/rules/navigator/output.md +9 -0
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action-result.js +23 -1
- package/dist/src/action.js +51 -42
- package/dist/src/ai/bosun.js +11 -1
- package/dist/src/ai/conversation.js +39 -0
- package/dist/src/ai/historian/codeceptjs.js +109 -0
- package/dist/src/ai/historian/experience.js +321 -0
- package/dist/src/ai/historian/mixin.js +2 -0
- package/dist/src/ai/historian/playwright.js +145 -0
- package/dist/src/ai/historian/screencast.js +121 -0
- package/dist/src/ai/historian/utils.js +18 -0
- package/dist/src/ai/historian.js +21 -405
- package/dist/src/ai/navigator.js +82 -29
- package/dist/src/ai/pilot.js +232 -13
- package/dist/src/ai/planner.js +29 -9
- package/dist/src/ai/provider.js +54 -17
- package/dist/src/ai/researcher.js +41 -32
- package/dist/src/ai/rules.js +26 -14
- package/dist/src/ai/tester.js +90 -26
- package/dist/src/ai/tools.js +13 -7
- package/dist/src/browser-server.js +16 -3
- package/dist/src/commands/add-rule-command.js +11 -8
- package/dist/src/commands/clean-command.js +2 -1
- package/dist/src/commands/explore-command.js +43 -15
- package/dist/src/commands/init-command.js +9 -8
- package/dist/src/commands/plan-command.js +32 -0
- package/dist/src/commands/plan-save-command.js +19 -7
- package/dist/src/commands/rerun-command.js +4 -0
- package/dist/src/components/App.js +15 -5
- package/dist/src/execution-controller.js +13 -2
- package/dist/src/experience-tracker.js +20 -64
- package/dist/src/explorbot.js +8 -8
- package/dist/src/explorer.js +11 -3
- package/dist/src/observability.js +50 -99
- package/dist/src/playwright-recorder.js +309 -0
- package/dist/src/reporter.js +4 -1
- package/dist/src/test-plan.js +12 -0
- package/dist/src/utils/aria.js +37 -1
- package/dist/src/utils/error-page.js +20 -7
- package/dist/src/utils/next-steps.js +37 -0
- package/dist/src/utils/strings.js +15 -0
- package/package.json +3 -3
- package/rules/navigator/output.md +9 -0
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action-result.ts +26 -1
- package/src/action.ts +49 -41
- package/src/ai/bosun.ts +11 -1
- package/src/ai/conversation.ts +37 -0
- package/src/ai/historian/codeceptjs.ts +130 -0
- package/src/ai/historian/experience.ts +384 -0
- package/src/ai/historian/mixin.ts +4 -0
- package/src/ai/historian/playwright.ts +169 -0
- package/src/ai/historian/screencast.ts +133 -0
- package/src/ai/historian/utils.ts +23 -0
- package/src/ai/historian.ts +37 -473
- package/src/ai/navigator.ts +82 -29
- package/src/ai/pilot.ts +237 -14
- package/src/ai/planner.ts +29 -9
- package/src/ai/provider.ts +51 -17
- package/src/ai/researcher.ts +45 -33
- package/src/ai/rules.ts +27 -14
- package/src/ai/tester.ts +94 -26
- package/src/ai/tools.ts +47 -25
- package/src/browser-server.ts +17 -3
- package/src/commands/add-rule-command.ts +11 -7
- package/src/commands/clean-command.ts +2 -1
- package/src/commands/explore-command.ts +46 -14
- package/src/commands/init-command.ts +9 -8
- package/src/commands/plan-command.ts +35 -0
- package/src/commands/plan-save-command.ts +18 -7
- package/src/commands/rerun-command.ts +5 -0
- package/src/components/App.tsx +16 -5
- package/src/config.ts +12 -1
- package/src/execution-controller.ts +14 -3
- package/src/experience-tracker.ts +21 -72
- package/src/explorbot.ts +8 -8
- package/src/explorer.ts +13 -3
- package/src/observability.ts +50 -109
- package/src/playwright-recorder.ts +305 -0
- package/src/reporter.ts +4 -1
- package/src/test-plan.ts +12 -0
- package/src/utils/aria.ts +38 -1
- package/src/utils/error-page.ts +22 -7
- package/src/utils/next-steps.ts +51 -0
- package/src/utils/strings.ts +17 -0
|
@@ -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",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"axe-core": "^4.11.1",
|
|
84
84
|
"bash-tool": "^1.3.15",
|
|
85
85
|
"cli-highlight": "^2.1.11",
|
|
86
|
-
"codeceptjs": "4.0.0-rc.
|
|
86
|
+
"codeceptjs": "4.0.0-rc.16",
|
|
87
87
|
"commander": "^14.0.1",
|
|
88
88
|
"debug": "^4.4.3",
|
|
89
89
|
"dedent": "^1.6.0",
|
|
@@ -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",
|
|
@@ -13,6 +13,10 @@ In <explanation> write only one line without heading or bullet list or any other
|
|
|
13
13
|
Check previous solutions, if there is already successful solution, use it!
|
|
14
14
|
CodeceptJS code must start with "I."
|
|
15
15
|
All lines of code must be CodeceptJS code and start with "I."
|
|
16
|
+
|
|
17
|
+
Do not mix form filling with navigation in the same code block.
|
|
18
|
+
If the code block fills a form and clicks a submit/confirm control, stop there — do not append I.amOnPage afterwards. Submitting the form already triggers navigation on the server side, and a follow-up I.amOnPage cancels that in-flight navigation and discards the just-submitted state (session, cookies, redirect target).
|
|
19
|
+
If the action does not cause navigation on its own and a separate page visit is required to reach the target, put I.amOnPage in its own code block as a distinct step, not glued onto the form submission block.
|
|
16
20
|
</rules>
|
|
17
21
|
|
|
18
22
|
<output>
|
|
@@ -42,6 +46,11 @@ Use only locators from HTML PAGE that was passed in <page> context.
|
|
|
42
46
|
<example_output>
|
|
43
47
|
Trying to fill the form on the page
|
|
44
48
|
|
|
49
|
+
```js
|
|
50
|
+
I.fillField({ "role": "textbox", "text": "Name" }, 'Value');
|
|
51
|
+
I.click({ "role": "button", "text": "Submit" });
|
|
52
|
+
```
|
|
53
|
+
|
|
45
54
|
```js
|
|
46
55
|
I.fillField('Name', 'Value');
|
|
47
56
|
I.click('Submit');
|
|
@@ -113,6 +113,8 @@ For input field values, ALWAYS use I.seeInField() — never check value via CSS
|
|
|
113
113
|
Prefer text locators (label, name, placeholder) for form fields: I.seeInField('Search', 'value') over I.seeInField('input[name="search"]', 'value').
|
|
114
114
|
Only use locators that exist in the provided HTML or ARIA snapshot.
|
|
115
115
|
Verify exact conditions, not approximate matches.
|
|
116
|
+
NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking text inside an element is the job of I.see(text, context) — the `:has-text()` form duplicates that capability with a fragile selector.
|
|
117
|
+
NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
|
|
116
118
|
</verification_rules>
|
|
117
119
|
|
|
118
120
|
[DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
|
package/src/action-result.ts
CHANGED
|
@@ -611,7 +611,7 @@ export class ActionResult implements ActionResultData {
|
|
|
611
611
|
}
|
|
612
612
|
}
|
|
613
613
|
if (processedParts.length > 0) {
|
|
614
|
-
pageDiff.htmlParts = processedParts;
|
|
614
|
+
pageDiff.htmlParts = collapseHtmlParts(processedParts);
|
|
615
615
|
}
|
|
616
616
|
}
|
|
617
617
|
|
|
@@ -629,6 +629,31 @@ export class ActionResult implements ActionResultData {
|
|
|
629
629
|
}
|
|
630
630
|
}
|
|
631
631
|
|
|
632
|
+
const HTML_PARTS_TOTAL_BUDGET = 8000;
|
|
633
|
+
const HTML_PARTS_COUNT_LIMIT = 8;
|
|
634
|
+
const HTML_PART_SUBTREE_BUDGET = 2000;
|
|
635
|
+
|
|
636
|
+
function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
|
|
637
|
+
const total = parts.reduce((sum, p) => sum + p.subtree.length, 0);
|
|
638
|
+
const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
|
|
639
|
+
|
|
640
|
+
if (fullPageReRender) {
|
|
641
|
+
return parts.map((part) => ({
|
|
642
|
+
...part,
|
|
643
|
+
subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
|
|
644
|
+
}));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return parts.map((part) => {
|
|
648
|
+
if (part.subtree.length <= HTML_PART_SUBTREE_BUDGET) return part;
|
|
649
|
+
const head = part.subtree.slice(0, HTML_PART_SUBTREE_BUDGET);
|
|
650
|
+
return {
|
|
651
|
+
...part,
|
|
652
|
+
subtree: `${head}...<!-- truncated ${part.subtree.length - HTML_PART_SUBTREE_BUDGET} chars -->`,
|
|
653
|
+
};
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
632
657
|
export class Diff {
|
|
633
658
|
private _htmlDiffResult: HtmlDiffResult | null = null;
|
|
634
659
|
private _ariaDiffResult: string | null = null;
|
package/src/action.ts
CHANGED
|
@@ -16,10 +16,12 @@ import { ConfigParser, outputPath } from './config.js';
|
|
|
16
16
|
import type { ExplorbotConfig } from './config.js';
|
|
17
17
|
import type { UserResolveFunction } from './explorbot.ts';
|
|
18
18
|
import { Observability } from './observability.ts';
|
|
19
|
+
import type { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
19
20
|
import type { StateManager } from './state-manager.js';
|
|
20
21
|
import { extractCodeBlocks } from './utils/code-extractor.js';
|
|
21
22
|
import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
22
23
|
import { createDebug, log, setStepSpanParent, tag } from './utils/logger.js';
|
|
24
|
+
import { safeFilename } from './utils/strings.ts';
|
|
23
25
|
import { throttle } from './utils/throttle.ts';
|
|
24
26
|
|
|
25
27
|
const debugLog = createDebug('explorbot:action');
|
|
@@ -36,12 +38,16 @@ class Action {
|
|
|
36
38
|
private expectation: string | null = null;
|
|
37
39
|
public lastError: Error | null = null;
|
|
38
40
|
public playwrightHelper: any;
|
|
41
|
+
public playwrightGroupId: string | null = null;
|
|
42
|
+
public assertionSteps: Array<{ name: string; args: any[] }> = [];
|
|
43
|
+
private recorder?: PlaywrightRecorder;
|
|
39
44
|
|
|
40
|
-
constructor(actor: CodeceptJS.I, stateManager: StateManager) {
|
|
45
|
+
constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder) {
|
|
41
46
|
this.actor = actor;
|
|
42
47
|
this.stateManager = stateManager;
|
|
43
48
|
this.config = ConfigParser.getInstance().getConfig();
|
|
44
49
|
this.playwrightHelper = container.helpers('Playwright');
|
|
50
|
+
this.recorder = recorder;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
async caputrePageWithScreenshot(): Promise<ActionResult> {
|
|
@@ -71,13 +77,20 @@ class Action {
|
|
|
71
77
|
const timestamp = Date.now();
|
|
72
78
|
const page = this.playwrightHelper.page;
|
|
73
79
|
const frame = this.playwrightHelper.frame;
|
|
74
|
-
|
|
80
|
+
await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
|
|
81
|
+
const grabAll = () => Promise.all([(this.actor as any).grabSource(), (this.actor as any).grabTitle(), this.captureBrowserLogs()]);
|
|
82
|
+
const [html, title, browserLogs] = await grabAll().catch(async (err: Error) => {
|
|
83
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
84
|
+
if (!/navigating and changing the content/i.test(msg)) throw err;
|
|
85
|
+
await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
|
|
86
|
+
return grabAll();
|
|
87
|
+
});
|
|
75
88
|
const url = page?.url() || (await (this.actor as any).grabCurrentUrl?.());
|
|
76
89
|
|
|
77
90
|
let screenshotFile: string | undefined = undefined;
|
|
78
91
|
|
|
79
92
|
if (includeScreenshot) {
|
|
80
|
-
const filename = `${stateHash}_${timestamp}.png
|
|
93
|
+
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
81
94
|
screenshotFile = await (this.actor as any)
|
|
82
95
|
.saveScreenshot(filename)
|
|
83
96
|
.then(() => filename)
|
|
@@ -90,13 +103,13 @@ class Action {
|
|
|
90
103
|
// Save HTML to file
|
|
91
104
|
const statesDir = outputPath('states');
|
|
92
105
|
fs.mkdirSync(statesDir, { recursive: true });
|
|
93
|
-
const htmlFile = `${stateHash}_${timestamp}.html
|
|
106
|
+
const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
|
|
94
107
|
const htmlPath = join(statesDir, htmlFile);
|
|
95
108
|
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
96
109
|
|
|
97
110
|
debugLog('Captured page state');
|
|
98
111
|
// Save logs to file
|
|
99
|
-
const logFile = `${stateHash}_${timestamp}.log
|
|
112
|
+
const logFile = safeFilename(`${stateHash}_${timestamp}`, '.log');
|
|
100
113
|
const logPath = join(statesDir, logFile);
|
|
101
114
|
const formattedLogs = browserLogs.map((log: any) => {
|
|
102
115
|
const logTimestamp = new Date().toISOString();
|
|
@@ -122,7 +135,7 @@ class Action {
|
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
if (ariaSnapshot) {
|
|
125
|
-
const ariaFileName = `${stateHash}_${timestamp}.aria.yaml
|
|
138
|
+
const ariaFileName = safeFilename(`${stateHash}_${timestamp}`, '.aria.yaml');
|
|
126
139
|
const ariaPath = join(statesDir, ariaFileName);
|
|
127
140
|
fs.writeFileSync(ariaPath, ariaSnapshot, 'utf8');
|
|
128
141
|
ariaSnapshotFile = ariaFileName;
|
|
@@ -218,7 +231,10 @@ class Action {
|
|
|
218
231
|
let codeString = code.replace(/^\(I\) => /, '').trim();
|
|
219
232
|
|
|
220
233
|
const executedSteps: string[] = [];
|
|
221
|
-
|
|
234
|
+
const assertionSteps: Array<{ name: string; args: any[] }> = [];
|
|
235
|
+
const stepListener = attachStepLogger(executedSteps, assertionSteps);
|
|
236
|
+
const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
|
|
237
|
+
this.playwrightGroupId = groupId;
|
|
222
238
|
const activeSpan = Observability.getSpan();
|
|
223
239
|
const tracer = trace.getTracer('ai');
|
|
224
240
|
const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
|
|
@@ -253,6 +269,7 @@ class Action {
|
|
|
253
269
|
this.stateManager.updateState(pageState, codeString);
|
|
254
270
|
|
|
255
271
|
this.actionResult = pageState;
|
|
272
|
+
this.assertionSteps = assertionSteps;
|
|
256
273
|
} catch (err) {
|
|
257
274
|
debugLog('Action error', errorToString(err));
|
|
258
275
|
error = err as Error;
|
|
@@ -260,9 +277,11 @@ class Action {
|
|
|
260
277
|
await recorder.reset();
|
|
261
278
|
await recorder.start();
|
|
262
279
|
}
|
|
280
|
+
this.assertionSteps = [];
|
|
263
281
|
throw err;
|
|
264
282
|
} finally {
|
|
265
|
-
|
|
283
|
+
if (groupId) await this.recorder!.endAction();
|
|
284
|
+
detachStepLogger(stepListener);
|
|
266
285
|
if (stepSpan) {
|
|
267
286
|
stepSpan.end();
|
|
268
287
|
}
|
|
@@ -407,41 +426,30 @@ function sleep(ms: number) {
|
|
|
407
426
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
408
427
|
}
|
|
409
428
|
|
|
410
|
-
|
|
411
|
-
let stepLoggerTarget: string[] | null = null;
|
|
429
|
+
const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeElement', 'seeInField', 'dontSeeInField', 'seeInCurrentUrl', 'dontSeeInCurrentUrl']);
|
|
412
430
|
|
|
413
|
-
|
|
414
|
-
if (!step?.toCode) {
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
if (step.name?.startsWith('grab')) return;
|
|
418
|
-
const stepCode = step.toCode();
|
|
419
|
-
if (stepLoggerTarget) {
|
|
420
|
-
stepLoggerTarget.push(stepCode);
|
|
421
|
-
}
|
|
422
|
-
if (error) {
|
|
423
|
-
tag('step').log(step, error);
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
tag('step').log(step);
|
|
427
|
-
};
|
|
431
|
+
type StepListener = (step: any, error?: any) => void;
|
|
428
432
|
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
return;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
433
|
+
const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: string; args: any[] }>): StepListener => {
|
|
434
|
+
const listener: StepListener = (step, error) => {
|
|
435
|
+
if (!step?.toCode) return;
|
|
436
|
+
if (step.name?.startsWith('grab')) return;
|
|
437
|
+
target.push(step.toCode());
|
|
438
|
+
if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
|
|
439
|
+
assertionsTarget.push({ name: step.name, args: step.args || [] });
|
|
440
|
+
}
|
|
441
|
+
if (error) {
|
|
442
|
+
tag('step').log(step, error);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
tag('step').log(step);
|
|
446
|
+
};
|
|
447
|
+
codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
|
|
448
|
+
codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
|
|
449
|
+
return listener;
|
|
437
450
|
};
|
|
438
451
|
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
stepLoggerRegistered = false;
|
|
445
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, stepLogger);
|
|
446
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, stepLogger);
|
|
452
|
+
const detachStepLogger = (listener: StepListener) => {
|
|
453
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
|
|
454
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
|
|
447
455
|
};
|
package/src/ai/bosun.ts
CHANGED
|
@@ -9,9 +9,11 @@ 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 { getCliName } from '../utils/cli-name.ts';
|
|
12
13
|
import { HooksRunner } from '../utils/hooks-runner.ts';
|
|
13
14
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
14
15
|
import { loop, pause } from '../utils/loop.ts';
|
|
16
|
+
import { type NextStepSection, printNextSteps } from '../utils/next-steps.ts';
|
|
15
17
|
import type { Agent } from './agent.ts';
|
|
16
18
|
import type { Conversation } from './conversation.ts';
|
|
17
19
|
import type { Navigator } from './navigator.ts';
|
|
@@ -497,7 +499,15 @@ export class Bosun extends TaskAgent implements Agent {
|
|
|
497
499
|
const content = this.generateKnowledgeContent(state, successfulInteractions);
|
|
498
500
|
const result = knowledgeTracker.addKnowledge(knowledgePath, content);
|
|
499
501
|
|
|
500
|
-
|
|
502
|
+
const cli = getCliName();
|
|
503
|
+
const sections: NextStepSection[] = [
|
|
504
|
+
{
|
|
505
|
+
label: 'Knowledge',
|
|
506
|
+
path: result.filePath,
|
|
507
|
+
commands: [{ label: 'View matches', command: `${cli} knows ${knowledgePath}` }],
|
|
508
|
+
},
|
|
509
|
+
];
|
|
510
|
+
printNextSteps(sections);
|
|
501
511
|
}
|
|
502
512
|
|
|
503
513
|
private generateKnowledgeContent(state: any, interactions: InteractionResult[]): string {
|
package/src/ai/conversation.ts
CHANGED
|
@@ -11,6 +11,9 @@ export function toolExecutionLabel(input: Record<string, any> | undefined): stri
|
|
|
11
11
|
return input?.explanation || input?.assertion || input?.reason || input?.request || '';
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const AUTO_COMPACT_ARIA_CHANGES_CUTOFF = 500;
|
|
15
|
+
const AUTO_COMPACT_TARGETED_HTML_CUTOFF = 500;
|
|
16
|
+
|
|
14
17
|
export class Conversation {
|
|
15
18
|
id: string;
|
|
16
19
|
messages: ModelMessage[];
|
|
@@ -132,6 +135,40 @@ export class Conversation {
|
|
|
132
135
|
this.autoTrimRules.set(tagName, maxLength);
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
compactToolResults(keepLastN: number): void {
|
|
139
|
+
const toolMessageIndexes: number[] = [];
|
|
140
|
+
for (let i = 0; i < this.messages.length; i++) {
|
|
141
|
+
if (this.messages[i].role === 'tool') toolMessageIndexes.push(i);
|
|
142
|
+
}
|
|
143
|
+
const compactUpTo = toolMessageIndexes.length - Math.max(0, keepLastN);
|
|
144
|
+
for (let k = 0; k < compactUpTo; k++) {
|
|
145
|
+
const message = this.messages[toolMessageIndexes[k]];
|
|
146
|
+
if (!Array.isArray(message.content)) continue;
|
|
147
|
+
for (const part of message.content) {
|
|
148
|
+
if (part.type !== 'tool-result') continue;
|
|
149
|
+
const rawOutput = part.output as Record<string, any> | undefined;
|
|
150
|
+
if (!rawOutput || rawOutput.type !== 'json' || !rawOutput.value || typeof rawOutput.value !== 'object') continue;
|
|
151
|
+
const value = rawOutput.value as Record<string, any>;
|
|
152
|
+
if (value.pageDiff && typeof value.pageDiff === 'object') {
|
|
153
|
+
const pageDiff = value.pageDiff as Record<string, any>;
|
|
154
|
+
if (Array.isArray(pageDiff.htmlParts)) {
|
|
155
|
+
pageDiff.htmlParts = undefined;
|
|
156
|
+
pageDiff.compacted = true;
|
|
157
|
+
}
|
|
158
|
+
if (typeof pageDiff.ariaChanges === 'string' && pageDiff.ariaChanges.length > AUTO_COMPACT_ARIA_CHANGES_CUTOFF) {
|
|
159
|
+
pageDiff.ariaChanges = `${pageDiff.ariaChanges.slice(0, AUTO_COMPACT_ARIA_CHANGES_CUTOFF)}...`;
|
|
160
|
+
}
|
|
161
|
+
if (typeof pageDiff.iframes === 'string') {
|
|
162
|
+
pageDiff.iframes = undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (typeof value.targetedHtml === 'string' && value.targetedHtml.length > AUTO_COMPACT_TARGETED_HTML_CUTOFF) {
|
|
166
|
+
value.targetedHtml = `${value.targetedHtml.slice(0, AUTO_COMPACT_TARGETED_HTML_CUTOFF)}...`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
135
172
|
hasTag(tagName: string, lastN?: number): boolean {
|
|
136
173
|
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
137
174
|
const regex = new RegExp(`<${escapedTag}>`, 'g');
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ActionResult } from '../../action-result.ts';
|
|
4
|
+
import { ConfigParser } from '../../config.ts';
|
|
5
|
+
import { KnowledgeTracker } from '../../knowledge-tracker.ts';
|
|
6
|
+
import type { Plan } from '../../test-plan.ts';
|
|
7
|
+
import { tag } from '../../utils/logger.ts';
|
|
8
|
+
import { relativeToCwd } from '../../utils/next-steps.ts';
|
|
9
|
+
import { safeFilename } from '../../utils/strings.ts';
|
|
10
|
+
import type { Conversation } from '../conversation.ts';
|
|
11
|
+
import { ASSERTION_TOOLS, CODECEPT_TOOLS } from '../tools.ts';
|
|
12
|
+
import type { Constructor } from './mixin.ts';
|
|
13
|
+
import { escapeString, getExecutionLabel, isNonReusableCode, stripComments } from './utils.ts';
|
|
14
|
+
|
|
15
|
+
export interface CodeceptJSMethods {
|
|
16
|
+
toCode(conversation: Conversation, scenario: string): string;
|
|
17
|
+
saveCodeceptPlanToFile(plan: Plan): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function WithCodeceptJS<T extends Constructor>(Base: T) {
|
|
21
|
+
return class extends Base {
|
|
22
|
+
declare savedFiles: Set<string>;
|
|
23
|
+
|
|
24
|
+
toCode(conversation: Conversation, scenario: string): string {
|
|
25
|
+
const toolExecutions = conversation.getToolExecutions();
|
|
26
|
+
const TRACKABLE_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
|
|
27
|
+
const successfulSteps = toolExecutions.filter((exec) => exec.wasSuccessful && TRACKABLE_TOOLS.includes(exec.toolName as any) && exec.output?.code);
|
|
28
|
+
|
|
29
|
+
if (successfulSteps.length === 0) {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
lines.push(`Scenario('${escapeString(scenario)}', ({ I }) => {`);
|
|
35
|
+
|
|
36
|
+
for (const exec of successfulSteps) {
|
|
37
|
+
if (isNonReusableCode(exec.output.code)) continue;
|
|
38
|
+
const explanation = getExecutionLabel(exec);
|
|
39
|
+
if (explanation) {
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push(` Section('${escapeString(explanation)}');`);
|
|
42
|
+
}
|
|
43
|
+
const code = stripComments(exec.output.code);
|
|
44
|
+
const codeLines = code.includes('\n') ? code.split('\n') : code.split('; ');
|
|
45
|
+
for (const codeLine of codeLines) {
|
|
46
|
+
const trimmed = codeLine.trim();
|
|
47
|
+
if (trimmed) {
|
|
48
|
+
lines.push(` ${trimmed}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lines.push('});');
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
saveCodeceptPlanToFile(plan: Plan): string {
|
|
58
|
+
const lines: string[] = [];
|
|
59
|
+
|
|
60
|
+
lines.push(`import step, { Section } from 'codeceptjs/steps';`);
|
|
61
|
+
lines.push('');
|
|
62
|
+
lines.push(`Feature('${escapeString(plan.title)}')`);
|
|
63
|
+
lines.push('');
|
|
64
|
+
|
|
65
|
+
const startUrl = plan.url || plan.tests[0]?.startUrl;
|
|
66
|
+
if (startUrl) {
|
|
67
|
+
lines.push('Before(({ I }) => {');
|
|
68
|
+
lines.push(` I.amOnPage('${escapeString(startUrl)}');`);
|
|
69
|
+
lines.push(...this.getKnowledgeLines(startUrl));
|
|
70
|
+
lines.push('});');
|
|
71
|
+
lines.push('');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const test of plan.tests) {
|
|
75
|
+
if (test.generatedCode) {
|
|
76
|
+
if (test.isSuccessful) {
|
|
77
|
+
lines.push(test.generatedCode);
|
|
78
|
+
} else {
|
|
79
|
+
lines.push(`// FAILED: ${test.scenario}`);
|
|
80
|
+
lines.push(test.generatedCode.replace(/Scenario\(/, 'Scenario.skip('));
|
|
81
|
+
}
|
|
82
|
+
lines.push('');
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lines.push(`Scenario.todo('${escapeString(test.scenario)}', ({ I }) => {`);
|
|
87
|
+
if (test.plannedSteps.length > 0) {
|
|
88
|
+
for (const step of test.plannedSteps) {
|
|
89
|
+
lines.push(` // ${step}`);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
lines.push(` // ${test.scenario}`);
|
|
93
|
+
}
|
|
94
|
+
lines.push('});');
|
|
95
|
+
lines.push('');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const testsDir = ConfigParser.getInstance().getTestsDir();
|
|
99
|
+
mkdirSync(testsDir, { recursive: true });
|
|
100
|
+
|
|
101
|
+
const filePath = join(testsDir, safeFilename(plan.title, '.js'));
|
|
102
|
+
writeFileSync(filePath, lines.join('\n'));
|
|
103
|
+
this.savedFiles.add(filePath);
|
|
104
|
+
|
|
105
|
+
tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
|
|
106
|
+
return filePath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private getKnowledgeLines(url: string, indent = ' '): string[] {
|
|
110
|
+
const knowledgeTracker = new KnowledgeTracker();
|
|
111
|
+
const state = new ActionResult({ url });
|
|
112
|
+
const { wait, waitForElement, code } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement', 'code']);
|
|
113
|
+
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
if (wait !== undefined) {
|
|
116
|
+
lines.push(`${indent}I.wait(${wait});`);
|
|
117
|
+
}
|
|
118
|
+
if (waitForElement) {
|
|
119
|
+
lines.push(`${indent}I.waitForElement(${JSON.stringify(waitForElement)});`);
|
|
120
|
+
}
|
|
121
|
+
if (code) {
|
|
122
|
+
for (const codeLine of code.split('\n')) {
|
|
123
|
+
const trimmed = codeLine.trim();
|
|
124
|
+
if (trimmed) lines.push(`${indent}${trimmed}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return lines;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|