browser-commander 0.17.2 → 0.19.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/.jscpd-baseline.json +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +338 -0
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +274 -0
- package/tests/helpers/trace-server.js +173 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +294 -0
package/src/tests/index.js
CHANGED
|
@@ -11,6 +11,25 @@ import path from 'node:path';
|
|
|
11
11
|
import { test as baseTest } from 'test-anywhere';
|
|
12
12
|
import { launchBrowser } from '../browser/launcher.js';
|
|
13
13
|
import { makeBrowserCommander } from '../factory.js';
|
|
14
|
+
import {
|
|
15
|
+
finishScenarioTrace,
|
|
16
|
+
resolveTestDownloads,
|
|
17
|
+
startScenarioTrace,
|
|
18
|
+
} from './tracing.js';
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
TEST_SCREENSHOT_MODES,
|
|
22
|
+
TEST_TRACE_MODES,
|
|
23
|
+
TRACE_BUNDLE_SUFFIX,
|
|
24
|
+
TEST_DOWNLOADS_DIRNAME,
|
|
25
|
+
finishScenarioTrace,
|
|
26
|
+
normalizeTestScreenshots,
|
|
27
|
+
normalizeTestTrace,
|
|
28
|
+
resolveTestDownloads,
|
|
29
|
+
resolveTraceSetting,
|
|
30
|
+
startScenarioTrace,
|
|
31
|
+
traceOutputPath,
|
|
32
|
+
} from './tracing.js';
|
|
14
33
|
|
|
15
34
|
export {
|
|
16
35
|
test,
|
|
@@ -406,11 +425,14 @@ export async function createBrowserFixture(options = {}) {
|
|
|
406
425
|
...launchOptions,
|
|
407
426
|
engine: normalizedEngine,
|
|
408
427
|
});
|
|
409
|
-
const { browser, page } = launched;
|
|
428
|
+
const { browser, page, downloads = null } = launched;
|
|
410
429
|
|
|
411
430
|
try {
|
|
412
431
|
const commander = makeCommander({
|
|
413
432
|
page,
|
|
433
|
+
// A managed download manager belongs to the commander that runs the
|
|
434
|
+
// test, so `commander.downloads` and the trace see the same files.
|
|
435
|
+
...(downloads ? { downloads } : {}),
|
|
414
436
|
...commanderOptions,
|
|
415
437
|
});
|
|
416
438
|
|
|
@@ -418,6 +440,7 @@ export async function createBrowserFixture(options = {}) {
|
|
|
418
440
|
engine: normalizedEngine,
|
|
419
441
|
browser,
|
|
420
442
|
page,
|
|
443
|
+
downloads,
|
|
421
444
|
commander,
|
|
422
445
|
async close() {
|
|
423
446
|
if (typeof commander.destroy === 'function') {
|
|
@@ -563,36 +586,67 @@ export function runBrowserScenario(scenario, options = {}) {
|
|
|
563
586
|
launch,
|
|
564
587
|
makeCommander,
|
|
565
588
|
artifactsDir = DEFAULT_ARTIFACTS_DIR,
|
|
589
|
+
trace = 'off',
|
|
590
|
+
screenshots,
|
|
591
|
+
traceOptions = {},
|
|
566
592
|
timeoutMs,
|
|
567
593
|
attempt = 1,
|
|
568
594
|
} = options;
|
|
569
595
|
const testId = browserTestId(normalizedScenario, engine);
|
|
596
|
+
const scenarioArtifactsDir = normalizedScenario.artifactsDir ?? artifactsDir;
|
|
597
|
+
const safeName = sanitizeArtifactName(`${testId}-${engine}`);
|
|
598
|
+
const mergedLaunchOptions = mergeOptions(
|
|
599
|
+
launchOptions,
|
|
600
|
+
normalizedScenario.launchOptions
|
|
601
|
+
);
|
|
602
|
+
// A download a test made is an artifact of that test, so it is kept beside
|
|
603
|
+
// the trace that references it rather than in the user's Downloads folder.
|
|
604
|
+
const downloads = resolveTestDownloads(mergedLaunchOptions.downloads, {
|
|
605
|
+
artifactsDir: scenarioArtifactsDir,
|
|
606
|
+
safeName,
|
|
607
|
+
});
|
|
570
608
|
|
|
571
609
|
return withBrowserCommander(
|
|
572
610
|
{
|
|
573
611
|
engine,
|
|
574
612
|
launch,
|
|
575
613
|
makeCommander,
|
|
576
|
-
launchOptions:
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
),
|
|
614
|
+
launchOptions: downloads
|
|
615
|
+
? { ...mergedLaunchOptions, downloads }
|
|
616
|
+
: mergedLaunchOptions,
|
|
580
617
|
commanderOptions: mergeOptions(
|
|
581
618
|
commanderOptions,
|
|
582
619
|
normalizedScenario.commanderOptions
|
|
583
620
|
),
|
|
584
621
|
},
|
|
585
622
|
async (fixture) => {
|
|
623
|
+
const started = await startScenarioTrace({
|
|
624
|
+
commander: fixture.commander,
|
|
625
|
+
page: fixture.page,
|
|
626
|
+
trace: normalizedScenario.trace ?? trace,
|
|
627
|
+
screenshots: normalizedScenario.screenshots ?? screenshots,
|
|
628
|
+
artifactsDir: scenarioArtifactsDir,
|
|
629
|
+
safeName,
|
|
630
|
+
attempt,
|
|
631
|
+
traceOptions: mergeOptions(
|
|
632
|
+
traceOptions,
|
|
633
|
+
normalizedScenario.traceOptions
|
|
634
|
+
),
|
|
635
|
+
});
|
|
636
|
+
|
|
586
637
|
try {
|
|
587
|
-
|
|
638
|
+
const result = await runWithTimeout(
|
|
588
639
|
() =>
|
|
589
640
|
normalizedScenario.fn({
|
|
590
641
|
...fixture,
|
|
642
|
+
trace: started?.trace ?? null,
|
|
591
643
|
testInfo: {
|
|
592
644
|
id: testId,
|
|
593
645
|
name: normalizedScenario.name,
|
|
594
646
|
engine,
|
|
595
647
|
attempt,
|
|
648
|
+
artifactsDir: scenarioArtifactsDir,
|
|
649
|
+
tracePath: started?.path ?? null,
|
|
596
650
|
tags: normalizedScenario.tags || [],
|
|
597
651
|
},
|
|
598
652
|
}),
|
|
@@ -601,14 +655,24 @@ export function runBrowserScenario(scenario, options = {}) {
|
|
|
601
655
|
testId,
|
|
602
656
|
}
|
|
603
657
|
);
|
|
658
|
+
await finishScenarioTrace(started, { log: fixture.commander?.log });
|
|
659
|
+
return result;
|
|
604
660
|
} catch (error) {
|
|
605
|
-
await
|
|
606
|
-
page: fixture.page,
|
|
661
|
+
const stopped = await finishScenarioTrace(started, {
|
|
607
662
|
error,
|
|
608
|
-
|
|
609
|
-
engine,
|
|
610
|
-
artifactsDir: normalizedScenario.artifactsDir ?? artifactsDir,
|
|
663
|
+
log: fixture.commander?.log,
|
|
611
664
|
});
|
|
665
|
+
// The trace bundle is the failure artifact when one was recorded; the
|
|
666
|
+
// standalone error file and screenshot stay for runs without a trace.
|
|
667
|
+
if (!stopped) {
|
|
668
|
+
await writeFailureArtifacts({
|
|
669
|
+
page: fixture.page,
|
|
670
|
+
error,
|
|
671
|
+
testId,
|
|
672
|
+
engine,
|
|
673
|
+
artifactsDir: scenarioArtifactsDir,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
612
676
|
throw error;
|
|
613
677
|
}
|
|
614
678
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace and download artifacts for the Browser Commander test runner
|
|
3
|
+
* (issues #87 and #88).
|
|
4
|
+
*
|
|
5
|
+
* A failing browser test is only useful if it can be read afterwards. The
|
|
6
|
+
* runner therefore records the same portable trace bundle every other
|
|
7
|
+
* consumer gets, instead of inventing a second artifact format, and points
|
|
8
|
+
* managed downloads at the same artifact directory so a trace can reference
|
|
9
|
+
* files that are still on disk.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { startTrace } from '../traces/recorder.js';
|
|
14
|
+
import { writeTraceViewer } from '../traces/viewer.js';
|
|
15
|
+
import { TRACE_MODE } from '../traces/schema.js';
|
|
16
|
+
|
|
17
|
+
/** Trace settings a scenario may ask for, in Playwright's vocabulary. */
|
|
18
|
+
export const TEST_TRACE_MODES = Object.freeze([
|
|
19
|
+
'off',
|
|
20
|
+
'on',
|
|
21
|
+
'retain-on-failure',
|
|
22
|
+
'on-first-retry',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** Screenshot settings a scenario may ask for. */
|
|
26
|
+
export const TEST_SCREENSHOT_MODES = Object.freeze([
|
|
27
|
+
'off',
|
|
28
|
+
'on',
|
|
29
|
+
'only-on-failure',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** Suffix that marks a trace bundle directory. */
|
|
33
|
+
export const TRACE_BUNDLE_SUFFIX = '.bc-trace';
|
|
34
|
+
|
|
35
|
+
/** Directory managed downloads use inside a test's artifact directory. */
|
|
36
|
+
export const TEST_DOWNLOADS_DIRNAME = 'downloads';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read a scenario's `trace` option.
|
|
40
|
+
*
|
|
41
|
+
* @param {boolean|string} [trace] - `true`, `false` or one of TEST_TRACE_MODES
|
|
42
|
+
* @returns {string} One of TEST_TRACE_MODES
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeTestTrace(trace) {
|
|
45
|
+
if (trace === undefined || trace === null || trace === false) {
|
|
46
|
+
return 'off';
|
|
47
|
+
}
|
|
48
|
+
if (trace === true) {
|
|
49
|
+
return 'on';
|
|
50
|
+
}
|
|
51
|
+
if (!TEST_TRACE_MODES.includes(trace)) {
|
|
52
|
+
throw new Error(`trace must be one of ${TEST_TRACE_MODES.join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
return trace;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read a scenario's `screenshots` option.
|
|
59
|
+
*
|
|
60
|
+
* @param {boolean|string} [screenshots] - `true`, `false` or one of
|
|
61
|
+
* TEST_SCREENSHOT_MODES
|
|
62
|
+
* @returns {string} One of TEST_SCREENSHOT_MODES
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeTestScreenshots(screenshots) {
|
|
65
|
+
if (screenshots === undefined || screenshots === null) {
|
|
66
|
+
return 'only-on-failure';
|
|
67
|
+
}
|
|
68
|
+
if (screenshots === true) {
|
|
69
|
+
return 'on';
|
|
70
|
+
}
|
|
71
|
+
if (screenshots === false) {
|
|
72
|
+
return 'off';
|
|
73
|
+
}
|
|
74
|
+
if (!TEST_SCREENSHOT_MODES.includes(screenshots)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`screenshots must be one of ${TEST_SCREENSHOT_MODES.join(', ')}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return screenshots;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decide whether this attempt records, and in which recorder mode.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} trace - A value from `normalizeTestTrace()`
|
|
86
|
+
* @param {number} attempt - 1 for the first run, 2 for the first retry
|
|
87
|
+
* @returns {{recorderMode: string, retainOnFailure: boolean}|null} The
|
|
88
|
+
* recorder settings, or null when this attempt does not record
|
|
89
|
+
*/
|
|
90
|
+
export function resolveTraceSetting(trace, attempt = 1) {
|
|
91
|
+
if (trace === 'off') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (trace === 'on-first-retry' && attempt < 2) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
recorderMode:
|
|
99
|
+
trace === 'on' ? TRACE_MODE.CONTINUOUS : TRACE_MODE.RETAIN_ON_FAILURE,
|
|
100
|
+
retainOnFailure: trace !== 'on',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function screenshotSettingForRecorder(screenshots) {
|
|
105
|
+
if (screenshots === 'on') {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (screenshots === 'off') {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return 'only-on-failure';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where one attempt's trace bundle lives.
|
|
116
|
+
*
|
|
117
|
+
* @param {Object} options - `{artifactsDir, safeName, attempt}`
|
|
118
|
+
* @returns {string} The bundle directory
|
|
119
|
+
*/
|
|
120
|
+
export function traceOutputPath(options = {}) {
|
|
121
|
+
const { artifactsDir, safeName, attempt = 1 } = options;
|
|
122
|
+
const suffix = attempt > 1 ? `.attempt-${attempt}` : '';
|
|
123
|
+
return path.resolve(
|
|
124
|
+
artifactsDir,
|
|
125
|
+
`${safeName}${suffix}${TRACE_BUNDLE_SUFFIX}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Point a scenario's managed downloads at its own artifact directory, so a
|
|
131
|
+
* completed download is still there when the trace that references it is read
|
|
132
|
+
* (issue #88).
|
|
133
|
+
*
|
|
134
|
+
* @param {boolean|Object} downloads - The scenario's `downloads` option
|
|
135
|
+
* @param {Object} options - `{artifactsDir, safeName}`
|
|
136
|
+
* @returns {boolean|Object|undefined} The download options to launch with
|
|
137
|
+
*/
|
|
138
|
+
export function resolveTestDownloads(downloads, options = {}) {
|
|
139
|
+
const { artifactsDir, safeName } = options;
|
|
140
|
+
if (!downloads || !artifactsDir) {
|
|
141
|
+
return downloads;
|
|
142
|
+
}
|
|
143
|
+
const settings = downloads === true ? {} : downloads;
|
|
144
|
+
if (settings.directory) {
|
|
145
|
+
return settings;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
...settings,
|
|
149
|
+
directory: path.resolve(artifactsDir, safeName, TEST_DOWNLOADS_DIRNAME),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Start a trace for one scenario attempt.
|
|
155
|
+
*
|
|
156
|
+
* @param {Object} options - `{commander, page, trace, screenshots,
|
|
157
|
+
* artifactsDir, safeName, attempt, traceOptions}`
|
|
158
|
+
* @returns {Promise<Object|null>} `{trace, path, retainOnFailure}`, or null
|
|
159
|
+
* when this attempt does not record
|
|
160
|
+
*/
|
|
161
|
+
export async function startScenarioTrace(options = {}) {
|
|
162
|
+
const {
|
|
163
|
+
commander,
|
|
164
|
+
page,
|
|
165
|
+
trace,
|
|
166
|
+
screenshots,
|
|
167
|
+
artifactsDir,
|
|
168
|
+
safeName,
|
|
169
|
+
attempt = 1,
|
|
170
|
+
traceOptions = {},
|
|
171
|
+
} = options;
|
|
172
|
+
|
|
173
|
+
const setting = resolveTraceSetting(normalizeTestTrace(trace), attempt);
|
|
174
|
+
if (!setting || !artifactsDir) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const running = await startTrace({
|
|
179
|
+
commander,
|
|
180
|
+
page,
|
|
181
|
+
output: traceOutputPath({ artifactsDir, safeName, attempt }),
|
|
182
|
+
mode: setting.recorderMode,
|
|
183
|
+
screenshots: screenshotSettingForRecorder(
|
|
184
|
+
normalizeTestScreenshots(screenshots)
|
|
185
|
+
),
|
|
186
|
+
// A test that fails halfway is exactly where ordered DOM mutations pay
|
|
187
|
+
// for themselves, so they are on whenever the runner records.
|
|
188
|
+
dom: { mutations: true },
|
|
189
|
+
...traceOptions,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
trace: running,
|
|
194
|
+
path: running.path,
|
|
195
|
+
retainOnFailure: setting.retainOnFailure,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Stop a scenario's trace, keeping it only when it is worth keeping.
|
|
201
|
+
*
|
|
202
|
+
* @param {Object|null} started - What `startScenarioTrace()` returned
|
|
203
|
+
* @param {Object} [options] - `{error, log}`
|
|
204
|
+
* @returns {Promise<Object|null>} The stopped trace, or null
|
|
205
|
+
*/
|
|
206
|
+
export async function finishScenarioTrace(started, options = {}) {
|
|
207
|
+
if (!started) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { error = null, log } = options;
|
|
212
|
+
const { trace, retainOnFailure } = started;
|
|
213
|
+
const note = (message) => log?.debug?.(`[trace] ${message}`);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await trace.checkpoint(error ? 'failure' : 'final', {
|
|
217
|
+
actor: 'runner',
|
|
218
|
+
reason: error ? 'failure' : 'final',
|
|
219
|
+
});
|
|
220
|
+
} catch (checkpointError) {
|
|
221
|
+
note(`could not capture the final checkpoint: ${checkpointError.message}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const discard = Boolean(retainOnFailure) && !error;
|
|
225
|
+
const stopped = await trace.stop({ error, discard });
|
|
226
|
+
|
|
227
|
+
if (!discard) {
|
|
228
|
+
try {
|
|
229
|
+
await writeTraceViewer(stopped.path);
|
|
230
|
+
} catch (viewerError) {
|
|
231
|
+
note(`could not write the viewer: ${viewerError.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return stopped;
|
|
236
|
+
}
|