browser-commander 0.21.0 → 0.21.2

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 (53) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +20 -0
  4. package/package.json +6 -3
  5. package/src/browser/attach/snapshot.js +4 -4
  6. package/src/browser/browser-cookie-cache.js +14 -2
  7. package/src/browser/profile-directory.js +109 -6
  8. package/src/browser/real-browser.js +54 -16
  9. package/src/cli/args.js +47 -0
  10. package/src/cli/sessions.js +4 -0
  11. package/src/traces/bundle.js +3 -2
  12. package/src/traces/observers.js +14 -5
  13. package/src/traces/recorder.js +3 -0
  14. package/src/traces/viewer.js +16 -8
  15. package/tests/e2e/playwright.e2e.test.js +1 -0
  16. package/tests/e2e/puppeteer.e2e.test.js +1 -0
  17. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  18. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  19. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  20. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  21. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  22. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  23. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  24. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  25. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  26. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  27. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  28. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  29. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  30. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  31. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  32. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  33. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  34. package/tests/unit/browser/attach/extension-relay.test.js +1 -1
  35. package/tests/unit/browser/attach/snapshot-launch.test.js +10 -1
  36. package/tests/unit/browser/attach/snapshot.test.js +23 -4
  37. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  38. package/tests/unit/browser/launch-building-blocks.test.js +127 -4
  39. package/tests/unit/browser/migration/index.test.js +1 -1
  40. package/tests/unit/browser/open-in-user-browser.test.js +1 -1
  41. package/tests/unit/browser/parity.test.js +1 -1
  42. package/tests/unit/browser/real-browser.test.js +1 -1
  43. package/tests/unit/browser/storage-state.test.js +2 -0
  44. package/tests/unit/cli/main.test.js +30 -2
  45. package/tests/unit/core/navigation-manager.test.js +11 -1
  46. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  47. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  48. package/tests/unit/scripts/ci-timeout-budgets.test.js +23 -0
  49. package/tests/unit/scripts/feature-parity.test.js +111 -5
  50. package/tests/unit/traces/conformance.test.js +92 -0
  51. package/tests/unit/traces/reader.test.js +1 -1
  52. package/tests/unit/traces/recorder.test.js +1 -1
  53. package/tests/unit/traces/viewer.test.js +78 -0
@@ -1,4 +1,4 @@
1
- // feature-parity: cli.version cli.script cli.serve
1
+ // feature-parity: cli.version@native-typed cli.script@untyped-via-cli cli.serve@untyped-via-cli
2
2
  import { describe, it } from 'node:test';
3
3
  import assert from 'node:assert';
4
4
  import { EventEmitter } from 'node:events';
@@ -7,7 +7,11 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { PassThrough } from 'node:stream';
9
9
 
10
- import { parseCommandLine, UsageError } from '../../../src/cli/args.js';
10
+ import {
11
+ launchParams,
12
+ parseCommandLine,
13
+ UsageError,
14
+ } from '../../../src/cli/args.js';
11
15
  import { TRACE_STOP_MARKER } from '../../../src/cli/commands.js';
12
16
  import { EXIT_CODES, runCli } from '../../../src/cli/main.js';
13
17
  import { runScript } from '../../../src/cli/script.js';
@@ -33,6 +37,30 @@ async function assertUsageErrors(commands) {
33
37
  }
34
38
 
35
39
  describe('parseCommandLine', () => {
40
+ it('maps profile settings to real-browser launch options', () => {
41
+ const { options } = parseCommandLine([
42
+ 'launch',
43
+ '--pref',
44
+ 'browser.show_home_button=true',
45
+ '--pref',
46
+ 'intl.accept_languages=en-US',
47
+ '--local-state',
48
+ 'fre.has_user_seen_fre=false',
49
+ '--default-browser-check',
50
+ ]);
51
+ assert.deepEqual(launchParams(options), {
52
+ preferences: {
53
+ browser: { show_home_button: true },
54
+ intl: { accept_languages: 'en-US' },
55
+ },
56
+ localState: { fre: { has_user_seen_fre: false } },
57
+ defaultBrowserCheck: true,
58
+ });
59
+ assert.throws(
60
+ () => launchParams({ pref: ['__proto__.polluted=true'] }),
61
+ UsageError
62
+ );
63
+ });
36
64
  it('parses positionals, kebab-case options and two-word commands', () => {
37
65
  assert.deepEqual(
38
66
  parseCommandLine([
@@ -112,7 +112,17 @@ describe('navigation manager', () => {
112
112
  const { manager } = createManagerWithNetwork({
113
113
  // Burn the whole remaining budget, the way a real idle wait does.
114
114
  idle: async (opts) => {
115
- await new Promise((resolve) => setTimeout(resolve, opts.timeout));
115
+ // Timers truncate fractional delays and can wake before the
116
+ // monotonic deadline. Check elapsed time instead of assuming it.
117
+ const deadline = performance.now() + opts.timeout;
118
+ do {
119
+ await new Promise((resolve) =>
120
+ setTimeout(
121
+ resolve,
122
+ Math.max(1, Math.ceil(deadline - performance.now()))
123
+ )
124
+ );
125
+ } while (performance.now() < deadline);
116
126
  return false;
117
127
  },
118
128
  });
@@ -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
+ });
@@ -201,6 +201,9 @@ describe('CI execution budgets', () => {
201
201
  'parity.yml cli Rust CLI build',
202
202
  'parity.yml parity Fingerprint parity suite',
203
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',
204
207
  'python.yml test pytest suite',
205
208
  'rust.yml coverage Rust code coverage',
206
209
  'rust.yml test Rust doc tests',
@@ -245,6 +248,26 @@ describe('CI execution budgets', () => {
245
248
  });
246
249
 
247
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
+
248
271
  it('is present in every workflow and needs every other job', () => {
249
272
  for (const { fileName, text } of WORKFLOWS) {
250
273
  const jobs = listWorkflowJobs(text);