browser-commander 0.8.0 → 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/.prettierignore +1 -0
- package/CHANGELOG.md +14 -0
- package/README.md +75 -0
- package/eslint.config.js +1 -0
- package/examples/browser-commander-tests.example.js +30 -0
- package/jsdoc.json +11 -0
- package/package.json +7 -3
- package/src/bindings.js +2 -0
- package/src/browser/navigation.js +1 -1
- package/src/index.js +4 -1
- package/src/interactions/click.js +3 -3
- package/src/interactions/fill.js +2 -2
- package/src/tests/index.js +709 -0
- package/tests/unit/bindings.test.js +8 -0
- package/tests/unit/factory.test.js +5 -0
- package/tests/unit/tests/index.test.js +368 -0
|
@@ -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
|
+
});
|