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.
@@ -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,138 @@
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import assert from 'node:assert/strict';
11
+ import { describe, it } from 'node:test';
12
+
13
+ import { removeDeprecatedAlwaysAuth } from '../../../scripts/clean-npm-config.mjs';
14
+ import {
15
+ formatRegistryPackagePath,
16
+ isVersionPublished,
17
+ } from '../../../scripts/npm-registry.mjs';
18
+ import {
19
+ buildNodeTestArgs,
20
+ parseTestRunnerArgs,
21
+ resolveTestFiles,
22
+ } from '../../../scripts/run-tests.mjs';
23
+
24
+ describe('npm release helpers', () => {
25
+ describe('removeDeprecatedAlwaysAuth', () => {
26
+ it('removes always-auth while preserving other config', () => {
27
+ const dir = mkdtempSync(join(tmpdir(), 'browser-commander-npmrc-'));
28
+ const npmrc = join(dir, '.npmrc');
29
+
30
+ try {
31
+ writeFileSync(
32
+ npmrc,
33
+ [
34
+ '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}',
35
+ 'always-auth=true',
36
+ 'registry=https://registry.npmjs.org/',
37
+ '',
38
+ ].join('\n')
39
+ );
40
+
41
+ assert.equal(removeDeprecatedAlwaysAuth(npmrc), true);
42
+ assert.equal(
43
+ readFileSync(npmrc, 'utf8'),
44
+ [
45
+ '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}',
46
+ 'registry=https://registry.npmjs.org/',
47
+ '',
48
+ ].join('\n')
49
+ );
50
+ } finally {
51
+ rmSync(dir, { force: true, recursive: true });
52
+ }
53
+ });
54
+
55
+ it('returns false when no config path exists', () => {
56
+ assert.equal(removeDeprecatedAlwaysAuth(''), false);
57
+ });
58
+ });
59
+
60
+ describe('isVersionPublished', () => {
61
+ it('returns false for npm registry 404 responses without logging npm errors', async () => {
62
+ const fetchCalls = [];
63
+ const fetchFn = async (url) => {
64
+ fetchCalls.push(url);
65
+ return {
66
+ ok: false,
67
+ status: 404,
68
+ statusText: 'Not Found',
69
+ };
70
+ };
71
+
72
+ const published = await isVersionPublished(
73
+ 'browser-commander',
74
+ '0.9.0',
75
+ fetchFn
76
+ );
77
+
78
+ assert.equal(published, false);
79
+ assert.deepEqual(fetchCalls, [
80
+ 'https://registry.npmjs.org/browser-commander/0.9.0',
81
+ ]);
82
+ });
83
+
84
+ it('handles scoped package names', () => {
85
+ assert.equal(
86
+ formatRegistryPackagePath('@scope/package'),
87
+ '%40scope%2Fpackage'
88
+ );
89
+ });
90
+ });
91
+ });
92
+
93
+ describe('test runner helper', () => {
94
+ it('expands test directories into stable explicit test files', () => {
95
+ const dir = mkdtempSync(join(tmpdir(), 'browser-commander-tests-'));
96
+
97
+ try {
98
+ writeFileSync(join(dir, 'root.test.js'), '');
99
+ writeFileSync(join(dir, 'ignored.js'), '');
100
+
101
+ const nestedDir = join(dir, 'nested');
102
+ mkdirSync(nestedDir);
103
+ writeFileSync(join(nestedDir, 'nested.test.js'), '');
104
+
105
+ assert.deepEqual(resolveTestFiles([dir], dir), [
106
+ join('nested', 'nested.test.js'),
107
+ 'root.test.js',
108
+ ]);
109
+ } finally {
110
+ rmSync(dir, { force: true, recursive: true });
111
+ }
112
+ });
113
+
114
+ it('builds node --test arguments with reporter and coverage options', () => {
115
+ const options = parseTestRunnerArgs([
116
+ '--coverage',
117
+ '--reporter',
118
+ 'spec',
119
+ '--env',
120
+ 'RUN_E2E=true',
121
+ 'tests/unit',
122
+ ]);
123
+
124
+ assert.deepEqual(options, {
125
+ coverage: true,
126
+ env: { RUN_E2E: 'true' },
127
+ reporter: 'spec',
128
+ targets: ['tests/unit'],
129
+ });
130
+ assert.deepEqual(buildNodeTestArgs(options, ['tests/unit/a.test.js']), [
131
+ '--test',
132
+ '--experimental-test-coverage',
133
+ '--test-reporter',
134
+ 'spec',
135
+ 'tests/unit/a.test.js',
136
+ ]);
137
+ });
138
+ });
@@ -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
+ });