explorbot 0.1.26 → 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/cache.js +8 -0
- package/dist/src/ai/researcher/coordinates.js +2 -3
- package/dist/src/ai/researcher/deep-analysis.js +149 -65
- 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/cache.ts +7 -0
- package/src/ai/researcher/coordinates.ts +2 -3
- package/src/ai/researcher/deep-analysis.ts +169 -72
- 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/ai/tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { ActionResult } from "../action-result.js";
|
|
5
5
|
import { TestResult } from '../test-plan.js';
|
|
6
6
|
import { extractFocusedElement } from "../utils/aria.js";
|
|
7
|
+
import { isFatalBrowserError } from "../utils/browser-errors.js";
|
|
7
8
|
import { createDebug, tag } from '../utils/logger.js';
|
|
8
9
|
import { pause } from '../utils/loop.js';
|
|
9
10
|
import { WebElement } from "../utils/web-element.js";
|
|
@@ -243,6 +244,7 @@ export function createCodeceptJSTools(explorer, task) {
|
|
|
243
244
|
});
|
|
244
245
|
}
|
|
245
246
|
catch (error) {
|
|
247
|
+
throwIfFatalBrowserError(error);
|
|
246
248
|
activeNote.commit(TestResult.FAILED);
|
|
247
249
|
const errorMessage = error instanceof Error ? error.toString() : 'Unknown error occurred';
|
|
248
250
|
return failedToolResult('pressKey', `PressKey tool failed: ${errorMessage}`);
|
|
@@ -343,6 +345,7 @@ export function createCodeceptJSTools(explorer, task) {
|
|
|
343
345
|
}, action);
|
|
344
346
|
}
|
|
345
347
|
catch (error) {
|
|
348
|
+
throwIfFatalBrowserError(error);
|
|
346
349
|
activeNote.commit(TestResult.FAILED);
|
|
347
350
|
const errorMessage = error instanceof Error ? error.toString() : 'Unknown error occurred';
|
|
348
351
|
return failedToolResult('form', `Form tool failed: ${errorMessage}`);
|
|
@@ -376,8 +379,7 @@ export function createSpecialContextTools(explorer, context) {
|
|
|
376
379
|
});
|
|
377
380
|
}
|
|
378
381
|
await explorer.switchToMainFrame();
|
|
379
|
-
const
|
|
380
|
-
const nextState = await action.capturePageState();
|
|
382
|
+
const nextState = await explorer.capturePageState();
|
|
381
383
|
const toolResult = await nextState.toToolResult(previousState, 'I.switchTo()');
|
|
382
384
|
return successToolResult('exitIframe', {
|
|
383
385
|
...toolResult,
|
|
@@ -386,6 +388,7 @@ export function createSpecialContextTools(explorer, context) {
|
|
|
386
388
|
});
|
|
387
389
|
}
|
|
388
390
|
catch (error) {
|
|
391
|
+
throwIfFatalBrowserError(error);
|
|
389
392
|
const errorMessage = error instanceof Error ? error.toString() : 'Unknown error occurred';
|
|
390
393
|
return failedToolResult('exitIframe', `Failed to exit iframe: ${errorMessage}`);
|
|
391
394
|
}
|
|
@@ -415,8 +418,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
415
418
|
return failedToolResult('see', 'Vision tools are disabled for this session. Use context() to get fresh ARIA snapshot and analyze page state from ARIA data.');
|
|
416
419
|
}
|
|
417
420
|
try {
|
|
418
|
-
const
|
|
419
|
-
const actionResult = await action.caputrePageWithScreenshot();
|
|
421
|
+
const actionResult = await explorer.capturePageWithScreenshot();
|
|
420
422
|
if (!actionResult.screenshot) {
|
|
421
423
|
return failedToolResult('see', 'Failed to capture screenshot for analysis');
|
|
422
424
|
}
|
|
@@ -431,6 +433,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
431
433
|
});
|
|
432
434
|
}
|
|
433
435
|
catch (error) {
|
|
436
|
+
throwIfFatalBrowserError(error);
|
|
434
437
|
const errorMessage = error instanceof Error ? error.toString() : 'Unknown error occurred';
|
|
435
438
|
visionDisabled = true;
|
|
436
439
|
tag('warning').log('⚠️ Vision model is not available. Visual checks are disabled for this session.');
|
|
@@ -506,8 +509,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
506
509
|
suggestion: verifications[assertion] ? 'This verification already passed. Call finish() to complete the test.' : 'This verification already failed. Perform actions to change the page state, then try again.',
|
|
507
510
|
});
|
|
508
511
|
}
|
|
509
|
-
const
|
|
510
|
-
const actionResult = await action.capturePageState();
|
|
512
|
+
const actionResult = await explorer.capturePageState();
|
|
511
513
|
const result = await navigator.verifyState(assertion, actionResult);
|
|
512
514
|
if (result.verified) {
|
|
513
515
|
return successToolResult('verify', {
|
|
@@ -520,6 +522,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
520
522
|
});
|
|
521
523
|
}
|
|
522
524
|
catch (error) {
|
|
525
|
+
throwIfFatalBrowserError(error);
|
|
523
526
|
const errorMessage = error instanceof Error ? error.toString() : 'Unknown error occurred';
|
|
524
527
|
return failedToolResult('verify', `Verify tool failed: ${errorMessage}`, {
|
|
525
528
|
error: errorMessage,
|
|
@@ -572,6 +575,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
572
575
|
});
|
|
573
576
|
}
|
|
574
577
|
catch (error) {
|
|
578
|
+
throwIfFatalBrowserError(error);
|
|
575
579
|
const errorMessage = error instanceof Error ? error.toString() : 'Unknown error occurred';
|
|
576
580
|
return failedToolResult('research', `Research tool failed: ${errorMessage}`, {
|
|
577
581
|
error: errorMessage,
|
|
@@ -611,6 +615,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
611
615
|
});
|
|
612
616
|
}
|
|
613
617
|
catch (error) {
|
|
618
|
+
throwIfFatalBrowserError(error);
|
|
614
619
|
const errorMessage = error instanceof Error ? error.toString() : 'Unknown error occurred';
|
|
615
620
|
return failedToolResult('interact', `Interact tool failed: ${errorMessage}`, {
|
|
616
621
|
error: errorMessage,
|
|
@@ -645,7 +650,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
645
650
|
}
|
|
646
651
|
const previousState = ActionResult.fromState(currentState);
|
|
647
652
|
const action = explorer.createAction();
|
|
648
|
-
const actionResult = await
|
|
653
|
+
const actionResult = await explorer.capturePageWithScreenshot();
|
|
649
654
|
if (!actionResult.screenshot) {
|
|
650
655
|
return failedToolResult('visualClick', 'Failed to capture screenshot for visual analysis');
|
|
651
656
|
}
|
|
@@ -678,6 +683,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
|
|
|
678
683
|
});
|
|
679
684
|
}
|
|
680
685
|
catch (error) {
|
|
686
|
+
throwIfFatalBrowserError(error);
|
|
681
687
|
const errorMessage = error instanceof Error ? error.toString() : 'Unknown error occurred';
|
|
682
688
|
visionDisabled = true;
|
|
683
689
|
tag('warning').log('⚠️ Vision model is not available. Visual clicks are disabled for this session.');
|
|
@@ -874,6 +880,10 @@ function cap(text, max) {
|
|
|
874
880
|
return text;
|
|
875
881
|
return `${text.slice(0, max)}\n[...truncated; ${text.length - max} chars omitted...]`;
|
|
876
882
|
}
|
|
883
|
+
function throwIfFatalBrowserError(error) {
|
|
884
|
+
if (isFatalBrowserError(error))
|
|
885
|
+
throw error;
|
|
886
|
+
}
|
|
877
887
|
function transformContainsCommand(command) {
|
|
878
888
|
if (!command.includes(':contains('))
|
|
879
889
|
return command;
|
|
@@ -5,7 +5,7 @@ import { normalizeUrl } from '../state-manager.js';
|
|
|
5
5
|
import { Stats } from '../stats.js';
|
|
6
6
|
import { TestResult } from '../test-plan.js';
|
|
7
7
|
import { getCliName } from "../utils/cli-name.js";
|
|
8
|
-
import { ErrorPageError } from "../utils/error-page.js";
|
|
8
|
+
import { ErrorPageError, getStateErrorPageError } from "../utils/error-page.js";
|
|
9
9
|
import { tag } from '../utils/logger.js';
|
|
10
10
|
import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
|
|
11
11
|
import { safeFilename } from "../utils/strings.js";
|
|
@@ -53,6 +53,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
53
53
|
Stats.mode ??= 'explore';
|
|
54
54
|
Stats.focus ??= feature;
|
|
55
55
|
const mainUrl = this.getCurrentPageUrl();
|
|
56
|
+
const error = getStateErrorPageError(this.explorBot.getExplorer().getStateManager().getCurrentState());
|
|
57
|
+
if (error) {
|
|
58
|
+
tag('warning').log(error.message);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
56
61
|
if (cfg.enabled) {
|
|
57
62
|
await this.runReuseMode(mainUrl, feature, cfg);
|
|
58
63
|
}
|
|
@@ -94,6 +94,7 @@ const LogPane = React.memo(({ verboseMode }) => {
|
|
|
94
94
|
case 'step':
|
|
95
95
|
return { color: 'cyan', dimColor: true };
|
|
96
96
|
case 'multiline':
|
|
97
|
+
case 'details':
|
|
97
98
|
return { color: 'gray', dimColor: true };
|
|
98
99
|
case 'html':
|
|
99
100
|
return { color: 'gray' };
|
|
@@ -113,14 +114,14 @@ const LogPane = React.memo(({ verboseMode }) => {
|
|
|
113
114
|
return null;
|
|
114
115
|
}
|
|
115
116
|
const styles = getLogStyles(log.type);
|
|
116
|
-
if (log.type === 'multiline') {
|
|
117
|
+
if (log.type === 'multiline' || log.type === 'details') {
|
|
117
118
|
const cleaned = stripAnsi(dedent(log.content));
|
|
118
119
|
const parsed = parseMarkdownToTerminal(cleaned);
|
|
119
120
|
const lines = parsed.split('\n');
|
|
120
121
|
const maxLines = log.maxLines || 16;
|
|
121
|
-
const
|
|
122
|
+
const content = log.type === 'details' ? parsed : lines.length > maxLines ? `${lines.slice(0, maxLines).join('\n')}\n... (${lines.length - maxLines} more lines)` : parsed;
|
|
122
123
|
return (React.createElement(Box, { key: index, borderStyle: "classic", borderLeft: false, borderRight: false, marginY: 1, padding: 1, borderColor: "dim", overflow: "hidden" },
|
|
123
|
-
React.createElement(Text, { color: "gray", dimColor: true },
|
|
124
|
+
React.createElement(Text, { color: "gray", dimColor: true }, content)));
|
|
124
125
|
}
|
|
125
126
|
if (log.type === 'html') {
|
|
126
127
|
// Convert HTML to markdown, then render as multiline
|
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
|
}
|