browser-commander 0.28.0 → 0.30.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 (57) hide show
  1. package/.jscpd-baseline.json +7 -20
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +27 -10
  4. package/package.json +1 -1
  5. package/scripts/run-tests.mjs +3 -1
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +140 -65
  8. package/src/browser/launcher.js +19 -8
  9. package/src/browser/persistent-session.js +110 -38
  10. package/src/browser/session-adoption.js +96 -0
  11. package/src/browser/session-worker.js +12 -0
  12. package/src/cli/args.js +3 -0
  13. package/src/cli/commands.js +3 -0
  14. package/src/cli/high-level-methods.js +7 -0
  15. package/src/cli/sessions.js +3 -0
  16. package/src/core/commander-features.js +33 -25
  17. package/src/core/navigation-safety.js +1 -0
  18. package/src/core/page-trigger-manager.js +173 -134
  19. package/src/index.js +2 -0
  20. package/src/interactions/overlays.js +59 -14
  21. package/src/traces/bundle.js +41 -3
  22. package/src/traces/checkpoints.js +7 -3
  23. package/src/traces/commander-actions.js +146 -0
  24. package/src/traces/debug-session.js +119 -0
  25. package/src/traces/drain-timer.js +25 -0
  26. package/src/traces/links.js +44 -5
  27. package/src/traces/mutation-stream.js +12 -5
  28. package/src/traces/network.js +65 -13
  29. package/src/traces/observers.js +50 -46
  30. package/src/traces/page-capture.js +65 -21
  31. package/src/traces/recorder.js +79 -28
  32. package/src/traces/redaction.js +89 -6
  33. package/src/traces/rolling-bundle.js +240 -0
  34. package/src/traces/rolling.js +10 -113
  35. package/src/traces/stop.js +1 -0
  36. package/src/traces/user-actions.js +405 -0
  37. package/tests/e2e/issue-160.e2e.test.js +291 -0
  38. package/tests/e2e/issue-162-recording.e2e.test.js +175 -0
  39. package/tests/e2e/traces.e2e.test.js +4 -1
  40. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  41. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  42. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +4 -4
  43. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  44. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  45. package/tests/fixtures/traces/conformance/expected/trace.lino +4 -4
  46. package/tests/fixtures/traces/conformance/scenario.json +1 -0
  47. package/tests/helpers/e2e-browser.js +2 -1
  48. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  49. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  50. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  51. package/tests/unit/interactions/overlays.test.js +58 -0
  52. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  53. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  54. package/tests/unit/traces/issue-162-defaults.test.js +223 -0
  55. package/tests/unit/traces/issue-162-user-actions.test.js +181 -0
  56. package/tests/unit/traces/network.test.js +55 -3
  57. package/tests/unit/traces/recorder.test.js +4 -1
@@ -0,0 +1,80 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+ import { createPageTriggerManager } from '../../../src/core/page-trigger-manager.js';
5
+
6
+ const flush = () => new Promise((resolve) => globalThis.setImmediate(resolve));
7
+
8
+ for (const concurrency of ['skip', 'queue', 'restart']) {
9
+ it(`retains DOM-ready work during stop (${concurrency})`, async () => {
10
+ const navigationManager = new EventEmitter();
11
+ const page = new EventEmitter();
12
+ page.url = () => 'https://example.test/new';
13
+ const manager = createPageTriggerManager({
14
+ navigationManager,
15
+ log: { debug() {} },
16
+ stopGraceMs: 20,
17
+ });
18
+ const calls = [];
19
+ let release;
20
+ manager.pageTrigger({
21
+ condition: () => true,
22
+ readyOn: 'domcontentloaded',
23
+ concurrency,
24
+ action: async (ctx) => {
25
+ calls.push(ctx.url);
26
+ if (calls.length === 1) {
27
+ await new Promise((resolve) => {
28
+ release = resolve;
29
+ });
30
+ }
31
+ },
32
+ });
33
+ manager.initialize({ page });
34
+ page.emit('domcontentloaded');
35
+ await flush();
36
+ const stopping = manager.stopCurrentAction();
37
+ page.emit('domcontentloaded');
38
+ await stopping;
39
+ assert.equal(calls.length, 1, 'grace expiry must never overlap actions');
40
+ release();
41
+ await flush();
42
+ await flush();
43
+ assert.equal(calls.length, 2, 'ready event survives grace expiry');
44
+ await manager.destroy();
45
+ });
46
+ }
47
+
48
+ it('queue coalesces repeated events and unregister cancels pending work', async () => {
49
+ const nav = new EventEmitter();
50
+ const page = new EventEmitter();
51
+ page.url = () => 'https://example.test/';
52
+ const manager = createPageTriggerManager({
53
+ navigationManager: nav,
54
+ log: { debug() {} },
55
+ });
56
+ let release;
57
+ let count = 0;
58
+ const remove = manager.pageTrigger({
59
+ condition: () => true,
60
+ concurrency: 'queue',
61
+ action: async () => {
62
+ count++;
63
+ await new Promise((resolve) => {
64
+ release = resolve;
65
+ });
66
+ },
67
+ });
68
+ manager.initialize({ page });
69
+ nav.emit('onPageReady', { url: page.url() });
70
+ await flush();
71
+ for (let i = 0; i < 20; i++) {
72
+ nav.emit('onPageReady', { url: page.url() });
73
+ }
74
+ await flush();
75
+ remove();
76
+ release();
77
+ await flush();
78
+ assert.equal(count, 1);
79
+ await manager.destroy();
80
+ });
@@ -0,0 +1,58 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { dismissOverlays } from '../../../src/interactions/overlays.js';
4
+
5
+ it('dismisses the first visible match and reports errors without failing the interaction', async () => {
6
+ const clicks = [],
7
+ reports = [];
8
+ const targets = [
9
+ { isVisible: async () => false, click: async () => clicks.push('hidden') },
10
+ {
11
+ isVisible: async () => true,
12
+ click: async () => {
13
+ clicks.push('visible');
14
+ throw new Error('detached');
15
+ },
16
+ },
17
+ ];
18
+ const locator = {
19
+ first: () => targets[0],
20
+ count: async () => targets.length,
21
+ nth: (i) => targets[i],
22
+ };
23
+ await dismissOverlays({
24
+ page: { locator: () => locator },
25
+ engine: 'playwright',
26
+ overlays: ['.dismiss'],
27
+ onDismiss: (report) => reports.push(report),
28
+ });
29
+ assert.deepEqual(clicks, ['visible']);
30
+ assert.equal(reports[0].status, 'error');
31
+ assert.equal(reports[0].error.message, 'detached');
32
+ });
33
+
34
+ it('reports successful dismissal and isolates reporting callback errors', async () => {
35
+ let clicked = false;
36
+ const target = {
37
+ isVisible: async () => true,
38
+ click: async () => {
39
+ clicked = true;
40
+ },
41
+ };
42
+ const reports = [];
43
+ await dismissOverlays({
44
+ page: { locator: () => ({ count: async () => 1, nth: () => target }) },
45
+ engine: 'playwright',
46
+ overlays: [
47
+ {
48
+ selector: '.dismiss',
49
+ onDismiss: (result) => {
50
+ reports.push(result);
51
+ throw new Error('reporting');
52
+ },
53
+ },
54
+ ],
55
+ });
56
+ assert.equal(clicked, true);
57
+ assert.equal(reports[0].status, 'dismissed');
58
+ });
@@ -0,0 +1,110 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { startTrace } from '../../../src/traces/recorder.js';
7
+ import {
8
+ normalizePrivacyOptions,
9
+ redactValue,
10
+ redactHeaders,
11
+ redactUrl,
12
+ } from '../../../src/traces/redaction.js';
13
+ import {
14
+ createFakePage,
15
+ createFakeCommander,
16
+ makeSnapshot,
17
+ } from '../../helpers/trace-fixtures.js';
18
+
19
+ it('redacts JSON, form, multipart fields and credential header/query names', () => {
20
+ const privacy = normalizePrivacyOptions();
21
+ const cases = [
22
+ {
23
+ postData: JSON.stringify({
24
+ password: 'PRIVATE_A',
25
+ nested: { _xsrf: 'PRIVATE_B', otp: 123456 },
26
+ public: 'visible',
27
+ }),
28
+ },
29
+ { postData: 'public=visible&password=PRIVATE_A&_xsrf=PRIVATE_B' },
30
+ {
31
+ postData:
32
+ '--boundary\r\nContent-Disposition: form-data; name="password"\r\n\r\nPRIVATE_A\r\n--boundary--\r\n',
33
+ },
34
+ {
35
+ body: { data: JSON.stringify({ csrf: 'PRIVATE_B', public: 'visible' }) },
36
+ },
37
+ ];
38
+ for (const value of cases) {
39
+ const result = JSON.stringify(redactValue(value, privacy));
40
+ assert.doesNotMatch(result, /PRIVATE_[AB]|123456/);
41
+ }
42
+ assert.equal(
43
+ redactHeaders({ 'X-Xsrftoken': 'PRIVATE_A' }, privacy)['X-Xsrftoken'],
44
+ '[redacted]'
45
+ );
46
+ assert.doesNotMatch(
47
+ redactUrl('https://example.test/?xsrf=PRIVATE_A&csrf=PRIVATE_B', privacy),
48
+ /PRIVATE/
49
+ );
50
+ assert.ok(privacy.redactSelectors.includes('input[type=hidden]'));
51
+ });
52
+
53
+ it('redacts checkpoint HTML and mutations before bundle and incremental links writes', async () => {
54
+ const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-private-'));
55
+ const page = createFakePage({
56
+ snapshot: makeSnapshot({ html: '<div>public PRIVATE_SENTINEL</div>' }),
57
+ });
58
+ try {
59
+ const trace = await startTrace({
60
+ commander: createFakeCommander(page),
61
+ output,
62
+ mode: 'continuous',
63
+ screenshots: false,
64
+ links: { output: path.join(output, 'trace.lino'), dom: 'full' },
65
+ privacy: { redactPatterns: [/PRIVATE_SENTINEL/g] },
66
+ });
67
+ page.queueMutations([
68
+ {
69
+ records: [
70
+ {
71
+ kind: 'attribute',
72
+ target: { path: '#public' },
73
+ value: 'public PRIVATE_SENTINEL',
74
+ },
75
+ ],
76
+ },
77
+ ]);
78
+ const cp = await trace.checkpoint('public');
79
+ await trace.stop();
80
+ assert.doesNotMatch(
81
+ await fs.readFile(path.join(output, cp.members.html), 'utf8'),
82
+ /PRIVATE_SENTINEL/
83
+ );
84
+ assert.doesNotMatch(
85
+ await fs.readFile(path.join(output, 'trace.lino'), 'utf8'),
86
+ /PRIVATE_SENTINEL/
87
+ );
88
+ const mutations = await fs.readFile(
89
+ path.join(output, 'mutations/0001.ndjson'),
90
+ 'utf8'
91
+ );
92
+ assert.doesNotMatch(mutations, /PRIVATE_SENTINEL/);
93
+ assert.match(mutations, /public/);
94
+ } finally {
95
+ await fs.rm(output, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ it('redacts escaped script values, numeric OTPs and multipart file fields', () => {
100
+ const privacy = normalizePrivacyOptions();
101
+ for (const postData of [
102
+ '<script>const xsrfToken="PRIVATE_A\\"PRIVATE_B"; const otp=123456;</script>',
103
+ '--b\r\nContent-Disposition: form-data; name="password"; filename="public.txt"\r\n\r\nPRIVATE_A\r\n--b--\r\n',
104
+ ]) {
105
+ assert.doesNotMatch(
106
+ JSON.stringify(redactValue({ postData }, privacy)),
107
+ /PRIVATE_[AB]|123456/
108
+ );
109
+ }
110
+ });
@@ -0,0 +1,140 @@
1
+ import { it } from 'node:test';
2
+ import { openTraceBundle } from '../../../src/traces/bundle.js';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { startTrace } from '../../../src/traces/recorder.js';
8
+ import { readTrace } from '../../../src/traces/reader.js';
9
+ import {
10
+ createFakePage,
11
+ createFakeCommander,
12
+ } from '../../helpers/trace-fixtures.js';
13
+
14
+ function assertUniqueIncreasingSequences(events) {
15
+ const sequences = events.map((event) => event.sequence);
16
+ assert.deepEqual(
17
+ sequences,
18
+ [...new Set(sequences)].sort((a, b) => a - b)
19
+ );
20
+ }
21
+
22
+ it('rotates before oversized checkpoints and retains every segment by default', async () => {
23
+ const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-rotation-'));
24
+ const page = createFakePage({
25
+ snapshot: {
26
+ html: `<div>${'payload'.repeat(1500)}</div>`,
27
+ state: { url: 'https://example.test' },
28
+ },
29
+ });
30
+ try {
31
+ const trace = await startTrace({
32
+ commander: createFakeCommander(page),
33
+ output,
34
+ screenshots: false,
35
+ links: { output: path.join(output, 'trace.lino'), dom: 'full' },
36
+ limits: { rotate: { maxBytes: 2048 } },
37
+ });
38
+ assert.equal(trace.links, path.join(output, 'trace.lino'));
39
+ for (let i = 0; i < 8; i++) {
40
+ await trace.checkpoint(`snapshot-${i}`);
41
+ await trace.event('step', { payload: 'x'.repeat(1500), i });
42
+ }
43
+ await trace.stop();
44
+ assert.equal(trace.stopped, true);
45
+ const opened = await readTrace(trace.path);
46
+ assert.equal(opened.events.filter((e) => e.action === 'step').length, 8);
47
+ assert.equal(opened.events.filter((e) => e.kind === 'dropped').length, 0);
48
+ assert.ok(opened.checkpoints.length >= 8);
49
+ assert.match(await opened.html(opened.checkpoints[0].index), /payload/);
50
+ assert.match(await fs.readFile(trace.links, 'utf8'), /dom-snapshot/);
51
+ assert.ok(
52
+ JSON.parse(await fs.readFile(path.join(output, 'segments.json'), 'utf8'))
53
+ .segments.length > 4
54
+ );
55
+ } finally {
56
+ await fs.rm(output, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ it('automatically rotates continuous traces instead of permanently dropping later events', async () => {
61
+ const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-continuous-'));
62
+ try {
63
+ const trace = await startTrace({
64
+ commander: createFakeCommander(createFakePage()),
65
+ output,
66
+ mode: 'continuous',
67
+ screenshots: false,
68
+ limits: { maxBundleBytes: 2048 },
69
+ });
70
+ for (let i = 0; i < 12; i++) {
71
+ await trace.event('step', { i, payload: 'x'.repeat(1500) });
72
+ }
73
+ await trace.stop();
74
+ const opened = await readTrace(output);
75
+ assert.equal(opened.events.filter((e) => e.action === 'step').length, 12);
76
+ assert.equal(opened.events.filter((e) => e.kind === 'dropped').length, 0);
77
+ assertUniqueIncreasingSequences(opened.events);
78
+ } finally {
79
+ await fs.rm(output, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ it('caps mutation intervals with a marker and records the next interval', async () => {
84
+ const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-interval-'));
85
+ const bundle = await openTraceBundle({
86
+ output,
87
+ limits: { maxMutationBytes: 512 },
88
+ });
89
+ try {
90
+ await bundle.writeMutations(1, [
91
+ { records: [{ kind: 'attribute', value: 'a' }] },
92
+ { records: [{ value: 'x'.repeat(1000) }] },
93
+ ]);
94
+ assert.equal(bundle.mutationTruncated, true);
95
+ assert.equal(await bundle.writeMutations(1, [{ records: [] }]), null);
96
+ const first = await fs.readFile(
97
+ path.join(output, 'mutations/0001.ndjson'),
98
+ 'utf8'
99
+ );
100
+ assert.ok(Buffer.byteLength(first) <= 512);
101
+ assert.match(first, /truncated/);
102
+ await bundle.writeMutations(2, [
103
+ { records: [{ kind: 'attribute', value: 'next' }] },
104
+ ]);
105
+ assert.equal(bundle.mutationTruncated, false);
106
+ assert.match(
107
+ await fs.readFile(path.join(output, 'mutations/0002.ndjson'), 'utf8'),
108
+ /next/
109
+ );
110
+ } finally {
111
+ await bundle.abort();
112
+ await fs.rm(output, { recursive: true, force: true });
113
+ }
114
+ });
115
+
116
+ it('keeps timeline sequences unique across drops and rotation', async () => {
117
+ const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-drop-rotation-'));
118
+ try {
119
+ const trace = await startTrace({
120
+ commander: createFakeCommander(createFakePage()),
121
+ output,
122
+ mode: 'continuous',
123
+ initialCheckpoint: false,
124
+ screenshots: false,
125
+ limits: { maxBundleBytes: 4096, maxEventBytes: 1024 },
126
+ });
127
+ await trace.event('oversized', { payload: 'x'.repeat(2000) });
128
+ for (let i = 0; i < 8; i++) {
129
+ await trace.event('step', { i, payload: 'x'.repeat(300) });
130
+ }
131
+ await trace.stop();
132
+ const opened = await readTrace(output);
133
+ assert.equal(opened.events.filter((e) => e.kind === 'dropped').length, 1);
134
+ assert.equal(opened.events.filter((e) => e.action === 'step').length, 8);
135
+ assert.ok(trace.segments.length > 1);
136
+ assertUniqueIncreasingSequences(opened.events);
137
+ } finally {
138
+ await fs.rm(output, { recursive: true, force: true });
139
+ }
140
+ });
@@ -0,0 +1,223 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { startTrace } from '../../../src/traces/recorder.js';
7
+ import { readTrace } from '../../../src/traces/reader.js';
8
+ import { attachCommanderFeatures } from '../../../src/core/commander-features.js';
9
+ import { launchBrowserWithDependencies } from '../../../src/browser/launcher.js';
10
+ import { makeBrowserCommander } from '../../../src/factory.js';
11
+ import { attachLaunchDebug } from '../../../src/traces/debug-session.js';
12
+ import {
13
+ createFakePage,
14
+ createFakeCommander,
15
+ } from '../../helpers/trace-fixtures.js';
16
+
17
+ describe('complete recording defaults (issue #162)', { timeout: 15000 }, () => {
18
+ async function fixture(run) {
19
+ const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-defaults-'));
20
+ const page = createFakePage();
21
+ const commander = createFakeCommander(page, { destroy: async () => {} });
22
+ const previous = { ...process.env };
23
+ delete process.env.BROWSER_COMMANDER_DEBUG;
24
+ delete process.env.BROWSER_COMMANDER_TRACE;
25
+ try {
26
+ await run({ output, page, commander });
27
+ } finally {
28
+ await commander.destroy();
29
+ for (const key of [
30
+ 'BROWSER_COMMANDER_DEBUG',
31
+ 'BROWSER_COMMANDER_TRACE',
32
+ ]) {
33
+ if (previous[key] === undefined) {
34
+ delete process.env[key];
35
+ } else {
36
+ process.env[key] = previous[key];
37
+ }
38
+ }
39
+ await fs.rm(output, { recursive: true, force: true });
40
+ }
41
+ }
42
+
43
+ it('captures an initial snapshot, changes, actions and network with a default links file', async () => {
44
+ await fixture(async ({ output, page, commander }) => {
45
+ const trace = await startTrace({ commander, output, screenshots: false });
46
+ try {
47
+ assert.equal(trace.mode, 'continuous');
48
+ assert.equal(trace.checkpoints[0].name, 'initial');
49
+ assert.equal(trace.links, path.join(output, 'trace.lino'));
50
+ assert.ok(page.listenerCount('request') > 0);
51
+ assert.ok(
52
+ page.state.initScripts.some(
53
+ (call) => call.name === 'installMutationRecorderInPage'
54
+ )
55
+ );
56
+ await commander.click('#delete');
57
+ } finally {
58
+ await trace.stop();
59
+ }
60
+ const opened = await readTrace(output);
61
+ assert.equal(
62
+ opened.events.find((event) => event.kind === 'interaction').source,
63
+ 'commander'
64
+ );
65
+ assert.match(
66
+ await fs.readFile(trace.links, 'utf8'),
67
+ /initial|dom-snapshot/
68
+ );
69
+ });
70
+ });
71
+
72
+ it('preserves explicit checkpoint, network and links opt-outs', async () => {
73
+ await fixture(async ({ output, page, commander }) => {
74
+ const trace = await startTrace({
75
+ commander,
76
+ output,
77
+ mode: 'checkpoints',
78
+ network: false,
79
+ links: false,
80
+ });
81
+ try {
82
+ assert.equal(trace.mode, 'checkpoints');
83
+ assert.equal(trace.checkpoints.length, 0);
84
+ assert.equal(trace.links, null);
85
+ assert.equal(page.listenerCount('request'), 0);
86
+ } finally {
87
+ await trace.stop();
88
+ }
89
+ });
90
+ });
91
+
92
+ it('rejects unbounded action queues before opening recording files', async () => {
93
+ await fixture(async ({ output, page }) => {
94
+ for (const maxQueuedUserActions of [Infinity, 0, -1, 1.5]) {
95
+ await assert.rejects(
96
+ startTrace({ page, output, limits: { maxQueuedUserActions } }),
97
+ /maxQueuedUserActions/
98
+ );
99
+ }
100
+ assert.deepEqual(await fs.readdir(output), []);
101
+ });
102
+ });
103
+
104
+ it('starts commander debugging immediately, and finalizes it on destroy', async () => {
105
+ await fixture(async ({ output, commander }) => {
106
+ attachCommanderFeatures(commander, { debug: { output } }, () => {});
107
+ assert.ok(commander.ready instanceof Promise);
108
+ await commander.ready;
109
+ assert.equal(commander.tracePath, output);
110
+ await commander.destroy();
111
+ assert.equal((await readTrace(output)).manifest.mode, 'continuous');
112
+ });
113
+ });
114
+
115
+ it('records the very first call with no options and chooses a unique session directory', async () => {
116
+ await fixture(async ({ output, page }) => {
117
+ const cwd = process.cwd();
118
+ process.chdir(output);
119
+ page.locator = () => ({});
120
+ page.context = () => ({});
121
+ page.url = () => 'https://example.com/first';
122
+ let commander;
123
+ try {
124
+ commander = makeBrowserCommander({
125
+ page,
126
+ enableNetworkTracking: false,
127
+ enableNavigationManager: false,
128
+ enableDialogManager: false,
129
+ });
130
+ page.goto = async () => true;
131
+ await commander.goto({
132
+ url: 'https://example.com/first',
133
+ waitAfter: false,
134
+ });
135
+ assert.match(
136
+ commander.tracePath,
137
+ /browser-commander-traces[/\\].+-\d+-[a-f0-9]{8}$/
138
+ );
139
+ await commander.destroy();
140
+ const trace = await readTrace(commander.tracePath);
141
+ assert.equal(
142
+ trace.events.filter((event) => event.source === 'commander').length,
143
+ 1
144
+ );
145
+ } finally {
146
+ await commander?.destroy();
147
+ process.chdir(cwd);
148
+ }
149
+ });
150
+ });
151
+
152
+ for (const disabled of ['option', 'environment']) {
153
+ it(`respects the ${disabled} opt-out even with BROWSER_COMMANDER_TRACE`, async () => {
154
+ await fixture(async ({ output, page, commander }) => {
155
+ process.env.BROWSER_COMMANDER_TRACE = output;
156
+ if (disabled === 'environment') {
157
+ process.env.BROWSER_COMMANDER_DEBUG = '0';
158
+ }
159
+ attachCommanderFeatures(
160
+ commander,
161
+ disabled === 'option' ? { debug: false } : {},
162
+ () => {}
163
+ );
164
+ await commander.ready;
165
+ assert.equal(page.listenerCount('request'), 0);
166
+ assert.equal(commander.tracePath, null);
167
+ });
168
+ });
169
+ }
170
+
171
+ it('keeps a launch opt-out when a default commander adopts the page', async () => {
172
+ await fixture(async ({ output, page, commander }) => {
173
+ const launched = await attachLaunchDebug({ page }, { debug: false });
174
+ attachCommanderFeatures(commander, { output }, () => {});
175
+ await commander.ready;
176
+ assert.equal(launched.tracePath, null);
177
+ assert.equal(commander.tracePath, null);
178
+ assert.equal(page.listenerCount('request'), 0);
179
+ });
180
+ });
181
+
182
+ it('starts a launch recording before returning, reuses it in a commander, and closes it', async () => {
183
+ await fixture(async ({ output, page, commander }) => {
184
+ let closed = 0;
185
+ page.bringToFront = async () => {};
186
+ const browser = {
187
+ pages: () => [page],
188
+ close: async () => {
189
+ closed += 1;
190
+ },
191
+ };
192
+ const launched = await launchBrowserWithDependencies(
193
+ { debug: { output } },
194
+ {
195
+ resolveSystem: async () => '/usr/bin/google-chrome',
196
+ settleMs: 0,
197
+ launchRealBrowser: async () => ({
198
+ page,
199
+ browser: { contexts: () => [browser] },
200
+ close: browser.close,
201
+ }),
202
+ }
203
+ );
204
+ try {
205
+ assert.equal(launched.tracePath, output);
206
+ const requests = page.listenerCount('request');
207
+ attachCommanderFeatures(commander, {}, () => {});
208
+ await commander.ready;
209
+ assert.equal(commander.tracePath, output);
210
+ assert.equal(page.listenerCount('request'), requests);
211
+ await commander.click('#delete');
212
+ } finally {
213
+ await launched.close();
214
+ }
215
+ assert.equal(closed, 1);
216
+ const opened = await readTrace(output);
217
+ assert.equal(
218
+ opened.events.filter((event) => event.kind === 'interaction').length,
219
+ 1
220
+ );
221
+ });
222
+ });
223
+ });