explorbot 0.2.1 → 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/boat/doc-collector/src/ai/documentarian.ts +3 -0
- package/boat/doc-collector/src/ai/tools.ts +17 -5
- package/boat/doc-collector/src/cli.ts +1 -0
- package/boat/doc-collector/src/config.ts +2 -0
- package/boat/doc-collector/src/docbot.ts +39 -11
- package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
- package/boat/doc-collector/src/screenshots.ts +22 -14
- package/dist/boat/doc-collector/src/ai/documentarian.js +3 -0
- package/dist/boat/doc-collector/src/ai/tools.js +10 -4
- package/dist/boat/doc-collector/src/cli.js +1 -0
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +36 -10
- package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
- package/dist/boat/doc-collector/src/screenshots.js +23 -14
- package/dist/package.json +4 -1
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/explorer.d.ts +1 -0
- package/dist/src/explorer.js +14 -13
- package/package.json +4 -1
- package/src/ai/researcher/locators.ts +1 -1
- package/src/explorer.ts +14 -13
|
@@ -49,7 +49,10 @@ export interface InteractionScreenshot {
|
|
|
49
49
|
relativePath: string;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export
|
|
52
|
+
export interface CaptureInteractionState {
|
|
53
|
+
before(): Promise<Buffer | null>;
|
|
54
|
+
after(beforeScreenshot: Buffer | null, state: WebPageState, transition: DocStateTransition): Promise<InteractionScreenshot | null>;
|
|
55
|
+
}
|
|
53
56
|
|
|
54
57
|
const DEFAULT_MAX_PRIMARY_CANDIDATES = 3;
|
|
55
58
|
const DEFAULT_MAX_INTERACTIONS = 5;
|
|
@@ -130,6 +133,8 @@ async function executeInteraction(explorer: Explorer, stateManager: StateManager
|
|
|
130
133
|
return null;
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
const beforeScreenshot = await captureState?.before();
|
|
137
|
+
|
|
133
138
|
const executed = await attemptInteraction(explorer, candidate);
|
|
134
139
|
if (!executed) {
|
|
135
140
|
return null;
|
|
@@ -151,13 +156,13 @@ async function executeInteraction(explorer: Explorer, stateManager: StateManager
|
|
|
151
156
|
});
|
|
152
157
|
|
|
153
158
|
if (captureState && isMeaningfulStateTransition(transition)) {
|
|
154
|
-
const screenshot = await captureState(afterState, transition);
|
|
159
|
+
const screenshot = await captureState.after(beforeScreenshot ?? null, afterState, transition);
|
|
155
160
|
if (screenshot) {
|
|
156
161
|
transition.screenshot = screenshot;
|
|
157
162
|
}
|
|
158
163
|
}
|
|
159
164
|
|
|
160
|
-
if (urlChanged || ariaChanges.newCount > 0) {
|
|
165
|
+
if (urlChanged || ariaChanges.newCount > 0 || ariaChanges.removedCount > 0) {
|
|
161
166
|
await restoreInteractionState(explorer, restoreUrl);
|
|
162
167
|
}
|
|
163
168
|
|
|
@@ -192,11 +197,18 @@ async function restoreInteractionState(explorer: Explorer, restoreUrl: string, p
|
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
function buildTransition(candidate: InteractionCandidate, beforeState: WebPageState, afterState: WebPageState, changes: InteractionChanges): DocStateTransition {
|
|
200
|
+
const existingUrls = new Set(collectLinks(beforeState).map((link) => link.url));
|
|
195
201
|
const transition: DocStateTransition = {
|
|
196
202
|
action: describeAction(candidate),
|
|
197
203
|
before: summarizeInteractiveState(beforeState),
|
|
198
204
|
after: summarizeInteractiveState(afterState),
|
|
199
|
-
discoveredUrls:
|
|
205
|
+
discoveredUrls: [
|
|
206
|
+
...new Set(
|
|
207
|
+
collectLinks(afterState)
|
|
208
|
+
.map((link) => link.url)
|
|
209
|
+
.filter((url) => !existingUrls.has(url))
|
|
210
|
+
),
|
|
211
|
+
],
|
|
200
212
|
newCapabilities: collectDiscoveryNotes(afterState, changes),
|
|
201
213
|
element: buildInteractionElement(candidate),
|
|
202
214
|
changes,
|
|
@@ -239,7 +251,7 @@ function isMeaningfulStateTransition(transition: DocStateTransition): boolean {
|
|
|
239
251
|
if (transition.targetUrl || transition.changes?.urlChanged) {
|
|
240
252
|
return true;
|
|
241
253
|
}
|
|
242
|
-
return (transition.changes?.newElements || 0) > 0;
|
|
254
|
+
return (transition.changes?.newElements || 0) > 0 || (transition.changes?.removedElements || 0) > 0;
|
|
243
255
|
}
|
|
244
256
|
|
|
245
257
|
function buildInteractionElement(candidate: InteractionCandidate): InteractionElement {
|
|
@@ -115,6 +115,7 @@ class DocbotConfigParser {
|
|
|
115
115
|
output: 'docs',
|
|
116
116
|
screenshot: true,
|
|
117
117
|
interactive: false,
|
|
118
|
+
ignoreErrors: true,
|
|
118
119
|
collapseDynamicPages: true,
|
|
119
120
|
scope: 'site',
|
|
120
121
|
includePaths: [],
|
|
@@ -151,6 +152,7 @@ interface DocbotConfig {
|
|
|
151
152
|
maxPages?: number;
|
|
152
153
|
output?: string;
|
|
153
154
|
screenshot?: boolean;
|
|
155
|
+
ignoreErrors?: boolean | string[];
|
|
154
156
|
prompt?: string;
|
|
155
157
|
collapseDynamicPages?: boolean;
|
|
156
158
|
scope?: 'site' | 'section' | 'subtree';
|
|
@@ -6,11 +6,12 @@ import { normalizeUrl } from '../../../src/state-manager.ts';
|
|
|
6
6
|
import { tag } from '../../../src/utils/logger.ts';
|
|
7
7
|
import { sanitizeFilename } from '../../../src/utils/strings.ts';
|
|
8
8
|
import { Documentarian, type PageDocumentation } from './ai/documentarian.ts';
|
|
9
|
+
import type { DocStateTransition } from './ai/tools.ts';
|
|
9
10
|
import { type DocbotConfig, DocbotConfigParser } from './config.ts';
|
|
10
11
|
import { type DocumentedPage, type SkippedPage, renderPageDocumentation, renderSpecIndex } from './docs-renderer.ts';
|
|
11
12
|
import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
|
|
12
13
|
import { extractResearchNavigationTargets } from './research-navigation.ts';
|
|
13
|
-
import { type DocumentationScreenshot, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
|
|
14
|
+
import { type DocumentationScreenshot, captureBeforeInteraction, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
|
|
14
15
|
import { renderMermaidBody } from './state-diagram.ts';
|
|
15
16
|
|
|
16
17
|
class DocBot {
|
|
@@ -113,16 +114,18 @@ class DocBot {
|
|
|
113
114
|
force: true,
|
|
114
115
|
});
|
|
115
116
|
const pagePath = this.getPageFilePath(state.url);
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
const captureState = this.shouldUseScreenshots()
|
|
118
|
+
? {
|
|
119
|
+
before: () => captureBeforeInteraction(this.explorBot.getExplorer()),
|
|
120
|
+
after: (beforeScreenshot: Buffer | null, interactionState: WebPageState, transition: DocStateTransition) =>
|
|
121
|
+
captureInteractionScreenshot(this.explorBot.getExplorer(), beforeScreenshot, interactionState, transition, {
|
|
122
|
+
pageFilePath: pagePath,
|
|
123
|
+
screenshotsDir: this.getScreenshotsDir(),
|
|
124
|
+
config: this.config,
|
|
125
|
+
}),
|
|
126
|
+
}
|
|
127
|
+
: undefined;
|
|
128
|
+
const documentation = await this.documentarian.document(state, research, captureState);
|
|
126
129
|
const lowSignalReason = this.getLowSignalReason(documentation, research);
|
|
127
130
|
if (lowSignalReason) {
|
|
128
131
|
skipped.push({
|
|
@@ -163,6 +166,9 @@ class DocBot {
|
|
|
163
166
|
}
|
|
164
167
|
} catch (error) {
|
|
165
168
|
const reason = error instanceof Error ? error.message : String(error);
|
|
169
|
+
if (!this.shouldIgnoreError(error)) {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
166
172
|
tag('warning').log(`Skipping ${target}: ${reason}`);
|
|
167
173
|
skipped.push({
|
|
168
174
|
url: target,
|
|
@@ -410,6 +416,28 @@ class DocBot {
|
|
|
410
416
|
return `low-signal page: only ${documentation.can.length} proven actions and ${interactiveCount} interactive elements`;
|
|
411
417
|
}
|
|
412
418
|
|
|
419
|
+
private shouldIgnoreError(error: unknown): boolean {
|
|
420
|
+
const ignoreErrors = this.config.docs?.ignoreErrors;
|
|
421
|
+
if (ignoreErrors === undefined || ignoreErrors === true) return true;
|
|
422
|
+
if (ignoreErrors === false) return false;
|
|
423
|
+
|
|
424
|
+
const details = [error instanceof Error ? error.name : '', error instanceof Error ? error.message : String(error)];
|
|
425
|
+
if (typeof error === 'object' && error && 'code' in error) {
|
|
426
|
+
details.push(String(error.code));
|
|
427
|
+
}
|
|
428
|
+
const normalized = details
|
|
429
|
+
.join(' ')
|
|
430
|
+
.toLowerCase()
|
|
431
|
+
.replaceAll(/[\W_]+/g, ' ');
|
|
432
|
+
return ignoreErrors.some((pattern) => {
|
|
433
|
+
const normalizedPattern = pattern
|
|
434
|
+
.trim()
|
|
435
|
+
.toLowerCase()
|
|
436
|
+
.replaceAll(/[\W_]+/g, ' ');
|
|
437
|
+
return normalizedPattern.length > 0 && normalized.includes(normalizedPattern);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
413
441
|
private countInteractiveElements(research: string): number {
|
|
414
442
|
const matches = [...research.matchAll(/\((\d+) elements?\)/g)];
|
|
415
443
|
return matches.reduce((sum, match) => sum + Number.parseInt(match[1], 10), 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,10 +3,10 @@ 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
|
|
|
@@ -61,7 +61,7 @@ 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> {
|
|
64
|
+
export async function captureInteractionScreenshot(explorer: Explorer, beforeScreenshot: Buffer | null, state: WebPageState, transition: DocStateTransition, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
|
|
65
65
|
const page = explorer.page;
|
|
66
66
|
if (!page) {
|
|
67
67
|
return null;
|
|
@@ -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 {
|
|
@@ -58,6 +58,7 @@ async function executeInteraction(explorer, stateManager, candidate, restoreUrl,
|
|
|
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;
|
|
@@ -75,12 +76,12 @@ async function executeInteraction(explorer, stateManager, candidate, restoreUrl,
|
|
|
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;
|
|
@@ -108,11 +109,16 @@ async function restoreInteractionState(explorer, restoreUrl, primaryCommand) {
|
|
|
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 = {
|
|
@@ -9,7 +9,7 @@ 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, captureInteractionScreenshot } from "./screenshots.js";
|
|
12
|
+
import { captureBeforeInteraction, captureDocumentationScreenshots, captureInteractionScreenshot } from "./screenshots.js";
|
|
13
13
|
import { renderMermaidBody } from "./state-diagram.js";
|
|
14
14
|
class DocBot {
|
|
15
15
|
explorBot;
|
|
@@ -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);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import pixelmatch from 'pixelmatch';
|
|
3
|
+
import { PNG } from 'pngjs';
|
|
4
|
+
const REGION_PADDING = 30;
|
|
5
|
+
const SCREENSHOT_OPTIONS = { animations: 'disabled', caret: 'hide' };
|
|
6
|
+
export async function captureInteractionBefore(page) {
|
|
7
|
+
await removeVisualAnnotations(page);
|
|
8
|
+
try {
|
|
9
|
+
return await page.screenshot(SCREENSHOT_OPTIONS);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function captureInteractionAfter(page, beforeScreenshot, filePath, detectUnmarkedOverlay = false) {
|
|
16
|
+
if (!beforeScreenshot)
|
|
17
|
+
return 'failed';
|
|
18
|
+
await removeVisualAnnotations(page);
|
|
19
|
+
try {
|
|
20
|
+
const afterScreenshot = await page.screenshot(SCREENSHOT_OPTIONS);
|
|
21
|
+
const before = PNG.sync.read(beforeScreenshot);
|
|
22
|
+
const after = PNG.sync.read(afterScreenshot);
|
|
23
|
+
if (before.width !== after.width || before.height !== after.height)
|
|
24
|
+
return 'failed';
|
|
25
|
+
const changedPixels = findChangedPixelBounds(before, after);
|
|
26
|
+
if (!changedPixels)
|
|
27
|
+
return 'unchanged';
|
|
28
|
+
const fullViewportChanged = changedPixels.x === 0 && changedPixels.y === 0 && changedPixels.width === after.width && changedPixels.height === after.height;
|
|
29
|
+
const changedRegion = addPadding(changedPixels, after.width, after.height);
|
|
30
|
+
const overlayRegion = fullViewportChanged ? await findOverlayRegion(page, after, detectUnmarkedOverlay) : null;
|
|
31
|
+
saveRegion(after, overlayRegion || changedRegion, filePath);
|
|
32
|
+
return 'captured';
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return 'failed';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function findChangedRegion(beforeScreenshot, afterScreenshot, padding = REGION_PADDING) {
|
|
39
|
+
const before = PNG.sync.read(beforeScreenshot);
|
|
40
|
+
const after = PNG.sync.read(afterScreenshot);
|
|
41
|
+
if (before.width !== after.width || before.height !== after.height)
|
|
42
|
+
return null;
|
|
43
|
+
const changedPixels = findChangedPixelBounds(before, after);
|
|
44
|
+
return changedPixels ? addPadding(changedPixels, before.width, before.height, padding) : null;
|
|
45
|
+
}
|
|
46
|
+
function saveRegion(after, region, filePath) {
|
|
47
|
+
const cropped = new PNG({ width: region.width, height: region.height });
|
|
48
|
+
PNG.bitblt(after, cropped, region.x, region.y, region.width, region.height, 0, 0);
|
|
49
|
+
writeFileSync(filePath, PNG.sync.write(cropped));
|
|
50
|
+
}
|
|
51
|
+
function findChangedPixelBounds(before, after) {
|
|
52
|
+
const diff = Buffer.alloc(before.width * before.height * 4);
|
|
53
|
+
const changedPixels = pixelmatch(before.data, after.data, diff, before.width, before.height, { diffMask: true });
|
|
54
|
+
if (changedPixels === 0)
|
|
55
|
+
return null;
|
|
56
|
+
let left = before.width;
|
|
57
|
+
let top = before.height;
|
|
58
|
+
let right = 0;
|
|
59
|
+
let bottom = 0;
|
|
60
|
+
for (let y = 0; y < before.height; y++) {
|
|
61
|
+
for (let x = 0; x < before.width; x++) {
|
|
62
|
+
if (diff[(y * before.width + x) * 4 + 3] === 0)
|
|
63
|
+
continue;
|
|
64
|
+
left = Math.min(left, x);
|
|
65
|
+
top = Math.min(top, y);
|
|
66
|
+
right = Math.max(right, x);
|
|
67
|
+
bottom = Math.max(bottom, y);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
|
71
|
+
}
|
|
72
|
+
function addPadding(region, imageWidth, imageHeight, padding = REGION_PADDING) {
|
|
73
|
+
const x = Math.max(0, region.x - padding);
|
|
74
|
+
const y = Math.max(0, region.y - padding);
|
|
75
|
+
const maxX = Math.min(imageWidth, region.x + region.width + padding);
|
|
76
|
+
const maxY = Math.min(imageHeight, region.y + region.height + padding);
|
|
77
|
+
return { x, y, width: maxX - x, height: maxY - y };
|
|
78
|
+
}
|
|
79
|
+
async function removeVisualAnnotations(page) {
|
|
80
|
+
try {
|
|
81
|
+
await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
|
|
82
|
+
for (const element of elements)
|
|
83
|
+
element.remove();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
}
|
|
88
|
+
async function findOverlayRegion(page, image, detectUnmarkedOverlay) {
|
|
89
|
+
let box = null;
|
|
90
|
+
try {
|
|
91
|
+
const dialogs = page.locator('[role="dialog"]:visible, [role="alertdialog"]:visible, [aria-modal="true"]:visible');
|
|
92
|
+
if ((await dialogs.count()) > 0)
|
|
93
|
+
box = await dialogs.last().boundingBox();
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
if (!box && detectUnmarkedOverlay) {
|
|
97
|
+
try {
|
|
98
|
+
box = await findUnmarkedOverlay(page);
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const viewport = page.viewportSize();
|
|
104
|
+
if (!box || !viewport)
|
|
105
|
+
return null;
|
|
106
|
+
const scaleX = image.width / viewport.width;
|
|
107
|
+
const scaleY = image.height / viewport.height;
|
|
108
|
+
const x = Math.max(0, Math.floor(box.x * scaleX) - REGION_PADDING);
|
|
109
|
+
const y = Math.max(0, Math.floor(box.y * scaleY) - REGION_PADDING);
|
|
110
|
+
const maxX = Math.min(image.width, Math.ceil((box.x + box.width) * scaleX) + REGION_PADDING);
|
|
111
|
+
const maxY = Math.min(image.height, Math.ceil((box.y + box.height) * scaleY) + REGION_PADDING);
|
|
112
|
+
if (maxX <= x || maxY <= y)
|
|
113
|
+
return null;
|
|
114
|
+
return { x, y, width: maxX - x, height: maxY - y };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function findUnmarkedOverlay(page) {
|
|
121
|
+
return page.evaluate(() => {
|
|
122
|
+
const elements = [...document.body.querySelectorAll('*')].map((element) => {
|
|
123
|
+
const style = getComputedStyle(element);
|
|
124
|
+
const box = element.getBoundingClientRect();
|
|
125
|
+
const zIndex = Number.parseInt(style.zIndex, 10);
|
|
126
|
+
return { element, style, box, zIndex, area: box.width * box.height };
|
|
127
|
+
});
|
|
128
|
+
const isVisibleLayer = ({ style, box, zIndex }) => {
|
|
129
|
+
if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0)
|
|
130
|
+
return false;
|
|
131
|
+
if (box.width <= 0 || box.height <= 0)
|
|
132
|
+
return false;
|
|
133
|
+
if (style.position !== 'fixed' && style.position !== 'absolute')
|
|
134
|
+
return false;
|
|
135
|
+
return Number.isFinite(zIndex);
|
|
136
|
+
};
|
|
137
|
+
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);
|
|
138
|
+
if (!Number.isFinite(backdropZIndex))
|
|
139
|
+
return null;
|
|
140
|
+
const candidates = elements
|
|
141
|
+
.filter((item) => {
|
|
142
|
+
if (!isVisibleLayer(item))
|
|
143
|
+
return false;
|
|
144
|
+
if (item.box.width >= window.innerWidth && item.box.height >= window.innerHeight)
|
|
145
|
+
return false;
|
|
146
|
+
if (item.zIndex < backdropZIndex)
|
|
147
|
+
return false;
|
|
148
|
+
return item.element.matches('button, input, select, textarea, a[href]') || !!item.element.querySelector('button, input, select, textarea, a[href]');
|
|
149
|
+
})
|
|
150
|
+
.sort((left, right) => right.zIndex - left.zIndex || left.area - right.area);
|
|
151
|
+
const box = candidates[0]?.box;
|
|
152
|
+
if (!box)
|
|
153
|
+
return null;
|
|
154
|
+
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseResearchSections } from "../../../src/ai/researcher/parser.js";
|
|
4
|
-
import { detectFocusArea } from "../../../src/utils/aria.js";
|
|
5
4
|
import { safeFilename, sanitizeFilename } from "../../../src/utils/strings.js";
|
|
5
|
+
import { captureInteractionAfter, captureInteractionBefore } from "./interaction-screenshots.js";
|
|
6
6
|
const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
|
|
7
7
|
export async function captureDocumentationScreenshots(explorer, state, research, options) {
|
|
8
8
|
const page = explorer.page;
|
|
@@ -47,7 +47,7 @@ export function getScreenshotSections(research) {
|
|
|
47
47
|
}
|
|
48
48
|
return sections;
|
|
49
49
|
}
|
|
50
|
-
export async function captureInteractionScreenshot(explorer, state, transition, options) {
|
|
50
|
+
export async function captureInteractionScreenshot(explorer, beforeScreenshot, state, transition, options) {
|
|
51
51
|
const page = explorer.page;
|
|
52
52
|
if (!page) {
|
|
53
53
|
return null;
|
|
@@ -55,19 +55,13 @@ export async function captureInteractionScreenshot(explorer, state, transition,
|
|
|
55
55
|
mkdirSync(options.screenshotsDir, { recursive: true });
|
|
56
56
|
const pageName = sanitizeFilename(state.url || 'page') || 'page';
|
|
57
57
|
const stateName = sanitizeFilename(transition.targetState?.label || transition.action) || 'state';
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
else {
|
|
65
|
-
await page.screenshot({ path: filePath });
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
58
|
+
const stateId = state.id ? `_${state.id}` : '';
|
|
59
|
+
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}${stateId}`, '.png'));
|
|
60
|
+
const result = await captureInteractionAfter(page, beforeScreenshot, filePath, transition.changes?.urlChanged !== true);
|
|
61
|
+
if (result === 'unchanged')
|
|
62
|
+
return null;
|
|
63
|
+
if (result === 'failed' && !(await captureViewport(page, filePath)))
|
|
69
64
|
return null;
|
|
70
|
-
}
|
|
71
65
|
return {
|
|
72
66
|
title: transition.targetState?.label || transition.action,
|
|
73
67
|
path: filePath,
|
|
@@ -75,6 +69,21 @@ export async function captureInteractionScreenshot(explorer, state, transition,
|
|
|
75
69
|
kind: 'state',
|
|
76
70
|
};
|
|
77
71
|
}
|
|
72
|
+
export async function captureBeforeInteraction(explorer) {
|
|
73
|
+
const page = explorer.page;
|
|
74
|
+
if (!page)
|
|
75
|
+
return null;
|
|
76
|
+
return captureInteractionBefore(page);
|
|
77
|
+
}
|
|
78
|
+
async function captureViewport(page, filePath) {
|
|
79
|
+
try {
|
|
80
|
+
await page.screenshot({ path: filePath });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
78
87
|
async function captureFullPageScreenshot(page, pageName, options) {
|
|
79
88
|
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
|
|
80
89
|
try {
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -114,7 +114,9 @@
|
|
|
114
114
|
"micromatch": "^4.0.8",
|
|
115
115
|
"ora-classic": "^5.4.2",
|
|
116
116
|
"parse5": "^8.0.0",
|
|
117
|
+
"pixelmatch": "^7.2.0",
|
|
117
118
|
"playwright": "^1.60",
|
|
119
|
+
"pngjs": "^7.0.0",
|
|
118
120
|
"react": "^19.1.1",
|
|
119
121
|
"sambanova-ai-provider": "^1.2.2",
|
|
120
122
|
"strip-ansi": "^7.1.2",
|
|
@@ -131,6 +133,7 @@
|
|
|
131
133
|
"@types/debug": "^4.1.12",
|
|
132
134
|
"@types/jsdom": "^27.0.0",
|
|
133
135
|
"@types/micromatch": "^4.0.9",
|
|
136
|
+
"@types/pngjs": "^6.0.5",
|
|
134
137
|
"@types/react": "^18.2.0",
|
|
135
138
|
"@types/yargs": "^17.0.24",
|
|
136
139
|
"bunosh": "^0.4.0",
|
|
@@ -207,7 +207,7 @@ export function WithLocators(Base) {
|
|
|
207
207
|
const eidxList = section.elements.map((el) => el.eidx).filter(Boolean);
|
|
208
208
|
if (eidxList.length < 2)
|
|
209
209
|
continue;
|
|
210
|
-
const ancestor = await this.explorer.
|
|
210
|
+
const ancestor = await this.explorer.withPage((page) => WebElement.commonAncestor(page, eidxList));
|
|
211
211
|
if (!ancestor)
|
|
212
212
|
continue;
|
|
213
213
|
const candidates = [];
|
package/dist/src/explorer.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ declare class Explorer {
|
|
|
39
39
|
testPageErrorHandler: ((error: Error) => void) | null;
|
|
40
40
|
testConsoleHandler: ((message: any) => void) | null;
|
|
41
41
|
testDialogHandler: ((dialog: any) => void) | null;
|
|
42
|
+
eventDispatcher: any;
|
|
42
43
|
constructor(config: ExplorbotConfig, options: ExplorerOptions | undefined, deps: ExplorerDeps);
|
|
43
44
|
get actor(): CodeceptJS.I;
|
|
44
45
|
get page(): Page | null;
|
package/dist/src/explorer.js
CHANGED
|
@@ -35,6 +35,7 @@ class Explorer {
|
|
|
35
35
|
testPageErrorHandler = null;
|
|
36
36
|
testConsoleHandler = null;
|
|
37
37
|
testDialogHandler = null;
|
|
38
|
+
eventDispatcher = codeceptjs.event.dispatcher;
|
|
38
39
|
constructor(config, options, deps) {
|
|
39
40
|
this.config = config;
|
|
40
41
|
this.options = options;
|
|
@@ -169,16 +170,16 @@ class Explorer {
|
|
|
169
170
|
stepHandler(step, 'failed', error?.message || String(error), error?.stack);
|
|
170
171
|
};
|
|
171
172
|
const onTestAfter = () => {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
this.eventDispatcher.off('step.passed', onStepPassed);
|
|
174
|
+
this.eventDispatcher.off('step.failed', onStepFailed);
|
|
175
|
+
this.eventDispatcher.off('test.after', onTestAfter);
|
|
175
176
|
this.unwatchActiveTestPages();
|
|
176
177
|
};
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
this.eventDispatcher.emit('test.before', codeceptjsTest);
|
|
179
|
+
this.eventDispatcher.emit('test.start', codeceptjsTest);
|
|
180
|
+
this.eventDispatcher.on('step.passed', onStepPassed);
|
|
181
|
+
this.eventDispatcher.on('step.failed', onStepFailed);
|
|
182
|
+
this.eventDispatcher.on('test.after', onTestAfter);
|
|
182
183
|
return { started: true, stop: (meta) => this.finishTest(test, meta) };
|
|
183
184
|
}
|
|
184
185
|
async openTab() {
|
|
@@ -568,18 +569,18 @@ class Explorer {
|
|
|
568
569
|
const codeceptjsTest = toCodeceptjsTest(test);
|
|
569
570
|
if (test.isSuccessful) {
|
|
570
571
|
codeceptjsTest.state = 'passed';
|
|
571
|
-
|
|
572
|
+
this.eventDispatcher.emit('test.passed', codeceptjsTest);
|
|
572
573
|
}
|
|
573
574
|
else if (test.isSkipped) {
|
|
574
575
|
codeceptjsTest.state = 'skipped';
|
|
575
|
-
|
|
576
|
+
this.eventDispatcher.emit('test.skipped', codeceptjsTest);
|
|
576
577
|
}
|
|
577
578
|
else {
|
|
578
579
|
codeceptjsTest.state = 'failed';
|
|
579
|
-
|
|
580
|
+
this.eventDispatcher.emit('test.failed', codeceptjsTest);
|
|
580
581
|
}
|
|
581
|
-
|
|
582
|
-
|
|
582
|
+
this.eventDispatcher.emit('test.finish', codeceptjsTest);
|
|
583
|
+
this.eventDispatcher.emit('test.after', codeceptjsTest);
|
|
583
584
|
}
|
|
584
585
|
watchActiveTestPage(page = this.playwrightHelper?.page) {
|
|
585
586
|
if (!this._activeTest)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -114,7 +114,9 @@
|
|
|
114
114
|
"micromatch": "^4.0.8",
|
|
115
115
|
"ora-classic": "^5.4.2",
|
|
116
116
|
"parse5": "^8.0.0",
|
|
117
|
+
"pixelmatch": "^7.2.0",
|
|
117
118
|
"playwright": "^1.60",
|
|
119
|
+
"pngjs": "^7.0.0",
|
|
118
120
|
"react": "^19.1.1",
|
|
119
121
|
"sambanova-ai-provider": "^1.2.2",
|
|
120
122
|
"strip-ansi": "^7.1.2",
|
|
@@ -131,6 +133,7 @@
|
|
|
131
133
|
"@types/debug": "^4.1.12",
|
|
132
134
|
"@types/jsdom": "^27.0.0",
|
|
133
135
|
"@types/micromatch": "^4.0.9",
|
|
136
|
+
"@types/pngjs": "^6.0.5",
|
|
134
137
|
"@types/react": "^18.2.0",
|
|
135
138
|
"@types/yargs": "^17.0.24",
|
|
136
139
|
"bunosh": "^0.4.0",
|
|
@@ -228,7 +228,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
|
|
|
228
228
|
const eidxList = section.elements.map((el) => el.eidx).filter(Boolean) as string[];
|
|
229
229
|
if (eidxList.length < 2) continue;
|
|
230
230
|
|
|
231
|
-
const ancestor = await this.explorer.
|
|
231
|
+
const ancestor = await this.explorer.withPage((page) => WebElement.commonAncestor(page, eidxList));
|
|
232
232
|
if (!ancestor) continue;
|
|
233
233
|
|
|
234
234
|
const candidates: string[] = [];
|
package/src/explorer.ts
CHANGED
|
@@ -58,6 +58,7 @@ class Explorer {
|
|
|
58
58
|
private testPageErrorHandler: ((error: Error) => void) | null = null;
|
|
59
59
|
private testConsoleHandler: ((message: any) => void) | null = null;
|
|
60
60
|
private testDialogHandler: ((dialog: any) => void) | null = null;
|
|
61
|
+
private eventDispatcher = codeceptjs.event.dispatcher;
|
|
61
62
|
|
|
62
63
|
constructor(config: ExplorbotConfig, options: ExplorerOptions | undefined, deps: ExplorerDeps) {
|
|
63
64
|
this.config = config;
|
|
@@ -215,17 +216,17 @@ class Explorer {
|
|
|
215
216
|
stepHandler(step, 'failed', error?.message || String(error), error?.stack);
|
|
216
217
|
};
|
|
217
218
|
const onTestAfter = () => {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
this.eventDispatcher.off('step.passed', onStepPassed);
|
|
220
|
+
this.eventDispatcher.off('step.failed', onStepFailed);
|
|
221
|
+
this.eventDispatcher.off('test.after', onTestAfter);
|
|
221
222
|
this.unwatchActiveTestPages();
|
|
222
223
|
};
|
|
223
224
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
this.eventDispatcher.emit('test.before', codeceptjsTest);
|
|
226
|
+
this.eventDispatcher.emit('test.start', codeceptjsTest);
|
|
227
|
+
this.eventDispatcher.on('step.passed', onStepPassed);
|
|
228
|
+
this.eventDispatcher.on('step.failed', onStepFailed);
|
|
229
|
+
this.eventDispatcher.on('test.after', onTestAfter);
|
|
229
230
|
|
|
230
231
|
return { started: true, stop: (meta) => this.finishTest(test, meta) };
|
|
231
232
|
}
|
|
@@ -665,17 +666,17 @@ class Explorer {
|
|
|
665
666
|
|
|
666
667
|
if (test.isSuccessful) {
|
|
667
668
|
codeceptjsTest.state = 'passed';
|
|
668
|
-
|
|
669
|
+
this.eventDispatcher.emit('test.passed', codeceptjsTest);
|
|
669
670
|
} else if (test.isSkipped) {
|
|
670
671
|
codeceptjsTest.state = 'skipped';
|
|
671
|
-
|
|
672
|
+
this.eventDispatcher.emit('test.skipped', codeceptjsTest);
|
|
672
673
|
} else {
|
|
673
674
|
codeceptjsTest.state = 'failed';
|
|
674
|
-
|
|
675
|
+
this.eventDispatcher.emit('test.failed', codeceptjsTest);
|
|
675
676
|
}
|
|
676
677
|
|
|
677
|
-
|
|
678
|
-
|
|
678
|
+
this.eventDispatcher.emit('test.finish', codeceptjsTest);
|
|
679
|
+
this.eventDispatcher.emit('test.after', codeceptjsTest);
|
|
679
680
|
}
|
|
680
681
|
|
|
681
682
|
private watchActiveTestPage(page = this.playwrightHelper?.page): void {
|