browser-commander 0.20.0 → 0.21.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 (139) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +19 -4
  10. package/src/browser/attach/differences.js +131 -0
  11. package/src/browser/attach/extension-relay.js +402 -0
  12. package/src/browser/attach/index.js +81 -0
  13. package/src/browser/attach/snapshot-launch.js +146 -0
  14. package/src/browser/attach/snapshot.js +374 -0
  15. package/src/browser/attach/websocket.js +615 -0
  16. package/src/browser/browser-cookie-cache.js +12 -17
  17. package/src/browser/browser-cookie-credentials.js +6 -9
  18. package/src/browser/browser-cookies.js +5 -2
  19. package/src/browser/cdp-session.js +94 -0
  20. package/src/browser/connector.js +42 -4
  21. package/src/browser/debugging-port.js +155 -0
  22. package/src/browser/launch-options.js +63 -52
  23. package/src/browser/launch-restrictions.json +147 -0
  24. package/src/browser/launcher.js +329 -133
  25. package/src/browser/media.js +5 -0
  26. package/src/browser/migration/bookmarks.js +73 -0
  27. package/src/browser/migration/chromium-crypto.js +89 -0
  28. package/src/browser/migration/cookies.js +155 -0
  29. package/src/browser/migration/extensions.js +187 -0
  30. package/src/browser/migration/firefox-bookmarks.js +144 -0
  31. package/src/browser/migration/firefox-der.js +84 -0
  32. package/src/browser/migration/firefox-nss.js +281 -0
  33. package/src/browser/migration/firefox.js +334 -0
  34. package/src/browser/migration/fs-utils.js +51 -0
  35. package/src/browser/migration/history.js +103 -0
  36. package/src/browser/migration/index.js +307 -0
  37. package/src/browser/migration/os-crypt-keys.js +106 -0
  38. package/src/browser/migration/passwords.js +171 -0
  39. package/src/browser/migration/preferences.js +132 -0
  40. package/src/browser/migration/sqlite-snapshot.js +138 -0
  41. package/src/browser/open-in-user-browser.js +112 -0
  42. package/src/browser/parity.js +439 -0
  43. package/src/browser/profile-directory.js +102 -0
  44. package/src/browser/real-browser.js +513 -306
  45. package/src/browser/restrictions.js +148 -0
  46. package/src/browser/system-browser.js +232 -0
  47. package/src/browser/webdriver.js +572 -0
  48. package/src/cli/args.js +253 -0
  49. package/src/cli/commands.js +332 -0
  50. package/src/cli/dispatcher.js +99 -0
  51. package/src/cli/handle-methods.js +136 -0
  52. package/src/cli/handles.js +273 -0
  53. package/src/cli/high-level-methods.js +310 -0
  54. package/src/cli/main.js +67 -0
  55. package/src/cli/modules.js +100 -0
  56. package/src/cli/rpc-error.js +77 -0
  57. package/src/cli/script.js +77 -0
  58. package/src/cli/serve.js +144 -0
  59. package/src/cli/sessions.js +195 -0
  60. package/src/core/constants.js +7 -1
  61. package/src/core/engine-adapter.js +107 -1
  62. package/src/core/engine-detection.js +18 -3
  63. package/src/core/navigation-safety.js +4 -0
  64. package/src/core/webdriver-conversions.js +347 -0
  65. package/src/core/webdriver-events.js +229 -0
  66. package/src/core/webdriver-page.js +716 -0
  67. package/src/downloads/attach.js +6 -0
  68. package/src/elements/selectors.js +3 -2
  69. package/src/elements/visibility.js +4 -0
  70. package/src/exports.js +61 -2
  71. package/src/factory.js +19 -4
  72. package/src/fingerprint/apply.js +6 -0
  73. package/src/fingerprint/automation-parity.js +7 -8
  74. package/src/fingerprint/limitations.js +1 -1
  75. package/src/fingerprint/limitations.json +45 -2
  76. package/src/parity/harness.js +303 -0
  77. package/src/parity/probe.js +838 -0
  78. package/src/parity/version-page.js +144 -0
  79. package/src/utilities/subprocess.js +199 -0
  80. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  81. package/tests/e2e/cli.e2e.test.js +160 -0
  82. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  83. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  84. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  85. package/tests/e2e/playwright.e2e.test.js +25 -17
  86. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  87. package/tests/e2e/webdriver.e2e.test.js +239 -0
  88. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  89. package/tests/helpers/cli-fakes.js +214 -0
  90. package/tests/helpers/declared-api.js +80 -0
  91. package/tests/helpers/e2e-browser.js +40 -14
  92. package/tests/helpers/fake-chrome.js +124 -0
  93. package/tests/helpers/migration-fixtures.js +242 -0
  94. package/tests/helpers/temp-directory.js +24 -0
  95. package/tests/helpers/webdriver-mocks.js +183 -0
  96. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  97. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  98. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  99. package/tests/unit/browser/attach/websocket.test.js +240 -0
  100. package/tests/unit/browser/browser-cookies.test.js +2 -32
  101. package/tests/unit/browser/cdp-session.test.js +162 -0
  102. package/tests/unit/browser/connector.test.js +23 -0
  103. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  104. package/tests/unit/browser/launch-options.test.js +41 -22
  105. package/tests/unit/browser/launcher.test.js +281 -102
  106. package/tests/unit/browser/media.test.js +13 -1
  107. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  108. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  109. package/tests/unit/browser/migration/cookies.test.js +94 -0
  110. package/tests/unit/browser/migration/extensions.test.js +104 -0
  111. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  112. package/tests/unit/browser/migration/firefox.test.js +139 -0
  113. package/tests/unit/browser/migration/history.test.js +48 -0
  114. package/tests/unit/browser/migration/index.test.js +117 -0
  115. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  116. package/tests/unit/browser/migration/passwords.test.js +144 -0
  117. package/tests/unit/browser/migration/preferences.test.js +77 -0
  118. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  119. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  120. package/tests/unit/browser/parity.test.js +281 -0
  121. package/tests/unit/browser/real-browser.test.js +458 -33
  122. package/tests/unit/browser/webdriver.test.js +473 -0
  123. package/tests/unit/cli/bin.test.js +69 -0
  124. package/tests/unit/cli/declared-api.test.js +49 -0
  125. package/tests/unit/cli/dispatcher.test.js +429 -0
  126. package/tests/unit/cli/handles.test.js +170 -0
  127. package/tests/unit/cli/main.test.js +409 -0
  128. package/tests/unit/cli/serve.test.js +167 -0
  129. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  130. package/tests/unit/core/webdriver-page.test.js +534 -0
  131. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  132. package/tests/unit/fingerprint/limitations.test.js +1 -1
  133. package/tests/unit/parity/harness.test.js +138 -0
  134. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  135. package/tests/unit/scripts/feature-parity.test.js +221 -0
  136. package/tests/unit/traces/reader.test.js +1 -0
  137. package/tests/unit/traces/recorder.test.js +1 -0
  138. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  139. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,238 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { PassThrough } from 'node:stream';
7
+ import {
8
+ access,
9
+ mkdtemp,
10
+ readFile,
11
+ rm,
12
+ stat,
13
+ writeFile,
14
+ } from 'node:fs/promises';
15
+ import {
16
+ LAUNCH_RESTRICTIONS,
17
+ LAUNCH_RESTRICTION_PRESETS,
18
+ mergeFeatureSwitches,
19
+ resolveRestrictions,
20
+ } from '../../../src/browser/restrictions.js';
21
+ import {
22
+ FIRST_RUN_SENTINEL,
23
+ INITIAL_LOCAL_STATE,
24
+ LOCAL_STATE_FILE,
25
+ TEMPORARY_PROFILE_PREFIX,
26
+ createTemporaryUserDataDir,
27
+ prepareUserDataDir,
28
+ removeUserDataDir,
29
+ } from '../../../src/browser/profile-directory.js';
30
+ import {
31
+ PortRaceError,
32
+ assertFixedDebuggingPort,
33
+ classifyDevToolsOwnership,
34
+ parseDevToolsOutput,
35
+ reserveLoopbackPort,
36
+ watchDevToolsOutput,
37
+ } from '../../../src/browser/debugging-port.js';
38
+ import { CHROME_ARGS } from '../../../src/core/constants.js';
39
+ import { detectAutomationControlledTriggers } from '../../../src/fingerprint/automation-parity.js';
40
+
41
+ describe('launch restrictions', () => {
42
+ it('has unique ids and a description for each entry', () => {
43
+ const ids = LAUNCH_RESTRICTIONS.map((entry) => entry.id);
44
+ assert.equal(new Set(ids).size, ids.length);
45
+ for (const entry of LAUNCH_RESTRICTIONS) {
46
+ assert.ok(entry.description.length > 10, entry.id);
47
+ assert.ok(entry.args || entry.disableFeatures || entry.env, entry.id);
48
+ }
49
+ });
50
+
51
+ it('never offers a switch that turns AutomationControlled on', () => {
52
+ for (const entry of LAUNCH_RESTRICTIONS) {
53
+ assert.deepEqual(
54
+ detectAutomationControlledTriggers(entry.args ?? []),
55
+ [],
56
+ entry.id
57
+ );
58
+ }
59
+ });
60
+
61
+ it('keeps the pre-#103 defaults available as a preset', () => {
62
+ const { args } = resolveRestrictions(['legacy-defaults']);
63
+ assert.deepEqual([...args].sort(), [...CHROME_ARGS].sort());
64
+ });
65
+
66
+ it('adds the Google key override only to the browser environment', () => {
67
+ const resolved = resolveRestrictions(['legacy-launch-browser']);
68
+ assert.deepEqual(resolved.env, {
69
+ GOOGLE_API_KEY: 'no',
70
+ GOOGLE_DEFAULT_CLIENT_ID: 'no',
71
+ GOOGLE_DEFAULT_CLIENT_SECRET: 'no',
72
+ });
73
+ assert.ok(resolved.args.includes('--disable-features=Translate'));
74
+ assert.ok(LAUNCH_RESTRICTION_PRESETS['legacy-launch-browser']);
75
+ });
76
+
77
+ it('deduplicates ids and joins feature lists into one switch', () => {
78
+ const resolved = resolveRestrictions([
79
+ 'no-translate',
80
+ 'no-lens',
81
+ 'no-translate',
82
+ ]);
83
+ assert.deepEqual(resolved.ids, ['no-translate', 'no-lens']);
84
+ assert.deepEqual(resolved.args, [
85
+ '--disable-features=Translate,LensOverlay',
86
+ ]);
87
+ });
88
+
89
+ it('rejects unknown names and non-arrays', () => {
90
+ assert.throws(() => resolveRestrictions(['nope']), RangeError);
91
+ assert.throws(() => resolveRestrictions('no-sync'), TypeError);
92
+ });
93
+
94
+ it('merges repeated feature switches into the first position', () => {
95
+ assert.deepEqual(
96
+ mergeFeatureSwitches([
97
+ '--a',
98
+ '--disable-features=A,B',
99
+ '--enable-features=X',
100
+ '--disable-features=B,C',
101
+ '--b',
102
+ ]),
103
+ ['--a', '--disable-features=A,B,C', '--enable-features=X', '--b']
104
+ );
105
+ });
106
+ });
107
+
108
+ describe('profile directory', () => {
109
+ it('creates a fresh profile with the First Run sentinel', async () => {
110
+ const dir = await createTemporaryUserDataDir();
111
+ try {
112
+ assert.ok(path.basename(dir).startsWith(TEMPORARY_PROFILE_PREFIX));
113
+ await access(path.join(dir, FIRST_RUN_SENTINEL));
114
+ assert.deepEqual(
115
+ JSON.parse(await readFile(path.join(dir, LOCAL_STATE_FILE), 'utf8')),
116
+ INITIAL_LOCAL_STATE
117
+ );
118
+ } finally {
119
+ await removeUserDataDir(dir);
120
+ }
121
+ await assert.rejects(() => access(dir), /ENOENT/);
122
+ });
123
+
124
+ it('never overwrites Local State that Chrome already wrote', async () => {
125
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'bc-profile-test-'));
126
+ try {
127
+ const localState = path.join(dir, LOCAL_STATE_FILE);
128
+ await writeFile(
129
+ localState,
130
+ '{"browser":{"enabled_labs_experiments":[]}}'
131
+ );
132
+ await prepareUserDataDir(dir);
133
+ assert.equal(
134
+ await readFile(localState, 'utf8'),
135
+ '{"browser":{"enabled_labs_experiments":[]}}'
136
+ );
137
+ } finally {
138
+ await rm(dir, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ it('keeps an existing sentinel untouched', async () => {
143
+ const parent = await mkdtemp(path.join(os.tmpdir(), 'bc-profile-test-'));
144
+ try {
145
+ const dir = path.join(parent, 'nested', 'profile');
146
+ await prepareUserDataDir(dir);
147
+ const sentinel = path.join(dir, FIRST_RUN_SENTINEL);
148
+ const before = await stat(sentinel);
149
+ await prepareUserDataDir(dir);
150
+ assert.equal((await stat(sentinel)).mtimeMs, before.mtimeMs);
151
+ assert.equal(await readFile(sentinel, 'utf8'), '');
152
+ } finally {
153
+ await rm(parent, { recursive: true, force: true });
154
+ }
155
+ });
156
+ });
157
+
158
+ describe('debugging port', () => {
159
+ it('reserves a loopback port that can be bound again', async () => {
160
+ const port = await reserveLoopbackPort();
161
+ assert.ok(port > 0 && port < 65_536);
162
+ await new Promise((resolve, reject) => {
163
+ const server = net.createServer();
164
+ server.once('error', reject);
165
+ server.listen({ port, host: '127.0.0.1' }, () => server.close(resolve));
166
+ });
167
+ });
168
+
169
+ it('refuses port 0 and out-of-range ports', () => {
170
+ assert.throws(() => assertFixedDebuggingPort(0), /AutomationControlled/);
171
+ assert.throws(() => assertFixedDebuggingPort(70_000), RangeError);
172
+ assert.throws(() => assertFixedDebuggingPort('9222'), RangeError);
173
+ assert.equal(assertFixedDebuggingPort(9222), 9222);
174
+ });
175
+
176
+ it('parses the DevTools listening line', () => {
177
+ assert.deepEqual(
178
+ parseDevToolsOutput(
179
+ '\nDevTools listening on ws://127.0.0.1:9222/devtools/browser/1-2\n'
180
+ ),
181
+ {
182
+ listening: {
183
+ url: 'ws://127.0.0.1:9222/devtools/browser/1-2',
184
+ host: '127.0.0.1',
185
+ port: 9222,
186
+ },
187
+ bindFailed: false,
188
+ }
189
+ );
190
+ assert.equal(
191
+ parseDevToolsOutput(
192
+ 'DevTools listening on ws://[::1]:9222/devtools/browser/x'
193
+ ).listening.host,
194
+ '::1'
195
+ );
196
+ });
197
+
198
+ it('classifies ownership, fallback and give-up output', () => {
199
+ const owned = parseDevToolsOutput(
200
+ 'DevTools listening on ws://127.0.0.1:9222/devtools/browser/x'
201
+ );
202
+ assert.equal(classifyDevToolsOwnership(owned, 9222), 'owned');
203
+ assert.equal(classifyDevToolsOwnership(owned, 9223), 'race');
204
+ const fallback = parseDevToolsOutput(
205
+ 'bind() failed\nDevTools listening on ws://[::1]:9222/devtools/browser/x'
206
+ );
207
+ assert.equal(classifyDevToolsOwnership(fallback, 9222), 'race');
208
+ assert.equal(
209
+ classifyDevToolsOwnership(
210
+ parseDevToolsOutput('Cannot start http server for devtools.'),
211
+ 9222
212
+ ),
213
+ 'race'
214
+ );
215
+ // Unrelated bind failures (mDNS, media router) are not conclusive.
216
+ assert.equal(
217
+ classifyDevToolsOwnership(parseDevToolsOutput('bind() failed'), 9222),
218
+ 'pending'
219
+ );
220
+ });
221
+
222
+ it('watches a stream across chunk boundaries', () => {
223
+ const stream = new PassThrough();
224
+ const watcher = watchDevToolsOutput(stream);
225
+ assert.equal(watcher.available, true);
226
+ stream.emit('data', 'DevTools listening on ws://127.0.0.1:92');
227
+ assert.equal(watcher.state().listening, null);
228
+ stream.emit('data', '22/devtools/browser/abc\n');
229
+ assert.equal(watcher.state().listening.port, 9222);
230
+ assert.equal(watchDevToolsOutput(undefined).available, false);
231
+ });
232
+
233
+ it('names the port in race errors', () => {
234
+ const error = new PortRaceError(9222, 'bind failed');
235
+ assert.equal(error.port, 9222);
236
+ assert.match(error.message, /9222.*bind failed/);
237
+ });
238
+ });
@@ -6,45 +6,64 @@ import {
6
6
  buildPuppeteerLaunchOptions,
7
7
  resolveChromeArgs,
8
8
  } from '../../../src/browser/launch-options.js';
9
+ import { CHROME_ARGS } from '../../../src/core/constants.js';
9
10
 
10
11
  describe('browser launch options', () => {
11
- it('applies safe defaults, appends extra arguments, and supports per-flag opt-out', () => {
12
- const options = resolveChromeArgs({
13
- args: ['--legacy-arg'],
14
- extraArgs: ['--lang=en-US'],
15
- ignoreDefaultArgs: ['--no-default-browser-check'],
16
- });
12
+ it('adds no switches of its own by default (issue #103)', () => {
13
+ const options = resolveChromeArgs();
17
14
 
18
- assert.ok(options.args.includes('--password-store=basic'));
19
- assert.ok(options.args.includes('--no-first-run'));
20
- assert.equal(options.args.includes('--no-default-browser-check'), false);
21
- assert.deepEqual(options.args.slice(-2), ['--legacy-arg', '--lang=en-US']);
22
- assert.deepEqual(options.ignoreDefaultArgs, ['--no-default-browser-check']);
15
+ assert.deepEqual(options.args, []);
16
+ assert.deepEqual(options.ignoreDefaultArgs, []);
23
17
  });
24
18
 
25
- it('can ignore every Browser Commander default', () => {
19
+ it('appends custom args after opt-in restrictions', () => {
26
20
  const options = resolveChromeArgs({
21
+ restrictions: ['no-sync', 'no-translate'],
22
+ args: ['--legacy-arg', '--disable-features=Foo'],
27
23
  extraArgs: ['--lang=en-US'],
28
- ignoreDefaultArgs: true,
29
24
  });
30
25
 
31
- assert.deepEqual(options.args, ['--lang=en-US']);
32
- assert.equal(options.ignoreDefaultArgs, true);
26
+ assert.deepEqual(options.args, [
27
+ '--disable-sync',
28
+ // Chrome keeps only the last --disable-features, so they are merged.
29
+ '--disable-features=Translate,Foo',
30
+ '--legacy-arg',
31
+ '--lang=en-US',
32
+ ]);
33
33
  });
34
34
 
35
- it('can opt out of the password-store default specifically', () => {
36
- const options = resolveChromeArgs({
37
- ignoreDefaultArgs: ['--password-store=basic'],
35
+ it('restores the pre-#103 switches through the legacy-defaults preset', () => {
36
+ const options = resolveChromeArgs({ restrictions: ['legacy-defaults'] });
37
+
38
+ assert.deepEqual([...options.args].sort(), [...CHROME_ARGS].sort());
39
+ });
40
+
41
+ it('rejects malformed arguments and unknown restrictions', () => {
42
+ assert.throws(() => resolveChromeArgs({ args: '--x' }), TypeError);
43
+ assert.throws(
44
+ () => resolveChromeArgs({ ignoreDefaultArgs: 'yes' }),
45
+ TypeError
46
+ );
47
+ assert.throws(
48
+ () => resolveChromeArgs({ restrictions: ['no-such-thing'] }),
49
+ RangeError
50
+ );
51
+ });
52
+
53
+ it('never adds --start-maximized to Puppeteer', () => {
54
+ const options = buildPuppeteerLaunchOptions({
55
+ headless: false,
56
+ chromeArgs: [],
57
+ userDataDir: '/tmp/browser-commander-test',
38
58
  });
39
59
 
40
- assert.equal(options.args.includes('--password-store=basic'), false);
41
- assert.ok(options.args.includes('--no-first-run'));
60
+ assert.equal(options.args.includes('--start-maximized'), false);
42
61
  });
43
62
 
44
63
  it('forwards ignored defaults to both browser engines', () => {
45
64
  const playwright = buildPlaywrightLaunchOptions({
46
65
  headless: false,
47
- slowMo: 150,
66
+ slowMo: 0,
48
67
  chromeArgs: [],
49
68
  ignoreDefaultArgs: ['--no-first-run'],
50
69
  });
@@ -99,7 +118,7 @@ describe('browser launch options', () => {
99
118
  it('omits browser selection options by default', () => {
100
119
  const playwright = buildPlaywrightLaunchOptions({
101
120
  headless: false,
102
- slowMo: 150,
121
+ slowMo: 0,
103
122
  chromeArgs: [],
104
123
  });
105
124
  const puppeteer = buildPuppeteerLaunchOptions({