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,138 @@
1
+ /**
2
+ * The reference capture starts a browser nothing automates and waits for the
3
+ * probe page to POST its report. A browser that cannot start at all (no usable
4
+ * sandbox, a rejected switch) never reports, so the capture has to fail with
5
+ * the browser's own exit code and stderr rather than a bare 60 s timeout.
6
+ */
7
+ import assert from 'node:assert/strict';
8
+ import { describe, it } from 'node:test';
9
+
10
+ import { captureReferenceReport } from '../../../src/parity/harness.js';
11
+
12
+ function fakeProcess({ exitCode, stderr = '' }) {
13
+ const listeners = new Set();
14
+ let resolveExit;
15
+ const child = {
16
+ exited: new Promise((resolve) => {
17
+ resolveExit = resolve;
18
+ }),
19
+ stderr: {
20
+ on(event, listener) {
21
+ listeners.add(listener);
22
+ },
23
+ },
24
+ kill() {
25
+ resolveExit(143);
26
+ },
27
+ };
28
+ setTimeout(() => {
29
+ for (const listener of listeners) {
30
+ listener(stderr);
31
+ }
32
+ if (exitCode !== null) {
33
+ resolveExit(exitCode);
34
+ }
35
+ }, 0);
36
+ return child;
37
+ }
38
+
39
+ function fakeServer({ report = null } = {}) {
40
+ return {
41
+ url: (token) => `http://127.0.0.1:1/probe/${token}`,
42
+ waitForReport: (token, timeoutMs) =>
43
+ report
44
+ ? new Promise((resolve) => setTimeout(() => resolve(report), 20))
45
+ : new Promise((resolve, reject) =>
46
+ setTimeout(
47
+ () => reject(new Error(`timed out waiting for report ${token}`)),
48
+ timeoutMs
49
+ )
50
+ ),
51
+ };
52
+ }
53
+
54
+ describe('captureReferenceReport', () => {
55
+ it('fails fast with the exit code and stderr of a browser that died', async () => {
56
+ const started = Date.now();
57
+ await assert.rejects(
58
+ captureReferenceReport({
59
+ executablePath: '/usr/bin/chromium',
60
+ server: fakeServer(),
61
+ token: 't1',
62
+ timeoutMs: 3000,
63
+ start: () =>
64
+ fakeProcess({
65
+ exitCode: 1,
66
+ stderr: 'FATAL: No usable sandbox!',
67
+ }),
68
+ }),
69
+ (error) => {
70
+ assert.match(error.message, /\/usr\/bin\/chromium exited with code 1/);
71
+ assert.match(error.message, /No usable sandbox/);
72
+ return true;
73
+ }
74
+ );
75
+ assert.ok(Date.now() - started < 5000);
76
+ });
77
+
78
+ it('includes stderr when the report times out', async () => {
79
+ await assert.rejects(
80
+ captureReferenceReport({
81
+ server: fakeServer(),
82
+ token: 't2',
83
+ timeoutMs: 50,
84
+ start: () => fakeProcess({ exitCode: null, stderr: 'GPU init failed' }),
85
+ }),
86
+ /timed out waiting for report t2\nbrowser stderr:\nGPU init failed/
87
+ );
88
+ });
89
+
90
+ it('retries a fresh profile after the browser network service crashes', async () => {
91
+ const tokens = [];
92
+ let starts = 0;
93
+ const report = await captureReferenceReport({
94
+ server: {
95
+ url: (token) => {
96
+ tokens.push(token);
97
+ return `http://127.0.0.1:1/probe/${token}`;
98
+ },
99
+ waitForReport: (token) =>
100
+ token === 't4'
101
+ ? new Promise((resolve, reject) =>
102
+ setTimeout(
103
+ () =>
104
+ reject(new Error(`timed out waiting for report ${token}`)),
105
+ 20
106
+ )
107
+ )
108
+ : Promise.resolve({ navigator: { webdriver: false } }),
109
+ },
110
+ token: 't4',
111
+ timeoutMs: 50,
112
+ start: () => {
113
+ starts += 1;
114
+ return fakeProcess({
115
+ exitCode: null,
116
+ stderr:
117
+ starts === 1
118
+ ? 'Network service crashed or was terminated, restarting service.'
119
+ : '',
120
+ });
121
+ },
122
+ });
123
+ assert.deepEqual(report, { navigator: { webdriver: false } });
124
+ assert.equal(starts, 2);
125
+ assert.equal(tokens.length, 2);
126
+ assert.notEqual(tokens[0], tokens[1]);
127
+ });
128
+
129
+ it('keeps waiting when a launcher script exits cleanly', async () => {
130
+ const report = await captureReferenceReport({
131
+ server: fakeServer({ report: { navigator: { webdriver: false } } }),
132
+ token: 't3',
133
+ start: () => fakeProcess({ exitCode: 0 }),
134
+ });
135
+ assert.deepEqual(report, { navigator: { webdriver: false } });
136
+ assert.ok(Array.isArray(report.commandLine));
137
+ });
138
+ });
@@ -196,7 +196,11 @@ describe('CI execution budgets', () => {
196
196
  assert.deepEqual(budgeted, [
197
197
  'docs.yml build-docs Rust API docs',
198
198
  'js.yml test Node.js test suite',
199
+ 'parity.yml cli CLI and API coverage suites',
200
+ 'parity.yml cli Cross-language CLI contract',
201
+ 'parity.yml cli Rust CLI build',
199
202
  'parity.yml parity Fingerprint parity suite',
203
+ 'parity.yml parity WebDriver suite',
200
204
  'python.yml test pytest suite',
201
205
  'rust.yml coverage Rust code coverage',
202
206
  'rust.yml test Rust doc tests',
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The generated feature-parity matrix (issue #104).
3
+ *
4
+ * `scripts/generate-feature-parity.mjs` builds the per-language matrix in
5
+ * docs/feature-parity.md from the `feature-parity:` tags in the three test
6
+ * suites, and fails when a feature is tested in one language and missing in
7
+ * another without a reason in docs/feature-parity/limitations.json. These
8
+ * tests pin those rules against a fixture tree, then pin the repository
9
+ * itself: the committed document must be what the generator writes.
10
+ *
11
+ * Fixture tags are assembled at run time, because the generator scans this
12
+ * file too and a literal tag here would claim a feature.
13
+ */
14
+
15
+ import assert from 'node:assert/strict';
16
+ import * as fs from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import path from 'node:path';
19
+ import { describe, it } from 'node:test';
20
+ import { pathToFileURL } from 'node:url';
21
+
22
+ import { repoPath } from '../../helpers/repo.js';
23
+
24
+ const {
25
+ BEGIN_MARKER,
26
+ END_MARKER,
27
+ escapeCell,
28
+ generate,
29
+ parseTags,
30
+ renderTable,
31
+ } = await import(
32
+ pathToFileURL(repoPath('scripts/generate-feature-parity.mjs'))
33
+ );
34
+
35
+ const TAG = ['feature', 'parity:'].join('-');
36
+
37
+ function tag(comment, ids) {
38
+ return `${comment} ${TAG} ${ids}\n`;
39
+ }
40
+
41
+ function fixture({ tests, limitations = [] }) {
42
+ const root = fs.mkdtempSync(path.join(tmpdir(), 'feature-parity-'));
43
+ const write = (relative, content) => {
44
+ const full = path.join(root, relative);
45
+ fs.mkdirSync(path.dirname(full), { recursive: true });
46
+ fs.writeFileSync(full, content);
47
+ };
48
+ write(
49
+ 'docs/feature-parity/features.json',
50
+ JSON.stringify({
51
+ sections: [
52
+ {
53
+ title: 'Launch',
54
+ features: [
55
+ { id: 'launch.real', title: 'Real launch' },
56
+ { id: 'launch.attach', title: 'Attach | snapshot' },
57
+ ],
58
+ },
59
+ ],
60
+ })
61
+ );
62
+ write(
63
+ 'docs/feature-parity/limitations.json',
64
+ JSON.stringify(limitations, null, 2)
65
+ );
66
+ write(
67
+ 'docs/feature-parity.md',
68
+ `# Parity\n\nprose\n\n${BEGIN_MARKER}\n${END_MARKER}\n\ntail\n`
69
+ );
70
+ for (const [relative, content] of Object.entries(tests)) {
71
+ write(relative, content);
72
+ }
73
+ return root;
74
+ }
75
+
76
+ const ALL_LANGUAGES = {
77
+ 'js/tests/unit/launch.test.js': tag('//', 'launch.real launch.attach'),
78
+ 'python/tests/unit/test_launch.py': tag('#', 'launch.real, launch.attach'),
79
+ 'rust/src/launch.rs': `fn x() {}\n${tag(' //', 'launch.real')}${tag('//', 'launch.attach')}`,
80
+ };
81
+
82
+ describe('feature parity generator', () => {
83
+ it('reads space- and comma-separated tags from line comments only', () => {
84
+ assert.deepEqual(parseTags(tag('//', 'a.b c-d')), ['a.b', 'c-d']);
85
+ assert.deepEqual(parseTags(tag(' #', 'a.b,c.d')), ['a.b', 'c.d']);
86
+ assert.deepEqual(parseTags(`const s = '${tag('//', 'a.b').trim()}';`), []);
87
+ assert.deepEqual(parseTags(tag('//', 'a.b').replaceAll('\n', '\r\n')), [
88
+ 'a.b',
89
+ ]);
90
+ });
91
+
92
+ it('escapes both Markdown pipes and backslashes in feature names', () => {
93
+ assert.equal(escapeCell('a\\b|c\nx'), 'a\\\\b\\|c x');
94
+ });
95
+
96
+ it('accepts a generated document checked out with CRLF endings', () => {
97
+ const root = fixture({ tests: ALL_LANGUAGES });
98
+ try {
99
+ const documentPath = path.join(root, 'docs/feature-parity.md');
100
+ const { next } = generate(root);
101
+ fs.writeFileSync(documentPath, next.replaceAll('\n', '\r\n'));
102
+ const generated = generate(root);
103
+ assert.equal(generated.current, generated.next);
104
+ assert.equal(generated.next.includes('\r'), false);
105
+ } finally {
106
+ fs.rmSync(root, { recursive: true, force: true });
107
+ }
108
+ });
109
+
110
+ it('pads tables so the output is stable under Prettier', () => {
111
+ assert.equal(
112
+ renderTable(['A', 'Long header'], [['wide cell', 'x']]),
113
+ [
114
+ '| A | Long header |',
115
+ '| --------- | ----------- |',
116
+ '| wide cell | x |',
117
+ ].join('\n')
118
+ );
119
+ });
120
+
121
+ it('writes a matrix with links to the claiming tests', () => {
122
+ const root = fixture({ tests: ALL_LANGUAGES });
123
+ try {
124
+ const { next, errors } = generate(root);
125
+ assert.deepEqual(errors, []);
126
+ assert.match(next, /^# Parity\n\nprose\n\n<!-- feature-parity/u);
127
+ assert.match(
128
+ next,
129
+ /\| Real launch +\| \[Supported\]\(\.\.\/js\/tests\/unit\/launch\.test\.js\) +\| \[Supported\]\(\.\.\/python\/tests\/unit\/test_launch\.py\) +\| \[Supported\]\(\.\.\/rust\/src\/launch\.rs\) +\|/u
130
+ );
131
+ assert.match(next, /Attach \\\| snapshot/u);
132
+ assert.match(next, /None: every feature is tested in every language/u);
133
+ assert.match(next, /\n\ntail\n$/u);
134
+ } finally {
135
+ fs.rmSync(root, { recursive: true, force: true });
136
+ }
137
+ });
138
+
139
+ it('fails on a gap without a limitation', () => {
140
+ const tests = { ...ALL_LANGUAGES };
141
+ tests['rust/src/launch.rs'] = tag('//', 'launch.real');
142
+ const root = fixture({ tests });
143
+ try {
144
+ const { errors, next } = generate(root);
145
+ assert.deepEqual(errors, [
146
+ 'feature "launch.attach" is tested in javascript, python but not in ' +
147
+ 'rust, and docs/feature-parity/limitations.json gives no reason',
148
+ ]);
149
+ assert.match(next, /\| Missing +\|/u);
150
+ } finally {
151
+ fs.rmSync(root, { recursive: true, force: true });
152
+ }
153
+ });
154
+
155
+ it('accepts a gap with a technical reason and links to it', () => {
156
+ const tests = { ...ALL_LANGUAGES };
157
+ tests['rust/src/launch.rs'] = tag('//', 'launch.real');
158
+ const root = fixture({
159
+ tests,
160
+ limitations: [
161
+ {
162
+ id: 'rust-no-attach',
163
+ features: ['launch.attach'],
164
+ languages: ['rust'],
165
+ reason: 'The crate has no WebSocket server.',
166
+ },
167
+ ],
168
+ });
169
+ try {
170
+ const { errors, next } = generate(root);
171
+ assert.deepEqual(errors, []);
172
+ assert.match(next, /\[Limitation\]\(#rust-no-attach\)/u);
173
+ assert.match(
174
+ next,
175
+ /<a id="rust-no-attach"><\/a>\*\*`rust-no-attach`\*\* \(Rust\): The crate has no WebSocket server\./u
176
+ );
177
+ } finally {
178
+ fs.rmSync(root, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ it('rejects stale, reasonless and unknown entries', () => {
183
+ const tests = {
184
+ ...ALL_LANGUAGES,
185
+ 'js/tests/unit/extra.test.js': tag('//', 'launch.unknown'),
186
+ };
187
+ const root = fixture({
188
+ tests,
189
+ limitations: [
190
+ {
191
+ id: 'stale',
192
+ features: ['launch.real', 'launch.missing'],
193
+ languages: ['rust', 'cobol'],
194
+ reason: ' ',
195
+ },
196
+ ],
197
+ });
198
+ try {
199
+ const { errors } = generate(root);
200
+ assert.deepEqual(errors.sort(), [
201
+ 'limitation "stale" covers launch.real in rust, but rust/src/launch.rs tests it; remove the stale entry',
202
+ 'limitation "stale" gives no technical reason',
203
+ 'limitation "stale" names unknown feature "launch.missing"',
204
+ 'limitation "stale" names unknown language "cobol"',
205
+ 'unknown feature "launch.unknown" claimed by js/tests/unit/extra.test.js; add it to docs/feature-parity/features.json',
206
+ ]);
207
+ } finally {
208
+ fs.rmSync(root, { recursive: true, force: true });
209
+ }
210
+ });
211
+
212
+ it('keeps docs/feature-parity.md in sync with the tests', () => {
213
+ const { current, next, errors } = generate(repoPath());
214
+ assert.deepEqual(errors, []);
215
+ assert.equal(
216
+ current,
217
+ next,
218
+ 'run node scripts/generate-feature-parity.mjs and commit the result'
219
+ );
220
+ });
221
+ });
@@ -1,3 +1,4 @@
1
+ // feature-parity: trace.read
1
2
  import { describe, it } from 'node:test';
2
3
  import assert from 'node:assert';
3
4
  import fs from 'node:fs/promises';
@@ -1,3 +1,4 @@
1
+ // feature-parity: trace.record
1
2
  import { describe, it } from 'node:test';
2
3
  import assert from 'node:assert';
3
4
  import { setImmediate as yieldToEngine } from 'node:timers/promises';
@@ -0,0 +1,28 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { readdirSync, readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ import { repoPath } from '../../helpers/repo.js';
7
+
8
+ const SOURCE_ROOT = repoPath('js', 'src');
9
+ const DIRECT_SUBPROCESS = /from\s+['"](?:node:)?child_process['"]/u;
10
+
11
+ function listSourceFiles(directory) {
12
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
13
+ const entryPath = path.join(directory, entry.name);
14
+ if (entry.isDirectory()) {
15
+ return listSourceFiles(entryPath);
16
+ }
17
+ return /\.[cm]?js$/u.test(entry.name) ? [entryPath] : [];
18
+ });
19
+ }
20
+
21
+ describe('subprocesses go through command-stream (issue #104)', () => {
22
+ it('no library module imports node:child_process directly', () => {
23
+ const offenders = listSourceFiles(SOURCE_ROOT)
24
+ .filter((file) => DIRECT_SUBPROCESS.test(readFileSync(file, 'utf8')))
25
+ .map((file) => path.relative(SOURCE_ROOT, file));
26
+ assert.deepEqual(offenders, []);
27
+ });
28
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import {
4
+ CommandError,
5
+ runCommand,
6
+ startProcess,
7
+ } from '../../../src/utilities/subprocess.js';
8
+
9
+ const node = process.execPath;
10
+
11
+ describe('runCommand', () => {
12
+ it('passes arguments without a shell and captures output', async () => {
13
+ const result = await runCommand(node, [
14
+ '-e',
15
+ 'process.stdout.write(JSON.stringify(process.argv.slice(1)))',
16
+ 'a b',
17
+ '$HOME',
18
+ '"q"',
19
+ ]);
20
+ assert.equal(result.code, 0);
21
+ assert.deepEqual(JSON.parse(result.stdout), ['a b', '$HOME', '"q"']);
22
+ });
23
+
24
+ it('writes input to stdin and sets the child environment only', async () => {
25
+ const result = await runCommand(
26
+ node,
27
+ [
28
+ '-e',
29
+ 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(s+process.env.BC_TEST_ONLY))',
30
+ ],
31
+ { input: 'in:', env: { ...process.env, BC_TEST_ONLY: 'child' } }
32
+ );
33
+ assert.equal(result.stdout, 'in:child');
34
+ assert.equal(process.env.BC_TEST_ONLY, undefined);
35
+ });
36
+
37
+ it('throws CommandError on a non-zero exit unless check is false', async () => {
38
+ const args = ['-e', 'process.stderr.write("boom");process.exit(3)'];
39
+ await assert.rejects(
40
+ () => runCommand(node, args),
41
+ (error) =>
42
+ error instanceof CommandError &&
43
+ error.code === 3 &&
44
+ error.stderr === 'boom'
45
+ );
46
+ const result = await runCommand(node, args, { check: false });
47
+ assert.equal(result.code, 3);
48
+ });
49
+ });
50
+
51
+ describe('startProcess', () => {
52
+ it('streams output and reports the exit code', async () => {
53
+ const child = startProcess(node, [
54
+ '-e',
55
+ 'process.stderr.write("hello\\n");process.exit(4)',
56
+ ]);
57
+ let stderr = '';
58
+ child.stderr.on('data', (chunk) => {
59
+ stderr += chunk;
60
+ });
61
+ const exitCode = await new Promise((resolve) =>
62
+ child.once('exit', resolve)
63
+ );
64
+ assert.equal(exitCode, 4);
65
+ assert.equal(stderr, 'hello\n');
66
+ assert.equal(await child.exited, 4);
67
+ assert.equal(child.kill(), false);
68
+ });
69
+
70
+ it('kills a running process', async () => {
71
+ const child = startProcess(node, ['-e', 'setInterval(() => {}, 1000)'], {
72
+ killGrace: 200,
73
+ });
74
+ await new Promise((resolve) => setTimeout(resolve, 100));
75
+ assert.ok(child.pid > 0);
76
+ assert.equal(child.kill(), true);
77
+ const exitCode = await child.exited;
78
+ assert.notEqual(exitCode, 0);
79
+ });
80
+ });