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
package/src/README.md ADDED
@@ -0,0 +1,517 @@
1
+ # Browser Commander
2
+
3
+ A universal browser automation library that supports both Playwright and Puppeteer with a unified API. The key focus is on **stoppable page triggers** - ensuring automation logic is properly mounted/unmounted during page navigation.
4
+
5
+ ## Core Concept: Page State Machine
6
+
7
+ Browser Commander manages the browser as a state machine with two states:
8
+
9
+ ```
10
+ ┌─────────────────┐ ┌─────────────────┐
11
+ │ │ navigation start │ │
12
+ │ WORKING STATE │ ─────────────────► │ LOADING STATE │
13
+ │ (action runs) │ │ (wait only) │
14
+ │ │ ◄───────────────── │ │
15
+ └─────────────────┘ page ready └─────────────────┘
16
+ ```
17
+
18
+ **LOADING STATE**: Page is loading. Only waiting/tracking operations are allowed. No automation logic runs.
19
+
20
+ **WORKING STATE**: Page is fully loaded (30 seconds of network idle). Page triggers can safely interact with DOM.
21
+
22
+ ## Quick Start
23
+
24
+ ```javascript
25
+ import {
26
+ launchBrowser,
27
+ makeBrowserCommander,
28
+ makeUrlCondition,
29
+ } from './browser-commander/index.js';
30
+
31
+ // 1. Launch browser
32
+ const { browser, page } = await launchBrowser({ engine: 'playwright' });
33
+
34
+ // 2. Create commander
35
+ const commander = makeBrowserCommander({ page, verbose: true });
36
+
37
+ // 3. Register page trigger with condition and action
38
+ commander.pageTrigger({
39
+ name: 'example-trigger',
40
+ condition: makeUrlCondition('*example.com*'), // matches URLs containing 'example.com'
41
+ action: async (ctx) => {
42
+ // ctx.commander has all methods, but they throw ActionStoppedError if navigation happens
43
+ // ctx.checkStopped() - call in loops to check if should stop
44
+ // ctx.abortSignal - use with fetch() for cancellation
45
+ // ctx.onCleanup(fn) - register cleanup when action stops
46
+
47
+ console.log(`Processing: ${ctx.url}`);
48
+
49
+ // Safe iteration - stops if navigation detected
50
+ await ctx.forEach(['item1', 'item2'], async (item) => {
51
+ await ctx.commander.clickButton({ selector: `[data-id="${item}"]` });
52
+ });
53
+ },
54
+ });
55
+
56
+ // 4. Navigate - action auto-starts when page is ready
57
+ await commander.goto({ url: 'https://example.com' });
58
+
59
+ // 5. Cleanup
60
+ await commander.destroy();
61
+ await browser.close();
62
+ ```
63
+
64
+ ## URL Condition Helpers
65
+
66
+ The `makeUrlCondition` helper makes it easy to create URL matching conditions:
67
+
68
+ ```javascript
69
+ import {
70
+ makeUrlCondition,
71
+ allConditions,
72
+ anyCondition,
73
+ notCondition,
74
+ } from './browser-commander/index.js';
75
+
76
+ // Exact URL match
77
+ makeUrlCondition('https://example.com/page');
78
+
79
+ // Contains substring (use * wildcards)
80
+ makeUrlCondition('*checkout*'); // URL contains 'checkout'
81
+ makeUrlCondition('*example.com*'); // URL contains 'example.com'
82
+
83
+ // Starts with / ends with
84
+ makeUrlCondition('/api/*'); // starts with '/api/'
85
+ makeUrlCondition('*.json'); // ends with '.json'
86
+
87
+ // Express-style route patterns
88
+ makeUrlCondition('/vacancy/:id'); // matches /vacancy/123
89
+ makeUrlCondition('https://hh.ru/vacancy/:vacancyId'); // matches specific domain + path
90
+ makeUrlCondition('/user/:userId/profile'); // multiple segments
91
+
92
+ // RegExp
93
+ makeUrlCondition(/\/product\/\d+/);
94
+
95
+ // Custom function (receives full context)
96
+ makeUrlCondition((url, ctx) => {
97
+ const parsed = new URL(url);
98
+ return (
99
+ parsed.pathname.startsWith('/admin') && parsed.searchParams.has('edit')
100
+ );
101
+ });
102
+
103
+ // Combine conditions
104
+ allConditions(
105
+ makeUrlCondition('*example.com*'),
106
+ makeUrlCondition('*/checkout*')
107
+ ); // Both must match
108
+
109
+ anyCondition(makeUrlCondition('*/cart*'), makeUrlCondition('*/checkout*')); // Either matches
110
+
111
+ notCondition(makeUrlCondition('*/admin*')); // Negation
112
+ ```
113
+
114
+ ## Page Trigger Lifecycle
115
+
116
+ ### The Guarantee
117
+
118
+ When navigation is detected:
119
+
120
+ 1. **Action is signaled to stop** (AbortController.abort())
121
+ 2. **Wait for action to finish** (up to 10 seconds for graceful cleanup)
122
+ 3. **Only then start waiting for page load**
123
+
124
+ This ensures:
125
+
126
+ - No DOM operations on stale/loading pages
127
+ - Actions can do proper cleanup (clear intervals, save state)
128
+ - No race conditions between action and navigation
129
+
130
+ ### Lifecycle Flow
131
+
132
+ ```
133
+ URL Change Detected
134
+
135
+
136
+ ┌──────────────────────────────────┐
137
+ │ 1. Signal action to stop │ ◄── AbortController.abort()
138
+ │ 2. Wait for action to finish │ ◄── Max 10 seconds
139
+ │ 3. Run cleanup callbacks │ ◄── ctx.onCleanup()
140
+ └──────────────────────────────────┘
141
+
142
+
143
+ LOADING STATE
144
+
145
+
146
+ ┌──────────────────────────────────┐
147
+ │ 1. Wait for URL stabilization │ ◄── No more redirects (1s)
148
+ │ 2. Wait for network idle │ ◄── 30 seconds no requests
149
+ └──────────────────────────────────┘
150
+
151
+
152
+ WORKING STATE
153
+
154
+
155
+ ┌──────────────────────────────────┐
156
+ │ 1. Find matching trigger │ ◄── condition(ctx)
157
+ │ 2. Start action │ ◄── action(ctx)
158
+ └──────────────────────────────────┘
159
+ ```
160
+
161
+ ## Action Context API
162
+
163
+ When your action is called, it receives a context object with these properties:
164
+
165
+ ```javascript
166
+ commander.pageTrigger({
167
+ name: 'my-trigger',
168
+ condition: makeUrlCondition('*/checkout*'),
169
+ action: async (ctx) => {
170
+ // Current URL
171
+ ctx.url; // 'https://example.com/checkout'
172
+
173
+ // Trigger name (for debugging)
174
+ ctx.triggerName; // 'my-trigger'
175
+
176
+ // Check if action should stop
177
+ ctx.isStopped(); // Returns true if navigation detected
178
+
179
+ // Throw ActionStoppedError if stopped (use in manual loops)
180
+ ctx.checkStopped();
181
+
182
+ // AbortSignal - use with fetch() or other cancellable APIs
183
+ ctx.abortSignal;
184
+
185
+ // Safe wait (throws if stopped during wait)
186
+ await ctx.wait(1000);
187
+
188
+ // Safe iteration (checks stopped between items)
189
+ await ctx.forEach(items, async (item) => {
190
+ await ctx.commander.clickButton({ selector: item.selector });
191
+ });
192
+
193
+ // Register cleanup (runs when action stops)
194
+ ctx.onCleanup(() => {
195
+ console.log('Cleaning up...');
196
+ });
197
+
198
+ // Commander with all methods wrapped to throw on stop
199
+ await ctx.commander.fillTextArea({ selector: 'input', text: 'hello' });
200
+
201
+ // Raw commander (use carefully - does not auto-throw)
202
+ ctx.rawCommander;
203
+ },
204
+ });
205
+ ```
206
+
207
+ ## ActionStoppedError
208
+
209
+ When navigation is detected, all `ctx.commander` methods throw `ActionStoppedError`:
210
+
211
+ ```javascript
212
+ action: async (ctx) => {
213
+ try {
214
+ await ctx.commander.clickButton({ selector: 'button' });
215
+ } catch (error) {
216
+ if (commander.isActionStoppedError(error)) {
217
+ // Navigation happened - clean up and return
218
+ console.log('Navigation detected, stopping');
219
+ return;
220
+ }
221
+ throw error; // Re-throw other errors
222
+ }
223
+ };
224
+ ```
225
+
226
+ The error is automatically caught by the PageTriggerManager, so you usually don't need to catch it unless you need custom cleanup logic.
227
+
228
+ ## Condition Function
229
+
230
+ The `condition` function determines when your trigger runs. It receives full context:
231
+
232
+ ```javascript
233
+ // Simple URL check
234
+ condition: (ctx) => ctx.url.includes('/checkout');
235
+
236
+ // Multiple pages
237
+ condition: (ctx) => ctx.url.includes('/cart') || ctx.url.includes('/checkout');
238
+
239
+ // Regex
240
+ condition: (ctx) => /\/product\/\d+/.test(ctx.url);
241
+
242
+ // Complex logic with commander access
243
+ condition: (ctx) => {
244
+ const parsed = new URL(ctx.url);
245
+ return (
246
+ parsed.pathname.startsWith('/admin') && parsed.searchParams.has('edit')
247
+ );
248
+ };
249
+
250
+ // Or use makeUrlCondition helper
251
+ condition: makeUrlCondition('/checkout/*');
252
+ ```
253
+
254
+ ### Trigger Priority
255
+
256
+ If multiple triggers match, the highest priority runs:
257
+
258
+ ```javascript
259
+ // Higher priority runs first
260
+ commander.pageTrigger({
261
+ name: 'specific-checkout',
262
+ priority: 10, // Higher priority
263
+ condition: makeUrlCondition('*/checkout/payment*'),
264
+ action: handlePaymentPage,
265
+ });
266
+
267
+ commander.pageTrigger({
268
+ name: 'general-checkout',
269
+ priority: 0, // Default priority
270
+ condition: makeUrlCondition('*/checkout*'),
271
+ action: handleCheckoutPage,
272
+ });
273
+ ```
274
+
275
+ ## Returning to a Page
276
+
277
+ If navigation brings you back to a matching URL, the action restarts:
278
+
279
+ ```javascript
280
+ // Trigger registered for /search
281
+ commander.pageTrigger({
282
+ condition: makeUrlCondition('*/search*'),
283
+ action: async (ctx) => {
284
+ console.log('Search action started');
285
+ // ... do work
286
+ },
287
+ });
288
+
289
+ // Navigate to search -> action starts
290
+ await commander.goto({ url: '/search' });
291
+
292
+ // Navigate away -> action stops
293
+ await commander.goto({ url: '/product/123' });
294
+
295
+ // Navigate back -> action restarts (new instance)
296
+ await commander.goto({ url: '/search' });
297
+ ```
298
+
299
+ ## Unregistering Triggers
300
+
301
+ `pageTrigger` returns an unregister function:
302
+
303
+ ```javascript
304
+ const unregister = commander.pageTrigger({
305
+ name: 'temp-trigger',
306
+ condition: makeUrlCondition('*/temp*'),
307
+ action: async (ctx) => {
308
+ /* ... */
309
+ },
310
+ });
311
+
312
+ // Later: remove the trigger
313
+ unregister();
314
+ ```
315
+
316
+ ## Architecture
317
+
318
+ ### File Structure
319
+
320
+ ```
321
+ browser-commander/
322
+ ├── index.js # Main entry, makeBrowserCommander()
323
+ ├── core/
324
+ │ ├── page-trigger-manager.js # Trigger lifecycle management
325
+ │ ├── navigation-manager.js # URL changes, abort signals
326
+ │ ├── network-tracker.js # HTTP request tracking
327
+ │ ├── page-session.js # Per-page context (legacy)
328
+ │ ├── navigation-safety.js # Handle navigation errors
329
+ │ ├── constants.js # CHROME_ARGS, TIMING
330
+ │ ├── logger.js # Logging utilities
331
+ │ ├── engine-detection.js # Detect Playwright/Puppeteer
332
+ │ └── preferences.js # Chrome preferences
333
+ ├── browser/
334
+ │ ├── launcher.js # Browser launch
335
+ │ └── navigation.js # goto, waitForNavigation
336
+ ├── elements/
337
+ │ ├── locators.js # Element location
338
+ │ ├── selectors.js # querySelector, findByText
339
+ │ ├── visibility.js # isVisible, isEnabled
340
+ │ └── content.js # textContent, getAttribute
341
+ ├── interactions/
342
+ │ ├── click.js # clickButton, clickElement
343
+ │ ├── fill.js # fillTextArea
344
+ │ └── scroll.js # scrollIntoView
345
+ ├── utilities/
346
+ │ ├── wait.js # wait(), evaluate()
347
+ │ └── url.js # getUrl
348
+ └── high-level/
349
+ └── universal-logic.js # High-level utilities
350
+ ```
351
+
352
+ ### Component Relationships
353
+
354
+ ```
355
+ ┌─────────────────────────────────────────────────────────────────┐
356
+ │ BrowserCommander │
357
+ │ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │
358
+ │ │ NetworkTracker │ │NavigationManager│ │PageTriggerMgr │ │
359
+ │ │ │ │ │ │ │ │
360
+ │ │ - Track HTTP │◄─│ - URL changes │◄─│ - Register │ │
361
+ │ │ - Wait idle │ │ - Abort signals │ │ - Start/Stop │ │
362
+ │ │ - 30s threshold │ │ - Events │ │ - Lifecycle │ │
363
+ │ └─────────────────┘ └─────────────────┘ └────────────────┘ │
364
+ └─────────────────────────────────────────────────────────────────┘
365
+ ```
366
+
367
+ ## Network Idle Detection
368
+
369
+ The library waits for **30 seconds of zero pending HTTP requests** before considering a page fully loaded:
370
+
371
+ ```javascript
372
+ // NetworkTracker created with 30s idle timeout
373
+ networkTracker = createNetworkTracker({
374
+ idleTimeout: 30000, // 30 seconds without requests = idle
375
+ });
376
+ ```
377
+
378
+ This ensures:
379
+
380
+ - All lazy-loaded content is fetched
381
+ - All analytics scripts complete
382
+ - All async JavaScript executes
383
+ - SPAs fully hydrate
384
+
385
+ ## API Reference
386
+
387
+ ### makeBrowserCommander(options)
388
+
389
+ ```javascript
390
+ const commander = makeBrowserCommander({
391
+ page, // Required: Playwright/Puppeteer page
392
+ verbose: false, // Enable debug logging
393
+ enableNetworkTracking: true, // Track HTTP requests
394
+ enableNavigationManager: true, // Enable navigation events
395
+ });
396
+ ```
397
+
398
+ ### commander.pageTrigger(config)
399
+
400
+ ```javascript
401
+ const unregister = commander.pageTrigger({
402
+ name: 'trigger-name', // For debugging
403
+ condition: (ctx) => boolean, // When to run (receives {url, commander})
404
+ action: async (ctx) => void, // What to do
405
+ priority: 0, // Higher runs first
406
+ });
407
+ ```
408
+
409
+ ### commander.goto(options)
410
+
411
+ ```javascript
412
+ await commander.goto({
413
+ url: 'https://example.com',
414
+ waitUntil: 'domcontentloaded', // Playwright/Puppeteer option
415
+ timeout: 60000,
416
+ });
417
+ ```
418
+
419
+ ### commander.clickButton(options)
420
+
421
+ ```javascript
422
+ await commander.clickButton({
423
+ selector: 'button.submit',
424
+ scrollIntoView: true,
425
+ waitForNavigation: true,
426
+ });
427
+ ```
428
+
429
+ ### commander.fillTextArea(options)
430
+
431
+ ```javascript
432
+ await commander.fillTextArea({
433
+ selector: 'textarea.message',
434
+ text: 'Hello world',
435
+ checkEmpty: true,
436
+ });
437
+ ```
438
+
439
+ ### commander.destroy()
440
+
441
+ ```javascript
442
+ await commander.destroy(); // Stop actions, cleanup
443
+ ```
444
+
445
+ ## Best Practices
446
+
447
+ ### 1. Use ctx.forEach for Loops
448
+
449
+ ```javascript
450
+ // BAD: Won't stop on navigation
451
+ for (const item of items) {
452
+ await ctx.commander.click({ selector: item });
453
+ }
454
+
455
+ // GOOD: Stops immediately on navigation
456
+ await ctx.forEach(items, async (item) => {
457
+ await ctx.commander.click({ selector: item });
458
+ });
459
+ ```
460
+
461
+ ### 2. Use ctx.checkStopped for Complex Logic
462
+
463
+ ```javascript
464
+ action: async (ctx) => {
465
+ while (hasMorePages) {
466
+ ctx.checkStopped(); // Throws if navigation detected
467
+
468
+ await processPage(ctx);
469
+ hasMorePages = await ctx.commander.isVisible({ selector: '.next' });
470
+ }
471
+ };
472
+ ```
473
+
474
+ ### 3. Register Cleanup for Resources
475
+
476
+ ```javascript
477
+ action: async (ctx) => {
478
+ const intervalId = setInterval(updateStatus, 1000);
479
+
480
+ ctx.onCleanup(() => {
481
+ clearInterval(intervalId);
482
+ console.log('Interval cleared');
483
+ });
484
+
485
+ // ... rest of action
486
+ };
487
+ ```
488
+
489
+ ### 4. Use ctx.abortSignal with Fetch
490
+
491
+ ```javascript
492
+ action: async (ctx) => {
493
+ const response = await fetch(url, {
494
+ signal: ctx.abortSignal, // Cancels on navigation
495
+ });
496
+ };
497
+ ```
498
+
499
+ ## Debugging
500
+
501
+ Enable verbose mode for detailed logs:
502
+
503
+ ```javascript
504
+ const commander = makeBrowserCommander({ page, verbose: true });
505
+ ```
506
+
507
+ Log symbols:
508
+
509
+ - `📋` Trigger registration/lifecycle
510
+ - `🚀` Action starting
511
+ - `🛑` Action stopping
512
+ - `✅` Action completed
513
+ - `❌` Action error
514
+ - `📤` Request started
515
+ - `📥` Request ended
516
+ - `🔗` URL change
517
+ - `🌐` Network idle