explorbot 0.2.0 → 0.2.1

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.
Files changed (141) hide show
  1. package/README.md +21 -5
  2. package/bin/explorbot-cli.ts +29 -7
  3. package/boat/api-tester/src/config.ts +45 -3
  4. package/boat/doc-collector/src/ai/documentarian.ts +5 -2
  5. package/boat/doc-collector/src/ai/tools.ts +12 -11
  6. package/boat/doc-collector/src/cli.ts +1 -6
  7. package/boat/doc-collector/src/docbot.ts +3 -3
  8. package/boat/doc-collector/src/screenshots.ts +2 -2
  9. package/dist/bin/explorbot-cli.js +26 -6
  10. package/dist/boat/api-tester/src/config.js +43 -4
  11. package/dist/boat/doc-collector/src/ai/documentarian.js +4 -2
  12. package/dist/boat/doc-collector/src/ai/tools.js +11 -11
  13. package/dist/boat/doc-collector/src/cli.js +1 -5
  14. package/dist/boat/doc-collector/src/docbot.js +3 -3
  15. package/dist/boat/doc-collector/src/screenshots.js +2 -2
  16. package/dist/models.json +30 -0
  17. package/dist/package.json +5 -2
  18. package/dist/src/action.d.ts +11 -3
  19. package/dist/src/action.js +22 -5
  20. package/dist/src/ai/agent.d.ts +17 -0
  21. package/dist/src/ai/captain/idle-mode.js +1 -1
  22. package/dist/src/ai/captain/test-mode.js +1 -1
  23. package/dist/src/ai/captain/web-mode.js +18 -22
  24. package/dist/src/ai/captain.js +13 -26
  25. package/dist/src/ai/driller.d.ts +2 -13
  26. package/dist/src/ai/driller.js +23 -38
  27. package/dist/src/ai/historian/codeceptjs.js +1 -1
  28. package/dist/src/ai/historian/playwright.d.ts +3 -2
  29. package/dist/src/ai/historian/playwright.js +1 -1
  30. package/dist/src/ai/historian/screencast.d.ts +2 -1
  31. package/dist/src/ai/historian/screencast.js +2 -2
  32. package/dist/src/ai/historian.d.ts +5 -4
  33. package/dist/src/ai/navigator.d.ts +6 -2
  34. package/dist/src/ai/navigator.js +26 -22
  35. package/dist/src/ai/pilot.d.ts +13 -6
  36. package/dist/src/ai/pilot.js +49 -44
  37. package/dist/src/ai/planner.d.ts +3 -2
  38. package/dist/src/ai/planner.js +19 -17
  39. package/dist/src/ai/provider.js +0 -1
  40. package/dist/src/ai/quartermaster.d.ts +3 -2
  41. package/dist/src/ai/quartermaster.js +4 -4
  42. package/dist/src/ai/rerunner.d.ts +2 -11
  43. package/dist/src/ai/rerunner.js +9 -22
  44. package/dist/src/ai/researcher/coordinates.js +4 -3
  45. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
  46. package/dist/src/ai/researcher/deep-analysis.js +6 -6
  47. package/dist/src/ai/researcher/locators.d.ts +9 -2
  48. package/dist/src/ai/researcher/locators.js +65 -8
  49. package/dist/src/ai/researcher/sections.d.ts +2 -0
  50. package/dist/src/ai/researcher/sections.js +2 -2
  51. package/dist/src/ai/researcher.js +29 -54
  52. package/dist/src/ai/task-agent.d.ts +18 -5
  53. package/dist/src/ai/task-agent.js +35 -6
  54. package/dist/src/ai/tester.d.ts +6 -12
  55. package/dist/src/ai/tester.js +37 -51
  56. package/dist/src/ai/tools.d.ts +11 -10
  57. package/dist/src/ai/tools.js +24 -36
  58. package/dist/src/command-handler.js +1 -1
  59. package/dist/src/commands/context-aria-command.js +1 -1
  60. package/dist/src/commands/context-command.js +7 -5
  61. package/dist/src/commands/context-data-command.js +2 -2
  62. package/dist/src/commands/context-experience-command.js +2 -2
  63. package/dist/src/commands/context-html-command.js +2 -2
  64. package/dist/src/commands/context-knowledge-command.js +1 -1
  65. package/dist/src/commands/drill-command.js +1 -1
  66. package/dist/src/commands/explore-command.js +4 -3
  67. package/dist/src/commands/freesail-command.js +2 -2
  68. package/dist/src/commands/learn-command.js +4 -4
  69. package/dist/src/commands/path-command.js +1 -1
  70. package/dist/src/commands/research-command.js +1 -1
  71. package/dist/src/commands/test-command.js +1 -1
  72. package/dist/src/components/App.js +1 -1
  73. package/dist/src/config.d.ts +17 -0
  74. package/dist/src/config.js +144 -10
  75. package/dist/src/experience-tracker.js +1 -1
  76. package/dist/src/explorbot.d.ts +17 -8
  77. package/dist/src/explorbot.js +74 -36
  78. package/dist/src/explorer.d.ts +66 -91
  79. package/dist/src/explorer.js +326 -567
  80. package/dist/src/state-manager.d.ts +6 -1
  81. package/dist/src/state-manager.js +1 -0
  82. package/dist/src/utils/hooks-runner.js +2 -4
  83. package/dist/src/utils/test-files.js +1 -1
  84. package/dist/src/utils/web-annotate.d.ts +5 -0
  85. package/dist/src/utils/web-annotate.js +58 -0
  86. package/dist/src/utils/web-eidx.d.ts +2 -0
  87. package/dist/src/utils/web-eidx.js +20 -0
  88. package/dist/src/utils/web-element.d.ts +1 -0
  89. package/dist/src/utils/web-element.js +29 -0
  90. package/models.json +30 -0
  91. package/package.json +5 -2
  92. package/src/action.ts +25 -5
  93. package/src/ai/agent.ts +20 -0
  94. package/src/ai/captain/idle-mode.ts +1 -1
  95. package/src/ai/captain/test-mode.ts +1 -1
  96. package/src/ai/captain/web-mode.ts +18 -23
  97. package/src/ai/captain.ts +12 -25
  98. package/src/ai/driller.ts +50 -67
  99. package/src/ai/historian/codeceptjs.ts +1 -1
  100. package/src/ai/historian/playwright.ts +4 -3
  101. package/src/ai/historian/screencast.ts +4 -3
  102. package/src/ai/historian.ts +3 -2
  103. package/src/ai/navigator.ts +30 -25
  104. package/src/ai/pilot.ts +55 -47
  105. package/src/ai/planner.ts +22 -20
  106. package/src/ai/provider.ts +0 -1
  107. package/src/ai/quartermaster.ts +5 -4
  108. package/src/ai/rerunner.ts +10 -29
  109. package/src/ai/researcher/coordinates.ts +4 -3
  110. package/src/ai/researcher/deep-analysis.ts +8 -6
  111. package/src/ai/researcher/locators.ts +66 -9
  112. package/src/ai/researcher/sections.ts +4 -2
  113. package/src/ai/researcher.ts +32 -61
  114. package/src/ai/task-agent.ts +45 -11
  115. package/src/ai/tester.ts +40 -57
  116. package/src/ai/tools.ts +32 -53
  117. package/src/command-handler.ts +1 -1
  118. package/src/commands/context-aria-command.ts +1 -1
  119. package/src/commands/context-command.ts +7 -5
  120. package/src/commands/context-data-command.ts +2 -2
  121. package/src/commands/context-experience-command.ts +2 -2
  122. package/src/commands/context-html-command.ts +2 -2
  123. package/src/commands/context-knowledge-command.ts +1 -1
  124. package/src/commands/drill-command.ts +1 -1
  125. package/src/commands/explore-command.ts +4 -3
  126. package/src/commands/freesail-command.ts +2 -2
  127. package/src/commands/learn-command.ts +4 -4
  128. package/src/commands/path-command.ts +1 -1
  129. package/src/commands/research-command.ts +1 -1
  130. package/src/commands/test-command.ts +1 -1
  131. package/src/components/App.tsx +1 -1
  132. package/src/config.ts +173 -11
  133. package/src/experience-tracker.ts +1 -1
  134. package/src/explorbot.ts +78 -39
  135. package/src/explorer.ts +389 -628
  136. package/src/state-manager.ts +7 -1
  137. package/src/utils/hooks-runner.ts +2 -4
  138. package/src/utils/test-files.ts +1 -1
  139. package/src/utils/web-annotate.ts +64 -0
  140. package/src/utils/web-eidx.ts +21 -0
  141. 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, { join } from 'node: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 { AIProvider } from './ai/provider.js';
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 { ExperienceTracker } from './experience-tracker.js';
19
- import { KnowledgeTracker } from './knowledge-tracker.js';
20
- import { PlaywrightRecorder } from './playwright-recorder.ts';
21
- import { Reporter } from './reporter.ts';
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,218 @@ 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 aiProvider: AIProvider;
60
- playwrightHelper: any;
61
- public isStarted = false;
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 requestStore: RequestStore | null = null;
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;
78
61
 
79
- constructor(config: ExplorbotConfig, aiProvider: AIProvider, options: { show?: boolean; headless?: boolean; incognito?: boolean; session?: string } | undefined, experienceTracker: ExperienceTracker, knowledgeTracker: KnowledgeTracker) {
62
+ constructor(config: ExplorbotConfig, options: ExplorerOptions | undefined, deps: ExplorerDeps) {
80
63
  this.config = config;
81
- this.aiProvider = aiProvider;
82
64
  this.options = options;
65
+ this.stateManager = deps.stateManager;
66
+ this.knowledgeTracker = deps.knowledgeTracker;
67
+ this.reporter = deps.reporter;
68
+ this.requestStore = deps.requestStore;
69
+ this.playwrightRecorder = deps.playwrightRecorder;
83
70
  this.initializeContainer();
84
- this.knowledgeTracker = knowledgeTracker;
85
- this.stateManager = new StateManager(experienceTracker, knowledgeTracker);
86
- this.reporter = new Reporter(config.reporter, this.stateManager);
87
71
  }
88
72
 
89
- private initializeContainer() {
90
- try {
91
- // Use project root for output directory, not current working directory
92
- const configParser = ConfigParser.getInstance();
93
- const projectRoot = configParser.getProjectRoot();
94
- (global as any).output_dir = path.join(projectRoot, 'output', 'states');
95
- (global as any).codecept_dir = projectRoot;
73
+ get actor(): CodeceptJS.I {
74
+ return this._actor;
75
+ }
76
+
77
+ get page(): Page | null {
78
+ const page = this.playwrightHelper?.page;
79
+ if (!page || page.isClosed?.()) return null;
80
+ return page;
81
+ }
82
+
83
+ get activeTest(): Test | null {
84
+ return this._activeTest;
85
+ }
96
86
 
97
- configParser.validateConfig(this.config);
87
+ async start(): Promise<void> {
88
+ if (this.started) return;
98
89
 
99
- const codeceptConfig = this.convertToCodeceptConfig(this.config);
90
+ await codeceptjs.recorder.start();
91
+ await codeceptjs.container.started(null);
92
+ storeListener();
93
+ stepsListener();
100
94
 
101
- codeceptjs.container.create(codeceptConfig, {});
102
- } catch (error) {
103
- log(`❌ Failed to initialize container: ${error}`);
104
- throw error;
95
+ codeceptjs.recorder.retry({
96
+ retries: this.config.action?.retries || 3,
97
+ when: (err: any) => !!err?.message?.includes?.('context'),
98
+ });
99
+
100
+ this.playwrightHelper = codeceptjs.container.helpers('Playwright');
101
+ if (!this.playwrightHelper) {
102
+ throw new Error('Playwright helper not available');
103
+ }
104
+ await this.connectOrLaunchBrowser();
105
+ const hasSession = this.options?.session && existsSync(this.options.session);
106
+ await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
107
+ await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
108
+ this.attachXhrCapture();
109
+ if (hasSession) {
110
+ tag('info').log(`Session restored from ${path.relative(process.cwd(), this.options!.session!)}`);
111
+ }
112
+
113
+ this._actor = codeceptjs.container.support('I');
114
+ this.started = true;
115
+
116
+ this.listenToStateChanged();
117
+
118
+ codeceptjs.event.dispatcher.emit('global.before');
119
+ tag('success').log('Browser started, ready to explore');
120
+ }
121
+
122
+ async stop(): Promise<void> {
123
+ if (!this.started) return;
124
+ this.started = false;
125
+
126
+ await this.stopCaptures();
127
+
128
+ if (this.options?.session && this.playwrightHelper?.browserContext) {
129
+ const dir = path.dirname(this.options.session);
130
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
131
+ await this.playwrightHelper.browserContext.storageState({ path: this.options.session });
132
+ debugLog(`Session saved to ${path.relative(process.cwd(), this.options.session)}`);
133
+ }
134
+
135
+ codeceptjs.event.dispatcher.emit('global.after');
136
+ codeceptjs.event.dispatcher.emit('global.result');
137
+
138
+ if (!this.isSharedBrowser) {
139
+ await Promise.all([this.reporter.finishRun(), this.playwrightHelper._stopBrowser(), codeceptjs.recorder.stop()]);
140
+ return;
141
+ }
142
+
143
+ tag('info').log('Closing browser context (persistent browser stays running)');
144
+ await this.closeBrowserContext();
145
+ this.playwrightHelper.browser = null;
146
+ this.playwrightHelper.isRunning = false;
147
+ await Promise.all([this.reporter.finishRun(), codeceptjs.recorder.stop()]);
148
+ }
149
+
150
+ action(): Action {
151
+ return new Action(this._actor, this.stateManager, this.playwrightRecorder, (fn) => this.runWithRecovery('action', fn));
152
+ }
153
+
154
+ async visit(url: string, opts: CaptureOpts = {}): Promise<ActionResult> {
155
+ return this.runWithRecovery('visit', async () => {
156
+ const action = await this.visitOnce(url);
157
+ if (opts.screenshot) return action.capturePageState({ includeScreenshot: true });
158
+ return action.getActionResult() ?? action.capturePageState();
159
+ });
160
+ }
161
+
162
+ async capture(opts: CaptureOpts = {}): Promise<ActionResult> {
163
+ return this.action().capturePageState({ includeScreenshot: opts.screenshot });
164
+ }
165
+
166
+ async withPage<T>(fn: (page: Page) => Promise<T>): Promise<T> {
167
+ return this.runWithRecovery('page operation', () => fn(this.playwrightHelper.page));
168
+ }
169
+
170
+ async recover(error?: unknown): Promise<Recovery> {
171
+ if (error) return this.recoverFromExecutionError(error);
172
+
173
+ if (this.page) {
174
+ this.watchActiveTestPage();
175
+ return { ok: true, action: 'continue', message: 'Page is available' };
176
+ }
177
+
178
+ if (!(await this.recoverOrRestart())) {
179
+ return { ok: false, action: 'stop', message: 'Browser page could not be recovered' };
180
+ }
181
+ this.watchActiveTestPage();
182
+ return { ok: true, action: 'continue', recovered: true, message: 'Browser page was recovered' };
183
+ }
184
+
185
+ async beginTest(test: Test): Promise<TestRun> {
186
+ if (!this.page && !(await this.recoverOrRestart())) {
187
+ return { started: false, stop: async () => {} };
105
188
  }
189
+
190
+ this._activeTest = test;
191
+ test.start();
192
+ await this.reporter.reportTestStart(test);
193
+ await this.closeOtherTabs();
194
+ this.stateManager.otherTabs = [];
195
+
196
+ const codeceptjsTest = toCodeceptjsTest(test);
197
+
198
+ const stepHandler = (step: any, status?: string, error?: string, log?: string) => {
199
+ if (!step.toCode) return;
200
+ if (step?.name?.startsWith('grab')) return;
201
+ if (step?.name?.startsWith('save')) return;
202
+
203
+ test.addStep(step.toCode(), step.duration, status, error, log);
204
+
205
+ if (!this.stateManager.getCurrentState()) return;
206
+
207
+ const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()!).screenshotFile;
208
+ test.setActiveNoteScreenshot(lastScreenshot);
209
+ };
210
+
211
+ this.watchActiveTestPage();
212
+
213
+ const onStepPassed = (step: any) => stepHandler(step, 'passed');
214
+ const onStepFailed = (step: any, error: any) => {
215
+ stepHandler(step, 'failed', error?.message || String(error), error?.stack);
216
+ };
217
+ const onTestAfter = () => {
218
+ codeceptjs.event.dispatcher.off('step.passed', onStepPassed);
219
+ codeceptjs.event.dispatcher.off('step.failed', onStepFailed);
220
+ codeceptjs.event.dispatcher.off('test.after', onTestAfter);
221
+ this.unwatchActiveTestPages();
222
+ };
223
+
224
+ codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
225
+ codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
226
+ codeceptjs.event.dispatcher.on('step.passed', onStepPassed);
227
+ codeceptjs.event.dispatcher.on('step.failed', onStepFailed);
228
+ codeceptjs.event.dispatcher.on('test.after', onTestAfter);
229
+
230
+ return { started: true, stop: (meta) => this.finishTest(test, meta) };
231
+ }
232
+
233
+ async openTab(): Promise<void> {
234
+ const oldPage = this.playwrightHelper?.page;
235
+ if (!oldPage) return;
236
+
237
+ await this.activateNewPage(oldPage.context());
238
+ await oldPage.close();
239
+ this.stateManager.otherTabs = [];
240
+
241
+ debugLog('Opened fresh tab, closed previous tab');
242
+ }
243
+
244
+ private initializeContainer() {
245
+ const configParser = ConfigParser.getInstance();
246
+ const projectRoot = configParser.getProjectRoot();
247
+ (global as any).output_dir = configParser.getStatesDir();
248
+ (global as any).codecept_dir = projectRoot;
249
+
250
+ configParser.validateConfig(this.config);
251
+
252
+ codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
106
253
  }
107
254
 
108
255
  private convertToCodeceptConfig(config: ExplorbotConfig): any {
@@ -160,99 +307,6 @@ class Explorer {
160
307
  return codeceptConfig;
161
308
  }
162
309
 
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
310
  private async connectOrLaunchBrowser(): Promise<void> {
257
311
  const { getAliveEndpoint } = await import('./browser-server.js');
258
312
  const endpoint = await getAliveEndpoint();
@@ -284,12 +338,13 @@ class Explorer {
284
338
  return contextOptions;
285
339
  }
286
340
 
287
- createAction() {
288
- return new Action(this.actor, this.stateManager, this.playwrightRecorder);
341
+ private attachXhrCapture(): void {
342
+ this.xhrCapture = new XhrCapture(this.requestStore, this.config.playwright.url);
343
+ this.xhrCapture.attach(this.playwrightHelper.page);
289
344
  }
290
345
 
291
- async runWithBrowserRecovery<T>(label: string, operation: () => Promise<T>): Promise<T> {
292
- if (!(await this.ensurePageAvailable())) {
346
+ private async runWithRecovery<T>(label: string, operation: () => Promise<T>): Promise<T> {
347
+ if (!this.page && !(await this.recoverOrRestart())) {
293
348
  throw new Error(`Browser page is unavailable before ${label}`);
294
349
  }
295
350
 
@@ -304,23 +359,21 @@ class Explorer {
304
359
  try {
305
360
  return await operation();
306
361
  } catch (retryError) {
307
- if (!isNavigationTransitionError(retryError) && !this.isFatalBrowserError(retryError)) throw retryError;
362
+ if (!isNavigationTransitionError(retryError) && !isFatalBrowserError(retryError)) throw retryError;
308
363
  recoveryError = retryError;
309
364
  }
310
365
  }
311
366
 
312
- if (!this.isFatalBrowserError(recoveryError)) throw recoveryError;
367
+ if (!isFatalBrowserError(recoveryError)) throw recoveryError;
313
368
 
314
369
  tag('warning').log(`${label}: browser page is unavailable, recovering...`);
315
- let recovered = await this.recoverFromBrowserError();
316
- if (!recovered) recovered = await this.restartBrowser();
317
- if (!recovered) throw new BrowserRecoveryError(label, recoveryError, false);
370
+ if (!(await this.recoverOrRestart())) throw new BrowserRecoveryError(label, recoveryError, false);
318
371
  if (!(await this.waitForPageReadiness())) throw new BrowserRecoveryError(label, recoveryError, true);
319
372
 
320
373
  try {
321
374
  return await operation();
322
375
  } catch (retryError) {
323
- if (this.isFatalBrowserError(retryError)) {
376
+ if (isFatalBrowserError(retryError)) {
324
377
  throw new BrowserRecoveryError(label, retryError, true);
325
378
  }
326
379
  throw retryError;
@@ -328,31 +381,7 @@ class Explorer {
328
381
  }
329
382
  }
330
383
 
331
- async capturePageState(opts: { includeScreenshot?: boolean } = {}): Promise<ActionResult> {
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) {
384
+ private async visitOnce(url: string): Promise<Action> {
356
385
  await this.closeOtherTabs();
357
386
 
358
387
  const serializedUrl = JSON.stringify(url);
@@ -361,7 +390,7 @@ class Explorer {
361
390
 
362
391
  const { statePush = false, wait, waitForElement, code } = this.knowledgeTracker.getStateParameters(actionResult, ['statePush', 'wait', 'waitForElement', 'code']);
363
392
 
364
- const action = this.createAction();
393
+ const action = new Action(this._actor, this.stateManager, this.playwrightRecorder);
365
394
 
366
395
  if (statePush) {
367
396
  await action.execute(`I.executeScript(() => { window.history.pushState({}, '', ${serializedUrl}); window.dispatchEvent(new PopStateEvent('popstate')); })`);
@@ -394,94 +423,65 @@ class Explorer {
394
423
  return action;
395
424
  }
396
425
 
397
- async annotateElements(): Promise<WebElement[]> {
398
- return this.runWithBrowserRecovery('annotateElements', async () => {
399
- const { elements } = await annotatePageElements(this.playwrightHelper.page);
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
- }
426
+ private async recoverFromExecutionError(error: unknown): Promise<Recovery> {
427
+ const message = browserErrorMessage(error);
428
+ tag('error').log(`Browser execution error: ${message}`);
407
429
 
408
- async getEidxInContainer(containerCss: string | null): Promise<string[]> {
409
- const page = this.playwrightHelper.page;
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 [];
430
+ if (error instanceof Error && error.name === 'AbortError') {
431
+ return { ok: false, action: 'stop', message };
425
432
  }
426
- }
427
433
 
428
- async getEidxByLocator(locator: string, container?: string | null): Promise<string | null> {
429
- try {
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;
434
+ if (error instanceof BrowserRecoveryError) {
435
+ return { ok: false, action: 'stop', recovered: error.recovered, message: error.message };
440
436
  }
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
437
 
449
- const baseUrl = this.config.playwright?.url || this.config.web?.url;
450
- if (!baseUrl) return null;
438
+ if (!isFatalBrowserError(error)) {
439
+ return {
440
+ ok: true,
441
+ action: 'continue',
442
+ message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
443
+ };
444
+ }
451
445
 
452
- try {
453
- return new URL(url, baseUrl).toString();
454
- } catch {
455
- return null;
446
+ if (!(await this.recoverOrRestart())) {
447
+ return { ok: false, action: 'stop', recovered: false, message: `Browser could not be recovered after fatal error: ${message}` };
456
448
  }
449
+
450
+ this.watchActiveTestPage();
451
+ return {
452
+ ok: true,
453
+ action: 'continue',
454
+ recovered: true,
455
+ message: dedent`
456
+ Browser was recovered after a fatal page error.
457
+ Continue from the restored page.
458
+ The interrupted browser action is not product evidence.
459
+ Inspect the restored page and retry the current step when it is still required.
460
+ `,
461
+ };
457
462
  }
458
463
 
459
- isFatalBrowserError(error: unknown): boolean {
460
- return isFatalBrowserError(error);
464
+ private async recoverOrRestart(): Promise<boolean> {
465
+ if (await this.recoverPage()) return true;
466
+ return this.restartBrowser();
461
467
  }
462
468
 
463
- async recoverFromBrowserError(): Promise<boolean> {
469
+ private async recoverPage(): Promise<boolean> {
464
470
  try {
465
- if (!this.playwrightHelper?.page || this.playwrightHelper.page.isClosed?.()) {
471
+ if (!this.page) {
466
472
  const context = this.playwrightHelper?.browserContext;
467
- if (!context) return await this.restartBrowser();
468
- const page = await context.newPage();
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
- }
473
+ if (!context) return false;
474
+ await this.activateNewPage(context);
475
475
  }
476
476
 
477
477
  const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
478
478
  if (url) {
479
479
  tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
480
- await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
480
+ await this.playwrightHelper.page.goto(url, RECOVERY_NAVIGATION);
481
481
  return this.waitForPageReadiness();
482
482
  }
483
483
  tag('warning').log('Browser error detected, reloading page');
484
- await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
484
+ await this.playwrightHelper.page.reload(RECOVERY_NAVIGATION);
485
485
  return this.waitForPageReadiness();
486
486
  } catch (err) {
487
487
  tag('error').log(`Browser recovery failed: ${browserErrorMessage(err)}`);
@@ -489,24 +489,37 @@ class Explorer {
489
489
  }
490
490
  }
491
491
 
492
- async restartBrowser(): Promise<boolean> {
493
- if (!this.playwrightHelper) return false;
494
-
495
- const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
492
+ private async activateNewPage(context: any): Promise<void> {
493
+ const page = await context.newPage();
494
+ await page.bringToFront();
495
+ await this.playwrightHelper._setPage(page);
496
+ this.bindFrameNavigated(page);
497
+ this.xhrCapture?.attach(page);
498
+ }
496
499
 
497
- try {
498
- if (this.xhrCapture && this.playwrightHelper.page) {
499
- this.xhrCapture.detach(this.playwrightHelper.page);
500
- }
500
+ private async stopCaptures(): Promise<void> {
501
+ if (this.xhrCapture && this.playwrightHelper?.page) {
502
+ this.xhrCapture.detach(this.playwrightHelper.page);
503
+ }
504
+ await this.playwrightRecorder.stop();
505
+ }
501
506
 
502
- await this.playwrightRecorder.stop();
507
+ private async closeBrowserContext(): Promise<void> {
508
+ if (!this.playwrightHelper.browserContext) return;
509
+ await this.playwrightHelper.browserContext.close().catch((err: unknown) => {
510
+ debugLog('Failed to close browser context:', err);
511
+ });
512
+ this.playwrightHelper.browserContext = null;
513
+ }
503
514
 
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
- }
515
+ private async restartBrowser(): Promise<boolean> {
516
+ if (!this.playwrightHelper) return false;
517
+
518
+ const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
519
+
520
+ try {
521
+ await this.stopCaptures();
522
+ await this.closeBrowserContext();
510
523
 
511
524
  if (!this.isSharedBrowser) {
512
525
  await this.playwrightHelper._stopBrowser().catch((err: unknown) => {
@@ -517,11 +530,11 @@ class Explorer {
517
530
  await this.connectOrLaunchBrowser();
518
531
  await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
519
532
  await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
520
- this.setupXhrCapture(true);
533
+ this.attachXhrCapture();
521
534
  this.listenToStateChanged();
522
535
 
523
536
  if (url) {
524
- await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
537
+ await this.playwrightHelper.page.goto(url, RECOVERY_NAVIGATION);
525
538
  if (!(await this.waitForPageReadiness())) return false;
526
539
  }
527
540
 
@@ -533,10 +546,19 @@ class Explorer {
533
546
  }
534
547
  }
535
548
 
536
- async switchToMainFrame() {
537
- if (this.playwrightHelper.frame) {
538
- debugLog('Switching to main frame');
539
- await this.playwrightHelper.switchTo();
549
+ private resolveBrowserUrl(url?: string): string | null {
550
+ if (!url) return null;
551
+ try {
552
+ return new URL(url).toString();
553
+ } catch {}
554
+
555
+ const baseUrl = this.config.playwright?.url || this.config.web?.url;
556
+ if (!baseUrl) return null;
557
+
558
+ try {
559
+ return new URL(url, baseUrl).toString();
560
+ } catch {
561
+ return null;
540
562
  }
541
563
  }
542
564
 
@@ -551,238 +573,112 @@ class Explorer {
551
573
  return true;
552
574
  }
553
575
 
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
576
  private listenToStateChanged(): void {
588
- if (!this.playwrightHelper) {
589
- debugLog('Playwright helper not available for state monitoring');
577
+ const page = this.playwrightHelper?.page;
578
+ if (!page) {
579
+ debugLog('Playwright page not available for state monitoring');
590
580
  return;
591
581
  }
582
+ const initialPage = page;
583
+ const context = page.context();
592
584
 
593
- try {
594
- const page = this.playwrightHelper.page;
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();
585
+ context.on('page', async (newPage: any) => {
586
+ if (newPage === initialPage) return;
601
587
 
602
- context.on('page', async (newPage: any) => {
603
- if (newPage === initialPage) {
604
- return;
588
+ try {
589
+ if (newPage.url() === 'about:blank') {
590
+ await newPage.waitForURL(/^(?!about:blank$)/, { timeout: 5000 }).catch(() => {});
605
591
  }
592
+ await newPage.waitForLoadState('domcontentloaded', { timeout: 5000 });
593
+ const url = await newPage.url();
594
+ const title = await newPage.title().catch(() => 'Unknown');
606
595
 
607
- try {
608
- if (newPage.url() === 'about:blank') {
609
- await newPage.waitForURL(/^(?!about:blank$)/, { timeout: 5000 }).catch(() => {});
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' });
596
+ this.stateManager.otherTabs.push({ url, title });
597
+ if (url !== 'about:blank') {
598
+ tag('info').log(`New browser tab opened: ${url}`);
623
599
  }
624
- });
600
+ debugLog(`New tab detected: ${url} - ${title}`);
601
+ } catch (error) {
602
+ debugLog('Failed to get new tab info:', error);
603
+ this.stateManager.otherTabs.push({ url: 'unknown', title: 'unknown' });
604
+ }
605
+ });
625
606
 
626
- this.bindFrameNavigated(page);
607
+ this.bindFrameNavigated(page);
627
608
 
628
- debugLog('Listening for automatic state changes');
629
- } catch (error) {
630
- debugLog('Failed to set up state change monitoring:', error);
631
- }
609
+ debugLog('Listening for automatic state changes');
632
610
  }
633
611
 
634
- async stop(): Promise<void> {
635
- if (!this.isStarted) {
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
- }
612
+ private bindFrameNavigated(page: any): void {
613
+ page.on('framenavigated', async (frame: any) => {
614
+ if (frame !== page.mainFrame()) return;
651
615
 
652
- codeceptjs.event.dispatcher.emit('global.after');
653
- codeceptjs.event.dispatcher.emit('global.result');
616
+ const newUrl = await frame.url();
617
+ let newTitle = '';
654
618
 
655
- if (this.isSharedBrowser) {
656
- tag('info').log('Closing browser context (persistent browser stays running)');
657
619
  try {
658
- if (this.playwrightHelper.browserContext) {
659
- await this.playwrightHelper.browserContext.close();
660
- this.playwrightHelper.browserContext = null;
661
- }
662
- } catch (err) {
663
- debugLog('Failed to close browser context:', err);
620
+ newTitle = await frame.title();
621
+ } catch (error) {
622
+ debugLog('Failed to get page title:', error);
664
623
  }
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
-
687
- const stepHandler = (step: any, status?: string, error?: string, log?: string) => {
688
- if (!step.toCode) return;
689
- if (step?.name?.startsWith('grab')) return;
690
- if (step?.name?.startsWith('save')) return;
691
624
 
692
- test.addStep(step.toCode(), step.duration, status, error, log);
625
+ this.stateManager.updateStateFromBasic(newUrl, newTitle, 'navigation');
693
626
 
694
- if (!this.stateManager.getCurrentState()) return;
627
+ await sleep(500);
628
+ });
629
+ }
695
630
 
696
- const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()!).screenshotFile;
697
- test.setActiveNoteScreenshot(lastScreenshot);
698
- };
631
+ private async closeOtherTabs(): Promise<void> {
632
+ if (!this.playwrightHelper) return;
699
633
 
700
- this.watchActiveTestPage();
634
+ const context = this.playwrightHelper.page.context();
635
+ const pages = context.pages();
701
636
 
702
- const onStepPassed = (step: any) => stepHandler(step, 'passed');
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
- };
637
+ if (pages.length <= 1) return;
712
638
 
713
- codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
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);
639
+ debugLog(`Found ${pages.length} tabs, cleaning up to keep only the first one`);
718
640
 
719
- return true;
720
- }
641
+ const firstPage = pages[0];
642
+ const tabsToClose = pages.slice(1);
721
643
 
722
- async ensurePageAvailable(): Promise<boolean> {
723
- const page = this.playwrightHelper?.page;
724
- if (page && !page.isClosed?.()) {
725
- this.watchActiveTestPage(page);
726
- return true;
644
+ for (const page of tabsToClose) {
645
+ await page.close();
646
+ debugLog(`Closed extra tab: ${await page.url()}`);
727
647
  }
728
648
 
729
- const recovered = await this.recoverFromBrowserError();
730
- if (!recovered) return false;
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
- }
649
+ await firstPage.bringToFront();
650
+ await this.playwrightHelper._setPage(firstPage);
745
651
 
746
- if (error instanceof BrowserRecoveryError) {
747
- return {
748
- action: 'stop',
749
- recovered: error.recovered,
750
- message: error.message,
751
- };
752
- }
652
+ debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
653
+ }
753
654
 
754
- if (!this.isFatalBrowserError(error)) {
755
- return {
756
- action: 'continue',
757
- message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
758
- };
655
+ private async finishTest(test: Test, meta?: Record<string, string>): Promise<void> {
656
+ this.unwatchActiveTestPages();
657
+ this._activeTest = null;
658
+ const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
659
+ if (lastScreenshot) {
660
+ meta ||= {};
661
+ meta.screenshotFile = lastScreenshot;
759
662
  }
663
+ await this.reporter.reportTest(test, meta);
664
+ const codeceptjsTest = toCodeceptjsTest(test);
760
665
 
761
- let recovered = await this.recoverFromBrowserError();
762
- if (!recovered) recovered = await this.restartBrowser();
763
-
764
- if (!recovered) {
765
- return {
766
- action: 'stop',
767
- recovered: false,
768
- message: `Browser could not be recovered after fatal error: ${message}`,
769
- };
666
+ if (test.isSuccessful) {
667
+ codeceptjsTest.state = 'passed';
668
+ codeceptjs.event.dispatcher.emit('test.passed', codeceptjsTest);
669
+ } else if (test.isSkipped) {
670
+ codeceptjsTest.state = 'skipped';
671
+ codeceptjs.event.dispatcher.emit('test.skipped', codeceptjsTest);
672
+ } else {
673
+ codeceptjsTest.state = 'failed';
674
+ codeceptjs.event.dispatcher.emit('test.failed', codeceptjsTest);
770
675
  }
771
676
 
772
- this.watchActiveTestPage();
773
- return {
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
- };
677
+ codeceptjs.event.dispatcher.emit('test.finish', codeceptjsTest);
678
+ codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
783
679
  }
784
680
 
785
- watchActiveTestPage(page = this.playwrightHelper?.page): void {
681
+ private watchActiveTestPage(page = this.playwrightHelper?.page): void {
786
682
  if (!this._activeTest) return;
787
683
  if (!page) return;
788
684
  if (this.observedTestPages.has(page)) return;
@@ -806,32 +702,6 @@ class Explorer {
806
702
  this.observedTestPages.add(page);
807
703
  }
808
704
 
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
705
  private unwatchActiveTestPages(): void {
836
706
  for (const page of this.observedTestPages) {
837
707
  if (this.testPageErrorHandler) page.off('pageerror', this.testPageErrorHandler);
@@ -840,87 +710,6 @@ class Explorer {
840
710
  }
841
711
  this.observedTestPages.clear();
842
712
  }
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
713
  }
925
714
 
926
715
  function toCodeceptjsTest(test: Test): any {
@@ -938,63 +727,35 @@ function toCodeceptjsTest(test: Test): any {
938
727
  return codeceptjsTest;
939
728
  }
940
729
 
941
- const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
942
-
943
- const ANNOTATABLE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
944
-
945
- function parseAriaRefs(ariaSnapshot: string): Array<{ role: string; name: string; ref: string }> {
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;
730
+ export interface ExplorerOptions {
731
+ show?: boolean;
732
+ headless?: boolean;
733
+ incognito?: boolean;
734
+ session?: string;
954
735
  }
955
736
 
956
- export async function annotatePageElements(page: any): Promise<{ ariaSnapshot: string; elements: WebElement[] }> {
957
- const ariaSnapshot: string = await page.locator('body').ariaSnapshot({ mode: 'ai' });
958
- const refEntries = parseAriaRefs(ariaSnapshot);
737
+ export interface ExplorerDeps {
738
+ stateManager: StateManager;
739
+ knowledgeTracker: KnowledgeTracker;
740
+ reporter: Reporter;
741
+ requestStore: RequestStore;
742
+ playwrightRecorder: PlaywrightRecorder;
743
+ }
959
744
 
960
- const byRole = new Map<string, Array<{ name: string; ref: string }>>();
961
- for (const { role, name, ref } of refEntries) {
962
- let list = byRole.get(role);
963
- if (!list) {
964
- list = [];
965
- byRole.set(role, list);
966
- }
967
- list.push({ name, ref });
968
- }
745
+ export interface Recovery {
746
+ ok: boolean;
747
+ action: 'continue' | 'stop';
748
+ message: string;
749
+ recovered?: boolean;
750
+ }
969
751
 
970
- const elements: WebElement[] = [];
971
- for (const [role, entries] of byRole) {
972
- try {
973
- const rawList = await page.getByRole(role).evaluateAll(
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
- }
752
+ export interface TestRun {
753
+ started: boolean;
754
+ stop(meta?: Record<string, string>): Promise<void>;
755
+ }
996
756
 
997
- return { ariaSnapshot, elements };
757
+ export interface CaptureOpts {
758
+ screenshot?: boolean;
998
759
  }
999
760
 
1000
761
  export default Explorer;