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
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { parseResearchSections } from "../../../../src/ai/researcher/parser.js";
|
|
2
|
-
const
|
|
3
|
-
const
|
|
2
|
+
const DEFAULT_MAX_PRIMARY_CANDIDATES = 3;
|
|
3
|
+
const DEFAULT_MAX_INTERACTIONS = 5;
|
|
4
4
|
const MAX_LINKS = 15;
|
|
5
5
|
const DEFAULT_WAIT_MS = 700;
|
|
6
6
|
const TAB_WAIT_MS = 500;
|
|
7
|
-
|
|
7
|
+
const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];
|
|
8
|
+
export async function collectDocInteractions(explorer, state, research, config = {}) {
|
|
8
9
|
const sections = parseResearchSections(research);
|
|
9
10
|
const transitions = [];
|
|
11
|
+
const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
|
|
10
12
|
const tabGroup = findTabGroup(sections);
|
|
11
13
|
if (tabGroup) {
|
|
12
|
-
transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url)));
|
|
14
|
+
transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions)));
|
|
13
15
|
}
|
|
14
|
-
for (const candidate of findActionCandidates(sections)) {
|
|
15
|
-
if (transitions.length >=
|
|
16
|
+
for (const candidate of findActionCandidates(sections, config)) {
|
|
17
|
+
if (transitions.length >= maxInteractions) {
|
|
16
18
|
break;
|
|
17
19
|
}
|
|
18
20
|
const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS);
|
|
@@ -23,16 +25,19 @@ export async function collectDocInteractions(explorer, state, research) {
|
|
|
23
25
|
}
|
|
24
26
|
return transitions;
|
|
25
27
|
}
|
|
26
|
-
export function pickDocActionCandidates(research) {
|
|
27
|
-
return findActionCandidates(parseResearchSections(research)).map((candidate) => ({
|
|
28
|
+
export function pickDocActionCandidates(research, config = {}) {
|
|
29
|
+
return findActionCandidates(parseResearchSections(research), config).map((candidate) => ({
|
|
28
30
|
label: candidate.element.name.trim(),
|
|
29
31
|
role: candidate.role,
|
|
30
32
|
section: candidate.sectionName,
|
|
31
33
|
}));
|
|
32
34
|
}
|
|
33
|
-
async function exploreTabGroup(explorer, tabGroup, restoreUrl) {
|
|
35
|
+
async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions) {
|
|
34
36
|
const transitions = [];
|
|
35
37
|
for (const element of tabGroup.elements) {
|
|
38
|
+
if (transitions.length >= maxInteractions) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
36
41
|
const transition = await executeInteraction(explorer, {
|
|
37
42
|
element,
|
|
38
43
|
container: tabGroup.container,
|
|
@@ -164,21 +169,19 @@ function findTabGroup(sections) {
|
|
|
164
169
|
}
|
|
165
170
|
return null;
|
|
166
171
|
}
|
|
167
|
-
function findActionCandidates(sections) {
|
|
172
|
+
function findActionCandidates(sections, config) {
|
|
168
173
|
const candidates = [];
|
|
169
174
|
const seen = new Set();
|
|
170
175
|
const navigationLabels = collectNavigationLabels(sections);
|
|
176
|
+
const maxPrimaryCandidates = getPositiveConfigNumber(config.docs?.maxPrimaryCandidates, DEFAULT_MAX_PRIMARY_CANDIDATES);
|
|
171
177
|
for (const section of sections) {
|
|
172
178
|
const sectionName = section.name.toLowerCase();
|
|
173
179
|
const container = section.containerCss?.toLowerCase() || '';
|
|
174
|
-
if (
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
if (isNavigationSection(sectionName)) {
|
|
180
|
+
if (isIgnoredSection(sectionName, container)) {
|
|
178
181
|
continue;
|
|
179
182
|
}
|
|
180
183
|
for (const element of section.elements) {
|
|
181
|
-
const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels);
|
|
184
|
+
const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels, config);
|
|
182
185
|
if (!candidate) {
|
|
183
186
|
continue;
|
|
184
187
|
}
|
|
@@ -190,9 +193,9 @@ function findActionCandidates(sections) {
|
|
|
190
193
|
candidates.push(candidate);
|
|
191
194
|
}
|
|
192
195
|
}
|
|
193
|
-
return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0,
|
|
196
|
+
return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, maxPrimaryCandidates);
|
|
194
197
|
}
|
|
195
|
-
function toInteractionCandidate(element, sectionName, container, navigationLabels) {
|
|
198
|
+
function toInteractionCandidate(element, sectionName, container, navigationLabels, config) {
|
|
196
199
|
const role = getElementRole(element);
|
|
197
200
|
if (role !== 'link' && role !== 'button' && role !== 'tab') {
|
|
198
201
|
return null;
|
|
@@ -200,7 +203,10 @@ function toInteractionCandidate(element, sectionName, container, navigationLabel
|
|
|
200
203
|
if (!hasUsableName(element)) {
|
|
201
204
|
return null;
|
|
202
205
|
}
|
|
203
|
-
if (
|
|
206
|
+
if (isPageShellContainer(element.css) || isPageShellContainer(element.xpath)) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (isDestructiveAction(element, config)) {
|
|
204
210
|
return null;
|
|
205
211
|
}
|
|
206
212
|
if (role === 'link' && navigationLabels.has(normalizeCandidateLabel(element.name))) {
|
|
@@ -340,6 +346,18 @@ function hasUsableName(element) {
|
|
|
340
346
|
function isNavigationSection(sectionName) {
|
|
341
347
|
return /(navigation|menu|header|footer|breadcrumb)/i.test(sectionName);
|
|
342
348
|
}
|
|
349
|
+
function isContentControlSection(sectionName) {
|
|
350
|
+
return /(content|control|filter|toolbar|action|list|data)/i.test(sectionName);
|
|
351
|
+
}
|
|
352
|
+
function isIgnoredSection(sectionName, container) {
|
|
353
|
+
if (isOverlaySection(sectionName, container)) {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
if (isContentControlSection(sectionName)) {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
return isNavigationSection(sectionName) || isPageShellContainer(container);
|
|
360
|
+
}
|
|
343
361
|
function isOverlaySection(sectionName, container) {
|
|
344
362
|
return /(overlay|modal|popup|dialog)/i.test(sectionName) || /(overlay|modal|popup|dialog)/i.test(container);
|
|
345
363
|
}
|
|
@@ -365,11 +383,11 @@ function scoreCandidate(candidate) {
|
|
|
365
383
|
}
|
|
366
384
|
return score;
|
|
367
385
|
}
|
|
368
|
-
function
|
|
386
|
+
function isPageShellContainer(locator) {
|
|
369
387
|
if (!locator) {
|
|
370
388
|
return false;
|
|
371
389
|
}
|
|
372
|
-
return /(
|
|
390
|
+
return /(^|[\s>+~,.#\[])(nav|navigation|mainnav|header|menu|breadcrumb|footer)([\s>+~,.#\]_-]|$)/i.test(locator);
|
|
373
391
|
}
|
|
374
392
|
function collectNavigationLabels(sections) {
|
|
375
393
|
const labels = new Set();
|
|
@@ -390,6 +408,21 @@ function collectNavigationLabels(sections) {
|
|
|
390
408
|
function normalizeCandidateLabel(label) {
|
|
391
409
|
return label.trim().toLowerCase();
|
|
392
410
|
}
|
|
411
|
+
function isDestructiveAction(element, config) {
|
|
412
|
+
const label = normalizeCandidateLabel(element.name);
|
|
413
|
+
const deniedLabels = config.docs?.deniedActionLabels || DEFAULT_DENIED_ACTION_LABELS;
|
|
414
|
+
if (deniedLabels.some((denied) => label.includes(normalizeCandidateLabel(denied)))) {
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
const locator = `${element.css || ''} ${element.xpath || ''}`.toLowerCase();
|
|
418
|
+
return deniedLabels.some((denied) => locator.includes(normalizeCandidateLabel(denied)));
|
|
419
|
+
}
|
|
420
|
+
function getPositiveConfigNumber(value, fallback) {
|
|
421
|
+
if (!value || value <= 0) {
|
|
422
|
+
return fallback;
|
|
423
|
+
}
|
|
424
|
+
return value;
|
|
425
|
+
}
|
|
393
426
|
function limitInlineText(text, maxLength) {
|
|
394
427
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
395
428
|
if (normalized.length <= maxLength) {
|
|
@@ -89,6 +89,9 @@ export function createDocsCommands(name = 'docs') {
|
|
|
89
89
|
includePaths: [],
|
|
90
90
|
excludePaths: [],
|
|
91
91
|
deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'],
|
|
92
|
+
deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'],
|
|
93
|
+
maxPrimaryCandidates: 3,
|
|
94
|
+
maxInteractions: 5,
|
|
92
95
|
minCanActions: 1,
|
|
93
96
|
minInteractiveElements: 3,
|
|
94
97
|
// prompt: 'Add domain-specific documentation guidance here',
|
|
@@ -110,6 +110,9 @@ class DocbotConfigParser {
|
|
|
110
110
|
includePaths: [],
|
|
111
111
|
excludePaths: [],
|
|
112
112
|
deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'],
|
|
113
|
+
deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'],
|
|
114
|
+
maxPrimaryCandidates: 3,
|
|
115
|
+
maxInteractions: 5,
|
|
113
116
|
minCanActions: 1,
|
|
114
117
|
minInteractiveElements: 3,
|
|
115
118
|
},
|
|
@@ -2,13 +2,14 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { ExplorBot } from "../../../src/explorbot.js";
|
|
4
4
|
import { normalizeUrl } from "../../../src/state-manager.js";
|
|
5
|
-
import { sanitizeFilename } from "../../../src/utils/strings.js";
|
|
6
5
|
import { tag } from "../../../src/utils/logger.js";
|
|
6
|
+
import { sanitizeFilename } from "../../../src/utils/strings.js";
|
|
7
7
|
import { Documentarian } from "./ai/documentarian.js";
|
|
8
8
|
import { DocbotConfigParser } from "./config.js";
|
|
9
9
|
import { renderPageDocumentation, renderSpecIndex } from "./docs-renderer.js";
|
|
10
10
|
import { getDocPageKey, shouldCrawlDocPath } from "./path-filter.js";
|
|
11
11
|
import { extractResearchNavigationTargets } from "./research-navigation.js";
|
|
12
|
+
import { captureDocumentationScreenshots } from "./screenshots.js";
|
|
12
13
|
class DocBot {
|
|
13
14
|
explorBot;
|
|
14
15
|
configParser;
|
|
@@ -105,7 +106,7 @@ class DocBot {
|
|
|
105
106
|
documented.add(pageKey);
|
|
106
107
|
continue;
|
|
107
108
|
}
|
|
108
|
-
const filePath = this.savePageDocumentation(state, documentation);
|
|
109
|
+
const filePath = await this.savePageDocumentation(state, documentation, research);
|
|
109
110
|
pages.push({
|
|
110
111
|
url: state.url,
|
|
111
112
|
title: state.title || '',
|
|
@@ -347,11 +348,22 @@ class DocBot {
|
|
|
347
348
|
const matches = [...research.matchAll(/\((\d+) elements?\)/g)];
|
|
348
349
|
return matches.reduce((sum, match) => sum + Number.parseInt(match[1], 10), 0);
|
|
349
350
|
}
|
|
350
|
-
savePageDocumentation(state, documentation) {
|
|
351
|
+
async savePageDocumentation(state, documentation, research) {
|
|
351
352
|
const pagePath = this.getPageFilePath(state.url);
|
|
352
|
-
|
|
353
|
+
const screenshots = await this.captureScreenshots(state, research, pagePath);
|
|
354
|
+
writeFileSync(pagePath, renderPageDocumentation(state, documentation, screenshots), 'utf8');
|
|
353
355
|
return pagePath;
|
|
354
356
|
}
|
|
357
|
+
async captureScreenshots(state, research, pagePath) {
|
|
358
|
+
if (!this.shouldUseScreenshots()) {
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
return captureDocumentationScreenshots(this.explorBot.getExplorer(), state, research, {
|
|
362
|
+
pageFilePath: pagePath,
|
|
363
|
+
screenshotsDir: this.getScreenshotsDir(),
|
|
364
|
+
config: this.config,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
355
367
|
saveIndex(startPath, pages, skipped, maxPages) {
|
|
356
368
|
const indexPath = path.join(this.configParser.getOutputDir(), 'spec.md');
|
|
357
369
|
writeFileSync(indexPath, renderSpecIndex(this.configParser.getOutputDir(), startPath, pages, skipped, maxPages), 'utf8');
|
|
@@ -360,6 +372,9 @@ class DocBot {
|
|
|
360
372
|
getPagesDir() {
|
|
361
373
|
return path.join(this.configParser.getOutputDir(), 'pages');
|
|
362
374
|
}
|
|
375
|
+
getScreenshotsDir() {
|
|
376
|
+
return path.join(this.configParser.getOutputDir(), 'screenshots');
|
|
377
|
+
}
|
|
363
378
|
getPageFilePath(pageUrl) {
|
|
364
379
|
const normalized = normalizeUrl(pageUrl || '/');
|
|
365
380
|
const baseName = sanitizeFilename(normalized || 'root');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
function renderPageDocumentation(state, documentation) {
|
|
2
|
+
function renderPageDocumentation(state, documentation, screenshots = []) {
|
|
3
3
|
const lines = [];
|
|
4
4
|
lines.push(`# ${state.url}`);
|
|
5
5
|
lines.push('');
|
|
@@ -11,6 +11,17 @@ function renderPageDocumentation(state, documentation) {
|
|
|
11
11
|
lines.push('');
|
|
12
12
|
lines.push(ensureSentence(documentation.summary));
|
|
13
13
|
lines.push('');
|
|
14
|
+
if (screenshots.length > 0) {
|
|
15
|
+
lines.push('## Screenshots');
|
|
16
|
+
lines.push('');
|
|
17
|
+
for (const screenshot of screenshots) {
|
|
18
|
+
lines.push(``);
|
|
19
|
+
if (screenshot.selector) {
|
|
20
|
+
lines.push(`Section: \`${screenshot.selector}\``);
|
|
21
|
+
}
|
|
22
|
+
lines.push('');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
14
25
|
const interactions = documentation.interactions;
|
|
15
26
|
if (interactions && interactions.length > 0) {
|
|
16
27
|
lines.push('## State Transitions');
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseResearchSections } from "../../../src/ai/researcher/parser.js";
|
|
4
|
+
import { safeFilename, sanitizeFilename } from "../../../src/utils/strings.js";
|
|
5
|
+
const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
|
|
6
|
+
export async function captureDocumentationScreenshots(explorer, state, research, options) {
|
|
7
|
+
const page = explorer.playwrightHelper?.page;
|
|
8
|
+
if (!page) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
mkdirSync(options.screenshotsDir, { recursive: true });
|
|
12
|
+
const screenshots = [];
|
|
13
|
+
const pageName = sanitizeFilename(state.url || 'page') || 'page';
|
|
14
|
+
const fullPage = await captureFullPageScreenshot(page, pageName, options);
|
|
15
|
+
if (fullPage) {
|
|
16
|
+
screenshots.push(fullPage);
|
|
17
|
+
}
|
|
18
|
+
const maxSections = getMaxSectionScreenshots(options.config);
|
|
19
|
+
for (const section of getScreenshotSections(research).slice(0, maxSections)) {
|
|
20
|
+
const screenshot = await captureSectionScreenshot(page, pageName, section, options);
|
|
21
|
+
if (!screenshot) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
screenshots.push(screenshot);
|
|
25
|
+
}
|
|
26
|
+
return screenshots;
|
|
27
|
+
}
|
|
28
|
+
export function getScreenshotSections(research) {
|
|
29
|
+
const sections = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const section of parseResearchSections(research)) {
|
|
32
|
+
if (!section.containerCss) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (section.elements.length === 0) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (seen.has(section.containerCss)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
seen.add(section.containerCss);
|
|
42
|
+
sections.push({
|
|
43
|
+
title: section.name,
|
|
44
|
+
selector: section.containerCss,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return sections;
|
|
48
|
+
}
|
|
49
|
+
async function captureFullPageScreenshot(page, pageName, options) {
|
|
50
|
+
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
|
|
51
|
+
try {
|
|
52
|
+
await page.screenshot({ path: filePath, fullPage: true });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
title: 'Page screenshot',
|
|
59
|
+
path: filePath,
|
|
60
|
+
relativePath: toMarkdownPath(options.pageFilePath, filePath),
|
|
61
|
+
kind: 'page',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function captureSectionScreenshot(page, pageName, section, options) {
|
|
65
|
+
const sectionName = sanitizeFilename(section.title) || 'section';
|
|
66
|
+
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${sectionName}`, '.png'));
|
|
67
|
+
try {
|
|
68
|
+
await page.locator(section.selector).first().screenshot({ path: filePath });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
title: section.title,
|
|
75
|
+
path: filePath,
|
|
76
|
+
relativePath: toMarkdownPath(options.pageFilePath, filePath),
|
|
77
|
+
kind: 'section',
|
|
78
|
+
selector: section.selector,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function getMaxSectionScreenshots(config) {
|
|
82
|
+
const configured = config.docs?.maxSectionScreenshots;
|
|
83
|
+
if (configured && configured > 0) {
|
|
84
|
+
return configured;
|
|
85
|
+
}
|
|
86
|
+
return DEFAULT_MAX_SECTION_SCREENSHOTS;
|
|
87
|
+
}
|
|
88
|
+
function toMarkdownPath(pageFilePath, assetPath) {
|
|
89
|
+
return path.relative(path.dirname(pageFilePath), assetPath).replaceAll('\\', '/');
|
|
90
|
+
}
|
package/dist/package.json
CHANGED
package/dist/src/action.js
CHANGED
|
@@ -10,11 +10,13 @@ import { ActionResult } from './action-result.js';
|
|
|
10
10
|
import { clearActivity, setActivity } from "./activity.js";
|
|
11
11
|
import { ConfigParser, outputPath } from './config.js';
|
|
12
12
|
import { Observability } from "./observability.js";
|
|
13
|
+
import { isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
|
|
13
14
|
import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
14
15
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
16
|
+
import { waitForPageReadiness } from "./utils/page-readiness.js";
|
|
15
17
|
import { safeFilename } from "./utils/strings.js";
|
|
16
|
-
import { isFatalBrowserError } from "./utils/browser-errors.js";
|
|
17
18
|
const debugLog = createDebug('explorbot:action');
|
|
19
|
+
const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
|
|
18
20
|
class Action {
|
|
19
21
|
actor;
|
|
20
22
|
stateManager;
|
|
@@ -63,17 +65,24 @@ class Action {
|
|
|
63
65
|
const timestamp = Date.now();
|
|
64
66
|
const page = this.playwrightHelper.page;
|
|
65
67
|
const frame = this.playwrightHelper.frame;
|
|
66
|
-
await page
|
|
67
|
-
await waitForUsablePageDom(page);
|
|
68
|
+
await this.waitForPageReadiness(page);
|
|
68
69
|
const grabAll = () => Promise.all([captureHtml(page, frame, this.actor), captureTitle(page, this.actor), this.captureBrowserLogs()]);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
let html = '';
|
|
71
|
+
let title = '';
|
|
72
|
+
let browserLogs = [];
|
|
73
|
+
for (let attempt = 1; attempt <= CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
[html, title, browserLogs] = await grabAll();
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (!isNavigationTransitionError(err))
|
|
80
|
+
throw err;
|
|
81
|
+
if (attempt === CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS)
|
|
82
|
+
throw err;
|
|
83
|
+
await this.waitForPageReadiness(page);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
77
86
|
const url = page?.url() || (await this.actor.grabCurrentUrl?.());
|
|
78
87
|
let screenshotFile = undefined;
|
|
79
88
|
const statesDir = outputPath('states');
|
|
@@ -399,6 +408,12 @@ class Action {
|
|
|
399
408
|
getActionResult() {
|
|
400
409
|
return this.actionResult;
|
|
401
410
|
}
|
|
411
|
+
async waitForPageReadiness(page) {
|
|
412
|
+
await waitForPageReadiness(page, {
|
|
413
|
+
timeout: this.config.playwright.waitForTimeout,
|
|
414
|
+
spinnerSelectors: this.config.playwright.spinnerSelectors,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
402
417
|
}
|
|
403
418
|
export default Action;
|
|
404
419
|
function errorToString(error) {
|
|
@@ -407,18 +422,6 @@ function errorToString(error) {
|
|
|
407
422
|
}
|
|
408
423
|
return error.message || error.toString();
|
|
409
424
|
}
|
|
410
|
-
async function waitForUsablePageDom(page) {
|
|
411
|
-
if (!page?.waitForFunction)
|
|
412
|
-
return;
|
|
413
|
-
await page
|
|
414
|
-
.waitForFunction(() => {
|
|
415
|
-
const body = document.body;
|
|
416
|
-
if (!body)
|
|
417
|
-
return false;
|
|
418
|
-
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
419
|
-
}, undefined, { timeout: 5000 })
|
|
420
|
-
.catch(() => { });
|
|
421
|
-
}
|
|
422
425
|
async function captureHtml(page, frame, actor) {
|
|
423
426
|
if (frame?.content)
|
|
424
427
|
return frame.content();
|
|
@@ -6,8 +6,9 @@ import { KnowledgeTracker } from "../../knowledge-tracker.js";
|
|
|
6
6
|
import { tag } from "../../utils/logger.js";
|
|
7
7
|
import { relativeToCwd } from "../../utils/next-steps.js";
|
|
8
8
|
import { safeFilename } from "../../utils/strings.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from "../../utils/step-analyzer.js";
|
|
10
|
+
import { ASSERTION_TOOLS } from "../tools.js";
|
|
11
|
+
import { escapeString, getExecutionLabel } from "./utils.js";
|
|
11
12
|
export function WithCodeceptJS(Base) {
|
|
12
13
|
return class extends Base {
|
|
13
14
|
toCode(conversation, scenario) {
|
|
@@ -3,10 +3,10 @@ import { z } from 'zod';
|
|
|
3
3
|
import { ActionResult } from "../../action-result.js";
|
|
4
4
|
import { Test } from "../../test-plan.js";
|
|
5
5
|
import { tag } from "../../utils/logger.js";
|
|
6
|
+
import { isCodeceptToolName, isNonReusableCode, mergeUniqueStepsByCode, stripComments, toReusableSessionStep } from "../../utils/step-analyzer.js";
|
|
6
7
|
import { extractStatePath } from "../../utils/url-matcher.js";
|
|
7
|
-
import { CODECEPT_TOOLS } from "../tools.js";
|
|
8
8
|
import { debugLog } from "./mixin.js";
|
|
9
|
-
import { getExecutionLabel
|
|
9
|
+
import { getExecutionLabel } from "./utils.js";
|
|
10
10
|
export function WithExperience(Base) {
|
|
11
11
|
return class extends Base {
|
|
12
12
|
async saveSession(task, initialState, conversation) {
|
|
@@ -16,11 +16,17 @@ export function WithExperience(Base) {
|
|
|
16
16
|
if (task instanceof Test) {
|
|
17
17
|
task.generatedCode = this.isPlaywrightFramework() ? await this.toPlaywrightCode(conversation, task.description) : this.toCode(conversation, task.description);
|
|
18
18
|
}
|
|
19
|
-
const
|
|
19
|
+
const conversationSteps = await this.extractSteps(toolExecutions);
|
|
20
|
+
const taskSteps = this.extractPassedCodeceptSteps(task);
|
|
21
|
+
const steps = mergeUniqueStepsByCode(conversationSteps, taskSteps);
|
|
20
22
|
const skipExperience = result === 'failed' || (task instanceof Test && (task.hasFailed || task.isSkipped));
|
|
21
23
|
if (!skipExperience) {
|
|
24
|
+
const hasExistingFlow = this.hasRelevantFlowExperience(initialState);
|
|
22
25
|
await this.detectRetryPatterns(toolExecutions, initialState);
|
|
23
|
-
|
|
26
|
+
let body = await this.curateFlow(steps, task, initialState);
|
|
27
|
+
if (!body.trim() && !hasExistingFlow) {
|
|
28
|
+
body = this.renderFlowFromSuccessfulSteps(steps, task);
|
|
29
|
+
}
|
|
24
30
|
if (body.trim()) {
|
|
25
31
|
const relatedUrls = this.extractVisitedUrls(toolExecutions, initialState.url || '');
|
|
26
32
|
this.experienceTracker.writeFlow(initialState, body, relatedUrls);
|
|
@@ -46,7 +52,7 @@ export function WithExperience(Base) {
|
|
|
46
52
|
async extractSteps(toolExecutions) {
|
|
47
53
|
const stepsWithDiffs = [];
|
|
48
54
|
for (const exec of toolExecutions) {
|
|
49
|
-
if (!
|
|
55
|
+
if (!isCodeceptToolName(exec.toolName))
|
|
50
56
|
continue;
|
|
51
57
|
if (!exec.output?.code)
|
|
52
58
|
continue;
|
|
@@ -65,6 +71,18 @@ export function WithExperience(Base) {
|
|
|
65
71
|
await this.analyzeDiscoveries(stepsWithDiffs);
|
|
66
72
|
return stepsWithDiffs.map((s) => s.step);
|
|
67
73
|
}
|
|
74
|
+
hasRelevantFlowExperience(state) {
|
|
75
|
+
return this.experienceTracker.getRelevantExperience(state).some((experience) => experience.content.includes('## FLOW:'));
|
|
76
|
+
}
|
|
77
|
+
extractPassedCodeceptSteps(task) {
|
|
78
|
+
const steps = [];
|
|
79
|
+
for (const step of Object.values(task.steps)) {
|
|
80
|
+
const sessionStep = toReusableSessionStep(step);
|
|
81
|
+
if (sessionStep)
|
|
82
|
+
steps.push(sessionStep);
|
|
83
|
+
}
|
|
84
|
+
return steps;
|
|
85
|
+
}
|
|
68
86
|
async curateFlow(steps, task, initialState) {
|
|
69
87
|
if (steps.length === 0)
|
|
70
88
|
return '';
|
|
@@ -167,13 +185,37 @@ export function WithExperience(Base) {
|
|
|
167
185
|
return '';
|
|
168
186
|
}
|
|
169
187
|
}
|
|
188
|
+
renderFlowFromSuccessfulSteps(steps, task) {
|
|
189
|
+
if (steps.length === 0)
|
|
190
|
+
return '';
|
|
191
|
+
const title = task.description.charAt(0).toLowerCase() + task.description.slice(1);
|
|
192
|
+
const blocks = steps
|
|
193
|
+
.filter((step) => step.code)
|
|
194
|
+
.map((step) => {
|
|
195
|
+
const lines = [`* ${step.message}`];
|
|
196
|
+
lines.push('');
|
|
197
|
+
lines.push('```js');
|
|
198
|
+
lines.push(stripComments(step.code || ''));
|
|
199
|
+
lines.push('```');
|
|
200
|
+
if (step.discovery) {
|
|
201
|
+
lines.push('');
|
|
202
|
+
for (const discovery of step.discovery.split('\n').filter((line) => line.trim())) {
|
|
203
|
+
lines.push(`> ${discovery.trim()}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return lines.join('\n');
|
|
207
|
+
});
|
|
208
|
+
if (blocks.length === 0)
|
|
209
|
+
return '';
|
|
210
|
+
return `## FLOW: ${title}\n\n${blocks.join('\n\n')}\n\n---\n`;
|
|
211
|
+
}
|
|
170
212
|
async detectRetryPatterns(toolExecutions, initialState) {
|
|
171
213
|
if (!this.experienceTracker || !this.stateManager)
|
|
172
214
|
return;
|
|
173
215
|
const failedByTool = new Map();
|
|
174
216
|
const candidates = [];
|
|
175
217
|
for (const exec of toolExecutions) {
|
|
176
|
-
if (!
|
|
218
|
+
if (!isCodeceptToolName(exec.toolName))
|
|
177
219
|
continue;
|
|
178
220
|
if (!exec.output?.code)
|
|
179
221
|
continue;
|
|
@@ -7,7 +7,8 @@ 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
9
|
import { safeFilename } from "../../utils/strings.js";
|
|
10
|
-
import {
|
|
10
|
+
import { CODECEPT_TOOLS } from "../../utils/step-analyzer.js";
|
|
11
|
+
import { ASSERTION_TOOLS } from "../tools.js";
|
|
11
12
|
import { escapeString, getExecutionLabel } from "./utils.js";
|
|
12
13
|
const PLAYWRIGHT_EMITTED_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
|
|
13
14
|
export function WithPlaywright(Base) {
|
|
@@ -1,25 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
export function isNonReusableCode(code) {
|
|
3
|
-
if (/\bI\.clickXY\s*\(/.test(code))
|
|
4
|
-
return true;
|
|
5
|
-
for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
|
|
6
|
-
if (isDynamicId(m[1]))
|
|
7
|
-
return true;
|
|
8
|
-
}
|
|
9
|
-
return false;
|
|
10
|
-
}
|
|
1
|
+
export { isNonReusableCode, stripComments } from "../../utils/step-analyzer.js";
|
|
11
2
|
export function escapeString(str) {
|
|
12
3
|
return str.replace(/'/g, "\\'").replace(/\n/g, ' ');
|
|
13
4
|
}
|
|
14
|
-
export function stripComments(code) {
|
|
15
|
-
return code
|
|
16
|
-
.split('\n')
|
|
17
|
-
.filter((line) => {
|
|
18
|
-
const trimmed = line.trim();
|
|
19
|
-
return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
|
|
20
|
-
})
|
|
21
|
-
.join('\n');
|
|
22
|
-
}
|
|
23
5
|
export function getExecutionLabel(exec, fallback) {
|
|
24
6
|
return exec.input?.explanation || exec.input?.assertion || exec.input?.note || fallback || '';
|
|
25
7
|
}
|
package/dist/src/ai/historian.js
CHANGED
|
@@ -6,7 +6,7 @@ import { WithCodeceptJS } from "./historian/codeceptjs.js";
|
|
|
6
6
|
import { WithExperience } from "./historian/experience.js";
|
|
7
7
|
import { WithPlaywright } from "./historian/playwright.js";
|
|
8
8
|
import { WithScreencast } from "./historian/screencast.js";
|
|
9
|
-
export { isNonReusableCode } from "
|
|
9
|
+
export { isNonReusableCode } from "../utils/step-analyzer.js";
|
|
10
10
|
const HistorianBase = WithScreencast(WithPlaywright(WithCodeceptJS(WithExperience(Object))));
|
|
11
11
|
export class Historian extends HistorianBase {
|
|
12
12
|
constructor(provider, experienceTracker, reporter, stateManager, config, playwright) {
|
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ConfigParser } from "../config.js";
|
|
5
5
|
import { createDebug, tag } from "../utils/logger.js";
|
|
6
|
-
import {
|
|
6
|
+
import { isCodeceptToolName } from "../utils/step-analyzer.js";
|
|
7
7
|
const debugLog = createDebug('explorbot:quartermaster');
|
|
8
8
|
export class Quartermaster {
|
|
9
9
|
provider;
|
|
@@ -88,7 +88,7 @@ export class Quartermaster {
|
|
|
88
88
|
const stateHash = initialState.getStateHash();
|
|
89
89
|
const pageAnalysis = this.pageAnalyses.get(stateHash);
|
|
90
90
|
const toolExecutions = conversation.getToolExecutions();
|
|
91
|
-
const codeceptExecutions = toolExecutions.filter((e) =>
|
|
91
|
+
const codeceptExecutions = toolExecutions.filter((e) => isCodeceptToolName(e.toolName));
|
|
92
92
|
if (codeceptExecutions.length === 0 && !pageAnalysis?.axeViolations.length) {
|
|
93
93
|
debugLog('No interactions or violations to analyze');
|
|
94
94
|
return null;
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -767,6 +767,7 @@ export class Tester extends TaskAgent {
|
|
|
767
767
|
}
|
|
768
768
|
buildScenarioBlock(task, actionResult) {
|
|
769
769
|
const knowledge = this.getKnowledge(actionResult);
|
|
770
|
+
const experience = this.getExperience(actionResult);
|
|
770
771
|
return dedent `
|
|
771
772
|
<task>
|
|
772
773
|
SCENARIO GOAL: ${task.scenario}
|
|
@@ -796,6 +797,8 @@ export class Tester extends TaskAgent {
|
|
|
796
797
|
${this.buildAvailableFiles()}
|
|
797
798
|
|
|
798
799
|
${knowledge}
|
|
800
|
+
|
|
801
|
+
${experience}
|
|
799
802
|
`;
|
|
800
803
|
}
|
|
801
804
|
getDeletableSessionNames(task) {
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -11,7 +11,6 @@ import { WebElement } from "../utils/web-element.js";
|
|
|
11
11
|
import { sectionContextRule } from "./rules.js";
|
|
12
12
|
import { isInteractive } from "./task-agent.js";
|
|
13
13
|
const debugLog = createDebug('explorbot:tools');
|
|
14
|
-
export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'];
|
|
15
14
|
export const ASSERTION_TOOLS = ['verify'];
|
|
16
15
|
export function createCodeceptJSTools(explorer, task) {
|
|
17
16
|
const stateManager = explorer.getStateManager();
|