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