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,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
+ });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Coverage of the typed Rust Playwright bindings against `protocol.yml`
3
+ * (issue #108).
4
+ *
5
+ * The Rust `playwright` engine talks to the official driver through
6
+ * `rust/src/playwright/protocol/`, generated from the vendored protocol spec in
7
+ * `rust/protocol/playwright/`. These tests read the spec on their own and fail
8
+ * when:
9
+ *
10
+ * - the committed bindings differ from what the generator writes;
11
+ * - any interface, command (own or inherited) or event in the spec has no typed
12
+ * Rust counterpart;
13
+ * - the vendored spec disagrees with the driver that is installed, which is
14
+ * the one the Rust tests and CI start.
15
+ */
16
+
17
+ import assert from 'node:assert/strict';
18
+ import * as fs from 'node:fs';
19
+ import { createRequire } from 'node:module';
20
+ import path from 'node:path';
21
+ import { describe, it } from 'node:test';
22
+ import { pathToFileURL } from 'node:url';
23
+
24
+ import { repoPath } from '../helpers/repo.js';
25
+
26
+ const SPEC_DIR = repoPath('rust/protocol/playwright');
27
+ const OUT_DIR = repoPath('rust/src/playwright/protocol');
28
+
29
+ const { generateProtocol } = await import(
30
+ pathToFileURL(repoPath('scripts/generate-playwright-protocol.mjs'))
31
+ );
32
+
33
+ const require = createRequire(repoPath('js/package.json'));
34
+ const YAML = require('yaml');
35
+
36
+ function loadDefinitions() {
37
+ const definitions = {};
38
+ for (const file of fs
39
+ .readdirSync(SPEC_DIR)
40
+ .filter((f) => f.endsWith('.yml'))) {
41
+ const parsed = YAML.parse(
42
+ fs.readFileSync(path.join(SPEC_DIR, file), 'utf8')
43
+ );
44
+ Object.assign(definitions, parsed ?? {});
45
+ }
46
+ return definitions;
47
+ }
48
+
49
+ const definitions = loadDefinitions();
50
+ const version = fs.readFileSync(path.join(SPEC_DIR, 'VERSION'), 'utf8').trim();
51
+ const interfaces = Object.keys(definitions).filter(
52
+ (name) => definitions[name].type === 'interface'
53
+ );
54
+
55
+ /** `goto` -> `Goto`, `setViewportSize` -> `SetViewportSize`. */
56
+ function upperFirst(name) {
57
+ return name[0].toUpperCase() + name.slice(1);
58
+ }
59
+
60
+ /** Own and inherited members, with the interface that declares each one. */
61
+ function members(name, kind) {
62
+ const definition = definitions[name];
63
+ const inherited = definition.extends ? members(definition.extends, kind) : [];
64
+ const own = Object.entries(definition[kind] ?? {}).map(([member, spec]) => ({
65
+ owner: name,
66
+ name: member,
67
+ spec: spec ?? {},
68
+ }));
69
+ const ownNames = new Set(own.map((member) => member.name));
70
+ return [...inherited.filter((m) => !ownNames.has(m.name)), ...own];
71
+ }
72
+
73
+ function hasEntries(value) {
74
+ return Boolean(value) && Object.keys(value).length > 0;
75
+ }
76
+
77
+ /**
78
+ * Every top-level `impl … {` block in the generated sources keyed by its
79
+ * header (`impl Page`, `impl ChannelType for Page`), and every struct or enum
80
+ * body keyed by `struct Name` or `enum Name`.
81
+ */
82
+ function rustBlocks() {
83
+ const blocks = new Map();
84
+ for (const file of fs.readdirSync(OUT_DIR).filter((f) => f.endsWith('.rs'))) {
85
+ const source = fs.readFileSync(path.join(OUT_DIR, file), 'utf8');
86
+ for (const match of source.matchAll(
87
+ /^(impl [^\n{]+?|pub (?:struct|enum) \w+) \{(?:\}|\n([\s\S]*?)^\})/gmu
88
+ )) {
89
+ const key = match[1].replace(/^pub /u, '');
90
+ blocks.set(key, `${blocks.get(key) ?? ''}${match[2] ?? ''}`);
91
+ }
92
+ }
93
+ return blocks;
94
+ }
95
+
96
+ function escape(text) {
97
+ return text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
98
+ }
99
+
100
+ describe('Rust Playwright protocol bindings', () => {
101
+ it('are what the generator writes from the vendored spec', () => {
102
+ const files = generateProtocol();
103
+ const committed = fs
104
+ .readdirSync(OUT_DIR)
105
+ .filter((file) => file.endsWith('.rs'))
106
+ .sort();
107
+ assert.deepEqual(committed, [...files.keys()].sort());
108
+ for (const [file, contents] of files) {
109
+ assert.equal(
110
+ fs.readFileSync(path.join(OUT_DIR, file), 'utf8'),
111
+ contents,
112
+ `${file} is stale; run node scripts/generate-playwright-protocol.mjs`
113
+ );
114
+ }
115
+ });
116
+
117
+ it('pin the spec version they were generated from', () => {
118
+ const module = fs.readFileSync(path.join(OUT_DIR, 'mod.rs'), 'utf8');
119
+ assert.match(
120
+ module,
121
+ new RegExp(
122
+ `pub const PROTOCOL_VERSION: &str = "${escape(version)}";`,
123
+ 'u'
124
+ )
125
+ );
126
+ });
127
+
128
+ it('cover every interface, command and event in protocol.yml', () => {
129
+ const blocks = rustBlocks();
130
+ const missing = [];
131
+ let commands = 0;
132
+ let events = 0;
133
+ assert.ok(interfaces.length >= 30, `only ${interfaces.length} interfaces`);
134
+ for (const name of interfaces) {
135
+ const channel = blocks.get(`impl ChannelType for ${name}`) ?? '';
136
+ if (!channel.includes(`const INTERFACE: &'static str = "${name}";`)) {
137
+ missing.push(`${name}: channel type`);
138
+ }
139
+ if (!blocks.has(`struct ${name}Initializer`)) {
140
+ missing.push(`${name}: initializer`);
141
+ }
142
+
143
+ const methods = blocks.get(`impl ${name}`) ?? '';
144
+ for (const command of members(name, 'commands')) {
145
+ commands += 1;
146
+ const base = `${command.owner}${upperFirst(command.name)}`;
147
+ const params = hasEntries(command.spec.parameters)
148
+ ? `, params: ${base}Params\\)`
149
+ : '\\)';
150
+ const result = hasEntries(command.spec.returns)
151
+ ? `${base}Result`
152
+ : '\\(\\)';
153
+ const send = hasEntries(command.spec.returns)
154
+ ? 'send'
155
+ : 'send_no_result';
156
+ const method = new RegExp(
157
+ `/// \`${escape(command.owner)}\\.${escape(command.name)}\`[^\\n]*\\n` +
158
+ `\\s*pub async fn (?:r#)?\\w+\\(&self${params} -> Result<${result}, ProtocolError> \\{\\n` +
159
+ `\\s*self\\.channel\\.${send}\\("${escape(command.name)}", `,
160
+ 'u'
161
+ );
162
+ if (!method.test(methods)) {
163
+ missing.push(`${name}.${command.name}`);
164
+ }
165
+ }
166
+
167
+ const variants = blocks.get(`enum ${name}Event`) ?? '';
168
+ const parse = blocks.get(`impl ProtocolEvent for ${name}Event`) ?? '';
169
+ for (const event of members(name, 'events')) {
170
+ events += 1;
171
+ const variant = upperFirst(event.name);
172
+ const declared = hasEntries(event.spec.parameters)
173
+ ? `${variant}(${event.owner}${variant}EventParams),`
174
+ : `${variant},`;
175
+ if (
176
+ !variants.includes(
177
+ `/// \`${event.owner}.${event.name}\`\n ${declared}`
178
+ ) ||
179
+ !parse.includes(`"${event.name}" => Ok(Self::${variant}`)
180
+ ) {
181
+ missing.push(`${name} event ${event.name}`);
182
+ }
183
+ }
184
+ }
185
+ assert.deepEqual(missing, []);
186
+ assert.ok(commands > 300, `only ${commands} commands`);
187
+ assert.ok(events > 50, `only ${events} events`);
188
+ });
189
+
190
+ it('match the protocol of the installed driver', (t) => {
191
+ let root;
192
+ try {
193
+ root = path.dirname(require.resolve('playwright-core/package.json'));
194
+ } catch {
195
+ t.skip('playwright-core is not installed');
196
+ return;
197
+ }
198
+ const installed = JSON.parse(
199
+ fs.readFileSync(path.join(root, 'package.json'), 'utf8')
200
+ ).version;
201
+ if (installed !== version) {
202
+ t.skip(
203
+ `playwright-core ${installed} is installed, the spec is ${version}`
204
+ );
205
+ return;
206
+ }
207
+ // The driver validates every message against these schemes; one exists per
208
+ // command (`<Interface><Command>Params`) and event (`<Interface><Event>Event`).
209
+ const bundle = fs.readFileSync(
210
+ path.join(root, 'lib/coreBundle.js'),
211
+ 'utf8'
212
+ );
213
+ const driver = new Set(
214
+ [...bundle.matchAll(/scheme\.(\w+(?:Params|Event)) = /gu)]
215
+ .map((match) => match[1])
216
+ .filter((scheme) => !definitions[scheme])
217
+ );
218
+ const spec = new Set();
219
+ for (const name of interfaces) {
220
+ for (const command of members(name, 'commands')) {
221
+ spec.add(`${name}${upperFirst(command.name)}Params`);
222
+ }
223
+ for (const event of members(name, 'events')) {
224
+ spec.add(`${name}${upperFirst(event.name)}Event`);
225
+ }
226
+ }
227
+ assert.ok(driver.size > 300, `found ${driver.size} driver schemes`);
228
+ assert.deepEqual([...spec].filter((s) => !driver.has(s)).sort(), []);
229
+ assert.deepEqual([...driver].filter((s) => !spec.has(s)).sort(), []);
230
+ });
231
+ });
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Coverage of the typed Rust and Python Puppeteer wrappers (issue #108).
3
+ *
4
+ * `rust/src/puppeteer/api/` and `python/src/browser_commander/puppeteer/api/`
5
+ * are generated by `scripts/generate-puppeteer-bindings.mjs` from
6
+ * `rust/protocol/puppeteer/api.json`, which `scripts/puppeteer-api.mjs`
7
+ * extracts from puppeteer-core's `lib/types.d.ts`. These tests fail when:
8
+ *
9
+ * - the committed wrappers differ from what the generator writes;
10
+ * - the committed manifest differs from the installed puppeteer-core;
11
+ * - a public method or getter declared in `lib/types.d.ts` (own or inherited)
12
+ * has no typed entry point in Rust or Python.
13
+ */
14
+
15
+ import assert from 'node:assert/strict';
16
+ import * as fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { describe, it } from 'node:test';
19
+ import { pathToFileURL } from 'node:url';
20
+
21
+ import { puppeteerDeclarations } from '../helpers/declared-api.js';
22
+ import { repoPath } from '../helpers/repo.js';
23
+
24
+ const generator = await import(
25
+ pathToFileURL(repoPath('scripts/generate-puppeteer-bindings.mjs'))
26
+ );
27
+ const extractor = await import(
28
+ pathToFileURL(repoPath('scripts/puppeteer-api.mjs'))
29
+ );
30
+
31
+ const {
32
+ MANIFEST_PATH,
33
+ RUST_DIR,
34
+ PYTHON_DIR,
35
+ formatManifest,
36
+ generatePuppeteerBindings,
37
+ loadManifest,
38
+ pythonMethodName,
39
+ rustMethodName,
40
+ } = generator;
41
+
42
+ const manifest = loadManifest();
43
+ const types = new Map(manifest.types.map((type) => [type.name, type]));
44
+
45
+ function readDir(dir, extension) {
46
+ return new Map(
47
+ fs
48
+ .readdirSync(dir)
49
+ .filter((file) => file.endsWith(extension))
50
+ .map((file) => [file, fs.readFileSync(path.join(dir, file), 'utf8')])
51
+ );
52
+ }
53
+
54
+ /** The body of every `impl Name {` block, keyed by type name. */
55
+ function rustImpls(files) {
56
+ const impls = new Map();
57
+ for (const source of files.values()) {
58
+ for (const match of source.matchAll(/^impl (\w+) \{\n([\s\S]*?)^\}/gmu)) {
59
+ impls.set(match[1], (impls.get(match[1]) ?? '') + match[2]);
60
+ }
61
+ }
62
+ return impls;
63
+ }
64
+
65
+ /** The body of every top-level `class Name(…):`, keyed by class name. */
66
+ function pythonClasses(files) {
67
+ const classes = new Map();
68
+ for (const source of files.values()) {
69
+ for (const match of source.matchAll(
70
+ /^class (\w+)\([^)]*\):\n([\s\S]*?)(?=^\S|(?![\s\S]))/gmu
71
+ )) {
72
+ classes.set(match[1], match[2]);
73
+ }
74
+ }
75
+ return classes;
76
+ }
77
+
78
+ const rust = rustImpls(readDir(RUST_DIR, '.rs'));
79
+ const python = pythonClasses(readDir(PYTHON_DIR, '.py'));
80
+
81
+ /** Python inherits: a method is reachable when it or an ancestor defines it. */
82
+ function pythonHas(typeName, method) {
83
+ const pattern = new RegExp(
84
+ `^ {4}async def ${pythonMethodName(method)}\\(`,
85
+ 'mu'
86
+ );
87
+ for (let name = typeName; name; name = types.get(name)?.extends ?? null) {
88
+ if (pattern.test(python.get(name) ?? '')) {
89
+ return true;
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+
95
+ /** Rust has no inheritance: the method must be on the type's own `impl`. */
96
+ function rustHas(typeName, owner, method) {
97
+ const doc = `/// \`${owner}.${method}\``;
98
+ const name = rustMethodName(method);
99
+ const body = rust.get(typeName) ?? '';
100
+ return body.includes(doc) && body.includes(`pub async fn ${name}(`);
101
+ }
102
+
103
+ describe('Puppeteer bindings (Rust and Python)', () => {
104
+ it('are what the generator writes from the committed manifest', () => {
105
+ const generated = generatePuppeteerBindings(manifest);
106
+ for (const [dir, files, extension] of [
107
+ [RUST_DIR, generated.rust, '.rs'],
108
+ [PYTHON_DIR, generated.python, '.py'],
109
+ ]) {
110
+ const committed = readDir(dir, extension);
111
+ assert.deepEqual(
112
+ [...committed.keys()].sort(),
113
+ [...files.keys()].sort(),
114
+ `${dir} has stale or missing files; run node scripts/generate-puppeteer-bindings.mjs`
115
+ );
116
+ for (const [file, text] of files) {
117
+ assert.equal(
118
+ committed.get(file),
119
+ text,
120
+ `${path.join(dir, file)} is out of date; run node scripts/generate-puppeteer-bindings.mjs`
121
+ );
122
+ }
123
+ }
124
+ });
125
+
126
+ it('come from the installed puppeteer-core', (t) => {
127
+ let installed;
128
+ try {
129
+ installed = extractor.installedPuppeteer();
130
+ } catch {
131
+ t.skip('puppeteer-core is not installed');
132
+ return;
133
+ }
134
+ if (installed.version !== manifest.version) {
135
+ t.skip(
136
+ `puppeteer-core ${installed.version} is installed, the manifest is ${manifest.version}`
137
+ );
138
+ return;
139
+ }
140
+ const fresh = {
141
+ package: 'puppeteer-core',
142
+ version: installed.version,
143
+ ...extractor.extractPuppeteerApi(installed.source),
144
+ };
145
+ assert.equal(
146
+ fs.readFileSync(MANIFEST_PATH, 'utf8'),
147
+ formatManifest(fresh),
148
+ 'run node scripts/generate-puppeteer-bindings.mjs --update-api'
149
+ );
150
+ });
151
+
152
+ it('give every declared Puppeteer method a typed entry point', () => {
153
+ const missing = [];
154
+ let checked = 0;
155
+ for (const [typeName, methods] of puppeteerDeclarations()) {
156
+ for (const method of methods) {
157
+ checked += 1;
158
+ const member = types
159
+ .get(typeName)
160
+ ?.methods.find((candidate) => candidate.name === method);
161
+ if (!member) {
162
+ missing.push(`${typeName}.${method}: not in the manifest`);
163
+ continue;
164
+ }
165
+ if (!rustHas(typeName, member.owner, method)) {
166
+ missing.push(`${typeName}.${method}: no Rust method`);
167
+ }
168
+ if (!pythonHas(typeName, method)) {
169
+ missing.push(`${typeName}.${method}: no Python method`);
170
+ }
171
+ }
172
+ }
173
+ assert.deepEqual(missing, []);
174
+ assert.ok(checked > 250, `only ${checked} declared methods were checked`);
175
+ });
176
+
177
+ it('give every manifest member, own or inherited, a typed entry point', () => {
178
+ const missing = [];
179
+ let members = 0;
180
+ for (const type of manifest.types) {
181
+ for (const member of type.methods) {
182
+ members += 1;
183
+ if (!rustHas(type.name, member.owner, member.name)) {
184
+ missing.push(`Rust ${type.name}.${member.name}`);
185
+ }
186
+ if (!pythonHas(type.name, member.name)) {
187
+ missing.push(`Python ${type.name}.${member.name}`);
188
+ }
189
+ }
190
+ }
191
+ assert.deepEqual(missing, []);
192
+ assert.ok(types.size > 40, `only ${types.size} types`);
193
+ assert.ok(members > 450, `only ${members} members`);
194
+ });
195
+
196
+ it('keep Python classes in the Puppeteer class hierarchy', () => {
197
+ for (const type of manifest.types) {
198
+ const source = fs.readFileSync(
199
+ path.join(PYTHON_DIR, `${generator.snakeCase(type.name)}.py`),
200
+ 'utf8'
201
+ );
202
+ const parent =
203
+ type.extends &&
204
+ fs.existsSync(
205
+ path.join(PYTHON_DIR, `${generator.snakeCase(type.extends)}.py`)
206
+ );
207
+ const base = parent ? type.extends : 'RemoteObject';
208
+ assert.match(
209
+ source,
210
+ new RegExp(`^class ${type.name}\\(${base}\\):`, 'mu')
211
+ );
212
+ }
213
+ });
214
+ });
@@ -196,7 +196,14 @@ 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',
204
+ 'parity.yml snapshots Python native snapshot launches',
205
+ 'parity.yml snapshots Rust native snapshots and parity',
206
+ 'parity.yml storage-state Rust storage state transfer',
200
207
  'python.yml test pytest suite',
201
208
  'rust.yml coverage Rust code coverage',
202
209
  'rust.yml test Rust doc tests',
@@ -241,6 +248,26 @@ describe('CI execution budgets', () => {
241
248
  });
242
249
 
243
250
  describe('pipeline status gate', () => {
251
+ it('resolves WebDriver executables in the job that runs native WebDriver tests', () => {
252
+ const workflow = readWorkflow('parity.yml');
253
+ const jobs = listWorkflowJobs(workflow).filter((job) =>
254
+ getJobBlock(workflow, job).includes('--test webdriver')
255
+ );
256
+ assert.ok(
257
+ jobs.length > 0,
258
+ 'native WebDriver integration tests are missing'
259
+ );
260
+ for (const job of jobs) {
261
+ const block = getJobBlock(workflow, job);
262
+ const resolver = block.indexOf('resolve-webdriver.mjs');
263
+ const test = block.indexOf('--test webdriver');
264
+ assert.ok(
265
+ resolver >= 0 && resolver < test,
266
+ `${job}: resolve WebDriver before running its native tests`
267
+ );
268
+ }
269
+ });
270
+
244
271
  it('is present in every workflow and needs every other job', () => {
245
272
  for (const { fileName, text } of WORKFLOWS) {
246
273
  const jobs = listWorkflowJobs(text);