explorbot 0.1.29 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/boat/doc-collector/src/ai/documentarian.ts +37 -13
- package/boat/doc-collector/src/ai/tools.ts +60 -20
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/config.ts +7 -0
- package/boat/doc-collector/src/docbot.ts +23 -5
- package/boat/doc-collector/src/docs-renderer.ts +14 -1
- package/boat/doc-collector/src/screenshots.ts +126 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
- package/dist/boat/doc-collector/src/ai/tools.js +53 -20
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/config.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +19 -4
- package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
- package/dist/boat/doc-collector/src/screenshots.js +90 -0
- package/dist/package.json +1 -1
- package/dist/src/action.js +26 -23
- package/dist/src/ai/historian/codeceptjs.js +3 -2
- package/dist/src/ai/historian/experience.js +48 -6
- package/dist/src/ai/historian/playwright.js +2 -1
- package/dist/src/ai/historian/utils.js +1 -19
- package/dist/src/ai/historian.js +1 -1
- package/dist/src/ai/quartermaster.js +2 -2
- package/dist/src/ai/tester.js +3 -0
- package/dist/src/ai/tools.js +0 -1
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +7 -1
- package/dist/src/explorer.js +30 -27
- package/dist/src/utils/browser-errors.js +5 -0
- package/dist/src/utils/page-readiness.js +48 -0
- package/dist/src/utils/step-analyzer.js +68 -0
- package/package.json +1 -1
- package/src/action.ts +24 -26
- package/src/ai/historian/codeceptjs.ts +3 -2
- package/src/ai/historian/experience.ts +51 -6
- package/src/ai/historian/playwright.ts +2 -1
- package/src/ai/historian/utils.ts +1 -21
- package/src/ai/historian.ts +1 -1
- package/src/ai/quartermaster.ts +2 -2
- package/src/ai/tester.ts +3 -0
- package/src/ai/tools.ts +0 -1
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +7 -1
- package/src/explorer.ts +28 -27
- package/src/utils/browser-errors.ts +6 -0
- package/src/utils/page-readiness.ts +59 -0
- package/src/utils/step-analyzer.ts +73 -0
package/src/explorer.ts
CHANGED
|
@@ -2,10 +2,10 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import path, { join } from 'node:path';
|
|
3
3
|
// @ts-ignore
|
|
4
4
|
import * as codeceptjs from 'codeceptjs';
|
|
5
|
-
import dedent from 'dedent';
|
|
6
5
|
import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
7
6
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
8
7
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
8
|
+
import dedent from 'dedent';
|
|
9
9
|
import type { BrowserContextOptions } from 'playwright';
|
|
10
10
|
import { ActionResult } from './action-result.ts';
|
|
11
11
|
import Action from './action.js';
|
|
@@ -21,10 +21,11 @@ import { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
|
21
21
|
import { Reporter } from './reporter.ts';
|
|
22
22
|
import { StateManager } from './state-manager.js';
|
|
23
23
|
import { Test, TestResult } from './test-plan.ts';
|
|
24
|
+
import { BrowserRecoveryError, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
|
|
24
25
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './utils/html.ts';
|
|
25
26
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
27
|
+
import { waitForPageReadiness } from './utils/page-readiness.ts';
|
|
26
28
|
import { WebElement } from './utils/web-element.ts';
|
|
27
|
-
import { BrowserRecoveryError, isFatalBrowserError } from './utils/browser-errors.ts';
|
|
28
29
|
|
|
29
30
|
declare global {
|
|
30
31
|
namespace NodeJS {
|
|
@@ -314,13 +315,26 @@ class Explorer {
|
|
|
314
315
|
try {
|
|
315
316
|
return await operation();
|
|
316
317
|
} catch (error) {
|
|
317
|
-
|
|
318
|
+
let recoveryError = error;
|
|
319
|
+
|
|
320
|
+
if (isNavigationTransitionError(error)) {
|
|
321
|
+
tag('warning').log(`${label}: page is still navigating, waiting before retry...`);
|
|
322
|
+
await this.waitForPageReadiness();
|
|
323
|
+
try {
|
|
324
|
+
return await operation();
|
|
325
|
+
} catch (retryError) {
|
|
326
|
+
if (!isNavigationTransitionError(retryError) && !this.isFatalBrowserError(retryError)) throw retryError;
|
|
327
|
+
recoveryError = retryError;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!this.isFatalBrowserError(recoveryError)) throw recoveryError;
|
|
318
332
|
|
|
319
333
|
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
320
334
|
let recovered = await this.recoverFromBrowserError();
|
|
321
335
|
if (!recovered) recovered = await this.restartBrowser();
|
|
322
|
-
if (!recovered) throw new BrowserRecoveryError(label,
|
|
323
|
-
if (!(await this.
|
|
336
|
+
if (!recovered) throw new BrowserRecoveryError(label, recoveryError, false);
|
|
337
|
+
if (!(await this.waitForPageReadiness())) throw new BrowserRecoveryError(label, recoveryError, true);
|
|
324
338
|
|
|
325
339
|
try {
|
|
326
340
|
return await operation();
|
|
@@ -377,7 +391,7 @@ class Explorer {
|
|
|
377
391
|
const msg = err instanceof Error ? err.message : String(err);
|
|
378
392
|
if (!RECOVERABLE_NAVIGATION_ERRORS.test(msg)) throw err;
|
|
379
393
|
tag('warning').log(`Navigation warning (continuing after load): ${msg.split('\n')[0]}`);
|
|
380
|
-
await this.
|
|
394
|
+
await this.waitForPageReadiness();
|
|
381
395
|
await action.capturePageState();
|
|
382
396
|
}
|
|
383
397
|
}
|
|
@@ -488,11 +502,11 @@ class Explorer {
|
|
|
488
502
|
if (url) {
|
|
489
503
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
490
504
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
491
|
-
return this.
|
|
505
|
+
return this.waitForPageReadiness();
|
|
492
506
|
}
|
|
493
507
|
tag('warning').log('Browser error detected, reloading page');
|
|
494
508
|
await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
495
|
-
return this.
|
|
509
|
+
return this.waitForPageReadiness();
|
|
496
510
|
} catch (err) {
|
|
497
511
|
tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
|
|
498
512
|
return false;
|
|
@@ -532,7 +546,7 @@ class Explorer {
|
|
|
532
546
|
|
|
533
547
|
if (url) {
|
|
534
548
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
535
|
-
if (!(await this.
|
|
549
|
+
if (!(await this.waitForPageReadiness())) return false;
|
|
536
550
|
}
|
|
537
551
|
|
|
538
552
|
tag('success').log('Browser restarted');
|
|
@@ -550,27 +564,14 @@ class Explorer {
|
|
|
550
564
|
}
|
|
551
565
|
}
|
|
552
566
|
|
|
553
|
-
private async
|
|
567
|
+
private async waitForPageReadiness(): Promise<boolean> {
|
|
554
568
|
const page = this.playwrightHelper?.page;
|
|
555
569
|
if (!page) return false;
|
|
556
570
|
|
|
557
|
-
await page
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
() => {
|
|
562
|
-
const body = document.body;
|
|
563
|
-
if (!body) return false;
|
|
564
|
-
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
565
|
-
},
|
|
566
|
-
undefined,
|
|
567
|
-
{ timeout: 5000 }
|
|
568
|
-
)
|
|
569
|
-
.then(() => true)
|
|
570
|
-
.catch(() => false);
|
|
571
|
-
if (!hasUsableDom) return false;
|
|
572
|
-
}
|
|
573
|
-
await page.waitForLoadState?.('networkidle', { timeout: 3000 }).catch(() => {});
|
|
571
|
+
await waitForPageReadiness(page, {
|
|
572
|
+
timeout: this.config.playwright.waitForTimeout,
|
|
573
|
+
spinnerSelectors: this.config.playwright.spinnerSelectors,
|
|
574
|
+
});
|
|
574
575
|
return true;
|
|
575
576
|
}
|
|
576
577
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// not typed exceptions. Keep those external message markers in one adapter so
|
|
3
3
|
// recovery decisions are not duplicated across agents/actions.
|
|
4
4
|
const FATAL_BROWSER_ERROR_MARKERS = ['Frame was detached', 'Target closed', 'Target page, context or browser has been closed', 'Execution context was destroyed', 'Protocol error', 'Session closed'];
|
|
5
|
+
const NAVIGATION_TRANSITION_ERROR_MARKERS = ['most likely because of a navigation', 'navigating and changing the content'];
|
|
5
6
|
|
|
6
7
|
export class BrowserRecoveryError extends Error {
|
|
7
8
|
constructor(
|
|
@@ -20,6 +21,11 @@ export function isFatalBrowserError(error: unknown): boolean {
|
|
|
20
21
|
return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
export function isNavigationTransitionError(error: unknown): boolean {
|
|
25
|
+
const message = browserErrorMessage(error).toLowerCase();
|
|
26
|
+
return NAVIGATION_TRANSITION_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export function browserErrorMessage(error: unknown): string {
|
|
24
30
|
return error instanceof Error ? error.message : String(error);
|
|
25
31
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export async function waitForPageReadiness(page: any, options: PageReadinessOptions = {}): Promise<void> {
|
|
2
|
+
if (!page) return;
|
|
3
|
+
|
|
4
|
+
const timeout = options.timeout ?? 6000;
|
|
5
|
+
await page.waitForLoadState?.('domcontentloaded', { timeout })?.catch(() => {});
|
|
6
|
+
|
|
7
|
+
await Promise.race([waitForNetworkIdle(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => {});
|
|
8
|
+
await waitForPageBodyContent(page, timeout);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function waitForNetworkIdle(page: any, timeout: number): Promise<void> {
|
|
12
|
+
if (!page?.waitForLoadState) return Promise.resolve();
|
|
13
|
+
return page.waitForLoadState('networkidle', { timeout }).catch(() => {});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function waitForVisibleSpinnersHidden(page: any, selectors: string[], timeout: number): Promise<void> {
|
|
17
|
+
if (!selectors.length) return new Promise(() => {});
|
|
18
|
+
if (!page?.locator) return new Promise(() => {});
|
|
19
|
+
|
|
20
|
+
const visibleSpinners = [];
|
|
21
|
+
for (const selector of selectors) {
|
|
22
|
+
const locator = page.locator(selector);
|
|
23
|
+
const isVisible = await locator
|
|
24
|
+
.first()
|
|
25
|
+
.isVisible({ timeout: 100 })
|
|
26
|
+
.catch(() => false);
|
|
27
|
+
if (!isVisible) continue;
|
|
28
|
+
visibleSpinners.push(locator);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (visibleSpinners.length === 0) return new Promise(() => {});
|
|
32
|
+
|
|
33
|
+
return Promise.all(visibleSpinners.map((locator) => locator.waitFor({ state: 'hidden', timeout }).catch(() => {}))).then(() => {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function waitForPageBodyContent(page: any, timeout: number): Promise<void> {
|
|
37
|
+
if (!page?.waitForFunction) return Promise.resolve();
|
|
38
|
+
|
|
39
|
+
return page
|
|
40
|
+
.waitForFunction(
|
|
41
|
+
() => {
|
|
42
|
+
const body = document.body;
|
|
43
|
+
if (!body) return false;
|
|
44
|
+
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
45
|
+
},
|
|
46
|
+
undefined,
|
|
47
|
+
{ timeout }
|
|
48
|
+
)
|
|
49
|
+
.catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sleep(ms: number): Promise<void> {
|
|
53
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface PageReadinessOptions {
|
|
57
|
+
timeout?: number;
|
|
58
|
+
spinnerSelectors?: string[];
|
|
59
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { SessionStep } from '../experience-tracker.ts';
|
|
2
|
+
import type { StepData } from '../test-plan.ts';
|
|
3
|
+
import { isDynamicId } from './xpath.ts';
|
|
4
|
+
|
|
5
|
+
export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'] as const;
|
|
6
|
+
export type CodeceptToolName = (typeof CODECEPT_TOOLS)[number];
|
|
7
|
+
|
|
8
|
+
const CODECEPT_FORM_COMMANDS: readonly string[] = ['I.fillField', 'I.type', 'I.selectOption', 'I.attachFile', 'I.checkOption', 'I.uncheckOption'];
|
|
9
|
+
|
|
10
|
+
export function isCodeceptToolName(toolName: string): toolName is CodeceptToolName {
|
|
11
|
+
return CODECEPT_TOOLS.includes(toolName as CodeceptToolName);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getCodeceptToolName(commandName: string): CodeceptToolName | null {
|
|
15
|
+
const toolName = CODECEPT_TOOLS.find((name) => commandName === `I.${name}`);
|
|
16
|
+
if (toolName) return toolName;
|
|
17
|
+
if (CODECEPT_FORM_COMMANDS.includes(commandName)) return 'form';
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getCodeceptToolNameFromCode(code: string): CodeceptToolName | null {
|
|
22
|
+
const parenIndex = code.trim().indexOf('(');
|
|
23
|
+
if (parenIndex < 1) return null;
|
|
24
|
+
return getCodeceptToolName(code.trim().slice(0, parenIndex));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function stripComments(code: string): string {
|
|
28
|
+
return code
|
|
29
|
+
.split('\n')
|
|
30
|
+
.filter((line) => {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
|
|
33
|
+
})
|
|
34
|
+
.join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isNonReusableCode(code: string): boolean {
|
|
38
|
+
if (/\bI\.clickXY\s*\(/.test(code)) return true;
|
|
39
|
+
|
|
40
|
+
for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
|
|
41
|
+
if (isDynamicId(m[1])) return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function toReusableSessionStep(step: StepData): SessionStep | null {
|
|
48
|
+
if (step.status !== 'passed') return null;
|
|
49
|
+
const code = stripComments(step.text);
|
|
50
|
+
if (!code || isNonReusableCode(code)) return null;
|
|
51
|
+
const toolName = getCodeceptToolNameFromCode(code);
|
|
52
|
+
if (!toolName) return null;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
message: step.text,
|
|
56
|
+
status: 'passed',
|
|
57
|
+
tool: toolName,
|
|
58
|
+
code,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function mergeUniqueStepsByCode(primary: SessionStep[], secondary: SessionStep[]): SessionStep[] {
|
|
63
|
+
const merged: SessionStep[] = [];
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
for (const step of [...primary, ...secondary]) {
|
|
66
|
+
const identity = stripComments(step.code || '').trim();
|
|
67
|
+
if (!identity) continue;
|
|
68
|
+
if (seen.has(identity)) continue;
|
|
69
|
+
seen.add(identity);
|
|
70
|
+
merged.push(step);
|
|
71
|
+
}
|
|
72
|
+
return merged;
|
|
73
|
+
}
|