browser-commander 0.8.1 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 21c63ac: Add the `browser-commander/tests` export with `test-anywhere`-based browser
8
+ test helpers for Playwright and Puppeteer, duration-aware ordering, balanced
9
+ shard planning, retries, timeouts, and failure artifacts.
10
+
3
11
  ## 0.8.1
4
12
 
5
13
  ### 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:
@@ -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,11 +1,12 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
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
12
  "test": "node --test --test-reporter spec tests/unit/",
@@ -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",
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,
package/src/index.js CHANGED
@@ -11,7 +11,10 @@
11
11
  */
12
12
 
13
13
  // Export the factory function
14
- export { makeBrowserCommander } from './factory.js';
14
+ export {
15
+ makeBrowserCommander,
16
+ makeBrowserCommander as createCommander,
17
+ } from './factory.js';
15
18
 
16
19
  // Re-export all public APIs from exports module
17
20
  export * from './exports.js';
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Browser Commander test helpers built on top of test-anywhere.
3
+ *
4
+ * The helpers keep the generic test definition layer in test-anywhere while
5
+ * adding browser fixtures, engine matrices, timing history, retries, and
6
+ * failure artifacts for browser-commander users.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { test as baseTest } from 'test-anywhere';
12
+ import { launchBrowser } from '../browser/launcher.js';
13
+ import { makeBrowserCommander } from '../factory.js';
14
+
15
+ export {
16
+ test,
17
+ it,
18
+ describe,
19
+ beforeAll,
20
+ beforeEach,
21
+ afterEach,
22
+ afterAll,
23
+ before,
24
+ after,
25
+ assert,
26
+ expect,
27
+ assertEquals,
28
+ assertNotEquals,
29
+ assertStrictEquals,
30
+ assertNotStrictEquals,
31
+ assertExists,
32
+ assertMatch,
33
+ assertArrayIncludes,
34
+ getRuntime,
35
+ setDefaultTimeout,
36
+ } from 'test-anywhere';
37
+
38
+ export const SUPPORTED_BROWSER_TEST_ENGINES = Object.freeze([
39
+ 'playwright',
40
+ 'puppeteer',
41
+ ]);
42
+
43
+ export const DEFAULT_TIMINGS_FILENAME = '.browser-commander-test-timings.json';
44
+ export const DEFAULT_ARTIFACTS_DIR = 'test-results/browser-commander';
45
+
46
+ const TIMING_DATA_VERSION = 1;
47
+
48
+ function createEmptyTimingData() {
49
+ return {
50
+ version: TIMING_DATA_VERSION,
51
+ updatedAt: null,
52
+ tests: {},
53
+ };
54
+ }
55
+
56
+ function assertObject(value, name) {
57
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
58
+ throw new Error(`${name} must be an object`);
59
+ }
60
+ }
61
+
62
+ function normalizeTestId(testLike) {
63
+ if (typeof testLike === 'string') {
64
+ return testLike;
65
+ }
66
+ if (testLike?.id) {
67
+ return testLike.id;
68
+ }
69
+ if (testLike?.name) {
70
+ return testLike.name;
71
+ }
72
+ throw new Error('Test entries must include a name or id');
73
+ }
74
+
75
+ function historicalDurationMs(testLike, timingData) {
76
+ const entry = timingData.tests?.[normalizeTestId(testLike)];
77
+ return Number.isFinite(entry?.averageMs) ? entry.averageMs : 0;
78
+ }
79
+
80
+ function formatBrowserTestName(name, engine) {
81
+ return `${name} [${engine}]`;
82
+ }
83
+
84
+ function browserTestId(scenario, engine) {
85
+ return formatBrowserTestName(scenario.id || scenario.name, engine);
86
+ }
87
+
88
+ function nowIso(now) {
89
+ return now instanceof Date ? now.toISOString() : new Date(now).toISOString();
90
+ }
91
+
92
+ function sortedTimingData(data) {
93
+ const tests = {};
94
+ for (const key of Object.keys(data.tests || {}).sort()) {
95
+ tests[key] = data.tests[key];
96
+ }
97
+ return {
98
+ version: TIMING_DATA_VERSION,
99
+ updatedAt: data.updatedAt || null,
100
+ tests,
101
+ };
102
+ }
103
+
104
+ function mergeOptions(base = {}, override = {}) {
105
+ return {
106
+ ...base,
107
+ ...override,
108
+ };
109
+ }
110
+
111
+ function normalizeScenario(scenario) {
112
+ assertObject(scenario, 'Browser test scenario');
113
+
114
+ if (!scenario.name) {
115
+ throw new Error('Browser test scenario name is required');
116
+ }
117
+
118
+ if (!scenario.todo && typeof scenario.fn !== 'function') {
119
+ throw new Error(`Browser test "${scenario.name}" must include a function`);
120
+ }
121
+
122
+ return {
123
+ ...scenario,
124
+ id: scenario.id || scenario.name,
125
+ };
126
+ }
127
+
128
+ function registrationForScenario(scenario, skipEngine, testApi) {
129
+ if (scenario.todo) {
130
+ return testApi.todo;
131
+ }
132
+ if (scenario.skip || skipEngine) {
133
+ return testApi.skip;
134
+ }
135
+ if (scenario.only) {
136
+ return testApi.only;
137
+ }
138
+ return testApi;
139
+ }
140
+
141
+ function expandBrowserTestEntries(scenarios, engines) {
142
+ return scenarios.flatMap((scenario) => {
143
+ const scenarioEngines = normalizeBrowserTestEngines(
144
+ scenario.engines || engines
145
+ );
146
+ const skipEngines = normalizeBrowserTestEngines(scenario.skipEngines || []);
147
+
148
+ return scenarioEngines.map((engine) => ({
149
+ id: browserTestId(scenario, engine),
150
+ name: formatBrowserTestName(scenario.name, engine),
151
+ scenario,
152
+ engine,
153
+ skipEngine: skipEngines.includes(engine),
154
+ }));
155
+ });
156
+ }
157
+
158
+ function sanitizeArtifactName(value) {
159
+ return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
160
+ }
161
+
162
+ function closeBrowserLike(browser) {
163
+ if (browser && typeof browser.close === 'function') {
164
+ return browser.close();
165
+ }
166
+ return Promise.resolve();
167
+ }
168
+
169
+ /**
170
+ * Resolve the timing history path. When a tests directory exists, the history
171
+ * file is placed there so scheduling data stays near the tests that use it.
172
+ */
173
+ export function resolveDefaultTimingsFile(options = {}) {
174
+ const { cwd = process.cwd(), testsDirectory = 'tests' } = options;
175
+ const testsPath = path.join(cwd, testsDirectory);
176
+ const parent = existsSync(testsPath) ? testsPath : cwd;
177
+ return path.join(parent, DEFAULT_TIMINGS_FILENAME);
178
+ }
179
+
180
+ /**
181
+ * Normalize a browser engine list and validate unsupported engines early.
182
+ */
183
+ export function normalizeBrowserTestEngines(engines) {
184
+ const list =
185
+ engines === undefined
186
+ ? SUPPORTED_BROWSER_TEST_ENGINES
187
+ : Array.isArray(engines)
188
+ ? engines
189
+ : [engines];
190
+
191
+ const normalized = list.map((engine) => {
192
+ if (typeof engine !== 'string') {
193
+ throw new Error('Browser test engine names must be strings');
194
+ }
195
+ return engine.toLowerCase();
196
+ });
197
+
198
+ for (const engine of normalized) {
199
+ if (!SUPPORTED_BROWSER_TEST_ENGINES.includes(engine)) {
200
+ throw new Error(
201
+ `Unsupported browser test engine "${engine}". Expected one of: ${SUPPORTED_BROWSER_TEST_ENGINES.join(', ')}`
202
+ );
203
+ }
204
+ }
205
+
206
+ return normalized;
207
+ }
208
+
209
+ /**
210
+ * Load browser test timing history from disk.
211
+ */
212
+ export function loadTimingData(options = {}) {
213
+ const { filePath = resolveDefaultTimingsFile() } = options;
214
+
215
+ try {
216
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
217
+ if (parsed.version !== TIMING_DATA_VERSION || !parsed.tests) {
218
+ return createEmptyTimingData();
219
+ }
220
+ return sortedTimingData(parsed);
221
+ } catch (error) {
222
+ if (error.code === 'ENOENT') {
223
+ return createEmptyTimingData();
224
+ }
225
+ throw new Error(
226
+ `Could not read browser test timing data: ${error.message}`
227
+ );
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Save browser test timing history to disk.
233
+ */
234
+ export function saveTimingData(data, options = {}) {
235
+ const { filePath = resolveDefaultTimingsFile() } = options;
236
+ mkdirSync(path.dirname(filePath), { recursive: true });
237
+ writeFileSync(
238
+ filePath,
239
+ `${JSON.stringify(sortedTimingData(data), null, 2)}\n`
240
+ );
241
+ }
242
+
243
+ /**
244
+ * Update a test's cumulative average duration.
245
+ */
246
+ export function updateTimingData(timingData, result) {
247
+ const { testId, durationMs, now = new Date() } = result;
248
+
249
+ if (!testId) {
250
+ throw new Error('testId is required when updating timing data');
251
+ }
252
+ if (!Number.isFinite(durationMs) || durationMs < 0) {
253
+ throw new Error('durationMs must be a non-negative finite number');
254
+ }
255
+
256
+ const previous = timingData.tests?.[testId];
257
+ const runs = (previous?.runs || 0) + 1;
258
+ const previousTotal = (previous?.averageMs || 0) * (previous?.runs || 0);
259
+ const averageMs = Math.round((previousTotal + durationMs) / runs);
260
+
261
+ return sortedTimingData({
262
+ ...timingData,
263
+ updatedAt: nowIso(now),
264
+ tests: {
265
+ ...(timingData.tests || {}),
266
+ [testId]: {
267
+ averageMs,
268
+ runs,
269
+ lastMs: Math.round(durationMs),
270
+ updatedAt: nowIso(now),
271
+ },
272
+ },
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Record one test duration to the timing history file.
278
+ */
279
+ export function recordTestDuration(result, options = {}) {
280
+ const { filePath = resolveDefaultTimingsFile() } = options;
281
+ const timingData = loadTimingData({ filePath });
282
+ const updated = updateTimingData(timingData, result);
283
+ saveTimingData(updated, { filePath });
284
+ return updated;
285
+ }
286
+
287
+ /**
288
+ * Sort tests so historically slow tests are registered first.
289
+ */
290
+ export function orderTestsByHistoricalDuration(
291
+ tests,
292
+ timingData = createEmptyTimingData()
293
+ ) {
294
+ return tests
295
+ .map((testEntry, index) => ({
296
+ testEntry,
297
+ index,
298
+ durationMs: historicalDurationMs(testEntry, timingData),
299
+ }))
300
+ .sort((a, b) => b.durationMs - a.durationMs || a.index - b.index)
301
+ .map(({ testEntry }) => testEntry);
302
+ }
303
+
304
+ /**
305
+ * Parse shard strings in the Playwright-style "1/3" format.
306
+ */
307
+ export function parseShard(shard) {
308
+ if (!shard) {
309
+ return null;
310
+ }
311
+
312
+ if (typeof shard === 'object') {
313
+ const { index, total } = shard;
314
+ return validateShard({ index: Number(index), total: Number(total) });
315
+ }
316
+
317
+ const match = String(shard).match(/^(\d+)\/(\d+)$/);
318
+ if (!match) {
319
+ throw new Error('Shard must use the "1/3" format');
320
+ }
321
+
322
+ return validateShard({
323
+ index: Number(match[1]),
324
+ total: Number(match[2]),
325
+ });
326
+ }
327
+
328
+ function validateShard(shard) {
329
+ const { index, total } = shard;
330
+ if (!Number.isInteger(index) || !Number.isInteger(total) || total < 1) {
331
+ throw new Error('Shard index and total must be positive integers');
332
+ }
333
+ if (index < 1 || index > total) {
334
+ throw new Error('Shard index must be between 1 and shard total');
335
+ }
336
+ return {
337
+ index,
338
+ total,
339
+ zeroBasedIndex: index - 1,
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Build balanced shards with a longest-processing-time-first schedule.
345
+ */
346
+ export function planTestShards(tests, options = {}) {
347
+ const { shardCount, timingData = createEmptyTimingData() } = options;
348
+
349
+ if (!Number.isInteger(shardCount) || shardCount < 1) {
350
+ throw new Error('shardCount must be a positive integer');
351
+ }
352
+
353
+ const shards = Array.from({ length: shardCount }, (_, index) => ({
354
+ index: index + 1,
355
+ totalDurationMs: 0,
356
+ tests: [],
357
+ }));
358
+
359
+ for (const testEntry of orderTestsByHistoricalDuration(tests, timingData)) {
360
+ shards.sort(
361
+ (a, b) => a.totalDurationMs - b.totalDurationMs || a.index - b.index
362
+ );
363
+ const durationMs = historicalDurationMs(testEntry, timingData);
364
+ shards[0].tests.push(testEntry);
365
+ shards[0].totalDurationMs += durationMs;
366
+ }
367
+
368
+ return shards.sort((a, b) => a.index - b.index);
369
+ }
370
+
371
+ /**
372
+ * Order tests, then optionally select the current shard.
373
+ */
374
+ export function selectTestsForShard(tests, options = {}) {
375
+ const {
376
+ shard = process.env.BROWSER_COMMANDER_TEST_SHARD,
377
+ timingData = createEmptyTimingData(),
378
+ } = options;
379
+ const parsedShard = parseShard(shard);
380
+
381
+ if (!parsedShard) {
382
+ return orderTestsByHistoricalDuration(tests, timingData);
383
+ }
384
+
385
+ return planTestShards(tests, {
386
+ shardCount: parsedShard.total,
387
+ timingData,
388
+ })[parsedShard.zeroBasedIndex].tests;
389
+ }
390
+
391
+ /**
392
+ * Create a browser, page, and commander fixture for one engine.
393
+ */
394
+ export async function createBrowserFixture(options = {}) {
395
+ const {
396
+ engine = 'playwright',
397
+ launchOptions = {},
398
+ commanderOptions = {},
399
+ launch = launchBrowser,
400
+ makeCommander = makeBrowserCommander,
401
+ } = options;
402
+
403
+ const normalizedEngine = normalizeBrowserTestEngines(engine)[0];
404
+ const launched = await launch({
405
+ ...launchOptions,
406
+ engine: normalizedEngine,
407
+ });
408
+ const { browser, page } = launched;
409
+
410
+ try {
411
+ const commander = makeCommander({
412
+ page,
413
+ ...commanderOptions,
414
+ });
415
+
416
+ return {
417
+ engine: normalizedEngine,
418
+ browser,
419
+ page,
420
+ commander,
421
+ async close() {
422
+ if (typeof commander.destroy === 'function') {
423
+ await commander.destroy();
424
+ }
425
+ await closeBrowserLike(browser);
426
+ },
427
+ };
428
+ } catch (error) {
429
+ await closeBrowserLike(browser);
430
+ throw error;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Run a callback with a browser fixture and always clean it up.
436
+ */
437
+ export async function withBrowserCommander(options, fn) {
438
+ const fixture = await createBrowserFixture(options);
439
+ try {
440
+ return await fn(fixture);
441
+ } finally {
442
+ await fixture.close();
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Run a function with a hard timeout.
448
+ */
449
+ export async function runWithTimeout(fn, options = {}) {
450
+ const { timeoutMs, testId = 'browser test' } = options;
451
+
452
+ if (!timeoutMs) {
453
+ return fn();
454
+ }
455
+
456
+ let timeoutId;
457
+ try {
458
+ return await Promise.race([
459
+ fn(),
460
+ new Promise((_resolve, reject) => {
461
+ timeoutId = setTimeout(() => {
462
+ reject(new Error(`Timeout of ${timeoutMs}ms exceeded for ${testId}`));
463
+ }, timeoutMs);
464
+ }),
465
+ ]);
466
+ } finally {
467
+ clearTimeout(timeoutId);
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Retry a failing operation.
473
+ */
474
+ export async function runWithRetries(fn, options = {}) {
475
+ const { retries = 0, testId = 'browser test', onRetry } = options;
476
+ let lastError;
477
+
478
+ for (let attempt = 1; attempt <= retries + 1; attempt++) {
479
+ try {
480
+ return await fn({ attempt });
481
+ } catch (error) {
482
+ lastError = error;
483
+ if (attempt > retries) {
484
+ break;
485
+ }
486
+ if (onRetry) {
487
+ await onRetry({
488
+ testId,
489
+ attempt,
490
+ nextAttempt: attempt + 1,
491
+ error,
492
+ });
493
+ }
494
+ }
495
+ }
496
+
497
+ throw lastError;
498
+ }
499
+
500
+ /**
501
+ * Write failure metadata and a screenshot when the page supports it.
502
+ */
503
+ export async function writeFailureArtifacts(options = {}) {
504
+ const {
505
+ page,
506
+ error,
507
+ testId,
508
+ engine,
509
+ artifactsDir = DEFAULT_ARTIFACTS_DIR,
510
+ } = options;
511
+
512
+ if (!artifactsDir) {
513
+ return [];
514
+ }
515
+
516
+ mkdirSync(artifactsDir, { recursive: true });
517
+ const safeName = sanitizeArtifactName(`${testId}-${engine}`);
518
+ const errorPath = path.join(artifactsDir, `${safeName}.error.json`);
519
+ const artifacts = [errorPath];
520
+
521
+ writeFileSync(
522
+ errorPath,
523
+ `${JSON.stringify(
524
+ {
525
+ testId,
526
+ engine,
527
+ message: error?.message || String(error),
528
+ stack: error?.stack || null,
529
+ },
530
+ null,
531
+ 2
532
+ )}\n`
533
+ );
534
+
535
+ if (page && typeof page.screenshot === 'function') {
536
+ const screenshotPath = path.join(artifactsDir, `${safeName}.png`);
537
+ try {
538
+ await page.screenshot({ path: screenshotPath });
539
+ artifacts.push(screenshotPath);
540
+ } catch (screenshotError) {
541
+ const screenshotErrorPath = path.join(
542
+ artifactsDir,
543
+ `${safeName}.screenshot-error.txt`
544
+ );
545
+ writeFileSync(screenshotErrorPath, `${screenshotError.message}\n`);
546
+ artifacts.push(screenshotErrorPath);
547
+ }
548
+ }
549
+
550
+ return artifacts;
551
+ }
552
+
553
+ /**
554
+ * Run one browser scenario without registering it with test-anywhere.
555
+ */
556
+ export function runBrowserScenario(scenario, options = {}) {
557
+ const normalizedScenario = normalizeScenario(scenario);
558
+ const {
559
+ engine = 'playwright',
560
+ launchOptions = {},
561
+ commanderOptions = {},
562
+ launch,
563
+ makeCommander,
564
+ artifactsDir = DEFAULT_ARTIFACTS_DIR,
565
+ timeoutMs,
566
+ attempt = 1,
567
+ } = options;
568
+ const testId = browserTestId(normalizedScenario, engine);
569
+
570
+ return withBrowserCommander(
571
+ {
572
+ engine,
573
+ launch,
574
+ makeCommander,
575
+ launchOptions: mergeOptions(
576
+ launchOptions,
577
+ normalizedScenario.launchOptions
578
+ ),
579
+ commanderOptions: mergeOptions(
580
+ commanderOptions,
581
+ normalizedScenario.commanderOptions
582
+ ),
583
+ },
584
+ async (fixture) => {
585
+ try {
586
+ return await runWithTimeout(
587
+ () =>
588
+ normalizedScenario.fn({
589
+ ...fixture,
590
+ testInfo: {
591
+ id: testId,
592
+ name: normalizedScenario.name,
593
+ engine,
594
+ attempt,
595
+ tags: normalizedScenario.tags || [],
596
+ },
597
+ }),
598
+ {
599
+ timeoutMs: normalizedScenario.timeoutMs || timeoutMs,
600
+ testId,
601
+ }
602
+ );
603
+ } catch (error) {
604
+ await writeFailureArtifacts({
605
+ page: fixture.page,
606
+ error,
607
+ testId,
608
+ engine,
609
+ artifactsDir: normalizedScenario.artifactsDir ?? artifactsDir,
610
+ });
611
+ throw error;
612
+ }
613
+ }
614
+ );
615
+ }
616
+
617
+ /**
618
+ * Register a matrix of browser scenarios with test-anywhere.
619
+ */
620
+ export function defineBrowserTests(scenarios, options = {}) {
621
+ if (!Array.isArray(scenarios)) {
622
+ throw new Error('defineBrowserTests expects an array of scenarios');
623
+ }
624
+
625
+ const {
626
+ engines,
627
+ timingFile = resolveDefaultTimingsFile(),
628
+ timingData = loadTimingData({ filePath: timingFile }),
629
+ recordDurations = true,
630
+ order = true,
631
+ shard = process.env.BROWSER_COMMANDER_TEST_SHARD,
632
+ testApi = baseTest,
633
+ } = options;
634
+ const normalizedScenarios = scenarios.map(normalizeScenario);
635
+ const expandedEntries = expandBrowserTestEntries(
636
+ normalizedScenarios,
637
+ engines
638
+ );
639
+ const selectedEntries =
640
+ order === false
641
+ ? expandedEntries
642
+ : selectTestsForShard(expandedEntries, { shard, timingData });
643
+ const registrations = [];
644
+
645
+ for (const entry of selectedEntries) {
646
+ const { scenario, engine, skipEngine } = entry;
647
+ const register = registrationForScenario(scenario, skipEngine, testApi);
648
+
649
+ register(entry.name, async () => {
650
+ const startedAt = Date.now();
651
+ try {
652
+ await runWithRetries(
653
+ ({ attempt }) =>
654
+ runBrowserScenario(scenario, {
655
+ ...options,
656
+ engine,
657
+ attempt,
658
+ artifactsDir: scenario.artifactsDir ?? options.artifactsDir,
659
+ timeoutMs: scenario.timeoutMs || options.timeoutMs,
660
+ }),
661
+ {
662
+ retries: scenario.retries ?? options.retries ?? 0,
663
+ testId: entry.id,
664
+ onRetry: options.onRetry,
665
+ }
666
+ );
667
+ } finally {
668
+ if (recordDurations && !scenario.todo) {
669
+ recordTestDuration(
670
+ {
671
+ testId: entry.id,
672
+ durationMs: Date.now() - startedAt,
673
+ },
674
+ { filePath: timingFile }
675
+ );
676
+ }
677
+ }
678
+ });
679
+
680
+ registrations.push({
681
+ id: entry.id,
682
+ name: entry.name,
683
+ engine,
684
+ skipped: Boolean(scenario.skip || skipEngine),
685
+ todo: Boolean(scenario.todo),
686
+ });
687
+ }
688
+
689
+ return registrations;
690
+ }
691
+
692
+ /**
693
+ * Register one browser scenario across Playwright and Puppeteer by default.
694
+ */
695
+ export function browserTest(name, fn, options = {}) {
696
+ return defineBrowserTests(
697
+ [
698
+ {
699
+ ...options,
700
+ name,
701
+ fn,
702
+ },
703
+ ],
704
+ {
705
+ ...options,
706
+ order: false,
707
+ }
708
+ );
709
+ }
@@ -93,10 +93,18 @@ describe('bindings', () => {
93
93
  typeof bindings.clickButton === 'function',
94
94
  'clickButton should be a function'
95
95
  );
96
+ assert.ok(
97
+ typeof bindings.click === 'function',
98
+ 'click should be a function'
99
+ );
96
100
  assert.ok(
97
101
  typeof bindings.fillTextArea === 'function',
98
102
  'fillTextArea should be a function'
99
103
  );
104
+ assert.ok(
105
+ typeof bindings.fill === 'function',
106
+ 'fill should be a function'
107
+ );
100
108
  assert.ok(
101
109
  typeof bindings.scrollIntoViewIfNeeded === 'function',
102
110
  'scrollIntoViewIfNeeded should be a function'
@@ -25,7 +25,9 @@ describe('factory', () => {
25
25
 
26
26
  assert.ok(commander);
27
27
  assert.ok(typeof commander.clickButton === 'function');
28
+ assert.ok(typeof commander.click === 'function');
28
29
  assert.ok(typeof commander.fillTextArea === 'function');
30
+ assert.ok(typeof commander.fill === 'function');
29
31
  assert.ok(typeof commander.goto === 'function');
30
32
  assert.ok(typeof commander.wait === 'function');
31
33
  });
@@ -41,6 +43,7 @@ describe('factory', () => {
41
43
 
42
44
  assert.ok(commander);
43
45
  assert.ok(typeof commander.clickButton === 'function');
46
+ assert.ok(typeof commander.click === 'function');
44
47
  });
45
48
 
46
49
  it('should accept verbose option', () => {
@@ -78,6 +81,8 @@ describe('factory', () => {
78
81
  // Interactions
79
82
  assert.ok(typeof commander.clickButton === 'function');
80
83
  assert.ok(typeof commander.fillTextArea === 'function');
84
+ assert.ok(typeof commander.click === 'function');
85
+ assert.ok(typeof commander.fill === 'function');
81
86
 
82
87
  // Element state
83
88
  assert.ok(typeof commander.isVisible === 'function');
@@ -0,0 +1,368 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ createBrowserFixture,
8
+ defineBrowserTests,
9
+ loadTimingData,
10
+ normalizeBrowserTestEngines,
11
+ orderTestsByHistoricalDuration,
12
+ parseShard,
13
+ planTestShards,
14
+ recordTestDuration,
15
+ runBrowserScenario,
16
+ runWithRetries,
17
+ runWithTimeout,
18
+ selectTestsForShard,
19
+ updateTimingData,
20
+ withBrowserCommander,
21
+ writeFailureArtifacts,
22
+ } from '../../../src/tests/index.js';
23
+
24
+ describe('browser-commander/tests', () => {
25
+ it('normalizes supported browser engines', () => {
26
+ assert.deepStrictEqual(normalizeBrowserTestEngines(), [
27
+ 'playwright',
28
+ 'puppeteer',
29
+ ]);
30
+ assert.deepStrictEqual(normalizeBrowserTestEngines('Playwright'), [
31
+ 'playwright',
32
+ ]);
33
+ assert.throws(
34
+ () => normalizeBrowserTestEngines('selenium'),
35
+ /Unsupported browser test engine/
36
+ );
37
+ });
38
+
39
+ it('updates cumulative timing averages', () => {
40
+ const first = updateTimingData(
41
+ { version: 1, updatedAt: null, tests: {} },
42
+ {
43
+ testId: 'slow test',
44
+ durationMs: 100,
45
+ now: new Date('2026-06-28T00:00:00Z'),
46
+ }
47
+ );
48
+ const second = updateTimingData(first, {
49
+ testId: 'slow test',
50
+ durationMs: 300,
51
+ now: new Date('2026-06-28T00:01:00Z'),
52
+ });
53
+
54
+ assert.strictEqual(second.tests['slow test'].runs, 2);
55
+ assert.strictEqual(second.tests['slow test'].averageMs, 200);
56
+ assert.strictEqual(second.tests['slow test'].lastMs, 300);
57
+ });
58
+
59
+ it('records timing data to disk', () => {
60
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'bc-tests-'));
61
+ const timingFile = path.join(
62
+ tmpDir,
63
+ '.browser-commander-test-timings.json'
64
+ );
65
+
66
+ try {
67
+ recordTestDuration(
68
+ { testId: 'writes timing', durationMs: 42 },
69
+ { filePath: timingFile }
70
+ );
71
+
72
+ const loaded = loadTimingData({ filePath: timingFile });
73
+ assert.strictEqual(loaded.tests['writes timing'].averageMs, 42);
74
+ } finally {
75
+ rmSync(tmpDir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ it('orders historically slow tests first while preserving ties', () => {
80
+ const ordered = orderTestsByHistoricalDuration(
81
+ [{ name: 'fast' }, { name: 'slow' }, { name: 'unknown' }],
82
+ {
83
+ version: 1,
84
+ updatedAt: null,
85
+ tests: {
86
+ fast: { averageMs: 10 },
87
+ slow: { averageMs: 100 },
88
+ },
89
+ }
90
+ );
91
+
92
+ assert.deepStrictEqual(
93
+ ordered.map((entry) => entry.name),
94
+ ['slow', 'fast', 'unknown']
95
+ );
96
+ });
97
+
98
+ it('plans balanced shards using longest tests first', () => {
99
+ const tests = [{ name: 'a' }, { name: 'b' }, { name: 'c' }, { name: 'd' }];
100
+ const timingData = {
101
+ version: 1,
102
+ updatedAt: null,
103
+ tests: {
104
+ a: { averageMs: 90 },
105
+ b: { averageMs: 40 },
106
+ c: { averageMs: 30 },
107
+ d: { averageMs: 10 },
108
+ },
109
+ };
110
+
111
+ const shards = planTestShards(tests, { shardCount: 2, timingData });
112
+
113
+ assert.deepStrictEqual(
114
+ shards.map((shard) => shard.tests.map((entry) => entry.name)),
115
+ [['a'], ['b', 'c', 'd']]
116
+ );
117
+ assert.deepStrictEqual(
118
+ selectTestsForShard(tests, { shard: '2/2', timingData }).map(
119
+ (entry) => entry.name
120
+ ),
121
+ ['b', 'c', 'd']
122
+ );
123
+ });
124
+
125
+ it('parses Playwright-style shard strings', () => {
126
+ assert.deepStrictEqual(parseShard('2/5'), {
127
+ index: 2,
128
+ total: 5,
129
+ zeroBasedIndex: 1,
130
+ });
131
+ assert.throws(() => parseShard('0/5'), /between 1 and shard total/);
132
+ assert.throws(() => parseShard('bad'), /1\/3/);
133
+ });
134
+
135
+ it('creates and closes a browser fixture with injected launchers', async () => {
136
+ const calls = [];
137
+ const page = { locator: () => ({}), context: () => ({}), $eval: true };
138
+ const browser = {
139
+ close: async () => calls.push('browser.close'),
140
+ };
141
+
142
+ const fixture = await createBrowserFixture({
143
+ engine: 'playwright',
144
+ launch: async (options) => {
145
+ calls.push(`launch:${options.engine}`);
146
+ return { browser, page };
147
+ },
148
+ makeCommander: ({ page: commanderPage }) => ({
149
+ page: commanderPage,
150
+ destroy: async () => calls.push('commander.destroy'),
151
+ }),
152
+ });
153
+
154
+ assert.strictEqual(fixture.engine, 'playwright');
155
+ assert.strictEqual(fixture.page, page);
156
+
157
+ await fixture.close();
158
+ assert.deepStrictEqual(calls, [
159
+ 'launch:playwright',
160
+ 'commander.destroy',
161
+ 'browser.close',
162
+ ]);
163
+ });
164
+
165
+ it('cleans up fixture after successful callbacks', async () => {
166
+ const calls = [];
167
+
168
+ const result = await withBrowserCommander(
169
+ {
170
+ engine: 'puppeteer',
171
+ launch: async () => ({
172
+ browser: { close: async () => calls.push('browser.close') },
173
+ page: {},
174
+ }),
175
+ makeCommander: () => ({
176
+ destroy: async () => calls.push('commander.destroy'),
177
+ }),
178
+ },
179
+ async ({ engine }) => {
180
+ calls.push(`run:${engine}`);
181
+ return 'ok';
182
+ }
183
+ );
184
+
185
+ assert.strictEqual(result, 'ok');
186
+ assert.deepStrictEqual(calls, [
187
+ 'run:puppeteer',
188
+ 'commander.destroy',
189
+ 'browser.close',
190
+ ]);
191
+ });
192
+
193
+ it('retries failing operations', async () => {
194
+ let attempts = 0;
195
+ const retryEvents = [];
196
+
197
+ const result = await runWithRetries(
198
+ async () => {
199
+ attempts++;
200
+ if (attempts < 2) {
201
+ throw new Error('try again');
202
+ }
203
+ return 'done';
204
+ },
205
+ {
206
+ retries: 1,
207
+ onRetry: async (event) => retryEvents.push(event.nextAttempt),
208
+ }
209
+ );
210
+
211
+ assert.strictEqual(result, 'done');
212
+ assert.deepStrictEqual(retryEvents, [2]);
213
+ });
214
+
215
+ it('times out slow operations', async () => {
216
+ await assert.rejects(
217
+ () =>
218
+ runWithTimeout(
219
+ () => new Promise((resolve) => setTimeout(resolve, 50)),
220
+ { timeoutMs: 1, testId: 'slow operation' }
221
+ ),
222
+ /Timeout of 1ms exceeded for slow operation/
223
+ );
224
+ });
225
+
226
+ it('writes error and screenshot artifacts on failure', async () => {
227
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'bc-artifacts-'));
228
+
229
+ try {
230
+ const artifacts = await writeFailureArtifacts({
231
+ page: {
232
+ screenshot: async ({ path: screenshotPath }) => {
233
+ assert.ok(screenshotPath.endsWith('.png'));
234
+ },
235
+ },
236
+ error: new Error('broken'),
237
+ testId: 'fixture fails',
238
+ engine: 'playwright',
239
+ artifactsDir: tmpDir,
240
+ });
241
+
242
+ assert.strictEqual(artifacts.length, 2);
243
+ const errorJson = JSON.parse(readFileSync(artifacts[0], 'utf8'));
244
+ assert.strictEqual(errorJson.message, 'broken');
245
+ assert.strictEqual(errorJson.engine, 'playwright');
246
+ } finally {
247
+ rmSync(tmpDir, { recursive: true, force: true });
248
+ }
249
+ });
250
+
251
+ it('captures failure artifacts before closing failed browser scenarios', async () => {
252
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'bc-scenario-'));
253
+ const calls = [];
254
+
255
+ try {
256
+ await assert.rejects(
257
+ () =>
258
+ runBrowserScenario(
259
+ {
260
+ name: 'fails',
261
+ fn: async () => {
262
+ throw new Error('scenario failure');
263
+ },
264
+ },
265
+ {
266
+ engine: 'playwright',
267
+ artifactsDir: tmpDir,
268
+ launch: async () => ({
269
+ browser: { close: async () => calls.push('browser.close') },
270
+ page: {
271
+ screenshot: async () => calls.push('page.screenshot'),
272
+ },
273
+ }),
274
+ makeCommander: () => ({
275
+ destroy: async () => calls.push('commander.destroy'),
276
+ }),
277
+ }
278
+ ),
279
+ /scenario failure/
280
+ );
281
+
282
+ assert.deepStrictEqual(calls, [
283
+ 'page.screenshot',
284
+ 'commander.destroy',
285
+ 'browser.close',
286
+ ]);
287
+ } finally {
288
+ rmSync(tmpDir, { recursive: true, force: true });
289
+ }
290
+ });
291
+
292
+ it('orders registered browser entries by engine-specific timing history', () => {
293
+ const calls = [];
294
+ const fakeTestApi = Object.assign(
295
+ (name, fn) => {
296
+ calls.push({ kind: 'test', name, fn });
297
+ },
298
+ {
299
+ skip: (name, fn) => calls.push({ kind: 'skip', name, fn }),
300
+ only: (name, fn) => calls.push({ kind: 'only', name, fn }),
301
+ todo: (name, fn) => calls.push({ kind: 'todo', name, fn }),
302
+ }
303
+ );
304
+
305
+ defineBrowserTests(
306
+ [
307
+ { name: 'fast', fn: async () => {} },
308
+ { name: 'slow', fn: async () => {} },
309
+ ],
310
+ {
311
+ engines: ['playwright'],
312
+ recordDurations: false,
313
+ testApi: fakeTestApi,
314
+ timingData: {
315
+ version: 1,
316
+ updatedAt: null,
317
+ tests: {
318
+ 'fast [playwright]': { averageMs: 10 },
319
+ 'slow [playwright]': { averageMs: 100 },
320
+ },
321
+ },
322
+ }
323
+ );
324
+
325
+ assert.deepStrictEqual(
326
+ calls.map((entry) => entry.name),
327
+ ['slow [playwright]', 'fast [playwright]']
328
+ );
329
+ });
330
+
331
+ it('registers browser tests without duration recording for unit testing', () => {
332
+ const calls = [];
333
+ const fakeTestApi = Object.assign(
334
+ (name, fn) => {
335
+ calls.push({ kind: 'test', name, fn });
336
+ },
337
+ {
338
+ skip: (name, fn) => calls.push({ kind: 'skip', name, fn }),
339
+ only: (name, fn) => calls.push({ kind: 'only', name, fn }),
340
+ todo: (name, fn) => calls.push({ kind: 'todo', name, fn }),
341
+ }
342
+ );
343
+ const registrations = defineBrowserTests(
344
+ [
345
+ {
346
+ name: 'metadata only',
347
+ todo: true,
348
+ },
349
+ ],
350
+ {
351
+ engines: ['playwright', 'puppeteer'],
352
+ recordDurations: false,
353
+ order: false,
354
+ testApi: fakeTestApi,
355
+ }
356
+ );
357
+
358
+ assert.deepStrictEqual(
359
+ registrations.map((entry) => entry.id),
360
+ ['metadata only [playwright]', 'metadata only [puppeteer]']
361
+ );
362
+ assert.ok(registrations.every((entry) => entry.todo));
363
+ assert.deepStrictEqual(
364
+ calls.map((entry) => entry.kind),
365
+ ['todo', 'todo']
366
+ );
367
+ });
368
+ });