explorbot 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -5
- package/bin/explorbot-cli.ts +29 -7
- package/boat/api-tester/src/config.ts +45 -3
- package/boat/doc-collector/src/ai/documentarian.ts +8 -2
- package/boat/doc-collector/src/ai/tools.ts +29 -16
- package/boat/doc-collector/src/cli.ts +2 -6
- package/boat/doc-collector/src/config.ts +2 -0
- package/boat/doc-collector/src/docbot.ts +42 -14
- package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
- package/boat/doc-collector/src/screenshots.ts +24 -16
- package/dist/bin/explorbot-cli.js +26 -6
- package/dist/boat/api-tester/src/config.js +43 -4
- package/dist/boat/doc-collector/src/ai/documentarian.js +7 -2
- package/dist/boat/doc-collector/src/ai/tools.js +21 -15
- package/dist/boat/doc-collector/src/cli.js +2 -5
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +39 -13
- package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
- package/dist/boat/doc-collector/src/screenshots.js +25 -16
- package/dist/models.json +30 -0
- package/dist/package.json +8 -2
- package/dist/src/action.d.ts +11 -3
- package/dist/src/action.js +22 -5
- package/dist/src/ai/agent.d.ts +17 -0
- package/dist/src/ai/captain/idle-mode.js +1 -1
- package/dist/src/ai/captain/test-mode.js +1 -1
- package/dist/src/ai/captain/web-mode.js +18 -22
- package/dist/src/ai/captain.js +13 -26
- package/dist/src/ai/driller.d.ts +2 -13
- package/dist/src/ai/driller.js +23 -38
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.d.ts +3 -2
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/historian/screencast.d.ts +2 -1
- package/dist/src/ai/historian/screencast.js +2 -2
- package/dist/src/ai/historian.d.ts +5 -4
- package/dist/src/ai/navigator.d.ts +6 -2
- package/dist/src/ai/navigator.js +26 -22
- package/dist/src/ai/pilot.d.ts +13 -6
- package/dist/src/ai/pilot.js +49 -44
- package/dist/src/ai/planner.d.ts +3 -2
- package/dist/src/ai/planner.js +19 -17
- package/dist/src/ai/provider.js +0 -1
- package/dist/src/ai/quartermaster.d.ts +3 -2
- package/dist/src/ai/quartermaster.js +4 -4
- package/dist/src/ai/rerunner.d.ts +2 -11
- package/dist/src/ai/rerunner.js +9 -22
- package/dist/src/ai/researcher/coordinates.js +4 -3
- package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
- package/dist/src/ai/researcher/deep-analysis.js +6 -6
- package/dist/src/ai/researcher/locators.d.ts +9 -2
- package/dist/src/ai/researcher/locators.js +65 -8
- package/dist/src/ai/researcher/sections.d.ts +2 -0
- package/dist/src/ai/researcher/sections.js +2 -2
- package/dist/src/ai/researcher.js +29 -54
- package/dist/src/ai/task-agent.d.ts +18 -5
- package/dist/src/ai/task-agent.js +35 -6
- package/dist/src/ai/tester.d.ts +6 -12
- package/dist/src/ai/tester.js +37 -51
- package/dist/src/ai/tools.d.ts +11 -10
- package/dist/src/ai/tools.js +24 -36
- package/dist/src/command-handler.js +1 -1
- package/dist/src/commands/context-aria-command.js +1 -1
- package/dist/src/commands/context-command.js +7 -5
- package/dist/src/commands/context-data-command.js +2 -2
- package/dist/src/commands/context-experience-command.js +2 -2
- package/dist/src/commands/context-html-command.js +2 -2
- package/dist/src/commands/context-knowledge-command.js +1 -1
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.js +4 -3
- package/dist/src/commands/freesail-command.js +2 -2
- package/dist/src/commands/learn-command.js +4 -4
- package/dist/src/commands/path-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/test-command.js +1 -1
- package/dist/src/components/App.js +1 -1
- package/dist/src/config.d.ts +17 -0
- package/dist/src/config.js +144 -10
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.d.ts +17 -8
- package/dist/src/explorbot.js +74 -36
- package/dist/src/explorer.d.ts +67 -91
- package/dist/src/explorer.js +327 -567
- package/dist/src/state-manager.d.ts +6 -1
- package/dist/src/state-manager.js +1 -0
- package/dist/src/utils/hooks-runner.js +2 -4
- package/dist/src/utils/test-files.js +1 -1
- package/dist/src/utils/web-annotate.d.ts +5 -0
- package/dist/src/utils/web-annotate.js +58 -0
- package/dist/src/utils/web-eidx.d.ts +2 -0
- package/dist/src/utils/web-eidx.js +20 -0
- package/dist/src/utils/web-element.d.ts +1 -0
- package/dist/src/utils/web-element.js +29 -0
- package/models.json +30 -0
- package/package.json +8 -2
- package/src/action.ts +25 -5
- package/src/ai/agent.ts +20 -0
- package/src/ai/captain/idle-mode.ts +1 -1
- package/src/ai/captain/test-mode.ts +1 -1
- package/src/ai/captain/web-mode.ts +18 -23
- package/src/ai/captain.ts +12 -25
- package/src/ai/driller.ts +50 -67
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +4 -3
- package/src/ai/historian/screencast.ts +4 -3
- package/src/ai/historian.ts +3 -2
- package/src/ai/navigator.ts +30 -25
- package/src/ai/pilot.ts +55 -47
- package/src/ai/planner.ts +22 -20
- package/src/ai/provider.ts +0 -1
- package/src/ai/quartermaster.ts +5 -4
- package/src/ai/rerunner.ts +10 -29
- package/src/ai/researcher/coordinates.ts +4 -3
- package/src/ai/researcher/deep-analysis.ts +8 -6
- package/src/ai/researcher/locators.ts +66 -9
- package/src/ai/researcher/sections.ts +4 -2
- package/src/ai/researcher.ts +32 -61
- package/src/ai/task-agent.ts +45 -11
- package/src/ai/tester.ts +40 -57
- package/src/ai/tools.ts +32 -53
- package/src/command-handler.ts +1 -1
- package/src/commands/context-aria-command.ts +1 -1
- package/src/commands/context-command.ts +7 -5
- package/src/commands/context-data-command.ts +2 -2
- package/src/commands/context-experience-command.ts +2 -2
- package/src/commands/context-html-command.ts +2 -2
- package/src/commands/context-knowledge-command.ts +1 -1
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +4 -3
- package/src/commands/freesail-command.ts +2 -2
- package/src/commands/learn-command.ts +4 -4
- package/src/commands/path-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/test-command.ts +1 -1
- package/src/components/App.tsx +1 -1
- package/src/config.ts +173 -11
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +78 -39
- package/src/explorer.ts +388 -626
- package/src/state-manager.ts +7 -1
- package/src/utils/hooks-runner.ts +2 -4
- package/src/utils/test-files.ts +1 -1
- package/src/utils/web-annotate.ts +64 -0
- package/src/utils/web-eidx.ts +21 -0
- package/src/utils/web-element.ts +30 -0
package/src/explorer.ts
CHANGED
|
@@ -1,31 +1,26 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import path
|
|
2
|
+
import path from 'node:path';
|
|
3
3
|
// @ts-ignore
|
|
4
4
|
import * as codeceptjs from 'codeceptjs';
|
|
5
5
|
import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
6
6
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
7
7
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
8
8
|
import dedent from 'dedent';
|
|
9
|
-
import type { BrowserContextOptions } from 'playwright';
|
|
9
|
+
import type { BrowserContextOptions, Page } from 'playwright';
|
|
10
10
|
import { ActionResult } from './action-result.ts';
|
|
11
11
|
import Action from './action.js';
|
|
12
|
-
import {
|
|
13
|
-
import { visuallyAnnotateContainers } from './ai/researcher/coordinates.ts';
|
|
14
|
-
import { RequestStore } from './api/request-store.ts';
|
|
12
|
+
import type { RequestStore } from './api/request-store.ts';
|
|
15
13
|
import { XhrCapture } from './api/xhr-capture.ts';
|
|
16
14
|
import type { ExplorbotConfig } from './config.js';
|
|
17
15
|
import { ConfigParser } from './config.js';
|
|
18
|
-
import type {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { StateManager } from './state-manager.js';
|
|
16
|
+
import type { KnowledgeTracker } from './knowledge-tracker.js';
|
|
17
|
+
import type { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
18
|
+
import type { Reporter } from './reporter.ts';
|
|
19
|
+
import type { StateManager } from './state-manager.js';
|
|
23
20
|
import { Test, TestResult } from './test-plan.ts';
|
|
24
21
|
import { BrowserRecoveryError, browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
|
|
25
|
-
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './utils/html.ts';
|
|
26
22
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
27
23
|
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
|
|
28
|
-
import { WebElement } from './utils/web-element.ts';
|
|
29
24
|
|
|
30
25
|
declare global {
|
|
31
26
|
namespace NodeJS {
|
|
@@ -43,66 +38,219 @@ declare namespace CodeceptJS {
|
|
|
43
38
|
|
|
44
39
|
const debugLog = createDebug('explorbot:explorer');
|
|
45
40
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
46
|
-
|
|
47
|
-
interface TabInfo {
|
|
48
|
-
url: string;
|
|
49
|
-
title: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
interface BrowserExecutionErrorResult {
|
|
53
|
-
action: 'continue' | 'stop';
|
|
54
|
-
message: string;
|
|
55
|
-
recovered?: boolean;
|
|
56
|
-
}
|
|
41
|
+
const RECOVERY_NAVIGATION = { waitUntil: 'domcontentloaded', timeout: 10000 } as const;
|
|
57
42
|
|
|
58
43
|
class Explorer {
|
|
59
|
-
private
|
|
60
|
-
|
|
61
|
-
|
|
44
|
+
private config: ExplorbotConfig;
|
|
45
|
+
private options?: ExplorerOptions;
|
|
46
|
+
private stateManager: StateManager;
|
|
47
|
+
private knowledgeTracker: KnowledgeTracker;
|
|
48
|
+
private reporter: Reporter;
|
|
49
|
+
private requestStore: RequestStore;
|
|
50
|
+
private playwrightRecorder: PlaywrightRecorder;
|
|
51
|
+
private playwrightHelper: any;
|
|
52
|
+
private _actor!: CodeceptJS.I;
|
|
53
|
+
private started = false;
|
|
62
54
|
private isSharedBrowser = false;
|
|
63
|
-
actor!: CodeceptJS.I;
|
|
64
|
-
private stateManager!: StateManager;
|
|
65
|
-
private knowledgeTracker!: KnowledgeTracker;
|
|
66
|
-
config: ExplorbotConfig;
|
|
67
|
-
private options?: { show?: boolean; headless?: boolean; incognito?: boolean; session?: string };
|
|
68
|
-
private reporter!: Reporter;
|
|
69
|
-
private otherTabs: TabInfo[] = [];
|
|
70
|
-
private _activeTest: Test | null = null;
|
|
71
55
|
private xhrCapture: XhrCapture | null = null;
|
|
72
|
-
private
|
|
73
|
-
private playwrightRecorder: PlaywrightRecorder = new PlaywrightRecorder();
|
|
56
|
+
private _activeTest: Test | null = null;
|
|
74
57
|
private observedTestPages = new Set<any>();
|
|
75
58
|
private testPageErrorHandler: ((error: Error) => void) | null = null;
|
|
76
59
|
private testConsoleHandler: ((message: any) => void) | null = null;
|
|
77
60
|
private testDialogHandler: ((dialog: any) => void) | null = null;
|
|
61
|
+
private eventDispatcher = codeceptjs.event.dispatcher;
|
|
78
62
|
|
|
79
|
-
constructor(config: ExplorbotConfig,
|
|
63
|
+
constructor(config: ExplorbotConfig, options: ExplorerOptions | undefined, deps: ExplorerDeps) {
|
|
80
64
|
this.config = config;
|
|
81
|
-
this.aiProvider = aiProvider;
|
|
82
65
|
this.options = options;
|
|
66
|
+
this.stateManager = deps.stateManager;
|
|
67
|
+
this.knowledgeTracker = deps.knowledgeTracker;
|
|
68
|
+
this.reporter = deps.reporter;
|
|
69
|
+
this.requestStore = deps.requestStore;
|
|
70
|
+
this.playwrightRecorder = deps.playwrightRecorder;
|
|
83
71
|
this.initializeContainer();
|
|
84
|
-
this.knowledgeTracker = knowledgeTracker;
|
|
85
|
-
this.stateManager = new StateManager(experienceTracker, knowledgeTracker);
|
|
86
|
-
this.reporter = new Reporter(config.reporter, this.stateManager);
|
|
87
72
|
}
|
|
88
73
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
74
|
+
get actor(): CodeceptJS.I {
|
|
75
|
+
return this._actor;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get page(): Page | null {
|
|
79
|
+
const page = this.playwrightHelper?.page;
|
|
80
|
+
if (!page || page.isClosed?.()) return null;
|
|
81
|
+
return page;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get activeTest(): Test | null {
|
|
85
|
+
return this._activeTest;
|
|
86
|
+
}
|
|
96
87
|
|
|
97
|
-
|
|
88
|
+
async start(): Promise<void> {
|
|
89
|
+
if (this.started) return;
|
|
98
90
|
|
|
99
|
-
|
|
91
|
+
await codeceptjs.recorder.start();
|
|
92
|
+
await codeceptjs.container.started(null);
|
|
93
|
+
storeListener();
|
|
94
|
+
stepsListener();
|
|
100
95
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
96
|
+
codeceptjs.recorder.retry({
|
|
97
|
+
retries: this.config.action?.retries || 3,
|
|
98
|
+
when: (err: any) => !!err?.message?.includes?.('context'),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.playwrightHelper = codeceptjs.container.helpers('Playwright');
|
|
102
|
+
if (!this.playwrightHelper) {
|
|
103
|
+
throw new Error('Playwright helper not available');
|
|
104
|
+
}
|
|
105
|
+
await this.connectOrLaunchBrowser();
|
|
106
|
+
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
107
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
108
|
+
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
109
|
+
this.attachXhrCapture();
|
|
110
|
+
if (hasSession) {
|
|
111
|
+
tag('info').log(`Session restored from ${path.relative(process.cwd(), this.options!.session!)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this._actor = codeceptjs.container.support('I');
|
|
115
|
+
this.started = true;
|
|
116
|
+
|
|
117
|
+
this.listenToStateChanged();
|
|
118
|
+
|
|
119
|
+
codeceptjs.event.dispatcher.emit('global.before');
|
|
120
|
+
tag('success').log('Browser started, ready to explore');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async stop(): Promise<void> {
|
|
124
|
+
if (!this.started) return;
|
|
125
|
+
this.started = false;
|
|
126
|
+
|
|
127
|
+
await this.stopCaptures();
|
|
128
|
+
|
|
129
|
+
if (this.options?.session && this.playwrightHelper?.browserContext) {
|
|
130
|
+
const dir = path.dirname(this.options.session);
|
|
131
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
132
|
+
await this.playwrightHelper.browserContext.storageState({ path: this.options.session });
|
|
133
|
+
debugLog(`Session saved to ${path.relative(process.cwd(), this.options.session)}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
codeceptjs.event.dispatcher.emit('global.after');
|
|
137
|
+
codeceptjs.event.dispatcher.emit('global.result');
|
|
138
|
+
|
|
139
|
+
if (!this.isSharedBrowser) {
|
|
140
|
+
await Promise.all([this.reporter.finishRun(), this.playwrightHelper._stopBrowser(), codeceptjs.recorder.stop()]);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
tag('info').log('Closing browser context (persistent browser stays running)');
|
|
145
|
+
await this.closeBrowserContext();
|
|
146
|
+
this.playwrightHelper.browser = null;
|
|
147
|
+
this.playwrightHelper.isRunning = false;
|
|
148
|
+
await Promise.all([this.reporter.finishRun(), codeceptjs.recorder.stop()]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
action(): Action {
|
|
152
|
+
return new Action(this._actor, this.stateManager, this.playwrightRecorder, (fn) => this.runWithRecovery('action', fn));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async visit(url: string, opts: CaptureOpts = {}): Promise<ActionResult> {
|
|
156
|
+
return this.runWithRecovery('visit', async () => {
|
|
157
|
+
const action = await this.visitOnce(url);
|
|
158
|
+
if (opts.screenshot) return action.capturePageState({ includeScreenshot: true });
|
|
159
|
+
return action.getActionResult() ?? action.capturePageState();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async capture(opts: CaptureOpts = {}): Promise<ActionResult> {
|
|
164
|
+
return this.action().capturePageState({ includeScreenshot: opts.screenshot });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async withPage<T>(fn: (page: Page) => Promise<T>): Promise<T> {
|
|
168
|
+
return this.runWithRecovery('page operation', () => fn(this.playwrightHelper.page));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async recover(error?: unknown): Promise<Recovery> {
|
|
172
|
+
if (error) return this.recoverFromExecutionError(error);
|
|
173
|
+
|
|
174
|
+
if (this.page) {
|
|
175
|
+
this.watchActiveTestPage();
|
|
176
|
+
return { ok: true, action: 'continue', message: 'Page is available' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!(await this.recoverOrRestart())) {
|
|
180
|
+
return { ok: false, action: 'stop', message: 'Browser page could not be recovered' };
|
|
181
|
+
}
|
|
182
|
+
this.watchActiveTestPage();
|
|
183
|
+
return { ok: true, action: 'continue', recovered: true, message: 'Browser page was recovered' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async beginTest(test: Test): Promise<TestRun> {
|
|
187
|
+
if (!this.page && !(await this.recoverOrRestart())) {
|
|
188
|
+
return { started: false, stop: async () => {} };
|
|
105
189
|
}
|
|
190
|
+
|
|
191
|
+
this._activeTest = test;
|
|
192
|
+
test.start();
|
|
193
|
+
await this.reporter.reportTestStart(test);
|
|
194
|
+
await this.closeOtherTabs();
|
|
195
|
+
this.stateManager.otherTabs = [];
|
|
196
|
+
|
|
197
|
+
const codeceptjsTest = toCodeceptjsTest(test);
|
|
198
|
+
|
|
199
|
+
const stepHandler = (step: any, status?: string, error?: string, log?: string) => {
|
|
200
|
+
if (!step.toCode) return;
|
|
201
|
+
if (step?.name?.startsWith('grab')) return;
|
|
202
|
+
if (step?.name?.startsWith('save')) return;
|
|
203
|
+
|
|
204
|
+
test.addStep(step.toCode(), step.duration, status, error, log);
|
|
205
|
+
|
|
206
|
+
if (!this.stateManager.getCurrentState()) return;
|
|
207
|
+
|
|
208
|
+
const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()!).screenshotFile;
|
|
209
|
+
test.setActiveNoteScreenshot(lastScreenshot);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
this.watchActiveTestPage();
|
|
213
|
+
|
|
214
|
+
const onStepPassed = (step: any) => stepHandler(step, 'passed');
|
|
215
|
+
const onStepFailed = (step: any, error: any) => {
|
|
216
|
+
stepHandler(step, 'failed', error?.message || String(error), error?.stack);
|
|
217
|
+
};
|
|
218
|
+
const onTestAfter = () => {
|
|
219
|
+
this.eventDispatcher.off('step.passed', onStepPassed);
|
|
220
|
+
this.eventDispatcher.off('step.failed', onStepFailed);
|
|
221
|
+
this.eventDispatcher.off('test.after', onTestAfter);
|
|
222
|
+
this.unwatchActiveTestPages();
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
this.eventDispatcher.emit('test.before', codeceptjsTest);
|
|
226
|
+
this.eventDispatcher.emit('test.start', codeceptjsTest);
|
|
227
|
+
this.eventDispatcher.on('step.passed', onStepPassed);
|
|
228
|
+
this.eventDispatcher.on('step.failed', onStepFailed);
|
|
229
|
+
this.eventDispatcher.on('test.after', onTestAfter);
|
|
230
|
+
|
|
231
|
+
return { started: true, stop: (meta) => this.finishTest(test, meta) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async openTab(): Promise<void> {
|
|
235
|
+
const oldPage = this.playwrightHelper?.page;
|
|
236
|
+
if (!oldPage) return;
|
|
237
|
+
|
|
238
|
+
await this.activateNewPage(oldPage.context());
|
|
239
|
+
await oldPage.close();
|
|
240
|
+
this.stateManager.otherTabs = [];
|
|
241
|
+
|
|
242
|
+
debugLog('Opened fresh tab, closed previous tab');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private initializeContainer() {
|
|
246
|
+
const configParser = ConfigParser.getInstance();
|
|
247
|
+
const projectRoot = configParser.getProjectRoot();
|
|
248
|
+
(global as any).output_dir = configParser.getStatesDir();
|
|
249
|
+
(global as any).codecept_dir = projectRoot;
|
|
250
|
+
|
|
251
|
+
configParser.validateConfig(this.config);
|
|
252
|
+
|
|
253
|
+
codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
|
|
106
254
|
}
|
|
107
255
|
|
|
108
256
|
private convertToCodeceptConfig(config: ExplorbotConfig): any {
|
|
@@ -160,99 +308,6 @@ class Explorer {
|
|
|
160
308
|
return codeceptConfig;
|
|
161
309
|
}
|
|
162
310
|
|
|
163
|
-
public getConfig(): ExplorbotConfig {
|
|
164
|
-
return this.config;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
public getAIProvider(): AIProvider {
|
|
168
|
-
return this.aiProvider;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
public getStateManager(): StateManager {
|
|
172
|
-
return this.stateManager;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
public getKnowledgeTracker(): KnowledgeTracker {
|
|
176
|
-
return this.knowledgeTracker;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
public getReporter(): Reporter {
|
|
180
|
-
return this.reporter;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
public getRequestStore(): RequestStore | null {
|
|
184
|
-
return this.requestStore;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async extractCookies(): Promise<Record<string, string>> {
|
|
188
|
-
if (!this.playwrightHelper?.browserContext) return {};
|
|
189
|
-
try {
|
|
190
|
-
const cookies = await this.playwrightHelper.browserContext.cookies();
|
|
191
|
-
if (!cookies.length) return {};
|
|
192
|
-
const cookieString = cookies.map((c: any) => `${c.name}=${c.value}`).join('; ');
|
|
193
|
-
return { Cookie: cookieString };
|
|
194
|
-
} catch {
|
|
195
|
-
return {};
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
private setupXhrCapture(reuseRequestStore = false): void {
|
|
200
|
-
const configParser = ConfigParser.getInstance();
|
|
201
|
-
const outputDir = configParser.getOutputDir();
|
|
202
|
-
if (!reuseRequestStore || !this.requestStore) {
|
|
203
|
-
this.requestStore = new RequestStore(outputDir);
|
|
204
|
-
}
|
|
205
|
-
const baseUrl = this.config.playwright.url;
|
|
206
|
-
this.xhrCapture = new XhrCapture(this.requestStore!, baseUrl);
|
|
207
|
-
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async start() {
|
|
211
|
-
if (this.isStarted) {
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
await codeceptjs.recorder.start();
|
|
216
|
-
await codeceptjs.container.started(null);
|
|
217
|
-
storeListener();
|
|
218
|
-
stepsListener();
|
|
219
|
-
|
|
220
|
-
codeceptjs.recorder.retry({
|
|
221
|
-
retries: this.config.action?.retries || 3,
|
|
222
|
-
when: (err: any) => {
|
|
223
|
-
if (!err || typeof err.message !== 'string') {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
// ignore context errors
|
|
227
|
-
return err.message.includes('context');
|
|
228
|
-
},
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
this.playwrightHelper = codeceptjs.container.helpers('Playwright');
|
|
232
|
-
if (!this.playwrightHelper) {
|
|
233
|
-
throw new Error('Playwright helper not available');
|
|
234
|
-
}
|
|
235
|
-
await this.connectOrLaunchBrowser();
|
|
236
|
-
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
237
|
-
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
238
|
-
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
239
|
-
this.setupXhrCapture();
|
|
240
|
-
if (hasSession) {
|
|
241
|
-
tag('info').log(`Session restored from ${path.relative(process.cwd(), this.options!.session!)}`);
|
|
242
|
-
}
|
|
243
|
-
const I = codeceptjs.container.support('I');
|
|
244
|
-
|
|
245
|
-
this.actor = I;
|
|
246
|
-
this.isStarted = true;
|
|
247
|
-
|
|
248
|
-
this.listenToStateChanged();
|
|
249
|
-
|
|
250
|
-
codeceptjs.event.dispatcher.emit('global.before');
|
|
251
|
-
tag('success').log('Browser started, ready to explore');
|
|
252
|
-
|
|
253
|
-
return I;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
311
|
private async connectOrLaunchBrowser(): Promise<void> {
|
|
257
312
|
const { getAliveEndpoint } = await import('./browser-server.js');
|
|
258
313
|
const endpoint = await getAliveEndpoint();
|
|
@@ -284,12 +339,13 @@ class Explorer {
|
|
|
284
339
|
return contextOptions;
|
|
285
340
|
}
|
|
286
341
|
|
|
287
|
-
|
|
288
|
-
|
|
342
|
+
private attachXhrCapture(): void {
|
|
343
|
+
this.xhrCapture = new XhrCapture(this.requestStore, this.config.playwright.url);
|
|
344
|
+
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
289
345
|
}
|
|
290
346
|
|
|
291
|
-
async
|
|
292
|
-
if (!(await this.
|
|
347
|
+
private async runWithRecovery<T>(label: string, operation: () => Promise<T>): Promise<T> {
|
|
348
|
+
if (!this.page && !(await this.recoverOrRestart())) {
|
|
293
349
|
throw new Error(`Browser page is unavailable before ${label}`);
|
|
294
350
|
}
|
|
295
351
|
|
|
@@ -304,23 +360,21 @@ class Explorer {
|
|
|
304
360
|
try {
|
|
305
361
|
return await operation();
|
|
306
362
|
} catch (retryError) {
|
|
307
|
-
if (!isNavigationTransitionError(retryError) && !
|
|
363
|
+
if (!isNavigationTransitionError(retryError) && !isFatalBrowserError(retryError)) throw retryError;
|
|
308
364
|
recoveryError = retryError;
|
|
309
365
|
}
|
|
310
366
|
}
|
|
311
367
|
|
|
312
|
-
if (!
|
|
368
|
+
if (!isFatalBrowserError(recoveryError)) throw recoveryError;
|
|
313
369
|
|
|
314
370
|
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
315
|
-
|
|
316
|
-
if (!recovered) recovered = await this.restartBrowser();
|
|
317
|
-
if (!recovered) throw new BrowserRecoveryError(label, recoveryError, false);
|
|
371
|
+
if (!(await this.recoverOrRestart())) throw new BrowserRecoveryError(label, recoveryError, false);
|
|
318
372
|
if (!(await this.waitForPageReadiness())) throw new BrowserRecoveryError(label, recoveryError, true);
|
|
319
373
|
|
|
320
374
|
try {
|
|
321
375
|
return await operation();
|
|
322
376
|
} catch (retryError) {
|
|
323
|
-
if (
|
|
377
|
+
if (isFatalBrowserError(retryError)) {
|
|
324
378
|
throw new BrowserRecoveryError(label, retryError, true);
|
|
325
379
|
}
|
|
326
380
|
throw retryError;
|
|
@@ -328,31 +382,7 @@ class Explorer {
|
|
|
328
382
|
}
|
|
329
383
|
}
|
|
330
384
|
|
|
331
|
-
async
|
|
332
|
-
return this.runWithBrowserRecovery('capturePageState', () => this.createAction().capturePageState(opts));
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
async capturePageWithScreenshot(): Promise<ActionResult> {
|
|
336
|
-
return this.capturePageState({ includeScreenshot: true });
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async executeAction(code: string): Promise<Action> {
|
|
340
|
-
return this.runWithBrowserRecovery('executeAction', () => this.createAction().execute(code));
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
async attemptAction(code: string, originalMessage?: string): Promise<boolean> {
|
|
344
|
-
return this.runWithBrowserRecovery('attemptAction', () => this.createAction().attempt(code, originalMessage));
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
getPlaywrightRecorder(): PlaywrightRecorder {
|
|
348
|
-
return this.playwrightRecorder;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
async visit(url: string) {
|
|
352
|
-
return this.runWithBrowserRecovery('visit', () => this.visitOnce(url));
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
private async visitOnce(url: string) {
|
|
385
|
+
private async visitOnce(url: string): Promise<Action> {
|
|
356
386
|
await this.closeOtherTabs();
|
|
357
387
|
|
|
358
388
|
const serializedUrl = JSON.stringify(url);
|
|
@@ -361,7 +391,7 @@ class Explorer {
|
|
|
361
391
|
|
|
362
392
|
const { statePush = false, wait, waitForElement, code } = this.knowledgeTracker.getStateParameters(actionResult, ['statePush', 'wait', 'waitForElement', 'code']);
|
|
363
393
|
|
|
364
|
-
const action = this.
|
|
394
|
+
const action = new Action(this._actor, this.stateManager, this.playwrightRecorder);
|
|
365
395
|
|
|
366
396
|
if (statePush) {
|
|
367
397
|
await action.execute(`I.executeScript(() => { window.history.pushState({}, '', ${serializedUrl}); window.dispatchEvent(new PopStateEvent('popstate')); })`);
|
|
@@ -394,94 +424,65 @@ class Explorer {
|
|
|
394
424
|
return action;
|
|
395
425
|
}
|
|
396
426
|
|
|
397
|
-
async
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
return elements;
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
async visuallyAnnotateElements(opts?: { containers?: Array<{ css: string; label: string }> }): Promise<number> {
|
|
405
|
-
return this.runWithBrowserRecovery('visuallyAnnotateElements', () => visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []));
|
|
406
|
-
}
|
|
427
|
+
private async recoverFromExecutionError(error: unknown): Promise<Recovery> {
|
|
428
|
+
const message = browserErrorMessage(error);
|
|
429
|
+
tag('error').log(`Browser execution error: ${message}`);
|
|
407
430
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
try {
|
|
411
|
-
const selector = containerCss ? `${containerCss} [${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]` : `[${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]`;
|
|
412
|
-
const elements = await page.locator(selector).all();
|
|
413
|
-
const result: string[] = [];
|
|
414
|
-
for (const el of elements) {
|
|
415
|
-
const attr = await el.getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
|
|
416
|
-
if (attr) result.push(attr);
|
|
417
|
-
}
|
|
418
|
-
return result;
|
|
419
|
-
} catch (error) {
|
|
420
|
-
if (this.isFatalBrowserError(error)) {
|
|
421
|
-
tag('warning').log(`getEidxInContainer: ${browserErrorMessage(error)}`);
|
|
422
|
-
await this.recoverFromBrowserError();
|
|
423
|
-
}
|
|
424
|
-
return [];
|
|
431
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
432
|
+
return { ok: false, action: 'stop', message };
|
|
425
433
|
}
|
|
426
|
-
}
|
|
427
434
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const page = this.playwrightHelper.page;
|
|
431
|
-
const base = container ? page.locator(container) : page;
|
|
432
|
-
const el = locator.startsWith('//') ? base.locator(`xpath=${locator}`) : base.locator(locator);
|
|
433
|
-
return await el.first().getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
|
|
434
|
-
} catch (error) {
|
|
435
|
-
if (this.isFatalBrowserError(error)) {
|
|
436
|
-
tag('warning').log(`getEidxByLocator: ${browserErrorMessage(error)}`);
|
|
437
|
-
await this.recoverFromBrowserError();
|
|
438
|
-
}
|
|
439
|
-
return null;
|
|
435
|
+
if (error instanceof BrowserRecoveryError) {
|
|
436
|
+
return { ok: false, action: 'stop', recovered: error.recovered, message: error.message };
|
|
440
437
|
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
private resolveBrowserUrl(url?: string): string | null {
|
|
444
|
-
if (!url) return null;
|
|
445
|
-
try {
|
|
446
|
-
return new URL(url).toString();
|
|
447
|
-
} catch {}
|
|
448
438
|
|
|
449
|
-
|
|
450
|
-
|
|
439
|
+
if (!isFatalBrowserError(error)) {
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
action: 'continue',
|
|
443
|
+
message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
451
446
|
|
|
452
|
-
|
|
453
|
-
return
|
|
454
|
-
} catch {
|
|
455
|
-
return null;
|
|
447
|
+
if (!(await this.recoverOrRestart())) {
|
|
448
|
+
return { ok: false, action: 'stop', recovered: false, message: `Browser could not be recovered after fatal error: ${message}` };
|
|
456
449
|
}
|
|
450
|
+
|
|
451
|
+
this.watchActiveTestPage();
|
|
452
|
+
return {
|
|
453
|
+
ok: true,
|
|
454
|
+
action: 'continue',
|
|
455
|
+
recovered: true,
|
|
456
|
+
message: dedent`
|
|
457
|
+
Browser was recovered after a fatal page error.
|
|
458
|
+
Continue from the restored page.
|
|
459
|
+
The interrupted browser action is not product evidence.
|
|
460
|
+
Inspect the restored page and retry the current step when it is still required.
|
|
461
|
+
`,
|
|
462
|
+
};
|
|
457
463
|
}
|
|
458
464
|
|
|
459
|
-
|
|
460
|
-
|
|
465
|
+
private async recoverOrRestart(): Promise<boolean> {
|
|
466
|
+
if (await this.recoverPage()) return true;
|
|
467
|
+
return this.restartBrowser();
|
|
461
468
|
}
|
|
462
469
|
|
|
463
|
-
async
|
|
470
|
+
private async recoverPage(): Promise<boolean> {
|
|
464
471
|
try {
|
|
465
|
-
if (!this.
|
|
472
|
+
if (!this.page) {
|
|
466
473
|
const context = this.playwrightHelper?.browserContext;
|
|
467
|
-
if (!context) return
|
|
468
|
-
|
|
469
|
-
await page.bringToFront();
|
|
470
|
-
await this.playwrightHelper._setPage(page);
|
|
471
|
-
this.bindFrameNavigated(page);
|
|
472
|
-
if (this.xhrCapture) {
|
|
473
|
-
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
474
|
-
}
|
|
474
|
+
if (!context) return false;
|
|
475
|
+
await this.activateNewPage(context);
|
|
475
476
|
}
|
|
476
477
|
|
|
477
478
|
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
478
479
|
if (url) {
|
|
479
480
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
480
|
-
await this.playwrightHelper.page.goto(url,
|
|
481
|
+
await this.playwrightHelper.page.goto(url, RECOVERY_NAVIGATION);
|
|
481
482
|
return this.waitForPageReadiness();
|
|
482
483
|
}
|
|
483
484
|
tag('warning').log('Browser error detected, reloading page');
|
|
484
|
-
await this.playwrightHelper.page.reload(
|
|
485
|
+
await this.playwrightHelper.page.reload(RECOVERY_NAVIGATION);
|
|
485
486
|
return this.waitForPageReadiness();
|
|
486
487
|
} catch (err) {
|
|
487
488
|
tag('error').log(`Browser recovery failed: ${browserErrorMessage(err)}`);
|
|
@@ -489,24 +490,37 @@ class Explorer {
|
|
|
489
490
|
}
|
|
490
491
|
}
|
|
491
492
|
|
|
492
|
-
async
|
|
493
|
+
private async activateNewPage(context: any): Promise<void> {
|
|
494
|
+
const page = await context.newPage();
|
|
495
|
+
await page.bringToFront();
|
|
496
|
+
await this.playwrightHelper._setPage(page);
|
|
497
|
+
this.bindFrameNavigated(page);
|
|
498
|
+
this.xhrCapture?.attach(page);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private async stopCaptures(): Promise<void> {
|
|
502
|
+
if (this.xhrCapture && this.playwrightHelper?.page) {
|
|
503
|
+
this.xhrCapture.detach(this.playwrightHelper.page);
|
|
504
|
+
}
|
|
505
|
+
await this.playwrightRecorder.stop();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private async closeBrowserContext(): Promise<void> {
|
|
509
|
+
if (!this.playwrightHelper.browserContext) return;
|
|
510
|
+
await this.playwrightHelper.browserContext.close().catch((err: unknown) => {
|
|
511
|
+
debugLog('Failed to close browser context:', err);
|
|
512
|
+
});
|
|
513
|
+
this.playwrightHelper.browserContext = null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private async restartBrowser(): Promise<boolean> {
|
|
493
517
|
if (!this.playwrightHelper) return false;
|
|
494
518
|
|
|
495
519
|
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
496
520
|
|
|
497
521
|
try {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
await this.playwrightRecorder.stop();
|
|
503
|
-
|
|
504
|
-
if (this.playwrightHelper.browserContext) {
|
|
505
|
-
await this.playwrightHelper.browserContext.close().catch((err: unknown) => {
|
|
506
|
-
debugLog('Failed to close browser context before restart:', err);
|
|
507
|
-
});
|
|
508
|
-
this.playwrightHelper.browserContext = null;
|
|
509
|
-
}
|
|
522
|
+
await this.stopCaptures();
|
|
523
|
+
await this.closeBrowserContext();
|
|
510
524
|
|
|
511
525
|
if (!this.isSharedBrowser) {
|
|
512
526
|
await this.playwrightHelper._stopBrowser().catch((err: unknown) => {
|
|
@@ -517,11 +531,11 @@ class Explorer {
|
|
|
517
531
|
await this.connectOrLaunchBrowser();
|
|
518
532
|
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
519
533
|
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
520
|
-
this.
|
|
534
|
+
this.attachXhrCapture();
|
|
521
535
|
this.listenToStateChanged();
|
|
522
536
|
|
|
523
537
|
if (url) {
|
|
524
|
-
await this.playwrightHelper.page.goto(url,
|
|
538
|
+
await this.playwrightHelper.page.goto(url, RECOVERY_NAVIGATION);
|
|
525
539
|
if (!(await this.waitForPageReadiness())) return false;
|
|
526
540
|
}
|
|
527
541
|
|
|
@@ -533,10 +547,19 @@ class Explorer {
|
|
|
533
547
|
}
|
|
534
548
|
}
|
|
535
549
|
|
|
536
|
-
|
|
537
|
-
if (
|
|
538
|
-
|
|
539
|
-
|
|
550
|
+
private resolveBrowserUrl(url?: string): string | null {
|
|
551
|
+
if (!url) return null;
|
|
552
|
+
try {
|
|
553
|
+
return new URL(url).toString();
|
|
554
|
+
} catch {}
|
|
555
|
+
|
|
556
|
+
const baseUrl = this.config.playwright?.url || this.config.web?.url;
|
|
557
|
+
if (!baseUrl) return null;
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
return new URL(url, baseUrl).toString();
|
|
561
|
+
} catch {
|
|
562
|
+
return null;
|
|
540
563
|
}
|
|
541
564
|
}
|
|
542
565
|
|
|
@@ -551,238 +574,112 @@ class Explorer {
|
|
|
551
574
|
return true;
|
|
552
575
|
}
|
|
553
576
|
|
|
554
|
-
async isInsideIframe(): Promise<boolean> {
|
|
555
|
-
if (this.playwrightHelper.frame) return true;
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
const page = this.playwrightHelper.page;
|
|
559
|
-
if (!page) return false;
|
|
560
|
-
return await page.evaluate(() => window.top !== window.self);
|
|
561
|
-
} catch (error) {
|
|
562
|
-
if (this.isFatalBrowserError(error)) {
|
|
563
|
-
tag('warning').log(`isInsideIframe: ${browserErrorMessage(error)}`);
|
|
564
|
-
await this.recoverFromBrowserError();
|
|
565
|
-
}
|
|
566
|
-
return false;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
getCurrentIframeInfo(): string | null {
|
|
571
|
-
if (!this.playwrightHelper?.frame) return null;
|
|
572
|
-
return 'iframe context active';
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
hasOtherTabs(): boolean {
|
|
576
|
-
return this.otherTabs.length > 0;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
getOtherTabsInfo(): TabInfo[] {
|
|
580
|
-
return [...this.otherTabs];
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
clearOtherTabsInfo(): void {
|
|
584
|
-
this.otherTabs = [];
|
|
585
|
-
}
|
|
586
|
-
|
|
587
577
|
private listenToStateChanged(): void {
|
|
588
|
-
|
|
589
|
-
|
|
578
|
+
const page = this.playwrightHelper?.page;
|
|
579
|
+
if (!page) {
|
|
580
|
+
debugLog('Playwright page not available for state monitoring');
|
|
590
581
|
return;
|
|
591
582
|
}
|
|
583
|
+
const initialPage = page;
|
|
584
|
+
const context = page.context();
|
|
592
585
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
if (!page) {
|
|
596
|
-
debugLog('Playwright page not available for state monitoring');
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
const initialPage = page;
|
|
600
|
-
const context = this.playwrightHelper.page.context();
|
|
586
|
+
context.on('page', async (newPage: any) => {
|
|
587
|
+
if (newPage === initialPage) return;
|
|
601
588
|
|
|
602
|
-
|
|
603
|
-
if (newPage ===
|
|
604
|
-
|
|
589
|
+
try {
|
|
590
|
+
if (newPage.url() === 'about:blank') {
|
|
591
|
+
await newPage.waitForURL(/^(?!about:blank$)/, { timeout: 5000 }).catch(() => {});
|
|
605
592
|
}
|
|
593
|
+
await newPage.waitForLoadState('domcontentloaded', { timeout: 5000 });
|
|
594
|
+
const url = await newPage.url();
|
|
595
|
+
const title = await newPage.title().catch(() => 'Unknown');
|
|
606
596
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
await newPage.waitForLoadState('domcontentloaded', { timeout: 5000 });
|
|
612
|
-
const url = await newPage.url();
|
|
613
|
-
const title = await newPage.title().catch(() => 'Unknown');
|
|
614
|
-
|
|
615
|
-
this.otherTabs.push({ url, title });
|
|
616
|
-
if (url !== 'about:blank') {
|
|
617
|
-
tag('info').log(`New browser tab opened: ${url}`);
|
|
618
|
-
}
|
|
619
|
-
debugLog(`New tab detected: ${url} - ${title}`);
|
|
620
|
-
} catch (error) {
|
|
621
|
-
debugLog('Failed to get new tab info:', error);
|
|
622
|
-
this.otherTabs.push({ url: 'unknown', title: 'unknown' });
|
|
597
|
+
this.stateManager.otherTabs.push({ url, title });
|
|
598
|
+
if (url !== 'about:blank') {
|
|
599
|
+
tag('info').log(`New browser tab opened: ${url}`);
|
|
623
600
|
}
|
|
624
|
-
|
|
601
|
+
debugLog(`New tab detected: ${url} - ${title}`);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
debugLog('Failed to get new tab info:', error);
|
|
604
|
+
this.stateManager.otherTabs.push({ url: 'unknown', title: 'unknown' });
|
|
605
|
+
}
|
|
606
|
+
});
|
|
625
607
|
|
|
626
|
-
|
|
608
|
+
this.bindFrameNavigated(page);
|
|
627
609
|
|
|
628
|
-
|
|
629
|
-
} catch (error) {
|
|
630
|
-
debugLog('Failed to set up state change monitoring:', error);
|
|
631
|
-
}
|
|
610
|
+
debugLog('Listening for automatic state changes');
|
|
632
611
|
}
|
|
633
612
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
if (this.xhrCapture && this.playwrightHelper?.page) {
|
|
640
|
-
this.xhrCapture.detach(this.playwrightHelper.page);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
await this.playwrightRecorder.stop();
|
|
644
|
-
|
|
645
|
-
if (this.options?.session && this.playwrightHelper?.browserContext) {
|
|
646
|
-
const dir = path.dirname(this.options.session);
|
|
647
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
648
|
-
await this.playwrightHelper.browserContext.storageState({ path: this.options.session });
|
|
649
|
-
debugLog(`Session saved to ${path.relative(process.cwd(), this.options.session)}`);
|
|
650
|
-
}
|
|
613
|
+
private bindFrameNavigated(page: any): void {
|
|
614
|
+
page.on('framenavigated', async (frame: any) => {
|
|
615
|
+
if (frame !== page.mainFrame()) return;
|
|
651
616
|
|
|
652
|
-
|
|
653
|
-
|
|
617
|
+
const newUrl = await frame.url();
|
|
618
|
+
let newTitle = '';
|
|
654
619
|
|
|
655
|
-
if (this.isSharedBrowser) {
|
|
656
|
-
tag('info').log('Closing browser context (persistent browser stays running)');
|
|
657
620
|
try {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
}
|
|
662
|
-
} catch (err) {
|
|
663
|
-
debugLog('Failed to close browser context:', err);
|
|
621
|
+
newTitle = await frame.title();
|
|
622
|
+
} catch (error) {
|
|
623
|
+
debugLog('Failed to get page title:', error);
|
|
664
624
|
}
|
|
665
|
-
this.playwrightHelper.browser = null;
|
|
666
|
-
this.playwrightHelper.isRunning = false;
|
|
667
|
-
await Promise.all([this.reporter.finishRun(), codeceptjs.recorder.stop()]);
|
|
668
|
-
} else {
|
|
669
|
-
await Promise.all([this.reporter.finishRun(), this.playwrightHelper._stopBrowser(), codeceptjs.recorder.stop()]);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
get activeTest(): Test | null {
|
|
674
|
-
return this._activeTest;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
async startTest(test: Test): Promise<boolean> {
|
|
678
|
-
this._activeTest = test;
|
|
679
|
-
test.start();
|
|
680
|
-
await this.reporter.reportTestStart(test);
|
|
681
|
-
await this.closeOtherTabs();
|
|
682
|
-
this.otherTabs = [];
|
|
683
|
-
if (!(await this.ensurePageAvailable())) return false;
|
|
684
|
-
|
|
685
|
-
const codeceptjsTest = toCodeceptjsTest(test);
|
|
686
625
|
|
|
687
|
-
|
|
688
|
-
if (!step.toCode) return;
|
|
689
|
-
if (step?.name?.startsWith('grab')) return;
|
|
690
|
-
if (step?.name?.startsWith('save')) return;
|
|
691
|
-
|
|
692
|
-
test.addStep(step.toCode(), step.duration, status, error, log);
|
|
626
|
+
this.stateManager.updateStateFromBasic(newUrl, newTitle, 'navigation');
|
|
693
627
|
|
|
694
|
-
|
|
628
|
+
await sleep(500);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
695
631
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
};
|
|
632
|
+
private async closeOtherTabs(): Promise<void> {
|
|
633
|
+
if (!this.playwrightHelper) return;
|
|
699
634
|
|
|
700
|
-
this.
|
|
635
|
+
const context = this.playwrightHelper.page.context();
|
|
636
|
+
const pages = context.pages();
|
|
701
637
|
|
|
702
|
-
|
|
703
|
-
const onStepFailed = (step: any, error: any) => {
|
|
704
|
-
stepHandler(step, 'failed', error?.message || String(error), error?.stack);
|
|
705
|
-
};
|
|
706
|
-
const onTestAfter = () => {
|
|
707
|
-
codeceptjs.event.dispatcher.off('step.passed', onStepPassed);
|
|
708
|
-
codeceptjs.event.dispatcher.off('step.failed', onStepFailed);
|
|
709
|
-
codeceptjs.event.dispatcher.off('test.after', onTestAfter);
|
|
710
|
-
this.unwatchActiveTestPages();
|
|
711
|
-
};
|
|
638
|
+
if (pages.length <= 1) return;
|
|
712
639
|
|
|
713
|
-
|
|
714
|
-
codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
|
|
715
|
-
codeceptjs.event.dispatcher.on('step.passed', onStepPassed);
|
|
716
|
-
codeceptjs.event.dispatcher.on('step.failed', onStepFailed);
|
|
717
|
-
codeceptjs.event.dispatcher.on('test.after', onTestAfter);
|
|
640
|
+
debugLog(`Found ${pages.length} tabs, cleaning up to keep only the first one`);
|
|
718
641
|
|
|
719
|
-
|
|
720
|
-
|
|
642
|
+
const firstPage = pages[0];
|
|
643
|
+
const tabsToClose = pages.slice(1);
|
|
721
644
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
this.watchActiveTestPage(page);
|
|
726
|
-
return true;
|
|
645
|
+
for (const page of tabsToClose) {
|
|
646
|
+
await page.close();
|
|
647
|
+
debugLog(`Closed extra tab: ${await page.url()}`);
|
|
727
648
|
}
|
|
728
649
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
this.watchActiveTestPage();
|
|
732
|
-
return true;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
async handleExecutionError(error: unknown): Promise<BrowserExecutionErrorResult> {
|
|
736
|
-
const message = browserErrorMessage(error);
|
|
737
|
-
tag('error').log(`Browser execution error: ${message}`);
|
|
738
|
-
|
|
739
|
-
if (error instanceof Error && error.name === 'AbortError') {
|
|
740
|
-
return {
|
|
741
|
-
action: 'stop',
|
|
742
|
-
message,
|
|
743
|
-
};
|
|
744
|
-
}
|
|
650
|
+
await firstPage.bringToFront();
|
|
651
|
+
await this.playwrightHelper._setPage(firstPage);
|
|
745
652
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
action: 'stop',
|
|
749
|
-
recovered: error.recovered,
|
|
750
|
-
message: error.message,
|
|
751
|
-
};
|
|
752
|
-
}
|
|
653
|
+
debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
|
|
654
|
+
}
|
|
753
655
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
656
|
+
private async finishTest(test: Test, meta?: Record<string, string>): Promise<void> {
|
|
657
|
+
this.unwatchActiveTestPages();
|
|
658
|
+
this._activeTest = null;
|
|
659
|
+
const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
|
|
660
|
+
if (lastScreenshot) {
|
|
661
|
+
meta ||= {};
|
|
662
|
+
meta.screenshotFile = lastScreenshot;
|
|
759
663
|
}
|
|
664
|
+
await this.reporter.reportTest(test, meta);
|
|
665
|
+
const codeceptjsTest = toCodeceptjsTest(test);
|
|
760
666
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
if (
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
667
|
+
if (test.isSuccessful) {
|
|
668
|
+
codeceptjsTest.state = 'passed';
|
|
669
|
+
this.eventDispatcher.emit('test.passed', codeceptjsTest);
|
|
670
|
+
} else if (test.isSkipped) {
|
|
671
|
+
codeceptjsTest.state = 'skipped';
|
|
672
|
+
this.eventDispatcher.emit('test.skipped', codeceptjsTest);
|
|
673
|
+
} else {
|
|
674
|
+
codeceptjsTest.state = 'failed';
|
|
675
|
+
this.eventDispatcher.emit('test.failed', codeceptjsTest);
|
|
770
676
|
}
|
|
771
677
|
|
|
772
|
-
this.
|
|
773
|
-
|
|
774
|
-
action: 'continue',
|
|
775
|
-
recovered: true,
|
|
776
|
-
message: dedent`
|
|
777
|
-
Browser was recovered after a fatal page error.
|
|
778
|
-
Continue from the restored page.
|
|
779
|
-
The interrupted browser action is not product evidence.
|
|
780
|
-
Inspect the restored page and retry the current step when it is still required.
|
|
781
|
-
`,
|
|
782
|
-
};
|
|
678
|
+
this.eventDispatcher.emit('test.finish', codeceptjsTest);
|
|
679
|
+
this.eventDispatcher.emit('test.after', codeceptjsTest);
|
|
783
680
|
}
|
|
784
681
|
|
|
785
|
-
watchActiveTestPage(page = this.playwrightHelper?.page): void {
|
|
682
|
+
private watchActiveTestPage(page = this.playwrightHelper?.page): void {
|
|
786
683
|
if (!this._activeTest) return;
|
|
787
684
|
if (!page) return;
|
|
788
685
|
if (this.observedTestPages.has(page)) return;
|
|
@@ -806,32 +703,6 @@ class Explorer {
|
|
|
806
703
|
this.observedTestPages.add(page);
|
|
807
704
|
}
|
|
808
705
|
|
|
809
|
-
async stopTest(test: Test, meta?: Record<string, string>) {
|
|
810
|
-
this.unwatchActiveTestPages();
|
|
811
|
-
this._activeTest = null;
|
|
812
|
-
const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
|
|
813
|
-
if (lastScreenshot) {
|
|
814
|
-
meta ||= {};
|
|
815
|
-
meta.screenshotFile = lastScreenshot;
|
|
816
|
-
}
|
|
817
|
-
await this.reporter.reportTest(test, meta);
|
|
818
|
-
const codeceptjsTest = toCodeceptjsTest(test);
|
|
819
|
-
|
|
820
|
-
if (test.isSuccessful) {
|
|
821
|
-
codeceptjsTest.state = 'passed';
|
|
822
|
-
codeceptjs.event.dispatcher.emit('test.passed', codeceptjsTest);
|
|
823
|
-
} else if (test.isSkipped) {
|
|
824
|
-
codeceptjsTest.state = 'skipped';
|
|
825
|
-
codeceptjs.event.dispatcher.emit('test.skipped', codeceptjsTest);
|
|
826
|
-
} else {
|
|
827
|
-
codeceptjsTest.state = 'failed';
|
|
828
|
-
codeceptjs.event.dispatcher.emit('test.failed', codeceptjsTest);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
codeceptjs.event.dispatcher.emit('test.finish', codeceptjsTest);
|
|
832
|
-
codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
706
|
private unwatchActiveTestPages(): void {
|
|
836
707
|
for (const page of this.observedTestPages) {
|
|
837
708
|
if (this.testPageErrorHandler) page.off('pageerror', this.testPageErrorHandler);
|
|
@@ -840,87 +711,6 @@ class Explorer {
|
|
|
840
711
|
}
|
|
841
712
|
this.observedTestPages.clear();
|
|
842
713
|
}
|
|
843
|
-
|
|
844
|
-
async playwrightLocatorCount(locatorFn: (page: any) => any): Promise<number> {
|
|
845
|
-
try {
|
|
846
|
-
const pwLocator = locatorFn(this.playwrightHelper.page);
|
|
847
|
-
return await pwLocator.count();
|
|
848
|
-
} catch (error) {
|
|
849
|
-
if (this.isFatalBrowserError(error)) {
|
|
850
|
-
tag('warning').log(`playwrightLocatorCount: ${browserErrorMessage(error)}`);
|
|
851
|
-
await this.recoverFromBrowserError();
|
|
852
|
-
}
|
|
853
|
-
throw error;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
private bindFrameNavigated(page: any): void {
|
|
858
|
-
page.on('framenavigated', async (frame: any) => {
|
|
859
|
-
if (frame !== page.mainFrame()) return;
|
|
860
|
-
|
|
861
|
-
const newUrl = await frame.url();
|
|
862
|
-
let newTitle = '';
|
|
863
|
-
|
|
864
|
-
try {
|
|
865
|
-
newTitle = await frame.title();
|
|
866
|
-
} catch (error) {
|
|
867
|
-
debugLog('Failed to get page title:', error);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
this.stateManager.updateStateFromBasic(newUrl, newTitle, 'navigation');
|
|
871
|
-
|
|
872
|
-
await sleep(500);
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
async openFreshTab(): Promise<void> {
|
|
877
|
-
if (!this.playwrightHelper?.page) return;
|
|
878
|
-
|
|
879
|
-
const oldPage = this.playwrightHelper.page;
|
|
880
|
-
const context = oldPage.context();
|
|
881
|
-
const newPage = await context.newPage();
|
|
882
|
-
|
|
883
|
-
await oldPage.close();
|
|
884
|
-
await newPage.bringToFront();
|
|
885
|
-
|
|
886
|
-
await this.playwrightHelper._setPage(newPage);
|
|
887
|
-
this.otherTabs = [];
|
|
888
|
-
|
|
889
|
-
this.bindFrameNavigated(newPage);
|
|
890
|
-
if (this.xhrCapture) {
|
|
891
|
-
this.xhrCapture.attach(newPage);
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
debugLog('Opened fresh tab, closed previous tab');
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
private async closeOtherTabs(): Promise<void> {
|
|
898
|
-
if (!this.playwrightHelper) {
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
const context = this.playwrightHelper.page.context();
|
|
903
|
-
const pages = context.pages();
|
|
904
|
-
|
|
905
|
-
if (pages.length <= 1) {
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
debugLog(`Found ${pages.length} tabs, cleaning up to keep only the first one`);
|
|
910
|
-
|
|
911
|
-
const firstPage = pages[0];
|
|
912
|
-
const tabsToClose = pages.slice(1);
|
|
913
|
-
|
|
914
|
-
for (const page of tabsToClose) {
|
|
915
|
-
await page.close();
|
|
916
|
-
debugLog(`Closed extra tab: ${await page.url()}`);
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
await firstPage.bringToFront();
|
|
920
|
-
await this.playwrightHelper._setPage(firstPage);
|
|
921
|
-
|
|
922
|
-
debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
|
|
923
|
-
}
|
|
924
714
|
}
|
|
925
715
|
|
|
926
716
|
function toCodeceptjsTest(test: Test): any {
|
|
@@ -938,63 +728,35 @@ function toCodeceptjsTest(test: Test): any {
|
|
|
938
728
|
return codeceptjsTest;
|
|
939
729
|
}
|
|
940
730
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const entries: Array<{ role: string; name: string; ref: string }> = [];
|
|
947
|
-
for (const line of ariaSnapshot.split('\n')) {
|
|
948
|
-
const match = line.match(REF_LINE_PATTERN);
|
|
949
|
-
if (!match) continue;
|
|
950
|
-
if (!ANNOTATABLE_ROLES.has(match[2])) continue;
|
|
951
|
-
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
952
|
-
}
|
|
953
|
-
return entries;
|
|
731
|
+
export interface ExplorerOptions {
|
|
732
|
+
show?: boolean;
|
|
733
|
+
headless?: boolean;
|
|
734
|
+
incognito?: boolean;
|
|
735
|
+
session?: string;
|
|
954
736
|
}
|
|
955
737
|
|
|
956
|
-
export
|
|
957
|
-
|
|
958
|
-
|
|
738
|
+
export interface ExplorerDeps {
|
|
739
|
+
stateManager: StateManager;
|
|
740
|
+
knowledgeTracker: KnowledgeTracker;
|
|
741
|
+
reporter: Reporter;
|
|
742
|
+
requestStore: RequestStore;
|
|
743
|
+
playwrightRecorder: PlaywrightRecorder;
|
|
744
|
+
}
|
|
959
745
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
}
|
|
967
|
-
list.push({ name, ref });
|
|
968
|
-
}
|
|
746
|
+
export interface Recovery {
|
|
747
|
+
ok: boolean;
|
|
748
|
+
action: 'continue' | 'stop';
|
|
749
|
+
message: string;
|
|
750
|
+
recovered?: boolean;
|
|
751
|
+
}
|
|
969
752
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
(domElements: Element[], [data, extractFnStr, config]: [Array<{ name: string; ref: string }>, string, typeof ELEMENT_EXTRACTION_CONFIG]) => {
|
|
975
|
-
const extract = new Function(`return ${extractFnStr}`)() as (el: Element) => any;
|
|
976
|
-
const results: any[] = [];
|
|
977
|
-
let ariaIdx = 0;
|
|
978
|
-
for (const el of domElements) {
|
|
979
|
-
if (ariaIdx >= data.length) break;
|
|
980
|
-
el.setAttribute(config.attrs.eidx, data[ariaIdx].ref);
|
|
981
|
-
const elData = extract(el, config);
|
|
982
|
-
if (elData) results.push(elData);
|
|
983
|
-
ariaIdx++;
|
|
984
|
-
}
|
|
985
|
-
return results;
|
|
986
|
-
},
|
|
987
|
-
[entries, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]
|
|
988
|
-
);
|
|
989
|
-
for (const raw of rawList) {
|
|
990
|
-
elements.push(WebElement.fromRawData(raw, role));
|
|
991
|
-
}
|
|
992
|
-
} catch {
|
|
993
|
-
debugLog(`Failed to annotate role=${role}`);
|
|
994
|
-
}
|
|
995
|
-
}
|
|
753
|
+
export interface TestRun {
|
|
754
|
+
started: boolean;
|
|
755
|
+
stop(meta?: Record<string, string>): Promise<void>;
|
|
756
|
+
}
|
|
996
757
|
|
|
997
|
-
|
|
758
|
+
export interface CaptureOpts {
|
|
759
|
+
screenshot?: boolean;
|
|
998
760
|
}
|
|
999
761
|
|
|
1000
762
|
export default Explorer;
|