browser-commander 0.3.0 → 0.5.2

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,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 87224ee: Fix package.json path in version-and-commit.mjs for monorepo structure
8
+
9
+ The git show command uses repository root paths, not the workflow's working directory. Since this is a monorepo with js/ and rust/ folders, the path must be js/package.json instead of just package.json.
10
+
11
+ This was causing "Unexpected end of JSON input" errors when the script tried to read package.json from the repository root (which doesn't exist) instead of js/package.json.
12
+
13
+ ## 0.5.1
14
+
15
+ ### Patch Changes
16
+
17
+ - 2b22f43: Fix PlaywrightAdapter.evaluateOnPage() to spread multiple arguments correctly
18
+
19
+ When using `evaluateOnPage()` with multiple arguments, the arguments are now properly spread to the function in the browser context, matching Puppeteer's behavior.
20
+
21
+ Previously, the function would receive the entire array as its first parameter instead of spread arguments, causing issues like invalid selectors when passing selector + array combinations.
22
+
23
+ ## 0.5.0
24
+
25
+ ### Minor Changes
26
+
27
+ - adfccde: Add Rust implementation with parallel JavaScript codebase reorganization
28
+
29
+ This introduces a complete Rust translation of the browser-commander library alongside the existing JavaScript implementation. The codebase is now organized into two parallel structures:
30
+ - `js/` - JavaScript implementation (all existing functionality preserved)
31
+ - `rust/` - New Rust implementation with the same modular architecture
32
+
33
+ Key features of the Rust implementation:
34
+ - Unified API across multiple browser engines (chromiumoxide, fantoccini)
35
+ - Core types and traits (constants, engine adapter, logger)
36
+ - Element operations (selectors, visibility, content)
37
+ - User interactions (click, scroll, fill)
38
+ - Browser management (launcher, navigation)
39
+ - General utilities (URL handling, wait operations)
40
+ - High-level DRY utilities
41
+ - Comprehensive test coverage with 106 tests
42
+
43
+ ## 0.4.0
44
+
45
+ ### Minor Changes
46
+
47
+ - 5af2479: Add support for custom Chrome args in launchBrowser
48
+
49
+ Adds a new `args` option to the `launchBrowser` function that allows passing custom Chrome arguments to append to the default `CHROME_ARGS`. This is useful for headless server environments (Docker, CI/CD) that require additional flags like `--no-sandbox`, `--disable-setuid-sandbox`, or `--disable-dev-shm-usage`.
50
+
51
+ Usage example:
52
+
53
+ ```javascript
54
+ import { launchBrowser } from 'browser-commander';
55
+
56
+ const { browser, page } = await launchBrowser({
57
+ engine: 'puppeteer',
58
+ headless: true,
59
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
60
+ });
61
+ ```
62
+
63
+ Fixes #11
64
+
3
65
  ## 0.3.0
4
66
 
5
67
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.3.0",
3
+ "version": "0.5.2",
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",
@@ -15,7 +15,10 @@ import { readFileSync, readdirSync, existsSync } from 'fs';
15
15
  import { join } from 'path';
16
16
 
17
17
  const PACKAGE_NAME = 'browser-commander';
18
+ // When running from js/ directory, the changeset dir is local
18
19
  const CHANGESET_DIR = '.changeset';
20
+ // But git diff output uses repository-root relative paths
21
+ const GIT_CHANGESET_PATH = 'js/.changeset';
19
22
 
20
23
  /**
21
24
  * Ensure a git commit is available locally, fetching if necessary
@@ -46,13 +49,19 @@ function parseAddedChangesets(diffOutput) {
46
49
  continue;
47
50
  }
48
51
  const [status, filePath] = line.split('\t');
52
+ // Check both local (.changeset/) and repo-root (js/.changeset/) paths
53
+ const isChangeset =
54
+ filePath.startsWith(`${CHANGESET_DIR}/`) ||
55
+ filePath.startsWith(`${GIT_CHANGESET_PATH}/`);
49
56
  if (
50
57
  status === 'A' &&
51
- filePath.startsWith(`${CHANGESET_DIR}/`) &&
58
+ isChangeset &&
52
59
  filePath.endsWith('.md') &&
53
60
  !filePath.endsWith('README.md')
54
61
  ) {
55
- addedChangesets.push(filePath.replace(`${CHANGESET_DIR}/`, ''));
62
+ // Extract just the filename
63
+ const fileName = filePath.split('/').pop();
64
+ addedChangesets.push(fileName);
56
65
  }
57
66
  }
58
67
  return addedChangesets;
@@ -126,7 +126,9 @@ function countChangesets() {
126
126
  */
127
127
  async function getVersion(source = 'local') {
128
128
  if (source === 'remote') {
129
- const result = await $`git show origin/main:package.json`.run({
129
+ // Use js/package.json for monorepo structure
130
+ // The workflow runs with working-directory: js, but git show uses repo root paths
131
+ const result = await $`git show origin/main:js/package.json`.run({
130
132
  capture: true,
131
133
  });
132
134
  return JSON.parse(result.stdout).version;
@@ -11,6 +11,7 @@ import { disableTranslateInPreferences } from '../core/preferences.js';
11
11
  * @param {boolean} options.headless - Run in headless mode (default: false)
12
12
  * @param {number} options.slowMo - Slow down operations by ms (default: 150 for Playwright, 0 for Puppeteer)
13
13
  * @param {boolean} options.verbose - Enable verbose logging (default: false)
14
+ * @param {string[]} options.args - Custom Chrome arguments to append to the default CHROME_ARGS
14
15
  * @returns {Promise<Object>} - Object with browser and page
15
16
  */
16
17
  export async function launchBrowser(options = {}) {
@@ -20,8 +21,12 @@ export async function launchBrowser(options = {}) {
20
21
  headless = false,
21
22
  slowMo = engine === 'playwright' ? 150 : 0,
22
23
  verbose = false,
24
+ args = [],
23
25
  } = options;
24
26
 
27
+ // Combine default CHROME_ARGS with custom args
28
+ const chromeArgs = [...CHROME_ARGS, ...args];
29
+
25
30
  if (!['playwright', 'puppeteer'].includes(engine)) {
26
31
  throw new Error(
27
32
  `Invalid engine: ${engine}. Expected 'playwright' or 'puppeteer'`
@@ -50,7 +55,7 @@ export async function launchBrowser(options = {}) {
50
55
  slowMo,
51
56
  chromiumSandbox: true,
52
57
  viewport: null,
53
- args: CHROME_ARGS,
58
+ args: chromeArgs,
54
59
  ignoreDefaultArgs: ['--enable-automation'],
55
60
  });
56
61
  page = browser.pages()[0];
@@ -59,7 +64,7 @@ export async function launchBrowser(options = {}) {
59
64
  browser = await puppeteer.default.launch({
60
65
  headless,
61
66
  defaultViewport: null,
62
- args: ['--start-maximized', ...CHROME_ARGS],
67
+ args: ['--start-maximized', ...chromeArgs],
63
68
  userDataDir,
64
69
  });
65
70
  const pages = await browser.pages();
@@ -304,13 +304,25 @@ export class PlaywrightAdapter extends EngineAdapter {
304
304
 
305
305
  async evaluateOnPage(fn, args = []) {
306
306
  // Playwright only accepts a single argument (can be array/object)
307
+ // To match Puppeteer's behavior where args are spread, we wrap the function
308
+ // and pass all args as a single array, then apply them in the browser context
307
309
  if (args.length === 0) {
308
310
  return await this.page.evaluate(fn);
309
311
  } else if (args.length === 1) {
310
312
  return await this.page.evaluate(fn, args[0]);
311
313
  } else {
312
- // Multiple args - pass as array
313
- return await this.page.evaluate(fn, args);
314
+ // Multiple args - wrap function to accept array and spread them
315
+ // This makes Playwright behave like Puppeteer's spread behavior
316
+ // We pass the function string and args array, then reconstruct and call in browser
317
+ const fnString = fn.toString();
318
+ return await this.page.evaluate(
319
+ ({ fnStr, argsArray }) => {
320
+ // Reconstruct the function in browser context and call with spread args
321
+ const reconstructedFn = new Function(`return (${fnStr})`)();
322
+ return reconstructedFn(...argsArray);
323
+ },
324
+ { fnStr: fnString, argsArray: args }
325
+ );
314
326
  }
315
327
  }
316
328
 
@@ -105,28 +105,15 @@ export function createMockPlaywrightPage(options = {}) {
105
105
  return locs.map((l) => l.evaluate(fn, ...args));
106
106
  },
107
107
  locator: locatorMock,
108
- evaluate: async (fn, ...args) => {
108
+ // Playwright's page.evaluate() only accepts a single argument (not spread)
109
+ // This is the key difference from Puppeteer
110
+ evaluate: async (fn, arg) => {
109
111
  if (evaluateResult !== null) {
110
112
  return evaluateResult;
111
113
  }
112
- // Create mock window/document context
113
- const mockContext = {
114
- innerHeight: 800,
115
- innerWidth: 1200,
116
- sessionStorage: {
117
- _data: {},
118
- getItem: (key) => mockContext.sessionStorage._data[key] || null,
119
- setItem: (key, val) => {
120
- mockContext.sessionStorage._data[key] = val;
121
- },
122
- removeItem: (key) => {
123
- delete mockContext.sessionStorage._data[key];
124
- },
125
- },
126
- querySelectorAll: () => [],
127
- };
128
114
  try {
129
- return fn(...args);
115
+ // Playwright passes exactly one argument (can be object/array)
116
+ return fn(arg);
130
117
  } catch {
131
118
  return fn;
132
119
  }
@@ -0,0 +1,126 @@
1
+ import { describe, it, mock, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ describe('launcher', () => {
5
+ describe('launchBrowser args option', () => {
6
+ let originalEnv;
7
+ let mockChromium;
8
+ let mockPuppeteer;
9
+ let launchBrowser;
10
+ let capturedPlaywrightArgs;
11
+ let capturedPuppeteerArgs;
12
+
13
+ beforeEach(async () => {
14
+ // Save original environment
15
+ originalEnv = { ...process.env };
16
+
17
+ // Reset captured args
18
+ capturedPlaywrightArgs = null;
19
+ capturedPuppeteerArgs = null;
20
+
21
+ // Create mock browser context for Playwright
22
+ const mockBrowserContext = {
23
+ pages: () => [
24
+ {
25
+ bringToFront: async () => {},
26
+ },
27
+ ],
28
+ close: async () => {},
29
+ };
30
+
31
+ // Create mock browser for Puppeteer
32
+ const mockBrowser = {
33
+ pages: async () => [
34
+ {
35
+ bringToFront: async () => {},
36
+ },
37
+ ],
38
+ close: async () => {},
39
+ };
40
+
41
+ // Mock Playwright's chromium
42
+ mockChromium = {
43
+ launchPersistentContext: async (userDataDir, options) => {
44
+ capturedPlaywrightArgs = options.args;
45
+ return mockBrowserContext;
46
+ },
47
+ };
48
+
49
+ // Mock Puppeteer
50
+ mockPuppeteer = {
51
+ default: {
52
+ launch: async (options) => {
53
+ capturedPuppeteerArgs = options.args;
54
+ return mockBrowser;
55
+ },
56
+ },
57
+ };
58
+
59
+ // Use mock.module to mock the dynamic imports
60
+ // Since we can't easily mock dynamic imports in Node.js test runner,
61
+ // we'll test the CHROME_ARGS constant usage directly
62
+ });
63
+
64
+ afterEach(() => {
65
+ // Restore original environment
66
+ process.env = originalEnv;
67
+ });
68
+
69
+ it('should export CHROME_ARGS constant', async () => {
70
+ const { CHROME_ARGS } = await import('../../../src/core/constants.js');
71
+ assert.ok(Array.isArray(CHROME_ARGS));
72
+ assert.ok(CHROME_ARGS.length > 0);
73
+ });
74
+
75
+ it('should include expected default Chrome args', async () => {
76
+ const { CHROME_ARGS } = await import('../../../src/core/constants.js');
77
+ assert.ok(CHROME_ARGS.includes('--disable-session-crashed-bubble'));
78
+ assert.ok(CHROME_ARGS.includes('--no-first-run'));
79
+ assert.ok(CHROME_ARGS.includes('--no-default-browser-check'));
80
+ });
81
+
82
+ it('launchBrowser function should accept args option', async () => {
83
+ // We can verify the function signature by checking that it
84
+ // destructures args from options without throwing
85
+ const { launchBrowser } =
86
+ await import('../../../src/browser/launcher.js');
87
+ assert.ok(typeof launchBrowser === 'function');
88
+
89
+ // The function should accept options object with args array
90
+ // We can't fully test the launch without actual browsers,
91
+ // but we can verify the function exists and is callable
92
+ });
93
+
94
+ it('launchBrowser should throw for invalid engine', async () => {
95
+ const { launchBrowser } =
96
+ await import('../../../src/browser/launcher.js');
97
+
98
+ await assert.rejects(
99
+ () => launchBrowser({ engine: 'invalid-engine' }),
100
+ (error) => {
101
+ assert.ok(error.message.includes('Invalid engine'));
102
+ assert.ok(error.message.includes('invalid-engine'));
103
+ return true;
104
+ }
105
+ );
106
+ });
107
+
108
+ it('launchBrowser should accept args in options', async () => {
109
+ const { launchBrowser } =
110
+ await import('../../../src/browser/launcher.js');
111
+
112
+ // Verify the function signature accepts args
113
+ // This test validates that the args parameter is correctly destructured
114
+ // by attempting to call with an invalid engine (which fails before browser launch)
115
+ // but proves args is accepted without error during options parsing
116
+ const customArgs = ['--no-sandbox', '--disable-setuid-sandbox'];
117
+
118
+ await assert.rejects(
119
+ () => launchBrowser({ engine: 'invalid', args: customArgs }),
120
+ /Invalid engine/
121
+ );
122
+
123
+ // If we got here, the args option was accepted without error
124
+ });
125
+ });
126
+ });
@@ -78,6 +78,53 @@ describe('engine-adapter', () => {
78
78
  const count = await adapter.count('button');
79
79
  assert.strictEqual(count, 5);
80
80
  });
81
+
82
+ describe('evaluateOnPage', () => {
83
+ it('should handle zero arguments', async () => {
84
+ page = createMockPlaywrightPage();
85
+ adapter = new PlaywrightAdapter(page);
86
+ const result = await adapter.evaluateOnPage(() => 42);
87
+ assert.strictEqual(result, 42);
88
+ });
89
+
90
+ it('should handle single argument', async () => {
91
+ page = createMockPlaywrightPage();
92
+ adapter = new PlaywrightAdapter(page);
93
+ const result = await adapter.evaluateOnPage((x) => x * 2, [5]);
94
+ assert.strictEqual(result, 10);
95
+ });
96
+
97
+ it('should handle multiple arguments by spreading them', async () => {
98
+ page = createMockPlaywrightPage();
99
+ adapter = new PlaywrightAdapter(page);
100
+ // This is the bug case - multiple args should be spread, not passed as array
101
+ const result = await adapter.evaluateOnPage((a, b) => a + b, [3, 7]);
102
+ assert.strictEqual(result, 10);
103
+ });
104
+
105
+ it('should handle selector + array arguments (real-world bug case)', async () => {
106
+ page = createMockPlaywrightPage();
107
+ adapter = new PlaywrightAdapter(page);
108
+ // This reproduces the original bug: selector and processedIds
109
+ const result = await adapter.evaluateOnPage(
110
+ (selector, processedIds) =>
111
+ `Selector: ${selector}, Count: ${processedIds.length}`,
112
+ ['[data-qa="test"]', ['id1', 'id2']]
113
+ );
114
+ assert.ok(result.includes('Selector: [data-qa="test"]'));
115
+ assert.ok(result.includes('Count: 2'));
116
+ });
117
+
118
+ it('should handle multiple arguments of different types', async () => {
119
+ page = createMockPlaywrightPage();
120
+ adapter = new PlaywrightAdapter(page);
121
+ const result = await adapter.evaluateOnPage(
122
+ (str, num, obj) => `${str}-${num}-${obj.key}`,
123
+ ['hello', 42, { key: 'world' }]
124
+ );
125
+ assert.strictEqual(result, 'hello-42-world');
126
+ });
127
+ });
81
128
  });
82
129
 
83
130
  describe('PuppeteerAdapter', () => {
@@ -130,6 +177,42 @@ describe('engine-adapter', () => {
130
177
  const count = await adapter.count('button');
131
178
  assert.strictEqual(count, 5);
132
179
  });
180
+
181
+ describe('evaluateOnPage', () => {
182
+ it('should handle zero arguments', async () => {
183
+ page = createMockPuppeteerPage();
184
+ adapter = new PuppeteerAdapter(page);
185
+ const result = await adapter.evaluateOnPage(() => 42);
186
+ assert.strictEqual(result, 42);
187
+ });
188
+
189
+ it('should handle single argument', async () => {
190
+ page = createMockPuppeteerPage();
191
+ adapter = new PuppeteerAdapter(page);
192
+ const result = await adapter.evaluateOnPage((x) => x * 2, [5]);
193
+ assert.strictEqual(result, 10);
194
+ });
195
+
196
+ it('should handle multiple arguments by spreading them', async () => {
197
+ page = createMockPuppeteerPage();
198
+ adapter = new PuppeteerAdapter(page);
199
+ // Puppeteer natively spreads args - ensure same behavior as Playwright fix
200
+ const result = await adapter.evaluateOnPage((a, b) => a + b, [3, 7]);
201
+ assert.strictEqual(result, 10);
202
+ });
203
+
204
+ it('should handle selector + array arguments (parity with PlaywrightAdapter)', async () => {
205
+ page = createMockPuppeteerPage();
206
+ adapter = new PuppeteerAdapter(page);
207
+ const result = await adapter.evaluateOnPage(
208
+ (selector, processedIds) =>
209
+ `Selector: ${selector}, Count: ${processedIds.length}`,
210
+ ['[data-qa="test"]', ['id1', 'id2']]
211
+ );
212
+ assert.ok(result.includes('Selector: [data-qa="test"]'));
213
+ assert.ok(result.includes('Count: 2'));
214
+ });
215
+ });
133
216
  });
134
217
 
135
218
  describe('createEngineAdapter', () => {
@@ -1,296 +0,0 @@
1
- name: Checks and release
2
-
3
- on:
4
- push:
5
- branches:
6
- - main
7
- pull_request:
8
- types: [opened, synchronize, reopened]
9
- # Manual release support - consolidated here to work with npm trusted publishing
10
- # npm only allows ONE workflow file as trusted publisher, so all publishing
11
- # must go through this workflow (release.yml)
12
- workflow_dispatch:
13
- inputs:
14
- release_mode:
15
- description: 'Manual release mode'
16
- required: true
17
- type: choice
18
- default: 'instant'
19
- options:
20
- - instant
21
- - changeset-pr
22
- bump_type:
23
- description: 'Manual release type'
24
- required: true
25
- type: choice
26
- options:
27
- - patch
28
- - minor
29
- - major
30
- description:
31
- description: 'Manual release description (optional)'
32
- required: false
33
- type: string
34
-
35
- concurrency: ${{ github.workflow }}-${{ github.ref }}
36
-
37
- jobs:
38
- # Changeset check - only runs on PRs
39
- changeset-check:
40
- name: Check for Changesets
41
- runs-on: ubuntu-latest
42
- if: github.event_name == 'pull_request'
43
- steps:
44
- - uses: actions/checkout@v4
45
- with:
46
- fetch-depth: 0
47
-
48
- - name: Setup Node.js
49
- uses: actions/setup-node@v4
50
- with:
51
- node-version: '20.x'
52
-
53
- - name: Install dependencies
54
- run: npm install
55
-
56
- - name: Check for changesets
57
- env:
58
- # Pass PR context to the validation script
59
- GITHUB_BASE_REF: ${{ github.base_ref }}
60
- GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
61
- GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
62
- run: |
63
- # Skip changeset check for automated version PRs
64
- if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then
65
- echo "Skipping changeset check for automated release PR"
66
- exit 0
67
- fi
68
-
69
- # Run changeset validation script
70
- # This validates that exactly ONE changeset was ADDED by this PR
71
- # Pre-existing changesets from other merged PRs are ignored
72
- node scripts/validate-changeset.mjs
73
-
74
- # Linting and formatting - runs after changeset check on PRs, immediately on main
75
- lint:
76
- name: Lint and Format Check
77
- runs-on: ubuntu-latest
78
- needs: [changeset-check]
79
- if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success')
80
- steps:
81
- - uses: actions/checkout@v4
82
-
83
- - name: Setup Node.js
84
- uses: actions/setup-node@v4
85
- with:
86
- node-version: '20.x'
87
-
88
- - name: Install dependencies
89
- run: npm install
90
-
91
- - name: Run ESLint
92
- run: npm run lint
93
-
94
- - name: Check formatting
95
- run: npm run format:check
96
-
97
- - name: Check code duplication
98
- run: npm run check:duplication
99
-
100
- # Test job - runs on Node.js only (browser automation requires specific setup)
101
- test:
102
- name: Test (Node.js on ${{ matrix.os }})
103
- runs-on: ${{ matrix.os }}
104
- needs: [changeset-check]
105
- if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success')
106
- strategy:
107
- fail-fast: false
108
- matrix:
109
- os: [ubuntu-latest, macos-latest, windows-latest]
110
- steps:
111
- - uses: actions/checkout@v4
112
-
113
- - name: Setup Node.js
114
- uses: actions/setup-node@v4
115
- with:
116
- node-version: '20.x'
117
-
118
- - name: Install dependencies
119
- run: npm install
120
-
121
- - name: Run unit tests
122
- run: npm test
123
-
124
- # Release - only runs on main after tests pass (for push events)
125
- release:
126
- name: Release
127
- needs: [lint, test]
128
- # Use always() to ensure this job runs even if changeset-check was skipped
129
- # This is needed because lint/test jobs have a transitive dependency on changeset-check
130
- if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success'
131
- runs-on: ubuntu-latest
132
- # Permissions required for npm OIDC trusted publishing
133
- permissions:
134
- contents: write
135
- pull-requests: write
136
- id-token: write
137
- steps:
138
- - uses: actions/checkout@v4
139
- with:
140
- fetch-depth: 0
141
-
142
- - name: Setup Node.js
143
- uses: actions/setup-node@v4
144
- with:
145
- node-version: '20.x'
146
- registry-url: 'https://registry.npmjs.org'
147
-
148
- - name: Install dependencies
149
- run: npm install
150
-
151
- - name: Update npm for OIDC trusted publishing
152
- run: node scripts/setup-npm.mjs
153
-
154
- - name: Check for changesets
155
- id: check_changesets
156
- run: |
157
- # Count changeset files (excluding README.md and config.json)
158
- CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" | wc -l)
159
- echo "Found $CHANGESET_COUNT changeset file(s)"
160
- echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
161
- echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT
162
-
163
- - name: Merge multiple changesets
164
- if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1
165
- run: |
166
- echo "Multiple changesets detected, merging..."
167
- node scripts/merge-changesets.mjs
168
-
169
- - name: Version packages and commit to main
170
- if: steps.check_changesets.outputs.has_changesets == 'true'
171
- id: version
172
- run: node scripts/version-and-commit.mjs --mode changeset
173
-
174
- - name: Publish to npm
175
- # Run if version was committed OR if a previous attempt already committed (for re-runs)
176
- if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
177
- id: publish
178
- run: node scripts/publish-to-npm.mjs --should-pull
179
-
180
- - name: Create GitHub Release
181
- if: steps.publish.outputs.published == 'true'
182
- env:
183
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
184
- run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
185
-
186
- - name: Format GitHub release notes
187
- if: steps.publish.outputs.published == 'true'
188
- env:
189
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
190
- run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
191
-
192
- # Manual Instant Release - triggered via workflow_dispatch with instant mode
193
- # This job is in release.yml because npm trusted publishing
194
- # only allows one workflow file to be registered as a trusted publisher
195
- instant-release:
196
- name: Instant Release
197
- if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant'
198
- runs-on: ubuntu-latest
199
- # Permissions required for npm OIDC trusted publishing
200
- permissions:
201
- contents: write
202
- pull-requests: write
203
- id-token: write
204
- steps:
205
- - uses: actions/checkout@v4
206
- with:
207
- fetch-depth: 0
208
-
209
- - name: Setup Node.js
210
- uses: actions/setup-node@v4
211
- with:
212
- node-version: '20.x'
213
- registry-url: 'https://registry.npmjs.org'
214
-
215
- - name: Install dependencies
216
- run: npm install
217
-
218
- - name: Update npm for OIDC trusted publishing
219
- run: node scripts/setup-npm.mjs
220
-
221
- - name: Version packages and commit to main
222
- id: version
223
- run: node scripts/version-and-commit.mjs --mode instant --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
224
-
225
- - name: Publish to npm
226
- # Run if version was committed OR if a previous attempt already committed (for re-runs)
227
- if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
228
- id: publish
229
- run: node scripts/publish-to-npm.mjs
230
-
231
- - name: Create GitHub Release
232
- if: steps.publish.outputs.published == 'true'
233
- env:
234
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
235
- run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
236
-
237
- - name: Format GitHub release notes
238
- if: steps.publish.outputs.published == 'true'
239
- env:
240
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
241
- run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
242
-
243
- # Manual Changeset PR - creates a pull request with the changeset for review
244
- changeset-pr:
245
- name: Create Changeset PR
246
- if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr'
247
- runs-on: ubuntu-latest
248
- permissions:
249
- contents: write
250
- pull-requests: write
251
- steps:
252
- - uses: actions/checkout@v4
253
- with:
254
- fetch-depth: 0
255
-
256
- - name: Setup Node.js
257
- uses: actions/setup-node@v4
258
- with:
259
- node-version: '20.x'
260
-
261
- - name: Install dependencies
262
- run: npm install
263
-
264
- - name: Create changeset file
265
- run: node scripts/create-manual-changeset.mjs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
266
-
267
- - name: Format changeset with Prettier
268
- run: |
269
- # Run Prettier on the changeset file to ensure it matches project style
270
- npx prettier --write ".changeset/*.md" || true
271
-
272
- echo "Formatted changeset files"
273
-
274
- - name: Create Pull Request
275
- uses: peter-evans/create-pull-request@v7
276
- with:
277
- token: ${{ secrets.GITHUB_TOKEN }}
278
- commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release'
279
- branch: changeset-manual-release-${{ github.run_id }}
280
- delete-branch: true
281
- title: 'chore: manual ${{ github.event.inputs.bump_type }} release'
282
- body: |
283
- ## Manual Release Request
284
-
285
- This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release.
286
-
287
- ### Release Details
288
- - **Type:** ${{ github.event.inputs.bump_type }}
289
- - **Description:** ${{ github.event.inputs.description || 'Manual release' }}
290
- - **Triggered by:** @${{ github.actor }}
291
-
292
- ### Next Steps
293
- 1. Review the changeset in this PR
294
- 2. Merge this PR to main
295
- 3. The automated release workflow will create a version PR
296
- 4. Merge the version PR to publish to npm and create a GitHub release
package/LICENSE DELETED
@@ -1,24 +0,0 @@
1
- This is free and unencumbered software released into the public domain.
2
-
3
- Anyone is free to copy, modify, publish, use, compile, sell, or
4
- distribute this software, either in source code form or as a compiled
5
- binary, for any purpose, commercial or non-commercial, and by any
6
- means.
7
-
8
- In jurisdictions that recognize copyright laws, the author or authors
9
- of this software dedicate any and all copyright interest in the
10
- software to the public domain. We make this dedication for the benefit
11
- of the public at large and to the detriment of our heirs and
12
- successors. We intend this dedication to be an overt act of
13
- relinquishment in perpetuity of all present and future rights to this
14
- software under copyright law.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
- IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
- OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
- ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
- OTHER DEALINGS IN THE SOFTWARE.
23
-
24
- For more information, please refer to <https://unlicense.org>
package/README.md DELETED
@@ -1,320 +0,0 @@
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
- ## 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
- ### makeBrowserCommander(options)
195
-
196
- ```javascript
197
- const commander = makeBrowserCommander({
198
- page, // Required: Playwright/Puppeteer page
199
- verbose: false, // Enable debug logging
200
- enableNetworkTracking: true, // Track HTTP requests
201
- enableNavigationManager: true, // Enable navigation events
202
- });
203
- ```
204
-
205
- ### commander.pageTrigger(config)
206
-
207
- ```javascript
208
- const unregister = commander.pageTrigger({
209
- name: 'trigger-name', // For debugging
210
- condition: (ctx) => boolean, // When to run (receives {url, commander})
211
- action: async (ctx) => void, // What to do
212
- priority: 0, // Higher runs first
213
- });
214
- ```
215
-
216
- ### commander.goto(options)
217
-
218
- ```javascript
219
- await commander.goto({
220
- url: 'https://example.com',
221
- waitUntil: 'domcontentloaded', // Playwright/Puppeteer option
222
- timeout: 60000,
223
- });
224
- ```
225
-
226
- ### commander.clickButton(options)
227
-
228
- ```javascript
229
- await commander.clickButton({
230
- selector: 'button.submit',
231
- scrollIntoView: true,
232
- waitForNavigation: true,
233
- });
234
- ```
235
-
236
- ### commander.fillTextArea(options)
237
-
238
- ```javascript
239
- await commander.fillTextArea({
240
- selector: 'textarea.message',
241
- text: 'Hello world',
242
- checkEmpty: true,
243
- });
244
- ```
245
-
246
- ### commander.destroy()
247
-
248
- ```javascript
249
- await commander.destroy(); // Stop actions, cleanup
250
- ```
251
-
252
- ## Best Practices
253
-
254
- ### 1. Use ctx.forEach for Loops
255
-
256
- ```javascript
257
- // BAD: Won't stop on navigation
258
- for (const item of items) {
259
- await ctx.commander.click({ selector: item });
260
- }
261
-
262
- // GOOD: Stops immediately on navigation
263
- await ctx.forEach(items, async (item) => {
264
- await ctx.commander.click({ selector: item });
265
- });
266
- ```
267
-
268
- ### 2. Use ctx.checkStopped for Complex Logic
269
-
270
- ```javascript
271
- action: async (ctx) => {
272
- while (hasMorePages) {
273
- ctx.checkStopped(); // Throws if navigation detected
274
-
275
- await processPage(ctx);
276
- hasMorePages = await ctx.commander.isVisible({ selector: '.next' });
277
- }
278
- };
279
- ```
280
-
281
- ### 3. Register Cleanup for Resources
282
-
283
- ```javascript
284
- action: async (ctx) => {
285
- const intervalId = setInterval(updateStatus, 1000);
286
-
287
- ctx.onCleanup(() => {
288
- clearInterval(intervalId);
289
- console.log('Interval cleared');
290
- });
291
-
292
- // ... rest of action
293
- };
294
- ```
295
-
296
- ### 4. Use ctx.abortSignal with Fetch
297
-
298
- ```javascript
299
- action: async (ctx) => {
300
- const response = await fetch(url, {
301
- signal: ctx.abortSignal, // Cancels on navigation
302
- });
303
- };
304
- ```
305
-
306
- ## Debugging
307
-
308
- Enable verbose mode for detailed logs:
309
-
310
- ```javascript
311
- const commander = makeBrowserCommander({ page, verbose: true });
312
- ```
313
-
314
- ## Architecture
315
-
316
- See [src/ARCHITECTURE.md](src/ARCHITECTURE.md) for detailed architecture documentation.
317
-
318
- ## License
319
-
320
- [UNLICENSE](LICENSE)