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.
Files changed (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -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: mergeOptions(
577
- launchOptions,
578
- normalizedScenario.launchOptions
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
- return await runWithTimeout(
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 writeFailureArtifacts({
606
- page: fixture.page,
661
+ const stopped = await finishScenarioTrace(started, {
607
662
  error,
608
- testId,
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
+ }