browser-commander 0.20.0 → 0.21.1

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 (169) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +159 -0
  4. package/README.md +216 -32
  5. package/bin/browser-commander.js +16 -0
  6. package/eslint.config.js +10 -0
  7. package/examples/react-test-app/src/App.jsx +24 -23
  8. package/extension/background.js +14 -0
  9. package/extension/manifest.json +12 -0
  10. package/extension/relay-handler.js +353 -0
  11. package/package.json +21 -4
  12. package/src/browser/attach/differences.js +131 -0
  13. package/src/browser/attach/extension-relay.js +402 -0
  14. package/src/browser/attach/index.js +81 -0
  15. package/src/browser/attach/snapshot-launch.js +146 -0
  16. package/src/browser/attach/snapshot.js +374 -0
  17. package/src/browser/attach/websocket.js +615 -0
  18. package/src/browser/browser-cookie-cache.js +26 -19
  19. package/src/browser/browser-cookie-credentials.js +6 -9
  20. package/src/browser/browser-cookies.js +5 -2
  21. package/src/browser/cdp-session.js +94 -0
  22. package/src/browser/connector.js +42 -4
  23. package/src/browser/debugging-port.js +155 -0
  24. package/src/browser/launch-options.js +63 -52
  25. package/src/browser/launch-restrictions.json +147 -0
  26. package/src/browser/launcher.js +329 -133
  27. package/src/browser/media.js +5 -0
  28. package/src/browser/migration/bookmarks.js +73 -0
  29. package/src/browser/migration/chromium-crypto.js +89 -0
  30. package/src/browser/migration/cookies.js +155 -0
  31. package/src/browser/migration/extensions.js +187 -0
  32. package/src/browser/migration/firefox-bookmarks.js +144 -0
  33. package/src/browser/migration/firefox-der.js +84 -0
  34. package/src/browser/migration/firefox-nss.js +281 -0
  35. package/src/browser/migration/firefox.js +334 -0
  36. package/src/browser/migration/fs-utils.js +51 -0
  37. package/src/browser/migration/history.js +103 -0
  38. package/src/browser/migration/index.js +307 -0
  39. package/src/browser/migration/os-crypt-keys.js +106 -0
  40. package/src/browser/migration/passwords.js +171 -0
  41. package/src/browser/migration/preferences.js +132 -0
  42. package/src/browser/migration/sqlite-snapshot.js +138 -0
  43. package/src/browser/open-in-user-browser.js +112 -0
  44. package/src/browser/parity.js +439 -0
  45. package/src/browser/profile-directory.js +205 -0
  46. package/src/browser/real-browser.js +545 -306
  47. package/src/browser/restrictions.js +148 -0
  48. package/src/browser/system-browser.js +232 -0
  49. package/src/browser/webdriver.js +572 -0
  50. package/src/cli/args.js +300 -0
  51. package/src/cli/commands.js +332 -0
  52. package/src/cli/dispatcher.js +99 -0
  53. package/src/cli/handle-methods.js +136 -0
  54. package/src/cli/handles.js +273 -0
  55. package/src/cli/high-level-methods.js +310 -0
  56. package/src/cli/main.js +67 -0
  57. package/src/cli/modules.js +100 -0
  58. package/src/cli/rpc-error.js +77 -0
  59. package/src/cli/script.js +77 -0
  60. package/src/cli/serve.js +144 -0
  61. package/src/cli/sessions.js +199 -0
  62. package/src/core/constants.js +7 -1
  63. package/src/core/engine-adapter.js +107 -1
  64. package/src/core/engine-detection.js +18 -3
  65. package/src/core/navigation-safety.js +4 -0
  66. package/src/core/webdriver-conversions.js +347 -0
  67. package/src/core/webdriver-events.js +229 -0
  68. package/src/core/webdriver-page.js +716 -0
  69. package/src/downloads/attach.js +6 -0
  70. package/src/elements/selectors.js +3 -2
  71. package/src/elements/visibility.js +4 -0
  72. package/src/exports.js +61 -2
  73. package/src/factory.js +19 -4
  74. package/src/fingerprint/apply.js +6 -0
  75. package/src/fingerprint/automation-parity.js +7 -8
  76. package/src/fingerprint/limitations.js +1 -1
  77. package/src/fingerprint/limitations.json +45 -2
  78. package/src/parity/harness.js +303 -0
  79. package/src/parity/probe.js +838 -0
  80. package/src/parity/version-page.js +144 -0
  81. package/src/traces/bundle.js +3 -2
  82. package/src/traces/observers.js +14 -5
  83. package/src/traces/recorder.js +3 -0
  84. package/src/traces/viewer.js +16 -8
  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/fingerprint-parity.e2e.test.js +104 -46
  89. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  90. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  91. package/tests/e2e/playwright.e2e.test.js +26 -17
  92. package/tests/e2e/puppeteer.e2e.test.js +18 -23
  93. package/tests/e2e/webdriver.e2e.test.js +239 -0
  94. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  95. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  96. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  97. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  98. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  99. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  100. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  101. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  102. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  103. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  104. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  105. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  106. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  107. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  108. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  109. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  110. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  111. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  112. package/tests/helpers/cli-fakes.js +214 -0
  113. package/tests/helpers/declared-api.js +80 -0
  114. package/tests/helpers/e2e-browser.js +40 -14
  115. package/tests/helpers/fake-chrome.js +124 -0
  116. package/tests/helpers/migration-fixtures.js +242 -0
  117. package/tests/helpers/temp-directory.js +24 -0
  118. package/tests/helpers/webdriver-mocks.js +183 -0
  119. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  120. package/tests/unit/browser/attach/snapshot-launch.test.js +321 -0
  121. package/tests/unit/browser/attach/snapshot.test.js +409 -0
  122. package/tests/unit/browser/attach/websocket.test.js +240 -0
  123. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  124. package/tests/unit/browser/browser-cookies.test.js +2 -32
  125. package/tests/unit/browser/cdp-session.test.js +162 -0
  126. package/tests/unit/browser/connector.test.js +23 -0
  127. package/tests/unit/browser/launch-building-blocks.test.js +361 -0
  128. package/tests/unit/browser/launch-options.test.js +41 -22
  129. package/tests/unit/browser/launcher.test.js +281 -102
  130. package/tests/unit/browser/media.test.js +13 -1
  131. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  132. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  133. package/tests/unit/browser/migration/cookies.test.js +94 -0
  134. package/tests/unit/browser/migration/extensions.test.js +104 -0
  135. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  136. package/tests/unit/browser/migration/firefox.test.js +139 -0
  137. package/tests/unit/browser/migration/history.test.js +48 -0
  138. package/tests/unit/browser/migration/index.test.js +117 -0
  139. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  140. package/tests/unit/browser/migration/passwords.test.js +144 -0
  141. package/tests/unit/browser/migration/preferences.test.js +77 -0
  142. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  143. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  144. package/tests/unit/browser/parity.test.js +281 -0
  145. package/tests/unit/browser/real-browser.test.js +458 -33
  146. package/tests/unit/browser/storage-state.test.js +2 -0
  147. package/tests/unit/browser/webdriver.test.js +473 -0
  148. package/tests/unit/cli/bin.test.js +69 -0
  149. package/tests/unit/cli/declared-api.test.js +49 -0
  150. package/tests/unit/cli/dispatcher.test.js +429 -0
  151. package/tests/unit/cli/handles.test.js +170 -0
  152. package/tests/unit/cli/main.test.js +437 -0
  153. package/tests/unit/cli/serve.test.js +167 -0
  154. package/tests/unit/core/navigation-manager.test.js +11 -1
  155. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  156. package/tests/unit/core/webdriver-page.test.js +534 -0
  157. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  158. package/tests/unit/fingerprint/limitations.test.js +1 -1
  159. package/tests/unit/parity/harness.test.js +138 -0
  160. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  161. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  162. package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
  163. package/tests/unit/scripts/feature-parity.test.js +327 -0
  164. package/tests/unit/traces/conformance.test.js +92 -0
  165. package/tests/unit/traces/reader.test.js +1 -0
  166. package/tests/unit/traces/recorder.test.js +1 -0
  167. package/tests/unit/traces/viewer.test.js +78 -0
  168. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  169. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Read chrome://version of a browser started with the reference command line.
3
+ *
4
+ * The browser rewrites its own command line: headless mode appends
5
+ * `--ozone-platform=headless`, `--use-angle=swiftshader-webgl`,
6
+ * `--no-first-run` and more, and a Linux build may append
7
+ * `--ozone-platform=x11`. chrome://version shows the rewritten line, so the
8
+ * automated browser's line cannot be compared with the argv a person typed.
9
+ * It is compared with what the same binary shows for that argv instead.
10
+ *
11
+ * A page cannot read chrome://version, so this capture needs a debugger. It
12
+ * uses a fixed `--remote-debugging-port` - which issue #101 measured to change
13
+ * nothing a page observes - and speaks raw CDP over the global WebSocket, so
14
+ * no automation engine is involved.
15
+ */
16
+ import { startProcess } from '../utilities/subprocess.js';
17
+ import { reserveLoopbackPort } from '../browser/debugging-port.js';
18
+ import {
19
+ createTemporaryUserDataDir,
20
+ removeUserDataDir,
21
+ } from '../browser/profile-directory.js';
22
+
23
+ /** Evaluated in chrome://version; returns null until the page has rendered. */
24
+ const READ_VERSION_PAGE = `(() => {
25
+ const text = (id) => (document.getElementById(id)?.textContent ?? '').trim();
26
+ if (!text('command_line')) return null;
27
+ return {
28
+ commandLine: text('command_line'),
29
+ version: text('version'),
30
+ executablePath: text('executable_path'),
31
+ };
32
+ })()`;
33
+
34
+ /** Send CDP commands over one page WebSocket. */
35
+ function openCdpSocket(url) {
36
+ if (typeof globalThis.WebSocket !== 'function') {
37
+ throw new Error('Reading chrome://version needs Node.js 22.4 or newer');
38
+ }
39
+ const socket = new globalThis.WebSocket(url);
40
+ const pending = new Map();
41
+ let nextId = 1;
42
+ socket.addEventListener('message', (event) => {
43
+ const message = JSON.parse(String(event.data));
44
+ const waiter = pending.get(message.id);
45
+ if (typeof waiter === 'function') {
46
+ pending.delete(message.id);
47
+ waiter(message);
48
+ }
49
+ });
50
+ const opened = new Promise((resolve, reject) => {
51
+ socket.addEventListener('open', resolve, { once: true });
52
+ socket.addEventListener('error', reject, { once: true });
53
+ });
54
+ return {
55
+ async send(method, params = {}) {
56
+ await opened;
57
+ const id = nextId++;
58
+ const reply = new Promise((resolve) => pending.set(id, resolve));
59
+ socket.send(JSON.stringify({ id, method, params }));
60
+ const message = await reply;
61
+ if (message.error) {
62
+ throw new Error(`${method}: ${message.error.message}`);
63
+ }
64
+ return message.result;
65
+ },
66
+ close() {
67
+ socket.close();
68
+ },
69
+ };
70
+ }
71
+
72
+ async function openVersionTarget(port, deadline) {
73
+ while (Date.now() < deadline) {
74
+ try {
75
+ const response = await fetch(
76
+ `http://127.0.0.1:${port}/json/new?chrome://version`,
77
+ { method: 'PUT' }
78
+ );
79
+ if (response.ok) {
80
+ return await response.json();
81
+ }
82
+ } catch {
83
+ // The DevTools HTTP server is not up yet.
84
+ }
85
+ await new Promise((resolve) => setTimeout(resolve, 100));
86
+ }
87
+ throw new Error(`no DevTools endpoint on port ${port}`);
88
+ }
89
+
90
+ async function evaluateUntilReady(cdp, deadline) {
91
+ while (Date.now() < deadline) {
92
+ const { result } = await cdp.send('Runtime.evaluate', {
93
+ expression: READ_VERSION_PAGE,
94
+ returnByValue: true,
95
+ });
96
+ if (result?.value) {
97
+ return result.value;
98
+ }
99
+ await new Promise((resolve) => setTimeout(resolve, 100));
100
+ }
101
+ throw new Error('chrome://version did not render');
102
+ }
103
+
104
+ /**
105
+ * Start `executablePath` with the reference command line plus a fixed
106
+ * debugging port and read chrome://version.
107
+ *
108
+ * @param {Object} options
109
+ * @param {string} options.executablePath
110
+ * @param {boolean} [options.headless=false]
111
+ * @param {number} [options.timeout=30000]
112
+ * @returns {Promise<{commandLine: string, version: string, executablePath: string}>}
113
+ */
114
+ export async function readReferenceVersionPage({
115
+ executablePath,
116
+ headless = false,
117
+ timeout = 30_000,
118
+ start = startProcess,
119
+ }) {
120
+ const userDataDir = await createTemporaryUserDataDir();
121
+ const port = await reserveLoopbackPort();
122
+ const child = start(
123
+ executablePath,
124
+ [
125
+ `--user-data-dir=${userDataDir}`,
126
+ `--remote-debugging-port=${port}`,
127
+ ...(headless ? ['--headless=new'] : []),
128
+ 'about:blank',
129
+ ],
130
+ { killGrace: 3000 }
131
+ );
132
+ let cdp;
133
+ try {
134
+ const deadline = Date.now() + timeout;
135
+ const target = await openVersionTarget(port, deadline);
136
+ cdp = openCdpSocket(target.webSocketDebuggerUrl);
137
+ return await evaluateUntilReady(cdp, deadline);
138
+ } finally {
139
+ cdp?.close();
140
+ child.kill('SIGTERM');
141
+ await child.exited;
142
+ await removeUserDataDir(userDataDir);
143
+ }
144
+ }
@@ -32,7 +32,7 @@ export const TRACE_DIRECTORY_MODE = 0o700;
32
32
  /**
33
33
  * Open a bundle for writing.
34
34
  *
35
- * @param {Object} options - `{output, limits, strict, now, onEvent}`
35
+ * @param {Object} options - `{output, limits, strict, now, monotonic, onEvent}`
36
36
  * @returns {Promise<Object>} The bundle writer
37
37
  */
38
38
  export async function openTraceBundle(options = {}) {
@@ -41,6 +41,7 @@ export async function openTraceBundle(options = {}) {
41
41
  strict = false,
42
42
  limits = {},
43
43
  now = () => Date.now(),
44
+ monotonic = () => performance.now(),
44
45
  onEvent = null,
45
46
  } = options;
46
47
 
@@ -119,7 +120,7 @@ export async function openTraceBundle(options = {}) {
119
120
  const record = {
120
121
  sequence: ++sequence,
121
122
  at: new Date(now()).toISOString(),
122
- monotonicMs: Math.round(performance.now()),
123
+ monotonicMs: Math.round(monotonic()),
123
124
  ...event,
124
125
  };
125
126
  const line = `${JSON.stringify(record)}\n`;
@@ -51,7 +51,7 @@ export function interactionTarget(argument) {
51
51
  /**
52
52
  * Subscribe to everything a running trace listens to.
53
53
  *
54
- * @param {Object} options - `{commander, page, eventSources, record, note, identity}`
54
+ * @param {Object} options - `{commander, page, eventSources, record, note, identity, now}`
55
55
  * @param {Object} [options.commander] - The commander being traced, when there
56
56
  * is one; interactions and managed downloads are reached through it
57
57
  * @param {Object} options.page - The page being traced
@@ -61,10 +61,19 @@ export function interactionTarget(argument) {
61
61
  * @param {Object} [options.identity] - Trace identity, see `identity.js`; the
62
62
  * navigation observer is what moves it on to the next navigation, and the
63
63
  * interaction observer is what names an action (issue #93)
64
+ * @param {Function} [options.now] - Wall clock in milliseconds, for durations
64
65
  * @returns {Function[]} One detach function per attached observer
65
66
  */
66
67
  export function attachTimelineObservers(options) {
67
- const { commander, page, eventSources, record, note, identity } = options;
68
+ const {
69
+ commander,
70
+ page,
71
+ eventSources,
72
+ record,
73
+ note,
74
+ identity,
75
+ now = () => Date.now(),
76
+ } = options;
68
77
  const detachers = [];
69
78
 
70
79
  function subscribe(source, attach) {
@@ -234,7 +243,7 @@ export function attachTimelineObservers(options) {
234
243
  }
235
244
  originals.set(name, original);
236
245
  commander[name] = async (...args) => {
237
- const startedMs = Date.now();
246
+ const startedMs = now();
238
247
  const target = interactionTarget(args[0]);
239
248
  // Named before the call, so everything the action causes can be traced
240
249
  // back to it even when the action itself ends in an exception.
@@ -245,7 +254,7 @@ export function attachTimelineObservers(options) {
245
254
  actionId,
246
255
  action: name,
247
256
  target,
248
- durationMs: Date.now() - startedMs,
257
+ durationMs: now() - startedMs,
249
258
  ok: true,
250
259
  });
251
260
  return result;
@@ -254,7 +263,7 @@ export function attachTimelineObservers(options) {
254
263
  actionId,
255
264
  action: name,
256
265
  target,
257
- durationMs: Date.now() - startedMs,
266
+ durationMs: now() - startedMs,
258
267
  ok: false,
259
268
  error: error.message,
260
269
  });
@@ -110,6 +110,7 @@ export async function startTrace(options = {}) {
110
110
  commanderVersion = null,
111
111
  log = commander?.log,
112
112
  now = () => Date.now(),
113
+ monotonic,
113
114
  } = options;
114
115
 
115
116
  if (!page) {
@@ -150,6 +151,7 @@ export async function startTrace(options = {}) {
150
151
  limits,
151
152
  strict,
152
153
  now,
154
+ monotonic,
153
155
  onEvent: (event) => linksSink?.event(event),
154
156
  });
155
157
  const startedAt = new Date(now()).toISOString();
@@ -323,6 +325,7 @@ export async function startTrace(options = {}) {
323
325
  record,
324
326
  note,
325
327
  identity,
328
+ now,
326
329
  });
327
330
 
328
331
  // Registered before the first record, so a navigation that starts in the same
@@ -36,7 +36,7 @@ function embed(data) {
36
36
  );
37
37
  }
38
38
 
39
- const VIEWER_STYLE = `
39
+ export const VIEWER_STYLE = `
40
40
  :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
41
41
  body { margin: 0; display: grid; grid-template-columns: 22rem 1fr;
42
42
  grid-template-rows: auto 1fr; height: 100vh; }
@@ -62,7 +62,7 @@ const VIEWER_STYLE = `
62
62
  button { font: inherit; }
63
63
  `;
64
64
 
65
- const VIEWER_SCRIPT = String.raw`
65
+ export const VIEWER_SCRIPT = String.raw`
66
66
  const trace = JSON.parse(document.getElementById('trace-data').textContent);
67
67
  const timeline = document.getElementById('timeline');
68
68
  const frame = document.getElementById('stage');
@@ -72,6 +72,13 @@ const stepLabel = document.getElementById('step');
72
72
  let selectedCheckpoint = null;
73
73
  let mutationStep = 0;
74
74
 
75
+ // Everything in the trace is what the recorded page made it, and the policy
76
+ // allows inline handlers for this script, so recorded text is escaped before
77
+ // it is put into the viewer's markup.
78
+ function escapeHtml(value) {
79
+ return String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
80
+ }
81
+
75
82
  function checkpointFor(event) {
76
83
  if (event.kind === 'checkpoint') return event.index;
77
84
  let latest = null;
@@ -241,15 +248,16 @@ function showCheckpoint(index) {
241
248
  const state = trace.state[index];
242
249
  const previous = trace.state[index - 1];
243
250
  details.innerHTML = state
244
- ? '<table><tr><th>url</th><td>' + state.url + '</td></tr>' +
245
- '<tr><th>title</th><td>' + (state.title || '') + '</td></tr>' +
251
+ ? '<table><tr><th>url</th><td>' + escapeHtml(state.url) + '</td></tr>' +
252
+ '<tr><th>title</th><td>' + escapeHtml(state.title || '') + '</td></tr>' +
246
253
  '<tr><th>controls</th><td>' + (state.controls || []).length + '</td></tr></table>'
247
254
  : '<p>no state captured</p>';
248
255
  const changes = trace.diffs[index] || [];
249
256
  diffPanel.innerHTML = previous && changes.length
250
257
  ? '<table><tr><th>control</th><th>before</th><th>after</th></tr>' +
251
- changes.map((c) => '<tr><td>' + c.path + '</td><td>' + (c.before ?? '') +
252
- '</td><td>' + (c.after ?? '') + '</td></tr>').join('') + '</table>'
258
+ changes.map((c) => '<tr><td>' + escapeHtml(c.path) + '</td><td>' +
259
+ escapeHtml(c.before ?? '') + '</td><td>' + escapeHtml(c.after ?? '') +
260
+ '</td></tr>').join('') + '</table>'
253
261
  : '<p>no control changes against the previous checkpoint</p>';
254
262
  stepLabel.textContent = (trace.mutations[index] || []).length + ' mutation batches';
255
263
  }
@@ -261,8 +269,8 @@ function select(event, element) {
261
269
  if (index != null && index !== selectedCheckpoint) showCheckpoint(index);
262
270
  if (event.kind !== 'checkpoint') {
263
271
  details.innerHTML = '<table>' + Object.entries(event)
264
- .map(([k, v]) => '<tr><th>' + k + '</th><td>' + String(
265
- typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400) + '</td></tr>')
272
+ .map(([k, v]) => '<tr><th>' + escapeHtml(k) + '</th><td>' + escapeHtml(String(
273
+ typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400)) + '</td></tr>')
266
274
  .join('') + '</table>';
267
275
  }
268
276
  }
@@ -0,0 +1,199 @@
1
+ import { ProcessRunner } from 'command-stream/process-runner';
2
+
3
+ /**
4
+ * Every subprocess Browser Commander starts - browsers, WebDriver servers, the
5
+ * engine bridge, credential tools (`security`, `secret-tool`, `kwallet-query`,
6
+ * `powershell.exe`, `icacls`, `whoami`) and the user's own browser opener -
7
+ * goes through command-stream (issue #104). Its `exec` mode runs the file
8
+ * directly with exact argv boundaries, no shell in between, and gives one
9
+ * consistent surface for streaming output, cancellation and exit codes. The
10
+ * Rust crate uses the `command-stream` crate and the Python package a
11
+ * `subprocess` wrapper with the same two functions.
12
+ *
13
+ * The lightweight `command-stream/process-runner` entry point is used so the
14
+ * optional PTY and terminal-rendering modules are never loaded.
15
+ */
16
+
17
+ function runnerFor(file, args, { env, cwd, stdin = 'ignore', killGrace }) {
18
+ return new ProcessRunner(
19
+ { mode: 'exec', file, args },
20
+ {
21
+ mirror: false,
22
+ capture: true,
23
+ stdin,
24
+ ...(env ? { env } : {}),
25
+ ...(cwd ? { cwd } : {}),
26
+ ...(killGrace === undefined ? {} : { killGrace }),
27
+ }
28
+ );
29
+ }
30
+
31
+ /** Error raised when a command exits with a non-zero status. */
32
+ export class CommandError extends Error {
33
+ constructor(file, args, result) {
34
+ const stderr = String(result.stderr ?? '').trim();
35
+ super(
36
+ `${file} exited with code ${result.code}${stderr ? `: ${stderr}` : ''}`
37
+ );
38
+ this.name = 'CommandError';
39
+ this.file = file;
40
+ this.args = args;
41
+ this.code = result.code;
42
+ this.exitCode = result.code;
43
+ this.stdout = String(result.stdout ?? '');
44
+ this.stderr = String(result.stderr ?? '');
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Run a command to completion and return its output.
50
+ *
51
+ * @param {string} file - Executable to run (resolved through PATH)
52
+ * @param {string[]} [args] - Exact arguments
53
+ * @param {Object} [options]
54
+ * @param {Object<string,string>} [options.env] - Environment for the child only
55
+ * @param {string} [options.cwd] - Working directory
56
+ * @param {string|Buffer} [options.input] - Data written to the child's stdin
57
+ * @param {boolean} [options.check=true] - Throw {@link CommandError} on a non-zero exit
58
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>}
59
+ */
60
+ export async function runCommand(file, args = [], options = {}) {
61
+ const { env, cwd, input, check = true } = options;
62
+ const runner = runnerFor(file, args, {
63
+ env,
64
+ cwd,
65
+ stdin: input === undefined ? 'ignore' : input,
66
+ });
67
+ let result;
68
+ try {
69
+ result = await runner;
70
+ } catch (error) {
71
+ // A missing executable fails the spawn itself; report it like execFile.
72
+ if (error && error.code === undefined) {
73
+ error.code = 'ENOENT';
74
+ }
75
+ throw error;
76
+ }
77
+ const normalized = {
78
+ stdout: String(result.stdout ?? ''),
79
+ stderr: String(result.stderr ?? ''),
80
+ code: result.code,
81
+ };
82
+ if (check && normalized.code !== 0) {
83
+ throw new CommandError(file, args, normalized);
84
+ }
85
+ return normalized;
86
+ }
87
+
88
+ /**
89
+ * Start a long-running process, such as a browser or a driver server.
90
+ *
91
+ * The returned handle has the part of the `ChildProcess` surface the launchers
92
+ * rely on (`pid`, `exitCode`, `kill()`, `stderr`/`stdout` `data` events and an
93
+ * `exit` event), plus an `exited` promise.
94
+ *
95
+ * @param {string} file - Executable path
96
+ * @param {string[]} args - Exact arguments
97
+ * @param {Object} [options]
98
+ * @param {Object<string,string>} [options.env] - Environment for the child only
99
+ * @param {boolean} [options.forwardOutput=false] - Mirror the child's output to this process
100
+ * @param {number} [options.killGrace=2000] - Milliseconds between SIGTERM and SIGKILL
101
+ * @returns {ManagedProcess}
102
+ */
103
+ export function startProcess(file, args = [], options = {}) {
104
+ const { env, cwd, forwardOutput = false, killGrace = 2000 } = options;
105
+ const runner = runnerFor(file, args, { env, cwd, killGrace });
106
+ return new ManagedProcess(runner, { forwardOutput });
107
+ }
108
+
109
+ class OutputChannel {
110
+ constructor() {
111
+ this.listeners = new Set();
112
+ }
113
+
114
+ on(event, listener) {
115
+ if (event === 'data') {
116
+ this.listeners.add(listener);
117
+ }
118
+ return this;
119
+ }
120
+
121
+ off(event, listener) {
122
+ this.listeners.delete(listener);
123
+ return this;
124
+ }
125
+
126
+ emit(chunk) {
127
+ for (const listener of this.listeners) {
128
+ listener(chunk);
129
+ }
130
+ }
131
+ }
132
+
133
+ /** Handle for a process started by {@link startProcess}. */
134
+ export class ManagedProcess {
135
+ constructor(runner, { forwardOutput }) {
136
+ this.runner = runner;
137
+ this.exitCode = null;
138
+ this.signalCode = null;
139
+ this.stdout = new OutputChannel();
140
+ this.stderr = new OutputChannel();
141
+ this.exitListeners = new Set();
142
+ runner.on('stdout', (chunk) => {
143
+ if (forwardOutput) {
144
+ process.stdout.write(chunk);
145
+ }
146
+ this.stdout.emit(chunk);
147
+ });
148
+ runner.on('stderr', (chunk) => {
149
+ if (forwardOutput) {
150
+ process.stderr.write(chunk);
151
+ }
152
+ this.stderr.emit(chunk);
153
+ });
154
+ runner.start();
155
+ this.exited = Promise.resolve(runner)
156
+ .catch((error) => ({ code: error?.code ?? 1 }))
157
+ .then((result) => {
158
+ this.exitCode = typeof result.code === 'number' ? result.code : 1;
159
+ for (const listener of this.exitListeners) {
160
+ listener(this.exitCode);
161
+ }
162
+ return this.exitCode;
163
+ });
164
+ }
165
+
166
+ /** Operating system process id, once spawned. */
167
+ get pid() {
168
+ return this.runner.pid;
169
+ }
170
+
171
+ on(event, listener) {
172
+ if (event === 'exit') {
173
+ if (this.exitCode !== null) {
174
+ listener(this.exitCode);
175
+ } else {
176
+ this.exitListeners.add(listener);
177
+ }
178
+ }
179
+ return this;
180
+ }
181
+
182
+ once(event, listener) {
183
+ return this.on(event, listener);
184
+ }
185
+
186
+ /**
187
+ * Stop the process: the signal first, then SIGKILL after the grace period.
188
+ *
189
+ * @param {string} [signal='SIGTERM']
190
+ * @returns {boolean} Whether a running process was signalled
191
+ */
192
+ kill(signal) {
193
+ if (this.exitCode !== null) {
194
+ return false;
195
+ }
196
+ this.runner.kill(signal);
197
+ return true;
198
+ }
199
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * API coverage of the generic handle methods (issue #104).
3
+ *
4
+ * `serve --stdio` promises that any public Playwright or Puppeteer method can
5
+ * be called through `handle.call`, so the Rust and Python ports need no
6
+ * binding per method. This suite walks the real engine objects through the
7
+ * same dispatcher `serve` uses and asserts that `handle.describe` lists every
8
+ * method the engine declares for that type:
9
+ *
10
+ * - Puppeteer: `puppeteer-core/lib/types.d.ts`.
11
+ * - Playwright: `playwright-core/types/types.d.ts`. Playwright's wire
12
+ * protocol description (`protocol.yml`) is not shipped in the npm
13
+ * package, so the published client API declarations are the reference.
14
+ *
15
+ * When an engine upgrade adds a method, the declarations grow and this test
16
+ * shows whether the bridge reaches it.
17
+ *
18
+ * Run with: npm run test:e2e:api-coverage
19
+ */
20
+ import { after, before, describe, it } from 'node:test';
21
+ import assert from 'node:assert';
22
+
23
+ import { createDispatcher } from '../../src/cli/dispatcher.js';
24
+ import {
25
+ playwrightDeclarations,
26
+ puppeteerDeclarations,
27
+ } from '../helpers/declared-api.js';
28
+ import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
29
+ import { sendHtml, startFixtureHost } from '../helpers/fixture-server.js';
30
+
31
+ const E2E = { skip: !process.env.RUN_E2E, timeout: 180_000 };
32
+
33
+ const PAGE = `<!doctype html><title>API coverage</title>
34
+ <p id="text">coverage</p><iframe srcdoc="<p>inner</p>"></iframe>`;
35
+
36
+ /**
37
+ * How to reach one declared type from the session roots. Each step is
38
+ * `['call', method, args]` or `['get', property]`, run with handle.call or
39
+ * handle.get on the previous handle; `from` names the starting handle.
40
+ */
41
+ /** Page-level types both engines expose the same way, under their own names. */
42
+ function sharedPaths({ response, request }) {
43
+ return {
44
+ Browser: { from: 'browser', steps: [] },
45
+ BrowserContext: { from: 'context', steps: [] },
46
+ Page: { from: 'page', steps: [] },
47
+ [response]: { from: 'page', steps: [['call', 'goto', ['$url']]] },
48
+ [request]: {
49
+ from: 'page',
50
+ steps: [
51
+ ['call', 'goto', ['$url']],
52
+ ['call', 'request', []],
53
+ ],
54
+ },
55
+ Frame: { from: 'page', steps: [['call', 'mainFrame', []]] },
56
+ ElementHandle: { from: 'page', steps: [['call', '$', ['#text']]] },
57
+ JSHandle: {
58
+ from: 'page',
59
+ steps: [['call', 'evaluateHandle', [{ $function: '() => window' }]]],
60
+ },
61
+ Locator: { from: 'page', steps: [['call', 'locator', ['#text']]] },
62
+ Keyboard: { from: 'page', steps: [['get', 'keyboard']] },
63
+ Mouse: { from: 'page', steps: [['get', 'mouse']] },
64
+ Touchscreen: { from: 'page', steps: [['get', 'touchscreen']] },
65
+ Coverage: { from: 'page', steps: [['get', 'coverage']] },
66
+ };
67
+ }
68
+
69
+ const PLAYWRIGHT_PATHS = {
70
+ ...sharedPaths({ response: 'Response', request: 'Request' }),
71
+ FrameLocator: { from: 'page', steps: [['call', 'frameLocator', ['iframe']]] },
72
+ Clock: { from: 'page', steps: [['get', 'clock']] },
73
+ APIRequestContext: { from: 'page', steps: [['get', 'request']] },
74
+ Tracing: { from: 'context', steps: [['get', 'tracing']] },
75
+ CDPSession: {
76
+ from: 'context',
77
+ steps: [['call', 'newCDPSession', ['$page']]],
78
+ },
79
+ BrowserType: { from: 'engine', steps: [['get', 'chromium']] },
80
+ APIRequest: { from: 'engine', steps: [['get', 'request']] },
81
+ Selectors: { from: 'engine', steps: [['get', 'selectors']] },
82
+ };
83
+
84
+ const PUPPETEER_PATHS = {
85
+ ...sharedPaths({ response: 'HTTPResponse', request: 'HTTPRequest' }),
86
+ PuppeteerNode: { from: 'engine', steps: [] },
87
+ Accessibility: { from: 'page', steps: [['get', 'accessibility']] },
88
+ Tracing: { from: 'page', steps: [['get', 'tracing']] },
89
+ Target: { from: 'page', steps: [['call', 'target', []]] },
90
+ CDPSession: { from: 'page', steps: [['call', 'createCDPSession', []]] },
91
+ };
92
+
93
+ const ENGINES = {
94
+ playwright: { paths: PLAYWRIGHT_PATHS, declarations: playwrightDeclarations },
95
+ puppeteer: { paths: PUPPETEER_PATHS, declarations: puppeteerDeclarations },
96
+ };
97
+
98
+ /** Follow one path from the session roots to a handle. */
99
+ async function reach(dispatch, roots, { from, steps }, url) {
100
+ const substitute = (arg) =>
101
+ arg === '$url' ? url : arg === '$page' ? roots.page : arg;
102
+ let handle = roots[from];
103
+ for (const [kind, name, args = []] of steps) {
104
+ handle =
105
+ kind === 'get'
106
+ ? await dispatch('handle.get', { handle, property: name })
107
+ : await dispatch('handle.call', {
108
+ handle,
109
+ method: name,
110
+ args: args.map(substitute),
111
+ });
112
+ }
113
+ return handle;
114
+ }
115
+
116
+ for (const [engine, { paths, declarations }] of Object.entries(ENGINES)) {
117
+ describe(`E2E - handle.describe covers the ${engine} API (issue #104)`, () => {
118
+ let server;
119
+ let dispatcher;
120
+ let roots;
121
+
122
+ before(async () => {
123
+ if (!process.env.RUN_E2E) {
124
+ return;
125
+ }
126
+ server = await startFixtureHost((_path, _req, res) =>
127
+ sendHtml(res, PAGE)
128
+ );
129
+ dispatcher = createDispatcher();
130
+ const { session } = await dispatcher.dispatch('session.launch', {
131
+ engine,
132
+ headless: true,
133
+ ...CHROME_LAUNCH_OPTIONS,
134
+ });
135
+ roots = {
136
+ ...(await dispatcher.dispatch('handle.root', {
137
+ name: `session:${session}`,
138
+ })),
139
+ engine: await dispatcher.dispatch('handle.root', { name: engine }),
140
+ };
141
+ });
142
+
143
+ after(async () => {
144
+ await dispatcher?.close();
145
+ await server?.close();
146
+ });
147
+
148
+ it('every mapped type is still declared by the engine', E2E, () => {
149
+ const declared = declarations();
150
+ const missing = Object.keys(paths).filter((type) => !declared.has(type));
151
+ assert.deepEqual(missing, []);
152
+ });
153
+
154
+ for (const type of Object.keys(paths)) {
155
+ it(`${type}: every declared method is reachable`, E2E, async () => {
156
+ const { dispatch } = dispatcher;
157
+ const handle = await reach(
158
+ dispatch,
159
+ roots,
160
+ paths[type],
161
+ server.baseUrl
162
+ );
163
+ assert.ok(handle?.$handle, `${type} did not resolve to a handle`);
164
+
165
+ const described = await dispatch('handle.describe', { handle });
166
+ const available = new Set(described.methods);
167
+ const missing = [...declarations().get(type)].filter(
168
+ (method) => !available.has(method)
169
+ );
170
+ assert.deepEqual(missing, [], `${type} (${described.type})`);
171
+ });
172
+ }
173
+ });
174
+ }