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/dist/src/explorer.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import path 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';
|
|
@@ -15,11 +16,12 @@ import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
|
15
16
|
import { PlaywrightRecorder } from "./playwright-recorder.js";
|
|
16
17
|
import { Reporter } from "./reporter.js";
|
|
17
18
|
import { StateManager } from './state-manager.js';
|
|
19
|
+
import { TestResult } from "./test-plan.js";
|
|
18
20
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./utils/html.js";
|
|
19
21
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
20
22
|
import { WebElement } from "./utils/web-element.js";
|
|
23
|
+
import { BrowserRecoveryError, isFatalBrowserError } from "./utils/browser-errors.js";
|
|
21
24
|
const debugLog = createDebug('explorbot:explorer');
|
|
22
|
-
const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
|
|
23
25
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
24
26
|
class Explorer {
|
|
25
27
|
aiProvider;
|
|
@@ -38,6 +40,10 @@ class Explorer {
|
|
|
38
40
|
xhrCapture = null;
|
|
39
41
|
requestStore = null;
|
|
40
42
|
playwrightRecorder = new PlaywrightRecorder();
|
|
43
|
+
observedTestPages = new Set();
|
|
44
|
+
testPageErrorHandler = null;
|
|
45
|
+
testConsoleHandler = null;
|
|
46
|
+
testDialogHandler = null;
|
|
41
47
|
constructor(config, aiProvider, options) {
|
|
42
48
|
this.config = config;
|
|
43
49
|
this.aiProvider = aiProvider;
|
|
@@ -154,10 +160,12 @@ class Explorer {
|
|
|
154
160
|
return {};
|
|
155
161
|
}
|
|
156
162
|
}
|
|
157
|
-
setupXhrCapture() {
|
|
163
|
+
setupXhrCapture(reuseRequestStore = false) {
|
|
158
164
|
const configParser = ConfigParser.getInstance();
|
|
159
165
|
const outputDir = configParser.getOutputDir();
|
|
160
|
-
this.requestStore
|
|
166
|
+
if (!reuseRequestStore || !this.requestStore) {
|
|
167
|
+
this.requestStore = new RequestStore(outputDir);
|
|
168
|
+
}
|
|
161
169
|
const baseUrl = this.config.playwright.url;
|
|
162
170
|
this.xhrCapture = new XhrCapture(this.requestStore, baseUrl);
|
|
163
171
|
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
@@ -189,21 +197,7 @@ class Explorer {
|
|
|
189
197
|
}
|
|
190
198
|
await this.connectOrLaunchBrowser();
|
|
191
199
|
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
192
|
-
|
|
193
|
-
// CodeceptJS skips _createContextPage when sessions/storageState are involved, so we
|
|
194
|
-
// build contextOptions ourselves. Most keys share a name with Playwright's
|
|
195
|
-
// BrowserContextOptions and are copied as-is; `emulate` must be flattened, `basicAuth`
|
|
196
|
-
// renamed to `httpCredentials`, and `storageState` comes from the --session flag.
|
|
197
|
-
const contextOptions = {
|
|
198
|
-
...helperOptions,
|
|
199
|
-
};
|
|
200
|
-
if (helperOptions.emulate)
|
|
201
|
-
Object.assign(contextOptions, helperOptions.emulate);
|
|
202
|
-
if (helperOptions.basicAuth)
|
|
203
|
-
contextOptions.httpCredentials = helperOptions.basicAuth;
|
|
204
|
-
if (hasSession)
|
|
205
|
-
contextOptions.storageState = this.options.session;
|
|
206
|
-
await this.playwrightHelper._createContextPage(contextOptions);
|
|
200
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
207
201
|
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
208
202
|
this.setupXhrCapture();
|
|
209
203
|
if (hasSession) {
|
|
@@ -232,13 +226,70 @@ class Explorer {
|
|
|
232
226
|
}
|
|
233
227
|
await this.playwrightHelper._startBrowser();
|
|
234
228
|
}
|
|
229
|
+
createBrowserContextOptions() {
|
|
230
|
+
const helperOptions = this.playwrightHelper.options || {};
|
|
231
|
+
const contextOptions = {
|
|
232
|
+
...helperOptions,
|
|
233
|
+
};
|
|
234
|
+
if (helperOptions.emulate)
|
|
235
|
+
Object.assign(contextOptions, helperOptions.emulate);
|
|
236
|
+
if (helperOptions.basicAuth)
|
|
237
|
+
contextOptions.httpCredentials = helperOptions.basicAuth;
|
|
238
|
+
if (this.options?.session && existsSync(this.options.session))
|
|
239
|
+
contextOptions.storageState = this.options.session;
|
|
240
|
+
return contextOptions;
|
|
241
|
+
}
|
|
235
242
|
createAction() {
|
|
236
243
|
return new Action(this.actor, this.stateManager, this.playwrightRecorder);
|
|
237
244
|
}
|
|
245
|
+
async runWithBrowserRecovery(label, operation) {
|
|
246
|
+
if (!(await this.ensurePageAvailable())) {
|
|
247
|
+
throw new Error(`Browser page is unavailable before ${label}`);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return await operation();
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (!this.isFatalBrowserError(error))
|
|
254
|
+
throw error;
|
|
255
|
+
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
256
|
+
let recovered = await this.recoverFromBrowserError();
|
|
257
|
+
if (!recovered)
|
|
258
|
+
recovered = await this.restartBrowser();
|
|
259
|
+
if (!recovered)
|
|
260
|
+
throw new BrowserRecoveryError(label, error, false);
|
|
261
|
+
if (!(await this.waitForUsablePageDom()))
|
|
262
|
+
throw new BrowserRecoveryError(label, error, true);
|
|
263
|
+
try {
|
|
264
|
+
return await operation();
|
|
265
|
+
}
|
|
266
|
+
catch (retryError) {
|
|
267
|
+
if (this.isFatalBrowserError(retryError)) {
|
|
268
|
+
throw new BrowserRecoveryError(label, retryError, true);
|
|
269
|
+
}
|
|
270
|
+
throw retryError;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async capturePageState(opts = {}) {
|
|
275
|
+
return this.runWithBrowserRecovery('capturePageState', () => this.createAction().capturePageState(opts));
|
|
276
|
+
}
|
|
277
|
+
async capturePageWithScreenshot() {
|
|
278
|
+
return this.capturePageState({ includeScreenshot: true });
|
|
279
|
+
}
|
|
280
|
+
async executeAction(code) {
|
|
281
|
+
return this.runWithBrowserRecovery('executeAction', () => this.createAction().execute(code));
|
|
282
|
+
}
|
|
283
|
+
async attemptAction(code, originalMessage, experience = true) {
|
|
284
|
+
return this.runWithBrowserRecovery('attemptAction', () => this.createAction().attempt(code, originalMessage, experience));
|
|
285
|
+
}
|
|
238
286
|
getPlaywrightRecorder() {
|
|
239
287
|
return this.playwrightRecorder;
|
|
240
288
|
}
|
|
241
289
|
async visit(url) {
|
|
290
|
+
return this.runWithBrowserRecovery('visit', () => this.visitOnce(url));
|
|
291
|
+
}
|
|
292
|
+
async visitOnce(url) {
|
|
242
293
|
await this.closeOtherTabs();
|
|
243
294
|
const serializedUrl = JSON.stringify(url);
|
|
244
295
|
const currentState = this.stateManager.getCurrentState();
|
|
@@ -275,11 +326,13 @@ class Explorer {
|
|
|
275
326
|
return action;
|
|
276
327
|
}
|
|
277
328
|
async annotateElements() {
|
|
278
|
-
|
|
279
|
-
|
|
329
|
+
return this.runWithBrowserRecovery('annotateElements', async () => {
|
|
330
|
+
const { elements } = await annotatePageElements(this.playwrightHelper.page);
|
|
331
|
+
return elements;
|
|
332
|
+
});
|
|
280
333
|
}
|
|
281
334
|
async visuallyAnnotateElements(opts) {
|
|
282
|
-
return visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []);
|
|
335
|
+
return this.runWithBrowserRecovery('visuallyAnnotateElements', () => visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []));
|
|
283
336
|
}
|
|
284
337
|
async getEidxInContainer(containerCss) {
|
|
285
338
|
const page = this.playwrightHelper.page;
|
|
@@ -321,33 +374,120 @@ class Explorer {
|
|
|
321
374
|
await this.closeOtherTabs();
|
|
322
375
|
await this.playwrightHelper.page.reload();
|
|
323
376
|
}
|
|
377
|
+
resolveBrowserUrl(url) {
|
|
378
|
+
if (!url)
|
|
379
|
+
return null;
|
|
380
|
+
try {
|
|
381
|
+
return new URL(url).toString();
|
|
382
|
+
}
|
|
383
|
+
catch { }
|
|
384
|
+
const baseUrl = this.config.playwright?.url || this.config.web?.url;
|
|
385
|
+
if (!baseUrl)
|
|
386
|
+
return null;
|
|
387
|
+
try {
|
|
388
|
+
return new URL(url, baseUrl).toString();
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
324
394
|
isFatalBrowserError(error) {
|
|
325
|
-
|
|
326
|
-
return FATAL_BROWSER_ERRORS.test(msg);
|
|
395
|
+
return isFatalBrowserError(error);
|
|
327
396
|
}
|
|
328
397
|
async recoverFromBrowserError() {
|
|
329
398
|
try {
|
|
330
|
-
|
|
399
|
+
if (!this.playwrightHelper?.page || this.playwrightHelper.page.isClosed?.()) {
|
|
400
|
+
const context = this.playwrightHelper?.browserContext;
|
|
401
|
+
if (!context)
|
|
402
|
+
return await this.restartBrowser();
|
|
403
|
+
const page = await context.newPage();
|
|
404
|
+
await page.bringToFront();
|
|
405
|
+
await this.playwrightHelper._setPage(page);
|
|
406
|
+
this.bindFrameNavigated(page);
|
|
407
|
+
if (this.xhrCapture) {
|
|
408
|
+
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
331
412
|
if (url) {
|
|
332
413
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
333
414
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
334
|
-
return
|
|
415
|
+
return this.waitForUsablePageDom();
|
|
335
416
|
}
|
|
336
417
|
tag('warning').log('Browser error detected, reloading page');
|
|
337
418
|
await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
338
|
-
return
|
|
419
|
+
return this.waitForUsablePageDom();
|
|
339
420
|
}
|
|
340
421
|
catch (err) {
|
|
341
422
|
tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
|
|
342
423
|
return false;
|
|
343
424
|
}
|
|
344
425
|
}
|
|
426
|
+
async restartBrowser() {
|
|
427
|
+
if (!this.playwrightHelper)
|
|
428
|
+
return false;
|
|
429
|
+
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
430
|
+
try {
|
|
431
|
+
if (this.xhrCapture && this.playwrightHelper.page) {
|
|
432
|
+
this.xhrCapture.detach(this.playwrightHelper.page);
|
|
433
|
+
}
|
|
434
|
+
await this.playwrightRecorder.stop();
|
|
435
|
+
if (this.playwrightHelper.browserContext) {
|
|
436
|
+
await this.playwrightHelper.browserContext.close().catch((err) => {
|
|
437
|
+
debugLog('Failed to close browser context before restart:', err);
|
|
438
|
+
});
|
|
439
|
+
this.playwrightHelper.browserContext = null;
|
|
440
|
+
}
|
|
441
|
+
if (!this.isSharedBrowser) {
|
|
442
|
+
await this.playwrightHelper._stopBrowser().catch((err) => {
|
|
443
|
+
debugLog('Failed to stop browser before restart:', err);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
await this.connectOrLaunchBrowser();
|
|
447
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
448
|
+
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
449
|
+
this.setupXhrCapture(true);
|
|
450
|
+
this.listenToStateChanged();
|
|
451
|
+
if (url) {
|
|
452
|
+
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
453
|
+
if (!(await this.waitForUsablePageDom()))
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
tag('success').log('Browser restarted');
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
tag('error').log(`Browser restart failed: ${err instanceof Error ? err.message : err}`);
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
345
464
|
async switchToMainFrame() {
|
|
346
465
|
if (this.playwrightHelper.frame) {
|
|
347
466
|
debugLog('Switching to main frame');
|
|
348
467
|
await this.playwrightHelper.switchTo();
|
|
349
468
|
}
|
|
350
469
|
}
|
|
470
|
+
async waitForUsablePageDom() {
|
|
471
|
+
const page = this.playwrightHelper?.page;
|
|
472
|
+
if (!page)
|
|
473
|
+
return false;
|
|
474
|
+
await page.waitForLoadState?.('domcontentloaded', { timeout: 5000 }).catch(() => { });
|
|
475
|
+
if (page.waitForFunction) {
|
|
476
|
+
const hasUsableDom = await page
|
|
477
|
+
.waitForFunction(() => {
|
|
478
|
+
const body = document.body;
|
|
479
|
+
if (!body)
|
|
480
|
+
return false;
|
|
481
|
+
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
482
|
+
}, undefined, { timeout: 5000 })
|
|
483
|
+
.then(() => true)
|
|
484
|
+
.catch(() => false);
|
|
485
|
+
if (!hasUsableDom)
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
await page.waitForLoadState?.('networkidle', { timeout: 3000 }).catch(() => { });
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
351
491
|
async isInsideIframe() {
|
|
352
492
|
if (this.playwrightHelper.frame)
|
|
353
493
|
return true;
|
|
@@ -465,9 +605,12 @@ class Explorer {
|
|
|
465
605
|
}
|
|
466
606
|
async startTest(test) {
|
|
467
607
|
this._activeTest = test;
|
|
608
|
+
test.start();
|
|
468
609
|
await this.reporter.reportTestStart(test);
|
|
469
610
|
await this.closeOtherTabs();
|
|
470
611
|
this.otherTabs = [];
|
|
612
|
+
if (!(await this.ensurePageAvailable()))
|
|
613
|
+
return false;
|
|
471
614
|
const codeceptjsTest = toCodeceptjsTest(test);
|
|
472
615
|
const stepHandler = (step, status, error, log) => {
|
|
473
616
|
if (!step.toCode)
|
|
@@ -482,12 +625,7 @@ class Explorer {
|
|
|
482
625
|
const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()).screenshotFile;
|
|
483
626
|
test.setActiveNoteScreenshot(lastScreenshot);
|
|
484
627
|
};
|
|
485
|
-
|
|
486
|
-
const dialogType = dialog.type();
|
|
487
|
-
const dialogMessage = dialog.message();
|
|
488
|
-
test.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
|
|
489
|
-
};
|
|
490
|
-
this.playwrightHelper?.page?.on('dialog', dialogHandler);
|
|
628
|
+
this.watchActiveTestPage();
|
|
491
629
|
codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
|
|
492
630
|
codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
|
|
493
631
|
codeceptjs.event.dispatcher.on('step.passed', (step) => stepHandler(step, 'passed'));
|
|
@@ -497,10 +635,96 @@ class Explorer {
|
|
|
497
635
|
codeceptjs.event.dispatcher.on('test.after', () => {
|
|
498
636
|
codeceptjs.event.dispatcher.off('step.passed', stepHandler);
|
|
499
637
|
codeceptjs.event.dispatcher.off('step.failed', stepHandler);
|
|
500
|
-
this.
|
|
638
|
+
this.unwatchActiveTestPages();
|
|
501
639
|
});
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
async ensurePageAvailable() {
|
|
643
|
+
const page = this.playwrightHelper?.page;
|
|
644
|
+
if (page && !page.isClosed?.()) {
|
|
645
|
+
this.watchActiveTestPage(page);
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
const recovered = await this.recoverFromBrowserError();
|
|
649
|
+
if (!recovered)
|
|
650
|
+
return false;
|
|
651
|
+
this.watchActiveTestPage();
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
async ensureActiveTestPageAvailable() {
|
|
655
|
+
return this.ensurePageAvailable();
|
|
656
|
+
}
|
|
657
|
+
async handleExecutionError(error) {
|
|
658
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
659
|
+
tag('error').log(`Browser execution error: ${message}`);
|
|
660
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
661
|
+
return {
|
|
662
|
+
action: 'stop',
|
|
663
|
+
message,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
if (error instanceof BrowserRecoveryError) {
|
|
667
|
+
return {
|
|
668
|
+
action: 'stop',
|
|
669
|
+
recovered: error.recovered,
|
|
670
|
+
message: error.message,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (!this.isFatalBrowserError(error)) {
|
|
674
|
+
return {
|
|
675
|
+
action: 'continue',
|
|
676
|
+
message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
let recovered = await this.recoverFromBrowserError();
|
|
680
|
+
if (!recovered)
|
|
681
|
+
recovered = await this.restartBrowser();
|
|
682
|
+
if (!recovered) {
|
|
683
|
+
return {
|
|
684
|
+
action: 'stop',
|
|
685
|
+
recovered: false,
|
|
686
|
+
message: `Browser could not be recovered after fatal error: ${message}`,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
this.watchActiveTestPage();
|
|
690
|
+
return {
|
|
691
|
+
action: 'continue',
|
|
692
|
+
recovered: true,
|
|
693
|
+
message: dedent `
|
|
694
|
+
Browser was recovered after a fatal page error.
|
|
695
|
+
Continue from the restored page.
|
|
696
|
+
The interrupted browser action is not product evidence.
|
|
697
|
+
Inspect the restored page and retry the current step when it is still required.
|
|
698
|
+
`,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
watchActiveTestPage(page = this.playwrightHelper?.page) {
|
|
702
|
+
if (!this._activeTest)
|
|
703
|
+
return;
|
|
704
|
+
if (!page)
|
|
705
|
+
return;
|
|
706
|
+
if (this.observedTestPages.has(page))
|
|
707
|
+
return;
|
|
708
|
+
this.testPageErrorHandler ||= (err) => {
|
|
709
|
+
this._activeTest?.addNote(`Console error: ${err.message}`, TestResult.FAILED);
|
|
710
|
+
};
|
|
711
|
+
this.testConsoleHandler ||= (msg) => {
|
|
712
|
+
if (msg.type() !== 'error')
|
|
713
|
+
return;
|
|
714
|
+
this._activeTest?.addNote(`Console error: ${msg.text()}`, TestResult.FAILED);
|
|
715
|
+
};
|
|
716
|
+
this.testDialogHandler ||= (dialog) => {
|
|
717
|
+
const dialogType = dialog.type();
|
|
718
|
+
const dialogMessage = dialog.message();
|
|
719
|
+
this._activeTest?.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
|
|
720
|
+
};
|
|
721
|
+
page.on('pageerror', this.testPageErrorHandler);
|
|
722
|
+
page.on('console', this.testConsoleHandler);
|
|
723
|
+
page.on('dialog', this.testDialogHandler);
|
|
724
|
+
this.observedTestPages.add(page);
|
|
502
725
|
}
|
|
503
726
|
async stopTest(test, meta) {
|
|
727
|
+
this.unwatchActiveTestPages();
|
|
504
728
|
this._activeTest = null;
|
|
505
729
|
const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
|
|
506
730
|
if (lastScreenshot) {
|
|
@@ -524,6 +748,17 @@ class Explorer {
|
|
|
524
748
|
codeceptjs.event.dispatcher.emit('test.finish', codeceptjsTest);
|
|
525
749
|
codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
|
|
526
750
|
}
|
|
751
|
+
unwatchActiveTestPages() {
|
|
752
|
+
for (const page of this.observedTestPages) {
|
|
753
|
+
if (this.testPageErrorHandler)
|
|
754
|
+
page.off('pageerror', this.testPageErrorHandler);
|
|
755
|
+
if (this.testConsoleHandler)
|
|
756
|
+
page.off('console', this.testConsoleHandler);
|
|
757
|
+
if (this.testDialogHandler)
|
|
758
|
+
page.off('dialog', this.testDialogHandler);
|
|
759
|
+
}
|
|
760
|
+
this.observedTestPages.clear();
|
|
761
|
+
}
|
|
527
762
|
async hasPlaywrightLocator(locatorFn, opts = {}) {
|
|
528
763
|
try {
|
|
529
764
|
const pwLocator = locatorFn(this.playwrightHelper.page);
|
|
@@ -584,7 +819,7 @@ class Explorer {
|
|
|
584
819
|
const newPage = await context.newPage();
|
|
585
820
|
await oldPage.close();
|
|
586
821
|
await newPage.bringToFront();
|
|
587
|
-
this.playwrightHelper.
|
|
822
|
+
await this.playwrightHelper._setPage(newPage);
|
|
588
823
|
this.otherTabs = [];
|
|
589
824
|
this.bindFrameNavigated(newPage);
|
|
590
825
|
if (this.xhrCapture) {
|
|
@@ -609,7 +844,7 @@ class Explorer {
|
|
|
609
844
|
debugLog(`Closed extra tab: ${await page.url()}`);
|
|
610
845
|
}
|
|
611
846
|
await firstPage.bringToFront();
|
|
612
|
-
this.playwrightHelper.
|
|
847
|
+
await this.playwrightHelper._setPage(firstPage);
|
|
613
848
|
debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
|
|
614
849
|
}
|
|
615
850
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
export class BrowserRecoveryError extends Error {
|
|
6
|
+
originalError;
|
|
7
|
+
recovered;
|
|
8
|
+
constructor(label, originalError, recovered) {
|
|
9
|
+
super(`${label} failed ${recovered ? 'after browser recovery' : 'because browser could not be recovered'}: ${browserErrorMessage(originalError)}`);
|
|
10
|
+
this.originalError = originalError;
|
|
11
|
+
this.recovered = recovered;
|
|
12
|
+
this.name = 'BrowserRecoveryError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function isFatalBrowserError(error) {
|
|
16
|
+
if (error instanceof BrowserRecoveryError)
|
|
17
|
+
return true;
|
|
18
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
19
|
+
return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
20
|
+
}
|
|
21
|
+
export function browserErrorMessage(error) {
|
|
22
|
+
return error instanceof Error ? error.message : String(error);
|
|
23
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { ActionResult } from '../action-result.js';
|
|
1
2
|
import { isBodyEmpty } from './html.js';
|
|
2
3
|
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'];
|
|
3
4
|
const SMALL_PAGE_THRESHOLD = 500;
|
|
4
5
|
const LOADING_WORD = /\bloading\b/i;
|
|
5
6
|
export function detectPageCondition(actionResult) {
|
|
7
|
+
if (actionResult.httpStatus && actionResult.httpStatus >= 400)
|
|
8
|
+
return 'error';
|
|
6
9
|
const headingFields = [actionResult.title, actionResult.h1, actionResult.h2].filter(Boolean);
|
|
7
10
|
for (const field of headingFields) {
|
|
8
11
|
for (const error of HTTP_ERRORS) {
|
|
@@ -29,13 +32,25 @@ export function detectPageCondition(actionResult) {
|
|
|
29
32
|
export function isErrorPage(actionResult) {
|
|
30
33
|
return detectPageCondition(actionResult) === 'error';
|
|
31
34
|
}
|
|
35
|
+
export function getStateErrorPageError(state) {
|
|
36
|
+
if (!state)
|
|
37
|
+
return null;
|
|
38
|
+
const actionResult = ActionResult.fromState(state);
|
|
39
|
+
if (!isErrorPage(actionResult))
|
|
40
|
+
return null;
|
|
41
|
+
return new ErrorPageError(actionResult.url, actionResult.title, actionResult.httpStatus);
|
|
42
|
+
}
|
|
32
43
|
export class ErrorPageError extends Error {
|
|
33
44
|
url;
|
|
34
45
|
title;
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
httpStatus;
|
|
47
|
+
constructor(url, title, httpStatus) {
|
|
48
|
+
const status = httpStatus ? `HTTP ${httpStatus}` : '';
|
|
49
|
+
const details = [status, title].filter(Boolean).join(', ');
|
|
50
|
+
super(`Error page detected at ${url}${details ? ` (${details})` : ''}`);
|
|
37
51
|
this.url = url;
|
|
38
52
|
this.title = title;
|
|
53
|
+
this.httpStatus = httpStatus;
|
|
39
54
|
this.name = 'ErrorPageError';
|
|
40
55
|
}
|
|
41
56
|
}
|
package/dist/src/utils/logger.js
CHANGED
|
@@ -72,7 +72,7 @@ class ConsoleDestination {
|
|
|
72
72
|
if (entry.type === 'step' && !this.verboseMode && this.recentSteps.shouldSuppress(entry.content))
|
|
73
73
|
return;
|
|
74
74
|
let content = entry.content;
|
|
75
|
-
if (entry.type === 'multiline') {
|
|
75
|
+
if (entry.type === 'multiline' || entry.type === 'details') {
|
|
76
76
|
const cleaned = stripAnsi(dedent(entry.content));
|
|
77
77
|
const parsed = parseMarkdownToTerminal(cleaned);
|
|
78
78
|
content = parsed;
|
|
@@ -280,7 +280,7 @@ class CaptainDestination {
|
|
|
280
280
|
}
|
|
281
281
|
stopCapture() {
|
|
282
282
|
this.capturing = false;
|
|
283
|
-
const logs = this.entries.filter((e) => e.type !== 'debug' && e.type !== 'html' && e.type !== 'multiline' && e.type !== 'operation').map((e) => `[${e.type}] ${e.content}`);
|
|
283
|
+
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}`);
|
|
284
284
|
this.entries = [];
|
|
285
285
|
return logs;
|
|
286
286
|
}
|
package/package.json
CHANGED
package/src/action-result.ts
CHANGED
|
@@ -52,6 +52,7 @@ export interface ToolResultMetadata {
|
|
|
52
52
|
export class ActionResult implements ActionResultData {
|
|
53
53
|
public id?: number;
|
|
54
54
|
public title = '';
|
|
55
|
+
public httpStatus: number | undefined = undefined;
|
|
55
56
|
public error: string | null = null;
|
|
56
57
|
public timestamp: Date = new Date();
|
|
57
58
|
public h1: string | undefined = undefined;
|
|
@@ -82,6 +83,7 @@ export class ActionResult implements ActionResultData {
|
|
|
82
83
|
this.url = data.url ?? '';
|
|
83
84
|
this.fullUrl = data.fullUrl;
|
|
84
85
|
this.title = data.title ?? '';
|
|
86
|
+
this.httpStatus = data.httpStatus;
|
|
85
87
|
this.error = data.error ?? null;
|
|
86
88
|
this.browserLogs = data.browserLogs ?? [];
|
|
87
89
|
this.iframeSnapshots = data.iframeSnapshots ?? [];
|