browser-commander 0.2.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 (82) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/release.yml +296 -0
  4. package/.husky/pre-commit +1 -0
  5. package/.jscpd.json +20 -0
  6. package/.prettierignore +7 -0
  7. package/.prettierrc +10 -0
  8. package/CHANGELOG.md +32 -0
  9. package/LICENSE +24 -0
  10. package/README.md +320 -0
  11. package/bunfig.toml +3 -0
  12. package/deno.json +7 -0
  13. package/eslint.config.js +125 -0
  14. package/examples/react-test-app/index.html +25 -0
  15. package/examples/react-test-app/package.json +19 -0
  16. package/examples/react-test-app/src/App.jsx +473 -0
  17. package/examples/react-test-app/src/main.jsx +10 -0
  18. package/examples/react-test-app/src/styles.css +323 -0
  19. package/examples/react-test-app/vite.config.js +9 -0
  20. package/package.json +89 -0
  21. package/scripts/changeset-version.mjs +38 -0
  22. package/scripts/create-github-release.mjs +93 -0
  23. package/scripts/create-manual-changeset.mjs +86 -0
  24. package/scripts/format-github-release.mjs +83 -0
  25. package/scripts/format-release-notes.mjs +216 -0
  26. package/scripts/instant-version-bump.mjs +121 -0
  27. package/scripts/merge-changesets.mjs +260 -0
  28. package/scripts/publish-to-npm.mjs +126 -0
  29. package/scripts/setup-npm.mjs +37 -0
  30. package/scripts/validate-changeset.mjs +262 -0
  31. package/scripts/version-and-commit.mjs +237 -0
  32. package/src/ARCHITECTURE.md +270 -0
  33. package/src/README.md +517 -0
  34. package/src/bindings.js +298 -0
  35. package/src/browser/launcher.js +93 -0
  36. package/src/browser/navigation.js +513 -0
  37. package/src/core/constants.js +24 -0
  38. package/src/core/engine-adapter.js +466 -0
  39. package/src/core/engine-detection.js +49 -0
  40. package/src/core/logger.js +21 -0
  41. package/src/core/navigation-manager.js +503 -0
  42. package/src/core/navigation-safety.js +160 -0
  43. package/src/core/network-tracker.js +373 -0
  44. package/src/core/page-session.js +299 -0
  45. package/src/core/page-trigger-manager.js +564 -0
  46. package/src/core/preferences.js +46 -0
  47. package/src/elements/content.js +197 -0
  48. package/src/elements/locators.js +243 -0
  49. package/src/elements/selectors.js +360 -0
  50. package/src/elements/visibility.js +166 -0
  51. package/src/exports.js +121 -0
  52. package/src/factory.js +192 -0
  53. package/src/high-level/universal-logic.js +206 -0
  54. package/src/index.js +17 -0
  55. package/src/interactions/click.js +684 -0
  56. package/src/interactions/fill.js +383 -0
  57. package/src/interactions/scroll.js +341 -0
  58. package/src/utilities/url.js +33 -0
  59. package/src/utilities/wait.js +135 -0
  60. package/tests/e2e/playwright.e2e.test.js +442 -0
  61. package/tests/e2e/puppeteer.e2e.test.js +408 -0
  62. package/tests/helpers/mocks.js +542 -0
  63. package/tests/unit/bindings.test.js +218 -0
  64. package/tests/unit/browser/navigation.test.js +345 -0
  65. package/tests/unit/core/constants.test.js +72 -0
  66. package/tests/unit/core/engine-adapter.test.js +170 -0
  67. package/tests/unit/core/engine-detection.test.js +81 -0
  68. package/tests/unit/core/logger.test.js +80 -0
  69. package/tests/unit/core/navigation-safety.test.js +202 -0
  70. package/tests/unit/core/network-tracker.test.js +198 -0
  71. package/tests/unit/core/page-trigger-manager.test.js +358 -0
  72. package/tests/unit/elements/content.test.js +318 -0
  73. package/tests/unit/elements/locators.test.js +236 -0
  74. package/tests/unit/elements/selectors.test.js +302 -0
  75. package/tests/unit/elements/visibility.test.js +234 -0
  76. package/tests/unit/factory.test.js +174 -0
  77. package/tests/unit/high-level/universal-logic.test.js +299 -0
  78. package/tests/unit/interactions/click.test.js +340 -0
  79. package/tests/unit/interactions/fill.test.js +378 -0
  80. package/tests/unit/interactions/scroll.test.js +330 -0
  81. package/tests/unit/utilities/url.test.js +63 -0
  82. package/tests/unit/utilities/wait.test.js +207 -0
@@ -0,0 +1,341 @@
1
+ import { TIMING } from '../core/constants.js';
2
+ import { isNavigationError } from '../core/navigation-safety.js';
3
+ import { isActionStoppedError } from '../core/page-trigger-manager.js';
4
+
5
+ // Shared evaluation function for checking if scrolling is needed
6
+ const needsScrollingFn = (el, thresholdPercent) => {
7
+ const rect = el.getBoundingClientRect();
8
+ const viewportHeight = window.innerHeight;
9
+ const elementCenter = rect.top + rect.height / 2;
10
+ const viewportCenter = viewportHeight / 2;
11
+ const distanceFromCenter = Math.abs(elementCenter - viewportCenter);
12
+ const thresholdPixels = (viewportHeight * thresholdPercent) / 100;
13
+
14
+ // Check if element is visible and within threshold
15
+ const isVisible = rect.top >= 0 && rect.bottom <= viewportHeight;
16
+ const isWithinThreshold = distanceFromCenter <= thresholdPixels;
17
+
18
+ return !isVisible || !isWithinThreshold;
19
+ };
20
+
21
+ // Shared evaluation function for verifying element is in viewport
22
+ const isElementInViewportFn = (el, margin = 50) => {
23
+ const rect = el.getBoundingClientRect();
24
+ const viewportHeight = window.innerHeight;
25
+ const viewportWidth = window.innerWidth;
26
+
27
+ // Check if element is at least partially visible with some margin
28
+ const isInVerticalView =
29
+ rect.top < viewportHeight - margin && rect.bottom > margin;
30
+ const isInHorizontalView =
31
+ rect.left < viewportWidth - margin && rect.right > margin;
32
+
33
+ return isInVerticalView && isInHorizontalView;
34
+ };
35
+
36
+ /**
37
+ * Default verification function for scroll operations.
38
+ * Verifies that the element is now visible in the viewport.
39
+ * @param {Object} options - Verification options
40
+ * @param {Object} options.page - Browser page object
41
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
42
+ * @param {Object} options.locatorOrElement - Element that was scrolled to
43
+ * @param {number} options.margin - Margin in pixels to consider element visible (default: 50)
44
+ * @returns {Promise<{verified: boolean, inViewport: boolean}>}
45
+ */
46
+ export async function defaultScrollVerification(options = {}) {
47
+ const { page, engine, locatorOrElement, margin = 50 } = options;
48
+
49
+ try {
50
+ let inViewport;
51
+ if (engine === 'playwright') {
52
+ inViewport = await locatorOrElement.evaluate(
53
+ isElementInViewportFn,
54
+ margin
55
+ );
56
+ } else {
57
+ inViewport = await page.evaluate(
58
+ isElementInViewportFn,
59
+ locatorOrElement,
60
+ margin
61
+ );
62
+ }
63
+ return { verified: inViewport, inViewport };
64
+ } catch (error) {
65
+ if (isNavigationError(error) || isActionStoppedError(error)) {
66
+ return { verified: false, inViewport: false, navigationError: true };
67
+ }
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Verify scroll operation with retry logic
74
+ * @param {Object} options - Verification options
75
+ * @param {Object} options.page - Browser page object
76
+ * @param {string} options.engine - Engine type
77
+ * @param {Object} options.locatorOrElement - Element to verify
78
+ * @param {Function} options.verifyFn - Custom verification function (optional, defaults to defaultScrollVerification)
79
+ * @param {number} options.timeout - Verification timeout in ms (default: TIMING.VERIFICATION_TIMEOUT)
80
+ * @param {number} options.retryInterval - Interval between retries (default: TIMING.VERIFICATION_RETRY_INTERVAL)
81
+ * @param {Function} options.log - Logger instance
82
+ * @returns {Promise<{verified: boolean, inViewport: boolean, attempts: number}>}
83
+ */
84
+ export async function verifyScroll(options = {}) {
85
+ const {
86
+ page,
87
+ engine,
88
+ locatorOrElement,
89
+ verifyFn = defaultScrollVerification,
90
+ timeout = TIMING.VERIFICATION_TIMEOUT,
91
+ retryInterval = TIMING.VERIFICATION_RETRY_INTERVAL,
92
+ log = { debug: () => {} },
93
+ } = options;
94
+
95
+ const startTime = Date.now();
96
+ let attempts = 0;
97
+ let lastResult = { verified: false, inViewport: false };
98
+
99
+ while (Date.now() - startTime < timeout) {
100
+ attempts++;
101
+ lastResult = await verifyFn({
102
+ page,
103
+ engine,
104
+ locatorOrElement,
105
+ });
106
+
107
+ if (lastResult.verified) {
108
+ log.debug(
109
+ () => `✅ Scroll verification succeeded after ${attempts} attempt(s)`
110
+ );
111
+ return { ...lastResult, attempts };
112
+ }
113
+
114
+ if (lastResult.navigationError) {
115
+ log.debug(
116
+ () => '⚠️ Navigation/stop detected during scroll verification'
117
+ );
118
+ return { ...lastResult, attempts };
119
+ }
120
+
121
+ // Wait before next retry
122
+ await new Promise((resolve) => setTimeout(resolve, retryInterval));
123
+ }
124
+
125
+ log.debug(
126
+ () =>
127
+ `❌ Scroll verification failed after ${attempts} attempts - element not in viewport`
128
+ );
129
+ return { ...lastResult, attempts };
130
+ }
131
+
132
+ /**
133
+ * Scroll element into view (low-level, does not check if scroll is needed)
134
+ * @param {Object} options - Configuration options
135
+ * @param {Object} options.page - Browser page object
136
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
137
+ * @param {Object} options.locatorOrElement - Playwright locator or Puppeteer element
138
+ * @param {string} options.behavior - 'smooth' or 'instant' (default: 'smooth')
139
+ * @param {boolean} options.verify - Whether to verify the scroll operation (default: true)
140
+ * @param {Function} options.verifyFn - Custom verification function (optional)
141
+ * @param {number} options.verificationTimeout - Verification timeout in ms (default: TIMING.VERIFICATION_TIMEOUT)
142
+ * @param {Function} options.log - Logger instance (optional)
143
+ * @returns {Promise<{scrolled: boolean, verified: boolean}>}
144
+ */
145
+ export async function scrollIntoView(options = {}) {
146
+ const {
147
+ page,
148
+ engine,
149
+ locatorOrElement,
150
+ behavior = 'smooth',
151
+ verify = true,
152
+ verifyFn,
153
+ verificationTimeout = TIMING.VERIFICATION_TIMEOUT,
154
+ log = { debug: () => {} },
155
+ } = options;
156
+
157
+ if (!locatorOrElement) {
158
+ throw new Error('locatorOrElement is required in options');
159
+ }
160
+
161
+ try {
162
+ if (engine === 'playwright') {
163
+ await locatorOrElement.evaluate((el, scrollBehavior) => {
164
+ el.scrollIntoView({
165
+ behavior: scrollBehavior,
166
+ block: 'center',
167
+ inline: 'center',
168
+ });
169
+ }, behavior);
170
+ } else {
171
+ await page.evaluate(
172
+ (el, scrollBehavior) => {
173
+ el.scrollIntoView({
174
+ behavior: scrollBehavior,
175
+ block: 'center',
176
+ inline: 'center',
177
+ });
178
+ },
179
+ locatorOrElement,
180
+ behavior
181
+ );
182
+ }
183
+
184
+ // Verify scroll if requested
185
+ if (verify) {
186
+ const verificationResult = await verifyScroll({
187
+ page,
188
+ engine,
189
+ locatorOrElement,
190
+ verifyFn,
191
+ timeout: verificationTimeout,
192
+ log,
193
+ });
194
+
195
+ return {
196
+ scrolled: true,
197
+ verified: verificationResult.verified,
198
+ };
199
+ }
200
+
201
+ return { scrolled: true, verified: true };
202
+ } catch (error) {
203
+ if (isNavigationError(error) || isActionStoppedError(error)) {
204
+ console.log(
205
+ '⚠️ Navigation/stop detected during scrollIntoView, skipping'
206
+ );
207
+ return { scrolled: false, verified: false };
208
+ }
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Check if element needs scrolling (is it more than threshold% away from viewport center)
215
+ * @param {Object} options - Configuration options
216
+ * @param {Object} options.page - Browser page object
217
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
218
+ * @param {Object} options.locatorOrElement - Playwright locator or Puppeteer element
219
+ * @param {number} options.threshold - Percentage of viewport height to consider "significant" (default: 10)
220
+ * @returns {Promise<boolean>} - True if scroll is needed, false on navigation/stop
221
+ */
222
+ export async function needsScrolling(options = {}) {
223
+ const { page, engine, locatorOrElement, threshold = 10 } = options;
224
+
225
+ if (!locatorOrElement) {
226
+ throw new Error('locatorOrElement is required in options');
227
+ }
228
+
229
+ try {
230
+ if (engine === 'playwright') {
231
+ return await locatorOrElement.evaluate(needsScrollingFn, threshold);
232
+ } else {
233
+ return await page.evaluate(needsScrollingFn, locatorOrElement, threshold);
234
+ }
235
+ } catch (error) {
236
+ if (isNavigationError(error) || isActionStoppedError(error)) {
237
+ console.log(
238
+ '⚠️ Navigation/stop detected during needsScrolling, returning false'
239
+ );
240
+ return false;
241
+ }
242
+ throw error;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Scroll element into view only if needed (>threshold% from center)
248
+ * Automatically waits for scroll animation if scroll was performed
249
+ * @param {Object} options - Configuration options
250
+ * @param {Object} options.page - Browser page object
251
+ * @param {string} options.engine - Engine type
252
+ * @param {Function} options.wait - Wait function
253
+ * @param {Function} options.log - Logger instance
254
+ * @param {Object} options.locatorOrElement - Playwright locator or Puppeteer element
255
+ * @param {string} options.behavior - 'smooth' or 'instant' (default: 'smooth')
256
+ * @param {number} options.threshold - Percentage of viewport height to consider "significant" (default: 10)
257
+ * @param {number} options.waitAfterScroll - Wait time after scroll in ms (default: TIMING.SCROLL_ANIMATION_WAIT for smooth, 0 for instant)
258
+ * @param {boolean} options.verify - Whether to verify the scroll operation (default: true)
259
+ * @param {Function} options.verifyFn - Custom verification function (optional)
260
+ * @param {number} options.verificationTimeout - Verification timeout in ms (default: TIMING.VERIFICATION_TIMEOUT)
261
+ * @returns {Promise<{scrolled: boolean, verified: boolean, skipped: boolean}>}
262
+ * - scrolled: true if scroll was performed
263
+ * - verified: true if element is confirmed in viewport (only meaningful if scrolled is true)
264
+ * - skipped: true if element was already in view
265
+ */
266
+ export async function scrollIntoViewIfNeeded(options = {}) {
267
+ const {
268
+ page,
269
+ engine,
270
+ wait,
271
+ log,
272
+ locatorOrElement,
273
+ behavior = 'smooth',
274
+ threshold = 10,
275
+ waitAfterScroll = behavior === 'smooth' ? TIMING.SCROLL_ANIMATION_WAIT : 0,
276
+ verify = true,
277
+ verifyFn,
278
+ verificationTimeout = TIMING.VERIFICATION_TIMEOUT,
279
+ } = options;
280
+
281
+ if (!locatorOrElement) {
282
+ throw new Error('locatorOrElement is required in options');
283
+ }
284
+
285
+ // Check if scrolling is needed
286
+ const needsScroll = await needsScrolling({
287
+ page,
288
+ engine,
289
+ locatorOrElement,
290
+ threshold,
291
+ });
292
+
293
+ if (!needsScroll) {
294
+ log.debug(
295
+ () =>
296
+ `🔍 [VERBOSE] Element already in view (within ${threshold}% threshold), skipping scroll`
297
+ );
298
+ return { scrolled: false, verified: true, skipped: true };
299
+ }
300
+
301
+ // Perform scroll with verification
302
+ log.debug(() => `🔍 [VERBOSE] Scrolling with behavior: ${behavior}`);
303
+ const scrollResult = await scrollIntoView({
304
+ page,
305
+ engine,
306
+ locatorOrElement,
307
+ behavior,
308
+ verify,
309
+ verifyFn,
310
+ verificationTimeout,
311
+ log,
312
+ });
313
+
314
+ if (!scrollResult.scrolled) {
315
+ // Navigation/stop occurred during scroll
316
+ return { scrolled: false, verified: false, skipped: false };
317
+ }
318
+
319
+ // Wait for scroll animation if specified
320
+ if (waitAfterScroll > 0) {
321
+ await wait({
322
+ ms: waitAfterScroll,
323
+ reason: `${behavior} scroll animation to complete`,
324
+ });
325
+ }
326
+
327
+ if (scrollResult.verified) {
328
+ log.debug(() => '✅ Scroll verification passed - element is in viewport');
329
+ } else {
330
+ log.debug(
331
+ () =>
332
+ '⚠️ Scroll verification failed - element may not be fully in viewport'
333
+ );
334
+ }
335
+
336
+ return {
337
+ scrolled: true,
338
+ verified: scrollResult.verified,
339
+ skipped: false,
340
+ };
341
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Get current URL
3
+ * @param {Object} options - Configuration options
4
+ * @param {Object} options.page - Browser page object
5
+ * @returns {string} - Current URL
6
+ */
7
+ export function getUrl(options = {}) {
8
+ const { page } = options;
9
+ return page.url();
10
+ }
11
+
12
+ /**
13
+ * Unfocus address bar to prevent it from being selected
14
+ * Fixes the annoying issue where address bar is focused after browser launch/navigation
15
+ * Uses page.bringToFront() as recommended by Puppeteer/Playwright communities
16
+ * @param {Object} options - Configuration options
17
+ * @param {Object} options.page - Browser page object
18
+ * @returns {Promise<void>}
19
+ */
20
+ export async function unfocusAddressBar(options = {}) {
21
+ const { page } = options;
22
+
23
+ if (!page) {
24
+ throw new Error('page is required in options');
25
+ }
26
+
27
+ try {
28
+ // Bring page to front - this removes focus from address bar
29
+ await page.bringToFront();
30
+ } catch {
31
+ // Ignore errors - this is just a UX improvement
32
+ }
33
+ }
@@ -0,0 +1,135 @@
1
+ import { isNavigationError } from '../core/navigation-safety.js';
2
+ import { createEngineAdapter } from '../core/engine-adapter.js';
3
+
4
+ /**
5
+ * Wait/sleep for a specified time with optional verbose logging
6
+ * Now supports abort signals to interrupt the wait when navigation occurs
7
+ *
8
+ * @param {Object} options - Configuration options
9
+ * @param {Function} options.log - Logger instance
10
+ * @param {number} options.ms - Milliseconds to wait
11
+ * @param {string} options.reason - Reason for waiting (for verbose logging)
12
+ * @param {AbortSignal} options.abortSignal - Optional abort signal to interrupt wait
13
+ * @returns {Promise<{completed: boolean, aborted: boolean}>}
14
+ */
15
+ export async function wait(options = {}) {
16
+ const { log, ms, reason, abortSignal } = options;
17
+
18
+ if (!ms) {
19
+ throw new Error('ms is required in options');
20
+ }
21
+
22
+ if (reason) {
23
+ log.debug(() => `🔍 [VERBOSE] Waiting ${ms}ms: ${reason}`);
24
+ }
25
+
26
+ // If abort signal provided, use abortable wait
27
+ if (abortSignal) {
28
+ // Check if already aborted
29
+ if (abortSignal.aborted) {
30
+ log.debug(
31
+ () => `🛑 Wait skipped (already aborted): ${reason || 'no reason'}`
32
+ );
33
+ return { completed: false, aborted: true };
34
+ }
35
+
36
+ return new Promise((resolve) => {
37
+ let timeoutId = null;
38
+ let abortHandler = null;
39
+
40
+ const cleanup = () => {
41
+ if (timeoutId) {
42
+ clearTimeout(timeoutId);
43
+ }
44
+ if (abortHandler) {
45
+ abortSignal.removeEventListener('abort', abortHandler);
46
+ }
47
+ };
48
+
49
+ abortHandler = () => {
50
+ cleanup();
51
+ log.debug(() => `🛑 Wait aborted: ${reason || 'no reason'}`);
52
+ resolve({ completed: false, aborted: true });
53
+ };
54
+
55
+ abortSignal.addEventListener('abort', abortHandler);
56
+
57
+ timeoutId = setTimeout(() => {
58
+ cleanup();
59
+ if (reason) {
60
+ log.debug(() => `🔍 [VERBOSE] Wait complete (${ms}ms)`);
61
+ }
62
+ resolve({ completed: true, aborted: false });
63
+ }, ms);
64
+ });
65
+ }
66
+
67
+ // Standard non-abortable wait (backwards compatible)
68
+ await new Promise((r) => setTimeout(r, ms));
69
+
70
+ if (reason) {
71
+ log.debug(() => `🔍 [VERBOSE] Wait complete (${ms}ms)`);
72
+ }
73
+
74
+ return { completed: true, aborted: false };
75
+ }
76
+
77
+ /**
78
+ * Evaluate JavaScript in page context
79
+ * @param {Object} options - Configuration options
80
+ * @param {Object} options.page - Browser page object
81
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
82
+ * @param {Function} options.fn - Function to evaluate
83
+ * @param {Array} options.args - Arguments to pass to function (default: [])
84
+ * @param {Object} options.adapter - Engine adapter (optional, will be created if not provided)
85
+ * @returns {Promise<any>} - Result of evaluation
86
+ */
87
+ export async function evaluate(options = {}) {
88
+ const { page, engine, fn, args = [], adapter: providedAdapter } = options;
89
+
90
+ if (!fn) {
91
+ throw new Error('fn is required in options');
92
+ }
93
+
94
+ const adapter = providedAdapter || createEngineAdapter(page, engine);
95
+ return await adapter.evaluateOnPage(fn, args);
96
+ }
97
+
98
+ /**
99
+ * Safe evaluate that catches navigation errors and returns default value
100
+ * @param {Object} options - Configuration options
101
+ * @param {Object} options.page - Browser page object
102
+ * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
103
+ * @param {Function} options.fn - Function to evaluate
104
+ * @param {Array} options.args - Arguments to pass to function (default: [])
105
+ * @param {any} options.defaultValue - Value to return on navigation error (default: null)
106
+ * @param {string} options.operationName - Name for logging (default: 'evaluate')
107
+ * @param {boolean} options.silent - Don't log warnings (default: false)
108
+ * @returns {Promise<{success: boolean, value: any, navigationError: boolean}>}
109
+ */
110
+ export async function safeEvaluate(options = {}) {
111
+ const {
112
+ page,
113
+ engine,
114
+ fn,
115
+ args = [],
116
+ defaultValue = null,
117
+ operationName = 'evaluate',
118
+ silent = false,
119
+ } = options;
120
+
121
+ try {
122
+ const value = await evaluate({ page, engine, fn, args });
123
+ return { success: true, value, navigationError: false };
124
+ } catch (error) {
125
+ if (isNavigationError(error)) {
126
+ if (!silent) {
127
+ console.log(
128
+ `⚠️ Navigation detected during ${operationName}, recovering gracefully`
129
+ );
130
+ }
131
+ return { success: false, value: defaultValue, navigationError: true };
132
+ }
133
+ throw error;
134
+ }
135
+ }