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.
@@ -94,6 +94,9 @@ class Documentarian {
94
94
  if ((interaction.changes?.newElements || 0) > 0) {
95
95
  return true;
96
96
  }
97
+ if ((interaction.changes?.removedElements || 0) > 0) {
98
+ return true;
99
+ }
97
100
  return (interaction.discoveredUrls || []).length > 0;
98
101
  });
99
102
  }
@@ -49,7 +49,10 @@ export interface InteractionScreenshot {
49
49
  relativePath: string;
50
50
  }
51
51
 
52
- export type CaptureInteractionState = (state: WebPageState, transition: DocStateTransition) => Promise<InteractionScreenshot | null>;
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: collectLinks(afterState).map((link) => link.url),
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 {
@@ -91,6 +91,7 @@ export function createDocsCommands(name = 'docs'): Command {
91
91
  output: 'docs',
92
92
  screenshot: true,
93
93
  interactive: false,
94
+ ignoreErrors: true,
94
95
  collapseDynamicPages: true,
95
96
  scope: 'site',
96
97
  includePaths: [],
@@ -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 documentation = await this.documentarian.document(state, research, async (interactionState, transition) => {
117
- if (!this.shouldUseScreenshots()) {
118
- return null;
119
- }
120
- return captureInteractionScreenshot(this.explorBot.getExplorer(), interactionState, transition, {
121
- pageFilePath: pagePath,
122
- screenshotsDir: this.getScreenshotsDir(),
123
- config: this.config,
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 filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}`, '.png'));
74
- const focus = detectFocusArea(state.ariaSnapshot || null);
75
-
76
- try {
77
- if (focus.detected) {
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 {
@@ -77,6 +77,9 @@ class Documentarian {
77
77
  if ((interaction.changes?.newElements || 0) > 0) {
78
78
  return true;
79
79
  }
80
+ if ((interaction.changes?.removedElements || 0) > 0) {
81
+ return true;
82
+ }
80
83
  return (interaction.discoveredUrls || []).length > 0;
81
84
  });
82
85
  }
@@ -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: collectLinks(afterState).map((link) => link.url),
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 = {
@@ -80,6 +80,7 @@ export function createDocsCommands(name = 'docs') {
80
80
  output: 'docs',
81
81
  screenshot: true,
82
82
  interactive: false,
83
+ ignoreErrors: true,
83
84
  collapseDynamicPages: true,
84
85
  scope: 'site',
85
86
  includePaths: [],
@@ -105,6 +105,7 @@ class DocbotConfigParser {
105
105
  output: 'docs',
106
106
  screenshot: true,
107
107
  interactive: false,
108
+ ignoreErrors: true,
108
109
  collapseDynamicPages: true,
109
110
  scope: 'site',
110
111
  includePaths: [],
@@ -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 documentation = await this.documentarian.document(state, research, async (interactionState, transition) => {
102
- if (!this.shouldUseScreenshots()) {
103
- return null;
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
- return captureInteractionScreenshot(this.explorBot.getExplorer(), interactionState, transition, {
106
- pageFilePath: pagePath,
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 filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}`, '.png'));
59
- const focus = detectFocusArea(state.ariaSnapshot || null);
60
- try {
61
- if (focus.detected) {
62
- await page.locator('[role="dialog"], [role="alertdialog"], [aria-modal="true"]').last().screenshot({ path: filePath });
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.1",
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.runWithBrowserRecovery('recoverContainerFromChildren', () => WebElement.commonAncestor(this.explorer.playwrightHelper.page, eidxList));
210
+ const ancestor = await this.explorer.withPage((page) => WebElement.commonAncestor(page, eidxList));
211
211
  if (!ancestor)
212
212
  continue;
213
213
  const candidates = [];
@@ -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;
@@ -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
- codeceptjs.event.dispatcher.off('step.passed', onStepPassed);
173
- codeceptjs.event.dispatcher.off('step.failed', onStepFailed);
174
- codeceptjs.event.dispatcher.off('test.after', onTestAfter);
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
- codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
178
- codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
179
- codeceptjs.event.dispatcher.on('step.passed', onStepPassed);
180
- codeceptjs.event.dispatcher.on('step.failed', onStepFailed);
181
- codeceptjs.event.dispatcher.on('test.after', onTestAfter);
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
- codeceptjs.event.dispatcher.emit('test.passed', codeceptjsTest);
572
+ this.eventDispatcher.emit('test.passed', codeceptjsTest);
572
573
  }
573
574
  else if (test.isSkipped) {
574
575
  codeceptjsTest.state = 'skipped';
575
- codeceptjs.event.dispatcher.emit('test.skipped', codeceptjsTest);
576
+ this.eventDispatcher.emit('test.skipped', codeceptjsTest);
576
577
  }
577
578
  else {
578
579
  codeceptjsTest.state = 'failed';
579
- codeceptjs.event.dispatcher.emit('test.failed', codeceptjsTest);
580
+ this.eventDispatcher.emit('test.failed', codeceptjsTest);
580
581
  }
581
- codeceptjs.event.dispatcher.emit('test.finish', codeceptjsTest);
582
- codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
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.1",
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.runWithBrowserRecovery('recoverContainerFromChildren', () => WebElement.commonAncestor(this.explorer.playwrightHelper.page, eidxList));
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
- codeceptjs.event.dispatcher.off('step.passed', onStepPassed);
219
- codeceptjs.event.dispatcher.off('step.failed', onStepFailed);
220
- codeceptjs.event.dispatcher.off('test.after', onTestAfter);
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
- codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
225
- codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
226
- codeceptjs.event.dispatcher.on('step.passed', onStepPassed);
227
- codeceptjs.event.dispatcher.on('step.failed', onStepFailed);
228
- codeceptjs.event.dispatcher.on('test.after', onTestAfter);
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
- codeceptjs.event.dispatcher.emit('test.passed', codeceptjsTest);
669
+ this.eventDispatcher.emit('test.passed', codeceptjsTest);
669
670
  } else if (test.isSkipped) {
670
671
  codeceptjsTest.state = 'skipped';
671
- codeceptjs.event.dispatcher.emit('test.skipped', codeceptjsTest);
672
+ this.eventDispatcher.emit('test.skipped', codeceptjsTest);
672
673
  } else {
673
674
  codeceptjsTest.state = 'failed';
674
- codeceptjs.event.dispatcher.emit('test.failed', codeceptjsTest);
675
+ this.eventDispatcher.emit('test.failed', codeceptjsTest);
675
676
  }
676
677
 
677
- codeceptjs.event.dispatcher.emit('test.finish', codeceptjsTest);
678
- codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
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 {