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,160 @@
1
+ /**
2
+ * Real-browser tests of the `browser-commander` CLI (issue #104).
3
+ *
4
+ * The binary runs as a separate process through command-stream, the way a
5
+ * shell script, a Rust program or a Python program would use it:
6
+ *
7
+ * - `run tests/cli-contract/basic.json` must print, after normalization,
8
+ * exactly `tests/cli-contract/basic.expected.json`. The Rust and Python
9
+ * CLIs are checked against the same file, so all three agree.
10
+ * - `launch --keep-open` leaves a browser running that later `goto` and
11
+ * `eval` commands reach with `--cdp-endpoint`.
12
+ *
13
+ * Run with: npm run test:e2e:cli
14
+ */
15
+ import { describe, it } from 'node:test';
16
+ import assert from 'node:assert';
17
+ import { readFileSync } from 'node:fs';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { ProcessRunner } from 'command-stream/process-runner';
21
+
22
+ import { normalizeRunOutput } from '../../../tests/cli-contract/normalize.mjs';
23
+ import { runCommand } from '../../src/utilities/subprocess.js';
24
+ import { SANDBOX_ARGS } from '../helpers/e2e-browser.js';
25
+ import { repoPath } from '../helpers/repo.js';
26
+
27
+ const BIN = fileURLToPath(
28
+ new URL('../../bin/browser-commander.js', import.meta.url)
29
+ );
30
+ const ENGINES = ['playwright', 'puppeteer'];
31
+ const E2E = { skip: !process.env.RUN_E2E, timeout: 180_000 };
32
+
33
+ /** Options that pick the test browser: CHROME_PATH and sandbox arguments. */
34
+ function browserOptions(engine) {
35
+ return [
36
+ '--engine',
37
+ engine,
38
+ ...(process.env.CHROME_PATH
39
+ ? ['--executable-path', process.env.CHROME_PATH]
40
+ : []),
41
+ // "--arg=VALUE": a value that starts with "--" cannot follow a space.
42
+ ...SANDBOX_ARGS.map((arg) => `--arg=${arg}`),
43
+ ];
44
+ }
45
+
46
+ /** Run the CLI to completion and parse its one JSON document. */
47
+ async function browserCommander(args) {
48
+ const { code, stdout, stderr } = await runCommand(
49
+ process.execPath,
50
+ [BIN, ...args],
51
+ { check: false }
52
+ );
53
+ let document;
54
+ try {
55
+ document = JSON.parse(stdout);
56
+ } catch {
57
+ throw new Error(`No JSON from ${args.join(' ')}: ${stdout}\n${stderr}`);
58
+ }
59
+ return { code, document };
60
+ }
61
+
62
+ /**
63
+ * Start `launch --keep-open` with stdin kept open, and resolve with its
64
+ * document once it has been printed.
65
+ */
66
+ function startKeptOpenBrowser(engine) {
67
+ const runner = new ProcessRunner(
68
+ {
69
+ mode: 'exec',
70
+ file: process.execPath,
71
+ args: [
72
+ BIN,
73
+ 'launch',
74
+ '--keep-open',
75
+ '--headless',
76
+ ...browserOptions(engine),
77
+ ],
78
+ },
79
+ { mirror: false, capture: true, stdin: 'pipe' }
80
+ );
81
+ let output = '';
82
+ const printed = new Promise((resolve, reject) => {
83
+ runner.on('stdout', (chunk) => {
84
+ output += chunk.toString();
85
+ if (output.includes('\n')) {
86
+ resolve(JSON.parse(output.split('\n')[0]));
87
+ }
88
+ });
89
+ Promise.resolve(runner).then(
90
+ (result) =>
91
+ reject(new Error(`launch exited early: ${output}${result.stderr}`)),
92
+ reject
93
+ );
94
+ });
95
+ runner.start();
96
+ return { runner, printed };
97
+ }
98
+
99
+ for (const engine of ENGINES) {
100
+ describe(`E2E - browser-commander CLI with ${engine} (issue #104)`, () => {
101
+ it(
102
+ 'run basic.json matches the cross-language expected output',
103
+ E2E,
104
+ async () => {
105
+ const { code, document } = await browserCommander([
106
+ 'run',
107
+ repoPath('tests', 'cli-contract', 'basic.json'),
108
+ ...browserOptions(engine),
109
+ ]);
110
+
111
+ const expected = JSON.parse(
112
+ readFileSync(
113
+ repoPath('tests', 'cli-contract', 'basic.expected.json'),
114
+ 'utf8'
115
+ )
116
+ );
117
+ assert.deepEqual(normalizeRunOutput(document), expected);
118
+ assert.equal(code, 0);
119
+ }
120
+ );
121
+
122
+ it(
123
+ 'launch --keep-open serves goto and eval over --cdp-endpoint',
124
+ E2E,
125
+ async () => {
126
+ const { runner, printed } = startKeptOpenBrowser(engine);
127
+ try {
128
+ const launched = await printed;
129
+ assert.match(launched.cdpEndpoint, /^http:\/\/127\.0\.0\.1:\d+$/u);
130
+ const attach = [
131
+ '--cdp-endpoint',
132
+ launched.cdpEndpoint,
133
+ '--engine',
134
+ engine,
135
+ ];
136
+
137
+ const url = `data:text/html,${encodeURIComponent(
138
+ '<title>Kept open</title><p id="x">42</p>'
139
+ )}`;
140
+ const goto = await browserCommander(['goto', url, ...attach]);
141
+ assert.equal(goto.code, 0);
142
+ assert.deepEqual(goto.document, { url, title: 'Kept open' });
143
+
144
+ // The second command attaches again and sees the page the first
145
+ // one left behind: the browser outlived both commands.
146
+ const evaluated = await browserCommander([
147
+ 'eval',
148
+ "document.title + ':' + document.querySelector('#x').textContent",
149
+ ...attach,
150
+ ]);
151
+ assert.equal(evaluated.code, 0);
152
+ assert.deepEqual(evaluated.document, { value: 'Kept open:42' });
153
+ } finally {
154
+ runner.kill('SIGTERM');
155
+ await Promise.resolve(runner).catch(() => {});
156
+ }
157
+ }
158
+ );
159
+ });
160
+ }
@@ -23,10 +23,6 @@
23
23
  */
24
24
  import { describe, it, before, after } from 'node:test';
25
25
  import assert from 'node:assert';
26
- import { existsSync } from 'node:fs';
27
- import { mkdtemp, rm } from 'node:fs/promises';
28
- import { tmpdir } from 'node:os';
29
- import path from 'node:path';
30
26
  import { randomUUID } from 'node:crypto';
31
27
 
32
28
  import {
@@ -34,25 +30,26 @@ import {
34
30
  diffReports,
35
31
  readProbeSource,
36
32
  startProbeServer,
37
- } from '../../../experiments/fingerprint-parity/harness.mjs';
38
- import { launchBrowser } from '../../src/index.js';
33
+ } from '../../src/parity/harness.js';
34
+ import { launchBrowser, launchRealBrowser } from '../../src/index.js';
39
35
  import { createFingerprintPreset } from '../../src/fingerprint/presets.js';
36
+ import {
37
+ PARITY_CHROME as CHROME,
38
+ paritySkipReason as skipReason,
39
+ } from '../helpers/e2e-browser.js';
40
40
 
41
- const CHROME = process.env.CHROME_PATH || '/usr/bin/google-chrome';
42
41
  const TEST_TIMEOUT = 180000;
43
42
 
44
- /** Why the suite cannot run here, or `false` when it can. */
45
- function skipReason({ headless }) {
46
- if (!process.env.RUN_E2E) {
47
- return 'set RUN_E2E=true to run the parity tests';
48
- }
49
- if (!existsSync(CHROME)) {
50
- return `no Chrome binary at ${CHROME}; set CHROME_PATH`;
51
- }
52
- if (!headless && process.platform === 'linux' && !process.env.DISPLAY) {
53
- return 'headful parity needs a display; run under xvfb-run';
54
- }
55
- return false;
43
+ /** Fail with every differing path when `report` is not `reference`. */
44
+ function assertIndistinguishable(reference, report, label) {
45
+ const differences = diffReports(reference, report);
46
+ assert.deepEqual(
47
+ differences,
48
+ [],
49
+ `${label} differs from a hand-started browser in: ${differences
50
+ .map((entry) => entry.path)
51
+ .join(', ')}`
52
+ );
56
53
  }
57
54
 
58
55
  function defineParitySuite({ headless }) {
@@ -64,29 +61,48 @@ function defineParitySuite({ headless }) {
64
61
  () => {
65
62
  let server;
66
63
  let reference;
67
- const userDataDirs = [];
68
64
 
69
- const nextUserDataDir = async () => {
70
- const directory = await mkdtemp(path.join(tmpdir(), 'bc-parity-e2e-'));
71
- userDataDirs.push(directory);
72
- return directory;
65
+ /**
66
+ * Drive one capture through the shipped launcher. With no `launch`
67
+ * option that is the default real launch: a fresh temporary profile and
68
+ * a clean command line (issue #103).
69
+ */
70
+ const captureWithLibrary = async (options) => {
71
+ const token = randomUUID();
72
+ const launched = await launchBrowser({
73
+ headless,
74
+ executablePath: CHROME,
75
+ ...options,
76
+ });
77
+ try {
78
+ await launched.page.goto(server.url(token), { waitUntil: 'load' });
79
+ return {
80
+ report: await server.waitForReport(token),
81
+ token,
82
+ launched,
83
+ };
84
+ } finally {
85
+ await launched.browser.close();
86
+ }
73
87
  };
74
88
 
75
- /** Drive one capture through the shipped launcher. */
76
- const captureWithLibrary = async (options) => {
89
+ /**
90
+ * Drive one capture through the real-browser launcher: Chrome is spawned
91
+ * with `--user-data-dir` and a reserved `--remote-debugging-port` only,
92
+ * and the engine attaches over CDP afterwards (issue #101).
93
+ */
94
+ const captureWithRealBrowser = async (options) => {
77
95
  const token = randomUUID();
78
- const { browser, page } = await launchBrowser({
96
+ const session = await launchRealBrowser({
79
97
  headless,
80
- slowMo: 0,
81
98
  executablePath: CHROME,
82
- userDataDir: await nextUserDataDir(),
83
99
  ...options,
84
100
  });
85
101
  try {
86
- await page.goto(server.url(token), { waitUntil: 'load' });
87
- return { report: await server.waitForReport(token), token };
102
+ await session.page.goto(server.url(token), { waitUntil: 'load' });
103
+ return { report: await server.waitForReport(token), session };
88
104
  } finally {
89
- await browser.close();
105
+ await session.close();
90
106
  }
91
107
  };
92
108
 
@@ -108,27 +124,49 @@ function defineParitySuite({ headless }) {
108
124
  if (server) {
109
125
  await server.close();
110
126
  }
111
- await Promise.all(
112
- userDataDirs.map((directory) =>
113
- rm(directory, { recursive: true, force: true, maxRetries: 10 })
114
- )
115
- );
116
127
  });
117
128
 
129
+ // Nothing but the profile, the fixed port, --headless=new when headless
130
+ // and the about:blank start page, so there is no switch that could show
131
+ // the unsupported-flag infobar.
132
+ const assertPersonCommandLine = (args) => {
133
+ assert.deepEqual(
134
+ args.map((argument) => argument.split('=')[0]),
135
+ [
136
+ '--user-data-dir',
137
+ '--remote-debugging-port',
138
+ ...(headless ? ['--headless'] : []),
139
+ 'about:blank',
140
+ ],
141
+ args.join(' ')
142
+ );
143
+ };
144
+
118
145
  for (const engine of ['playwright', 'puppeteer']) {
119
146
  it(
120
147
  `${engine} is indistinguishable from a hand-started Chrome`,
121
148
  { timeout: TEST_TIMEOUT },
122
149
  async () => {
123
- const { report } = await captureWithLibrary({ engine });
124
- const differences = diffReports(reference, report);
125
-
126
- assert.deepEqual(
127
- differences,
128
- [],
129
- `${engine} differs from a real ${mode} browser in: ${differences
130
- .map((entry) => entry.path)
131
- .join(', ')}`
150
+ const { report, launched } = await captureWithLibrary({ engine });
151
+ assert.equal(launched.launch, 'real');
152
+ assert.equal(launched.temporaryProfile, true);
153
+ assertPersonCommandLine(launched.args);
154
+ assertIndistinguishable(reference, report, `${engine} (${mode})`);
155
+ }
156
+ );
157
+
158
+ it(
159
+ `${engine} with launch: 'engine' is indistinguishable from a hand-started Chrome`,
160
+ { timeout: TEST_TIMEOUT },
161
+ async () => {
162
+ const { report } = await captureWithLibrary({
163
+ engine,
164
+ launch: 'engine',
165
+ });
166
+ assertIndistinguishable(
167
+ reference,
168
+ report,
169
+ `engine-launched ${engine} (${mode})`
132
170
  );
133
171
  }
134
172
  );
@@ -140,8 +178,11 @@ function defineParitySuite({ headless }) {
140
178
  // Negative control: without this the parity assertion above could
141
179
  // pass for the wrong reason, for example because the probe stopped
142
180
  // reporting the field.
181
+ // An engine launch passes switches that turn AutomationControlled
182
+ // on; the default real launch has none to undo.
143
183
  const { report } = await captureWithLibrary({
144
184
  engine,
185
+ launch: 'engine',
145
186
  automationParity: false,
146
187
  });
147
188
  const paths = diffReports(reference, report).map(
@@ -155,6 +196,23 @@ function defineParitySuite({ headless }) {
155
196
  }
156
197
  );
157
198
 
199
+ it(
200
+ `launchRealBrowser with ${engine} is indistinguishable from a hand-started Chrome`,
201
+ { timeout: TEST_TIMEOUT },
202
+ async () => {
203
+ const { report, session } = await captureWithRealBrowser({
204
+ engine,
205
+ });
206
+ assertPersonCommandLine(session.args);
207
+ assert.equal(report.navigator.webdriver, false);
208
+ assertIndistinguishable(
209
+ reference,
210
+ report,
211
+ `real ${engine} session (${mode})`
212
+ );
213
+ }
214
+ );
215
+
158
216
  it(
159
217
  `${engine} presents the fingerprint profile it was launched with`,
160
218
  { timeout: TEST_TIMEOUT },
@@ -0,0 +1,112 @@
1
+ /**
2
+ * E2E acceptance probe for issue #102: a clean default real-browser launch must
3
+ * let a person sign in with a Google account.
4
+ *
5
+ * The tell-tale is what Google says after an address is submitted. A browser
6
+ * Google trusts answers "Couldn't find your Google Account" for an address that
7
+ * does not exist; a browser it flags as automated answers "This browser or app
8
+ * may not be secure" and never reaches the account lookup. The default launch
9
+ * carries no automation switches (issue #101/#103), so `navigator.webdriver` is
10
+ * false and this probe should see the former, not the latter.
11
+ *
12
+ * This talks to the live accounts.google.com, so it is off by default and never
13
+ * runs in CI. It is headful (Google's checks differ under headless), so on Linux
14
+ * it needs a display:
15
+ *
16
+ * RUN_GOOGLE_PROBE=true xvfb-run -a --server-args="-screen 0 1920x1080x24" \
17
+ * npm run test:e2e:google-probe
18
+ *
19
+ * On success it writes docs/screenshots/google-signin-probe.png as evidence.
20
+ */
21
+ import { describe, it } from 'node:test';
22
+ import assert from 'node:assert';
23
+ import { existsSync } from 'node:fs';
24
+ import { mkdir } from 'node:fs/promises';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import { launchRealBrowser } from '../../src/index.js';
29
+
30
+ const CHROME = process.env.CHROME_PATH || '/usr/bin/google-chrome';
31
+ const TEST_TIMEOUT = 180000;
32
+
33
+ const SECURE_WARNING = 'This browser or app may not be secure';
34
+ const ACCOUNT_NOT_FOUND = "Couldn't find your Google Account";
35
+
36
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
37
+ const SCREENSHOT = path.resolve(
38
+ HERE,
39
+ '../../../docs/screenshots/google-signin-probe.png'
40
+ );
41
+
42
+ /** Why the probe cannot run here, or `false` when it can. */
43
+ function skipReason() {
44
+ if (process.env.RUN_GOOGLE_PROBE !== 'true') {
45
+ return 'set RUN_GOOGLE_PROBE=true to run the live Google sign-in probe';
46
+ }
47
+ if (!existsSync(CHROME)) {
48
+ return `no Chrome binary at ${CHROME}; set CHROME_PATH`;
49
+ }
50
+ if (process.platform === 'linux' && !process.env.DISPLAY) {
51
+ return 'the headful Google probe needs a display; run under xvfb-run';
52
+ }
53
+ return false;
54
+ }
55
+
56
+ describe(
57
+ 'E2E Tests - Google sign-in probe (#102)',
58
+ { skip: skipReason() },
59
+ () => {
60
+ it(
61
+ 'a clean default launch reaches the account lookup, not the "not secure" wall',
62
+ { timeout: TEST_TIMEOUT },
63
+ async () => {
64
+ // A clearly nonexistent address, so a trusted browser lands on
65
+ // "Couldn't find your Google Account" instead of a password prompt.
66
+ const address = `bc-parity-probe-${Date.now()}@gmail.com`;
67
+ const session = await launchRealBrowser({
68
+ headless: false,
69
+ executablePath: CHROME,
70
+ args:
71
+ process.env.CHROME_NO_SANDBOX === 'true' ? ['--no-sandbox'] : [],
72
+ });
73
+ try {
74
+ const { page } = session;
75
+ await page.goto('https://accounts.google.com/signin/v2/identifier', {
76
+ waitUntil: 'load',
77
+ timeout: 60000,
78
+ });
79
+ await page.locator('input[type="email"]').fill(address);
80
+ await page.keyboard.press('Enter');
81
+
82
+ // Wait for either outcome so the assertion is not racing the network.
83
+ await page
84
+ .waitForFunction(
85
+ (needles) =>
86
+ needles.some((needle) =>
87
+ document.body.innerText.includes(needle)
88
+ ),
89
+ [ACCOUNT_NOT_FOUND, SECURE_WARNING],
90
+ { timeout: 60000 }
91
+ )
92
+ .catch(() => {});
93
+
94
+ await mkdir(path.dirname(SCREENSHOT), { recursive: true });
95
+ await page.screenshot({ path: SCREENSHOT, fullPage: true });
96
+
97
+ const bodyText = await page.evaluate(() => document.body.innerText);
98
+ assert.ok(
99
+ !bodyText.includes(SECURE_WARNING),
100
+ `Google flagged the clean launch as not secure. Screenshot: ${SCREENSHOT}`
101
+ );
102
+ assert.ok(
103
+ bodyText.includes(ACCOUNT_NOT_FOUND),
104
+ `expected "${ACCOUNT_NOT_FOUND}"; got a different page. Screenshot: ${SCREENSHOT}`
105
+ );
106
+ } finally {
107
+ await session.close();
108
+ }
109
+ }
110
+ );
111
+ }
112
+ );
@@ -0,0 +1,118 @@
1
+ /**
2
+ * E2E: `measureParity()` against every installed Chrome-family browser.
3
+ *
4
+ * The default real launch must be `ok` - no difference from the same binary
5
+ * started by hand, neither in the command line chrome://version reports nor in
6
+ * anything the environment probe reads - headful and headless, through either
7
+ * engine. The engine launch is `ok` only because every one of its extra
8
+ * switches is tied to the `engine-launch-switches` catalogue entry.
9
+ *
10
+ * Google Chrome is required. Chromium, Edge and Brave are measured when they
11
+ * are installed; a browser that is not installed defines no test rather than
12
+ * a skipped one, so the parity workflow's "no SKIP" gate still holds.
13
+ *
14
+ * RUN_E2E=true xvfb-run -a --server-args="-screen 0 1920x1080x24" \
15
+ * npm run test:e2e:parity
16
+ */
17
+ import { describe, it } from 'node:test';
18
+ import assert from 'node:assert';
19
+
20
+ import { measureParity } from '../../src/browser/parity.js';
21
+ import { resolveSystemBrowserExecutable } from '../../src/browser/system-browser.js';
22
+ import {
23
+ PARITY_CHROME as CHROME,
24
+ paritySkipReason,
25
+ } from '../helpers/e2e-browser.js';
26
+
27
+ const TEST_TIMEOUT = 180000;
28
+
29
+ async function installedBrowsers() {
30
+ const browsers = [{ name: 'chrome', executablePath: CHROME }];
31
+ for (const channel of ['chromium', 'msedge', 'brave']) {
32
+ const executablePath = await resolveSystemBrowserExecutable({
33
+ channel,
34
+ }).catch(() => null);
35
+ if (executablePath) {
36
+ browsers.push({ name: channel, executablePath });
37
+ }
38
+ }
39
+ return browsers;
40
+ }
41
+
42
+ function describeUnlisted(report) {
43
+ return JSON.stringify(
44
+ report.unlisted.map(({ path, expected, actual }) => ({
45
+ path,
46
+ expected,
47
+ actual,
48
+ }))
49
+ );
50
+ }
51
+
52
+ // Every browser is measured headful too, so a display is required.
53
+ const skip = paritySkipReason();
54
+ const browsers = skip ? [] : await installedBrowsers();
55
+
56
+ describe('E2E Tests - measureParity', { skip }, () => {
57
+ for (const { name, executablePath } of browsers) {
58
+ for (const headless of [false, true]) {
59
+ const mode = headless ? 'headless' : 'headful';
60
+
61
+ for (const engine of ['playwright', 'puppeteer']) {
62
+ it(
63
+ `${name} ${mode}: the real launch through ${engine} matches a hand-started browser`,
64
+ { timeout: TEST_TIMEOUT },
65
+ async () => {
66
+ const report = await measureParity({
67
+ engine,
68
+ executablePath,
69
+ headless,
70
+ });
71
+ assert.equal(report.browser.launch, 'real');
72
+ assert.deepEqual(report.commandLine.extra, []);
73
+ assert.deepEqual(report.commandLine.missing, []);
74
+ assert.equal(report.commandLine.attachment.length, 1);
75
+ assert.equal(report.ok, true, describeUnlisted(report));
76
+ assert.deepEqual(report.differences, []);
77
+ }
78
+ );
79
+ }
80
+ }
81
+
82
+ it(
83
+ `${name}: an engine launch is explained by the catalogue`,
84
+ { timeout: TEST_TIMEOUT },
85
+ async () => {
86
+ const report = await measureParity({
87
+ engine: 'playwright',
88
+ executablePath,
89
+ launch: 'engine',
90
+ });
91
+ assert.ok(report.commandLine.extra.length > 0);
92
+ assert.equal(report.ok, true, describeUnlisted(report));
93
+ assert.ok(
94
+ report.differences.every(
95
+ (entry) => entry.limitation === 'engine-launch-switches'
96
+ )
97
+ );
98
+ }
99
+ );
100
+ }
101
+
102
+ it(
103
+ 'reports an opt-in restriction as requested, not as a leak',
104
+ { timeout: TEST_TIMEOUT },
105
+ async () => {
106
+ const report = await measureParity({
107
+ executablePath: CHROME,
108
+ restrictions: ['no-sync'],
109
+ });
110
+ assert.deepEqual(report.commandLine.extra, ['--disable-sync']);
111
+ assert.equal(report.ok, true, describeUnlisted(report));
112
+ assert.deepEqual(
113
+ report.differences.map(({ path, requested }) => ({ path, requested })),
114
+ [{ path: 'commandLine.extra.--disable-sync', requested: true }]
115
+ );
116
+ }
117
+ );
118
+ });
@@ -1,3 +1,4 @@
1
+ // feature-parity: engine.playwright@native-typed
1
2
  /**
2
3
  * E2E tests for browser-commander using Playwright engine
3
4
  *
@@ -12,6 +13,7 @@ import assert from 'node:assert';
12
13
  // Dynamic import for playwright since it may not be installed
13
14
  let playwright;
14
15
  let createCommander;
16
+ const TOGGLE_SLIDER = '[data-testid="toggle-switch"] + .toggle-slider';
15
17
 
16
18
  describe(
17
19
  'E2E Tests - Playwright Engine',
@@ -124,12 +126,11 @@ describe(
124
126
  }
125
127
 
126
128
  await commander.click({ selector: '[data-testid="radio-male"]' });
127
- const checked = await commander.getAttribute({
128
- selector: '[data-testid="radio-male"]',
129
- attribute: 'checked',
130
- });
131
- // Playwright returns empty string for checked attribute when true
132
- assert.ok(checked !== null);
129
+ // React controls the live property without adding a checked attribute.
130
+ assert.strictEqual(
131
+ await page.locator('[data-testid="radio-male"]').isChecked(),
132
+ true
133
+ );
133
134
  });
134
135
 
135
136
  it('should check checkbox', async () => {
@@ -237,7 +238,7 @@ describe(
237
238
  const initialStatus = await commander.textContent({
238
239
  selector: '[data-testid="toggle-status"]',
239
240
  });
240
- await commander.click({ selector: '[data-testid="toggle-switch"]' });
241
+ await commander.click({ selector: TOGGLE_SLIDER });
241
242
  await commander.wait({ ms: 50 });
242
243
  const newStatus = await commander.textContent({
243
244
  selector: '[data-testid="toggle-status"]',
@@ -337,7 +338,10 @@ describe(
337
338
  }
338
339
 
339
340
  // Scroll to target item (item 15)
340
- await commander.scroll({ selector: '[data-testid="scroll-item-15"]' });
341
+ await commander.scrollIntoView({
342
+ locatorOrElement: page.locator('[data-testid="scroll-item-15"]'),
343
+ behavior: 'instant',
344
+ });
341
345
  await commander.wait({ ms: 500 });
342
346
 
343
347
  // Element should now be visible
@@ -354,17 +358,22 @@ describe(
354
358
 
355
359
  // Use click which auto-scrolls
356
360
  let alertHandled = false;
357
- page.on('dialog', async (dialog) => {
361
+ const onDialog = async (dialog) => {
358
362
  alertHandled = true;
359
363
  await dialog.accept();
360
- });
361
-
362
- await commander.click({
363
- selector: '[data-testid="scroll-target-button"]',
364
- });
365
- await commander.wait({ ms: 100 });
366
-
367
- assert.strictEqual(alertHandled, true);
364
+ };
365
+ commander.onDialog(onDialog);
366
+
367
+ try {
368
+ await commander.click({
369
+ selector: '[data-testid="scroll-target-button"]',
370
+ });
371
+ await commander.wait({ ms: 100 });
372
+
373
+ assert.strictEqual(alertHandled, true);
374
+ } finally {
375
+ commander.offDialog(onDialog);
376
+ }
368
377
  });
369
378
  });
370
379