explorbot 0.2.0 → 0.2.1

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