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
|
@@ -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'
|