jumpy-lion 0.1.6-beta.7 → 1.0.0-beta.0

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 (232) hide show
  1. package/README.md +1 -607
  2. package/browser/fonts/macos-bundle/40-macos-aliases.conf +515 -0
  3. package/browser/fonts/macos-bundle/README.md +24 -0
  4. package/browser/fonts/macos-bundle/install.sh +83 -0
  5. package/dist/browser-controller.d.ts +47 -4
  6. package/dist/browser-controller.d.ts.map +1 -1
  7. package/dist/browser-controller.js +219 -278
  8. package/dist/browser-controller.js.map +1 -1
  9. package/dist/browser-plugin.d.ts +82 -110
  10. package/dist/browser-plugin.d.ts.map +1 -1
  11. package/dist/browser-plugin.js +376 -379
  12. package/dist/browser-plugin.js.map +1 -1
  13. package/dist/browser-process/align-fingerprint-config.d.ts +36 -0
  14. package/dist/browser-process/align-fingerprint-config.d.ts.map +1 -0
  15. package/dist/browser-process/align-fingerprint-config.js +74 -0
  16. package/dist/browser-process/align-fingerprint-config.js.map +1 -0
  17. package/dist/browser-process/anti-detect-browser.d.ts +2 -2
  18. package/dist/browser-process/anti-detect-browser.js +7 -7
  19. package/dist/browser-process/anti-detect-config.d.ts +131 -2
  20. package/dist/browser-process/anti-detect-config.d.ts.map +1 -1
  21. package/dist/browser-process/anti-detect-config.js +337 -127
  22. package/dist/browser-process/anti-detect-config.js.map +1 -1
  23. package/dist/browser-process/browser.d.ts +25 -92
  24. package/dist/browser-process/browser.d.ts.map +1 -1
  25. package/dist/browser-process/browser.js +429 -73
  26. package/dist/browser-process/browser.js.map +1 -1
  27. package/dist/browser-process/fingerprint-config.d.ts +146 -0
  28. package/dist/browser-process/fingerprint-config.d.ts.map +1 -0
  29. package/dist/browser-process/fingerprint-config.js +308 -0
  30. package/dist/browser-process/fingerprint-config.js.map +1 -0
  31. package/dist/browser-process/get-chrome-executable.d.ts +6 -0
  32. package/dist/browser-process/get-chrome-executable.d.ts.map +1 -1
  33. package/dist/browser-process/get-chrome-executable.js +79 -4
  34. package/dist/browser-process/get-chrome-executable.js.map +1 -1
  35. package/dist/browser-process/gpu-family-profiles.d.ts +58 -0
  36. package/dist/browser-process/gpu-family-profiles.d.ts.map +1 -0
  37. package/dist/browser-process/gpu-family-profiles.js +480 -0
  38. package/dist/browser-process/gpu-family-profiles.js.map +1 -0
  39. package/dist/browser-process/launch-options.d.ts +301 -0
  40. package/dist/browser-process/launch-options.d.ts.map +1 -0
  41. package/dist/browser-process/launch-options.js +2 -0
  42. package/dist/browser-process/launch-options.js.map +1 -0
  43. package/dist/browser-process/port-allocator.d.ts +64 -0
  44. package/dist/browser-process/port-allocator.d.ts.map +1 -0
  45. package/dist/browser-process/port-allocator.js +198 -0
  46. package/dist/browser-process/port-allocator.js.map +1 -0
  47. package/dist/browser-process/process.d.ts +85 -0
  48. package/dist/browser-process/process.d.ts.map +1 -1
  49. package/dist/browser-process/process.js +235 -19
  50. package/dist/browser-process/process.js.map +1 -1
  51. package/dist/browser-process/signal-cleanup.d.ts +40 -0
  52. package/dist/browser-process/signal-cleanup.d.ts.map +1 -0
  53. package/dist/browser-process/signal-cleanup.js +105 -0
  54. package/dist/browser-process/signal-cleanup.js.map +1 -0
  55. package/dist/browser-process/widevine-cdm.d.ts +144 -0
  56. package/dist/browser-process/widevine-cdm.d.ts.map +1 -0
  57. package/dist/browser-process/widevine-cdm.js +298 -0
  58. package/dist/browser-process/widevine-cdm.js.map +1 -0
  59. package/dist/browser-profiles/chrome/populate-profile.d.ts.map +1 -1
  60. package/dist/browser-profiles/chrome/populate-profile.js +21 -22
  61. package/dist/browser-profiles/chrome/populate-profile.js.map +1 -1
  62. package/dist/connection/cdp-reconnection.d.ts +50 -0
  63. package/dist/connection/cdp-reconnection.d.ts.map +1 -0
  64. package/dist/connection/cdp-reconnection.js +75 -0
  65. package/dist/connection/cdp-reconnection.js.map +1 -0
  66. package/dist/connection/page-session.d.ts +114 -0
  67. package/dist/connection/page-session.d.ts.map +1 -0
  68. package/dist/connection/page-session.js +237 -0
  69. package/dist/connection/page-session.js.map +1 -0
  70. package/dist/connection/session-client.d.ts +34 -0
  71. package/dist/connection/session-client.d.ts.map +1 -0
  72. package/dist/connection/session-client.js +129 -0
  73. package/dist/connection/session-client.js.map +1 -0
  74. package/dist/crawler.d.ts +9 -88
  75. package/dist/crawler.d.ts.map +1 -1
  76. package/dist/crawler.js +157 -8
  77. package/dist/crawler.js.map +1 -1
  78. package/dist/fingerprinting/fingerprint-injector.d.ts +24 -114
  79. package/dist/fingerprinting/fingerprint-injector.d.ts.map +1 -1
  80. package/dist/fingerprinting/fingerprint-injector.js +305 -611
  81. package/dist/fingerprinting/fingerprint-injector.js.map +1 -1
  82. package/dist/fingerprinting/fingerprint-overrides/datadome-bypass.d.ts +1 -1
  83. package/dist/fingerprinting/fingerprint-overrides/datadome-bypass.d.ts.map +1 -1
  84. package/dist/fingerprinting/fingerprint-overrides/datadome-bypass.js +16 -14
  85. package/dist/fingerprinting/fingerprint-overrides/datadome-bypass.js.map +1 -1
  86. package/dist/fingerprinting/fingerprint-overrides/index.d.ts +6 -2
  87. package/dist/fingerprinting/fingerprint-overrides/index.d.ts.map +1 -1
  88. package/dist/fingerprinting/fingerprint-overrides/index.js +6 -2
  89. package/dist/fingerprinting/fingerprint-overrides/index.js.map +1 -1
  90. package/dist/fingerprinting/fingerprint-overrides/keyboard-humanization.d.ts +1 -1
  91. package/dist/fingerprinting/fingerprint-overrides/keyboard-humanization.js +4 -4
  92. package/dist/fingerprinting/fingerprint-overrides/keyboard-humanization.js.map +1 -1
  93. package/dist/fingerprinting/fingerprint-overrides/locale-spoofing.js +4 -4
  94. package/dist/fingerprinting/fingerprint-overrides/navigator-override.d.ts +18 -0
  95. package/dist/fingerprinting/fingerprint-overrides/navigator-override.d.ts.map +1 -0
  96. package/dist/fingerprinting/fingerprint-overrides/navigator-override.js +136 -0
  97. package/dist/fingerprinting/fingerprint-overrides/navigator-override.js.map +1 -0
  98. package/dist/fingerprinting/fingerprint-overrides/override.d.ts +151 -0
  99. package/dist/fingerprinting/fingerprint-overrides/override.d.ts.map +1 -0
  100. package/dist/fingerprinting/fingerprint-overrides/override.js +14 -0
  101. package/dist/fingerprinting/fingerprint-overrides/override.js.map +1 -0
  102. package/dist/fingerprinting/fingerprint-overrides/platform-consistency.d.ts.map +1 -1
  103. package/dist/fingerprinting/fingerprint-overrides/platform-consistency.js +46 -15
  104. package/dist/fingerprinting/fingerprint-overrides/platform-consistency.js.map +1 -1
  105. package/dist/fingerprinting/fingerprint-overrides/registry.d.ts +43 -0
  106. package/dist/fingerprinting/fingerprint-overrides/registry.d.ts.map +1 -0
  107. package/dist/fingerprinting/fingerprint-overrides/registry.js +342 -0
  108. package/dist/fingerprinting/fingerprint-overrides/registry.js.map +1 -0
  109. package/dist/fingerprinting/fingerprint-overrides/screen-override.d.ts +16 -0
  110. package/dist/fingerprinting/fingerprint-overrides/screen-override.d.ts.map +1 -0
  111. package/dist/fingerprinting/fingerprint-overrides/screen-override.js +175 -0
  112. package/dist/fingerprinting/fingerprint-overrides/screen-override.js.map +1 -0
  113. package/dist/fingerprinting/fingerprint-overrides/scroll-humanization.d.ts +1 -1
  114. package/dist/fingerprinting/fingerprint-overrides/scroll-humanization.js +4 -4
  115. package/dist/fingerprinting/fingerprint-overrides/scroll-humanization.js.map +1 -1
  116. package/dist/fingerprinting/fingerprint-overrides/stealth-script.d.ts.map +1 -1
  117. package/dist/fingerprinting/fingerprint-overrides/stealth-script.js +75 -9
  118. package/dist/fingerprinting/fingerprint-overrides/stealth-script.js.map +1 -1
  119. package/dist/fingerprinting/fingerprint-overrides/ua-ch.d.ts +1 -1
  120. package/dist/fingerprinting/fingerprint-overrides/ua-ch.d.ts.map +1 -1
  121. package/dist/fingerprinting/fingerprint-overrides/ua-ch.js +34 -20
  122. package/dist/fingerprinting/fingerprint-overrides/ua-ch.js.map +1 -1
  123. package/dist/fingerprinting/fingerprint-overrides/webgl-spoofing.d.ts.map +1 -1
  124. package/dist/fingerprinting/fingerprint-overrides/webgl-spoofing.js +25 -6
  125. package/dist/fingerprinting/fingerprint-overrides/webgl-spoofing.js.map +1 -1
  126. package/dist/fingerprinting/fingerprint-overrides/webrtc-spoofing.js +1 -1
  127. package/dist/fingerprinting/hardware-realism.d.ts +83 -0
  128. package/dist/fingerprinting/hardware-realism.d.ts.map +1 -0
  129. package/dist/fingerprinting/hardware-realism.js +156 -0
  130. package/dist/fingerprinting/hardware-realism.js.map +1 -0
  131. package/dist/fingerprinting/injection-planner.d.ts +133 -0
  132. package/dist/fingerprinting/injection-planner.d.ts.map +1 -0
  133. package/dist/fingerprinting/injection-planner.js +497 -0
  134. package/dist/fingerprinting/injection-planner.js.map +1 -0
  135. package/dist/fingerprinting/locale-resolver.d.ts +8 -1
  136. package/dist/fingerprinting/locale-resolver.d.ts.map +1 -1
  137. package/dist/fingerprinting/locale-resolver.js +26 -3
  138. package/dist/fingerprinting/locale-resolver.js.map +1 -1
  139. package/dist/fingerprinting/non-apify-fingerprint-generator.d.ts +4 -4
  140. package/dist/fingerprinting/non-apify-fingerprint-generator.d.ts.map +1 -1
  141. package/dist/fingerprinting/non-apify-fingerprint-generator.js +24 -58
  142. package/dist/fingerprinting/non-apify-fingerprint-generator.js.map +1 -1
  143. package/dist/fingerprinting/non-apify-profiles.json +10515 -8494
  144. package/dist/fingerprinting/os-consistency.d.ts +28 -0
  145. package/dist/fingerprinting/os-consistency.d.ts.map +1 -0
  146. package/dist/fingerprinting/os-consistency.js +45 -0
  147. package/dist/fingerprinting/os-consistency.js.map +1 -0
  148. package/dist/fingerprinting/profile-quality.d.ts +25 -0
  149. package/dist/fingerprinting/profile-quality.d.ts.map +1 -0
  150. package/dist/fingerprinting/profile-quality.js +310 -0
  151. package/dist/fingerprinting/profile-quality.js.map +1 -0
  152. package/dist/fingerprinting/profile-selector.d.ts +102 -0
  153. package/dist/fingerprinting/profile-selector.d.ts.map +1 -0
  154. package/dist/fingerprinting/profile-selector.js +168 -0
  155. package/dist/fingerprinting/profile-selector.js.map +1 -0
  156. package/dist/fingerprinting/ua-alignment.d.ts +83 -0
  157. package/dist/fingerprinting/ua-alignment.d.ts.map +1 -0
  158. package/dist/fingerprinting/ua-alignment.js +197 -0
  159. package/dist/fingerprinting/ua-alignment.js.map +1 -0
  160. package/dist/fingerprinting/ua-ch-brands.d.ts +88 -0
  161. package/dist/fingerprinting/ua-ch-brands.d.ts.map +1 -0
  162. package/dist/fingerprinting/ua-ch-brands.js +174 -0
  163. package/dist/fingerprinting/ua-ch-brands.js.map +1 -0
  164. package/dist/index.d.ts +5 -0
  165. package/dist/index.d.ts.map +1 -1
  166. package/dist/index.js +6 -0
  167. package/dist/index.js.map +1 -1
  168. package/dist/input/dropdown-selector.d.ts +90 -0
  169. package/dist/input/dropdown-selector.d.ts.map +1 -0
  170. package/dist/input/dropdown-selector.js +394 -0
  171. package/dist/input/dropdown-selector.js.map +1 -0
  172. package/dist/input/element-target.d.ts +152 -0
  173. package/dist/input/element-target.d.ts.map +1 -0
  174. package/dist/input/element-target.js +461 -0
  175. package/dist/input/element-target.js.map +1 -0
  176. package/dist/input/input-emulator.d.ts +85 -0
  177. package/dist/input/input-emulator.d.ts.map +1 -0
  178. package/dist/input/input-emulator.js +325 -0
  179. package/dist/input/input-emulator.js.map +1 -0
  180. package/dist/input/input-transport.d.ts +60 -0
  181. package/dist/input/input-transport.d.ts.map +1 -0
  182. package/dist/input/input-transport.js +28 -0
  183. package/dist/input/input-transport.js.map +1 -0
  184. package/dist/input/recording-transport.d.ts +32 -0
  185. package/dist/input/recording-transport.d.ts.map +1 -0
  186. package/dist/input/recording-transport.js +43 -0
  187. package/dist/input/recording-transport.js.map +1 -0
  188. package/dist/keyboard-layout.d.ts +2 -2
  189. package/dist/keyboard-layout.js +2 -2
  190. package/dist/navigation/page-navigation.d.ts +67 -0
  191. package/dist/navigation/page-navigation.d.ts.map +1 -0
  192. package/dist/navigation/page-navigation.js +122 -0
  193. package/dist/navigation/page-navigation.js.map +1 -0
  194. package/dist/network/network-watch.d.ts +110 -0
  195. package/dist/network/network-watch.d.ts.map +1 -0
  196. package/dist/network/network-watch.js +223 -0
  197. package/dist/network/network-watch.js.map +1 -0
  198. package/dist/page.d.ts +118 -104
  199. package/dist/page.d.ts.map +1 -1
  200. package/dist/page.js +311 -1130
  201. package/dist/page.js.map +1 -1
  202. package/dist/session/inject-hook.d.ts +24 -0
  203. package/dist/session/inject-hook.d.ts.map +1 -0
  204. package/dist/session/inject-hook.js +80 -0
  205. package/dist/session/inject-hook.js.map +1 -0
  206. package/dist/session/save.d.ts +21 -0
  207. package/dist/session/save.d.ts.map +1 -0
  208. package/dist/session/save.js +163 -0
  209. package/dist/session/save.js.map +1 -0
  210. package/dist/session/tar-data-dir.d.ts +18 -0
  211. package/dist/session/tar-data-dir.d.ts.map +1 -0
  212. package/dist/session/tar-data-dir.js +105 -0
  213. package/dist/session/tar-data-dir.js.map +1 -0
  214. package/dist/session/types.d.ts +80 -0
  215. package/dist/session/types.d.ts.map +1 -0
  216. package/dist/session/types.js +29 -0
  217. package/dist/session/types.js.map +1 -0
  218. package/dist/session-profile.d.ts +102 -0
  219. package/dist/session-profile.d.ts.map +1 -0
  220. package/dist/session-profile.js +163 -0
  221. package/dist/session-profile.js.map +1 -0
  222. package/dist/tsconfig.build.tsbuildinfo +1 -1
  223. package/package.json +17 -6
  224. package/scripts/postinstall.cjs +58 -0
  225. package/dist/fingerprinting/custom-fingerprint-injector.d.ts +0 -87
  226. package/dist/fingerprinting/custom-fingerprint-injector.d.ts.map +0 -1
  227. package/dist/fingerprinting/custom-fingerprint-injector.js +0 -342
  228. package/dist/fingerprinting/custom-fingerprint-injector.js.map +0 -1
  229. package/dist/launcher-wrap.d.ts +0 -10
  230. package/dist/launcher-wrap.d.ts.map +0 -1
  231. package/dist/launcher-wrap.js +0 -11
  232. package/dist/launcher-wrap.js.map +0 -1
package/dist/page.js CHANGED
@@ -1,230 +1,221 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import { load as cheerioLoad } from 'cheerio';
3
- import CDP from 'chrome-remote-interface';
4
3
  import { log } from 'crawlee';
4
+ import { PageSession } from './connection/page-session.js';
5
5
  import { PAGE_CLOSED, PAGE_CREATED } from './events.js';
6
6
  import { humanScrollToElement } from './fingerprinting/fingerprint-overrides/index.js';
7
- import { describeChar, MODIFIER_SHIFT } from './keyboard-layout.js';
7
+ import { DropdownSelector } from './input/dropdown-selector.js';
8
+ import { ElementTarget } from './input/element-target.js';
9
+ import { InputEmulator } from './input/input-emulator.js';
10
+ import { CdpInputTransport } from './input/input-transport.js';
11
+ import { PageNavigation } from './navigation/page-navigation.js';
12
+ import { NetworkWatch } from './network/network-watch.js';
8
13
  // Constants for timing and delays
9
- const PRESS_RELEASE_DELAY_MS = 50; // ms between mouse press and release
10
- const SCROLL_ANIMATION_DELAY_MS = 200; // ms to wait for scroll animations
11
- const OPTION_LOAD_DELAY_MS = 300; // ms to wait for new options to load in dropdowns
12
- const DROPDOWN_OPEN_DELAY_MS = 300; // ms to wait for dropdown to open
13
- const HUMAN_DELAY_MS = 200; // ms delay for human-like interactions
14
- const SCROLL_COMPLETE_DELAY_MS = 300; // ms to wait for scroll to complete
15
- const HUMAN_KEYBOARD_DELAY_MIN_MS = 45;
16
- const HUMAN_KEYBOARD_DELAY_MAX_MS = 160;
17
- const HUMAN_MOUSE_MOVE_DELAY_MIN_MS = 6;
18
- const HUMAN_MOUSE_MOVE_DELAY_MAX_MS = 18;
19
- /** Bit values for `MouseEvent.buttons` per the UI Events spec. */
20
- const MOUSE_BUTTON_BITS = {
21
- left: 1,
22
- right: 2,
23
- middle: 4,
24
- };
14
+ // (mouse/keyboard humanization timing + button bitmask moved to src/input/input-emulator.ts)
25
15
  // Constants for stability and positioning
26
16
  const DEFAULT_TIMEOUT_MS = 30000; // Default timeout for operations
27
17
  const POSITION_STABILITY_TIMEOUT_MS = 2000; // Timeout for position stabilization
28
18
  const POSITION_CHECK_INTERVAL_MS = 100; // Interval for position checks
29
19
  const POSITION_STABILITY_THRESHOLD = 3; // Number of consecutive stable checks required
30
20
  const POSITION_TOLERANCE_PX = 1; // Maximum pixel difference to consider "stable"
31
- const VIEWPORT_TOLERANCE_PX = 50; // Tolerance for viewport positioning
32
- // Constants for polling and retries
33
- const POLLING_INTERVAL_MS = 100; // Default polling interval
34
- /**
35
- * Creates a proxy that wraps CdpPage methods with reconnection logic
36
-
37
- */
38
- // @ts-expect-error not used now
39
- function createReconnectingPageProxy(page) {
40
- const MAX_RETRIES = 3;
41
- const INITIAL_BACKOFF = 1000;
42
- async function executeWithRetries(operation, methodName) {
43
- let lastError = null;
44
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
45
- try {
46
- // Wait for any ongoing reconnection
47
- while (page.isReconnecting) {
48
- await new Promise(resolve => setTimeout(resolve, 100));
49
- }
50
- // @ts-expect-error Accessing internal WebSocket
51
- const ws = page._rawClient._ws;
52
- // Check connection state before method call
53
- if (ws && (ws.readyState !== ws.OPEN || ws.readyState === ws.CLOSING)) {
54
- const backoff = INITIAL_BACKOFF * 2 ** attempt;
55
- log.info(`WebSocket not connected before ${methodName}, attempting to reconnect... (attempt ${attempt + 1}/${MAX_RETRIES})`);
56
- try {
57
- await page.reconnectSession();
58
- await new Promise(resolve => setTimeout(resolve, backoff));
59
- }
60
- catch (reconnectError) {
61
- const error = reconnectError instanceof Error ? reconnectError : new Error(String(reconnectError));
62
- log.error('Reconnection failed:', { error });
63
- continue;
64
- }
65
- }
66
- return await operation();
67
- }
68
- catch (error) {
69
- lastError = error instanceof Error ? error : new Error(String(error));
70
- if (error instanceof Error && error.message?.includes('WebSocket is not open')) {
71
- const backoff = INITIAL_BACKOFF * 2 ** attempt;
72
- log.info(`WebSocket error during ${methodName}, attempting to reconnect... (attempt ${attempt + 1}/${MAX_RETRIES})`);
73
- try {
74
- await page.reconnectSession();
75
- await new Promise(resolve => setTimeout(resolve, backoff));
76
- continue;
77
- }
78
- catch (reconnectError) {
79
- const reconnectErr = reconnectError instanceof Error ? reconnectError : new Error(String(reconnectError));
80
- log.error('Reconnection failed:', { error: reconnectErr });
81
- }
82
- }
83
- throw error;
84
- }
85
- }
86
- throw lastError || new Error(`Failed to execute ${methodName} after ${MAX_RETRIES} attempts`);
87
- }
88
- return new Proxy(page, {
89
- get(target, property) {
90
- const original = target[property];
91
- const propertyName = String(property);
92
- // Special handling for close method
93
- if (propertyName === 'close') {
94
- return original;
95
- }
96
- // Only proxy public methods that return promises
97
- if (typeof original === 'function' &&
98
- !propertyName.startsWith('_') &&
99
- !propertyName.startsWith('on') &&
100
- propertyName !== 'emit' &&
101
- propertyName !== 'addListener' &&
102
- propertyName !== 'removeListener') {
103
- return async (...args) => {
104
- // @ts-expect-error Accessing private property
105
- if (target.isClosed) {
106
- log.debug(`Operation '${propertyName}' skipped - page is closed`);
107
- return;
108
- }
109
- return executeWithRetries(() => original.apply(target, args), propertyName);
110
- };
111
- }
112
- return original;
113
- },
114
- });
115
- }
116
21
  export default class CdpPage extends EventEmitter {
117
- constructor(client) {
22
+ /**
23
+ * Navigation + load-wait protocol - navigate, race dom/load events vs
24
+ * timeout, reconcile URL via getNavigationHistory - lives in PageNavigation
25
+ * behind NavigationPort. The port closures read the current client through
26
+ * the session accessor, so a reconnect client swap is picked up. The
27
+ * `loadedUrl` cache lives inside the module, not on this object.
28
+ */
29
+ get navigation() {
30
+ this._navigation ?? (this._navigation = new PageNavigation(this.navigationPort()));
31
+ return this._navigation;
32
+ }
33
+ navigationPort() {
34
+ return {
35
+ enablePage: async () => this.ensurePageEnabled(),
36
+ navigate: async (url) => {
37
+ this._documentNodeId = undefined;
38
+ await this.client.Page.navigate({ url });
39
+ },
40
+ reload: async () => {
41
+ this._documentNodeId = undefined;
42
+ await this.client.Page.reload({});
43
+ },
44
+ // chrome-remote-interface returns an unsubscribe fn from an event
45
+ // subscription; surface it so PageNavigation can detach the
46
+ // milestone listener after every navigation (incl. timeouts).
47
+ onDomContent: (handler) => this.client.Page.domContentEventFired(() => handler()),
48
+ onLoad: (handler) => this.client.Page.loadEventFired(() => handler()),
49
+ navigationHistory: async () => this.client.Page.getNavigationHistory(),
50
+ evaluateUrl: async () => this.evaluate('window.location.href'),
51
+ };
52
+ }
53
+ /**
54
+ * Network interception protocol - match/timeout/cleanup races live in
55
+ * NetworkWatch behind NetworkWatchPort. The port closures read
56
+ * `this.client` at call time, so a reconnectSession() client swap is
57
+ * picked up automatically.
58
+ */
59
+ get networkWatch() {
60
+ this._networkWatch ?? (this._networkWatch = new NetworkWatch({
61
+ ensureNetworkEnabled: async () => this.ensureNetworkEnabled(),
62
+ onResponseReceived: (handler) => {
63
+ const client = this.client;
64
+ client.on('Network.responseReceived', handler);
65
+ return () => client.removeListener('Network.responseReceived', handler);
66
+ },
67
+ getResponseBody: async (requestId) => this.client.Network.getResponseBody({ requestId }),
68
+ setRequestInterception: async (patterns) => {
69
+ await this.client.Network.setRequestInterception({ patterns });
70
+ },
71
+ onRequestIntercepted: (handler) => {
72
+ this.client.Network.on('requestIntercepted', handler);
73
+ },
74
+ continueInterceptedRequest: async (params) => {
75
+ await this.client.Network.continueInterceptedRequest(params);
76
+ },
77
+ }));
78
+ return this._networkWatch;
79
+ }
80
+ constructor(client, sessionOptions) {
118
81
  super();
119
- Object.defineProperty(this, "_rawClient", {
82
+ /**
83
+ * Owns the live CDP session for this page: the current client, reconnect +
84
+ * heartbeat, the `isReconnecting` latch, and domain re-bind on swap. CDP
85
+ * methods route through `session.run(op)`; consumers read the current
86
+ * client through `session.client()`. See src/connection/page-session.ts.
87
+ */
88
+ Object.defineProperty(this, "session", {
120
89
  enumerable: true,
121
90
  configurable: true,
122
91
  writable: true,
123
92
  value: void 0
124
93
  });
125
- Object.defineProperty(this, "client", {
94
+ Object.defineProperty(this, "_navigation", {
126
95
  enumerable: true,
127
96
  configurable: true,
128
97
  writable: true,
129
98
  value: void 0
130
99
  });
131
- Object.defineProperty(this, "loadedUrl", {
132
- enumerable: true,
133
- configurable: true,
134
- writable: true,
135
- value: null
136
- });
137
- Object.defineProperty(this, "isReconnecting", {
100
+ /**
101
+ * Cached document root nodeId for {@link getNodeId}. Populated on first
102
+ * lookup and reused across clicks so we don't re-issue DOM.getDocument on
103
+ * every element resolution. Invalidated on main-frame navigation (the
104
+ * navigate/reload closures below) because a new document reassigns node
105
+ * IDs; getNodeId additionally self-heals with a fresh getDocument if a
106
+ * stale root slips through.
107
+ */
108
+ Object.defineProperty(this, "_documentNodeId", {
138
109
  enumerable: true,
139
110
  configurable: true,
140
111
  writable: true,
141
- value: false
112
+ value: void 0
142
113
  });
143
- Object.defineProperty(this, "enabledDomains", {
114
+ Object.defineProperty(this, "_networkWatch", {
144
115
  enumerable: true,
145
116
  configurable: true,
146
117
  writable: true,
147
- value: new Set()
118
+ value: void 0
148
119
  });
149
- Object.defineProperty(this, "connectionMonitor", {
120
+ Object.defineProperty(this, "humanization", {
150
121
  enumerable: true,
151
122
  configurable: true,
152
123
  writable: true,
153
- value: void 0
124
+ value: {
125
+ mouse: false,
126
+ keyboard: false,
127
+ scroll: false,
128
+ }
154
129
  });
155
- Object.defineProperty(this, "isClosed", {
130
+ Object.defineProperty(this, "launchSnapshot", {
156
131
  enumerable: true,
157
132
  configurable: true,
158
133
  writable: true,
159
- value: false
134
+ value: {}
160
135
  });
161
- Object.defineProperty(this, "humanization", {
136
+ /**
137
+ * Human-like input emulation (mouse, keyboard, IME) over a transport bound
138
+ * to the live CDP client - see src/input/input-emulator.ts. The lazy
139
+ * `() => this.client` accessor keeps the transport pointed at the current
140
+ * connection across session reconnects, which swap `this.client`.
141
+ */
142
+ Object.defineProperty(this, "input", {
162
143
  enumerable: true,
163
144
  configurable: true,
164
145
  writable: true,
165
- value: {
166
- mouse: false,
167
- keyboard: false,
168
- scroll: false,
169
- }
146
+ value: void 0
170
147
  });
171
- Object.defineProperty(this, "lastMousePosition", {
148
+ /**
149
+ * Element targeting (geometry, visibility, scroll, position stabilization)
150
+ * over the narrow ElementTargetPort - see src/input/element-target.ts.
151
+ * Decides WHERE/WHEN to interact; `input` decides HOW.
152
+ */
153
+ Object.defineProperty(this, "elements", {
172
154
  enumerable: true,
173
155
  configurable: true,
174
156
  writable: true,
175
- value: { x: 0, y: 0 }
157
+ value: void 0
176
158
  });
177
159
  /**
178
- * Set of currently-held mouse buttons. Resolved to the `MouseEvent.buttons`
179
- * bitmask (left=1, right=2, middle=4) on every `Input.dispatchMouseEvent`
180
- * so JS sees the spec-correct value during press / drag / release. Without
181
- * this, every press reads `event.buttons === 0` — a one-line bot signal.
160
+ * Select/dropdown protocol (native <select>, custom-dropdown trigger
161
+ * detection, idempotent open, virtualized-list scrolling) over the same
162
+ * port - see src/input/dropdown-selector.ts.
182
163
  */
183
- Object.defineProperty(this, "heldMouseButtons", {
164
+ Object.defineProperty(this, "dropdowns", {
184
165
  enumerable: true,
185
166
  configurable: true,
186
167
  writable: true,
187
- value: new Set()
168
+ value: void 0
188
169
  });
189
- this._rawClient = client;
190
- this.client = client;
170
+ this.session = new PageSession({ initialClient: client, ...sessionOptions });
171
+ this.input = new InputEmulator(new CdpInputTransport(() => this.client), this.humanization);
172
+ const targetPort = {
173
+ evaluate: async (fn, ...args) => this.evaluate(fn, ...args),
174
+ humanScrollToElement: async (selector) => {
175
+ await Promise.all([
176
+ this._enableDomain('DOM'),
177
+ this._enableDomain('Input'),
178
+ ]);
179
+ await humanScrollToElement(this.client, selector);
180
+ },
181
+ humanization: () => this.humanization,
182
+ };
183
+ this.elements = new ElementTarget(targetPort);
184
+ this.dropdowns = new DropdownSelector({
185
+ ...targetPort,
186
+ waitForSelector: async (selector, options) => this.waitForSelector(selector, options),
187
+ isLooselyVisible: async (selector) => this.isVisible(selector),
188
+ // Trusted coordinate click for dropdown triggers/options: the same
189
+ // CDP Input path CdpPage.click uses, so dropdown interactions carry
190
+ // isTrusted === true like every other click we make.
191
+ clickAt: async (x, y) => {
192
+ await Promise.all([
193
+ this._enableDomain('DOM'),
194
+ this._enableDomain('Input'),
195
+ ]);
196
+ await this.input.clickAt(x, y);
197
+ },
198
+ }, this.elements);
191
199
  this.emit(PAGE_CREATED);
192
- // this._attachConnectionMonitor();
193
200
  }
194
- _attachConnectionMonitor() {
195
- if (this.connectionMonitor) {
196
- clearInterval(this.connectionMonitor);
197
- }
198
- // @ts-expect-error Accessing internal WebSocket
199
- const ws = this._rawClient._ws;
200
- if (!ws)
201
- return;
202
- ws.on('error', (error) => {
203
- log.error('WebSocket error occurred:', { error });
204
- });
205
- // Only monitor connection health
206
- this.connectionMonitor = setInterval(() => {
207
- if (ws.readyState === ws.OPEN) {
208
- ws.ping(() => { });
209
- }
210
- else if (!this.isReconnecting && ws.readyState !== ws.CONNECTING) {
211
- log.debug('Connection health check failed, clearing monitor');
212
- if (this.connectionMonitor)
213
- clearInterval(this.connectionMonitor);
214
- }
215
- }, 5000);
216
- this.once(PAGE_CLOSED, () => {
217
- if (this.connectionMonitor)
218
- clearInterval(this.connectionMonitor);
219
- });
201
+ /** The current CDP client. Reads through PageSession so a reconnect swap is picked up. */
202
+ get client() {
203
+ return this.session.client();
204
+ }
205
+ /** The current raw CDP client (some consumers reach for `_ws` directly). */
206
+ get _rawClient() {
207
+ return this.session.client();
208
+ }
209
+ /** True while the page session is mid-reconnect. */
210
+ get isReconnecting() {
211
+ return this.session.isReconnecting;
220
212
  }
221
213
  // @TODO: The enabling of API can cause blocking.
222
214
  // We need to discover this more and add options to the crawler.
223
- static async create(client) {
224
- const page = new CdpPage(client);
215
+ static async create(client, sessionOptions) {
216
+ const page = new CdpPage(client, sessionOptions);
225
217
  await page.initialize();
226
- // const proxiedPage = createReconnectingPageProxy(page);
227
- log.debug('Created proxied page instance');
218
+ log.debug('Created page instance');
228
219
  return page;
229
220
  }
230
221
  async initialize() {
@@ -245,13 +236,8 @@ export default class CdpPage extends EventEmitter {
245
236
  * Once enabled, subsequent calls are no-ops.
246
237
  */
247
238
  async ensurePageEnabled() {
248
- if (this.enabledDomains.has('Page')) {
249
- return; // Already enabled
250
- }
251
239
  try {
252
- await this.client.Page.enable();
253
- this.enabledDomains.add('Page');
254
- log.debug('Page domain enabled lazily for script injection');
240
+ await this.session.enableDomain('Page');
255
241
  }
256
242
  catch (error) {
257
243
  log.error('Failed to enable Page domain:', { error });
@@ -264,12 +250,7 @@ export default class CdpPage extends EventEmitter {
264
250
  * Once enabled, subsequent calls are no-ops.
265
251
  */
266
252
  async ensureNetworkEnabled() {
267
- if (this.enabledDomains.has('Network')) {
268
- return; // Already enabled
269
- }
270
- await this.client.Network.enable();
271
- this.enabledDomains.add('Network');
272
- log.debug('Network domain enabled (lazy)');
253
+ await this.session.enableDomain('Network');
273
254
  }
274
255
  setHumanizationOptions(options) {
275
256
  this.humanization = {
@@ -277,86 +258,33 @@ export default class CdpPage extends EventEmitter {
277
258
  keyboard: options?.keyboard ?? false,
278
259
  scroll: options?.scroll ?? false,
279
260
  };
261
+ this.input.setHumanization(this.humanization);
280
262
  }
281
263
  async url() {
282
- if (!this.loadedUrl) {
283
- return await this._getCurrentUrlWithoutRuntime();
284
- }
285
- return this.loadedUrl;
286
- }
287
- /**
288
- * Get current URL without activating the Runtime domain.
289
- * Uses Page.getNavigationHistory which only requires the Page domain.
290
- */
291
- async _getCurrentUrlWithoutRuntime() {
292
- try {
293
- await this.ensurePageEnabled();
294
- const { currentIndex, entries } = await this.client.Page.getNavigationHistory();
295
- if (entries && entries.length > 0 && currentIndex >= 0) {
296
- return entries[currentIndex].url;
297
- }
298
- }
299
- catch {
300
- // Fallback: if Page.getNavigationHistory fails, try evaluate
301
- log.debug('getNavigationHistory failed, falling back to evaluate');
302
- }
303
- return await this.evaluate('window.location.href');
264
+ return this.navigation.url();
304
265
  }
305
- // Navigation method
266
+ // Navigation method - protocol lives in PageNavigation (src/navigation/page-navigation.ts)
306
267
  async goto(url, options) {
307
- const { Page } = this.client;
308
- const timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS;
309
- await Page.navigate({ url });
310
- const waitUntil = options?.waitUntil ?? 'load';
311
- let waitEvent;
312
- switch (waitUntil) {
313
- case 'load':
314
- waitEvent = new Promise((resolve) => Page.loadEventFired(() => resolve()));
315
- break;
316
- case 'domcontentloaded':
317
- waitEvent = new Promise((resolve) => Page.domContentEventFired(() => resolve()));
318
- break;
319
- default:
320
- throw new Error(`Unsupported waitUntil event: ${waitUntil}`);
321
- }
322
- await Promise.race([
323
- waitEvent,
324
- new Promise((_, reject) => setTimeout(() => reject(new Error('Navigation timeout')), timeout)),
325
- ]);
326
- // Use Page.getNavigationHistory instead of Runtime.evaluate to avoid
327
- // activating the Runtime domain (which is detection vector #1)
328
- this.loadedUrl = await this._getCurrentUrlWithoutRuntime();
268
+ // Transparently heal a dropped CDP socket; a closed page is a no-op.
269
+ await this.session.run(async () => this.navigation.goto(url, options));
329
270
  }
330
271
  /**
331
272
  * Reloads the current page.
332
273
  * @param options - Options for waiting and timeout, similar to goto.
333
274
  */
334
275
  async reload(options) {
335
- const { Page } = this.client;
336
- const timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS;
337
- const waitUntil = options?.waitUntil ?? 'load';
338
- await Page.reload({});
339
- let waitEvent;
340
- switch (waitUntil) {
341
- case 'load':
342
- waitEvent = new Promise((resolve) => Page.loadEventFired(() => resolve()));
343
- break;
344
- case 'domcontentloaded':
345
- waitEvent = new Promise((resolve) => Page.domContentEventFired(() => resolve()));
346
- break;
347
- default:
348
- throw new Error(`Unsupported waitUntil event: ${waitUntil}`);
349
- }
350
- await Promise.race([
351
- waitEvent,
352
- new Promise((_, reject) => setTimeout(() => reject(new Error('Reload timeout')), timeout)),
353
- ]);
354
- // Use Page.getNavigationHistory instead of Runtime.evaluate
355
- this.loadedUrl = await this._getCurrentUrlWithoutRuntime();
276
+ // Transparently heal a dropped CDP socket; a closed page is a no-op.
277
+ await this.session.run(async () => this.navigation.reload(options));
356
278
  }
357
279
  // Evaluate method
358
280
  // eslint-disable-next-line @typescript-eslint/ban-types
359
281
  async evaluate(pageFunction, ...args) {
282
+ // Transparently heal a dropped CDP socket; a closed page resolves to
283
+ // undefined rather than throwing.
284
+ return await this.session.run(async () => this._evaluateInner(pageFunction, ...args));
285
+ }
286
+ // eslint-disable-next-line @typescript-eslint/ban-types
287
+ async _evaluateInner(pageFunction, ...args) {
360
288
  await this._enableDomain('Runtime');
361
289
  const { Runtime } = this.client;
362
290
  let expression;
@@ -387,7 +315,6 @@ export default class CdpPage extends EventEmitter {
387
315
  this._enableDomain('DOM'),
388
316
  this._enableDomain('Input'),
389
317
  ]);
390
- const { DOM, Input } = this.client;
391
318
  const timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS;
392
319
  const force = options?.force ?? false;
393
320
  const delay = options?.delay ?? 0;
@@ -395,469 +322,73 @@ export default class CdpPage extends EventEmitter {
395
322
  const clickCount = options?.clickCount ?? 1;
396
323
  // Wait for element to exist
397
324
  await this.waitForSelector(selector, { timeout });
398
- // Get element details and check visibility
399
- const elementInfo = await this._getElementInfo(selector);
400
- if (!elementInfo) {
401
- throw new Error(`Element not found: ${selector}`);
402
- }
403
- // Check if element is visible (unless force is true)
404
- if (!force) {
405
- const isVisible = await this._isElementVisible(selector);
406
- if (!isVisible) {
407
- throw new Error(`Element is not visible: ${selector}. Use force: true to bypass the visibility check and attempt interaction anyway. Warning: Interacting with non-visible elements may lead to unexpected behavior or errors.`);
408
- }
409
- }
410
- // Check if element is clickable (unless force is true)
411
- if (!force) {
412
- const isClickable = await this._isElementClickable(selector);
413
- if (!isClickable) {
414
- throw new Error(`Element is not clickable: ${selector}. Use force: true to bypass this check.`);
415
- }
416
- }
417
- // Scroll element into view if needed
418
- await this._scrollElementIntoView(selector);
419
- // Wait for scroll animations to complete by polling for position stabilization
420
- // Wait for the element's position to stabilize after scrolling.
421
- // This is necessary because scroll animations or layout changes may cause the element's position
422
- // to shift for a short period. If we attempt to click before the position is stable, the click
423
- // may be sent to the wrong location, potentially missing the target element or causing unreliable
424
- // automation. Polling for position stabilization ensures the click is performed accurately.
425
- await this._waitForElementPositionToStabilize(selector, POSITION_STABILITY_TIMEOUT_MS, POSITION_CHECK_INTERVAL_MS);
426
- // Get updated element position after scrolling
427
- const { nodeId } = await this.getNodeId(selector);
428
- // For hidden elements, we need to handle the case where box model can't be computed
429
- let model;
430
- try {
431
- const boxModelResult = await DOM.getBoxModel({ nodeId });
432
- model = boxModelResult.model;
433
- }
434
- catch (error) {
435
- if (force) {
436
- // If force is true, try to get a default position
437
- const fallbackElementInfo = await this._getElementInfo(selector);
438
- if (fallbackElementInfo) {
439
- const x = fallbackElementInfo.boundingBox.x + fallbackElementInfo.boundingBox.width / 2;
440
- const y = fallbackElementInfo.boundingBox.y + fallbackElementInfo.boundingBox.height / 2;
441
- await this._performClick(Input, x, y, button, clickCount, delay);
442
- return;
443
- }
444
- }
445
- throw new Error(`Could not get box model for node: ${nodeId}. Element might be hidden or not rendered.`);
446
- }
447
- if (!model) {
448
- throw new Error(`Could not get box model for node: ${nodeId}`);
449
- }
450
- // Calculate click coordinates (center of the element)
451
- const x = (model.content[0] + model.content[4]) / 2;
452
- const y = (model.content[1] + model.content[5]) / 2;
453
- // Verify element is still in viewport after scrolling (with some tolerance)
454
- if (!force) {
455
- const isInViewport = await this._isElementInViewport(selector);
456
- if (!isInViewport) {
457
- // Give a bit more time for scroll animations to complete
458
- await new Promise(resolve => setTimeout(resolve, HUMAN_DELAY_MS));
459
- const isInViewportAfterDelay = await this._isElementInViewport(selector);
460
- if (!isInViewportAfterDelay) {
461
- throw new Error(`Element is not in viewport after scrolling: ${selector}`);
462
- }
463
- }
464
- }
325
+ // Resolve the selector into a stable, in-viewport coordinate target
326
+ // (visibility/clickability checks, scroll, position stabilization,
327
+ // humanized point selection) - see src/input/element-target.ts.
328
+ const { x, y } = await this.elements.acquire(selector, { force });
465
329
  // Perform the click with human-like interaction
466
- await this._performClick(Input, x, y, button, clickCount, delay);
467
- }
468
- /**
469
- * Performs a human-like click with proper mouse events.
470
- * Note: screenX/screenY compensation is handled browser-side via fingerprint
471
- * overrides that set realistic window.screenX/screenY values, which Chrome
472
- * uses when constructing MouseEvent from CDP dispatch.
473
- */
474
- async _performClick(Input, x, y, button, clickCount, delay) {
475
- await this._moveMouseTo(Input, x, y);
476
- // Small delay to simulate human behavior
477
- if (delay > 0) {
478
- await new Promise(resolve => setTimeout(resolve, delay));
479
- }
480
- else if (this.humanization.mouse) {
481
- await new Promise(resolve => setTimeout(resolve, 40 + Math.floor(Math.random() * 120)));
482
- }
483
- // Press: add to held set FIRST so `event.buttons` includes it on mousedown
484
- // (matches real hardware where mousedown fires with the button already in the bitmask).
485
- this.heldMouseButtons.add(button);
486
- await Input.dispatchMouseEvent({
487
- type: 'mousePressed',
488
- x,
489
- y,
490
- button,
491
- clickCount,
492
- buttons: this._buttonsBitmask(),
493
- pointerType: 'mouse',
494
- });
495
- // Small delay between press and release
496
- await new Promise(resolve => setTimeout(resolve, PRESS_RELEASE_DELAY_MS));
497
- // Release: remove from held set FIRST so `event.buttons` reflects the post-release
498
- // state on mouseup (per UI Events spec — the released button is no longer pressed).
499
- this.heldMouseButtons.delete(button);
500
- await Input.dispatchMouseEvent({
501
- type: 'mouseReleased',
502
- x,
503
- y,
504
- button,
505
- clickCount,
506
- buttons: this._buttonsBitmask(),
507
- pointerType: 'mouse',
508
- });
509
- this.lastMousePosition = { x, y };
510
- }
511
- _buttonsBitmask() {
512
- let mask = 0;
513
- for (const button of this.heldMouseButtons) {
514
- mask += MOUSE_BUTTON_BITS[button];
515
- }
516
- return mask;
517
- }
518
- async _moveMouseTo(Input, x, y) {
519
- if (!this.humanization.mouse) {
520
- await Input.dispatchMouseEvent({
521
- type: 'mouseMoved',
522
- x,
523
- y,
524
- buttons: this._buttonsBitmask(),
525
- pointerType: 'mouse',
526
- });
527
- this.lastMousePosition = { x, y };
528
- return;
529
- }
530
- const fromX = this.lastMousePosition.x;
531
- const fromY = this.lastMousePosition.y;
532
- const path = await this.evaluate((startX, startY, endX, endY) => {
533
- const fallback = [{ x: endX, y: endY }];
534
- const globalStealth = window.__stealth;
535
- if (!globalStealth || typeof globalStealth.humanizeMouse !== 'function') {
536
- return fallback;
537
- }
538
- const points = globalStealth.humanizeMouse(startX, startY, endX, endY);
539
- return Array.isArray(points) && points.length > 0 ? points : fallback;
540
- }, fromX, fromY, x, y);
541
- for (const point of path) {
542
- await Input.dispatchMouseEvent({
543
- type: 'mouseMoved',
544
- x: point.x,
545
- y: point.y,
546
- buttons: this._buttonsBitmask(),
547
- pointerType: 'mouse',
548
- });
549
- const delay = HUMAN_MOUSE_MOVE_DELAY_MIN_MS
550
- + Math.floor(Math.random() * (HUMAN_MOUSE_MOVE_DELAY_MAX_MS - HUMAN_MOUSE_MOVE_DELAY_MIN_MS + 1));
551
- await new Promise(resolve => setTimeout(resolve, delay));
552
- }
553
- this.lastMousePosition = { x, y };
554
- }
555
- /**
556
- * Gets detailed information about an element
557
- */
558
- async _getElementInfo(selector) {
559
- return await this.evaluate((sel) => {
560
- const element = document.querySelector(sel);
561
- if (!element)
562
- return null;
563
- const rect = element.getBoundingClientRect();
564
- const style = window.getComputedStyle(element);
565
- // Check if element is visible
566
- const isVisible = style.display !== 'none' &&
567
- style.visibility !== 'hidden' &&
568
- rect.width > 0 &&
569
- rect.height > 0;
570
- // Check if element is clickable
571
- const isClickable = !element.hasAttribute('disabled') &&
572
- !element.classList.contains('disabled') &&
573
- style.pointerEvents !== 'none' &&
574
- rect.width > 0 &&
575
- rect.height > 0;
576
- return {
577
- nodeId: 0, // Will be set by CDP
578
- boundingBox: {
579
- x: rect.left,
580
- y: rect.top,
581
- width: rect.width,
582
- height: rect.height,
583
- },
584
- isVisible,
585
- isClickable,
586
- };
587
- }, selector);
588
- }
589
- /**
590
- * Enhanced visibility check that considers more factors
591
- */
592
- async _isElementVisible(selector) {
593
- return await this.evaluate((sel) => {
594
- const element = document.querySelector(sel);
595
- if (!element)
596
- return false;
597
- const rect = element.getBoundingClientRect();
598
- const style = window.getComputedStyle(element);
599
- // Basic visibility checks
600
- if (style.display === 'none' || style.visibility === 'hidden') {
601
- return false;
602
- }
603
- // Check if element has dimensions
604
- if (rect.width === 0 || rect.height === 0) {
605
- return false;
606
- }
607
- // Check if element is not transparent
608
- if (parseFloat(style.opacity) === 0) {
609
- return false;
610
- }
611
- // Check if element is not clipped by overflow
612
- const parent = element.parentElement;
613
- if (parent) {
614
- const parentStyle = window.getComputedStyle(parent);
615
- if (parentStyle.overflow === 'hidden') {
616
- const parentRect = parent.getBoundingClientRect();
617
- if (rect.left >= parentRect.right ||
618
- rect.right <= parentRect.left ||
619
- rect.top >= parentRect.bottom ||
620
- rect.bottom <= parentRect.top) {
621
- return false;
622
- }
623
- }
624
- }
625
- return true;
626
- }, selector);
627
- }
628
- /**
629
- * Enhanced clickable check that considers more factors
630
- */
631
- async _isElementClickable(selector) {
632
- return await this.evaluate((sel) => {
633
- const element = document.querySelector(sel);
634
- if (!element)
635
- return false;
636
- const rect = element.getBoundingClientRect();
637
- const style = window.getComputedStyle(element);
638
- // Check if element is disabled
639
- if (element.hasAttribute('disabled') || element.classList.contains('disabled')) {
640
- return false;
641
- }
642
- // Check if pointer events are disabled
643
- if (style.pointerEvents === 'none') {
644
- return false;
645
- }
646
- // Check if element has dimensions
647
- if (rect.width === 0 || rect.height === 0) {
648
- return false;
649
- }
650
- // Check if element is not transparent
651
- if (parseFloat(style.opacity) === 0) {
652
- return false;
653
- }
654
- // Check if element is not read-only (for form elements)
655
- if (element.hasAttribute('readonly')) {
656
- return false;
657
- }
658
- return true;
659
- }, selector);
660
- }
661
- /**
662
- * Enhanced scroll into view with better positioning
663
- */
664
- async _scrollElementIntoView(selector) {
665
- if (this.humanization.scroll) {
666
- try {
667
- await Promise.all([
668
- this._enableDomain('DOM'),
669
- this._enableDomain('Input'),
670
- ]);
671
- await humanScrollToElement(this.client, selector);
672
- await this.evaluate((sel) => {
673
- const element = document.querySelector(sel);
674
- element?.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' });
675
- }, selector);
676
- return;
677
- }
678
- catch {
679
- // Some hidden/offscreen nodes do not expose a box model.
680
- // Fall back to native DOM scrolling for robustness.
681
- }
682
- }
683
- await this.evaluate((sel, tolerance) => {
684
- const element = document.querySelector(sel);
685
- if (!element)
686
- return;
687
- const rect = element.getBoundingClientRect();
688
- const viewportHeight = window.innerHeight;
689
- const viewportWidth = window.innerWidth;
690
- // Check if element is already in viewport (with tolerance)
691
- const isInViewport = rect.top >= -tolerance &&
692
- rect.left >= -tolerance &&
693
- rect.bottom <= viewportHeight + tolerance &&
694
- rect.right <= viewportWidth + tolerance;
695
- if (!isInViewport) {
696
- // Scroll element into view with better positioning
697
- element.scrollIntoView({
698
- behavior: 'smooth',
699
- block: 'center',
700
- inline: 'center',
701
- });
702
- }
703
- }, selector, VIEWPORT_TOLERANCE_PX);
704
- }
705
- /**
706
- * Waits for an element's position to stabilize by polling its position over time.
707
- * This is useful for waiting for scroll animations or dynamic layout changes to complete.
708
- *
709
- * @param selector - The CSS selector for the element to monitor
710
- * @param timeout - Maximum time to wait in milliseconds (default: 2000)
711
- * @param checkInterval - How often to check position in milliseconds (default: 100)
712
- * @param stabilityThreshold - Number of consecutive stable checks required (default: 3)
713
- * @param tolerance - Maximum pixel difference to consider "stable" (default: 1)
714
- */
715
- async _waitForElementPositionToStabilize(selector, timeout = POSITION_STABILITY_TIMEOUT_MS, checkInterval = POSITION_CHECK_INTERVAL_MS, stabilityThreshold = POSITION_STABILITY_THRESHOLD, tolerance = POSITION_TOLERANCE_PX) {
716
- const startTime = Date.now();
717
- let stableCount = 0;
718
- let lastPosition = null;
719
- while (Date.now() - startTime < timeout) {
720
- try {
721
- const currentPosition = await this.evaluate((sel) => {
722
- const element = document.querySelector(sel);
723
- if (!element)
724
- return null;
725
- const rect = element.getBoundingClientRect();
726
- return {
727
- x: Math.round(rect.left),
728
- y: Math.round(rect.top),
729
- width: Math.round(rect.width),
730
- height: Math.round(rect.height),
731
- };
732
- }, selector);
733
- if (!currentPosition) {
734
- // Element not found, reset stability counter
735
- stableCount = 0;
736
- lastPosition = null;
737
- }
738
- else if (!lastPosition) {
739
- // First position reading
740
- lastPosition = currentPosition;
741
- stableCount = 1;
742
- }
743
- else {
744
- // Check if position is stable (within tolerance)
745
- const deltaX = Math.abs(currentPosition.x - lastPosition.x);
746
- const deltaY = Math.abs(currentPosition.y - lastPosition.y);
747
- const deltaWidth = Math.abs(currentPosition.width - lastPosition.width);
748
- const deltaHeight = Math.abs(currentPosition.height - lastPosition.height);
749
- const isStable = deltaX <= tolerance &&
750
- deltaY <= tolerance &&
751
- deltaWidth <= tolerance &&
752
- deltaHeight <= tolerance;
753
- if (isStable) {
754
- stableCount++;
755
- if (stableCount >= stabilityThreshold) {
756
- // Position has been stable for required number of checks
757
- return;
758
- }
759
- }
760
- else {
761
- // Position changed, reset counter
762
- stableCount = 1;
763
- }
764
- lastPosition = currentPosition;
765
- }
766
- }
767
- catch (error) {
768
- // Error reading position, reset stability counter
769
- stableCount = 0;
770
- lastPosition = null;
771
- }
772
- // Wait before next check
773
- await new Promise(resolve => setTimeout(resolve, checkInterval));
774
- }
775
- // If we reach here, we timed out - this is acceptable as some elements may never stabilize
776
- // If we reach here, we timed out. This means the element's position did not stabilize within the allotted time.
777
- // Proceeding may cause interaction failures if the element is still moving or changing position.
778
- // Callers should be aware that stabilization was not achieved and handle this case appropriately.
330
+ await this.input.clickAt(x, y, { button, clickCount, delay });
779
331
  }
780
332
  // Public wrapper for tests and external use
781
333
  async waitForElementPositionToStabilize(selector, timeout = POSITION_STABILITY_TIMEOUT_MS, checkInterval = POSITION_CHECK_INTERVAL_MS, stabilityThreshold = POSITION_STABILITY_THRESHOLD, tolerance = POSITION_TOLERANCE_PX) {
782
- return this._waitForElementPositionToStabilize(selector, timeout, checkInterval, stabilityThreshold, tolerance);
783
- }
784
- /**
785
- * Check if element is within the viewport (with some tolerance)
786
- */
787
- async _isElementInViewport(selector) {
788
- return await this.evaluate((sel, tolerance) => {
789
- const element = document.querySelector(sel);
790
- if (!element)
791
- return false;
792
- const rect = element.getBoundingClientRect();
793
- const viewportHeight = window.innerHeight;
794
- const viewportWidth = window.innerWidth;
795
- // Add some tolerance for scroll positioning
796
- return rect.top >= -tolerance &&
797
- rect.left >= -tolerance &&
798
- rect.bottom <= viewportHeight + tolerance &&
799
- rect.right <= viewportWidth + tolerance;
800
- }, selector, VIEWPORT_TOLERANCE_PX);
334
+ return this.elements.waitForPositionToStabilize(selector, timeout, checkInterval, stabilityThreshold, tolerance);
801
335
  }
802
336
  // Type method
803
337
  async type(selector, text, options) {
804
- const { Input } = this.client;
805
- await this.click(selector);
806
- const delay = options?.delay ?? 0;
807
- for (const char of text) {
808
- await this._typeChar(Input, char);
809
- if (delay > 0 || this.humanization.keyboard) {
810
- const effectiveDelay = delay > 0
811
- ? delay
812
- : HUMAN_KEYBOARD_DELAY_MIN_MS + Math.floor(Math.random() * (HUMAN_KEYBOARD_DELAY_MAX_MS - HUMAN_KEYBOARD_DELAY_MIN_MS + 1));
813
- await new Promise((resolve) => setTimeout(resolve, effectiveDelay));
814
- }
338
+ // Focus the field, then delegate keystroke emulation (ANSI key
339
+ // sequences, CJK IME composition, humanized cadence) to the
340
+ // InputEmulator - see src/input/input-emulator.ts. With mouse
341
+ // humanization on, focus goes through the full humanized click path;
342
+ // otherwise a single in-page element.focus() round-trip skips the
343
+ // mouse-move emulation and click dispatch entirely.
344
+ if (this.humanization.mouse) {
345
+ await this.click(selector);
346
+ }
347
+ else {
348
+ await this.focusElement(selector);
815
349
  }
350
+ await this.input.typeText(text, { delay: options?.delay ?? 0 });
816
351
  }
817
352
  /**
818
- * Dispatch a single character as a realistic key sequence:
819
- * rawKeyDown (with code/key/keyCode/modifiers) → char (fires keypress + beforeinput + input)
820
- * → keyUp.
821
- *
822
- * Characters outside the US ANSI printable set fall back to `Input.insertText`,
823
- * which still produces beforeinput/input but no keyboard events (matches IME
824
- * behaviour for Unicode/emoji input).
353
+ * Focus an element in a single Runtime.evaluate round-trip. Mirrors the
354
+ * wait-then-act semantics the click path gets from `waitForSelector`
355
+ * (MutationObserver wait, same timeout, same timeout error for a missing
356
+ * element), but collapses the wait and the `element.focus()` call into
357
+ * one CDP round-trip.
825
358
  */
826
- async _typeChar(Input, char) {
827
- const mapping = describeChar(char);
828
- if (!mapping) {
829
- await Input.insertText({ text: char });
830
- return;
359
+ async focusElement(selector, timeout = DEFAULT_TIMEOUT_MS) {
360
+ const focused = await this.evaluate(async (sel, timeoutMs) => {
361
+ return new Promise((resolve) => {
362
+ const tryFocus = () => {
363
+ const el = document.querySelector(sel);
364
+ if (!el)
365
+ return false;
366
+ el.focus();
367
+ resolve(true);
368
+ return true;
369
+ };
370
+ if (tryFocus())
371
+ return;
372
+ let timer;
373
+ const observer = new MutationObserver(() => {
374
+ if (tryFocus()) {
375
+ clearTimeout(timer);
376
+ observer.disconnect();
377
+ }
378
+ });
379
+ // Same observation surface as waitForSelector: childList+subtree
380
+ // catches insertion; attributes+characterData catch an existing
381
+ // node becoming matching.
382
+ observer.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });
383
+ timer = setTimeout(() => {
384
+ observer.disconnect();
385
+ resolve(false);
386
+ }, timeoutMs);
387
+ });
388
+ }, selector, timeout);
389
+ if (!focused) {
390
+ throw new Error(`Timeout waiting for selector: ${selector}`);
831
391
  }
832
- const { descriptor, shift } = mapping;
833
- const modifiers = shift ? MODIFIER_SHIFT : 0;
834
- const text = descriptor.text ?? descriptor.key;
835
- await Input.dispatchKeyEvent({
836
- type: 'rawKeyDown',
837
- modifiers,
838
- key: descriptor.key,
839
- code: descriptor.code,
840
- windowsVirtualKeyCode: descriptor.keyCode,
841
- location: descriptor.location ?? 0,
842
- });
843
- await Input.dispatchKeyEvent({
844
- type: 'char',
845
- modifiers,
846
- key: descriptor.key,
847
- code: descriptor.code,
848
- text,
849
- unmodifiedText: text,
850
- windowsVirtualKeyCode: descriptor.keyCode,
851
- location: descriptor.location ?? 0,
852
- });
853
- await Input.dispatchKeyEvent({
854
- type: 'keyUp',
855
- modifiers,
856
- key: descriptor.key,
857
- code: descriptor.code,
858
- windowsVirtualKeyCode: descriptor.keyCode,
859
- location: descriptor.location ?? 0,
860
- });
861
392
  }
862
393
  /**
863
394
  * Deletes (clears) the value of an input field specified by selector.
@@ -893,60 +424,42 @@ export default class CdpPage extends EventEmitter {
893
424
  // Wait for selector
894
425
  async waitForSelector(selector, options) {
895
426
  const timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS;
896
- const maxAttempts = Math.ceil(timeout / POLLING_INTERVAL_MS);
897
- let attempts = 0;
898
- while (attempts < maxAttempts) {
899
- const exists = await this.elementExists(selector);
900
- if (exists) {
901
- return;
902
- }
903
- await new Promise((resolve) => setTimeout(resolve, POLLING_INTERVAL_MS));
904
- attempts++;
427
+ // Single in-page round-trip: resolve as soon as the selector matches via
428
+ // a MutationObserver (with an initial querySelector for elements already
429
+ // present), instead of re-issuing an evaluate on a fixed poll interval.
430
+ // The in-page promise resolves true on match / false on timeout; the
431
+ // observer is always torn down before resolving.
432
+ const found = await this.evaluate(async (sel, timeoutMs) => {
433
+ return new Promise((resolve) => {
434
+ if (document.querySelector(sel)) {
435
+ resolve(true);
436
+ return;
437
+ }
438
+ let timer;
439
+ const observer = new MutationObserver(() => {
440
+ if (document.querySelector(sel)) {
441
+ clearTimeout(timer);
442
+ observer.disconnect();
443
+ resolve(true);
444
+ }
445
+ });
446
+ // childList+subtree catches insertion; attributes+characterData catch
447
+ // an already-present node becoming matching via a class/attribute/text
448
+ // change - the old 100ms querySelector poll caught those too.
449
+ observer.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });
450
+ timer = setTimeout(() => {
451
+ observer.disconnect();
452
+ resolve(false);
453
+ }, timeoutMs);
454
+ });
455
+ }, selector, timeout);
456
+ if (!found) {
457
+ throw new Error(`Timeout waiting for selector: ${selector}`);
905
458
  }
906
- throw new Error(`Timeout waiting for selector: ${selector}`);
907
459
  }
908
- // Wait for response
460
+ // Wait for response - protocol lives in NetworkWatch (src/network/network-watch.ts)
909
461
  async waitForResponse(urlPart, statusCode = 200, timeout = 180000) {
910
- await this.ensureNetworkEnabled(); // Lazy enable Network only when needed
911
- return new Promise((resolve, reject) => {
912
- let timer;
913
- const onResponseReceived = async ({ requestId, response }) => {
914
- if (response.url.includes(urlPart) && response.status === statusCode) {
915
- clearTimeout(timer);
916
- try {
917
- const { body } = await this.client.Network.getResponseBody({ requestId });
918
- resolve({
919
- ...response,
920
- body: JSON.parse(body),
921
- });
922
- }
923
- catch (err) {
924
- // Retry logic with exponential backoff
925
- for (let attempt = 1; attempt <= 3; attempt++) {
926
- try {
927
- await this.ensureNetworkEnabled();
928
- await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 1000 * 2 ** attempt)); // Exponential backoff
929
- const { body } = await this.client.Network.getResponseBody({ requestId });
930
- resolve({
931
- ...response,
932
- body: JSON.parse(body),
933
- });
934
- return;
935
- }
936
- catch (retryErr) {
937
- if (attempt === 3)
938
- reject(new Error(`Failed to get response body after ${attempt} attempts`));
939
- }
940
- }
941
- }
942
- }
943
- };
944
- // @ts-expect-error it exists
945
- this.client.Network.on('responseReceived', onResponseReceived);
946
- timer = setTimeout(() => {
947
- reject(new Error(`Timeout after ${timeout / 1000} seconds`));
948
- }, timeout);
949
- });
462
+ return this.networkWatch.waitForResponse(urlPart, statusCode, timeout);
950
463
  }
951
464
  async screenshot(options) {
952
465
  const { Page } = this.client;
@@ -980,7 +493,7 @@ export default class CdpPage extends EventEmitter {
980
493
  // Close method
981
494
  async close() {
982
495
  const { Page } = this.client;
983
- this.isClosed = true;
496
+ this.session.markClosed();
984
497
  await Page.close();
985
498
  this.emit(PAGE_CLOSED);
986
499
  }
@@ -1015,71 +528,42 @@ export default class CdpPage extends EventEmitter {
1015
528
  }
1016
529
  // Add proper Fetch Metadata headers to avoid CDP detection
1017
530
  async setupFetchMetadataHeaders() {
1018
- const { Network } = this.client;
1019
- await this._enableDomain('Network');
1020
- await Network.setRequestInterception({ patterns: [{ urlPattern: '*' }] });
1021
- Network.on('requestIntercepted', async (params) => {
1022
- const { request, interceptionId } = params;
1023
- const url = new URL(request.url);
1024
- // Add proper fetch metadata headers
1025
- const headers = { ...request.headers };
1026
- // Set sec-fetch-* headers based on request type and destination
1027
- if (request.method === 'GET' && !headers['sec-fetch-dest']) {
1028
- if (url.pathname.endsWith('.js')) {
1029
- headers['sec-fetch-dest'] = 'script';
1030
- headers['sec-fetch-mode'] = 'no-cors';
1031
- headers['sec-fetch-site'] = url.origin === headers.origin ? 'same-origin' : 'cross-site';
1032
- }
1033
- else if (url.pathname.endsWith('.css')) {
1034
- headers['sec-fetch-dest'] = 'style';
1035
- headers['sec-fetch-mode'] = 'no-cors';
1036
- headers['sec-fetch-site'] = url.origin === headers.origin ? 'same-origin' : 'cross-site';
1037
- }
1038
- else if (url.pathname.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i)) {
1039
- headers['sec-fetch-dest'] = 'image';
1040
- headers['sec-fetch-mode'] = 'no-cors';
1041
- headers['sec-fetch-site'] = url.origin === headers.origin ? 'same-origin' : 'cross-site';
1042
- }
1043
- else if (url.pathname === '/' || !url.pathname.includes('.')) {
1044
- headers['sec-fetch-dest'] = 'document';
1045
- headers['sec-fetch-mode'] = 'navigate';
1046
- headers['sec-fetch-site'] = 'none';
1047
- headers['sec-fetch-user'] = '?1';
1048
- }
1049
- }
1050
- // Continue the request with modified headers
1051
- await Network.continueInterceptedRequest({
1052
- interceptionId,
1053
- headers,
1054
- });
1055
- });
531
+ return this.networkWatch.setupFetchMetadataHeaders();
1056
532
  }
1057
533
  // Block requests matching URL patterns
1058
534
  async blockRequests(urlPatterns) {
1059
- const { Network } = this.client;
1060
- await this.ensureNetworkEnabled(); // Lazy enable Network only when needed
1061
- await Network.setRequestInterception({ patterns: [{ urlPattern: '*' }] });
1062
- Network.on('requestIntercepted', async (params) => {
1063
- const shouldBlock = urlPatterns.some((pattern) => {
1064
- const regex = new RegExp(pattern.replace(/\*/g, '.*'));
1065
- return regex.test(params.request.url);
1066
- });
1067
- if (shouldBlock) {
1068
- await Network.continueInterceptedRequest({
1069
- interceptionId: params.interceptionId,
1070
- errorReason: 'Aborted',
1071
- });
1072
- }
1073
- else {
1074
- await Network.continueInterceptedRequest({ interceptionId: params.interceptionId });
1075
- }
1076
- });
535
+ return this.networkWatch.blockRequests(urlPatterns);
1077
536
  }
1078
- // Helper method to get node ID from selector
537
+ /**
538
+ * Resolve a CSS selector to a CDP DOM nodeId. Retained as public page API
539
+ * for callers that need a node handle for direct CDP DOM work; the click
540
+ * hot-path no longer uses it (geometry now comes from in-page
541
+ * getBoundingClientRect - see ElementTarget.acquire). Reuses a cached
542
+ * document root and self-heals a stale root with a fresh getDocument.
543
+ */
1079
544
  async getNodeId(selector) {
1080
545
  const { DOM } = this.client;
1081
- const { root: { nodeId: documentNodeId } } = await DOM.getDocument();
1082
- const { nodeId } = await DOM.querySelector({ selector, nodeId: documentNodeId });
546
+ // Reuse a cached document root nodeId to skip a per-call DOM.getDocument
547
+ // round-trip. querySelector still runs on every call.
548
+ if (this._documentNodeId === undefined) {
549
+ const { root: { nodeId } } = await DOM.getDocument();
550
+ this._documentNodeId = nodeId;
551
+ }
552
+ try {
553
+ const { nodeId } = await DOM.querySelector({ selector, nodeId: this._documentNodeId });
554
+ if (nodeId) {
555
+ return { nodeId };
556
+ }
557
+ }
558
+ catch {
559
+ // Cached root may be stale (e.g. a navigation we did not observe).
560
+ // Fall through to the fresh-document retry below.
561
+ }
562
+ // Defensive retry: refetch the document root once and try again before
563
+ // reporting the selector as missing. Covers a stale cached root.
564
+ const { root: { nodeId: freshRoot } } = await DOM.getDocument();
565
+ this._documentNodeId = freshRoot;
566
+ const { nodeId } = await DOM.querySelector({ selector, nodeId: freshRoot });
1083
567
  if (!nodeId) {
1084
568
  throw new Error(`No node found for selector: ${selector}`);
1085
569
  }
@@ -1089,9 +573,8 @@ export default class CdpPage extends EventEmitter {
1089
573
  if (cookies.length === 0) {
1090
574
  return;
1091
575
  }
1092
- for (const cookie of cookies) {
1093
- await this.client.Network.setCookie(cookie);
1094
- }
576
+ // One batched CDP call instead of a round-trip per cookie.
577
+ await this.client.Network.setCookies({ cookies });
1095
578
  }
1096
579
  /**
1097
580
  * Retrieves all cookies associated with the given URLs or all cookies for the current page if no URLs are provided.
@@ -1109,6 +592,17 @@ export default class CdpPage extends EventEmitter {
1109
592
  const cookiesResult = await Network.getCookies({ urls });
1110
593
  return cookiesResult.cookies;
1111
594
  }
595
+ /**
596
+ * Records launch-time inputs (fingerprint config, resolved locale, user-data-dir, …)
597
+ * that `saveSession()` needs to assemble a SessionBundle. Called by the browser
598
+ * controller after fingerprint injection - not part of the user-facing API.
599
+ */
600
+ setLaunchSnapshot(snapshot) {
601
+ this.launchSnapshot = { ...this.launchSnapshot, ...snapshot };
602
+ }
603
+ getLaunchSnapshot() {
604
+ return this.launchSnapshot;
605
+ }
1112
606
  // Add this method to your CdpPage class
1113
607
  /**
1114
608
  * Evaluates a function across multiple elements matching the given selector.
@@ -1127,87 +621,16 @@ export default class CdpPage extends EventEmitter {
1127
621
  }, selector, pageFunction.toString(), ...args);
1128
622
  }
1129
623
  /**
1130
- * Enables a CDP domain only if it hasn't been enabled yet
624
+ * Enables a CDP domain only if it hasn't been enabled yet. Tracking lives
625
+ * in PageSession so a reconnect re-binds exactly the enabled domains.
1131
626
  * @private
1132
627
  */
1133
628
  async _enableDomain(domain) {
1134
- if (this.enabledDomains.has(domain))
1135
- return;
1136
- const domainApi = this.client[domain];
1137
- if (domainApi?.enable) {
1138
- await domainApi.enable();
1139
- this.enabledDomains.add(domain);
1140
- log.debug(`Enabled ${domain} domain`);
1141
- }
629
+ await this.session.enableDomain(domain);
1142
630
  }
631
+ /** Re-bind the page's CDP session after a transport drop. Delegates to PageSession. */
1143
632
  async reconnectSession(retries = 3) {
1144
- if (this.isReconnecting) {
1145
- log.debug('Reconnection skipped - already in progress');
1146
- return;
1147
- }
1148
- if (this.isClosed) {
1149
- log.debug('Reconnection skipped - page is closed');
1150
- return;
1151
- }
1152
- let attempt = 0;
1153
- this.isReconnecting = true;
1154
- try {
1155
- while (attempt < retries) {
1156
- try {
1157
- // Wait with exponential backoff before retrying
1158
- if (attempt > 0) {
1159
- const backoffMs = Math.min(1000 * (2 ** attempt), 10000);
1160
- log.info(`Waiting ${backoffMs}ms before reconnection attempt ${attempt + 1}/${retries}`);
1161
- await new Promise(resolve => setTimeout(resolve, backoffMs));
1162
- }
1163
- // Create a fresh CDP connection
1164
- const newSession = await CDP({
1165
- port: this._rawClient.port,
1166
- local: true,
1167
- });
1168
- try {
1169
- // Get available targets
1170
- const { targetInfos } = await newSession.Target.getTargets();
1171
- const pageTarget = targetInfos.find(t => t.type === 'page' && !t.url.includes('chrome://'));
1172
- if (!pageTarget) {
1173
- log.debug('No valid page target found, stopping reconnection');
1174
- return;
1175
- }
1176
- // Connect to the specific page target
1177
- const pageSession = await CDP({
1178
- target: pageTarget.targetId,
1179
- port: this._rawClient.port,
1180
- local: true,
1181
- });
1182
- // Re-enable only the domains that were previously enabled
1183
- const enablePromises = Array.from(this.enabledDomains).map(domain => pageSession[domain].enable());
1184
- await Promise.all(enablePromises);
1185
- // Update references
1186
- this._rawClient = pageSession;
1187
- this.client = pageSession;
1188
- this._attachConnectionMonitor();
1189
- log.info(`Page session reconnected successfully on attempt ${attempt + 1}`);
1190
- return;
1191
- }
1192
- catch (error) {
1193
- // Clean up the session if anything fails
1194
- await newSession.close().catch(() => { });
1195
- throw error;
1196
- }
1197
- }
1198
- catch (error) {
1199
- attempt++;
1200
- if (attempt === retries) {
1201
- log.debug('Reconnection stopped - max retries reached');
1202
- return;
1203
- }
1204
- log.error('Failed to reconnect page session', { error });
1205
- }
1206
- }
1207
- }
1208
- finally {
1209
- this.isReconnecting = false;
1210
- }
633
+ await this.session.reconnect(retries);
1211
634
  }
1212
635
  /**
1213
636
  * Selects one or more options from a select element or dropdown.
@@ -1236,249 +659,7 @@ export default class CdpPage extends EventEmitter {
1236
659
  this._enableDomain('DOM'),
1237
660
  this._enableDomain('Input'),
1238
661
  ]);
1239
- // DOM and Input domains are enabled and actively used in this method
1240
- const timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS;
1241
- const force = options?.force ?? false;
1242
- const waitForOptions = options?.waitForOptions ?? true;
1243
- const maxScrollAttempts = options?.maxScrollAttempts ?? 10;
1244
- // Normalize target selectors to array
1245
- const targetSelectors = Array.isArray(optionSelector) ? optionSelector : [optionSelector];
1246
- // Wait for dropdown element to exist
1247
- await this.waitForSelector(dropdownSelector, { timeout });
1248
- // Check if this is a regular select element
1249
- const isSelectElement = await this.evaluate((sel) => {
1250
- const element = document.querySelector(sel);
1251
- return element?.tagName === 'SELECT';
1252
- }, dropdownSelector);
1253
- // For regular select elements, do visibility and disabled checks upfront
1254
- if (isSelectElement) {
1255
- // Check if element is visible (unless force is true)
1256
- if (!force) {
1257
- const isVisible = await this._isElementVisible(dropdownSelector);
1258
- if (!isVisible) {
1259
- throw new Error(`Element is not visible: ${dropdownSelector}. Use force: true to bypass this check.`);
1260
- }
1261
- }
1262
- // Check if element is disabled (unless force is true)
1263
- if (!force) {
1264
- const isDisabled = await this.evaluate((sel) => {
1265
- const element = document.querySelector(sel);
1266
- return element?.hasAttribute('disabled') || element?.getAttribute('disabled') === 'true';
1267
- }, dropdownSelector);
1268
- if (isDisabled) {
1269
- throw new Error(`Element is disabled: ${dropdownSelector}. Use force: true to bypass this check.`);
1270
- }
1271
- }
1272
- // Scroll element into view if needed
1273
- await this._scrollElementIntoView(dropdownSelector);
1274
- // Wait for scroll animations to complete
1275
- await new Promise(resolve => setTimeout(resolve, SCROLL_ANIMATION_DELAY_MS));
1276
- }
1277
- if (isSelectElement) {
1278
- // Handle regular select element
1279
- await this._handleRegularSelect(dropdownSelector, targetSelectors, {
1280
- timeout,
1281
- force,
1282
- });
1283
- }
1284
- else {
1285
- // Handle custom dropdown - the dropdownSelector is the container
1286
- await this._handleCustomDropdown(dropdownSelector, targetSelectors, {
1287
- timeout,
1288
- force,
1289
- waitForOptions,
1290
- maxScrollAttempts,
1291
- });
1292
- }
1293
- }
1294
- /**
1295
- * Handles selection for regular HTML select elements
1296
- */
1297
- async _handleRegularSelect(selectSelector, targetSelectors, _options) {
1298
- const result = await this.evaluate((sel, targetSels) => {
1299
- const select = document.querySelector(sel);
1300
- if (!select) {
1301
- throw new Error(`Select element not found: ${sel}`);
1302
- }
1303
- // Check if multiple selection is allowed
1304
- if (!select.multiple && targetSels.length > 1) {
1305
- throw new Error(`Select element does not support multiple selection. Found ${targetSels.length} selectors but multiple is false.`);
1306
- }
1307
- // Clear previous selections
1308
- if (select.multiple) {
1309
- Array.from(select.options).forEach(option => {
1310
- option.selected = false;
1311
- });
1312
- }
1313
- let foundCount = 0;
1314
- const foundSelectors = [];
1315
- // Select the options by CSS selector
1316
- for (const targetSelector of targetSels) {
1317
- let optionFound = false;
1318
- // Find option by CSS selector within the select element
1319
- const option = select.querySelector(targetSelector);
1320
- if (option) {
1321
- option.selected = true;
1322
- optionFound = true;
1323
- foundSelectors.push(targetSelector);
1324
- }
1325
- if (optionFound) {
1326
- foundCount++;
1327
- }
1328
- }
1329
- // Trigger change event
1330
- const event = new Event('change', { bubbles: true });
1331
- select.dispatchEvent(event);
1332
- return { foundCount, foundSelectors, totalOptions: select.options.length };
1333
- }, selectSelector, targetSelectors);
1334
- if (result.foundCount === 0) {
1335
- throw new Error(`No options found for selectors: ${targetSelectors.join(', ')}. Available options: ${result.totalOptions}`);
1336
- }
1337
- if (result.foundCount < targetSelectors.length) {
1338
- const missingSelectors = targetSelectors.filter(s => !result.foundSelectors.includes(s));
1339
- throw new Error(`Some selectors not found: ${missingSelectors.join(', ')}. Found: ${result.foundSelectors.join(', ')}`);
1340
- }
1341
- }
1342
- /**
1343
- * Handles selection for custom dropdown elements.
1344
- * Automatically detects and clicks triggers using multiple strategies:
1345
- * - Common trigger patterns ([aria-haspopup], .dropdown-trigger, etc.)
1346
- * - Previous sibling elements
1347
- * - ID pattern matching (menu → trigger)
1348
- */
1349
- async _handleCustomDropdown(dropdownContainer, targetSelectors, options) {
1350
- const { timeout, force, waitForOptions, maxScrollAttempts, dropdownOpenDelay = DROPDOWN_OPEN_DELAY_MS } = options;
1351
- // For custom dropdowns, always try to find and click a trigger to open
1352
- // Most custom dropdowns need a trigger click to become visible
1353
- await this.evaluate((containerSel) => {
1354
- const container = document.querySelector(containerSel);
1355
- if (!container)
1356
- return false;
1357
- // Strategy 1: Look for sibling trigger elements with common patterns
1358
- const parent = container.parentElement;
1359
- if (parent) {
1360
- const triggers = parent.querySelectorAll('[role="button"], button, [data-toggle], [aria-haspopup], .dropdown-trigger, .select-trigger');
1361
- for (const trigger of triggers) {
1362
- if (trigger !== container) {
1363
- trigger.click();
1364
- return true;
1365
- }
1366
- }
1367
- // Strategy 2: Look for previous sibling that might be the trigger
1368
- const prevSibling = container.previousElementSibling;
1369
- if (prevSibling && prevSibling.tagName !== 'LABEL') {
1370
- prevSibling.click();
1371
- return true;
1372
- }
1373
- }
1374
- // Strategy 3: Look for element with similar ID (e.g., menu -> trigger)
1375
- const containerId = container.id;
1376
- if (containerId) {
1377
- const triggerPatterns = [
1378
- containerId.replace('menu', 'trigger').replace('dropdown', 'trigger'),
1379
- containerId.replace('-menu', '-trigger'),
1380
- containerId.replace('_menu', '_trigger'),
1381
- `${containerId}-trigger`,
1382
- `trigger-${containerId}`,
1383
- ];
1384
- for (const triggerId of triggerPatterns) {
1385
- const trigger = document.getElementById(triggerId);
1386
- if (trigger && trigger !== container) {
1387
- trigger.click();
1388
- return true;
1389
- }
1390
- }
1391
- }
1392
- return false;
1393
- }, dropdownContainer);
1394
- // Wait for dropdown to open
1395
- await new Promise(resolve => setTimeout(resolve, dropdownOpenDelay));
1396
- // Wait for dropdown to be visible
1397
- if (waitForOptions) {
1398
- await this.waitForSelector(dropdownContainer, { timeout });
1399
- }
1400
- // Now check if dropdown is visible (unless force is true)
1401
- if (!force) {
1402
- const isVisible = await this._isElementVisible(dropdownContainer);
1403
- if (!isVisible) {
1404
- throw new Error(`Dropdown container is not visible after trigger click: ${dropdownContainer}. Use force: true to bypass this check.`);
1405
- }
1406
- }
1407
- // Select each target selector
1408
- for (const targetSelector of targetSelectors) {
1409
- await this._selectOptionFromDropdown(dropdownContainer, targetSelector, {
1410
- timeout,
1411
- force,
1412
- maxScrollAttempts,
1413
- });
1414
- }
1415
- }
1416
- /**
1417
- * Selects a single option from a dropdown container.
1418
- * Handles virtualized lists with intelligent scrolling to find options that may not be initially visible.
1419
- */
1420
- async _selectOptionFromDropdown(dropdownSelector, targetSelector, dropdownOptions) {
1421
- const { timeout, maxScrollAttempts } = dropdownOptions;
1422
- const startTime = Date.now();
1423
- let scrollAttempts = 0;
1424
- let optionFound = false;
1425
- while (scrollAttempts < maxScrollAttempts && !optionFound && (Date.now() - startTime) < timeout) {
1426
- // Try to find and click the option
1427
- const result = await this.evaluate((dropdownSel, targetSel) => {
1428
- const dropdown = document.querySelector(dropdownSel);
1429
- if (!dropdown)
1430
- return { found: false, needsScroll: false };
1431
- // Try to find the target element directly first
1432
- const targetOption = dropdown.querySelector(targetSel);
1433
- if (!targetOption) {
1434
- // Check if we need to scroll to find more options
1435
- const isScrollable = dropdown.scrollHeight > dropdown.clientHeight;
1436
- const isAtBottom = dropdown.scrollTop + dropdown.clientHeight >= dropdown.scrollHeight - 1;
1437
- return { found: false, needsScroll: isScrollable && !isAtBottom };
1438
- }
1439
- // Check if option is visible within the dropdown
1440
- const rect = targetOption.getBoundingClientRect();
1441
- const dropdownRect = dropdown.getBoundingClientRect();
1442
- const isVisible = rect.top >= dropdownRect.top &&
1443
- rect.bottom <= dropdownRect.bottom &&
1444
- rect.left >= dropdownRect.left &&
1445
- rect.right <= dropdownRect.right;
1446
- if (!isVisible) {
1447
- // Scroll to make the option visible
1448
- targetOption.scrollIntoView({ block: 'center', behavior: 'smooth' });
1449
- return { found: false, needsScroll: false, scrolled: true };
1450
- }
1451
- // Click the option
1452
- targetOption.click();
1453
- return { found: true, needsScroll: false };
1454
- }, dropdownSelector, targetSelector);
1455
- if (result.found) {
1456
- optionFound = true;
1457
- break;
1458
- }
1459
- if (result.needsScroll) {
1460
- // Scroll down to load more options
1461
- await this.evaluate((dropdownSel) => {
1462
- const dropdown = document.querySelector(dropdownSel);
1463
- if (dropdown) {
1464
- dropdown.scrollTop += dropdown.clientHeight * 0.8;
1465
- }
1466
- }, dropdownSelector);
1467
- scrollAttempts++;
1468
- await new Promise(resolve => setTimeout(resolve, OPTION_LOAD_DELAY_MS)); // Wait for new options to load
1469
- }
1470
- else if (result.scrolled) {
1471
- // Option was scrolled into view, try clicking it now
1472
- await new Promise(resolve => setTimeout(resolve, SCROLL_COMPLETE_DELAY_MS)); // Wait for scroll to complete
1473
- }
1474
- else {
1475
- // No more scrolling needed, option not found
1476
- break;
1477
- }
1478
- }
1479
- if (!optionFound) {
1480
- throw new Error(`Option "${targetSelector}" not found in dropdown "${dropdownSelector}"`);
1481
- }
662
+ await this.dropdowns.selectOption(dropdownSelector, optionSelector, options);
1482
663
  }
1483
664
  /**
1484
665
  * Checks if the element specified by selector is visible (not display:none and not visibility:hidden).