explorbot 0.1.27 → 0.1.28
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/dist/package.json +1 -1
- package/dist/src/action-result.js +2 -0
- package/dist/src/action.js +88 -7
- package/dist/src/ai/captain/file-tools.js +100 -0
- package/dist/src/ai/captain/idle-mode.js +70 -6
- package/dist/src/ai/captain/web-mode.js +36 -6
- package/dist/src/ai/captain.js +87 -19
- package/dist/src/ai/historian/screencast.js +11 -2
- package/dist/src/ai/navigator.js +5 -2
- package/dist/src/ai/pilot.js +33 -5
- package/dist/src/ai/researcher/coordinates.js +2 -3
- package/dist/src/ai/researcher/deep-analysis.js +3 -4
- package/dist/src/ai/researcher/locators.js +1 -2
- package/dist/src/ai/researcher.js +17 -18
- package/dist/src/ai/task-agent.js +1 -0
- package/dist/src/ai/tester.js +91 -39
- package/dist/src/ai/tools.js +17 -7
- package/dist/src/commands/explore-command.js +6 -1
- package/dist/src/components/LogPane.js +4 -3
- package/dist/src/explorer.js +270 -35
- package/dist/src/utils/browser-errors.js +23 -0
- package/dist/src/utils/error-page.js +17 -2
- package/dist/src/utils/logger.js +2 -2
- package/package.json +1 -1
- package/src/action-result.ts +2 -0
- package/src/action.ts +83 -7
- package/src/ai/captain/file-tools.ts +126 -0
- package/src/ai/captain/idle-mode.ts +72 -6
- package/src/ai/captain/mixin.ts +1 -1
- package/src/ai/captain/web-mode.ts +40 -5
- package/src/ai/captain.ts +94 -20
- package/src/ai/historian/screencast.ts +11 -2
- package/src/ai/navigator.ts +6 -2
- package/src/ai/pilot.ts +34 -5
- package/src/ai/researcher/coordinates.ts +2 -3
- package/src/ai/researcher/deep-analysis.ts +3 -4
- package/src/ai/researcher/locators.ts +1 -2
- package/src/ai/researcher.ts +17 -18
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +101 -41
- package/src/ai/tools.ts +17 -7
- package/src/commands/explore-command.ts +6 -1
- package/src/components/LogPane.tsx +4 -3
- package/src/explorer.ts +295 -38
- package/src/state-manager.ts +2 -0
- package/src/utils/browser-errors.ts +25 -0
- package/src/utils/error-page.ts +16 -3
- package/src/utils/logger.ts +3 -3
package/src/explorer.ts
CHANGED
|
@@ -2,13 +2,14 @@ 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';
|
|
5
6
|
import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
6
7
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
7
8
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
9
|
+
import type { BrowserContextOptions } from 'playwright';
|
|
8
10
|
import { ActionResult } from './action-result.ts';
|
|
9
11
|
import Action from './action.js';
|
|
10
12
|
import { AIProvider } from './ai/provider.js';
|
|
11
|
-
import type { BrowserContextOptions } from 'playwright';
|
|
12
13
|
import { visuallyAnnotateContainers } from './ai/researcher/coordinates.ts';
|
|
13
14
|
import { RequestStore } from './api/request-store.ts';
|
|
14
15
|
import { XhrCapture } from './api/xhr-capture.ts';
|
|
@@ -19,10 +20,11 @@ import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
|
19
20
|
import { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
20
21
|
import { Reporter } from './reporter.ts';
|
|
21
22
|
import { StateManager } from './state-manager.js';
|
|
22
|
-
import { Test } from './test-plan.ts';
|
|
23
|
+
import { Test, TestResult } from './test-plan.ts';
|
|
23
24
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './utils/html.ts';
|
|
24
25
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
25
26
|
import { WebElement } from './utils/web-element.ts';
|
|
27
|
+
import { BrowserRecoveryError, isFatalBrowserError } from './utils/browser-errors.ts';
|
|
26
28
|
|
|
27
29
|
declare global {
|
|
28
30
|
namespace NodeJS {
|
|
@@ -39,7 +41,6 @@ declare namespace CodeceptJS {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
const debugLog = createDebug('explorbot:explorer');
|
|
42
|
-
const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
|
|
43
44
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
44
45
|
|
|
45
46
|
interface TabInfo {
|
|
@@ -47,6 +48,12 @@ interface TabInfo {
|
|
|
47
48
|
title: string;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
interface BrowserExecutionErrorResult {
|
|
52
|
+
action: 'continue' | 'stop';
|
|
53
|
+
message: string;
|
|
54
|
+
recovered?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
class Explorer {
|
|
51
58
|
private aiProvider: AIProvider;
|
|
52
59
|
playwrightHelper: any;
|
|
@@ -64,6 +71,10 @@ class Explorer {
|
|
|
64
71
|
private xhrCapture: XhrCapture | null = null;
|
|
65
72
|
private requestStore: RequestStore | null = null;
|
|
66
73
|
private playwrightRecorder: PlaywrightRecorder = new PlaywrightRecorder();
|
|
74
|
+
private observedTestPages = new Set<any>();
|
|
75
|
+
private testPageErrorHandler: ((error: Error) => void) | null = null;
|
|
76
|
+
private testConsoleHandler: ((message: any) => void) | null = null;
|
|
77
|
+
private testDialogHandler: ((dialog: any) => void) | null = null;
|
|
67
78
|
|
|
68
79
|
constructor(config: ExplorbotConfig, aiProvider: AIProvider, options?: { show?: boolean; headless?: boolean; incognito?: boolean; session?: string }) {
|
|
69
80
|
this.config = config;
|
|
@@ -199,12 +210,14 @@ class Explorer {
|
|
|
199
210
|
}
|
|
200
211
|
}
|
|
201
212
|
|
|
202
|
-
private setupXhrCapture(): void {
|
|
213
|
+
private setupXhrCapture(reuseRequestStore = false): void {
|
|
203
214
|
const configParser = ConfigParser.getInstance();
|
|
204
215
|
const outputDir = configParser.getOutputDir();
|
|
205
|
-
this.requestStore
|
|
216
|
+
if (!reuseRequestStore || !this.requestStore) {
|
|
217
|
+
this.requestStore = new RequestStore(outputDir);
|
|
218
|
+
}
|
|
206
219
|
const baseUrl = this.config.playwright.url;
|
|
207
|
-
this.xhrCapture = new XhrCapture(this.requestStore
|
|
220
|
+
this.xhrCapture = new XhrCapture(this.requestStore!, baseUrl);
|
|
208
221
|
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
209
222
|
}
|
|
210
223
|
|
|
@@ -239,18 +252,7 @@ class Explorer {
|
|
|
239
252
|
}
|
|
240
253
|
await this.connectOrLaunchBrowser();
|
|
241
254
|
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
242
|
-
|
|
243
|
-
// CodeceptJS skips _createContextPage when sessions/storageState are involved, so we
|
|
244
|
-
// build contextOptions ourselves. Most keys share a name with Playwright's
|
|
245
|
-
// BrowserContextOptions and are copied as-is; `emulate` must be flattened, `basicAuth`
|
|
246
|
-
// renamed to `httpCredentials`, and `storageState` comes from the --session flag.
|
|
247
|
-
const contextOptions: BrowserContextOptions = {
|
|
248
|
-
...helperOptions,
|
|
249
|
-
};
|
|
250
|
-
if (helperOptions.emulate) Object.assign(contextOptions, helperOptions.emulate);
|
|
251
|
-
if (helperOptions.basicAuth) contextOptions.httpCredentials = helperOptions.basicAuth;
|
|
252
|
-
if (hasSession) contextOptions.storageState = this.options!.session;
|
|
253
|
-
await this.playwrightHelper._createContextPage(contextOptions);
|
|
255
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
254
256
|
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
255
257
|
this.setupXhrCapture();
|
|
256
258
|
if (hasSession) {
|
|
@@ -287,15 +289,75 @@ class Explorer {
|
|
|
287
289
|
await this.playwrightHelper._startBrowser();
|
|
288
290
|
}
|
|
289
291
|
|
|
292
|
+
private createBrowserContextOptions(): BrowserContextOptions {
|
|
293
|
+
const helperOptions = this.playwrightHelper.options || {};
|
|
294
|
+
const contextOptions: BrowserContextOptions = {
|
|
295
|
+
...helperOptions,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
if (helperOptions.emulate) Object.assign(contextOptions, helperOptions.emulate);
|
|
299
|
+
if (helperOptions.basicAuth) contextOptions.httpCredentials = helperOptions.basicAuth;
|
|
300
|
+
if (this.options?.session && existsSync(this.options.session)) contextOptions.storageState = this.options.session;
|
|
301
|
+
|
|
302
|
+
return contextOptions;
|
|
303
|
+
}
|
|
304
|
+
|
|
290
305
|
createAction() {
|
|
291
306
|
return new Action(this.actor, this.stateManager, this.playwrightRecorder);
|
|
292
307
|
}
|
|
293
308
|
|
|
309
|
+
async runWithBrowserRecovery<T>(label: string, operation: () => Promise<T>): Promise<T> {
|
|
310
|
+
if (!(await this.ensurePageAvailable())) {
|
|
311
|
+
throw new Error(`Browser page is unavailable before ${label}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
return await operation();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (!this.isFatalBrowserError(error)) throw error;
|
|
318
|
+
|
|
319
|
+
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
320
|
+
let recovered = await this.recoverFromBrowserError();
|
|
321
|
+
if (!recovered) recovered = await this.restartBrowser();
|
|
322
|
+
if (!recovered) throw new BrowserRecoveryError(label, error, false);
|
|
323
|
+
if (!(await this.waitForUsablePageDom())) throw new BrowserRecoveryError(label, error, true);
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
return await operation();
|
|
327
|
+
} catch (retryError) {
|
|
328
|
+
if (this.isFatalBrowserError(retryError)) {
|
|
329
|
+
throw new BrowserRecoveryError(label, retryError, true);
|
|
330
|
+
}
|
|
331
|
+
throw retryError;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async capturePageState(opts: { includeScreenshot?: boolean } = {}): Promise<ActionResult> {
|
|
337
|
+
return this.runWithBrowserRecovery('capturePageState', () => this.createAction().capturePageState(opts));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async capturePageWithScreenshot(): Promise<ActionResult> {
|
|
341
|
+
return this.capturePageState({ includeScreenshot: true });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async executeAction(code: string): Promise<Action> {
|
|
345
|
+
return this.runWithBrowserRecovery('executeAction', () => this.createAction().execute(code));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async attemptAction(code: string, originalMessage?: string, experience = true): Promise<boolean> {
|
|
349
|
+
return this.runWithBrowserRecovery('attemptAction', () => this.createAction().attempt(code, originalMessage, experience));
|
|
350
|
+
}
|
|
351
|
+
|
|
294
352
|
getPlaywrightRecorder(): PlaywrightRecorder {
|
|
295
353
|
return this.playwrightRecorder;
|
|
296
354
|
}
|
|
297
355
|
|
|
298
356
|
async visit(url: string) {
|
|
357
|
+
return this.runWithBrowserRecovery('visit', () => this.visitOnce(url));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async visitOnce(url: string) {
|
|
299
361
|
await this.closeOtherTabs();
|
|
300
362
|
|
|
301
363
|
const serializedUrl = JSON.stringify(url);
|
|
@@ -338,12 +400,14 @@ class Explorer {
|
|
|
338
400
|
}
|
|
339
401
|
|
|
340
402
|
async annotateElements(): Promise<WebElement[]> {
|
|
341
|
-
|
|
342
|
-
|
|
403
|
+
return this.runWithBrowserRecovery('annotateElements', async () => {
|
|
404
|
+
const { elements } = await annotatePageElements(this.playwrightHelper.page);
|
|
405
|
+
return elements;
|
|
406
|
+
});
|
|
343
407
|
}
|
|
344
408
|
|
|
345
409
|
async visuallyAnnotateElements(opts?: { containers?: Array<{ css: string; label: string }> }): Promise<number> {
|
|
346
|
-
return visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []);
|
|
410
|
+
return this.runWithBrowserRecovery('visuallyAnnotateElements', () => visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []));
|
|
347
411
|
}
|
|
348
412
|
|
|
349
413
|
async getEidxInContainer(containerCss: string | null): Promise<string[]> {
|
|
@@ -386,28 +450,99 @@ class Explorer {
|
|
|
386
450
|
await this.playwrightHelper.page.reload();
|
|
387
451
|
}
|
|
388
452
|
|
|
453
|
+
private resolveBrowserUrl(url?: string): string | null {
|
|
454
|
+
if (!url) return null;
|
|
455
|
+
try {
|
|
456
|
+
return new URL(url).toString();
|
|
457
|
+
} catch {}
|
|
458
|
+
|
|
459
|
+
const baseUrl = this.config.playwright?.url || this.config.web?.url;
|
|
460
|
+
if (!baseUrl) return null;
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
return new URL(url, baseUrl).toString();
|
|
464
|
+
} catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
389
469
|
isFatalBrowserError(error: unknown): boolean {
|
|
390
|
-
|
|
391
|
-
return FATAL_BROWSER_ERRORS.test(msg);
|
|
470
|
+
return isFatalBrowserError(error);
|
|
392
471
|
}
|
|
393
472
|
|
|
394
473
|
async recoverFromBrowserError(): Promise<boolean> {
|
|
395
474
|
try {
|
|
396
|
-
|
|
475
|
+
if (!this.playwrightHelper?.page || this.playwrightHelper.page.isClosed?.()) {
|
|
476
|
+
const context = this.playwrightHelper?.browserContext;
|
|
477
|
+
if (!context) return await this.restartBrowser();
|
|
478
|
+
const page = await context.newPage();
|
|
479
|
+
await page.bringToFront();
|
|
480
|
+
await this.playwrightHelper._setPage(page);
|
|
481
|
+
this.bindFrameNavigated(page);
|
|
482
|
+
if (this.xhrCapture) {
|
|
483
|
+
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
397
488
|
if (url) {
|
|
398
489
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
399
490
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
400
|
-
return
|
|
491
|
+
return this.waitForUsablePageDom();
|
|
401
492
|
}
|
|
402
493
|
tag('warning').log('Browser error detected, reloading page');
|
|
403
494
|
await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
404
|
-
return
|
|
495
|
+
return this.waitForUsablePageDom();
|
|
405
496
|
} catch (err) {
|
|
406
497
|
tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
|
|
407
498
|
return false;
|
|
408
499
|
}
|
|
409
500
|
}
|
|
410
501
|
|
|
502
|
+
async restartBrowser(): Promise<boolean> {
|
|
503
|
+
if (!this.playwrightHelper) return false;
|
|
504
|
+
|
|
505
|
+
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
if (this.xhrCapture && this.playwrightHelper.page) {
|
|
509
|
+
this.xhrCapture.detach(this.playwrightHelper.page);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
await this.playwrightRecorder.stop();
|
|
513
|
+
|
|
514
|
+
if (this.playwrightHelper.browserContext) {
|
|
515
|
+
await this.playwrightHelper.browserContext.close().catch((err: unknown) => {
|
|
516
|
+
debugLog('Failed to close browser context before restart:', err);
|
|
517
|
+
});
|
|
518
|
+
this.playwrightHelper.browserContext = null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!this.isSharedBrowser) {
|
|
522
|
+
await this.playwrightHelper._stopBrowser().catch((err: unknown) => {
|
|
523
|
+
debugLog('Failed to stop browser before restart:', err);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
await this.connectOrLaunchBrowser();
|
|
528
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
529
|
+
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
530
|
+
this.setupXhrCapture(true);
|
|
531
|
+
this.listenToStateChanged();
|
|
532
|
+
|
|
533
|
+
if (url) {
|
|
534
|
+
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
535
|
+
if (!(await this.waitForUsablePageDom())) return false;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
tag('success').log('Browser restarted');
|
|
539
|
+
return true;
|
|
540
|
+
} catch (err) {
|
|
541
|
+
tag('error').log(`Browser restart failed: ${err instanceof Error ? err.message : err}`);
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
411
546
|
async switchToMainFrame() {
|
|
412
547
|
if (this.playwrightHelper.frame) {
|
|
413
548
|
debugLog('Switching to main frame');
|
|
@@ -415,6 +550,30 @@ class Explorer {
|
|
|
415
550
|
}
|
|
416
551
|
}
|
|
417
552
|
|
|
553
|
+
private async waitForUsablePageDom(): Promise<boolean> {
|
|
554
|
+
const page = this.playwrightHelper?.page;
|
|
555
|
+
if (!page) return false;
|
|
556
|
+
|
|
557
|
+
await page.waitForLoadState?.('domcontentloaded', { timeout: 5000 }).catch(() => {});
|
|
558
|
+
if (page.waitForFunction) {
|
|
559
|
+
const hasUsableDom = await page
|
|
560
|
+
.waitForFunction(
|
|
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(() => {});
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
418
577
|
async isInsideIframe(): Promise<boolean> {
|
|
419
578
|
if (this.playwrightHelper.frame) return true;
|
|
420
579
|
|
|
@@ -542,11 +701,13 @@ class Explorer {
|
|
|
542
701
|
return this._activeTest;
|
|
543
702
|
}
|
|
544
703
|
|
|
545
|
-
async startTest(test: Test) {
|
|
704
|
+
async startTest(test: Test): Promise<boolean> {
|
|
546
705
|
this._activeTest = test;
|
|
706
|
+
test.start();
|
|
547
707
|
await this.reporter.reportTestStart(test);
|
|
548
708
|
await this.closeOtherTabs();
|
|
549
709
|
this.otherTabs = [];
|
|
710
|
+
if (!(await this.ensurePageAvailable())) return false;
|
|
550
711
|
|
|
551
712
|
const codeceptjsTest = toCodeceptjsTest(test);
|
|
552
713
|
|
|
@@ -563,13 +724,7 @@ class Explorer {
|
|
|
563
724
|
test.setActiveNoteScreenshot(lastScreenshot);
|
|
564
725
|
};
|
|
565
726
|
|
|
566
|
-
|
|
567
|
-
const dialogType = dialog.type();
|
|
568
|
-
const dialogMessage = dialog.message();
|
|
569
|
-
test.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
this.playwrightHelper?.page?.on('dialog', dialogHandler);
|
|
727
|
+
this.watchActiveTestPage();
|
|
573
728
|
|
|
574
729
|
codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
|
|
575
730
|
codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
|
|
@@ -580,11 +735,105 @@ class Explorer {
|
|
|
580
735
|
codeceptjs.event.dispatcher.on('test.after', () => {
|
|
581
736
|
codeceptjs.event.dispatcher.off('step.passed', stepHandler);
|
|
582
737
|
codeceptjs.event.dispatcher.off('step.failed', stepHandler);
|
|
583
|
-
this.
|
|
738
|
+
this.unwatchActiveTestPages();
|
|
584
739
|
});
|
|
740
|
+
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async ensurePageAvailable(): Promise<boolean> {
|
|
745
|
+
const page = this.playwrightHelper?.page;
|
|
746
|
+
if (page && !page.isClosed?.()) {
|
|
747
|
+
this.watchActiveTestPage(page);
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const recovered = await this.recoverFromBrowserError();
|
|
752
|
+
if (!recovered) return false;
|
|
753
|
+
this.watchActiveTestPage();
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async ensureActiveTestPageAvailable(): Promise<boolean> {
|
|
758
|
+
return this.ensurePageAvailable();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async handleExecutionError(error: unknown): Promise<BrowserExecutionErrorResult> {
|
|
762
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
763
|
+
tag('error').log(`Browser execution error: ${message}`);
|
|
764
|
+
|
|
765
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
766
|
+
return {
|
|
767
|
+
action: 'stop',
|
|
768
|
+
message,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (error instanceof BrowserRecoveryError) {
|
|
773
|
+
return {
|
|
774
|
+
action: 'stop',
|
|
775
|
+
recovered: error.recovered,
|
|
776
|
+
message: error.message,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (!this.isFatalBrowserError(error)) {
|
|
781
|
+
return {
|
|
782
|
+
action: 'continue',
|
|
783
|
+
message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
let recovered = await this.recoverFromBrowserError();
|
|
788
|
+
if (!recovered) recovered = await this.restartBrowser();
|
|
789
|
+
|
|
790
|
+
if (!recovered) {
|
|
791
|
+
return {
|
|
792
|
+
action: 'stop',
|
|
793
|
+
recovered: false,
|
|
794
|
+
message: `Browser could not be recovered after fatal error: ${message}`,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
this.watchActiveTestPage();
|
|
799
|
+
return {
|
|
800
|
+
action: 'continue',
|
|
801
|
+
recovered: true,
|
|
802
|
+
message: dedent`
|
|
803
|
+
Browser was recovered after a fatal page error.
|
|
804
|
+
Continue from the restored page.
|
|
805
|
+
The interrupted browser action is not product evidence.
|
|
806
|
+
Inspect the restored page and retry the current step when it is still required.
|
|
807
|
+
`,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
watchActiveTestPage(page = this.playwrightHelper?.page): void {
|
|
812
|
+
if (!this._activeTest) return;
|
|
813
|
+
if (!page) return;
|
|
814
|
+
if (this.observedTestPages.has(page)) return;
|
|
815
|
+
|
|
816
|
+
this.testPageErrorHandler ||= (err: Error) => {
|
|
817
|
+
this._activeTest?.addNote(`Console error: ${err.message}`, TestResult.FAILED);
|
|
818
|
+
};
|
|
819
|
+
this.testConsoleHandler ||= (msg: any) => {
|
|
820
|
+
if (msg.type() !== 'error') return;
|
|
821
|
+
this._activeTest?.addNote(`Console error: ${msg.text()}`, TestResult.FAILED);
|
|
822
|
+
};
|
|
823
|
+
this.testDialogHandler ||= (dialog: any) => {
|
|
824
|
+
const dialogType = dialog.type();
|
|
825
|
+
const dialogMessage = dialog.message();
|
|
826
|
+
this._activeTest?.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
page.on('pageerror', this.testPageErrorHandler);
|
|
830
|
+
page.on('console', this.testConsoleHandler);
|
|
831
|
+
page.on('dialog', this.testDialogHandler);
|
|
832
|
+
this.observedTestPages.add(page);
|
|
585
833
|
}
|
|
586
834
|
|
|
587
835
|
async stopTest(test: Test, meta?: Record<string, string>) {
|
|
836
|
+
this.unwatchActiveTestPages();
|
|
588
837
|
this._activeTest = null;
|
|
589
838
|
const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
|
|
590
839
|
if (lastScreenshot) {
|
|
@@ -609,6 +858,15 @@ class Explorer {
|
|
|
609
858
|
codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
|
|
610
859
|
}
|
|
611
860
|
|
|
861
|
+
private unwatchActiveTestPages(): void {
|
|
862
|
+
for (const page of this.observedTestPages) {
|
|
863
|
+
if (this.testPageErrorHandler) page.off('pageerror', this.testPageErrorHandler);
|
|
864
|
+
if (this.testConsoleHandler) page.off('console', this.testConsoleHandler);
|
|
865
|
+
if (this.testDialogHandler) page.off('dialog', this.testDialogHandler);
|
|
866
|
+
}
|
|
867
|
+
this.observedTestPages.clear();
|
|
868
|
+
}
|
|
869
|
+
|
|
612
870
|
async hasPlaywrightLocator(locatorFn: (page: any) => any, opts: { multiple?: boolean; contents?: boolean; success?: (locator: any) => Promise<void> | void } = {}): Promise<boolean> {
|
|
613
871
|
try {
|
|
614
872
|
const pwLocator = locatorFn(this.playwrightHelper.page);
|
|
@@ -671,7 +929,7 @@ class Explorer {
|
|
|
671
929
|
await oldPage.close();
|
|
672
930
|
await newPage.bringToFront();
|
|
673
931
|
|
|
674
|
-
this.playwrightHelper.
|
|
932
|
+
await this.playwrightHelper._setPage(newPage);
|
|
675
933
|
this.otherTabs = [];
|
|
676
934
|
|
|
677
935
|
this.bindFrameNavigated(newPage);
|
|
@@ -705,8 +963,7 @@ class Explorer {
|
|
|
705
963
|
}
|
|
706
964
|
|
|
707
965
|
await firstPage.bringToFront();
|
|
708
|
-
|
|
709
|
-
this.playwrightHelper.page = firstPage;
|
|
966
|
+
await this.playwrightHelper._setPage(firstPage);
|
|
710
967
|
|
|
711
968
|
debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
|
|
712
969
|
}
|
package/src/state-manager.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface WebPageState {
|
|
|
24
24
|
url: string;
|
|
25
25
|
/** Page title */
|
|
26
26
|
title?: string;
|
|
27
|
+
/** HTTP status of the main document navigation */
|
|
28
|
+
httpStatus?: number;
|
|
27
29
|
/** Full URL for reference */
|
|
28
30
|
fullUrl?: string;
|
|
29
31
|
/** Timestamp when state was captured */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Playwright and CodeceptJS surface browser/page disposal as plain Error objects,
|
|
2
|
+
// not typed exceptions. Keep those external message markers in one adapter so
|
|
3
|
+
// recovery decisions are not duplicated across agents/actions.
|
|
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
|
+
|
|
6
|
+
export class BrowserRecoveryError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
label: string,
|
|
9
|
+
public originalError: unknown,
|
|
10
|
+
public recovered: boolean
|
|
11
|
+
) {
|
|
12
|
+
super(`${label} failed ${recovered ? 'after browser recovery' : 'because browser could not be recovered'}: ${browserErrorMessage(originalError)}`);
|
|
13
|
+
this.name = 'BrowserRecoveryError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isFatalBrowserError(error: unknown): boolean {
|
|
18
|
+
if (error instanceof BrowserRecoveryError) return true;
|
|
19
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
20
|
+
return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function browserErrorMessage(error: unknown): string {
|
|
24
|
+
return error instanceof Error ? error.message : String(error);
|
|
25
|
+
}
|
package/src/utils/error-page.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ActionResult } from '../action-result.js';
|
|
2
|
+
import type { WebPageState } from '../state-manager.js';
|
|
2
3
|
import { isBodyEmpty } from './html.js';
|
|
3
4
|
|
|
4
5
|
const HTTP_ERRORS = ['400 Bad Request', '401 Unauthorized', '403 Forbidden', '404 Not Found', '405 Method Not Allowed', '408 Request Timeout', '500 Internal Server Error', '502 Bad Gateway', '503 Service Unavailable', '504 Gateway Timeout'];
|
|
@@ -9,6 +10,8 @@ const LOADING_WORD = /\bloading\b/i;
|
|
|
9
10
|
export type PageCondition = 'ok' | 'loading' | 'error';
|
|
10
11
|
|
|
11
12
|
export function detectPageCondition(actionResult: ActionResult): PageCondition {
|
|
13
|
+
if (actionResult.httpStatus && actionResult.httpStatus >= 400) return 'error';
|
|
14
|
+
|
|
12
15
|
const headingFields = [actionResult.title, actionResult.h1, actionResult.h2].filter(Boolean) as string[];
|
|
13
16
|
|
|
14
17
|
for (const field of headingFields) {
|
|
@@ -37,12 +40,22 @@ export function isErrorPage(actionResult: ActionResult): boolean {
|
|
|
37
40
|
return detectPageCondition(actionResult) === 'error';
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
export function getStateErrorPageError(state: WebPageState | null | undefined): ErrorPageError | null {
|
|
44
|
+
if (!state) return null;
|
|
45
|
+
const actionResult = ActionResult.fromState(state);
|
|
46
|
+
if (!isErrorPage(actionResult)) return null;
|
|
47
|
+
return new ErrorPageError(actionResult.url, actionResult.title, actionResult.httpStatus);
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
export class ErrorPageError extends Error {
|
|
41
51
|
constructor(
|
|
42
52
|
public readonly url: string,
|
|
43
|
-
public readonly title?: string
|
|
53
|
+
public readonly title?: string,
|
|
54
|
+
public readonly httpStatus?: number
|
|
44
55
|
) {
|
|
45
|
-
|
|
56
|
+
const status = httpStatus ? `HTTP ${httpStatus}` : '';
|
|
57
|
+
const details = [status, title].filter(Boolean).join(', ');
|
|
58
|
+
super(`Error page detected at ${url}${details ? ` (${details})` : ''}`);
|
|
46
59
|
this.name = 'ErrorPageError';
|
|
47
60
|
}
|
|
48
61
|
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { Observability } from '../observability.ts';
|
|
|
11
11
|
import { RecentStepFilter } from './log-filters.ts';
|
|
12
12
|
import { parseMarkdownToTerminal } from './markdown-terminal.ts';
|
|
13
13
|
|
|
14
|
-
export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'html' | 'input';
|
|
14
|
+
export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input';
|
|
15
15
|
|
|
16
16
|
export interface TaggedLogEntry {
|
|
17
17
|
type: LogType;
|
|
@@ -99,7 +99,7 @@ class ConsoleDestination implements LogDestination {
|
|
|
99
99
|
if (entry.type === 'operation' && !this.verboseMode) return;
|
|
100
100
|
if (entry.type === 'step' && !this.verboseMode && this.recentSteps.shouldSuppress(entry.content)) return;
|
|
101
101
|
let content = entry.content;
|
|
102
|
-
if (entry.type === 'multiline') {
|
|
102
|
+
if (entry.type === 'multiline' || entry.type === 'details') {
|
|
103
103
|
const cleaned = stripAnsi(dedent(entry.content));
|
|
104
104
|
const parsed = parseMarkdownToTerminal(cleaned);
|
|
105
105
|
content = parsed;
|
|
@@ -314,7 +314,7 @@ class CaptainDestination implements LogDestination {
|
|
|
314
314
|
|
|
315
315
|
stopCapture(): string[] {
|
|
316
316
|
this.capturing = false;
|
|
317
|
-
const logs = this.entries.filter((e) => e.type !== 'debug' && e.type !== 'html' && e.type !== 'multiline' && e.type !== 'operation').map((e) => `[${e.type}] ${e.content}`);
|
|
317
|
+
const logs = this.entries.filter((e) => e.type !== 'debug' && e.type !== 'html' && e.type !== 'multiline' && e.type !== 'details' && e.type !== 'operation').map((e) => `[${e.type}] ${e.content}`);
|
|
318
318
|
this.entries = [];
|
|
319
319
|
return logs;
|
|
320
320
|
}
|