browser-commander 0.19.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 (155) hide show
  1. package/CHANGELOG.md +156 -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 +25 -6
  10. package/scripts/run-tests.mjs +4 -2
  11. package/src/browser/attach/differences.js +131 -0
  12. package/src/browser/attach/extension-relay.js +402 -0
  13. package/src/browser/attach/index.js +81 -0
  14. package/src/browser/attach/snapshot-launch.js +146 -0
  15. package/src/browser/attach/snapshot.js +374 -0
  16. package/src/browser/attach/websocket.js +615 -0
  17. package/src/browser/browser-cookie-cache.js +12 -17
  18. package/src/browser/browser-cookie-credentials.js +6 -9
  19. package/src/browser/browser-cookies.js +5 -2
  20. package/src/browser/cdp-session.js +94 -0
  21. package/src/browser/connector.js +42 -4
  22. package/src/browser/debugging-port.js +155 -0
  23. package/src/browser/launch-options.js +63 -52
  24. package/src/browser/launch-restrictions.json +147 -0
  25. package/src/browser/launcher.js +329 -133
  26. package/src/browser/media.js +5 -0
  27. package/src/browser/migration/bookmarks.js +73 -0
  28. package/src/browser/migration/chromium-crypto.js +89 -0
  29. package/src/browser/migration/cookies.js +155 -0
  30. package/src/browser/migration/extensions.js +187 -0
  31. package/src/browser/migration/firefox-bookmarks.js +144 -0
  32. package/src/browser/migration/firefox-der.js +84 -0
  33. package/src/browser/migration/firefox-nss.js +281 -0
  34. package/src/browser/migration/firefox.js +334 -0
  35. package/src/browser/migration/fs-utils.js +51 -0
  36. package/src/browser/migration/history.js +103 -0
  37. package/src/browser/migration/index.js +307 -0
  38. package/src/browser/migration/os-crypt-keys.js +106 -0
  39. package/src/browser/migration/passwords.js +171 -0
  40. package/src/browser/migration/preferences.js +132 -0
  41. package/src/browser/migration/sqlite-snapshot.js +138 -0
  42. package/src/browser/open-in-user-browser.js +112 -0
  43. package/src/browser/parity.js +439 -0
  44. package/src/browser/profile-directory.js +102 -0
  45. package/src/browser/real-browser.js +513 -306
  46. package/src/browser/restrictions.js +148 -0
  47. package/src/browser/system-browser.js +232 -0
  48. package/src/browser/webdriver.js +572 -0
  49. package/src/cli/args.js +253 -0
  50. package/src/cli/commands.js +332 -0
  51. package/src/cli/dispatcher.js +99 -0
  52. package/src/cli/handle-methods.js +136 -0
  53. package/src/cli/handles.js +273 -0
  54. package/src/cli/high-level-methods.js +310 -0
  55. package/src/cli/main.js +67 -0
  56. package/src/cli/modules.js +100 -0
  57. package/src/cli/rpc-error.js +77 -0
  58. package/src/cli/script.js +77 -0
  59. package/src/cli/serve.js +144 -0
  60. package/src/cli/sessions.js +195 -0
  61. package/src/core/constants.js +7 -1
  62. package/src/core/engine-adapter.js +107 -1
  63. package/src/core/engine-detection.js +18 -3
  64. package/src/core/navigation-safety.js +4 -0
  65. package/src/core/webdriver-conversions.js +347 -0
  66. package/src/core/webdriver-events.js +229 -0
  67. package/src/core/webdriver-page.js +716 -0
  68. package/src/downloads/attach.js +6 -0
  69. package/src/downloads/manager.js +9 -0
  70. package/src/downloads/sources.js +71 -0
  71. package/src/downloads/staging.js +21 -9
  72. package/src/elements/selectors.js +3 -2
  73. package/src/elements/visibility.js +4 -0
  74. package/src/exports.js +61 -2
  75. package/src/factory.js +19 -4
  76. package/src/fingerprint/apply.js +6 -0
  77. package/src/fingerprint/automation-parity.js +7 -8
  78. package/src/fingerprint/limitations.js +1 -1
  79. package/src/fingerprint/limitations.json +45 -2
  80. package/src/parity/harness.js +303 -0
  81. package/src/parity/probe.js +838 -0
  82. package/src/parity/version-page.js +144 -0
  83. package/src/traces/bundle.js +10 -2
  84. package/src/traces/recorder.js +16 -3
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/downloads.e2e.test.js +79 -2
  89. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  90. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  91. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  92. package/tests/e2e/playwright.e2e.test.js +25 -17
  93. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  94. package/tests/e2e/webdriver.e2e.test.js +239 -0
  95. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  96. package/tests/helpers/cli-fakes.js +214 -0
  97. package/tests/helpers/declared-api.js +80 -0
  98. package/tests/helpers/e2e-browser.js +40 -14
  99. package/tests/helpers/fake-chrome.js +124 -0
  100. package/tests/helpers/migration-fixtures.js +242 -0
  101. package/tests/helpers/temp-directory.js +24 -0
  102. package/tests/helpers/trace-fixtures.js +18 -0
  103. package/tests/helpers/webdriver-mocks.js +183 -0
  104. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  105. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  106. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  107. package/tests/unit/browser/attach/websocket.test.js +240 -0
  108. package/tests/unit/browser/browser-cookies.test.js +2 -32
  109. package/tests/unit/browser/cdp-session.test.js +162 -0
  110. package/tests/unit/browser/connector.test.js +23 -0
  111. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  112. package/tests/unit/browser/launch-options.test.js +41 -22
  113. package/tests/unit/browser/launcher.test.js +281 -102
  114. package/tests/unit/browser/media.test.js +13 -1
  115. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  116. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  117. package/tests/unit/browser/migration/cookies.test.js +94 -0
  118. package/tests/unit/browser/migration/extensions.test.js +104 -0
  119. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  120. package/tests/unit/browser/migration/firefox.test.js +139 -0
  121. package/tests/unit/browser/migration/history.test.js +48 -0
  122. package/tests/unit/browser/migration/index.test.js +117 -0
  123. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  124. package/tests/unit/browser/migration/passwords.test.js +144 -0
  125. package/tests/unit/browser/migration/preferences.test.js +77 -0
  126. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  127. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  128. package/tests/unit/browser/parity.test.js +281 -0
  129. package/tests/unit/browser/real-browser.test.js +458 -33
  130. package/tests/unit/browser/webdriver.test.js +473 -0
  131. package/tests/unit/cli/bin.test.js +69 -0
  132. package/tests/unit/cli/declared-api.test.js +49 -0
  133. package/tests/unit/cli/dispatcher.test.js +429 -0
  134. package/tests/unit/cli/handles.test.js +170 -0
  135. package/tests/unit/cli/main.test.js +409 -0
  136. package/tests/unit/cli/serve.test.js +167 -0
  137. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  138. package/tests/unit/core/webdriver-page.test.js +534 -0
  139. package/tests/unit/downloads/manager.test.js +82 -9
  140. package/tests/unit/downloads/staging.test.js +24 -2
  141. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  142. package/tests/unit/fingerprint/limitations.test.js +1 -1
  143. package/tests/unit/parity/harness.test.js +138 -0
  144. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  145. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  146. package/tests/unit/scripts/feature-parity.test.js +221 -0
  147. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  148. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  149. package/tests/unit/traces/bundle.test.js +24 -3
  150. package/tests/unit/traces/links.test.js +7 -0
  151. package/tests/unit/traces/reader.test.js +11 -4
  152. package/tests/unit/traces/recorder.test.js +4 -0
  153. package/tests/unit/traces/viewer.test.js +4 -0
  154. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  155. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -22,7 +22,7 @@ describe('AutomationControlled triggers', () => {
22
22
  }
23
23
  });
24
24
 
25
- it('finds each switch Chromium maps onto the feature', () => {
25
+ it('finds each switch that turns the feature on', () => {
26
26
  const found = detectAutomationControlledTriggers([
27
27
  '--enable-automation',
28
28
  '--headless=new',
@@ -32,16 +32,29 @@ describe('AutomationControlled triggers', () => {
32
32
 
33
33
  assert.deepEqual(found, [
34
34
  '--enable-automation',
35
- '--headless',
36
35
  '--remote-debugging-pipe',
37
36
  '--remote-debugging-port=0',
38
37
  ]);
39
38
  });
40
39
 
40
+ it('does not treat headless as a trigger', () => {
41
+ // Measured: a hand-started headless Chrome reports navigator.webdriver
42
+ // false, because the renderer never sees the --headless switch.
43
+ assert.deepEqual(
44
+ detectAutomationControlledTriggers([
45
+ '--headless',
46
+ '--headless=new',
47
+ '--headless=old',
48
+ ]),
49
+ []
50
+ );
51
+ });
52
+
41
53
  it('reports the argument as written next to the switch it matched', () => {
42
54
  assert.deepEqual(
43
- detectAutomationControlledTriggers(['--headless=new'])[0].argument,
44
- '--headless=new'
55
+ detectAutomationControlledTriggers(['--remote-debugging-port=0'])[0]
56
+ .argument,
57
+ '--remote-debugging-port=0'
45
58
  );
46
59
  });
47
60
 
@@ -44,7 +44,7 @@ describe('the documented limitations', () => {
44
44
 
45
45
  it('is the shared JSON asset Python and Rust also publish', () => {
46
46
  // The catalogue is data, not code, so the three packages ship one file
47
- // rather than three translations of the same eleven paragraphs.
47
+ // rather than three translations of the same sixteen paragraphs.
48
48
  const asset = JSON.parse(
49
49
  readFileSync(
50
50
  new URL('../../../src/fingerprint/limitations.json', import.meta.url),
@@ -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',
@@ -155,6 +155,14 @@ jobs:
155
155
  assert.equal(countFailures(content), 2);
156
156
  });
157
157
 
158
+ it('rejects download-artifact v7 after its runtime emitted DEP0005', () => {
159
+ const content = workflow({
160
+ steps: ' - uses: actions/download-artifact@v7',
161
+ });
162
+
163
+ assert.equal(countFailures(content), 1);
164
+ });
165
+
158
166
  it('accepts a base_ref bound to any environment variable name', () => {
159
167
  const content = workflow({
160
168
  on: 'pull_request',
@@ -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
+ });
@@ -131,6 +131,7 @@ describe('test runner helper', () => {
131
131
  buildNodeTestArgs(options, ['tests/unit/a.test.js'], '24.18.0'),
132
132
  [
133
133
  '--test',
134
+ '--throw-deprecation',
134
135
  '--test-isolation=none',
135
136
  '--experimental-test-coverage',
136
137
  '--test-reporter',
@@ -144,8 +145,8 @@ describe('test runner helper', () => {
144
145
  const options = parseTestRunnerArgs(['tests/unit']);
145
146
 
146
147
  assert.deepEqual(
147
- buildNodeTestArgs(options, ['tests/unit/a.test.js'], '20.20.2'),
148
- ['--test', 'tests/unit/a.test.js']
148
+ buildNodeTestArgs(options, ['tests/unit/a.test.js'], '22.22.3'),
149
+ ['--test', '--throw-deprecation', 'tests/unit/a.test.js']
149
150
  );
150
151
  });
151
152
  });
@@ -48,7 +48,8 @@ const MIRRORED_COMMANDS = [
48
48
  {
49
49
  id: 'python-mypy-scripts',
50
50
  workflow: 'python.yml',
51
- command: 'mypy --python-version 3.13 scripts tests/unit/scripts',
51
+ command:
52
+ 'mypy --no-warn-unused-configs --python-version 3.13 scripts tests/unit/scripts',
52
53
  },
53
54
  {
54
55
  id: 'rust-fmt',
@@ -16,10 +16,14 @@ import {
16
16
  TRACE_MODE,
17
17
  TRACE_OUTCOME,
18
18
  } from '../../../src/traces/schema.js';
19
- import { useTempTraceDirectory } from '../../helpers/trace-fixtures.js';
19
+ import {
20
+ useTempTraceDirectory,
21
+ useTraceBundleCleanup,
22
+ } from '../../helpers/trace-fixtures.js';
20
23
 
21
24
  describe('trace bundle writer (issue #87)', () => {
22
25
  const directory = useTempTraceDirectory();
26
+ const cleanup = useTraceBundleCleanup();
23
27
 
24
28
  /**
25
29
  * Open a bundle inside the suite's temporary directory.
@@ -27,8 +31,13 @@ describe('trace bundle writer (issue #87)', () => {
27
31
  * @param {Object} [options] - Options for `openTraceBundle`
28
32
  * @returns {Promise<Object>} The bundle writer
29
33
  */
30
- const open = (options = {}) =>
31
- openTraceBundle({ output: path.join(directory.path, 'run'), ...options });
34
+ const open = async (options = {}) =>
35
+ cleanup(
36
+ await openTraceBundle({
37
+ output: path.join(directory.path, 'run'),
38
+ ...options,
39
+ })
40
+ );
32
41
 
33
42
  /**
34
43
  * List a bundle's members, relative to its root.
@@ -66,6 +75,18 @@ describe('trace bundle writer (issue #87)', () => {
66
75
  await assert.rejects(openTraceBundle({}), /trace output must be a path/);
67
76
  });
68
77
 
78
+ it('should abort an unfinished bundle without writing a manifest', async () => {
79
+ const bundle = await open();
80
+ await bundle.appendEvent({ kind: TRACE_EVENT.CHECKPOINT });
81
+
82
+ await bundle.abort();
83
+ await bundle.abort();
84
+
85
+ await assert.rejects(
86
+ fs.access(path.join(bundle.root, TRACE_FILES.MANIFEST))
87
+ );
88
+ });
89
+
69
90
  it('should write the layout the format documents', async () => {
70
91
  const bundle = await open();
71
92
 
@@ -27,6 +27,7 @@ import {
27
27
  createFakePage,
28
28
  makeSnapshot,
29
29
  useTempTraceDirectory,
30
+ useTraceBundleCleanup,
30
31
  } from '../../helpers/trace-fixtures.js';
31
32
 
32
33
  /**
@@ -66,6 +67,7 @@ function parseExport(text) {
66
67
 
67
68
  describe('trace links export (issue #94)', () => {
68
69
  const directory = useTempTraceDirectory();
70
+ const trackOpenBundle = useTraceBundleCleanup();
69
71
 
70
72
  /**
71
73
  * Write a bundle holding one of everything the export has to say.
@@ -76,6 +78,7 @@ describe('trace links export (issue #94)', () => {
76
78
  const writeBundle = async ({ close = true } = {}) => {
77
79
  const root = path.join(directory.path, 'run.bc-trace');
78
80
  const bundle = await openTraceBundle({ output: root });
81
+ trackOpenBundle(bundle);
79
82
  const owner = {
80
83
  traceId: 'trace-1',
81
84
  browserContextId: 'context-1',
@@ -457,6 +460,10 @@ describe('trace links export (issue #94)', () => {
457
460
  );
458
461
  // No closing link, which is how a reader tells this run never finished.
459
462
  assert.ok(!links.some((link) => link.id === 'result'));
463
+
464
+ // The assertions above model the process being killed. The test process
465
+ // itself survives, so release its open descriptor explicitly.
466
+ await trace.stop();
460
467
  });
461
468
 
462
469
  it('should take the export away with a discarded bundle', async () => {
@@ -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';
@@ -16,10 +17,14 @@ import {
16
17
  TRACE_MODE,
17
18
  TRACE_OUTCOME,
18
19
  } from '../../../src/traces/schema.js';
19
- import { useTempTraceDirectory } from '../../helpers/trace-fixtures.js';
20
+ import {
21
+ useTempTraceDirectory,
22
+ useTraceBundleCleanup,
23
+ } from '../../helpers/trace-fixtures.js';
20
24
 
21
25
  describe('trace reader (issue #87)', () => {
22
26
  const directory = useTempTraceDirectory();
27
+ const cleanup = useTraceBundleCleanup();
23
28
 
24
29
  /**
25
30
  * Write a small bundle the tests can read back.
@@ -29,9 +34,11 @@ describe('trace reader (issue #87)', () => {
29
34
  * @returns {Promise<Object>} The bundle writer
30
35
  */
31
36
  const writeBundle = async ({ close = true } = {}) => {
32
- const bundle = await openTraceBundle({
33
- output: path.join(directory.path, 'run'),
34
- });
37
+ const bundle = cleanup(
38
+ await openTraceBundle({
39
+ output: path.join(directory.path, 'run'),
40
+ })
41
+ );
35
42
  await bundle.appendEvent({
36
43
  kind: TRACE_EVENT.TRACE_START,
37
44
  mode: TRACE_MODE.CHECKPOINTS,
@@ -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';
@@ -172,6 +173,7 @@ describe('trace recorder (issue #87)', () => {
172
173
  await trace.checkpoint('start');
173
174
 
174
175
  assert.strictEqual(page.state.screenshots, 0);
176
+ await trace.stop();
175
177
  });
176
178
 
177
179
  it('should take a screenshot only on failure when asked', async () => {
@@ -182,6 +184,7 @@ describe('trace recorder (issue #87)', () => {
182
184
 
183
185
  await trace.checkpoint('the end', { reason: 'failure' });
184
186
  assert.strictEqual(page.state.screenshots, 1);
187
+ await trace.stop();
185
188
  });
186
189
 
187
190
  it('should keep a gap rather than fail when the page has closed', async () => {
@@ -237,6 +240,7 @@ describe('trace recorder (issue #87)', () => {
237
240
  // Once at the start, and once after each of the two checkpoints - the
238
241
  // base one the recorder takes itself and the one named above.
239
242
  assert.strictEqual(installs.length, 3);
243
+ await trace.stop();
240
244
  });
241
245
  });
242
246
 
@@ -282,6 +282,10 @@ describe('offline trace viewer (issue #87)', () => {
282
282
 
283
283
  assert.match(body, /outcome truncated/);
284
284
  assert.match(body, /the last thing it did|1 checkpoints/);
285
+
286
+ // The assertions model an interrupted process; this test process keeps
287
+ // running, so explicitly release the descriptor afterwards.
288
+ await trace.stop();
285
289
  });
286
290
 
287
291
  it('should refuse a directory that is not a bundle', async () => {
@@ -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
+ });