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.
Files changed (145) 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 +8 -2
  5. package/boat/doc-collector/src/ai/tools.ts +29 -16
  6. package/boat/doc-collector/src/cli.ts +2 -6
  7. package/boat/doc-collector/src/config.ts +2 -0
  8. package/boat/doc-collector/src/docbot.ts +42 -14
  9. package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
  10. package/boat/doc-collector/src/screenshots.ts +24 -16
  11. package/dist/bin/explorbot-cli.js +26 -6
  12. package/dist/boat/api-tester/src/config.js +43 -4
  13. package/dist/boat/doc-collector/src/ai/documentarian.js +7 -2
  14. package/dist/boat/doc-collector/src/ai/tools.js +21 -15
  15. package/dist/boat/doc-collector/src/cli.js +2 -5
  16. package/dist/boat/doc-collector/src/config.js +1 -0
  17. package/dist/boat/doc-collector/src/docbot.js +39 -13
  18. package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
  19. package/dist/boat/doc-collector/src/screenshots.js +25 -16
  20. package/dist/models.json +30 -0
  21. package/dist/package.json +8 -2
  22. package/dist/src/action.d.ts +11 -3
  23. package/dist/src/action.js +22 -5
  24. package/dist/src/ai/agent.d.ts +17 -0
  25. package/dist/src/ai/captain/idle-mode.js +1 -1
  26. package/dist/src/ai/captain/test-mode.js +1 -1
  27. package/dist/src/ai/captain/web-mode.js +18 -22
  28. package/dist/src/ai/captain.js +13 -26
  29. package/dist/src/ai/driller.d.ts +2 -13
  30. package/dist/src/ai/driller.js +23 -38
  31. package/dist/src/ai/historian/codeceptjs.js +1 -1
  32. package/dist/src/ai/historian/playwright.d.ts +3 -2
  33. package/dist/src/ai/historian/playwright.js +1 -1
  34. package/dist/src/ai/historian/screencast.d.ts +2 -1
  35. package/dist/src/ai/historian/screencast.js +2 -2
  36. package/dist/src/ai/historian.d.ts +5 -4
  37. package/dist/src/ai/navigator.d.ts +6 -2
  38. package/dist/src/ai/navigator.js +26 -22
  39. package/dist/src/ai/pilot.d.ts +13 -6
  40. package/dist/src/ai/pilot.js +49 -44
  41. package/dist/src/ai/planner.d.ts +3 -2
  42. package/dist/src/ai/planner.js +19 -17
  43. package/dist/src/ai/provider.js +0 -1
  44. package/dist/src/ai/quartermaster.d.ts +3 -2
  45. package/dist/src/ai/quartermaster.js +4 -4
  46. package/dist/src/ai/rerunner.d.ts +2 -11
  47. package/dist/src/ai/rerunner.js +9 -22
  48. package/dist/src/ai/researcher/coordinates.js +4 -3
  49. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
  50. package/dist/src/ai/researcher/deep-analysis.js +6 -6
  51. package/dist/src/ai/researcher/locators.d.ts +9 -2
  52. package/dist/src/ai/researcher/locators.js +65 -8
  53. package/dist/src/ai/researcher/sections.d.ts +2 -0
  54. package/dist/src/ai/researcher/sections.js +2 -2
  55. package/dist/src/ai/researcher.js +29 -54
  56. package/dist/src/ai/task-agent.d.ts +18 -5
  57. package/dist/src/ai/task-agent.js +35 -6
  58. package/dist/src/ai/tester.d.ts +6 -12
  59. package/dist/src/ai/tester.js +37 -51
  60. package/dist/src/ai/tools.d.ts +11 -10
  61. package/dist/src/ai/tools.js +24 -36
  62. package/dist/src/command-handler.js +1 -1
  63. package/dist/src/commands/context-aria-command.js +1 -1
  64. package/dist/src/commands/context-command.js +7 -5
  65. package/dist/src/commands/context-data-command.js +2 -2
  66. package/dist/src/commands/context-experience-command.js +2 -2
  67. package/dist/src/commands/context-html-command.js +2 -2
  68. package/dist/src/commands/context-knowledge-command.js +1 -1
  69. package/dist/src/commands/drill-command.js +1 -1
  70. package/dist/src/commands/explore-command.js +4 -3
  71. package/dist/src/commands/freesail-command.js +2 -2
  72. package/dist/src/commands/learn-command.js +4 -4
  73. package/dist/src/commands/path-command.js +1 -1
  74. package/dist/src/commands/research-command.js +1 -1
  75. package/dist/src/commands/test-command.js +1 -1
  76. package/dist/src/components/App.js +1 -1
  77. package/dist/src/config.d.ts +17 -0
  78. package/dist/src/config.js +144 -10
  79. package/dist/src/experience-tracker.js +1 -1
  80. package/dist/src/explorbot.d.ts +17 -8
  81. package/dist/src/explorbot.js +74 -36
  82. package/dist/src/explorer.d.ts +67 -91
  83. package/dist/src/explorer.js +327 -567
  84. package/dist/src/state-manager.d.ts +6 -1
  85. package/dist/src/state-manager.js +1 -0
  86. package/dist/src/utils/hooks-runner.js +2 -4
  87. package/dist/src/utils/test-files.js +1 -1
  88. package/dist/src/utils/web-annotate.d.ts +5 -0
  89. package/dist/src/utils/web-annotate.js +58 -0
  90. package/dist/src/utils/web-eidx.d.ts +2 -0
  91. package/dist/src/utils/web-eidx.js +20 -0
  92. package/dist/src/utils/web-element.d.ts +1 -0
  93. package/dist/src/utils/web-element.js +29 -0
  94. package/models.json +30 -0
  95. package/package.json +8 -2
  96. package/src/action.ts +25 -5
  97. package/src/ai/agent.ts +20 -0
  98. package/src/ai/captain/idle-mode.ts +1 -1
  99. package/src/ai/captain/test-mode.ts +1 -1
  100. package/src/ai/captain/web-mode.ts +18 -23
  101. package/src/ai/captain.ts +12 -25
  102. package/src/ai/driller.ts +50 -67
  103. package/src/ai/historian/codeceptjs.ts +1 -1
  104. package/src/ai/historian/playwright.ts +4 -3
  105. package/src/ai/historian/screencast.ts +4 -3
  106. package/src/ai/historian.ts +3 -2
  107. package/src/ai/navigator.ts +30 -25
  108. package/src/ai/pilot.ts +55 -47
  109. package/src/ai/planner.ts +22 -20
  110. package/src/ai/provider.ts +0 -1
  111. package/src/ai/quartermaster.ts +5 -4
  112. package/src/ai/rerunner.ts +10 -29
  113. package/src/ai/researcher/coordinates.ts +4 -3
  114. package/src/ai/researcher/deep-analysis.ts +8 -6
  115. package/src/ai/researcher/locators.ts +66 -9
  116. package/src/ai/researcher/sections.ts +4 -2
  117. package/src/ai/researcher.ts +32 -61
  118. package/src/ai/task-agent.ts +45 -11
  119. package/src/ai/tester.ts +40 -57
  120. package/src/ai/tools.ts +32 -53
  121. package/src/command-handler.ts +1 -1
  122. package/src/commands/context-aria-command.ts +1 -1
  123. package/src/commands/context-command.ts +7 -5
  124. package/src/commands/context-data-command.ts +2 -2
  125. package/src/commands/context-experience-command.ts +2 -2
  126. package/src/commands/context-html-command.ts +2 -2
  127. package/src/commands/context-knowledge-command.ts +1 -1
  128. package/src/commands/drill-command.ts +1 -1
  129. package/src/commands/explore-command.ts +4 -3
  130. package/src/commands/freesail-command.ts +2 -2
  131. package/src/commands/learn-command.ts +4 -4
  132. package/src/commands/path-command.ts +1 -1
  133. package/src/commands/research-command.ts +1 -1
  134. package/src/commands/test-command.ts +1 -1
  135. package/src/components/App.tsx +1 -1
  136. package/src/config.ts +173 -11
  137. package/src/experience-tracker.ts +1 -1
  138. package/src/explorbot.ts +78 -39
  139. package/src/explorer.ts +388 -626
  140. package/src/state-manager.ts +7 -1
  141. package/src/utils/hooks-runner.ts +2 -4
  142. package/src/utils/test-files.ts +1 -1
  143. package/src/utils/web-annotate.ts +64 -0
  144. package/src/utils/web-eidx.ts +21 -0
  145. 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,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 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;
61
+ private eventDispatcher = codeceptjs.event.dispatcher;
78
62
 
79
- constructor(config: ExplorbotConfig, aiProvider: AIProvider, options: { show?: boolean; headless?: boolean; incognito?: boolean; session?: string } | undefined, experienceTracker: ExperienceTracker, knowledgeTracker: KnowledgeTracker) {
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
- 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;
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
- configParser.validateConfig(this.config);
88
+ async start(): Promise<void> {
89
+ if (this.started) return;
98
90
 
99
- const codeceptConfig = this.convertToCodeceptConfig(this.config);
91
+ await codeceptjs.recorder.start();
92
+ await codeceptjs.container.started(null);
93
+ storeListener();
94
+ stepsListener();
100
95
 
101
- codeceptjs.container.create(codeceptConfig, {});
102
- } catch (error) {
103
- log(`❌ Failed to initialize container: ${error}`);
104
- throw error;
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
- createAction() {
288
- return new Action(this.actor, this.stateManager, this.playwrightRecorder);
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 runWithBrowserRecovery<T>(label: string, operation: () => Promise<T>): Promise<T> {
292
- if (!(await this.ensurePageAvailable())) {
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) && !this.isFatalBrowserError(retryError)) throw retryError;
363
+ if (!isNavigationTransitionError(retryError) && !isFatalBrowserError(retryError)) throw retryError;
308
364
  recoveryError = retryError;
309
365
  }
310
366
  }
311
367
 
312
- if (!this.isFatalBrowserError(recoveryError)) throw recoveryError;
368
+ if (!isFatalBrowserError(recoveryError)) throw recoveryError;
313
369
 
314
370
  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);
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 (this.isFatalBrowserError(retryError)) {
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 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) {
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.createAction();
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 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
- }
427
+ private async recoverFromExecutionError(error: unknown): Promise<Recovery> {
428
+ const message = browserErrorMessage(error);
429
+ tag('error').log(`Browser execution error: ${message}`);
407
430
 
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 [];
431
+ if (error instanceof Error && error.name === 'AbortError') {
432
+ return { ok: false, action: 'stop', message };
425
433
  }
426
- }
427
434
 
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;
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
- const baseUrl = this.config.playwright?.url || this.config.web?.url;
450
- if (!baseUrl) return null;
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
- try {
453
- return new URL(url, baseUrl).toString();
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
- isFatalBrowserError(error: unknown): boolean {
460
- return isFatalBrowserError(error);
465
+ private async recoverOrRestart(): Promise<boolean> {
466
+ if (await this.recoverPage()) return true;
467
+ return this.restartBrowser();
461
468
  }
462
469
 
463
- async recoverFromBrowserError(): Promise<boolean> {
470
+ private async recoverPage(): Promise<boolean> {
464
471
  try {
465
- if (!this.playwrightHelper?.page || this.playwrightHelper.page.isClosed?.()) {
472
+ if (!this.page) {
466
473
  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
- }
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, { waitUntil: 'domcontentloaded', timeout: 10000 });
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({ waitUntil: 'domcontentloaded', timeout: 10000 });
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 restartBrowser(): Promise<boolean> {
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
- if (this.xhrCapture && this.playwrightHelper.page) {
499
- this.xhrCapture.detach(this.playwrightHelper.page);
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.setupXhrCapture(true);
534
+ this.attachXhrCapture();
521
535
  this.listenToStateChanged();
522
536
 
523
537
  if (url) {
524
- await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
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
- async switchToMainFrame() {
537
- if (this.playwrightHelper.frame) {
538
- debugLog('Switching to main frame');
539
- await this.playwrightHelper.switchTo();
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
- if (!this.playwrightHelper) {
589
- debugLog('Playwright helper not available for state monitoring');
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
- 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();
586
+ context.on('page', async (newPage: any) => {
587
+ if (newPage === initialPage) return;
601
588
 
602
- context.on('page', async (newPage: any) => {
603
- if (newPage === initialPage) {
604
- return;
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
- 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' });
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
- this.bindFrameNavigated(page);
608
+ this.bindFrameNavigated(page);
627
609
 
628
- debugLog('Listening for automatic state changes');
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
- 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
- }
613
+ private bindFrameNavigated(page: any): void {
614
+ page.on('framenavigated', async (frame: any) => {
615
+ if (frame !== page.mainFrame()) return;
651
616
 
652
- codeceptjs.event.dispatcher.emit('global.after');
653
- codeceptjs.event.dispatcher.emit('global.result');
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
- 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);
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
- 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
-
692
- test.addStep(step.toCode(), step.duration, status, error, log);
626
+ this.stateManager.updateStateFromBasic(newUrl, newTitle, 'navigation');
693
627
 
694
- if (!this.stateManager.getCurrentState()) return;
628
+ await sleep(500);
629
+ });
630
+ }
695
631
 
696
- const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()!).screenshotFile;
697
- test.setActiveNoteScreenshot(lastScreenshot);
698
- };
632
+ private async closeOtherTabs(): Promise<void> {
633
+ if (!this.playwrightHelper) return;
699
634
 
700
- this.watchActiveTestPage();
635
+ const context = this.playwrightHelper.page.context();
636
+ const pages = context.pages();
701
637
 
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
- };
638
+ if (pages.length <= 1) return;
712
639
 
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);
640
+ debugLog(`Found ${pages.length} tabs, cleaning up to keep only the first one`);
718
641
 
719
- return true;
720
- }
642
+ const firstPage = pages[0];
643
+ const tabsToClose = pages.slice(1);
721
644
 
722
- async ensurePageAvailable(): Promise<boolean> {
723
- const page = this.playwrightHelper?.page;
724
- if (page && !page.isClosed?.()) {
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
- 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
- }
650
+ await firstPage.bringToFront();
651
+ await this.playwrightHelper._setPage(firstPage);
745
652
 
746
- if (error instanceof BrowserRecoveryError) {
747
- return {
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
- 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
- };
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
- 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
- };
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.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
- };
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
- 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;
731
+ export interface ExplorerOptions {
732
+ show?: boolean;
733
+ headless?: boolean;
734
+ incognito?: boolean;
735
+ session?: string;
954
736
  }
955
737
 
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);
738
+ export interface ExplorerDeps {
739
+ stateManager: StateManager;
740
+ knowledgeTracker: KnowledgeTracker;
741
+ reporter: Reporter;
742
+ requestStore: RequestStore;
743
+ playwrightRecorder: PlaywrightRecorder;
744
+ }
959
745
 
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
- }
746
+ export interface Recovery {
747
+ ok: boolean;
748
+ action: 'continue' | 'stop';
749
+ message: string;
750
+ recovered?: boolean;
751
+ }
969
752
 
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
- }
753
+ export interface TestRun {
754
+ started: boolean;
755
+ stop(meta?: Record<string, string>): Promise<void>;
756
+ }
996
757
 
997
- return { ariaSnapshot, elements };
758
+ export interface CaptureOpts {
759
+ screenshot?: boolean;
998
760
  }
999
761
 
1000
762
  export default Explorer;