browser-commander 0.26.3 → 0.28.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 (115) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +6 -0
  4. package/eslint.config.js +11 -0
  5. package/package.json +9 -4
  6. package/src/bindings.js +19 -0
  7. package/src/browser/attach/snapshot-launch.js +8 -6
  8. package/src/browser/attach/snapshot.js +39 -2
  9. package/src/browser/browser-cookie-database.js +71 -2
  10. package/src/browser/browser-cookie-session.js +183 -0
  11. package/src/browser/browser-cookies.js +29 -0
  12. package/src/browser/connector.js +117 -13
  13. package/src/browser/debugging-port.js +4 -2
  14. package/src/browser/default-browser.js +33 -9
  15. package/src/browser/launch-diagnostics.js +102 -0
  16. package/src/browser/launch-options.js +2 -1
  17. package/src/browser/launch-restrictions.json +33 -2
  18. package/src/browser/launcher.js +54 -9
  19. package/src/browser/migration/sqlite-snapshot.js +5 -27
  20. package/src/browser/navigation.js +173 -98
  21. package/src/browser/persistent-session.js +360 -0
  22. package/src/browser/profile-directory.js +20 -0
  23. package/src/browser/real-browser.js +142 -70
  24. package/src/browser/restrictions.js +13 -4
  25. package/src/browser/session-cookies.js +78 -0
  26. package/src/browser/session-persistence.js +72 -0
  27. package/src/browser/session-watchdog.js +71 -0
  28. package/src/browser/session-worker.js +70 -0
  29. package/src/browser/storage-state.js +32 -6
  30. package/src/browser/webdriver.js +4 -0
  31. package/src/capture/encoding.js +327 -0
  32. package/src/capture/errors.js +12 -0
  33. package/src/capture/files.js +9 -0
  34. package/src/capture/index.js +5 -0
  35. package/src/capture/recording.js +136 -0
  36. package/src/capture/screenshot.js +222 -0
  37. package/src/capture/video.js +186 -0
  38. package/src/capture/viewport.js +68 -0
  39. package/src/capture/worker.js +22 -0
  40. package/src/cli/args.js +66 -1
  41. package/src/cli/commands.js +113 -2
  42. package/src/cli/high-level-methods.js +40 -3
  43. package/src/cli/sessions.js +11 -3
  44. package/src/core/commander-features.js +105 -0
  45. package/src/core/constants.js +1 -0
  46. package/src/core/dialog-manager.js +7 -0
  47. package/src/core/managed-navigation.js +101 -0
  48. package/src/core/navigation-manager.js +66 -80
  49. package/src/core/navigation-operation.js +25 -0
  50. package/src/core/navigation-readiness.js +68 -16
  51. package/src/core/navigation-safety.js +51 -0
  52. package/src/core/network-tracker.js +39 -92
  53. package/src/core/page-session.js +3 -2
  54. package/src/core/page-trigger-manager.js +130 -42
  55. package/src/core/readiness.js +76 -14
  56. package/src/core/subscriptions.js +20 -0
  57. package/src/core/webdriver-page.js +38 -0
  58. package/src/elements/locators.js +61 -37
  59. package/src/elements/reusable.js +92 -0
  60. package/src/elements/selectors.js +37 -81
  61. package/src/elements/visibility.js +38 -21
  62. package/src/exports.js +14 -0
  63. package/src/factory.js +17 -16
  64. package/src/high-level/universal-logic.js +57 -21
  65. package/src/index.js +2 -0
  66. package/src/interactions/click.js +47 -0
  67. package/src/interactions/overlays.js +28 -0
  68. package/src/interactions/scroll.js +8 -2
  69. package/src/traces/bundle.js +14 -5
  70. package/src/traces/checkpoints.js +99 -0
  71. package/src/traces/links.js +117 -1
  72. package/src/traces/mutation-stream.js +1 -0
  73. package/src/traces/network.js +257 -0
  74. package/src/traces/page-capture.js +143 -52
  75. package/src/traces/reader.js +8 -10
  76. package/src/traces/recorder.js +221 -245
  77. package/src/traces/render.js +168 -0
  78. package/src/traces/rolling.js +180 -0
  79. package/src/traces/start-cleanup.js +15 -0
  80. package/src/traces/stop.js +159 -0
  81. package/src/traces/storage.js +43 -0
  82. package/src/utilities/subprocess.js +47 -4
  83. package/tests/e2e/cli.e2e.test.js +20 -1
  84. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  85. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  86. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  87. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  88. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  89. package/tests/unit/browser/default-browser.test.js +18 -0
  90. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  91. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  92. package/tests/unit/browser/launcher.test.js +5 -1
  93. package/tests/unit/browser/persistent-session.test.js +59 -0
  94. package/tests/unit/browser/quiet-ui.test.js +16 -0
  95. package/tests/unit/browser/real-browser.test.js +1 -1
  96. package/tests/unit/browser/session-workflows.test.js +206 -0
  97. package/tests/unit/browser/storage-state.test.js +7 -2
  98. package/tests/unit/browser/tab-selection.test.js +38 -0
  99. package/tests/unit/capture/capture.test.js +52 -0
  100. package/tests/unit/capture/cleanup.test.js +24 -0
  101. package/tests/unit/capture/viewport.test.js +168 -0
  102. package/tests/unit/cli/dispatcher.test.js +25 -0
  103. package/tests/unit/core/navigation-budget.test.js +120 -0
  104. package/tests/unit/core/navigation-manager.test.js +23 -1
  105. package/tests/unit/core/readiness.test.js +21 -0
  106. package/tests/unit/core/trigger-readiness.test.js +39 -0
  107. package/tests/unit/core/webdriver-page.test.js +33 -0
  108. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  109. package/tests/unit/elements/visible-count.test.js +24 -0
  110. package/tests/unit/elements/wait-navigation.test.js +26 -0
  111. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  112. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  113. package/tests/unit/traces/bundle.test.js +16 -0
  114. package/tests/unit/traces/links.test.js +2 -2
  115. package/tests/unit/traces/network.test.js +97 -0
@@ -59,18 +59,40 @@ describe('navigation manager', () => {
59
59
  assert.strictEqual(loaded, true);
60
60
  assert.strictEqual(manager.getSessionId(), 1);
61
61
  assert.strictEqual(manager.isNavigating(), false);
62
+ const contentTimeout = calls.find(([name]) => name === 'setContent')[2]
63
+ .timeout;
64
+ assert.ok(contentTimeout > 0 && contentTimeout <= 5000);
62
65
  assert.deepStrictEqual(calls, [
63
66
  ['cleanup'],
64
67
  ['navigationStart'],
65
68
  [
66
69
  'setContent',
67
70
  '<h1>Hi</h1>',
68
- { waitUntil: 'networkidle', timeout: 5000 },
71
+ { waitUntil: 'networkidle', timeout: contentTimeout },
69
72
  ],
70
73
  ['pageReady'],
71
74
  ]);
72
75
  });
73
76
 
77
+ it('bounds stalled content replacement and clears navigation state', async () => {
78
+ const page = createMockPlaywrightPage();
79
+ page.setContent = async () =>
80
+ new Promise((resolve) => setTimeout(resolve, 150));
81
+ const manager = createNavigationManager({
82
+ page,
83
+ engine: 'playwright',
84
+ log: createMockLogger(),
85
+ });
86
+ const start = performance.now();
87
+ assert.equal(
88
+ await manager.setContent({ html: '<h1>Bounded</h1>', timeout: 40 }),
89
+ false
90
+ );
91
+ assert.ok(performance.now() - start < 120);
92
+ assert.equal(manager.isNavigating(), false);
93
+ await new Promise((resolve) => setTimeout(resolve, 160));
94
+ });
95
+
74
96
  it('should require html while accepting an empty string', async () => {
75
97
  const page = createMockPlaywrightPage();
76
98
  const log = createMockLogger();
@@ -427,6 +427,27 @@ describe('readiness', () => {
427
427
  assert.strictEqual(result.ready, false);
428
428
  });
429
429
 
430
+ it('should preserve timer expiry while a fractional clock budget remains', async () => {
431
+ const clock = createFakeClock();
432
+ const deadline = createDeadline({ timeout: 20, now: clock.now });
433
+ // The timer and monotonic clock can disagree by a fraction of a
434
+ // millisecond. Freeze that disagreement to reproduce it every time.
435
+ clock.advance(19.4);
436
+ const result = await runReadinessChecks({
437
+ checks: [
438
+ { name: 'never', run: () => new Promise(() => {}) },
439
+ ok('unreached'),
440
+ ],
441
+ deadline,
442
+ });
443
+
444
+ assert.strictEqual(deadline.expired(), false);
445
+ assert.strictEqual(result.status, READINESS_STATUS.TIMED_OUT);
446
+ assert.strictEqual(result.ready, false);
447
+ assert.deepStrictEqual(result.checks.pending, ['unreached']);
448
+ assert.strictEqual(result.evidence[0].detail.reason, 'deadline reached');
449
+ });
450
+
430
451
  it('should report elapsed and total budget on the result', async () => {
431
452
  const clock = createFakeClock();
432
453
  const deadline = createDeadline({ timeout: 800, now: clock.now });
@@ -0,0 +1,39 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { it } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { createPageTriggerManager } from '../../../src/core/page-trigger-manager.js';
5
+
6
+ it('DOM-ready triggers start without network idle and skip overlapping runs', async () => {
7
+ const events = new EventEmitter();
8
+ let release;
9
+ let calls = 0;
10
+ const manager = createPageTriggerManager({
11
+ navigationManager: events,
12
+ log: { debug() {} },
13
+ });
14
+ manager.pageTrigger({
15
+ readyOn: 'domcontentloaded',
16
+ concurrency: 'skip',
17
+ condition: () => true,
18
+ action: async () => {
19
+ calls++;
20
+ await new Promise((resolve) => {
21
+ release = resolve;
22
+ });
23
+ },
24
+ });
25
+ const page = new EventEmitter();
26
+ page.url = () => 'https://example.com/form';
27
+ manager.initialize({ page });
28
+ page.emit('domcontentloaded');
29
+ await new Promise((resolve) => setImmediate(resolve));
30
+ assert.equal(calls, 1);
31
+ page.emit('domcontentloaded');
32
+ await new Promise((resolve) => setImmediate(resolve));
33
+ assert.equal(calls, 1);
34
+ release();
35
+ await manager.destroy();
36
+ assert.equal(page.listenerCount('domcontentloaded'), 0);
37
+ assert.equal(events.listenerCount('onPageReady'), 0);
38
+ });
39
+ import { setImmediate } from 'node:timers';
@@ -256,6 +256,39 @@ describe('WebDriverPage', () => {
256
256
  );
257
257
  });
258
258
 
259
+ it('supplies BiDi cookie domains from explicit scope, URL or current page', async () => {
260
+ const { page, bidi } = bidiPage({
261
+ driverOptions: { url: 'https://current.example.test/path' },
262
+ });
263
+ await page.syncUrl();
264
+ await page.setCookie(
265
+ { name: 'current', value: 'artificial' },
266
+ {
267
+ name: 'url',
268
+ value: 'artificial',
269
+ url: 'https://url.example.test/path',
270
+ },
271
+ { name: 'domain', value: 'artificial', domain: '.explicit.example.test' }
272
+ );
273
+ const cookies = bidi.sent
274
+ .filter(({ method }) => method === 'storage.setCookie')
275
+ .map(({ params }) => params.cookie);
276
+ assert.deepEqual(
277
+ cookies.map(({ domain }) => domain),
278
+ ['current.example.test', 'url.example.test', '.explicit.example.test']
279
+ );
280
+ assert.ok(cookies.every((cookie) => !('url' in cookie)));
281
+ assert.ok(cookies.every((cookie) => !('expiry' in cookie)));
282
+ assert.equal(cookies[1].secure, true);
283
+ await page.setCookie({
284
+ name: 'http',
285
+ value: 'artificial',
286
+ url: 'http://url.example.test',
287
+ secure: false,
288
+ });
289
+ assert.equal(bidi.sent.at(-1).params.cookie.secure, false);
290
+ });
291
+
259
292
  it('adds and removes preload scripts over BiDi', async () => {
260
293
  const { page, bidi } = bidiPage({
261
294
  responses: { 'script.addPreloadScript': { script: 'preload-1' } },
@@ -0,0 +1,83 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import * as api from '../../../src/index.js';
4
+ import {
5
+ createMockPlaywrightPage,
6
+ createMockLogger,
7
+ } from '../../helpers/mocks.js';
8
+
9
+ it('finds ordered fallback selectors and supports alternate toggle texts', async () => {
10
+ const commander = api.makeBrowserCommander({
11
+ page: createMockPlaywrightPage({
12
+ elements: {
13
+ '#absent': { count: 0 },
14
+ '#present': { count: 1, visible: true },
15
+ },
16
+ }),
17
+ engine: 'playwright',
18
+ log: createMockLogger(),
19
+ enableNavigationManager: false,
20
+ });
21
+ assert.equal(
22
+ await commander.findFirst({
23
+ selectors: ['#absent', '#present'],
24
+ visible: true,
25
+ }),
26
+ '#present'
27
+ );
28
+ const found = await api.findToggleButton({
29
+ texts: ['one', 'two'],
30
+ elementTypes: ['button'],
31
+ count: async ({ selector }) => (selector === 'two' ? 1 : 0),
32
+ findByText: async ({ text }) => text,
33
+ });
34
+ assert.equal(found, 'two');
35
+ commander.destroy();
36
+ });
37
+
38
+ it('navigation subscriptions return idempotent unregister functions', () => {
39
+ const commander = api.makeBrowserCommander({
40
+ page: createMockPlaywrightPage(),
41
+ engine: 'playwright',
42
+ });
43
+ for (const event of [
44
+ 'onUrlChange',
45
+ 'onNavigationStart',
46
+ 'onNavigationComplete',
47
+ 'onPageReady',
48
+ ]) {
49
+ const unregister = commander[event](() => {});
50
+ assert.equal(typeof unregister, 'function');
51
+ unregister();
52
+ unregister();
53
+ }
54
+ commander.destroy();
55
+ });
56
+
57
+ it('exposes reusable text, checkbox, flag and cookie helpers', () => {
58
+ for (const name of [
59
+ 'hasText',
60
+ 'isChecked',
61
+ 'check',
62
+ 'readFlag',
63
+ 'uninstallClickListener',
64
+ 'setCookies',
65
+ 'clearCookies',
66
+ ]) {
67
+ assert.equal(typeof api[name], 'function', name);
68
+ }
69
+ });
70
+
71
+ it('unsubscribe retains a second registration of the same callback', async () => {
72
+ const { subscribeCallbacks } =
73
+ await import('../../../src/core/subscriptions.js');
74
+ const callbacks = [],
75
+ callback = () => {};
76
+ const first = subscribeCallbacks(callbacks, callback);
77
+ const second = subscribeCallbacks(callbacks, callback);
78
+ first();
79
+ first();
80
+ assert.deepEqual(callbacks, [callback]);
81
+ second();
82
+ assert.deepEqual(callbacks, []);
83
+ });
@@ -0,0 +1,24 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { count } from '../../../src/elements/visibility.js';
4
+
5
+ it('counts only visible matches when requested', async () => {
6
+ const locator = {
7
+ count: async () => 3,
8
+ evaluateAll: async (fn) =>
9
+ fn([
10
+ { getClientRects: () => [1], checkVisibility: () => true },
11
+ { getClientRects: () => [], checkVisibility: () => false },
12
+ { getClientRects: () => [1], checkVisibility: () => true },
13
+ ]),
14
+ };
15
+ assert.equal(
16
+ await count({
17
+ page: { locator: () => locator },
18
+ engine: 'playwright',
19
+ selector: 'div',
20
+ visible: true,
21
+ }),
22
+ 2
23
+ );
24
+ });
@@ -0,0 +1,26 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { waitForLocatorOrElement } from '../../../src/elements/locators.js';
4
+ import { waitForSelector } from '../../../src/elements/selectors.js';
5
+
6
+ for (const wait of [waitForLocatorOrElement, waitForSelector]) {
7
+ it(`${wait.name} treats a timeout after navigation as interrupted`, async () => {
8
+ let url = 'https://example.test/form';
9
+ const locator = {
10
+ first() {
11
+ return this;
12
+ },
13
+ async waitFor() {
14
+ url = 'https://example.test/sent';
15
+ throw Object.assign(new Error('timeout'), { name: 'TimeoutError' });
16
+ },
17
+ };
18
+ const result = await wait({
19
+ page: { url: () => url, locator: () => locator },
20
+ engine: 'playwright',
21
+ selector: 'button',
22
+ throwOnNavigation: false,
23
+ });
24
+ assert.ok(result === null || result === false);
25
+ });
26
+ }
@@ -0,0 +1,39 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { clickButton } from '../../../src/interactions/click.js';
4
+
5
+ for (const change of ['url', 'session', 'none']) {
6
+ it(`element timeout detects ${change} changes`, async () => {
7
+ let url = 'https://example.com/form';
8
+ let session = 1;
9
+ const error = Object.assign(new Error('waitFor: Timeout 5000ms exceeded'), {
10
+ name: 'TimeoutError',
11
+ });
12
+ const locator = {
13
+ first: () => locator,
14
+ waitFor: async () => {
15
+ if (change === 'url') {
16
+ url = 'https://example.com/done';
17
+ }
18
+ if (change === 'session') {
19
+ session++;
20
+ }
21
+ throw error;
22
+ },
23
+ };
24
+ const operation = clickButton({
25
+ page: { url: () => url, locator: () => locator },
26
+ engine: 'playwright',
27
+ navigationManager: { getSessionId: () => session },
28
+ selector: '#submit',
29
+ });
30
+ if (change === 'none') {
31
+ await assert.rejects(operation, error);
32
+ } else {
33
+ const result = await operation;
34
+ assert.equal(result.status, 'interrupted');
35
+ assert.equal(result.navigated, true);
36
+ assert.equal(result.dispatched, false);
37
+ }
38
+ });
39
+ }
@@ -202,6 +202,7 @@ describe('CI execution budgets', () => {
202
202
  'parity.yml engine-suites Engine lifecycle suites',
203
203
  'parity.yml fixture-suites React fixture suites',
204
204
  'parity.yml parity Fingerprint parity suite',
205
+ 'parity.yml parity Stable viewport displayed-pixel regression',
205
206
  'parity.yml parity WebDriver suite',
206
207
  'parity.yml snapshots Python headful real-browser launches',
207
208
  'parity.yml snapshots Python native snapshot launches',
@@ -232,6 +232,22 @@ describe('trace bundle writer (issue #87)', () => {
232
232
  assert.strictEqual(manifest.outcome, TRACE_OUTCOME.COMPLETE);
233
233
  });
234
234
 
235
+ it('retains every periodic drain in the same checkpoint interval', async () => {
236
+ const bundle = await open();
237
+ await bundle.writeMutations(1, [{ records: ['first'] }]);
238
+ await bundle.writeMutations(1, [{ records: ['second'] }]);
239
+ const lines = (
240
+ await fs.readFile(path.join(bundle.root, 'mutations/0001.ndjson'), 'utf8')
241
+ )
242
+ .trim()
243
+ .split('\n')
244
+ .map(JSON.parse);
245
+ assert.deepStrictEqual(lines, [
246
+ { records: ['first'] },
247
+ { records: ['second'] },
248
+ ]);
249
+ });
250
+
235
251
  it('should record a dropped member even under a tiny resource limit', async () => {
236
252
  // The record of a gap must not be the next thing that falls through it.
237
253
  const bundle = await open({ limits: { maxResourceBytes: 16 } });
@@ -544,8 +544,8 @@ describe('trace links export (issue #94)', () => {
544
544
  // The notation's formatter and parser decide whether an export can be read
545
545
  // back, so the range this package depends on and the version the tests
546
546
  // proved are the same range.
547
- assert.strictEqual(manifest.dependencies['links-notation'], '^0.22.0');
548
- assert.match(installed.version, /^0\.22\./);
547
+ assert.strictEqual(manifest.dependencies['links-notation'], '^0.23.0');
548
+ assert.match(installed.version, /^0\.23\./);
549
549
  });
550
550
 
551
551
  it('should export a bundle named by path as readily as an open one', async () => {
@@ -0,0 +1,97 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { startTrace } from '../../../src/traces/recorder.js';
7
+ import { UnsupportedCaptureError } from '../../../src/capture/errors.js';
8
+ import {
9
+ createFakeCommander,
10
+ createFakePage,
11
+ } from '../../helpers/trace-fixtures.js';
12
+
13
+ it('records filtered network metadata and bounded redacted bodies in HAR', async () => {
14
+ const output = await fs.mkdtemp(path.join(os.tmpdir(), 'commander-network-'));
15
+ const page = createFakePage();
16
+ try {
17
+ const trace = await startTrace({
18
+ commander: createFakeCommander(page),
19
+ output,
20
+ initialCheckpoint: false,
21
+ network: {
22
+ resourceTypes: ['fetch'],
23
+ bodies: true,
24
+ maxBodyBytes: 4,
25
+ har: true,
26
+ },
27
+ });
28
+ const request = {
29
+ url: () => 'https://example.test/api',
30
+ method: () => 'GET',
31
+ resourceType: () => 'fetch',
32
+ headers: () => ({
33
+ authorization: 'secret',
34
+ cookie: 'session',
35
+ accept: 'text/plain',
36
+ }),
37
+ timing: () => ({ startTime: Date.now(), responseEnd: 10 }),
38
+ };
39
+ page.emit('request', request);
40
+ page.emit('response', {
41
+ request: () => request,
42
+ url: request.url,
43
+ status: () => 200,
44
+ headers: () => ({ 'set-cookie': 'secret', 'content-type': 'text/plain' }),
45
+ body: async () => Buffer.from('abcdef'),
46
+ });
47
+ await trace.stop();
48
+ const events = await fs.readFile(
49
+ path.join(output, 'events.ndjson'),
50
+ 'utf8'
51
+ );
52
+ assert.match(events, /network.response/);
53
+ assert.doesNotMatch(events, /secret|session/);
54
+ const har = JSON.parse(
55
+ await fs.readFile(path.join(output, 'network.har'), 'utf8')
56
+ );
57
+ assert.equal(har.log.entries.length, 1);
58
+ assert.equal(har.log.entries[0].response.content.text, 'YWJjZA==');
59
+ assert.equal(har.log.entries[0].response.content._truncated, true);
60
+ } finally {
61
+ await fs.rm(output, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ it('rejected video startup removes every installed observer', async () => {
66
+ const output = await fs.mkdtemp(path.join(os.tmpdir(), 'commander-start-'));
67
+ const page = createFakePage();
68
+ const listeners = new Set();
69
+ const evaluate = page.evaluate.bind(page);
70
+ page.evaluate = (fn, argument) =>
71
+ argument === 'mov' ? Promise.resolve(false) : evaluate(fn, argument);
72
+ const on = page.on.bind(page);
73
+ const off = page.off.bind(page);
74
+ page.on = (event, listener) => {
75
+ listeners.add(listener);
76
+ return on(event, listener);
77
+ };
78
+ page.off = (event, listener) => {
79
+ listeners.delete(listener);
80
+ return off(event, listener);
81
+ };
82
+ try {
83
+ await assert.rejects(
84
+ startTrace({
85
+ commander: createFakeCommander(page),
86
+ output,
87
+ links: { output: path.join(output, 'trace.lino') },
88
+ video: { format: 'mov' },
89
+ network: true,
90
+ }),
91
+ UnsupportedCaptureError
92
+ );
93
+ assert.equal(listeners.size, 0);
94
+ } finally {
95
+ await fs.rm(output, { recursive: true, force: true });
96
+ }
97
+ });