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,95 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+ import path from 'node:path';
4
+
5
+ import {
6
+ createSourceKeyResolver,
7
+ localStatePathForProfile,
8
+ resolveTargetKey,
9
+ } from '../../../../src/browser/migration/os-crypt-keys.js';
10
+ import { deriveChromiumCookieKey } from '../../../../src/browser/browser-cookie-crypto.js';
11
+
12
+ describe('createSourceKeyResolver', () => {
13
+ it('uses the hardcoded key for Linux v10', async () => {
14
+ const resolve = createSourceKeyResolver({
15
+ browser: 'chromium',
16
+ platform: 'linux',
17
+ });
18
+ const key = await resolve('v10');
19
+ assert.deepEqual(key, deriveChromiumCookieKey('peanuts', 'linux'));
20
+ });
21
+
22
+ it('derives the Safe Storage key for Linux v11 and caches it', async () => {
23
+ let calls = 0;
24
+ const resolve = createSourceKeyResolver({
25
+ browser: 'chrome',
26
+ platform: 'linux',
27
+ readSafeStoragePassword: async () => {
28
+ calls += 1;
29
+ return 'keyring-pass';
30
+ },
31
+ });
32
+ const first = await resolve('v11');
33
+ const second = await resolve('v11');
34
+ assert.deepEqual(first, deriveChromiumCookieKey('keyring-pass', 'linux'));
35
+ assert.deepEqual(second, first);
36
+ assert.equal(calls, 1);
37
+ });
38
+
39
+ it('reads the Windows key from Local State', async () => {
40
+ const windowsKey = Buffer.alloc(32, 9);
41
+ const resolve = createSourceKeyResolver({
42
+ browser: 'chrome',
43
+ platform: 'win32',
44
+ localStatePath: '/fake/Local State',
45
+ readWindowsEncryptionKey: async ({ localStatePath }) => {
46
+ assert.equal(localStatePath, '/fake/Local State');
47
+ return windowsKey;
48
+ },
49
+ });
50
+ assert.deepEqual(await resolve('v10'), windowsKey);
51
+ });
52
+
53
+ it('derives the macOS Safe Storage key', async () => {
54
+ const resolve = createSourceKeyResolver({
55
+ browser: 'chrome',
56
+ platform: 'darwin',
57
+ readSafeStoragePassword: async () => 'mac-pass',
58
+ });
59
+ assert.deepEqual(
60
+ await resolve('v10'),
61
+ deriveChromiumCookieKey('mac-pass', 'darwin')
62
+ );
63
+ });
64
+ });
65
+
66
+ describe('resolveTargetKey', () => {
67
+ it('derives the launching profile key on macOS/Linux', async () => {
68
+ const result = await resolveTargetKey({
69
+ browser: 'chrome',
70
+ platform: 'linux',
71
+ readSafeStoragePassword: async () => 'target-pass',
72
+ });
73
+ assert.deepEqual(
74
+ result.key,
75
+ deriveChromiumCookieKey('target-pass', 'linux')
76
+ );
77
+ assert.equal(result.prefix, 'v11');
78
+ });
79
+
80
+ it('refuses to derive a Windows key', async () => {
81
+ await assert.rejects(
82
+ resolveTargetKey({ browser: 'chrome', platform: 'win32' }),
83
+ /createWindowsProfileKey/
84
+ );
85
+ });
86
+ });
87
+
88
+ describe('localStatePathForProfile', () => {
89
+ it('points at Local State next to the profile directory', () => {
90
+ assert.equal(
91
+ localStatePathForProfile('/root/google-chrome/Default'),
92
+ path.join('/root/google-chrome', 'Local State')
93
+ );
94
+ });
95
+ });
@@ -0,0 +1,144 @@
1
+ import assert from 'node:assert';
2
+ import path from 'node:path';
3
+ import { describe, it } from 'node:test';
4
+
5
+ import BetterSqlite3 from 'better-sqlite3';
6
+
7
+ import { migratePasswords } from '../../../../src/browser/migration/passwords.js';
8
+ import { encryptChromiumValue } from '../../../../src/browser/migration/chromium-crypto.js';
9
+ import { deriveChromiumCookieKey } from '../../../../src/browser/browser-cookie-crypto.js';
10
+ import {
11
+ assertNothingMigrated,
12
+ assertSourceUnchanged,
13
+ migrateBetween,
14
+ readMigratedLogins,
15
+ } from '../../../helpers/migration-fixtures.js';
16
+ import { useTempDirectories } from '../../../helpers/temp-directory.js';
17
+
18
+ const makeTempDir = useTempDirectories();
19
+
20
+ function writeLoginData(dir, rows) {
21
+ const db = new BetterSqlite3(path.join(dir, 'Login Data'));
22
+ db.exec(
23
+ 'CREATE TABLE logins (origin_url TEXT, username_value TEXT, password_value BLOB)'
24
+ );
25
+ const insert = db.prepare(
26
+ 'INSERT INTO logins (origin_url, username_value, password_value) VALUES (?, ?, ?)'
27
+ );
28
+ for (const row of rows) {
29
+ insert.run(row.originUrl, row.username, row.passwordValue);
30
+ }
31
+ db.close();
32
+ }
33
+
34
+ // A source login whose password is encrypted with the Linux source key.
35
+ function encryptedLogin({ originUrl, username, plaintext, key, prefix }) {
36
+ return {
37
+ originUrl,
38
+ username,
39
+ passwordValue: encryptChromiumValue({
40
+ plaintext,
41
+ key,
42
+ platform: 'linux',
43
+ prefix,
44
+ }),
45
+ };
46
+ }
47
+
48
+ // Migrate on Linux, decrypting the source with `sourceKey`.
49
+ function migrateLinuxPasswords(source, target, { sourceKey, ...options }) {
50
+ return migrateBetween(migratePasswords, source, target, {
51
+ platform: 'linux',
52
+ resolveSourceKey: () => sourceKey,
53
+ ...options,
54
+ });
55
+ }
56
+
57
+ describe('migratePasswords', () => {
58
+ it('re-encrypts each password for the target key on Linux', async () => {
59
+ const source = await makeTempDir('bc-pw-src-');
60
+ const target = await makeTempDir('bc-pw-dst-');
61
+ const sourceKey = deriveChromiumCookieKey('source-pass', 'linux');
62
+ const targetKey = deriveChromiumCookieKey('target-pass', 'linux');
63
+
64
+ writeLoginData(source, [
65
+ encryptedLogin({
66
+ originUrl: 'https://a.example/login',
67
+ username: 'alice',
68
+ plaintext: 'secret-A',
69
+ key: sourceKey,
70
+ prefix: 'v11',
71
+ }),
72
+ encryptedLogin({
73
+ originUrl: 'https://b.example/login',
74
+ username: 'bob',
75
+ plaintext: 'secret-B',
76
+ key: sourceKey,
77
+ prefix: 'v10',
78
+ }),
79
+ ]);
80
+
81
+ const report = await migrateLinuxPasswords(source, target, {
82
+ sourceKey,
83
+ targetKey,
84
+ targetPrefix: 'v11',
85
+ });
86
+
87
+ assert.equal(report.migrated, 2);
88
+ assert.deepEqual(report.skipped, []);
89
+
90
+ const decrypted = readMigratedLogins(target, targetKey).map(
91
+ (login) => login.password
92
+ );
93
+ assert.deepEqual(decrypted, ['secret-A', 'secret-B']);
94
+ });
95
+
96
+ it('reports app-bound v20 passwords as skipped', async () => {
97
+ const source = await makeTempDir('bc-pw-src-');
98
+ const target = await makeTempDir('bc-pw-dst-');
99
+ const targetKey = deriveChromiumCookieKey('target-pass', 'linux');
100
+ writeLoginData(source, [
101
+ {
102
+ originUrl: 'https://locked.example/login',
103
+ username: 'carol',
104
+ passwordValue: Buffer.concat([Buffer.from('v20'), Buffer.alloc(32, 1)]),
105
+ },
106
+ ]);
107
+
108
+ const report = await migrateLinuxPasswords(source, target, {
109
+ sourceKey: targetKey,
110
+ targetKey,
111
+ });
112
+
113
+ assertNothingMigrated(report, 'app-bound-v20');
114
+ });
115
+
116
+ it('never modifies the source Login Data', async () => {
117
+ const source = await makeTempDir('bc-pw-src-');
118
+ const target = await makeTempDir('bc-pw-dst-');
119
+ const key = deriveChromiumCookieKey('pw', 'linux');
120
+ writeLoginData(source, [
121
+ encryptedLogin({
122
+ originUrl: 'https://a.example/login',
123
+ username: 'a',
124
+ plaintext: 'x',
125
+ key,
126
+ prefix: 'v11',
127
+ }),
128
+ ]);
129
+
130
+ await assertSourceUnchanged(path.join(source, 'Login Data'), () =>
131
+ migrateLinuxPasswords(source, target, { sourceKey: key, targetKey: key })
132
+ );
133
+ });
134
+
135
+ it('reports a skip when there is no Login Data', async () => {
136
+ const source = await makeTempDir('bc-pw-src-');
137
+ const target = await makeTempDir('bc-pw-dst-');
138
+ const report = await migrateLinuxPasswords(source, target, {
139
+ sourceKey: Buffer.alloc(16),
140
+ targetKey: Buffer.alloc(16),
141
+ });
142
+ assertNothingMigrated(report, 'source-missing');
143
+ });
144
+ });
@@ -0,0 +1,77 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import {
5
+ MIGRATED_PREFERENCE_PATHS,
6
+ mergePreferenceSubset,
7
+ migratePreferences,
8
+ } from '../../../../src/browser/migration/preferences.js';
9
+ import {
10
+ assertNothingMigrated,
11
+ migrateBetween,
12
+ readProfileJson,
13
+ writeProfileJson,
14
+ } from '../../../helpers/migration-fixtures.js';
15
+ import { useTempDirectories } from '../../../helpers/temp-directory.js';
16
+
17
+ const makeTempDir = useTempDirectories('bc-prefs-');
18
+
19
+ describe('mergePreferenceSubset', () => {
20
+ it('copies only the documented subset and skips missing paths', () => {
21
+ const source = {
22
+ intl: { accept_languages: 'en-US,en' },
23
+ download: { default_directory: '/home/alice/Downloads' },
24
+ homepage: 'https://start.example/',
25
+ unrelated: { setting: true },
26
+ };
27
+ const target = {};
28
+ const { migratedPaths } = mergePreferenceSubset(source, target);
29
+
30
+ assert.equal(target.intl.accept_languages, 'en-US,en');
31
+ assert.equal(target.homepage, 'https://start.example/');
32
+ assert.equal(target.unrelated, undefined);
33
+ assert.ok(migratedPaths.includes('intl.accept_languages'));
34
+ assert.ok(migratedPaths.includes('homepage'));
35
+ });
36
+
37
+ it('never migrates download.default_directory', () => {
38
+ assert.ok(
39
+ !MIGRATED_PREFERENCE_PATHS.includes('download.default_directory')
40
+ );
41
+ const source = { download: { default_directory: '/tmp/dl' } };
42
+ const target = {};
43
+ mergePreferenceSubset(source, target);
44
+ assert.equal(target.download, undefined);
45
+ });
46
+ });
47
+
48
+ describe('migratePreferences', () => {
49
+ it('merges the subset into an existing target Preferences', async () => {
50
+ const source = await makeTempDir();
51
+ const target = await makeTempDir();
52
+ await writeProfileJson(source, 'Preferences', {
53
+ intl: { accept_languages: 'de-DE,de' },
54
+ download: { default_directory: '/home/bob/Downloads' },
55
+ session: { restore_on_startup: 4, startup_urls: ['https://x/'] },
56
+ });
57
+ await writeProfileJson(target, 'Preferences', {
58
+ profile: { name: 'existing' },
59
+ });
60
+
61
+ const report = await migrateBetween(migratePreferences, source, target);
62
+
63
+ assert.ok(report.migrated >= 2);
64
+ const merged = await readProfileJson(target, 'Preferences');
65
+ assert.equal(merged.intl.accept_languages, 'de-DE,de');
66
+ assert.equal(merged.session.restore_on_startup, 4);
67
+ assert.equal(merged.profile.name, 'existing');
68
+ assert.equal(merged.download, undefined);
69
+ });
70
+
71
+ it('reports a skip when the source has no Preferences', async () => {
72
+ const source = await makeTempDir();
73
+ const target = await makeTempDir();
74
+ const report = await migrateBetween(migratePreferences, source, target);
75
+ assertNothingMigrated(report, 'source-missing');
76
+ });
77
+ });
@@ -0,0 +1,75 @@
1
+ import assert from 'node:assert';
2
+ import { stat } from 'node:fs/promises';
3
+ import { describe, it } from 'node:test';
4
+
5
+ import BetterSqlite3 from 'better-sqlite3';
6
+
7
+ import {
8
+ readDatabaseSnapshot,
9
+ withDatabaseSnapshot,
10
+ } from '../../../../src/browser/migration/sqlite-snapshot.js';
11
+ import {
12
+ assertSourceUnchanged,
13
+ writeChromiumHistory,
14
+ } from '../../../helpers/migration-fixtures.js';
15
+ import { useTempDirectories } from '../../../helpers/temp-directory.js';
16
+
17
+ const makeTempDir = useTempDirectories('bc-snap-');
18
+
19
+ async function makeDatabase(rowCount) {
20
+ return writeChromiumHistory(await makeTempDir(), rowCount);
21
+ }
22
+
23
+ describe('withDatabaseSnapshot', () => {
24
+ it('produces a consistent snapshot readable by the reader', async () => {
25
+ const dbPath = await makeDatabase(3);
26
+ const count = await withDatabaseSnapshot({
27
+ sourcePath: dbPath,
28
+ read: (snapshotPath) => {
29
+ const db = new BetterSqlite3(snapshotPath, { readonly: true });
30
+ try {
31
+ return db.prepare('SELECT COUNT(*) AS c FROM urls').get().c;
32
+ } finally {
33
+ db.close();
34
+ }
35
+ },
36
+ });
37
+ assert.equal(count, 3);
38
+ });
39
+
40
+ it('deletes the snapshot after the reader completes', async () => {
41
+ const dbPath = await makeDatabase(1);
42
+ let capturedPath;
43
+ await withDatabaseSnapshot({
44
+ sourcePath: dbPath,
45
+ read: (snapshotPath) => {
46
+ capturedPath = snapshotPath;
47
+ },
48
+ });
49
+ await assert.rejects(stat(capturedPath));
50
+ });
51
+
52
+ it('rejects when the source does not exist', async () => {
53
+ await assert.rejects(
54
+ withDatabaseSnapshot({
55
+ sourcePath: '/no/such/History',
56
+ read: () => null,
57
+ }),
58
+ /does not exist/
59
+ );
60
+ });
61
+ });
62
+
63
+ describe('readDatabaseSnapshot', () => {
64
+ it('opens the snapshot read-only and does not modify the source', async () => {
65
+ const dbPath = await makeDatabase(2);
66
+ let count;
67
+ await assertSourceUnchanged(dbPath, async () => {
68
+ count = await readDatabaseSnapshot({
69
+ sourcePath: dbPath,
70
+ read: (db) => db.prepare('SELECT COUNT(*) AS c FROM urls').get().c,
71
+ });
72
+ });
73
+ assert.equal(Number(count), 2);
74
+ });
75
+ });
@@ -0,0 +1,123 @@
1
+ // feature-parity: attach.open
2
+ import assert from 'node:assert';
3
+ import { describe, it } from 'node:test';
4
+
5
+ import {
6
+ buildOpenCommand,
7
+ openInUserBrowser,
8
+ validateOpenUrl,
9
+ } from '../../../src/browser/open-in-user-browser.js';
10
+ import { openInUserBrowser as publicOpenInUserBrowser } from '../../../src/index.js';
11
+
12
+ function recordingRunner() {
13
+ const calls = [];
14
+ const runner = async (file, args, options) => {
15
+ calls.push({ file, args, options });
16
+ return { stdout: '', stderr: '', code: 0 };
17
+ };
18
+ return { calls, runner };
19
+ }
20
+
21
+ describe('validateOpenUrl', () => {
22
+ it('accepts http, https and file URLs', () => {
23
+ assert.equal(
24
+ validateOpenUrl('https://example.com/'),
25
+ 'https://example.com/'
26
+ );
27
+ assert.equal(validateOpenUrl('http://example.com'), 'http://example.com');
28
+ assert.equal(validateOpenUrl('file:///tmp/x.html'), 'file:///tmp/x.html');
29
+ });
30
+
31
+ it('rejects strings that could be parsed as an option', () => {
32
+ assert.throws(() => validateOpenUrl('--version'), /cannot start with "-"/);
33
+ assert.throws(() => validateOpenUrl('-e'), /cannot start with "-"/);
34
+ });
35
+
36
+ it('rejects non-URL strings and disallowed schemes', () => {
37
+ assert.throws(
38
+ () => validateOpenUrl('not a url'),
39
+ /not a valid absolute URL/
40
+ );
41
+ assert.throws(
42
+ () => validateOpenUrl('javascript:alert(1)'),
43
+ /is not one of/
44
+ );
45
+ assert.throws(() => validateOpenUrl(''), /requires a URL string/);
46
+ assert.throws(() => validateOpenUrl(undefined), /requires a URL string/);
47
+ });
48
+ });
49
+
50
+ describe('buildOpenCommand', () => {
51
+ it('uses open on macOS', () => {
52
+ assert.deepEqual(buildOpenCommand('https://x.dev/', 'darwin'), [
53
+ 'open',
54
+ 'https://x.dev/',
55
+ ]);
56
+ });
57
+
58
+ it('uses xdg-open on Linux', () => {
59
+ assert.deepEqual(buildOpenCommand('https://x.dev/', 'linux'), [
60
+ 'xdg-open',
61
+ 'https://x.dev/',
62
+ ]);
63
+ });
64
+
65
+ it('uses Explorer on Windows without parsing URL metacharacters as shell syntax', () => {
66
+ assert.deepEqual(buildOpenCommand('https://x.dev/', 'win32'), [
67
+ 'explorer.exe',
68
+ 'https://x.dev/',
69
+ ]);
70
+ assert.deepEqual(buildOpenCommand('https://x.dev/?a=1&b=2', 'win32'), [
71
+ 'explorer.exe',
72
+ 'https://x.dev/?a=1&b=2',
73
+ ]);
74
+ });
75
+
76
+ it('throws on an unsupported platform', () => {
77
+ assert.throws(
78
+ () => buildOpenCommand('https://x.dev/', 'sunos'),
79
+ /not supported/
80
+ );
81
+ });
82
+ });
83
+
84
+ describe('openInUserBrowser', () => {
85
+ for (const platform of ['darwin', 'linux', 'win32']) {
86
+ it(`runs the ${platform} opener and returns the command`, async () => {
87
+ const { calls, runner } = recordingRunner();
88
+ const result = await openInUserBrowser('https://example.com/', {
89
+ platform,
90
+ runner,
91
+ });
92
+ const expected = buildOpenCommand('https://example.com/', platform);
93
+ assert.equal(result.opened, 'https://example.com/');
94
+ assert.deepEqual(result.command, expected);
95
+ assert.equal(calls.length, 1);
96
+ assert.equal(calls[0].file, expected[0]);
97
+ assert.deepEqual(calls[0].args, expected.slice(1));
98
+ });
99
+ }
100
+
101
+ it('passes env through to the runner when provided', async () => {
102
+ const { calls, runner } = recordingRunner();
103
+ await openInUserBrowser('https://example.com/', {
104
+ platform: 'linux',
105
+ runner,
106
+ env: { DISPLAY: ':1' },
107
+ });
108
+ assert.deepEqual(calls[0].options, { env: { DISPLAY: ':1' } });
109
+ });
110
+
111
+ it('validates before running', async () => {
112
+ const { calls, runner } = recordingRunner();
113
+ await assert.rejects(
114
+ openInUserBrowser('-e', { platform: 'linux', runner }),
115
+ /cannot start with "-"/
116
+ );
117
+ assert.equal(calls.length, 0);
118
+ });
119
+
120
+ it('is exported from the package entry point', () => {
121
+ assert.equal(typeof publicOpenInUserBrowser, 'function');
122
+ });
123
+ });