explorbot 0.1.29 → 0.1.30
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/boat/doc-collector/src/ai/documentarian.ts +37 -13
- package/boat/doc-collector/src/ai/tools.ts +60 -20
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/config.ts +7 -0
- package/boat/doc-collector/src/docbot.ts +23 -5
- package/boat/doc-collector/src/docs-renderer.ts +14 -1
- package/boat/doc-collector/src/screenshots.ts +126 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
- package/dist/boat/doc-collector/src/ai/tools.js +53 -20
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/config.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +19 -4
- package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
- package/dist/boat/doc-collector/src/screenshots.js +90 -0
- package/dist/package.json +1 -1
- package/dist/src/action.js +26 -23
- package/dist/src/ai/historian/codeceptjs.js +3 -2
- package/dist/src/ai/historian/experience.js +48 -6
- package/dist/src/ai/historian/playwright.js +2 -1
- package/dist/src/ai/historian/utils.js +1 -19
- package/dist/src/ai/historian.js +1 -1
- package/dist/src/ai/quartermaster.js +2 -2
- package/dist/src/ai/tester.js +3 -0
- package/dist/src/ai/tools.js +0 -1
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +7 -1
- package/dist/src/explorer.js +30 -27
- package/dist/src/utils/browser-errors.js +5 -0
- package/dist/src/utils/page-readiness.js +48 -0
- package/dist/src/utils/step-analyzer.js +68 -0
- package/package.json +1 -1
- package/src/action.ts +24 -26
- package/src/ai/historian/codeceptjs.ts +3 -2
- package/src/ai/historian/experience.ts +51 -6
- package/src/ai/historian/playwright.ts +2 -1
- package/src/ai/historian/utils.ts +1 -21
- package/src/ai/historian.ts +1 -1
- package/src/ai/quartermaster.ts +2 -2
- package/src/ai/tester.ts +3 -0
- package/src/ai/tools.ts +0 -1
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +7 -1
- package/src/explorer.ts +28 -27
- package/src/utils/browser-errors.ts +6 -0
- package/src/utils/page-readiness.ts +59 -0
- package/src/utils/step-analyzer.ts +73 -0
|
@@ -2,11 +2,11 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
|
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { marked } from 'marked';
|
|
5
|
-
import { isNonReusableCode } from "./ai/historian/utils.js";
|
|
6
5
|
import { ConfigParser } from './config.js';
|
|
7
6
|
import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
8
7
|
import { createDebug, tag } from './utils/logger.js';
|
|
9
8
|
import { mdq } from './utils/markdown-query.js';
|
|
9
|
+
import { isNonReusableCode } from "./utils/step-analyzer.js";
|
|
10
10
|
import { extractStatePath } from './utils/url-matcher.js';
|
|
11
11
|
const debugLog = createDebug('explorbot:experience');
|
|
12
12
|
const DEFAULT_MAX_EXPERIENCE_LINES = 100;
|
package/dist/src/explorbot.js
CHANGED
|
@@ -180,7 +180,13 @@ export class ExplorBot {
|
|
|
180
180
|
this.agents.tester = this.createAgent(({ ai, explorer }) => {
|
|
181
181
|
const researcher = this.agentResearcher();
|
|
182
182
|
const navigator = this.agentNavigator();
|
|
183
|
-
const
|
|
183
|
+
const stateManager = explorer.getStateManager();
|
|
184
|
+
const experienceTracker = stateManager.getExperienceTracker();
|
|
185
|
+
const getState = () => {
|
|
186
|
+
const state = stateManager.getCurrentState();
|
|
187
|
+
return state ? ActionResult.fromState(state) : null;
|
|
188
|
+
};
|
|
189
|
+
const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
|
|
184
190
|
return new Tester(explorer, ai, researcher, navigator, tools);
|
|
185
191
|
});
|
|
186
192
|
const qm = this.agentQuartermaster();
|
package/dist/src/explorer.js
CHANGED
|
@@ -2,10 +2,10 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
// @ts-ignore
|
|
4
4
|
import * as codeceptjs from 'codeceptjs';
|
|
5
|
-
import dedent from 'dedent';
|
|
6
5
|
import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
7
6
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
8
7
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
8
|
+
import dedent from 'dedent';
|
|
9
9
|
import { ActionResult } from "./action-result.js";
|
|
10
10
|
import Action from './action.js';
|
|
11
11
|
import { visuallyAnnotateContainers } from "./ai/researcher/coordinates.js";
|
|
@@ -17,10 +17,11 @@ import { PlaywrightRecorder } from "./playwright-recorder.js";
|
|
|
17
17
|
import { Reporter } from "./reporter.js";
|
|
18
18
|
import { StateManager } from './state-manager.js';
|
|
19
19
|
import { TestResult } from "./test-plan.js";
|
|
20
|
+
import { BrowserRecoveryError, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
|
|
20
21
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./utils/html.js";
|
|
21
22
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
23
|
+
import { waitForPageReadiness } from "./utils/page-readiness.js";
|
|
22
24
|
import { WebElement } from "./utils/web-element.js";
|
|
23
|
-
import { BrowserRecoveryError, isFatalBrowserError } from "./utils/browser-errors.js";
|
|
24
25
|
const debugLog = createDebug('explorbot:explorer');
|
|
25
26
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
26
27
|
class Explorer {
|
|
@@ -250,16 +251,29 @@ class Explorer {
|
|
|
250
251
|
return await operation();
|
|
251
252
|
}
|
|
252
253
|
catch (error) {
|
|
253
|
-
|
|
254
|
-
|
|
254
|
+
let recoveryError = error;
|
|
255
|
+
if (isNavigationTransitionError(error)) {
|
|
256
|
+
tag('warning').log(`${label}: page is still navigating, waiting before retry...`);
|
|
257
|
+
await this.waitForPageReadiness();
|
|
258
|
+
try {
|
|
259
|
+
return await operation();
|
|
260
|
+
}
|
|
261
|
+
catch (retryError) {
|
|
262
|
+
if (!isNavigationTransitionError(retryError) && !this.isFatalBrowserError(retryError))
|
|
263
|
+
throw retryError;
|
|
264
|
+
recoveryError = retryError;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!this.isFatalBrowserError(recoveryError))
|
|
268
|
+
throw recoveryError;
|
|
255
269
|
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
256
270
|
let recovered = await this.recoverFromBrowserError();
|
|
257
271
|
if (!recovered)
|
|
258
272
|
recovered = await this.restartBrowser();
|
|
259
273
|
if (!recovered)
|
|
260
|
-
throw new BrowserRecoveryError(label,
|
|
261
|
-
if (!(await this.
|
|
262
|
-
throw new BrowserRecoveryError(label,
|
|
274
|
+
throw new BrowserRecoveryError(label, recoveryError, false);
|
|
275
|
+
if (!(await this.waitForPageReadiness()))
|
|
276
|
+
throw new BrowserRecoveryError(label, recoveryError, true);
|
|
263
277
|
try {
|
|
264
278
|
return await operation();
|
|
265
279
|
}
|
|
@@ -308,7 +322,7 @@ class Explorer {
|
|
|
308
322
|
if (!RECOVERABLE_NAVIGATION_ERRORS.test(msg))
|
|
309
323
|
throw err;
|
|
310
324
|
tag('warning').log(`Navigation warning (continuing after load): ${msg.split('\n')[0]}`);
|
|
311
|
-
await this.
|
|
325
|
+
await this.waitForPageReadiness();
|
|
312
326
|
await action.capturePageState();
|
|
313
327
|
}
|
|
314
328
|
}
|
|
@@ -412,11 +426,11 @@ class Explorer {
|
|
|
412
426
|
if (url) {
|
|
413
427
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
414
428
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
415
|
-
return this.
|
|
429
|
+
return this.waitForPageReadiness();
|
|
416
430
|
}
|
|
417
431
|
tag('warning').log('Browser error detected, reloading page');
|
|
418
432
|
await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
419
|
-
return this.
|
|
433
|
+
return this.waitForPageReadiness();
|
|
420
434
|
}
|
|
421
435
|
catch (err) {
|
|
422
436
|
tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
|
|
@@ -450,7 +464,7 @@ class Explorer {
|
|
|
450
464
|
this.listenToStateChanged();
|
|
451
465
|
if (url) {
|
|
452
466
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
453
|
-
if (!(await this.
|
|
467
|
+
if (!(await this.waitForPageReadiness()))
|
|
454
468
|
return false;
|
|
455
469
|
}
|
|
456
470
|
tag('success').log('Browser restarted');
|
|
@@ -467,25 +481,14 @@ class Explorer {
|
|
|
467
481
|
await this.playwrightHelper.switchTo();
|
|
468
482
|
}
|
|
469
483
|
}
|
|
470
|
-
async
|
|
484
|
+
async waitForPageReadiness() {
|
|
471
485
|
const page = this.playwrightHelper?.page;
|
|
472
486
|
if (!page)
|
|
473
487
|
return false;
|
|
474
|
-
await page
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const body = document.body;
|
|
479
|
-
if (!body)
|
|
480
|
-
return false;
|
|
481
|
-
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
482
|
-
}, undefined, { timeout: 5000 })
|
|
483
|
-
.then(() => true)
|
|
484
|
-
.catch(() => false);
|
|
485
|
-
if (!hasUsableDom)
|
|
486
|
-
return false;
|
|
487
|
-
}
|
|
488
|
-
await page.waitForLoadState?.('networkidle', { timeout: 3000 }).catch(() => { });
|
|
488
|
+
await waitForPageReadiness(page, {
|
|
489
|
+
timeout: this.config.playwright.waitForTimeout,
|
|
490
|
+
spinnerSelectors: this.config.playwright.spinnerSelectors,
|
|
491
|
+
});
|
|
489
492
|
return true;
|
|
490
493
|
}
|
|
491
494
|
async isInsideIframe() {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// not typed exceptions. Keep those external message markers in one adapter so
|
|
3
3
|
// recovery decisions are not duplicated across agents/actions.
|
|
4
4
|
const FATAL_BROWSER_ERROR_MARKERS = ['Frame was detached', 'Target closed', 'Target page, context or browser has been closed', 'Execution context was destroyed', 'Protocol error', 'Session closed'];
|
|
5
|
+
const NAVIGATION_TRANSITION_ERROR_MARKERS = ['most likely because of a navigation', 'navigating and changing the content'];
|
|
5
6
|
export class BrowserRecoveryError extends Error {
|
|
6
7
|
originalError;
|
|
7
8
|
recovered;
|
|
@@ -18,6 +19,10 @@ export function isFatalBrowserError(error) {
|
|
|
18
19
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
19
20
|
return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
20
21
|
}
|
|
22
|
+
export function isNavigationTransitionError(error) {
|
|
23
|
+
const message = browserErrorMessage(error).toLowerCase();
|
|
24
|
+
return NAVIGATION_TRANSITION_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
25
|
+
}
|
|
21
26
|
export function browserErrorMessage(error) {
|
|
22
27
|
return error instanceof Error ? error.message : String(error);
|
|
23
28
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export async function waitForPageReadiness(page, options = {}) {
|
|
2
|
+
if (!page)
|
|
3
|
+
return;
|
|
4
|
+
const timeout = options.timeout ?? 6000;
|
|
5
|
+
await page.waitForLoadState?.('domcontentloaded', { timeout })?.catch(() => { });
|
|
6
|
+
await Promise.race([waitForNetworkIdle(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => { });
|
|
7
|
+
await waitForPageBodyContent(page, timeout);
|
|
8
|
+
}
|
|
9
|
+
function waitForNetworkIdle(page, timeout) {
|
|
10
|
+
if (!page?.waitForLoadState)
|
|
11
|
+
return Promise.resolve();
|
|
12
|
+
return page.waitForLoadState('networkidle', { timeout }).catch(() => { });
|
|
13
|
+
}
|
|
14
|
+
async function waitForVisibleSpinnersHidden(page, selectors, timeout) {
|
|
15
|
+
if (!selectors.length)
|
|
16
|
+
return new Promise(() => { });
|
|
17
|
+
if (!page?.locator)
|
|
18
|
+
return new Promise(() => { });
|
|
19
|
+
const visibleSpinners = [];
|
|
20
|
+
for (const selector of selectors) {
|
|
21
|
+
const locator = page.locator(selector);
|
|
22
|
+
const isVisible = await locator
|
|
23
|
+
.first()
|
|
24
|
+
.isVisible({ timeout: 100 })
|
|
25
|
+
.catch(() => false);
|
|
26
|
+
if (!isVisible)
|
|
27
|
+
continue;
|
|
28
|
+
visibleSpinners.push(locator);
|
|
29
|
+
}
|
|
30
|
+
if (visibleSpinners.length === 0)
|
|
31
|
+
return new Promise(() => { });
|
|
32
|
+
return Promise.all(visibleSpinners.map((locator) => locator.waitFor({ state: 'hidden', timeout }).catch(() => { }))).then(() => { });
|
|
33
|
+
}
|
|
34
|
+
function waitForPageBodyContent(page, timeout) {
|
|
35
|
+
if (!page?.waitForFunction)
|
|
36
|
+
return Promise.resolve();
|
|
37
|
+
return page
|
|
38
|
+
.waitForFunction(() => {
|
|
39
|
+
const body = document.body;
|
|
40
|
+
if (!body)
|
|
41
|
+
return false;
|
|
42
|
+
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
43
|
+
}, undefined, { timeout })
|
|
44
|
+
.catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
function sleep(ms) {
|
|
47
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isDynamicId } from "./xpath.js";
|
|
2
|
+
export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'];
|
|
3
|
+
const CODECEPT_FORM_COMMANDS = ['I.fillField', 'I.type', 'I.selectOption', 'I.attachFile', 'I.checkOption', 'I.uncheckOption'];
|
|
4
|
+
export function isCodeceptToolName(toolName) {
|
|
5
|
+
return CODECEPT_TOOLS.includes(toolName);
|
|
6
|
+
}
|
|
7
|
+
export function getCodeceptToolName(commandName) {
|
|
8
|
+
const toolName = CODECEPT_TOOLS.find((name) => commandName === `I.${name}`);
|
|
9
|
+
if (toolName)
|
|
10
|
+
return toolName;
|
|
11
|
+
if (CODECEPT_FORM_COMMANDS.includes(commandName))
|
|
12
|
+
return 'form';
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
export function getCodeceptToolNameFromCode(code) {
|
|
16
|
+
const parenIndex = code.trim().indexOf('(');
|
|
17
|
+
if (parenIndex < 1)
|
|
18
|
+
return null;
|
|
19
|
+
return getCodeceptToolName(code.trim().slice(0, parenIndex));
|
|
20
|
+
}
|
|
21
|
+
export function stripComments(code) {
|
|
22
|
+
return code
|
|
23
|
+
.split('\n')
|
|
24
|
+
.filter((line) => {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
|
|
27
|
+
})
|
|
28
|
+
.join('\n');
|
|
29
|
+
}
|
|
30
|
+
export function isNonReusableCode(code) {
|
|
31
|
+
if (/\bI\.clickXY\s*\(/.test(code))
|
|
32
|
+
return true;
|
|
33
|
+
for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
|
|
34
|
+
if (isDynamicId(m[1]))
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
export function toReusableSessionStep(step) {
|
|
40
|
+
if (step.status !== 'passed')
|
|
41
|
+
return null;
|
|
42
|
+
const code = stripComments(step.text);
|
|
43
|
+
if (!code || isNonReusableCode(code))
|
|
44
|
+
return null;
|
|
45
|
+
const toolName = getCodeceptToolNameFromCode(code);
|
|
46
|
+
if (!toolName)
|
|
47
|
+
return null;
|
|
48
|
+
return {
|
|
49
|
+
message: step.text,
|
|
50
|
+
status: 'passed',
|
|
51
|
+
tool: toolName,
|
|
52
|
+
code,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function mergeUniqueStepsByCode(primary, secondary) {
|
|
56
|
+
const merged = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const step of [...primary, ...secondary]) {
|
|
59
|
+
const identity = stripComments(step.code || '').trim();
|
|
60
|
+
if (!identity)
|
|
61
|
+
continue;
|
|
62
|
+
if (seen.has(identity))
|
|
63
|
+
continue;
|
|
64
|
+
seen.add(identity);
|
|
65
|
+
merged.push(step);
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
}
|
package/package.json
CHANGED
package/src/action.ts
CHANGED
|
@@ -18,14 +18,16 @@ import type { UserResolveFunction } from './explorbot.ts';
|
|
|
18
18
|
import { Observability } from './observability.ts';
|
|
19
19
|
import type { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
20
20
|
import type { StateManager } from './state-manager.js';
|
|
21
|
+
import { isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
|
|
21
22
|
import { extractCodeBlocks } from './utils/code-extractor.js';
|
|
22
23
|
import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
23
24
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
25
|
+
import { waitForPageReadiness } from './utils/page-readiness.ts';
|
|
24
26
|
import { safeFilename } from './utils/strings.ts';
|
|
25
27
|
import { throttle } from './utils/throttle.ts';
|
|
26
|
-
import { isFatalBrowserError } from './utils/browser-errors.ts';
|
|
27
28
|
|
|
28
29
|
const debugLog = createDebug('explorbot:action');
|
|
30
|
+
const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
|
|
29
31
|
|
|
30
32
|
class Action {
|
|
31
33
|
private actor: CodeceptJS.I;
|
|
@@ -78,16 +80,21 @@ class Action {
|
|
|
78
80
|
const timestamp = Date.now();
|
|
79
81
|
const page = this.playwrightHelper.page;
|
|
80
82
|
const frame = this.playwrightHelper.frame;
|
|
81
|
-
await page
|
|
82
|
-
await waitForUsablePageDom(page);
|
|
83
|
+
await this.waitForPageReadiness(page);
|
|
83
84
|
const grabAll = () => Promise.all([captureHtml(page, frame, this.actor), captureTitle(page, this.actor), this.captureBrowserLogs()]);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
let html = '';
|
|
86
|
+
let title = '';
|
|
87
|
+
let browserLogs: any[] = [];
|
|
88
|
+
for (let attempt = 1; attempt <= CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS; attempt++) {
|
|
89
|
+
try {
|
|
90
|
+
[html, title, browserLogs] = await grabAll();
|
|
91
|
+
break;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (!isNavigationTransitionError(err)) throw err;
|
|
94
|
+
if (attempt === CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS) throw err;
|
|
95
|
+
await this.waitForPageReadiness(page);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
91
98
|
const url = page?.url() || (await (this.actor as any).grabCurrentUrl?.());
|
|
92
99
|
|
|
93
100
|
let screenshotFile: string | undefined = undefined;
|
|
@@ -442,6 +449,13 @@ class Action {
|
|
|
442
449
|
getActionResult(): ActionResult | null {
|
|
443
450
|
return this.actionResult;
|
|
444
451
|
}
|
|
452
|
+
|
|
453
|
+
private async waitForPageReadiness(page: any): Promise<void> {
|
|
454
|
+
await waitForPageReadiness(page, {
|
|
455
|
+
timeout: this.config.playwright.waitForTimeout,
|
|
456
|
+
spinnerSelectors: this.config.playwright.spinnerSelectors,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
445
459
|
}
|
|
446
460
|
|
|
447
461
|
export default Action;
|
|
@@ -453,22 +467,6 @@ function errorToString(error: any): string {
|
|
|
453
467
|
return error.message || error.toString();
|
|
454
468
|
}
|
|
455
469
|
|
|
456
|
-
async function waitForUsablePageDom(page: any): Promise<void> {
|
|
457
|
-
if (!page?.waitForFunction) return;
|
|
458
|
-
|
|
459
|
-
await page
|
|
460
|
-
.waitForFunction(
|
|
461
|
-
() => {
|
|
462
|
-
const body = document.body;
|
|
463
|
-
if (!body) return false;
|
|
464
|
-
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
465
|
-
},
|
|
466
|
-
undefined,
|
|
467
|
-
{ timeout: 5000 }
|
|
468
|
-
)
|
|
469
|
-
.catch(() => {});
|
|
470
|
-
}
|
|
471
|
-
|
|
472
470
|
async function captureHtml(page: any, frame: any, actor: any): Promise<string> {
|
|
473
471
|
if (frame?.content) return frame.content();
|
|
474
472
|
if (page?.content) return page.content();
|
|
@@ -7,10 +7,11 @@ 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
9
|
import { safeFilename } from '../../utils/strings.ts';
|
|
10
|
+
import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
|
|
10
11
|
import type { Conversation } from '../conversation.ts';
|
|
11
|
-
import { ASSERTION_TOOLS
|
|
12
|
+
import { ASSERTION_TOOLS } from '../tools.ts';
|
|
12
13
|
import type { Constructor } from './mixin.ts';
|
|
13
|
-
import { escapeString, getExecutionLabel
|
|
14
|
+
import { escapeString, getExecutionLabel } from './utils.ts';
|
|
14
15
|
|
|
15
16
|
export interface CodeceptJSMethods {
|
|
16
17
|
toCode(conversation: Conversation, scenario: string): string;
|
|
@@ -6,12 +6,12 @@ import type { Reporter, ReporterStep } from '../../reporter.ts';
|
|
|
6
6
|
import type { StateManager } from '../../state-manager.ts';
|
|
7
7
|
import { type Task, Test } from '../../test-plan.ts';
|
|
8
8
|
import { tag } from '../../utils/logger.ts';
|
|
9
|
+
import { isCodeceptToolName, isNonReusableCode, mergeUniqueStepsByCode, stripComments, toReusableSessionStep } from '../../utils/step-analyzer.ts';
|
|
9
10
|
import { extractStatePath } from '../../utils/url-matcher.ts';
|
|
10
11
|
import type { Conversation, ToolExecution } from '../conversation.ts';
|
|
11
12
|
import type { Provider } from '../provider.ts';
|
|
12
|
-
import { CODECEPT_TOOLS } from '../tools.ts';
|
|
13
13
|
import { type Constructor, debugLog } from './mixin.ts';
|
|
14
|
-
import { getExecutionLabel
|
|
14
|
+
import { getExecutionLabel } from './utils.ts';
|
|
15
15
|
|
|
16
16
|
export interface ExperienceMethods {
|
|
17
17
|
saveSession(task: Task, initialState: ActionResult, conversation: Conversation): Promise<void>;
|
|
@@ -38,12 +38,18 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
38
38
|
task.generatedCode = this.isPlaywrightFramework() ? await this.toPlaywrightCode(conversation, task.description) : this.toCode(conversation, task.description);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const
|
|
41
|
+
const conversationSteps = await this.extractSteps(toolExecutions);
|
|
42
|
+
const taskSteps = this.extractPassedCodeceptSteps(task);
|
|
43
|
+
const steps = mergeUniqueStepsByCode(conversationSteps, taskSteps);
|
|
42
44
|
|
|
43
45
|
const skipExperience = result === 'failed' || (task instanceof Test && (task.hasFailed || task.isSkipped));
|
|
44
46
|
if (!skipExperience) {
|
|
47
|
+
const hasExistingFlow = this.hasRelevantFlowExperience(initialState);
|
|
45
48
|
await this.detectRetryPatterns(toolExecutions, initialState);
|
|
46
|
-
|
|
49
|
+
let body = await this.curateFlow(steps, task, initialState);
|
|
50
|
+
if (!body.trim() && !hasExistingFlow) {
|
|
51
|
+
body = this.renderFlowFromSuccessfulSteps(steps, task);
|
|
52
|
+
}
|
|
47
53
|
if (body.trim()) {
|
|
48
54
|
const relatedUrls = this.extractVisitedUrls(toolExecutions, initialState.url || '');
|
|
49
55
|
this.experienceTracker.writeFlow(initialState, body, relatedUrls);
|
|
@@ -76,7 +82,7 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
76
82
|
const stepsWithDiffs: Array<{ step: SessionStep; ariaDiff: string | null }> = [];
|
|
77
83
|
|
|
78
84
|
for (const exec of toolExecutions) {
|
|
79
|
-
if (!
|
|
85
|
+
if (!isCodeceptToolName(exec.toolName)) continue;
|
|
80
86
|
if (!exec.output?.code) continue;
|
|
81
87
|
if (!exec.wasSuccessful) continue;
|
|
82
88
|
if (isNonReusableCode(exec.output.code)) continue;
|
|
@@ -96,6 +102,19 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
96
102
|
return stepsWithDiffs.map((s) => s.step);
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
private hasRelevantFlowExperience(state: ActionResult): boolean {
|
|
106
|
+
return this.experienceTracker.getRelevantExperience(state).some((experience) => experience.content.includes('## FLOW:'));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private extractPassedCodeceptSteps(task: Task): SessionStep[] {
|
|
110
|
+
const steps: SessionStep[] = [];
|
|
111
|
+
for (const step of Object.values(task.steps)) {
|
|
112
|
+
const sessionStep = toReusableSessionStep(step);
|
|
113
|
+
if (sessionStep) steps.push(sessionStep);
|
|
114
|
+
}
|
|
115
|
+
return steps;
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
private async curateFlow(steps: SessionStep[], task: Task, initialState: ActionResult): Promise<string> {
|
|
100
119
|
if (steps.length === 0) return '';
|
|
101
120
|
|
|
@@ -207,6 +226,32 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
207
226
|
}
|
|
208
227
|
}
|
|
209
228
|
|
|
229
|
+
private renderFlowFromSuccessfulSteps(steps: SessionStep[], task: Task): string {
|
|
230
|
+
if (steps.length === 0) return '';
|
|
231
|
+
|
|
232
|
+
const title = task.description.charAt(0).toLowerCase() + task.description.slice(1);
|
|
233
|
+
const blocks = steps
|
|
234
|
+
.filter((step) => step.code)
|
|
235
|
+
.map((step) => {
|
|
236
|
+
const lines = [`* ${step.message}`];
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push('```js');
|
|
239
|
+
lines.push(stripComments(step.code || ''));
|
|
240
|
+
lines.push('```');
|
|
241
|
+
if (step.discovery) {
|
|
242
|
+
lines.push('');
|
|
243
|
+
for (const discovery of step.discovery.split('\n').filter((line) => line.trim())) {
|
|
244
|
+
lines.push(`> ${discovery.trim()}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (blocks.length === 0) return '';
|
|
251
|
+
|
|
252
|
+
return `## FLOW: ${title}\n\n${blocks.join('\n\n')}\n\n---\n`;
|
|
253
|
+
}
|
|
254
|
+
|
|
210
255
|
private async detectRetryPatterns(toolExecutions: ToolExecution[], initialState: ActionResult): Promise<void> {
|
|
211
256
|
if (!this.experienceTracker || !this.stateManager) return;
|
|
212
257
|
|
|
@@ -214,7 +259,7 @@ export function WithExperience<T extends Constructor>(Base: T) {
|
|
|
214
259
|
const candidates: Array<{ failed: ToolExecution[]; success: ToolExecution }> = [];
|
|
215
260
|
|
|
216
261
|
for (const exec of toolExecutions) {
|
|
217
|
-
if (!
|
|
262
|
+
if (!isCodeceptToolName(exec.toolName)) continue;
|
|
218
263
|
if (!exec.output?.code) continue;
|
|
219
264
|
|
|
220
265
|
if (!exec.wasSuccessful) {
|
|
@@ -8,8 +8,9 @@ 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
10
|
import { safeFilename } from '../../utils/strings.ts';
|
|
11
|
+
import { CODECEPT_TOOLS } from '../../utils/step-analyzer.ts';
|
|
11
12
|
import type { Conversation } from '../conversation.ts';
|
|
12
|
-
import { ASSERTION_TOOLS
|
|
13
|
+
import { ASSERTION_TOOLS } from '../tools.ts';
|
|
13
14
|
import type { Constructor } from './mixin.ts';
|
|
14
15
|
import { escapeString, getExecutionLabel } from './utils.ts';
|
|
15
16
|
|
|
@@ -1,30 +1,10 @@
|
|
|
1
|
-
import { isDynamicId } from '../../utils/xpath.ts';
|
|
2
1
|
import type { ToolExecution } from '../conversation.ts';
|
|
3
|
-
|
|
4
|
-
export function isNonReusableCode(code: string): boolean {
|
|
5
|
-
if (/\bI\.clickXY\s*\(/.test(code)) return true;
|
|
6
|
-
|
|
7
|
-
for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
|
|
8
|
-
if (isDynamicId(m[1])) return true;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
2
|
+
export { isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
|
|
13
3
|
|
|
14
4
|
export function escapeString(str: string): string {
|
|
15
5
|
return str.replace(/'/g, "\\'").replace(/\n/g, ' ');
|
|
16
6
|
}
|
|
17
7
|
|
|
18
|
-
export function stripComments(code: string): string {
|
|
19
|
-
return code
|
|
20
|
-
.split('\n')
|
|
21
|
-
.filter((line) => {
|
|
22
|
-
const trimmed = line.trim();
|
|
23
|
-
return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
|
|
24
|
-
})
|
|
25
|
-
.join('\n');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
8
|
export function getExecutionLabel(exec: ToolExecution, fallback?: string): string {
|
|
29
9
|
return exec.input?.explanation || exec.input?.assertion || exec.input?.note || fallback || '';
|
|
30
10
|
}
|
package/src/ai/historian.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { type PlaywrightMethods, WithPlaywright } from './historian/playwright.t
|
|
|
13
13
|
import { type ScreencastMethods, WithScreencast } from './historian/screencast.ts';
|
|
14
14
|
import type { Provider } from './provider.ts';
|
|
15
15
|
|
|
16
|
-
export { isNonReusableCode } from '
|
|
16
|
+
export { isNonReusableCode } from '../utils/step-analyzer.ts';
|
|
17
17
|
|
|
18
18
|
const HistorianBase = WithScreencast(WithPlaywright(WithCodeceptJS(WithExperience(Object as unknown as new (...args: any[]) => object))));
|
|
19
19
|
|
package/src/ai/quartermaster.ts
CHANGED
|
@@ -6,9 +6,9 @@ import { ConfigParser } from '../config.ts';
|
|
|
6
6
|
import type { StateManager, StateTransition, WebPageState } from '../state-manager.ts';
|
|
7
7
|
import type { Task } from '../test-plan.ts';
|
|
8
8
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
9
|
+
import { isCodeceptToolName } from '../utils/step-analyzer.ts';
|
|
9
10
|
import type { Conversation, ToolExecution } from './conversation.ts';
|
|
10
11
|
import type { Provider } from './provider.ts';
|
|
11
|
-
import { CODECEPT_TOOLS } from './tools.ts';
|
|
12
12
|
|
|
13
13
|
const debugLog = createDebug('explorbot:quartermaster');
|
|
14
14
|
|
|
@@ -140,7 +140,7 @@ export class Quartermaster {
|
|
|
140
140
|
const pageAnalysis = this.pageAnalyses.get(stateHash);
|
|
141
141
|
|
|
142
142
|
const toolExecutions = conversation.getToolExecutions();
|
|
143
|
-
const codeceptExecutions = toolExecutions.filter((e) =>
|
|
143
|
+
const codeceptExecutions = toolExecutions.filter((e) => isCodeceptToolName(e.toolName));
|
|
144
144
|
|
|
145
145
|
if (codeceptExecutions.length === 0 && !pageAnalysis?.axeViolations.length) {
|
|
146
146
|
debugLog('No interactions or violations to analyze');
|
package/src/ai/tester.ts
CHANGED
|
@@ -853,6 +853,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
853
853
|
|
|
854
854
|
private buildScenarioBlock(task: Test, actionResult: ActionResult): string {
|
|
855
855
|
const knowledge = this.getKnowledge(actionResult);
|
|
856
|
+
const experience = this.getExperience(actionResult);
|
|
856
857
|
|
|
857
858
|
return dedent`
|
|
858
859
|
<task>
|
|
@@ -883,6 +884,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
883
884
|
${this.buildAvailableFiles()}
|
|
884
885
|
|
|
885
886
|
${knowledge}
|
|
887
|
+
|
|
888
|
+
${experience}
|
|
886
889
|
`;
|
|
887
890
|
}
|
|
888
891
|
|
package/src/ai/tools.ts
CHANGED
|
@@ -18,7 +18,6 @@ import { isInteractive } from './task-agent.ts';
|
|
|
18
18
|
|
|
19
19
|
const debugLog = createDebug('explorbot:tools');
|
|
20
20
|
|
|
21
|
-
export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'] as const;
|
|
22
21
|
export const ASSERTION_TOOLS = ['verify'] as const;
|
|
23
22
|
|
|
24
23
|
export function createCodeceptJSTools(explorer: Explorer, task: Task) {
|
package/src/config.ts
CHANGED
|
@@ -3,12 +3,12 @@ import { basename, dirname, join } from 'node:path';
|
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { type Tokens, marked } from 'marked';
|
|
5
5
|
import type { ActionResult } from './action-result.js';
|
|
6
|
-
import { isNonReusableCode } from './ai/historian/utils.ts';
|
|
7
6
|
import { ConfigParser } from './config.js';
|
|
8
7
|
import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
9
8
|
import type { WebPageState } from './state-manager.js';
|
|
10
9
|
import { createDebug, tag } from './utils/logger.js';
|
|
11
10
|
import { mdq } from './utils/markdown-query.js';
|
|
11
|
+
import { isNonReusableCode } from './utils/step-analyzer.ts';
|
|
12
12
|
import { extractStatePath } from './utils/url-matcher.js';
|
|
13
13
|
|
|
14
14
|
const debugLog = createDebug('explorbot:experience');
|
package/src/explorbot.ts
CHANGED
|
@@ -221,7 +221,13 @@ export class ExplorBot {
|
|
|
221
221
|
this.agents.tester = this.createAgent(({ ai, explorer }) => {
|
|
222
222
|
const researcher = this.agentResearcher();
|
|
223
223
|
const navigator = this.agentNavigator();
|
|
224
|
-
const
|
|
224
|
+
const stateManager = explorer.getStateManager();
|
|
225
|
+
const experienceTracker = stateManager.getExperienceTracker();
|
|
226
|
+
const getState = () => {
|
|
227
|
+
const state = stateManager.getCurrentState();
|
|
228
|
+
return state ? ActionResult.fromState(state) : null;
|
|
229
|
+
};
|
|
230
|
+
const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
|
|
225
231
|
return new Tester(explorer, ai, researcher, navigator, tools);
|
|
226
232
|
});
|
|
227
233
|
|