browser-commander 0.8.1 → 0.9.1

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1
4
+
5
+ ### Patch Changes
6
+
7
+ - cbd4fe1: Remove CI warning noise from workflow actions, release logging, and lint output.
8
+
9
+ ## 0.9.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 21c63ac: Add the `browser-commander/tests` export with `test-anywhere`-based browser
14
+ test helpers for Playwright and Puppeteer, duration-aware ordering, balanced
15
+ shard planning, retries, timeouts, and failure artifacts.
16
+
3
17
  ## 0.8.1
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -87,6 +87,53 @@ await commander.destroy();
87
87
  await browser.close();
88
88
  ```
89
89
 
90
+ ## Browser Commander Tests
91
+
92
+ `browser-commander/tests` adds browser fixtures and scheduling helpers on top of
93
+ [`test-anywhere`](https://github.com/link-foundation/test-anywhere). It keeps
94
+ tests portable across Node.js, Bun, and Deno while running the same browser
95
+ scenario against Playwright and Puppeteer.
96
+
97
+ ```javascript
98
+ import { assert, browserTest } from 'browser-commander/tests';
99
+
100
+ browserTest(
101
+ 'loads example.com',
102
+ async ({ commander, engine }) => {
103
+ await commander.goto({
104
+ url: 'https://example.com',
105
+ waitForNetworkIdle: false,
106
+ });
107
+
108
+ const heading = await commander.textContent({ selector: 'h1' });
109
+ assert.ok(heading.includes('Example Domain'), `${engine} should work`);
110
+ },
111
+ {
112
+ engines: ['playwright', 'puppeteer'],
113
+ launchOptions: { headless: true },
114
+ retries: 1,
115
+ timeoutMs: 60000,
116
+ }
117
+ );
118
+ ```
119
+
120
+ The test helpers provide:
121
+
122
+ - `browserTest()` and `defineBrowserTests()` for Playwright/Puppeteer matrices.
123
+ - Automatic fixture cleanup with `commander.destroy()` and `browser.close()`.
124
+ - `retries`, per-test `timeoutMs`, and failure artifacts under
125
+ `test-results/browser-commander` by default.
126
+ - Historical duration tracking in `tests/.browser-commander-test-timings.json`
127
+ when a `tests` directory exists.
128
+ - Longest-first ordering and balanced shard planning. Set
129
+ `BROWSER_COMMANDER_TEST_SHARD=1/3` to select a shard.
130
+ - Re-exported `test-anywhere` APIs such as `test`, `describe`, `it`, `assert`,
131
+ `expect`, and lifecycle hooks.
132
+
133
+ See
134
+ [`examples/browser-commander-tests.example.js`](examples/browser-commander-tests.example.js)
135
+ for a runnable example.
136
+
90
137
  ## URL Condition Helpers
91
138
 
92
139
  The `makeUrlCondition` helper makes it easy to create URL matching conditions:
package/eslint.config.js CHANGED
@@ -88,13 +88,13 @@ export default [
88
88
  // Comments and documentation
89
89
  'spaced-comment': ['error', 'always', { markers: ['/'] }],
90
90
 
91
- // Complexity rules - reasonable thresholds for maintainability
92
- complexity: ['warn', 15], // Cyclomatic complexity - allow more complex logic than strict 8
91
+ // Complexity rules - tuned for browser-automation orchestration code
92
+ complexity: ['warn', 30], // Cyclomatic complexity guardrail for unusually branchy functions
93
93
  'max-depth': ['warn', 5], // Maximum nesting depth - slightly more lenient than strict 4
94
94
  'max-lines-per-function': [
95
95
  'warn',
96
96
  {
97
- max: 150, // More reasonable than strict 50 lines per function
97
+ max: 300, // Allows orchestration functions while still catching very large functions
98
98
  skipBlankLines: true,
99
99
  skipComments: true,
100
100
  },
@@ -0,0 +1,30 @@
1
+ import { assert, browserTest } from '../src/tests/index.js';
2
+
3
+ const engines = process.env.BROWSER_COMMANDER_TEST_ENGINE
4
+ ? process.env.BROWSER_COMMANDER_TEST_ENGINE.split(',')
5
+ : undefined;
6
+
7
+ browserTest(
8
+ 'loads example.com',
9
+ async ({ commander, engine, testInfo }) => {
10
+ await commander.goto({
11
+ url: process.env.TEST_URL || 'https://example.com',
12
+ waitForNetworkIdle: false,
13
+ });
14
+
15
+ const heading = await commander.textContent({ selector: 'h1' });
16
+ assert.ok(
17
+ heading.includes('Example Domain'),
18
+ `${testInfo.name} should read the heading with ${engine}`
19
+ );
20
+ },
21
+ {
22
+ engines,
23
+ launchOptions: {
24
+ headless: process.env.HEADLESS !== 'false',
25
+ },
26
+ retries: process.env.CI ? 1 : 0,
27
+ timeoutMs: 60000,
28
+ artifactsDir: 'test-results/browser-commander-example',
29
+ }
30
+ );
package/package.json CHANGED
@@ -1,19 +1,20 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
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",
7
7
  "exports": {
8
- ".": "./src/index.js"
8
+ ".": "./src/index.js",
9
+ "./tests": "./src/tests/index.js"
9
10
  },
10
11
  "scripts": {
11
- "test": "node --test --test-reporter spec tests/unit/",
12
- "test:unit": "node --test --test-reporter spec tests/unit/",
13
- "test:coverage": "node --test --experimental-test-coverage tests/unit/",
14
- "test:e2e": "RUN_E2E=true node --test tests/e2e/",
15
- "test:e2e:playwright": "RUN_E2E=true node --test tests/e2e/playwright.e2e.test.js",
16
- "test:e2e:puppeteer": "RUN_E2E=true node --test tests/e2e/puppeteer.e2e.test.js",
12
+ "test": "node scripts/run-tests.mjs --reporter spec tests/unit",
13
+ "test:unit": "node scripts/run-tests.mjs --reporter spec tests/unit",
14
+ "test:coverage": "node scripts/run-tests.mjs --coverage tests/unit",
15
+ "test:e2e": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e",
16
+ "test:e2e:playwright": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/playwright.e2e.test.js",
17
+ "test:e2e:puppeteer": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/puppeteer.e2e.test.js",
17
18
  "test:all": "npm run test:unit && npm run test:e2e",
18
19
  "lint": "eslint .",
19
20
  "lint:fix": "eslint . --fix",
@@ -62,7 +63,8 @@
62
63
  }
63
64
  },
64
65
  "dependencies": {
65
- "log-lazy": "^1.0.4"
66
+ "log-lazy": "^1.0.4",
67
+ "test-anywhere": "^0.9.1"
66
68
  },
67
69
  "devDependencies": {
68
70
  "@changesets/cli": "^2.29.7",
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ export function removeDeprecatedAlwaysAuth(configPath) {
7
+ if (!configPath || !existsSync(configPath)) {
8
+ return false;
9
+ }
10
+
11
+ const config = readFileSync(configPath, 'utf8');
12
+ const lines = config.split(/\r?\n/);
13
+ const filteredLines = lines.filter(
14
+ (line) => !/^\s*always-auth\s*=/.test(line)
15
+ );
16
+
17
+ if (filteredLines.length === lines.length) {
18
+ return false;
19
+ }
20
+
21
+ writeFileSync(configPath, filteredLines.join('\n'));
22
+ return true;
23
+ }
24
+
25
+ function main() {
26
+ const removed = removeDeprecatedAlwaysAuth(process.env.NPM_CONFIG_USERCONFIG);
27
+ if (removed) {
28
+ console.log('Removed deprecated always-auth from npm user config.');
29
+ } else {
30
+ console.log('No deprecated npm always-auth config found.');
31
+ }
32
+ }
33
+
34
+ if (
35
+ process.argv[1] &&
36
+ import.meta.url === pathToFileURL(process.argv[1]).href
37
+ ) {
38
+ main();
39
+ }
@@ -0,0 +1,31 @@
1
+ export function formatRegistryPackagePath(packageName) {
2
+ return encodeURIComponent(packageName);
3
+ }
4
+
5
+ export async function isVersionPublished(
6
+ packageName,
7
+ version,
8
+ fetchFn = fetch
9
+ ) {
10
+ const packagePath = formatRegistryPackagePath(packageName);
11
+ const versionPath = encodeURIComponent(version);
12
+ const response = await fetchFn(
13
+ `https://registry.npmjs.org/${packagePath}/${versionPath}`,
14
+ {
15
+ headers: { accept: 'application/json' },
16
+ }
17
+ );
18
+
19
+ if (response.status === 404) {
20
+ return false;
21
+ }
22
+
23
+ if (!response.ok) {
24
+ throw new Error(
25
+ `Failed to check npm registry for ${packageName}@${version}: ${response.status} ${response.statusText}`
26
+ );
27
+ }
28
+
29
+ const metadata = await response.json();
30
+ return metadata?.version === version;
31
+ }
@@ -13,6 +13,8 @@
13
13
 
14
14
  import { readFileSync, appendFileSync } from 'fs';
15
15
 
16
+ import { isVersionPublished } from './npm-registry.mjs';
17
+
16
18
  const PACKAGE_NAME = 'browser-commander';
17
19
 
18
20
  // Load use-m dynamically
@@ -74,26 +76,23 @@ async function main() {
74
76
  console.log(
75
77
  `Checking if version ${currentVersion} is already published...`
76
78
  );
77
- const checkResult =
78
- await $`npm view "${PACKAGE_NAME}@${currentVersion}" version`.run({
79
- capture: true,
80
- });
81
-
82
- // command-stream returns { code: 0 } on success, { code: 1 } on failure (e.g., E404)
83
- // Exit code 0 means version exists, non-zero means version not found
84
- if (checkResult.code === 0) {
79
+ const versionIsPublished = await isVersionPublished(
80
+ PACKAGE_NAME,
81
+ currentVersion
82
+ );
83
+
84
+ if (versionIsPublished) {
85
85
  console.log(`Version ${currentVersion} is already published to npm`);
86
86
  setOutput('published', 'true');
87
87
  setOutput('published_version', currentVersion);
88
88
  setOutput('already_published', 'true');
89
89
  return;
90
- } else {
91
- // Version not found on npm (E404), proceed with publish
92
- console.log(
93
- `Version ${currentVersion} not found on npm, proceeding with publish...`
94
- );
95
90
  }
96
91
 
92
+ console.log(
93
+ `Version ${currentVersion} not found on npm, proceeding with publish...`
94
+ );
95
+
97
96
  // Publish to npm using OIDC trusted publishing with retry logic
98
97
  for (let i = 1; i <= MAX_RETRIES; i++) {
99
98
  console.log(`Publish attempt ${i} of ${MAX_RETRIES}...`);
@@ -0,0 +1,192 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { resolve, relative, sep } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ const TEST_FILE_PATTERN = /\.test\.[cm]?js$/u;
7
+
8
+ function readOptionValue(argv, index, optionName) {
9
+ const value = argv[index + 1];
10
+
11
+ if (!value || value.startsWith('--')) {
12
+ throw new Error(`${optionName} requires a value`);
13
+ }
14
+
15
+ return value;
16
+ }
17
+
18
+ function parseEnvAssignment(assignment) {
19
+ const separatorIndex = assignment.indexOf('=');
20
+
21
+ if (separatorIndex <= 0) {
22
+ throw new Error(`Invalid environment override: ${assignment}`);
23
+ }
24
+
25
+ return {
26
+ name: assignment.slice(0, separatorIndex),
27
+ value: assignment.slice(separatorIndex + 1),
28
+ };
29
+ }
30
+
31
+ export function parseTestRunnerArgs(argv) {
32
+ const options = {
33
+ coverage: false,
34
+ env: {},
35
+ reporter: null,
36
+ targets: [],
37
+ };
38
+
39
+ for (let index = 0; index < argv.length; index += 1) {
40
+ const arg = argv[index];
41
+
42
+ if (arg === '--coverage') {
43
+ options.coverage = true;
44
+ continue;
45
+ }
46
+
47
+ if (arg === '--reporter') {
48
+ options.reporter = readOptionValue(argv, index, '--reporter');
49
+ index += 1;
50
+ continue;
51
+ }
52
+
53
+ if (arg === '--env') {
54
+ const { name, value } = parseEnvAssignment(
55
+ readOptionValue(argv, index, '--env')
56
+ );
57
+ options.env[name] = value;
58
+ index += 1;
59
+ continue;
60
+ }
61
+
62
+ if (arg.startsWith('--env=')) {
63
+ const { name, value } = parseEnvAssignment(arg.slice('--env='.length));
64
+ options.env[name] = value;
65
+ continue;
66
+ }
67
+
68
+ options.targets.push(arg);
69
+ }
70
+
71
+ if (options.targets.length === 0) {
72
+ throw new Error('At least one test file or directory is required');
73
+ }
74
+
75
+ return options;
76
+ }
77
+
78
+ function normalizeForSort(filePath) {
79
+ return filePath.split(sep).join('/');
80
+ }
81
+
82
+ function isTestFile(filePath) {
83
+ return TEST_FILE_PATTERN.test(filePath);
84
+ }
85
+
86
+ function listFilesRecursively(directory) {
87
+ const entries = readdirSync(directory, { withFileTypes: true });
88
+ const files = [];
89
+
90
+ for (const entry of entries) {
91
+ const entryPath = resolve(directory, entry.name);
92
+
93
+ if (entry.isDirectory()) {
94
+ files.push(...listFilesRecursively(entryPath));
95
+ continue;
96
+ }
97
+
98
+ if (entry.isFile() && isTestFile(entryPath)) {
99
+ files.push(entryPath);
100
+ }
101
+ }
102
+
103
+ return files;
104
+ }
105
+
106
+ export function resolveTestFiles(targets, cwd = process.cwd()) {
107
+ const files = [];
108
+
109
+ for (const target of targets) {
110
+ const absoluteTarget = resolve(cwd, target);
111
+
112
+ if (!existsSync(absoluteTarget)) {
113
+ throw new Error(`Test target does not exist: ${target}`);
114
+ }
115
+
116
+ const targetStats = statSync(absoluteTarget);
117
+
118
+ if (targetStats.isDirectory()) {
119
+ files.push(...listFilesRecursively(absoluteTarget));
120
+ continue;
121
+ }
122
+
123
+ if (targetStats.isFile() && isTestFile(absoluteTarget)) {
124
+ files.push(absoluteTarget);
125
+ }
126
+ }
127
+
128
+ const uniqueFiles = [...new Set(files)];
129
+ uniqueFiles.sort((left, right) =>
130
+ normalizeForSort(left).localeCompare(normalizeForSort(right))
131
+ );
132
+
133
+ if (uniqueFiles.length === 0) {
134
+ throw new Error(`No test files found in: ${targets.join(', ')}`);
135
+ }
136
+
137
+ return uniqueFiles.map((file) => relative(cwd, file));
138
+ }
139
+
140
+ export function buildNodeTestArgs(options, testFiles) {
141
+ const args = ['--test'];
142
+
143
+ if (options.coverage) {
144
+ args.push('--experimental-test-coverage');
145
+ }
146
+
147
+ if (options.reporter) {
148
+ args.push('--test-reporter', options.reporter);
149
+ }
150
+
151
+ args.push(...testFiles);
152
+ return args;
153
+ }
154
+
155
+ function runNodeTests(options, testFiles) {
156
+ const result = spawnSync(
157
+ process.execPath,
158
+ buildNodeTestArgs(options, testFiles),
159
+ {
160
+ env: { ...process.env, ...options.env },
161
+ stdio: 'inherit',
162
+ }
163
+ );
164
+
165
+ if (result.error) {
166
+ throw result.error;
167
+ }
168
+
169
+ if (result.signal) {
170
+ throw new Error(`Node test runner terminated by signal ${result.signal}`);
171
+ }
172
+
173
+ return result.status ?? 1;
174
+ }
175
+
176
+ export function main(argv = process.argv.slice(2)) {
177
+ const options = parseTestRunnerArgs(argv);
178
+ const testFiles = resolveTestFiles(options.targets);
179
+ return runNodeTests(options, testFiles);
180
+ }
181
+
182
+ if (
183
+ process.argv[1] &&
184
+ import.meta.url === pathToFileURL(process.argv[1]).href
185
+ ) {
186
+ try {
187
+ process.exitCode = main();
188
+ } catch (error) {
189
+ console.error(error.message);
190
+ process.exitCode = 1;
191
+ }
192
+ }
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Update npm for OIDC trusted publishing
5
5
  * npm trusted publishing requires npm >= 11.5.1
6
- * Node.js 20.x ships with npm 10.x, so we need to update
6
+ * Keep the runner npm version current enough for OIDC publishing
7
7
  *
8
8
  * Uses link-foundation libraries:
9
9
  * - use-m: Dynamic package loading without package.json dependencies
package/src/bindings.js CHANGED
@@ -283,7 +283,9 @@ export function createBoundFunctions(options = {}) {
283
283
  // Main API functions
284
284
  wait: waitBound,
285
285
  emulateMedia: emulateMediaBound,
286
+ fill: fillTextAreaWrapped,
286
287
  fillTextArea: fillTextAreaWrapped,
288
+ click: clickButtonWrapped,
287
289
  clickButton: clickButtonWrapped,
288
290
  evaluate: evaluateBound,
289
291
  safeEvaluate: safeEvaluateBound,
@@ -25,7 +25,7 @@ export async function defaultNavigationVerification(options = {}) {
25
25
  const { page, expectedUrl, startUrl } = options;
26
26
 
27
27
  try {
28
- const actualUrl = page.url();
28
+ const actualUrl = await Promise.resolve(page.url());
29
29
 
30
30
  // If expected URL is provided, verify it matches
31
31
  if (expectedUrl) {
@@ -232,7 +232,7 @@ export async function goto(options = {}) {
232
232
  waitUntil = 'domcontentloaded',
233
233
  waitForStableUrlBefore = true,
234
234
  waitForStableUrlAfter = true,
235
- waitForNetworkIdle = true,
235
+ waitForNetworkIdle: _waitForNetworkIdle = true,
236
236
  stableChecks = 3,
237
237
  checkInterval = 1000,
238
238
  timeout = 240000,
@@ -395,7 +395,6 @@ export async function waitForNavigation(options = {}) {
395
395
  */
396
396
  export async function waitForPageReady(options = {}) {
397
397
  const {
398
- page,
399
398
  navigationManager,
400
399
  networkTracker,
401
400
  log,
@@ -19,7 +19,7 @@
19
19
  * @returns {Object} - DialogManager API
20
20
  */
21
21
  export function createDialogManager(options = {}) {
22
- const { page, engine, log } = options;
22
+ const { page, log } = options;
23
23
 
24
24
  if (!page) {
25
25
  throw new Error('page is required in options');