browser-commander 0.5.2 → 0.5.4

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.4
4
+
5
+ ### Patch Changes
6
+
7
+ - e9043cc: Fix normalizeSelector to validate input type and reject arrays
8
+
9
+ When `normalizeSelector` receives an invalid type (array, number, or non-text-selector object), it now returns `null` with a warning instead of returning the invalid value unchanged.
10
+
11
+ This prevents downstream `querySelectorAll` errors with invalid selector syntax (like trailing commas when arrays are accidentally passed).
12
+
13
+ Fixes #23
14
+
15
+ ## 0.5.3
16
+
17
+ ### Patch Changes
18
+
19
+ - 8b86dd7: Include README.md in npm package
20
+
21
+ Added language-specific README.md files for each implementation:
22
+ - js/README.md: JavaScript/npm-specific documentation with installation and API usage
23
+ - rust/README.md: Rust/Cargo-specific documentation
24
+ - Root README.md: Common overview linking to both implementations
25
+
26
+ The npm package now includes the JavaScript-specific README.md directly from the js/ directory.
27
+
3
28
  ## 0.5.2
4
29
 
5
30
  ### Patch Changes
package/README.md ADDED
@@ -0,0 +1,335 @@
1
+ # Browser Commander
2
+
3
+ A universal browser automation library for JavaScript/TypeScript 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
+ ## Installation
6
+
7
+ ```bash
8
+ npm install browser-commander
9
+ ```
10
+
11
+ You'll also need either Playwright or Puppeteer:
12
+
13
+ ```bash
14
+ # With Playwright
15
+ npm install playwright
16
+
17
+ # Or with Puppeteer
18
+ npm install puppeteer
19
+ ```
20
+
21
+ ## Core Concept: Page State Machine
22
+
23
+ Browser Commander manages the browser as a state machine with two states:
24
+
25
+ ```
26
+ +------------------+ +------------------+
27
+ | | navigation start | |
28
+ | WORKING STATE | -------------------> | LOADING STATE |
29
+ | (action runs) | | (wait only) |
30
+ | | <----------------- | |
31
+ +------------------+ page ready +------------------+
32
+ ```
33
+
34
+ **LOADING STATE**: Page is loading. Only waiting/tracking operations are allowed. No automation logic runs.
35
+
36
+ **WORKING STATE**: Page is fully loaded (30 seconds of network idle). Page triggers can safely interact with DOM.
37
+
38
+ ## Quick Start
39
+
40
+ ```javascript
41
+ import {
42
+ launchBrowser,
43
+ makeBrowserCommander,
44
+ makeUrlCondition,
45
+ } from 'browser-commander';
46
+
47
+ // 1. Launch browser
48
+ const { browser, page } = await launchBrowser({ engine: 'playwright' });
49
+
50
+ // 2. Create commander
51
+ const commander = makeBrowserCommander({ page, verbose: true });
52
+
53
+ // 3. Register page trigger with condition and action
54
+ commander.pageTrigger({
55
+ name: 'example-trigger',
56
+ condition: makeUrlCondition('*example.com*'), // matches URLs containing 'example.com'
57
+ action: async (ctx) => {
58
+ // ctx.commander has all methods, but they throw ActionStoppedError if navigation happens
59
+ // ctx.checkStopped() - call in loops to check if should stop
60
+ // ctx.abortSignal - use with fetch() for cancellation
61
+ // ctx.onCleanup(fn) - register cleanup when action stops
62
+
63
+ console.log(`Processing: ${ctx.url}`);
64
+
65
+ // Safe iteration - stops if navigation detected
66
+ await ctx.forEach(['item1', 'item2'], async (item) => {
67
+ await ctx.commander.clickButton({ selector: `[data-id="${item}"]` });
68
+ });
69
+ },
70
+ });
71
+
72
+ // 4. Navigate - action auto-starts when page is ready
73
+ await commander.goto({ url: 'https://example.com' });
74
+
75
+ // 5. Cleanup
76
+ await commander.destroy();
77
+ await browser.close();
78
+ ```
79
+
80
+ ## URL Condition Helpers
81
+
82
+ The `makeUrlCondition` helper makes it easy to create URL matching conditions:
83
+
84
+ ```javascript
85
+ import {
86
+ makeUrlCondition,
87
+ allConditions,
88
+ anyCondition,
89
+ notCondition,
90
+ } from 'browser-commander';
91
+
92
+ // Exact URL match
93
+ makeUrlCondition('https://example.com/page');
94
+
95
+ // Contains substring (use * wildcards)
96
+ makeUrlCondition('*checkout*'); // URL contains 'checkout'
97
+ makeUrlCondition('*example.com*'); // URL contains 'example.com'
98
+
99
+ // Starts with / ends with
100
+ makeUrlCondition('/api/*'); // starts with '/api/'
101
+ makeUrlCondition('*.json'); // ends with '.json'
102
+
103
+ // Express-style route patterns
104
+ makeUrlCondition('/vacancy/:id'); // matches /vacancy/123
105
+ makeUrlCondition('https://hh.ru/vacancy/:vacancyId'); // matches specific domain + path
106
+ makeUrlCondition('/user/:userId/profile'); // multiple segments
107
+
108
+ // RegExp
109
+ makeUrlCondition(/\/product\/\d+/);
110
+
111
+ // Custom function (receives full context)
112
+ makeUrlCondition((url, ctx) => {
113
+ const parsed = new URL(url);
114
+ return (
115
+ parsed.pathname.startsWith('/admin') && parsed.searchParams.has('edit')
116
+ );
117
+ });
118
+
119
+ // Combine conditions
120
+ allConditions(
121
+ makeUrlCondition('*example.com*'),
122
+ makeUrlCondition('*/checkout*')
123
+ ); // Both must match
124
+
125
+ anyCondition(makeUrlCondition('*/cart*'), makeUrlCondition('*/checkout*')); // Either matches
126
+
127
+ notCondition(makeUrlCondition('*/admin*')); // Negation
128
+ ```
129
+
130
+ ## Page Trigger Lifecycle
131
+
132
+ ### The Guarantee
133
+
134
+ When navigation is detected:
135
+
136
+ 1. **Action is signaled to stop** (AbortController.abort())
137
+ 2. **Wait for action to finish** (up to 10 seconds for graceful cleanup)
138
+ 3. **Only then start waiting for page load**
139
+
140
+ This ensures:
141
+
142
+ - No DOM operations on stale/loading pages
143
+ - Actions can do proper cleanup (clear intervals, save state)
144
+ - No race conditions between action and navigation
145
+
146
+ ## Action Context API
147
+
148
+ When your action is called, it receives a context object with these properties:
149
+
150
+ ```javascript
151
+ commander.pageTrigger({
152
+ name: 'my-trigger',
153
+ condition: makeUrlCondition('*/checkout*'),
154
+ action: async (ctx) => {
155
+ // Current URL
156
+ ctx.url; // 'https://example.com/checkout'
157
+
158
+ // Trigger name (for debugging)
159
+ ctx.triggerName; // 'my-trigger'
160
+
161
+ // Check if action should stop
162
+ ctx.isStopped(); // Returns true if navigation detected
163
+
164
+ // Throw ActionStoppedError if stopped (use in manual loops)
165
+ ctx.checkStopped();
166
+
167
+ // AbortSignal - use with fetch() or other cancellable APIs
168
+ ctx.abortSignal;
169
+
170
+ // Safe wait (throws if stopped during wait)
171
+ await ctx.wait(1000);
172
+
173
+ // Safe iteration (checks stopped between items)
174
+ await ctx.forEach(items, async (item) => {
175
+ await ctx.commander.clickButton({ selector: item.selector });
176
+ });
177
+
178
+ // Register cleanup (runs when action stops)
179
+ ctx.onCleanup(() => {
180
+ console.log('Cleaning up...');
181
+ });
182
+
183
+ // Commander with all methods wrapped to throw on stop
184
+ await ctx.commander.fillTextArea({ selector: 'input', text: 'hello' });
185
+
186
+ // Raw commander (use carefully - does not auto-throw)
187
+ ctx.rawCommander;
188
+ },
189
+ });
190
+ ```
191
+
192
+ ## API Reference
193
+
194
+ ### launchBrowser(options)
195
+
196
+ ```javascript
197
+ const { browser, page } = await launchBrowser({
198
+ engine: 'playwright', // 'playwright' or 'puppeteer'
199
+ headless: false, // Run in headless mode
200
+ userDataDir: '~/.hh-apply/playwright-data', // Browser profile directory
201
+ slowMo: 150, // Slow down operations (ms)
202
+ verbose: false, // Enable debug logging
203
+ args: ['--no-sandbox', '--disable-setuid-sandbox'], // Custom Chrome args to append
204
+ });
205
+ ```
206
+
207
+ The `args` option allows passing custom Chrome arguments, which is useful for headless server environments (Docker, CI/CD) that require flags like `--no-sandbox`.
208
+
209
+ ### makeBrowserCommander(options)
210
+
211
+ ```javascript
212
+ const commander = makeBrowserCommander({
213
+ page, // Required: Playwright/Puppeteer page
214
+ verbose: false, // Enable debug logging
215
+ enableNetworkTracking: true, // Track HTTP requests
216
+ enableNavigationManager: true, // Enable navigation events
217
+ });
218
+ ```
219
+
220
+ ### commander.pageTrigger(config)
221
+
222
+ ```javascript
223
+ const unregister = commander.pageTrigger({
224
+ name: 'trigger-name', // For debugging
225
+ condition: (ctx) => boolean, // When to run (receives {url, commander})
226
+ action: async (ctx) => void, // What to do
227
+ priority: 0, // Higher runs first
228
+ });
229
+ ```
230
+
231
+ ### commander.goto(options)
232
+
233
+ ```javascript
234
+ await commander.goto({
235
+ url: 'https://example.com',
236
+ waitUntil: 'domcontentloaded', // Playwright/Puppeteer option
237
+ timeout: 60000,
238
+ });
239
+ ```
240
+
241
+ ### commander.clickButton(options)
242
+
243
+ ```javascript
244
+ await commander.clickButton({
245
+ selector: 'button.submit',
246
+ scrollIntoView: true,
247
+ waitForNavigation: true,
248
+ });
249
+ ```
250
+
251
+ ### commander.fillTextArea(options)
252
+
253
+ ```javascript
254
+ await commander.fillTextArea({
255
+ selector: 'textarea.message',
256
+ text: 'Hello world',
257
+ checkEmpty: true,
258
+ });
259
+ ```
260
+
261
+ ### commander.destroy()
262
+
263
+ ```javascript
264
+ await commander.destroy(); // Stop actions, cleanup
265
+ ```
266
+
267
+ ## Best Practices
268
+
269
+ ### 1. Use ctx.forEach for Loops
270
+
271
+ ```javascript
272
+ // BAD: Won't stop on navigation
273
+ for (const item of items) {
274
+ await ctx.commander.click({ selector: item });
275
+ }
276
+
277
+ // GOOD: Stops immediately on navigation
278
+ await ctx.forEach(items, async (item) => {
279
+ await ctx.commander.click({ selector: item });
280
+ });
281
+ ```
282
+
283
+ ### 2. Use ctx.checkStopped for Complex Logic
284
+
285
+ ```javascript
286
+ action: async (ctx) => {
287
+ while (hasMorePages) {
288
+ ctx.checkStopped(); // Throws if navigation detected
289
+
290
+ await processPage(ctx);
291
+ hasMorePages = await ctx.commander.isVisible({ selector: '.next' });
292
+ }
293
+ };
294
+ ```
295
+
296
+ ### 3. Register Cleanup for Resources
297
+
298
+ ```javascript
299
+ action: async (ctx) => {
300
+ const intervalId = setInterval(updateStatus, 1000);
301
+
302
+ ctx.onCleanup(() => {
303
+ clearInterval(intervalId);
304
+ console.log('Interval cleared');
305
+ });
306
+
307
+ // ... rest of action
308
+ };
309
+ ```
310
+
311
+ ### 4. Use ctx.abortSignal with Fetch
312
+
313
+ ```javascript
314
+ action: async (ctx) => {
315
+ const response = await fetch(url, {
316
+ signal: ctx.abortSignal, // Cancels on navigation
317
+ });
318
+ };
319
+ ```
320
+
321
+ ## Debugging
322
+
323
+ Enable verbose mode for detailed logs:
324
+
325
+ ```javascript
326
+ const commander = makeBrowserCommander({ page, verbose: true });
327
+ ```
328
+
329
+ ## Architecture
330
+
331
+ See [src/ARCHITECTURE.md](src/ARCHITECTURE.md) for detailed architecture documentation.
332
+
333
+ ## License
334
+
335
+ [UNLICENSE](../LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -162,6 +162,25 @@ export async function normalizeSelector(options = {}) {
162
162
  throw new Error('selector is required in options');
163
163
  }
164
164
 
165
+ // DEFENSIVE: Validate selector type - must be string or Puppeteer text selector object
166
+ // Arrays and other invalid types should be rejected to prevent downstream querySelectorAll errors
167
+ if (Array.isArray(selector)) {
168
+ console.warn(
169
+ `normalizeSelector received invalid selector type: array. Expected string or text selector object.`
170
+ );
171
+ return null;
172
+ }
173
+
174
+ if (
175
+ typeof selector !== 'string' &&
176
+ (typeof selector !== 'object' || !selector._isPuppeteerTextSelector)
177
+ ) {
178
+ console.warn(
179
+ `normalizeSelector received invalid selector type: ${typeof selector}. Expected string or text selector object.`
180
+ );
181
+ return null;
182
+ }
183
+
165
184
  // Handle Playwright text selectors (strings containing :has-text or :text-is)
166
185
  // These are valid for Playwright's locator API but NOT for document.querySelectorAll
167
186
  if (
@@ -267,6 +286,8 @@ export async function normalizeSelector(options = {}) {
267
286
  }
268
287
  }
269
288
 
289
+ // This line should be unreachable after validation, but kept as a safeguard
290
+ // istanbul ignore next
270
291
  return selector;
271
292
  }
272
293
 
@@ -207,11 +207,41 @@ describe('selectors', () => {
207
207
  assert.strictEqual(result, 'button');
208
208
  });
209
209
 
210
- it('should return non-text-selector object unchanged', async () => {
210
+ it('should return null for invalid object selector', async () => {
211
211
  const page = createMockPlaywrightPage();
212
212
  const obj = { someKey: 'value' };
213
213
  const result = await normalizeSelector({ page, selector: obj });
214
- assert.strictEqual(result, obj);
214
+ assert.strictEqual(result, null);
215
+ });
216
+
217
+ it('should return null for array selector', async () => {
218
+ const page = createMockPlaywrightPage();
219
+ const arr = ['[data-qa="test"]', []];
220
+ const result = await normalizeSelector({ page, selector: arr });
221
+ assert.strictEqual(result, null);
222
+ });
223
+
224
+ it('should return null for number selector', async () => {
225
+ const page = createMockPlaywrightPage();
226
+ const result = await normalizeSelector({ page, selector: 123 });
227
+ assert.strictEqual(result, null);
228
+ });
229
+
230
+ it('should accept valid Puppeteer text selector object', async () => {
231
+ const page = createMockPuppeteerPage();
232
+ page.evaluate = async () => '[data-qa="test"]';
233
+ const textSelector = {
234
+ _isPuppeteerTextSelector: true,
235
+ baseSelector: 'button',
236
+ text: 'Click me',
237
+ exact: false,
238
+ };
239
+ const result = await normalizeSelector({
240
+ page,
241
+ engine: 'puppeteer',
242
+ selector: textSelector,
243
+ });
244
+ assert.strictEqual(result, '[data-qa="test"]');
215
245
  });
216
246
  });
217
247