explorbot 0.2.0 → 0.2.2
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 +21 -5
- package/bin/explorbot-cli.ts +29 -7
- package/boat/api-tester/src/config.ts +45 -3
- package/boat/doc-collector/src/ai/documentarian.ts +8 -2
- package/boat/doc-collector/src/ai/tools.ts +29 -16
- package/boat/doc-collector/src/cli.ts +2 -6
- package/boat/doc-collector/src/config.ts +2 -0
- package/boat/doc-collector/src/docbot.ts +42 -14
- package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
- package/boat/doc-collector/src/screenshots.ts +24 -16
- package/dist/bin/explorbot-cli.js +26 -6
- package/dist/boat/api-tester/src/config.js +43 -4
- package/dist/boat/doc-collector/src/ai/documentarian.js +7 -2
- package/dist/boat/doc-collector/src/ai/tools.js +21 -15
- package/dist/boat/doc-collector/src/cli.js +2 -5
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +39 -13
- package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
- package/dist/boat/doc-collector/src/screenshots.js +25 -16
- package/dist/models.json +30 -0
- package/dist/package.json +8 -2
- package/dist/src/action.d.ts +11 -3
- package/dist/src/action.js +22 -5
- package/dist/src/ai/agent.d.ts +17 -0
- package/dist/src/ai/captain/idle-mode.js +1 -1
- package/dist/src/ai/captain/test-mode.js +1 -1
- package/dist/src/ai/captain/web-mode.js +18 -22
- package/dist/src/ai/captain.js +13 -26
- package/dist/src/ai/driller.d.ts +2 -13
- package/dist/src/ai/driller.js +23 -38
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.d.ts +3 -2
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/historian/screencast.d.ts +2 -1
- package/dist/src/ai/historian/screencast.js +2 -2
- package/dist/src/ai/historian.d.ts +5 -4
- package/dist/src/ai/navigator.d.ts +6 -2
- package/dist/src/ai/navigator.js +26 -22
- package/dist/src/ai/pilot.d.ts +13 -6
- package/dist/src/ai/pilot.js +49 -44
- package/dist/src/ai/planner.d.ts +3 -2
- package/dist/src/ai/planner.js +19 -17
- package/dist/src/ai/provider.js +0 -1
- package/dist/src/ai/quartermaster.d.ts +3 -2
- package/dist/src/ai/quartermaster.js +4 -4
- package/dist/src/ai/rerunner.d.ts +2 -11
- package/dist/src/ai/rerunner.js +9 -22
- package/dist/src/ai/researcher/coordinates.js +4 -3
- package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
- package/dist/src/ai/researcher/deep-analysis.js +6 -6
- package/dist/src/ai/researcher/locators.d.ts +9 -2
- package/dist/src/ai/researcher/locators.js +65 -8
- package/dist/src/ai/researcher/sections.d.ts +2 -0
- package/dist/src/ai/researcher/sections.js +2 -2
- package/dist/src/ai/researcher.js +29 -54
- package/dist/src/ai/task-agent.d.ts +18 -5
- package/dist/src/ai/task-agent.js +35 -6
- package/dist/src/ai/tester.d.ts +6 -12
- package/dist/src/ai/tester.js +37 -51
- package/dist/src/ai/tools.d.ts +11 -10
- package/dist/src/ai/tools.js +24 -36
- package/dist/src/command-handler.js +1 -1
- package/dist/src/commands/context-aria-command.js +1 -1
- package/dist/src/commands/context-command.js +7 -5
- package/dist/src/commands/context-data-command.js +2 -2
- package/dist/src/commands/context-experience-command.js +2 -2
- package/dist/src/commands/context-html-command.js +2 -2
- package/dist/src/commands/context-knowledge-command.js +1 -1
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.js +4 -3
- package/dist/src/commands/freesail-command.js +2 -2
- package/dist/src/commands/learn-command.js +4 -4
- package/dist/src/commands/path-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/test-command.js +1 -1
- package/dist/src/components/App.js +1 -1
- package/dist/src/config.d.ts +17 -0
- package/dist/src/config.js +144 -10
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.d.ts +17 -8
- package/dist/src/explorbot.js +74 -36
- package/dist/src/explorer.d.ts +67 -91
- package/dist/src/explorer.js +327 -567
- package/dist/src/state-manager.d.ts +6 -1
- package/dist/src/state-manager.js +1 -0
- package/dist/src/utils/hooks-runner.js +2 -4
- package/dist/src/utils/test-files.js +1 -1
- package/dist/src/utils/web-annotate.d.ts +5 -0
- package/dist/src/utils/web-annotate.js +58 -0
- package/dist/src/utils/web-eidx.d.ts +2 -0
- package/dist/src/utils/web-eidx.js +20 -0
- package/dist/src/utils/web-element.d.ts +1 -0
- package/dist/src/utils/web-element.js +29 -0
- package/models.json +30 -0
- package/package.json +8 -2
- package/src/action.ts +25 -5
- package/src/ai/agent.ts +20 -0
- package/src/ai/captain/idle-mode.ts +1 -1
- package/src/ai/captain/test-mode.ts +1 -1
- package/src/ai/captain/web-mode.ts +18 -23
- package/src/ai/captain.ts +12 -25
- package/src/ai/driller.ts +50 -67
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +4 -3
- package/src/ai/historian/screencast.ts +4 -3
- package/src/ai/historian.ts +3 -2
- package/src/ai/navigator.ts +30 -25
- package/src/ai/pilot.ts +55 -47
- package/src/ai/planner.ts +22 -20
- package/src/ai/provider.ts +0 -1
- package/src/ai/quartermaster.ts +5 -4
- package/src/ai/rerunner.ts +10 -29
- package/src/ai/researcher/coordinates.ts +4 -3
- package/src/ai/researcher/deep-analysis.ts +8 -6
- package/src/ai/researcher/locators.ts +66 -9
- package/src/ai/researcher/sections.ts +4 -2
- package/src/ai/researcher.ts +32 -61
- package/src/ai/task-agent.ts +45 -11
- package/src/ai/tester.ts +40 -57
- package/src/ai/tools.ts +32 -53
- package/src/command-handler.ts +1 -1
- package/src/commands/context-aria-command.ts +1 -1
- package/src/commands/context-command.ts +7 -5
- package/src/commands/context-data-command.ts +2 -2
- package/src/commands/context-experience-command.ts +2 -2
- package/src/commands/context-html-command.ts +2 -2
- package/src/commands/context-knowledge-command.ts +1 -1
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +4 -3
- package/src/commands/freesail-command.ts +2 -2
- package/src/commands/learn-command.ts +4 -4
- package/src/commands/path-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/test-command.ts +1 -1
- package/src/components/App.tsx +1 -1
- package/src/config.ts +173 -11
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +78 -39
- package/src/explorer.ts +388 -626
- package/src/state-manager.ts +7 -1
- package/src/utils/hooks-runner.ts +2 -4
- package/src/utils/test-files.ts +1 -1
- package/src/utils/web-annotate.ts +64 -0
- package/src/utils/web-eidx.ts +21 -0
- package/src/utils/web-element.ts +30 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import pixelmatch from 'pixelmatch';
|
|
3
|
+
import type { Page } from 'playwright';
|
|
4
|
+
import { PNG } from 'pngjs';
|
|
5
|
+
|
|
6
|
+
const REGION_PADDING = 30;
|
|
7
|
+
const SCREENSHOT_OPTIONS = { animations: 'disabled', caret: 'hide' } as const;
|
|
8
|
+
|
|
9
|
+
export async function captureInteractionBefore(page: Page): Promise<Buffer | null> {
|
|
10
|
+
await removeVisualAnnotations(page);
|
|
11
|
+
try {
|
|
12
|
+
return await page.screenshot(SCREENSHOT_OPTIONS);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function captureInteractionAfter(page: Page, beforeScreenshot: Buffer | null, filePath: string, detectUnmarkedOverlay = false): Promise<InteractionCaptureResult> {
|
|
19
|
+
if (!beforeScreenshot) return 'failed';
|
|
20
|
+
|
|
21
|
+
await removeVisualAnnotations(page);
|
|
22
|
+
try {
|
|
23
|
+
const afterScreenshot = await page.screenshot(SCREENSHOT_OPTIONS);
|
|
24
|
+
const before = PNG.sync.read(beforeScreenshot);
|
|
25
|
+
const after = PNG.sync.read(afterScreenshot);
|
|
26
|
+
if (before.width !== after.width || before.height !== after.height) return 'failed';
|
|
27
|
+
const changedPixels = findChangedPixelBounds(before, after);
|
|
28
|
+
if (!changedPixels) return 'unchanged';
|
|
29
|
+
const fullViewportChanged = changedPixels.x === 0 && changedPixels.y === 0 && changedPixels.width === after.width && changedPixels.height === after.height;
|
|
30
|
+
const changedRegion = addPadding(changedPixels, after.width, after.height);
|
|
31
|
+
const overlayRegion = fullViewportChanged ? await findOverlayRegion(page, after, detectUnmarkedOverlay) : null;
|
|
32
|
+
saveRegion(after, overlayRegion || changedRegion, filePath);
|
|
33
|
+
return 'captured';
|
|
34
|
+
} catch {
|
|
35
|
+
return 'failed';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function findChangedRegion(beforeScreenshot: Buffer, afterScreenshot: Buffer, padding = REGION_PADDING): ScreenshotRegion | null {
|
|
40
|
+
const before = PNG.sync.read(beforeScreenshot);
|
|
41
|
+
const after = PNG.sync.read(afterScreenshot);
|
|
42
|
+
if (before.width !== after.width || before.height !== after.height) return null;
|
|
43
|
+
const changedPixels = findChangedPixelBounds(before, after);
|
|
44
|
+
return changedPixels ? addPadding(changedPixels, before.width, before.height, padding) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function saveRegion(after: PNG, region: ScreenshotRegion, filePath: string): void {
|
|
48
|
+
const cropped = new PNG({ width: region.width, height: region.height });
|
|
49
|
+
PNG.bitblt(after, cropped, region.x, region.y, region.width, region.height, 0, 0);
|
|
50
|
+
writeFileSync(filePath, PNG.sync.write(cropped));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findChangedPixelBounds(before: PNG, after: PNG): ScreenshotRegion | null {
|
|
54
|
+
const diff = Buffer.alloc(before.width * before.height * 4);
|
|
55
|
+
const changedPixels = pixelmatch(before.data, after.data, diff, before.width, before.height, { diffMask: true });
|
|
56
|
+
if (changedPixels === 0) return null;
|
|
57
|
+
|
|
58
|
+
let left = before.width;
|
|
59
|
+
let top = before.height;
|
|
60
|
+
let right = 0;
|
|
61
|
+
let bottom = 0;
|
|
62
|
+
|
|
63
|
+
for (let y = 0; y < before.height; y++) {
|
|
64
|
+
for (let x = 0; x < before.width; x++) {
|
|
65
|
+
if (diff[(y * before.width + x) * 4 + 3] === 0) continue;
|
|
66
|
+
left = Math.min(left, x);
|
|
67
|
+
top = Math.min(top, y);
|
|
68
|
+
right = Math.max(right, x);
|
|
69
|
+
bottom = Math.max(bottom, y);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function addPadding(region: ScreenshotRegion, imageWidth: number, imageHeight: number, padding = REGION_PADDING): ScreenshotRegion {
|
|
77
|
+
const x = Math.max(0, region.x - padding);
|
|
78
|
+
const y = Math.max(0, region.y - padding);
|
|
79
|
+
const maxX = Math.min(imageWidth, region.x + region.width + padding);
|
|
80
|
+
const maxY = Math.min(imageHeight, region.y + region.height + padding);
|
|
81
|
+
return { x, y, width: maxX - x, height: maxY - y };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function removeVisualAnnotations(page: Page): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
|
|
87
|
+
for (const element of elements) element.remove();
|
|
88
|
+
});
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function findOverlayRegion(page: Page, image: PNG, detectUnmarkedOverlay: boolean): Promise<ScreenshotRegion | null> {
|
|
93
|
+
let box: { x: number; y: number; width: number; height: number } | null = null;
|
|
94
|
+
try {
|
|
95
|
+
const dialogs = page.locator('[role="dialog"]:visible, [role="alertdialog"]:visible, [aria-modal="true"]:visible');
|
|
96
|
+
if ((await dialogs.count()) > 0) box = await dialogs.last().boundingBox();
|
|
97
|
+
} catch {}
|
|
98
|
+
|
|
99
|
+
if (!box && detectUnmarkedOverlay) {
|
|
100
|
+
try {
|
|
101
|
+
box = await findUnmarkedOverlay(page);
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const viewport = page.viewportSize();
|
|
107
|
+
if (!box || !viewport) return null;
|
|
108
|
+
|
|
109
|
+
const scaleX = image.width / viewport.width;
|
|
110
|
+
const scaleY = image.height / viewport.height;
|
|
111
|
+
const x = Math.max(0, Math.floor(box.x * scaleX) - REGION_PADDING);
|
|
112
|
+
const y = Math.max(0, Math.floor(box.y * scaleY) - REGION_PADDING);
|
|
113
|
+
const maxX = Math.min(image.width, Math.ceil((box.x + box.width) * scaleX) + REGION_PADDING);
|
|
114
|
+
const maxY = Math.min(image.height, Math.ceil((box.y + box.height) * scaleY) + REGION_PADDING);
|
|
115
|
+
if (maxX <= x || maxY <= y) return null;
|
|
116
|
+
return { x, y, width: maxX - x, height: maxY - y };
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function findUnmarkedOverlay(page: Page): Promise<{ x: number; y: number; width: number; height: number } | null> {
|
|
123
|
+
return page.evaluate(() => {
|
|
124
|
+
const elements = [...document.body.querySelectorAll('*')].map((element) => {
|
|
125
|
+
const style = getComputedStyle(element);
|
|
126
|
+
const box = element.getBoundingClientRect();
|
|
127
|
+
const zIndex = Number.parseInt(style.zIndex, 10);
|
|
128
|
+
return { element, style, box, zIndex, area: box.width * box.height };
|
|
129
|
+
});
|
|
130
|
+
const isVisibleLayer = ({ style, box, zIndex }: (typeof elements)[number]) => {
|
|
131
|
+
if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) return false;
|
|
132
|
+
if (box.width <= 0 || box.height <= 0) return false;
|
|
133
|
+
if (style.position !== 'fixed' && style.position !== 'absolute') return false;
|
|
134
|
+
return Number.isFinite(zIndex);
|
|
135
|
+
};
|
|
136
|
+
const backdropZIndex = elements.filter((item) => isVisibleLayer(item) && item.box.width >= window.innerWidth && item.box.height >= window.innerHeight).reduce((highest, item) => Math.max(highest, item.zIndex), Number.NEGATIVE_INFINITY);
|
|
137
|
+
if (!Number.isFinite(backdropZIndex)) return null;
|
|
138
|
+
|
|
139
|
+
const candidates = elements
|
|
140
|
+
.filter((item) => {
|
|
141
|
+
if (!isVisibleLayer(item)) return false;
|
|
142
|
+
if (item.box.width >= window.innerWidth && item.box.height >= window.innerHeight) return false;
|
|
143
|
+
if (item.zIndex < backdropZIndex) return false;
|
|
144
|
+
return item.element.matches('button, input, select, textarea, a[href]') || !!item.element.querySelector('button, input, select, textarea, a[href]');
|
|
145
|
+
})
|
|
146
|
+
.sort((left, right) => right.zIndex - left.zIndex || left.area - right.area);
|
|
147
|
+
const box = candidates[0]?.box;
|
|
148
|
+
if (!box) return null;
|
|
149
|
+
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ScreenshotRegion {
|
|
154
|
+
x: number;
|
|
155
|
+
y: number;
|
|
156
|
+
width: number;
|
|
157
|
+
height: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type InteractionCaptureResult = 'captured' | 'unchanged' | 'failed';
|
|
@@ -3,15 +3,15 @@ import path from 'node:path';
|
|
|
3
3
|
import { parseResearchSections } from '../../../src/ai/researcher/parser.ts';
|
|
4
4
|
import type Explorer from '../../../src/explorer.ts';
|
|
5
5
|
import type { WebPageState } from '../../../src/state-manager.ts';
|
|
6
|
-
import { detectFocusArea } from '../../../src/utils/aria.ts';
|
|
7
6
|
import { safeFilename, sanitizeFilename } from '../../../src/utils/strings.ts';
|
|
8
7
|
import type { DocStateTransition } from './ai/tools.ts';
|
|
9
8
|
import type { DocbotConfig } from './config.ts';
|
|
9
|
+
import { captureInteractionAfter, captureInteractionBefore } from './interaction-screenshots.ts';
|
|
10
10
|
|
|
11
11
|
const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
|
|
12
12
|
|
|
13
13
|
export async function captureDocumentationScreenshots(explorer: Explorer, state: WebPageState, research: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot[]> {
|
|
14
|
-
const page = explorer.
|
|
14
|
+
const page = explorer.page;
|
|
15
15
|
if (!page) {
|
|
16
16
|
return [];
|
|
17
17
|
}
|
|
@@ -61,8 +61,8 @@ export function getScreenshotSections(research: string): ScreenshotSection[] {
|
|
|
61
61
|
return sections;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export async function captureInteractionScreenshot(explorer: Explorer, state: WebPageState, transition: DocStateTransition, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
|
|
65
|
-
const page = explorer.
|
|
64
|
+
export async function captureInteractionScreenshot(explorer: Explorer, beforeScreenshot: Buffer | null, state: WebPageState, transition: DocStateTransition, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
|
|
65
|
+
const page = explorer.page;
|
|
66
66
|
if (!page) {
|
|
67
67
|
return null;
|
|
68
68
|
}
|
|
@@ -70,18 +70,11 @@ export async function captureInteractionScreenshot(explorer: Explorer, state: We
|
|
|
70
70
|
mkdirSync(options.screenshotsDir, { recursive: true });
|
|
71
71
|
const pageName = sanitizeFilename(state.url || 'page') || 'page';
|
|
72
72
|
const stateName = sanitizeFilename(transition.targetState?.label || transition.action) || 'state';
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await page.locator('[role="dialog"], [role="alertdialog"], [aria-modal="true"]').last().screenshot({ path: filePath });
|
|
79
|
-
} else {
|
|
80
|
-
await page.screenshot({ path: filePath });
|
|
81
|
-
}
|
|
82
|
-
} catch {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
73
|
+
const stateId = state.id ? `_${state.id}` : '';
|
|
74
|
+
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}${stateId}`, '.png'));
|
|
75
|
+
const result = await captureInteractionAfter(page, beforeScreenshot, filePath, transition.changes?.urlChanged !== true);
|
|
76
|
+
if (result === 'unchanged') return null;
|
|
77
|
+
if (result === 'failed' && !(await captureViewport(page, filePath))) return null;
|
|
85
78
|
|
|
86
79
|
return {
|
|
87
80
|
title: transition.targetState?.label || transition.action,
|
|
@@ -91,6 +84,21 @@ export async function captureInteractionScreenshot(explorer: Explorer, state: We
|
|
|
91
84
|
};
|
|
92
85
|
}
|
|
93
86
|
|
|
87
|
+
export async function captureBeforeInteraction(explorer: Explorer): Promise<Buffer | null> {
|
|
88
|
+
const page = explorer.page;
|
|
89
|
+
if (!page) return null;
|
|
90
|
+
return captureInteractionBefore(page);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function captureViewport(page: any, filePath: string): Promise<boolean> {
|
|
94
|
+
try {
|
|
95
|
+
await page.screenshot({ path: filePath });
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
async function captureFullPageScreenshot(page: any, pageName: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
|
|
95
103
|
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
|
|
96
104
|
try {
|
|
@@ -9,7 +9,7 @@ import { render } from 'ink';
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { App } from '../src/components/App.js';
|
|
11
11
|
import { StatusPane } from '../src/components/StatusPane.js';
|
|
12
|
-
import { ConfigParser } from '../src/config.js';
|
|
12
|
+
import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
|
|
13
13
|
import { ExplorBot } from '../src/explorbot.js';
|
|
14
14
|
import { Stats } from '../src/stats.js';
|
|
15
15
|
import { Plan } from '../src/test-plan.js';
|
|
@@ -27,7 +27,6 @@ if (!process.env.EXPLORBOT_NO_BANNER) {
|
|
|
27
27
|
console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
|
|
28
28
|
}
|
|
29
29
|
function buildExplorBotOptions(from, options) {
|
|
30
|
-
const sessionFile = options.session === true ? path.join(path.resolve(options.path || process.cwd()), 'output', 'session.json') : options.session;
|
|
31
30
|
return {
|
|
32
31
|
from,
|
|
33
32
|
verbose: options.verbose || options.debug,
|
|
@@ -36,7 +35,7 @@ function buildExplorBotOptions(from, options) {
|
|
|
36
35
|
show: options.show,
|
|
37
36
|
headless: options.headless,
|
|
38
37
|
incognito: options.incognito,
|
|
39
|
-
session:
|
|
38
|
+
session: options.session,
|
|
40
39
|
};
|
|
41
40
|
}
|
|
42
41
|
function addCommonOptions(cmd) {
|
|
@@ -623,7 +622,7 @@ program
|
|
|
623
622
|
path: options.path,
|
|
624
623
|
config: options.config,
|
|
625
624
|
headless: true,
|
|
626
|
-
session: options.session
|
|
625
|
+
session: options.session,
|
|
627
626
|
};
|
|
628
627
|
const explorBot = new ExplorBot(mainOptions);
|
|
629
628
|
await explorBot.start();
|
|
@@ -652,10 +651,10 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
|
|
|
652
651
|
const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
|
|
653
652
|
await explorBot.start();
|
|
654
653
|
await explorBot.agentNavigator().visit(url);
|
|
655
|
-
const action = explorBot.getExplorer().
|
|
654
|
+
const action = explorBot.getExplorer().action();
|
|
656
655
|
await action.execute(command);
|
|
657
656
|
log('Command executed successfully');
|
|
658
|
-
const state = explorBot.
|
|
657
|
+
const state = explorBot.stateManager().getCurrentState();
|
|
659
658
|
if (state)
|
|
660
659
|
log(`URL: ${state.url}`);
|
|
661
660
|
await explorBot.stop();
|
|
@@ -793,4 +792,25 @@ import { createApiCommands } from "../boat/api-tester/src/cli.js";
|
|
|
793
792
|
import { createDocsCommands } from "../boat/doc-collector/src/cli.js";
|
|
794
793
|
program.addCommand(createApiCommands('api'));
|
|
795
794
|
program.addCommand(createDocsCommands('docs'));
|
|
795
|
+
const envHelp = () => {
|
|
796
|
+
const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
|
|
797
|
+
const rows = EXPLORBOT_ENV_VARS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`).join('\n');
|
|
798
|
+
return `
|
|
799
|
+
Environment variables (config-free one-liner mode):
|
|
800
|
+
Set EXPLORBOT_AI_PROVIDER to run without an explorbot.config.js. A config file always wins.
|
|
801
|
+
|
|
802
|
+
${rows}
|
|
803
|
+
|
|
804
|
+
Providers: ${Object.keys(PROVIDERS).join(', ')}
|
|
805
|
+
Example:
|
|
806
|
+
EXPLORBOT_URL=https://app.example.com EXPLORBOT_AI_PROVIDER=openrouter \\
|
|
807
|
+
${cli} explore /login --max-tests 3
|
|
808
|
+
`;
|
|
809
|
+
};
|
|
810
|
+
const addEnvHelp = (cmd) => {
|
|
811
|
+
cmd.addHelpText('after', envHelp);
|
|
812
|
+
for (const sub of cmd.commands)
|
|
813
|
+
addEnvHelp(sub);
|
|
814
|
+
};
|
|
815
|
+
addEnvHelp(program);
|
|
796
816
|
program.parse();
|
|
@@ -9,7 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
10
10
|
import path, { resolve } from 'node:path';
|
|
11
11
|
import { parseEnv } from 'node:util';
|
|
12
|
-
import { EXPLORBOT_CONFIG_PATHS } from "../../../src/config.js";
|
|
12
|
+
import { EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from "../../../src/config.js";
|
|
13
13
|
export class ApibotConfigParser {
|
|
14
14
|
static instance;
|
|
15
15
|
config = null;
|
|
@@ -37,9 +37,13 @@ export class ApibotConfigParser {
|
|
|
37
37
|
ApibotConfigParser.loadEnv('.env');
|
|
38
38
|
const resolvedPath = options?.config || this.findConfigFile();
|
|
39
39
|
if (!resolvedPath) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
try {
|
|
41
|
+
return await this.loadEnvConfig();
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
if (options?.path && originalCwd !== process.cwd())
|
|
45
|
+
process.chdir(originalCwd);
|
|
46
|
+
}
|
|
43
47
|
}
|
|
44
48
|
try {
|
|
45
49
|
const configModule = await this.loadConfigModule(resolvedPath);
|
|
@@ -98,6 +102,41 @@ export class ApibotConfigParser {
|
|
|
98
102
|
mkdirSync(dirPath, { recursive: true });
|
|
99
103
|
}
|
|
100
104
|
}
|
|
105
|
+
async loadEnvConfig() {
|
|
106
|
+
const provider = process.env.EXPLORBOT_AI_PROVIDER;
|
|
107
|
+
const modelSpec = process.env.EXPLORBOT_AI_MODEL;
|
|
108
|
+
if (!provider && !modelSpec) {
|
|
109
|
+
throw new Error('No configuration file found. Create apibot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
|
|
110
|
+
}
|
|
111
|
+
if (modelSpec && !provider && !modelSpec.includes('/')) {
|
|
112
|
+
throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
|
|
113
|
+
}
|
|
114
|
+
const baseEndpoint = process.env.EXPLORBOT_URL;
|
|
115
|
+
if (!baseEndpoint) {
|
|
116
|
+
throw new Error('No API endpoint to test. Set EXPLORBOT_URL to the API base endpoint');
|
|
117
|
+
}
|
|
118
|
+
const outputRoot = resolveOutputRoot();
|
|
119
|
+
materializeKnowledge(outputRoot);
|
|
120
|
+
const api = { baseEndpoint };
|
|
121
|
+
if (process.env.EXPLORBOT_API_SPEC) {
|
|
122
|
+
api.spec = [process.env.EXPLORBOT_API_SPEC];
|
|
123
|
+
}
|
|
124
|
+
let model;
|
|
125
|
+
if (provider && modelSpec)
|
|
126
|
+
model = await createModel(provider, modelSpec);
|
|
127
|
+
if (provider && !modelSpec)
|
|
128
|
+
model = await resolveModel(provider, 'model');
|
|
129
|
+
if (!provider)
|
|
130
|
+
model = await resolveModel(modelSpec, 'model');
|
|
131
|
+
this.config = {
|
|
132
|
+
ai: { model },
|
|
133
|
+
api,
|
|
134
|
+
dirs: { output: '.', knowledge: 'knowledge' },
|
|
135
|
+
};
|
|
136
|
+
this.configPath = path.join(outputRoot, 'apibot.config.js');
|
|
137
|
+
this.validateConfig(this.config);
|
|
138
|
+
return this.config;
|
|
139
|
+
}
|
|
101
140
|
findConfigFile() {
|
|
102
141
|
const apibotPaths = ['apibot.config.js', 'apibot.config.mjs', 'apibot.config.ts'];
|
|
103
142
|
for (const p of apibotPaths) {
|
|
@@ -6,10 +6,12 @@ class Documentarian {
|
|
|
6
6
|
provider;
|
|
7
7
|
config;
|
|
8
8
|
explorer;
|
|
9
|
-
|
|
9
|
+
stateManager;
|
|
10
|
+
constructor(provider, config = {}, explorer, stateManager) {
|
|
10
11
|
this.provider = provider;
|
|
11
12
|
this.config = config;
|
|
12
13
|
this.explorer = explorer;
|
|
14
|
+
this.stateManager = stateManager;
|
|
13
15
|
}
|
|
14
16
|
async document(state, research, captureState) {
|
|
15
17
|
const interactiveEnabled = this.config.docs?.interactive === true && this.explorer;
|
|
@@ -35,7 +37,7 @@ class Documentarian {
|
|
|
35
37
|
let meaningfulInteractions = [];
|
|
36
38
|
try {
|
|
37
39
|
tag('info').log('Starting interactive exploration...');
|
|
38
|
-
const deterministicInteractions = await collectDocInteractions(this.explorer, state, research, this.config, captureState);
|
|
40
|
+
const deterministicInteractions = await collectDocInteractions(this.explorer, this.stateManager, state, research, this.config, captureState);
|
|
39
41
|
meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
|
|
40
42
|
if (meaningfulInteractions.length > 0) {
|
|
41
43
|
tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
|
|
@@ -75,6 +77,9 @@ class Documentarian {
|
|
|
75
77
|
if ((interaction.changes?.newElements || 0) > 0) {
|
|
76
78
|
return true;
|
|
77
79
|
}
|
|
80
|
+
if ((interaction.changes?.removedElements || 0) > 0) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
78
83
|
return (interaction.discoveredUrls || []).length > 0;
|
|
79
84
|
});
|
|
80
85
|
}
|
|
@@ -6,19 +6,19 @@ const MAX_LINKS = 15;
|
|
|
6
6
|
const DEFAULT_WAIT_MS = 700;
|
|
7
7
|
const TAB_WAIT_MS = 500;
|
|
8
8
|
const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];
|
|
9
|
-
export async function collectDocInteractions(explorer, state, research, config = {}, captureState) {
|
|
9
|
+
export async function collectDocInteractions(explorer, stateManager, state, research, config = {}, captureState) {
|
|
10
10
|
const sections = parseResearchSections(research);
|
|
11
11
|
const transitions = [];
|
|
12
12
|
const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
|
|
13
13
|
const tabGroup = findTabGroup(sections);
|
|
14
14
|
if (tabGroup) {
|
|
15
|
-
transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions, captureState)));
|
|
15
|
+
transitions.push(...(await exploreTabGroup(explorer, stateManager, tabGroup, state.url, maxInteractions, captureState)));
|
|
16
16
|
}
|
|
17
17
|
for (const candidate of findActionCandidates(sections, config)) {
|
|
18
18
|
if (transitions.length >= maxInteractions) {
|
|
19
19
|
break;
|
|
20
20
|
}
|
|
21
|
-
const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS, captureState);
|
|
21
|
+
const transition = await executeInteraction(explorer, stateManager, candidate, state.url, DEFAULT_WAIT_MS, captureState);
|
|
22
22
|
if (!transition) {
|
|
23
23
|
continue;
|
|
24
24
|
}
|
|
@@ -33,13 +33,13 @@ export function pickDocActionCandidates(research, config = {}) {
|
|
|
33
33
|
section: candidate.sectionName,
|
|
34
34
|
}));
|
|
35
35
|
}
|
|
36
|
-
async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions, captureState) {
|
|
36
|
+
async function exploreTabGroup(explorer, stateManager, tabGroup, restoreUrl, maxInteractions, captureState) {
|
|
37
37
|
const transitions = [];
|
|
38
38
|
for (const element of tabGroup.elements) {
|
|
39
39
|
if (transitions.length >= maxInteractions) {
|
|
40
40
|
break;
|
|
41
41
|
}
|
|
42
|
-
const transition = await executeInteraction(explorer, {
|
|
42
|
+
const transition = await executeInteraction(explorer, stateManager, {
|
|
43
43
|
element,
|
|
44
44
|
container: tabGroup.container,
|
|
45
45
|
role: 'tab',
|
|
@@ -53,17 +53,18 @@ async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions,
|
|
|
53
53
|
await restoreInteractionState(explorer, restoreUrl, buildPrimaryCommand(tabGroup.elements[0], tabGroup.container));
|
|
54
54
|
return transitions;
|
|
55
55
|
}
|
|
56
|
-
async function executeInteraction(explorer, candidate, restoreUrl, waitMs, captureState) {
|
|
57
|
-
const beforeState =
|
|
56
|
+
async function executeInteraction(explorer, stateManager, candidate, restoreUrl, waitMs, captureState) {
|
|
57
|
+
const beforeState = stateManager.getCurrentState();
|
|
58
58
|
if (!beforeState) {
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
|
+
const beforeScreenshot = await captureState?.before();
|
|
61
62
|
const executed = await attemptInteraction(explorer, candidate);
|
|
62
63
|
if (!executed) {
|
|
63
64
|
return null;
|
|
64
65
|
}
|
|
65
66
|
await wait(waitMs);
|
|
66
|
-
const afterState =
|
|
67
|
+
const afterState = stateManager.getCurrentState();
|
|
67
68
|
if (!afterState) {
|
|
68
69
|
return null;
|
|
69
70
|
}
|
|
@@ -75,18 +76,18 @@ async function executeInteraction(explorer, candidate, restoreUrl, waitMs, captu
|
|
|
75
76
|
removedElements: ariaChanges.removedCount,
|
|
76
77
|
});
|
|
77
78
|
if (captureState && isMeaningfulStateTransition(transition)) {
|
|
78
|
-
const screenshot = await captureState(afterState, transition);
|
|
79
|
+
const screenshot = await captureState.after(beforeScreenshot ?? null, afterState, transition);
|
|
79
80
|
if (screenshot) {
|
|
80
81
|
transition.screenshot = screenshot;
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
|
-
if (urlChanged || ariaChanges.newCount > 0) {
|
|
84
|
+
if (urlChanged || ariaChanges.newCount > 0 || ariaChanges.removedCount > 0) {
|
|
84
85
|
await restoreInteractionState(explorer, restoreUrl);
|
|
85
86
|
}
|
|
86
87
|
return transition;
|
|
87
88
|
}
|
|
88
89
|
async function attemptInteraction(explorer, candidate) {
|
|
89
|
-
const action = explorer.
|
|
90
|
+
const action = explorer.action();
|
|
90
91
|
for (const command of buildClickCommands(candidate.element, candidate.container)) {
|
|
91
92
|
const success = await action.attempt(command, buildPurpose(candidate));
|
|
92
93
|
if (success) {
|
|
@@ -97,22 +98,27 @@ async function attemptInteraction(explorer, candidate) {
|
|
|
97
98
|
}
|
|
98
99
|
async function restoreInteractionState(explorer, restoreUrl, primaryCommand) {
|
|
99
100
|
if (primaryCommand) {
|
|
100
|
-
const action = explorer.
|
|
101
|
+
const action = explorer.action();
|
|
101
102
|
const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`);
|
|
102
103
|
if (restored) {
|
|
103
104
|
await wait(TAB_WAIT_MS);
|
|
104
105
|
return;
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
|
-
const action = explorer.
|
|
108
|
+
const action = explorer.action();
|
|
108
109
|
await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`);
|
|
109
110
|
}
|
|
110
111
|
function buildTransition(candidate, beforeState, afterState, changes) {
|
|
112
|
+
const existingUrls = new Set(collectLinks(beforeState).map((link) => link.url));
|
|
111
113
|
const transition = {
|
|
112
114
|
action: describeAction(candidate),
|
|
113
115
|
before: summarizeInteractiveState(beforeState),
|
|
114
116
|
after: summarizeInteractiveState(afterState),
|
|
115
|
-
discoveredUrls:
|
|
117
|
+
discoveredUrls: [
|
|
118
|
+
...new Set(collectLinks(afterState)
|
|
119
|
+
.map((link) => link.url)
|
|
120
|
+
.filter((url) => !existingUrls.has(url))),
|
|
121
|
+
],
|
|
116
122
|
newCapabilities: collectDiscoveryNotes(afterState, changes),
|
|
117
123
|
element: buildInteractionElement(candidate),
|
|
118
124
|
changes,
|
|
@@ -150,7 +156,7 @@ function isMeaningfulStateTransition(transition) {
|
|
|
150
156
|
if (transition.targetUrl || transition.changes?.urlChanged) {
|
|
151
157
|
return true;
|
|
152
158
|
}
|
|
153
|
-
return (transition.changes?.newElements || 0) > 0;
|
|
159
|
+
return (transition.changes?.newElements || 0) > 0 || (transition.changes?.removedElements || 0) > 0;
|
|
154
160
|
}
|
|
155
161
|
function buildInteractionElement(candidate) {
|
|
156
162
|
const element = {
|
|
@@ -4,10 +4,6 @@ import { Command } from 'commander';
|
|
|
4
4
|
import { setPreserveConsoleLogs } from "../../../src/utils/logger.js";
|
|
5
5
|
import { DocBot } from "./docbot.js";
|
|
6
6
|
function buildOptions(options) {
|
|
7
|
-
let session = options.session;
|
|
8
|
-
if (options.session === true) {
|
|
9
|
-
session = 'output/session.json';
|
|
10
|
-
}
|
|
11
7
|
return {
|
|
12
8
|
verbose: options.verbose || options.debug,
|
|
13
9
|
config: options.config,
|
|
@@ -15,7 +11,7 @@ function buildOptions(options) {
|
|
|
15
11
|
show: options.show,
|
|
16
12
|
headless: options.headless,
|
|
17
13
|
incognito: options.incognito,
|
|
18
|
-
session,
|
|
14
|
+
session: options.session,
|
|
19
15
|
docsConfig: options.docsConfig,
|
|
20
16
|
};
|
|
21
17
|
}
|
|
@@ -84,6 +80,7 @@ export function createDocsCommands(name = 'docs') {
|
|
|
84
80
|
output: 'docs',
|
|
85
81
|
screenshot: true,
|
|
86
82
|
interactive: false,
|
|
83
|
+
ignoreErrors: true,
|
|
87
84
|
collapseDynamicPages: true,
|
|
88
85
|
scope: 'site',
|
|
89
86
|
includePaths: [],
|
|
@@ -7,10 +7,10 @@ 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
|
-
import { renderMermaidBody } from "./state-diagram.js";
|
|
11
10
|
import { getDocPageKey, shouldCrawlDocPath } from "./path-filter.js";
|
|
12
11
|
import { extractResearchNavigationTargets } from "./research-navigation.js";
|
|
13
|
-
import { captureDocumentationScreenshots, captureInteractionScreenshot } from "./screenshots.js";
|
|
12
|
+
import { captureBeforeInteraction, captureDocumentationScreenshots, captureInteractionScreenshot } from "./screenshots.js";
|
|
13
|
+
import { renderMermaidBody } from "./state-diagram.js";
|
|
14
14
|
class DocBot {
|
|
15
15
|
explorBot;
|
|
16
16
|
configParser;
|
|
@@ -39,7 +39,7 @@ class DocBot {
|
|
|
39
39
|
config: this.options.docsConfig,
|
|
40
40
|
path: this.options.path,
|
|
41
41
|
});
|
|
42
|
-
this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer());
|
|
42
|
+
this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer(), this.explorBot.stateManager());
|
|
43
43
|
this.ensureDirectory(this.configParser.getOutputDir());
|
|
44
44
|
this.ensureDirectory(this.getPagesDir());
|
|
45
45
|
}
|
|
@@ -66,7 +66,7 @@ class DocBot {
|
|
|
66
66
|
if (documented.has(targetKey)) {
|
|
67
67
|
continue;
|
|
68
68
|
}
|
|
69
|
-
const stateManager = this.explorBot.
|
|
69
|
+
const stateManager = this.explorBot.stateManager();
|
|
70
70
|
if (stateManager.hasVisitedState(target)) {
|
|
71
71
|
continue;
|
|
72
72
|
}
|
|
@@ -98,16 +98,17 @@ class DocBot {
|
|
|
98
98
|
force: true,
|
|
99
99
|
});
|
|
100
100
|
const pagePath = this.getPageFilePath(state.url);
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
const captureState = this.shouldUseScreenshots()
|
|
102
|
+
? {
|
|
103
|
+
before: () => captureBeforeInteraction(this.explorBot.getExplorer()),
|
|
104
|
+
after: (beforeScreenshot, interactionState, transition) => captureInteractionScreenshot(this.explorBot.getExplorer(), beforeScreenshot, interactionState, transition, {
|
|
105
|
+
pageFilePath: pagePath,
|
|
106
|
+
screenshotsDir: this.getScreenshotsDir(),
|
|
107
|
+
config: this.config,
|
|
108
|
+
}),
|
|
104
109
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
screenshotsDir: this.getScreenshotsDir(),
|
|
108
|
-
config: this.config,
|
|
109
|
-
});
|
|
110
|
-
});
|
|
110
|
+
: undefined;
|
|
111
|
+
const documentation = await this.documentarian.document(state, research, captureState);
|
|
111
112
|
const lowSignalReason = this.getLowSignalReason(documentation, research);
|
|
112
113
|
if (lowSignalReason) {
|
|
113
114
|
skipped.push({
|
|
@@ -147,6 +148,9 @@ class DocBot {
|
|
|
147
148
|
}
|
|
148
149
|
catch (error) {
|
|
149
150
|
const reason = error instanceof Error ? error.message : String(error);
|
|
151
|
+
if (!this.shouldIgnoreError(error)) {
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
150
154
|
tag('warning').log(`Skipping ${target}: ${reason}`);
|
|
151
155
|
skipped.push({
|
|
152
156
|
url: target,
|
|
@@ -360,6 +364,28 @@ class DocBot {
|
|
|
360
364
|
}
|
|
361
365
|
return `low-signal page: only ${documentation.can.length} proven actions and ${interactiveCount} interactive elements`;
|
|
362
366
|
}
|
|
367
|
+
shouldIgnoreError(error) {
|
|
368
|
+
const ignoreErrors = this.config.docs?.ignoreErrors;
|
|
369
|
+
if (ignoreErrors === undefined || ignoreErrors === true)
|
|
370
|
+
return true;
|
|
371
|
+
if (ignoreErrors === false)
|
|
372
|
+
return false;
|
|
373
|
+
const details = [error instanceof Error ? error.name : '', error instanceof Error ? error.message : String(error)];
|
|
374
|
+
if (typeof error === 'object' && error && 'code' in error) {
|
|
375
|
+
details.push(String(error.code));
|
|
376
|
+
}
|
|
377
|
+
const normalized = details
|
|
378
|
+
.join(' ')
|
|
379
|
+
.toLowerCase()
|
|
380
|
+
.replaceAll(/[\W_]+/g, ' ');
|
|
381
|
+
return ignoreErrors.some((pattern) => {
|
|
382
|
+
const normalizedPattern = pattern
|
|
383
|
+
.trim()
|
|
384
|
+
.toLowerCase()
|
|
385
|
+
.replaceAll(/[\W_]+/g, ' ');
|
|
386
|
+
return normalizedPattern.length > 0 && normalized.includes(normalizedPattern);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
363
389
|
countInteractiveElements(research) {
|
|
364
390
|
const matches = [...research.matchAll(/\((\d+) elements?\)/g)];
|
|
365
391
|
return matches.reduce((sum, match) => sum + Number.parseInt(match[1], 10), 0);
|