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
@@ -15,6 +15,8 @@
15
15
  import { describe, it } from 'node:test';
16
16
  import assert from 'node:assert';
17
17
  import { readFileSync } from 'node:fs';
18
+ import { readFile, rm } from 'node:fs/promises';
19
+ import path from 'node:path';
18
20
  import { fileURLToPath } from 'node:url';
19
21
 
20
22
  import { ProcessRunner } from 'command-stream/process-runner';
@@ -23,6 +25,10 @@ import { normalizeRunOutput } from '../../../tests/cli-contract/normalize.mjs';
23
25
  import { runCommand } from '../../src/utilities/subprocess.js';
24
26
  import { SANDBOX_ARGS } from '../helpers/e2e-browser.js';
25
27
  import { repoPath } from '../helpers/repo.js';
28
+ import {
29
+ closeRemoteBrowser,
30
+ SESSION_METADATA,
31
+ } from '../../src/browser/persistent-session.js';
26
32
 
27
33
  const BIN = fileURLToPath(
28
34
  new URL('../../bin/browser-commander.js', import.meta.url)
@@ -124,8 +130,9 @@ for (const engine of ENGINES) {
124
130
  E2E,
125
131
  async () => {
126
132
  const { runner, printed } = startKeptOpenBrowser(engine);
133
+ let launched;
127
134
  try {
128
- const launched = await printed;
135
+ launched = await printed;
129
136
  assert.match(launched.cdpEndpoint, /^http:\/\/127\.0\.0\.1:\d+$/u);
130
137
  const attach = [
131
138
  '--cdp-endpoint',
@@ -153,6 +160,18 @@ for (const engine of ENGINES) {
153
160
  } finally {
154
161
  runner.kill('SIGTERM');
155
162
  await Promise.resolve(runner).catch(() => {});
163
+ if (launched?.userDataDir) {
164
+ const file = path.join(launched.userDataDir, SESSION_METADATA);
165
+ const metadata = JSON.parse(await readFile(file, 'utf8'));
166
+ await closeRemoteBrowser(metadata.webSocketDebuggerUrl);
167
+ await assert.rejects(fetch(`${launched.cdpEndpoint}/json/version`));
168
+ // Chrome finishes flushing its profile after closing CDP.
169
+ await rm(launched.userDataDir, {
170
+ recursive: true,
171
+ force: true,
172
+ maxRetries: 5,
173
+ });
174
+ }
156
175
  }
157
176
  }
158
177
  );
@@ -216,7 +216,7 @@ describe(
216
216
  });
217
217
 
218
218
  assert.strictEqual(result.ready, false);
219
- assert.strictEqual(result.status, 'timed_out');
219
+ assert.strictEqual(result.status, 'timed_out', JSON.stringify(result));
220
220
  assert.ok(
221
221
  elapsed < 6000,
222
222
  `the wait took ${elapsed}ms for a 1500ms budget`
@@ -0,0 +1,132 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
4
+ import { createCommander, predicate } from '../../src/index.js';
5
+ import { sendHtml, startFixtureHost } from '../helpers/fixture-server.js';
6
+
7
+ // Authored local fixtures only. Each deliberately stalled connection is closed
8
+ // by server teardown; browser actions and suites have finite budgets.
9
+ for (const engine of ['playwright', 'puppeteer']) {
10
+ describe(
11
+ `navigation budgets: ${engine}`,
12
+ { skip: !process.env.RUN_E2E, timeout: 60000 },
13
+ () => {
14
+ let browser;
15
+ let page;
16
+ let commander;
17
+ let server;
18
+ before(async () => {
19
+ server = await startFixtureHost((route, _req, res) => {
20
+ if (route === '/stall' || route === '/pending') {
21
+ return;
22
+ }
23
+ if (route === '/redirect') {
24
+ res.writeHead(302, { location: '/ready' });
25
+ res.end();
26
+ return;
27
+ }
28
+ if (route === '/continuous') {
29
+ sendHtml(
30
+ res,
31
+ '<h1>Continuous requests</h1><script>setInterval(() => fetch("/pending"), 60);</script>'
32
+ );
33
+ return;
34
+ }
35
+ sendHtml(res, '<h1 id="ready">Ready</h1>');
36
+ });
37
+ const { chromium } = await import('playwright');
38
+ const launchOptions = { headless: true, ...CHROME_LAUNCH_OPTIONS };
39
+ if (engine === 'playwright') {
40
+ browser = await chromium.launch(launchOptions);
41
+ page = await browser.newPage();
42
+ } else {
43
+ const { default: puppeteer } = await import('puppeteer');
44
+ browser = await puppeteer.launch({
45
+ ...launchOptions,
46
+ executablePath:
47
+ CHROME_LAUNCH_OPTIONS.executablePath ?? chromium.executablePath(),
48
+ });
49
+ page = await browser.newPage();
50
+ }
51
+ commander = createCommander({ page, engine });
52
+ });
53
+ after(async () => {
54
+ await commander?.destroy();
55
+ await browser?.close();
56
+ await server?.close();
57
+ });
58
+ const navigate = async (url, options = {}) => {
59
+ const start = performance.now();
60
+ const result = await commander.goto({
61
+ url,
62
+ timeout: 800,
63
+ verify: false,
64
+ waitForStableUrlBefore: false,
65
+ waitForStableUrlAfter: false,
66
+ waitForNetworkIdle: false,
67
+ ...options,
68
+ });
69
+ assert.ok(
70
+ performance.now() - start < 1600,
71
+ 'end-to-end budget must bound elapsed time'
72
+ );
73
+ return result;
74
+ };
75
+ it('loads a data page quickly with tracking enabled', async () => {
76
+ assert.equal(
77
+ (await navigate('data:text/html,<h1>Authored fixture</h1>')).status,
78
+ 'ready'
79
+ );
80
+ assert.ok(commander.networkTracker);
81
+ assert.ok(page.listenerCount?.('request') > 0);
82
+ });
83
+ it('follows redirects under the same deadline', async () => {
84
+ const result = await navigate(`${server.baseUrl}/redirect`);
85
+ assert.equal(result.status, 'ready');
86
+ assert.equal(page.url(), `${server.baseUrl}/ready`);
87
+ });
88
+ it('times out continuous network activity and cleans navigation state', async () => {
89
+ const result = await navigate(`${server.baseUrl}/continuous`, {
90
+ waitForNetworkIdle: true,
91
+ });
92
+ assert.equal(result.status, 'timed_out');
93
+ assert.equal(commander.navigationManager.isNavigating(), false);
94
+ });
95
+ it('bounds a stalled engine navigation', async () => {
96
+ assert.equal(
97
+ (await navigate(`${server.baseUrl}/stall`)).status,
98
+ 'timed_out'
99
+ );
100
+ });
101
+ it('observes a positive selector check', async () => {
102
+ const ready = predicate({
103
+ name: 'selector_ready',
104
+ fn: async () =>
105
+ page.evaluate(() => Boolean(document.querySelector('#ready'))),
106
+ });
107
+ assert.equal(
108
+ (await navigate(`${server.baseUrl}/ready`, { checks: [ready] }))
109
+ .status,
110
+ 'ready'
111
+ );
112
+ });
113
+ it('cancels a stalled call and preserves only the session listeners', async () => {
114
+ const abort = new AbortController();
115
+ const event =
116
+ engine === 'playwright' ? 'framenavigated' : 'framenavigated';
117
+ const count = page.listenerCount?.(event);
118
+ const timer = setTimeout(() => abort.abort(), 80);
119
+ try {
120
+ const result = await navigate(`${server.baseUrl}/stall`, {
121
+ signal: abort.signal,
122
+ });
123
+ assert.equal(result.status, 'interrupted');
124
+ assert.equal(commander.navigationManager.isNavigating(), false);
125
+ assert.equal(page.listenerCount?.(event), count);
126
+ } finally {
127
+ clearTimeout(timer);
128
+ }
129
+ });
130
+ }
131
+ );
132
+ }
@@ -0,0 +1,231 @@
1
+ // feature-parity: elements.reusable@native-typed sessions.runtime@native-typed sessions.workflow@native-typed
2
+ import { describe, it, before, after } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {
5
+ createCommander,
6
+ launchBrowser,
7
+ saveStorageState,
8
+ } from '../../src/index.js';
9
+ import { mkdtemp, rm } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import { sendHtml, startFixtureHost } from '../helpers/fixture-server.js';
13
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
14
+
15
+ for (const engine of ['playwright', 'puppeteer']) {
16
+ describe(
17
+ `reusable helpers and session cookies: ${engine}`,
18
+ { skip: !process.env.RUN_E2E, timeout: 60000 },
19
+ () => {
20
+ let browser, page, commander, server;
21
+ before(async () => {
22
+ server = await startFixtureHost((_route, _req, res) =>
23
+ sendHtml(
24
+ res,
25
+ `<p>Hello&nbsp; world</p>
26
+ <button class="pick disabled">Disabled</button><button class="pick">Choose me</button>
27
+ <input type="checkbox" id="checkbox"><input type="radio" id="radio"><div id="hidden" hidden>Hidden</div>`
28
+ )
29
+ );
30
+ const { chromium } = await import('playwright');
31
+ const options = { headless: true, ...CHROME_LAUNCH_OPTIONS };
32
+ if (engine === 'playwright') {
33
+ browser = await chromium.launch(options);
34
+ page = await browser.newPage();
35
+ } else {
36
+ const { default: puppeteer } = await import('puppeteer');
37
+ browser = await puppeteer.launch({
38
+ ...options,
39
+ executablePath:
40
+ CHROME_LAUNCH_OPTIONS.executablePath ?? chromium.executablePath(),
41
+ });
42
+ page = await browser.newPage();
43
+ }
44
+ await page.goto(server.baseUrl);
45
+ commander = createCommander({ page, engine });
46
+ });
47
+ after(async () => {
48
+ await commander?.destroy();
49
+ await browser?.close();
50
+ await server?.close();
51
+ });
52
+ it('uses ordered selectors and normalizes nonbreaking spaces only when requested', async () => {
53
+ assert.equal(
54
+ await commander.findFirst({
55
+ selectors: ['#absent', '#hidden', '#checkbox'],
56
+ visible: true,
57
+ }),
58
+ '#checkbox'
59
+ );
60
+ assert.equal(
61
+ await commander.hasText({ texts: ['Hello world'] }),
62
+ false
63
+ );
64
+ assert.equal(
65
+ await commander.hasText({
66
+ texts: ['missing', 'Hello world'],
67
+ normalizeWhitespace: true,
68
+ }),
69
+ true
70
+ );
71
+ });
72
+ it('selects the requested index for enabled checks, clicks and scrolls', async () => {
73
+ assert.equal(
74
+ await commander.isEnabled({ selector: '.pick', index: 0 }),
75
+ false
76
+ );
77
+ assert.equal(
78
+ await commander.isEnabled({ selector: '.pick', index: 1 }),
79
+ true
80
+ );
81
+ await commander.installClickListener({
82
+ buttonText: 'Choose me',
83
+ storageKey: 'picked',
84
+ });
85
+ await commander.scrollIntoView({ selector: '.pick', index: 1 });
86
+ await commander.clickButton({
87
+ selector: '.pick',
88
+ index: 1,
89
+ verify: false,
90
+ waitAfterClick: 0,
91
+ waitForNavigation: false,
92
+ });
93
+ assert.deepEqual(await commander.readFlag({ storageKey: 'picked' }), {
94
+ status: 'observed',
95
+ set: true,
96
+ });
97
+ assert.deepEqual(await commander.readFlag({ storageKey: 'picked' }), {
98
+ status: 'observed',
99
+ set: true,
100
+ });
101
+ assert.equal(
102
+ await commander.uninstallClickListener({ storageKey: 'picked' }),
103
+ true
104
+ );
105
+ await page.evaluate(() => window.sessionStorage.removeItem('picked'));
106
+ await commander.clickButton({
107
+ selector: '.pick',
108
+ index: 1,
109
+ verify: false,
110
+ waitAfterClick: 0,
111
+ waitForNavigation: false,
112
+ });
113
+ assert.deepEqual(await commander.readFlag({ storageKey: 'picked' }), {
114
+ status: 'observed',
115
+ set: false,
116
+ });
117
+ });
118
+ it('checks checkboxes and radios idempotently with native input', async () => {
119
+ assert.equal(
120
+ await commander.isChecked({ selector: '#checkbox' }),
121
+ false
122
+ );
123
+ assert.deepEqual(await commander.check({ selector: '#checkbox' }), {
124
+ checked: true,
125
+ changed: true,
126
+ verified: true,
127
+ });
128
+ assert.deepEqual(await commander.check({ selector: '#checkbox' }), {
129
+ checked: true,
130
+ changed: false,
131
+ verified: true,
132
+ });
133
+ assert.equal(
134
+ (await commander.check({ selector: '#checkbox', checked: false }))
135
+ .verified,
136
+ true
137
+ );
138
+ assert.equal(
139
+ (await commander.check({ selector: '#radio' })).checked,
140
+ true
141
+ );
142
+ });
143
+ it('sets HttpOnly cookies across origins and clears by domain boundary', async () => {
144
+ await commander.setCookies({
145
+ cookies: [
146
+ {
147
+ name: 'session',
148
+ value: 'artificial',
149
+ domain: 'example.test',
150
+ path: '/',
151
+ expires: 0,
152
+ httpOnly: true,
153
+ sameSite: 'lax',
154
+ },
155
+ {
156
+ name: 'keep',
157
+ value: 'artificial',
158
+ domain: 'notexample.test',
159
+ path: '/',
160
+ expires: -1,
161
+ },
162
+ ],
163
+ });
164
+ const state = await saveStorageState(page);
165
+ const session = state.cookies.find(
166
+ (cookie) => cookie.name === 'session'
167
+ );
168
+ assert.ok(session);
169
+ assert.equal(session.httpOnly, true);
170
+ assert.equal(session.expires, -1);
171
+ assert.equal(session.sameSite, 'Lax');
172
+ await commander.clearCookies({ domain: 'example.test' });
173
+ const retained = (await saveStorageState(page)).cookies;
174
+ assert.equal(
175
+ retained.some((cookie) => cookie.name === 'session'),
176
+ false
177
+ );
178
+ assert.equal(
179
+ retained.some((cookie) => cookie.name === 'keep'),
180
+ true
181
+ );
182
+ await commander.clearCookies();
183
+ });
184
+ it('restores opted-in session cookies after closing a dedicated profile', async () => {
185
+ const directory = await mkdtemp(
186
+ path.join(os.tmpdir(), 'bc-persist-e2e-')
187
+ );
188
+ const { chromium } = await import('playwright');
189
+ const options = {
190
+ engine,
191
+ launch: 'engine',
192
+ headless: true,
193
+ ...CHROME_LAUNCH_OPTIONS,
194
+ executablePath:
195
+ CHROME_LAUNCH_OPTIONS.executablePath ?? chromium.executablePath(),
196
+ userDataDir: directory,
197
+ persistSessionCookies: true,
198
+ };
199
+ let result;
200
+ try {
201
+ result = await launchBrowser(options);
202
+ const sessionCommander = createCommander({
203
+ page: result.page,
204
+ engine,
205
+ });
206
+ await sessionCommander.setCookies([
207
+ {
208
+ name: 'session',
209
+ value: 'artificial',
210
+ domain: 'example.test',
211
+ path: '/',
212
+ expires: 0,
213
+ httpOnly: true,
214
+ },
215
+ ]);
216
+ await sessionCommander.destroy();
217
+ await result.close();
218
+ result = await launchBrowser(options);
219
+ const cookies = (await saveStorageState(result.page)).cookies;
220
+ assert.equal(
221
+ cookies.find((cookie) => cookie.name === 'session')?.value,
222
+ 'artificial'
223
+ );
224
+ } finally {
225
+ await result?.close();
226
+ await rm(directory, { recursive: true, force: true });
227
+ }
228
+ });
229
+ }
230
+ );
231
+ }
@@ -108,8 +108,8 @@ describe('describeAttachDifferences', () => {
108
108
  describe('snapshot attach launch option', () => {
109
109
  it('maps channels to the browser whose profile is copied', () => {
110
110
  assert.equal(snapshotBrowserForChannel('chrome'), 'chrome');
111
- assert.equal(snapshotBrowserForChannel('chrome-beta'), 'chrome');
112
- assert.equal(snapshotBrowserForChannel('msedge-dev'), 'edge');
111
+ assert.equal(snapshotBrowserForChannel('chrome-beta'), 'chrome-beta');
112
+ assert.equal(snapshotBrowserForChannel('msedge-dev'), 'edge-dev');
113
113
  assert.equal(snapshotBrowserForChannel('brave'), 'brave');
114
114
  assert.equal(snapshotBrowserForChannel('chromium'), 'chromium');
115
115
  });
@@ -437,3 +437,36 @@ describe('snapshotUserDataDir', () => {
437
437
  await assert.rejects(stat(path.join(root, 'copy')), { code: 'ENOENT' });
438
438
  });
439
439
  });
440
+
441
+ it('cookie-only snapshots include committed WAL data without history or storage', async () => {
442
+ const root = await makeTempDir();
443
+ const target = await makeTempDir();
444
+ await put(root, 'Local State', '{}');
445
+ await put(root, 'Default/History', 'private history');
446
+ await put(root, 'Default/Local Storage/unrelated', 'private storage');
447
+ await put(root, 'Default/Network/Cookies', '');
448
+ const writer = new BetterSqlite3(path.join(root, 'Default/Network/Cookies'));
449
+ writer.pragma('journal_mode=WAL');
450
+ writer.exec('CREATE TABLE cookies(value); INSERT INTO cookies VALUES (42)');
451
+ try {
452
+ await snapshotUserDataDir({
453
+ browser: 'chrome',
454
+ userDataDir: root,
455
+ to: target,
456
+ include: ['cookies'],
457
+ });
458
+ await assert.rejects(stat(path.join(target, 'Default/History')));
459
+ await assert.rejects(stat(path.join(target, 'Default/Local Storage')));
460
+ const copy = new BetterSqlite3(
461
+ path.join(target, 'Default/Network/Cookies'),
462
+ { readonly: true }
463
+ );
464
+ try {
465
+ assert.equal(copy.prepare('SELECT value FROM cookies').get().value, 42);
466
+ } finally {
467
+ copy.close();
468
+ }
469
+ } finally {
470
+ writer.close();
471
+ }
472
+ });
@@ -119,3 +119,21 @@ describe('resolveDefaultBrowser', () => {
119
119
  assert.equal(browserForIdentifier('', 'darwin'), null);
120
120
  });
121
121
  });
122
+
123
+ it('uses the modern macOS URL handler when LSHandlers is empty', async () => {
124
+ assert.equal(
125
+ await resolveDefaultBrowser({
126
+ platform: 'darwin',
127
+ runCommand: async (command) =>
128
+ command === 'osascript' ? 'com.google.chrome\n' : '()',
129
+ }),
130
+ 'chrome'
131
+ );
132
+ assert.equal(
133
+ await resolveDefaultBrowser({
134
+ platform: 'darwin',
135
+ runCommand: async () => '',
136
+ }),
137
+ null
138
+ );
139
+ });
@@ -73,7 +73,11 @@ describe('launch restrictions', () => {
73
73
  GOOGLE_DEFAULT_CLIENT_ID: 'no',
74
74
  GOOGLE_DEFAULT_CLIENT_SECRET: 'no',
75
75
  });
76
- assert.ok(resolved.args.includes('--disable-features=Translate'));
76
+ assert.ok(
77
+ resolved.args.includes(
78
+ '--disable-features=SessionRestoreInfobar,Translate'
79
+ )
80
+ );
77
81
  assert.ok(LAUNCH_RESTRICTION_PRESETS['legacy-launch-browser']);
78
82
  });
79
83
 
@@ -0,0 +1,52 @@
1
+ // feature-parity: launch.diagnostics@native-typed
2
+ import { it } from 'node:test';
3
+ import { redactLaunchEvidence } from '../../../src/browser/launch-diagnostics.js';
4
+
5
+ it('diagnostics scrub URL credentials and queries and bound multibyte evidence', () => {
6
+ const redacted = redactLaunchEvidence(
7
+ ['https://', 'user:', 'private@example.test/secret?access=private'].join('')
8
+ );
9
+ assert.doesNotMatch(redacted, /private|example\.test/);
10
+ assert.ok(Buffer.byteLength(redactLaunchEvidence('😀'.repeat(4096))) <= 4096);
11
+ });
12
+ import assert from 'node:assert/strict';
13
+ import { launchRealBrowser } from '../../../src/browser/real-browser.js';
14
+
15
+ it('bad executable preserves bounded, redacted structured early-exit evidence', async () => {
16
+ await assert.rejects(
17
+ () =>
18
+ launchRealBrowser({
19
+ engine: 'playwright',
20
+ executablePath: process.execPath,
21
+ headless: true,
22
+ startupTimeout: 1000,
23
+ }),
24
+ (error) => {
25
+ assert.equal(error.name, 'BrowserLaunchError');
26
+ assert.equal(error.phase, 'endpoint');
27
+ assert.equal(error.engine, 'playwright');
28
+ assert.equal(error.category, 'early_exit');
29
+ assert.equal(typeof error.exitCode, 'number');
30
+ assert.match(error.stderrTail, /bad option/);
31
+ assert.ok(error.cause);
32
+ assert.ok(error.stderrTail.length <= 4096);
33
+ return true;
34
+ }
35
+ );
36
+ });
37
+
38
+ it('missing executable is classified without exposing its path', async () => {
39
+ await assert.rejects(
40
+ () =>
41
+ launchRealBrowser({
42
+ executablePath: '/private/secret-token/missing-browser',
43
+ }),
44
+ (error) => {
45
+ assert.equal(error.name, 'BrowserLaunchError');
46
+ assert.equal(error.category, 'missing_executable');
47
+ assert.ok(!JSON.stringify(error).includes('secret-token'));
48
+ assert.ok(!error.message.includes('secret-token'));
49
+ return true;
50
+ }
51
+ );
52
+ });
@@ -202,7 +202,11 @@ describe('launchBrowser', () => {
202
202
  assert.equal(options.env.GOOGLE_API_KEY, 'no');
203
203
  assert.equal(process.env.GOOGLE_API_KEY, googleBefore);
204
204
  assert.ok(options.args.includes('--no-first-run'));
205
- assert.ok(options.args.includes('--disable-features=Translate'));
205
+ assert.ok(
206
+ options.args.includes(
207
+ '--disable-features=SessionRestoreInfobar,Translate'
208
+ )
209
+ );
206
210
  await result.close();
207
211
  });
208
212
 
@@ -0,0 +1,59 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { closeRemoteBrowser } from '../../../src/browser/persistent-session.js';
4
+
5
+ function websocket(context) {
6
+ const sockets = [];
7
+ class Socket {
8
+ constructor() {
9
+ sockets.push(this);
10
+ globalThis.queueMicrotask(() => this.onopen());
11
+ }
12
+ send(message) {
13
+ this.request = JSON.parse(message);
14
+ }
15
+ close() {
16
+ this.clientClosed = true;
17
+ this.onclose();
18
+ }
19
+ message(value) {
20
+ this.onmessage({ data: JSON.stringify(value) });
21
+ }
22
+ }
23
+ function createSocket() {
24
+ return new Socket();
25
+ }
26
+ context.mock.method(globalThis, 'WebSocket', createSocket);
27
+ return sockets;
28
+ }
29
+
30
+ it('waits for browser shutdown rather than closing on unrelated CDP events', async (context) => {
31
+ const sockets = websocket(context);
32
+ const closing = closeRemoteBrowser('ws://127.0.0.1/browser');
33
+ const socket = sockets[0];
34
+ await Promise.resolve();
35
+ socket.message({ method: 'Target.targetCreated', params: {} });
36
+ const prematurelyClosed = socket.clientClosed;
37
+ socket.message({ id: socket.request.id, result: {} });
38
+ socket.onclose();
39
+ await closing;
40
+ assert.equal(prematurelyClosed, undefined);
41
+ });
42
+
43
+ it('reports CDP shutdown errors instead of claiming the browser closed', async (context) => {
44
+ const sockets = websocket(context);
45
+ const closing = closeRemoteBrowser('ws://127.0.0.1/browser');
46
+ await Promise.resolve();
47
+ sockets[0].message({ id: 1, error: { message: 'shutdown refused' } });
48
+ await assert.rejects(closing, /shutdown refused/);
49
+ });
50
+
51
+ it('accepts Chrome disconnecting abruptly after its shutdown acknowledgement', async (context) => {
52
+ const sockets = websocket(context);
53
+ const closing = closeRemoteBrowser('ws://127.0.0.1/browser');
54
+ await Promise.resolve();
55
+ sockets[0].message({ id: 1, result: {} });
56
+ sockets[0].onerror();
57
+ sockets[0].onclose();
58
+ await closing;
59
+ });
@@ -0,0 +1,16 @@
1
+ import { it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { resolveRestrictions } from '../../../src/browser/restrictions.js';
4
+
5
+ it('quiet-ui merges caller features and disables Translate in preferences', () => {
6
+ const result = resolveRestrictions(['quiet-ui'], {
7
+ disableFeatures: ['CustomFeature', 'Translate'],
8
+ });
9
+ const switches = result.args.filter((arg) =>
10
+ arg.startsWith('--disable-features=')
11
+ );
12
+ assert.equal(switches.length, 1);
13
+ assert.ok(switches[0].includes('SessionRestoreInfobar'));
14
+ assert.ok(switches[0].includes('CustomFeature'));
15
+ assert.equal(result.preferences.translate.enabled, false);
16
+ });
@@ -405,7 +405,7 @@ describe('launchAndConnectRealBrowser', () => {
405
405
  },
406
406
  }
407
407
  ),
408
- PortRaceError
408
+ (error) => error.category === 'port_race' && error.phase === 'endpoint'
409
409
  );
410
410
  assert.equal(attempts, 1);
411
411
  });