diff --git a/javascript/selenium-webdriver/BUILD.bazel b/javascript/selenium-webdriver/BUILD.bazel index 7cebfc0bcc37c..db31b8d70b7ed 100644 --- a/javascript/selenium-webdriver/BUILD.bazel +++ b/javascript/selenium-webdriver/BUILD.bazel @@ -33,10 +33,11 @@ js_binary( name = "generate_bidi_script", data = [ "generate_bidi.mjs", + "normalize_bidi_ast.mjs", + "project_bidi_schema.mjs", ":generated_note_local", ":license_header_local", ":node_modules/cddl", - ":node_modules/cddl2ts", ], entry_point = "generate_bidi.mjs", ) @@ -92,7 +93,6 @@ generate_bidi_library( # The schema step joins each spec's dfns index by type name to attach a `specHref` # link to each type's definition in the live spec. dfns_files = BIDI_DFNS_FILES, - enhancements_manifest = "//javascript/selenium-webdriver/private:bidi_enhancements_manifest", extra_cddl_files = BIDI_EXTENSION_CDDL_FILES, # Spec-shaped, browser-neutral productions that supersede an upstream one by name. Stay # in the shared schema: they complete command/event aggregates the specs define members diff --git a/javascript/selenium-webdriver/bidi/serialization/enum.d.ts b/javascript/selenium-webdriver/bidi/serialization/enum.d.ts index 4b0be54149e5e..93722f43ad79c 100644 --- a/javascript/selenium-webdriver/bidi/serialization/enum.d.ts +++ b/javascript/selenium-webdriver/bidi/serialization/enum.d.ts @@ -15,16 +15,19 @@ // specific language governing permissions and limitations // under the License. -export interface EnumEntry { +export interface EnumEntry { readonly values: readonly T[] includes(value: unknown): value is T } /** - * Registers a schema `enum` — a closed set of string values a field may hold. + * Registers a schema `enum` — a closed set of string, number, or boolean values a + * field may hold (e.g. emulation.MediaFeaturesGrid, CSS's `grid` media feature, is + * spec'd as the integer 0 or 1, not a string; the schema projector's literalPrimitive() + * recognizes a boolean-literal choice the same way). * @param name Schema type name, e.g. 'network.InterceptPhase'. * @param values The enum's valid values. * @returns The registered entry, used by validateValue() to check a ref'd value's * membership; also returned so a generator can build a discoverable constant from it. */ -export function defineEnum(name: string, values: readonly T[]): EnumEntry +export function defineEnum(name: string, values: readonly T[]): EnumEntry diff --git a/javascript/selenium-webdriver/bidi/serialization/enum.js b/javascript/selenium-webdriver/bidi/serialization/enum.js index 339c2d06b6515..194dc2db6abe4 100644 --- a/javascript/selenium-webdriver/bidi/serialization/enum.js +++ b/javascript/selenium-webdriver/bidi/serialization/enum.js @@ -18,10 +18,13 @@ const { register } = require('./registry') /** - * Registers a schema `enum` — a closed set of string values a field may hold. + * Registers a schema `enum` — a closed set of string, number, or boolean values a + * field may hold (e.g. emulation.MediaFeaturesGrid, CSS's `grid` media feature, is + * spec'd as the integer 0 or 1, not a string; the schema projector's literalPrimitive() + * recognizes a boolean-literal choice the same way). * @param {string} name Schema type name, e.g. 'network.InterceptPhase'. - * @param {string[]} values The enum's valid values. - * @returns {{kind: 'enum', values: string[], includes: function(unknown): boolean}} + * @param {Array} values The enum's valid values. + * @returns {{kind: 'enum', values: Array, includes: function(unknown): boolean}} * The registered entry, used by validateValue() to check a ref'd value's * membership; also returned so a generator can build a discoverable constant from it. */ diff --git a/javascript/selenium-webdriver/bidi/serialization/record.d.ts b/javascript/selenium-webdriver/bidi/serialization/record.d.ts index 39867e87275d7..b5be3d498d6d3 100644 --- a/javascript/selenium-webdriver/bidi/serialization/record.d.ts +++ b/javascript/selenium-webdriver/bidi/serialization/record.d.ts @@ -20,7 +20,11 @@ export interface TypeNode { primitive?: string const?: unknown ref?: string - enum?: string[] + // Almost always string (e.g. network.SameSite), but the schema can also derive a + // numeric or boolean enum where the spec itself uses one — emulation.MediaFeaturesGrid + // (CSS's `grid` media feature) is spec'd as the integer 0 or 1, not a string, and + // literalPrimitive() recognizes a boolean-literal choice the same way. + enum?: (string | number | boolean)[] list?: TypeNode map?: TypeNode union?: TypeNode[] diff --git a/javascript/selenium-webdriver/bidi/serialization/record.js b/javascript/selenium-webdriver/bidi/serialization/record.js index 37b3af6348188..661329f061aa1 100644 --- a/javascript/selenium-webdriver/bidi/serialization/record.js +++ b/javascript/selenium-webdriver/bidi/serialization/record.js @@ -20,14 +20,14 @@ const { register, resolve } = require('./registry') class ValidationError extends Error {} // Validates a *present* value against a resolved type node, and returns the value to -// assign for it — usually the same value, but for a nested ref-to-record/union parsed -// inbound (fromWire()/build() results, not the raw wire object) and for list/map/inline -// record values (a fresh, deeply-frozen copy, so validity can't be corrupted after the -// fact by mutating an array or object a caller still holds a reference to). -// Outbound nested refs deliberately keep the caller's own value instead of the -// newly-constructed instance — the nested `new referenced.RecordClass(value)` / -// `referenced.build(value)` call below exists only to validate, matching the -// constructor's existing outbound behavior of trusting the caller's own object shape. +// assign for it — usually the same value, but for a nested ref-to-record/union (the +// freshly constructed/parsed instance — fromWire()/build() inbound, `new RecordClass(value)`/ +// build() outbound, not the caller's raw value) and for list/map/inline record values (a +// fresh, deeply-frozen copy, so validity can't be corrupted after the fact by mutating an +// array or object a caller still holds a reference to). Outbound needs the constructed +// instance, symmetric with inbound, so a typed parent's toJSON() cascades into a nested +// record's own toJSON() — converting *its* fields to their declared wire names too, not +// just the parent's own fields — when the whole instance is serialized for the wire. // `direction` only affects how a nested ref-to-record/union is itself validated/parsed. function validateValue(typeNode, value, path, direction) { if (value === null) { @@ -125,23 +125,24 @@ function validateValue(typeNode, value, path, direction) { throw new ValidationError(`${path}: expected an object, got ${typeof value}`) } // Recurse through the same-direction path so a nested field gets the same - // tolerance (inbound) or strictness (outbound) as its parent. Inbound returns - // the parsed instance itself, so a typed parent record ends up with a typed - // nested value instead of the raw wire object; outbound only validates this - // way (the caller's own value is what gets kept, see the note above). + // tolerance (inbound) or strictness (outbound) as its parent, and — both + // directions alike — ends up holding the typed instance, not the raw value: + // inbound so a typed parent record has a typed nested value instead of the + // raw wire object; outbound so the parent's own toJSON() cascades into this + // nested instance's toJSON() too, converting its fields to their wire names. if (direction === 'inbound') { return referenced.RecordClass.fromWire(value) } - new referenced.RecordClass(value) - return value + return new referenced.RecordClass(value) } if (referenced.kind === 'union') { + // build()/fromWire() already return the resolved variant's own instance — + // same reasoning as the record branch above applies to it symmetrically. if (direction === 'inbound') { return referenced.fromWire(value) } - referenced.build(value) - return value + return referenced.build(value) } if (referenced.kind === 'alias') { @@ -220,33 +221,37 @@ function defineRecord(name, fields, options = {}) { class Record { // Outbound: strict. Any value that doesn't match its declared shape is an error here. + // Reads `data` by JS-facing field name (field.name), not the wire key (field.wire) — + // a caller builds params using the same property names the generated TS interface + // declares (e.g. `prefersColorScheme`), never the raw spec key (`prefers-color-scheme`); + // toJSON() below is what maps back to field.wire for the actual wire send. constructor(data) { if (typeof data !== 'object' || data === null || Array.isArray(data)) { throw new ValidationError(`${name}: expected an object`) } for (const field of fields) { - if (!Object.hasOwn(data, field.wire)) { + if (!Object.hasOwn(data, field.name)) { if (field.required) { - throw new ValidationError(`${name}.${field.wire}: required field is missing`) + throw new ValidationError(`${name}.${field.name}: required field is missing`) } continue } - const value = data[field.wire] - this[field.name] = validateValue(field.type, value, `${name}.${field.wire}`, 'outbound') + const value = data[field.name] + this[field.name] = validateValue(field.type, value, `${name}.${field.name}`, 'outbound') } - for (const wireKey of Object.keys(data)) { - if (byWire.has(wireKey)) continue + for (const key of Object.keys(data)) { + if (byName.has(key)) continue if (!extensible) { - throw new ValidationError(`${name}: unknown property "${wireKey}"`) + throw new ValidationError(`${name}: unknown property "${key}"`) } - // Object.defineProperty, not `this[wireKey] = ...`: wireKey is caller-supplied - // and a literal "__proto__" key assigned via bracket notation hijacks this - // instance's actual prototype instead of becoming a field (CWE-1321). - // defineProperty always creates a genuine own data property, regardless of name. - Object.defineProperty(this, wireKey, { - value: data[wireKey], // vendor extras reach the wire on an extensible type + // Object.defineProperty, not `this[key] = ...`: key is caller-supplied and a + // literal "__proto__" key assigned via bracket notation hijacks this instance's + // actual prototype instead of becoming a field (CWE-1321). defineProperty always + // creates a genuine own data property, regardless of name. + Object.defineProperty(this, key, { + value: data[key], // vendor extras have no separate wire name — this key is both enumerable: true, writable: true, configurable: true, diff --git a/javascript/selenium-webdriver/generate_bidi.mjs b/javascript/selenium-webdriver/generate_bidi.mjs index 5fdb371fb01bb..11cad9e2b49a5 100644 --- a/javascript/selenium-webdriver/generate_bidi.mjs +++ b/javascript/selenium-webdriver/generate_bidi.mjs @@ -22,40 +22,20 @@ * 1. parse --cddl --dump-ast CDDL → AST * 2. model --ast --dump-model AST → command/event model * 3. generate --ast --model --output-dir AST + model → one TS module per domain - * [--enhancements ] [--spec-version ] + * [--spec-version ] */ import { parse } from 'cddl' -import { transform } from 'cddl2ts' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' +import { projectSchema } from './project_bidi_schema.mjs' // ============================================================ // Domain configuration // ============================================================ -// Maps TypeScript export name prefixes to domain keys. -// Ordered longest-first so the most specific prefix always wins. -const NAME_PREFIX_TO_DOMAIN = [ - ['UserAgentClientHints', 'userAgentClientHints'], - ['DigitalCredentials', 'digitalCredentials'], - ['BrowsingContext', 'browsingContext'], - ['WebExtension', 'webExtension'], - ['Permissions', 'permissions'], - ['Bluetooth', 'bluetooth'], - ['Emulation', 'emulation'], - ['Speculation', 'speculation'], - ['Storage', 'storage'], - ['Session', 'session'], - ['Network', 'network'], - ['Script', 'script'], - ['Input', 'input'], - ['Browser', 'browser'], - ['Log', 'log'], -] - // Output filename for each domain key. const DOMAIN_FILES = { browser: 'browser.ts', @@ -136,7 +116,6 @@ async function main() { model: { type: 'string' }, 'dump-ast': { type: 'string' }, 'dump-model': { type: 'string' }, - enhancements: { type: 'string' }, 'output-dir': { type: 'string' }, 'spec-version': { type: 'string', default: '1.0' }, }, @@ -167,7 +146,7 @@ async function main() { ' generate_bidi.mjs --cddl [--cddl ...] [--override-cddl ...] --dump-ast \n' + ' generate_bidi.mjs --ast --vendor-cddl = [...] --dump-ast \n' + ' generate_bidi.mjs --ast --dump-model \n' + - ' generate_bidi.mjs --ast --model --output-dir [--enhancements ] [--spec-version ]', + ' generate_bidi.mjs --ast --model --output-dir [--spec-version ]', ) process.exit(1) } @@ -276,35 +255,32 @@ function writeJson(fileArg, data, label, pretty = false) { console.log(` → ${out} (${label})`) } -/** Emit one TS module per domain: types from the AST (cddl2ts), methods from the model. */ +/** Emit one TS module per domain: types and commands/events, both from bidi_schema.json. */ function generateTypeScript(ast, model, args) { const outputDir = resolve(args['output-dir']) const specVersion = args['spec-version'] - const enhancements = loadEnhancements(args.enhancements) const unmapped = Object.keys(model).filter((d) => !(d in DOMAIN_FILES)) if (unmapped.length) { throw new Error(`model domains without a DOMAIN_FILES/DOMAIN_CLASSES entry: ${unmapped.join(', ')}`) } - console.log('Pass 1: generating types via cddl2ts…') - const rawTypes = transform(ast) - const cleanTypes = postProcessTypes(rawTypes) - const typesByDomain = splitTypesByDomain(cleanTypes) - const typeNameToDomain = buildTypeNameToDomainMap(typesByDomain) + console.log('Projecting the binding-neutral schema…') + const schema = projectSchema(ast, model) + console.log( + ` ${schema.commands.length} commands, ${schema.events.length} events, ${Object.keys(schema.types).length} types`, + ) - console.log('Pass 2: building commands and events from model…') - const allCommands = modelToCommands(model) - const allEvents = modelToEvents(model) - console.log(` ${allCommands.length} commands, ${allEvents.length} events`) + const typesByDomain = groupTypesByDomain(schema.types) + const allCommands = schemaToCommands(schema) + const allEvents = schemaToEvents(schema) mkdirSync(outputDir, { recursive: true }) for (const [domainKey, filename] of Object.entries(DOMAIN_FILES)) { - const types = typesByDomain[domainKey] ?? '' + const types = typesByDomain[domainKey] ?? {} const commands = allCommands.filter((c) => c.domain === domainKey) const events = allEvents.filter((e) => e.domain === domainKey) - const enhancement = enhancements[domainKey] ?? {} const className = DOMAIN_CLASSES[domainKey] const content = generateDomainFile({ @@ -313,9 +289,7 @@ function generateTypeScript(ast, model, args) { types, commands, events, - enhancement, specVersion, - typeNameToDomain, }) const outPath = join(outputDir, filename) @@ -327,188 +301,23 @@ function generateTypeScript(ast, model, args) { } // ============================================================ -// Enhancements manifest -// ============================================================ - -function loadEnhancements(manifestPath) { - if (!manifestPath) return {} - const fullPath = resolveInputPath(manifestPath) - if (!existsSync(fullPath)) { - console.warn(`Warning: enhancements manifest not found: ${fullPath}`) - return {} - } - let parsed - try { - parsed = JSON.parse(readFileSync(fullPath, 'utf8')) - } catch (err) { - throw new Error(`Failed to parse enhancements manifest at ${fullPath}: ${err.message}`) - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error( - `Enhancements manifest at ${fullPath} must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`, - ) - } - return parsed -} - -// ============================================================ -// Pass 1: type post-processing -// ============================================================ - -/** - * Reconcile cddl2ts's per-name declarations and replace `any` with `unknown`. - * - * cddl2ts emits several declarations for one name in two cases: - * - identical duplicates, when the `*-all.cddl` input concatenates local + remote - * definitions of a shared type — keep the first, drop the rest; and - * - a group that both aliases another (`X = ( Extensible )` → `type X = Extensible`) - * and gains fields via `//=` (→ `interface X { … }`). A type alias and an interface - * of the same name cannot coexist in TS, so fold them into one intersection - * (`type X = Extensible & { … }`) rather than letting the interface's fields drop. - */ -function postProcessTypes(rawTs) { - const clean = (s) => - s.replace(/Record/g, 'Record').replace(/: any([;,)\s\[])/g, ': unknown$1') - - // Split into ordered items: a declaration block { name, kind, lines } or raw { text }. - const lines = rawTs.split('\n') - const items = [] - let i = 0 - while (i < lines.length) { - const m = lines[i].match(/^export (type|interface) (\w+)/) - if (!m) { - items.push({ text: lines[i] }) - i++ - continue - } - const start = i - if (lines[i].includes('{') && !lines[i].endsWith('{}') && !lines[i].endsWith('{};')) { - let depth = (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length - i++ - while (i < lines.length && depth > 0) { - depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length - i++ - } - } else { - i++ - } - items.push({ name: m[2], kind: m[1], lines: lines.slice(start, i) }) - } - - const byName = new Map() - for (const it of items) if (it.name) byName.set(it.name, [...(byName.get(it.name) ?? []), it]) - - const emitted = new Set() - const output = [] - for (const it of items) { - if (!it.name) { - output.push(it.text) - continue - } - if (emitted.has(it.name)) continue - emitted.add(it.name) - output.push(reconcileDecls(it.name, byName.get(it.name)).join('\n')) - } - - return clean(output.join('\n')) -} - -/** The lines between an interface's braces, i.e. its member declarations. */ -function interfaceBody(block) { - const text = block.lines.join('\n') - return text.slice(text.indexOf('{') + 1, text.lastIndexOf('}')).replace(/^\n|\n$/g, '') -} - -/** - * Collapse a name's cddl2ts declarations into one. A lone declaration (or identical - * duplicates) keeps the first. A `type X = ` alias plus `interface X { … }` - * bodies fold into `type X = & { … }` so the interface fields survive. - */ -function reconcileDecls(name, blocks) { - const alias = blocks.find((b) => b.kind === 'type') - const bodies = blocks - .filter((b) => b.kind === 'interface') - .map(interfaceBody) - .filter((b) => b.trim()) - if (!alias || !bodies.length) return blocks[0].lines - const rhs = alias.lines - .join('\n') - .replace(/^export type \w+\s*=\s*/, '') - .replace(/;\s*$/, '') - .trim() - return [`export type ${name} = ${[rhs, ...bodies.map((b) => `{\n${b}\n}`)].join(' & ')};`] -} - -// ============================================================ -// Domain splitting +// Schema-derived domain grouping // ============================================================ -function getDomainForExportName(name) { - for (const [prefix, domain] of NAME_PREFIX_TO_DOMAIN) { - if (name.startsWith(prefix)) return domain - } - return 'common' +/** A schema type name's domain segment, e.g. 'network.AddIntercept' -> 'network'. */ +function domainForTypeName(name) { + const dotIdx = name.indexOf('.') + if (dotIdx === -1) return 'common' + const domain = name.slice(0, dotIdx) + return Object.hasOwn(DOMAIN_FILES, domain) ? domain : 'common' } -/** - * Partition the flat cddl2ts TypeScript output into per-domain strings, - * treating each blank-line-separated block as one export declaration. - */ -function splitTypesByDomain(cleanTypes) { - const domainLines = {} - - const lines = cleanTypes.split('\n') - let blockLines = [] - - // Flush one accumulated block, splitting it further by individual exports - // so that consecutive single-line declarations (no blank line between them) - // each land in the correct domain rather than all being bucketed under the - // first declaration's domain. - const flushBlock = () => { - if (blockLines.length === 0) return - - let exportLines = [] - let exportDomain = null - - const commitExport = () => { - if (exportLines.length === 0) return - const domain = exportDomain ?? 'common' - if (!domainLines[domain]) domainLines[domain] = [] - domainLines[domain].push(...exportLines, '') - exportLines = [] - exportDomain = null - } - - for (const line of blockLines) { - const m = line.match(/^export (?:type|interface) (\w+)/) - if (m) { - commitExport() - exportDomain = getDomainForExportName(m[1]) - } - exportLines.push(line) - } - commitExport() - - blockLines = [] - } - - for (const line of lines) { - // Skip cddl2ts source comment headers. - if (line.startsWith('// GENERATED CONTENT') || line.startsWith('// Source:')) { - flushBlock() - continue - } - if (line === '' && blockLines.length > 0) { - flushBlock() - } else if (line !== '') { - blockLines.push(line) - } - } - flushBlock() - +/** Groups the schema's flat `types` map into `{ domain: { typeName: node } }`. */ +function groupTypesByDomain(types) { const result = {} - for (const [domain, dl] of Object.entries(domainLines)) { - result[domain] = dl.join('\n').trimEnd() + for (const [name, node] of Object.entries(types)) { + const domain = domainForTypeName(name) + ;(result[domain] ??= {})[name] = node } return result } @@ -541,14 +350,22 @@ function buildEmptyParamTypes(ast) { function normalizeDottedName(name) { return name .split('.') - .map((part) => { - const titled = part.charAt(0).toUpperCase() + part.slice(1) - // Normalize acronym runs to match cddl2ts output: - // CSPParameters → CspParameters HTMLCollection → HtmlCollection - // Rule: 2+ uppercase letters followed by an uppercase+lowercase pair (or end - // of string) → keep only the first uppercase and lowercase the rest. - return titled.replace(/([A-Z]{2,})(?=[A-Z][a-z]|$)/g, (m) => m[0] + m.slice(1).toLowerCase()) - }) + .map((part) => + // A schema name segment may itself be hyphenated (e.g. the CDDL prelude + // range aliases 'js-uint'/'js-int'), which isn't a valid TS identifier + // character — split on '-' too so each word gets its own PascalCase turn. + part + .split('-') + .map((word) => { + const titled = word.charAt(0).toUpperCase() + word.slice(1) + // Normalize acronym runs to match cddl2ts output: + // CSPParameters → CspParameters HTMLCollection → HtmlCollection + // Rule: 2+ uppercase letters followed by an uppercase+lowercase pair (or end + // of string) → keep only the first uppercase and lowercase the rest. + return titled.replace(/([A-Z]{2,})(?=[A-Z][a-z]|$)/g, (m) => m[0] + m.slice(1).toLowerCase()) + }) + .join(''), + ) .join('') } @@ -793,39 +610,26 @@ function buildResultTypeNames(ast) { return names } -/** Map model commands to the generator's command-entry shape. */ -function modelToCommands(model) { - const commands = [] - for (const [domain, entry] of Object.entries(model)) { - for (const c of entry.commands) { - commands.push({ - domain, - methodStr: c.method, - methodName: c.name, - paramsTypeName: c.params !== null ? normalizeDottedName(c.params) : null, - hasParams: c.params !== null, - resultTypeName: c.result !== null ? normalizeDottedName(c.result) : null, - }) - } - } - return commands +/** Map the schema's commands to the generator's command-entry shape. */ +function schemaToCommands(schema) { + return schema.commands.map((c) => ({ + domain: c.domain, + methodStr: c.method, + methodName: c.name, + paramsTypeName: c.params ? normalizeDottedName(c.params.ref) : null, + hasParams: c.params !== null, + resultTypeName: c.result ? normalizeDottedName(c.result.ref) : null, + })) } -/** Map model events to the generator's event-entry shape. */ -function modelToEvents(model) { - const events = [] - for (const [domain, entry] of Object.entries(model)) { - for (const e of entry.events) { - events.push({ - domain, - methodStr: e.method, - eventName: e.name, - paramsTypeName: e.params !== null ? normalizeDottedName(e.params) : null, - onMethodName: 'on' + e.name.charAt(0).toUpperCase() + e.name.slice(1), - }) - } - } - return events +/** Map the schema's events to the generator's event-entry shape. */ +function schemaToEvents(schema) { + return schema.events.map((e) => ({ + domain: e.domain, + methodStr: e.method, + eventName: e.name, + paramsTypeName: e.params ? normalizeDottedName(e.params.ref) : null, + })) } // ============================================================ @@ -850,120 +654,267 @@ const GENERATED_NOTE = commentLines( ) // ============================================================ -// Type-map helpers for cross-domain import generation +// Schema type node -> TypeScript, and cross-domain imports // ============================================================ -/** - * Returns a Map from exported type name → domain key. - * Used to generate cross-domain import statements. - */ -function buildTypeNameToDomainMap(typesByDomain) { - const map = new Map() - for (const [domain, typeBlock] of Object.entries(typesByDomain)) { - for (const line of typeBlock.split('\n')) { - const m = line.match(/^export (?:type|interface) (\w+)/) - if (m) map.set(m[1], domain) - } +const PRIMITIVE_TS = { string: 'string', integer: 'number', number: 'number', boolean: 'boolean', null: 'null' } + +/** Converts a schema type-ref node into a TypeScript type expression. */ +function typeNodeToTs(node) { + if (!node) return 'unknown' + let base + if (node.primitive !== undefined) { + base = PRIMITIVE_TS[node.primitive] ?? 'unknown' + } else if (node.const !== undefined) { + base = JSON.stringify(node.const) + } else if (node.ref !== undefined) { + base = normalizeDottedName(node.ref) + } else if (node.enum !== undefined) { + base = node.enum.map((v) => JSON.stringify(v)).join(' | ') + } else if (node.list !== undefined) { + base = `Array<${typeNodeToTs(node.list)}>` + } else if (node.map !== undefined) { + base = `Record` + } else if (node.union !== undefined) { + base = node.union.map((v) => typeNodeToTs(v)).join(' | ') + } else { + base = 'unknown' } - return map + return node.nullable ? `${base} | null` : base } -/** - * Scans a domain's type block for references to types that live in OTHER - * domains and returns the import statements needed to make the file compile. - * - * Only PascalCase identifiers that exist in typeNameToDomain and belong to a - * different domain are considered. Built-in TypeScript types (string, number, - * boolean, …) never appear in the map, so they are naturally excluded. - */ -function computeCrossDomainImports(typeBlock, domain, typeNameToDomain) { - if (!typeBlock) return [] +/** The type-name refs a projected ref node points at (list/map/union recurse). */ +function refsIn(node) { + if (!node) return [] + if (node.ref) return [node.ref] + if (node.list) return refsIn(node.list) + if (node.map) return refsIn(node.map) + if (node.union) return node.union.flatMap(refsIn) + return [] +} - // Collect all PascalCase identifiers referenced in the type block. - const referenced = new Set() - for (const match of typeBlock.matchAll(/\b([A-Z][A-Za-z0-9]*)\b/g)) { - referenced.add(match[1]) +/** The type-name refs a type *node* (record/union/alias) points at. */ +function typeRefNames(node) { + if (node.kind === 'record') { + const refs = node.fields.flatMap((f) => refsIn(f.type)) + if (node.map) refs.push(...refsIn(node.map)) + return refs } + if (node.kind === 'union') return node.variants + if (node.kind === 'alias') return refsIn(node.type) + return [] +} - // Group by source domain (skip same-domain types and unknown types). +/** + * Import statements for every other-domain type this domain's types reference: a + * type-only import for the TS names themselves, plus a plain side-effect import of + * the same module. The type-only import is erased at compile time (it exists purely + * for `tsc`), so it registers nothing at runtime — a caller who only imports, say, + * `session.js` would never actually load `browsing_context.js`, leaving every + * `browsingContext.*` type unregistered in the shared runtime registry (registry.js) + * and silently skipping deep validation for any field that refs one (see + * validateValue()'s `if (referenced === undefined) return value` in record.js). + * The side-effect import forces that module to load — and therefore register its + * types — regardless of whether this domain is used standalone. Safe even when two + * domains reference each other (e.g. session <-> browsingContext): CommonJS resolves + * a require() cycle by returning the other module's not-yet-complete exports, but a + * bare side-effect import binds nothing, and no type here is read at define time + * anyway — resolution happens lazily, at validation time (registry.js), by which + * point both modules have finished loading. + */ +function computeCrossDomainImports(types, domain) { const bySourceDomain = new Map() - for (const name of referenced) { - const sourceDomain = typeNameToDomain.get(name) - if (!sourceDomain || sourceDomain === domain) continue - if (!bySourceDomain.has(sourceDomain)) bySourceDomain.set(sourceDomain, new Set()) - bySourceDomain.get(sourceDomain).add(name) + for (const node of Object.values(types)) { + for (const ref of typeRefNames(node)) { + const sourceDomain = domainForTypeName(ref) + if (sourceDomain === domain) continue + if (!bySourceDomain.has(sourceDomain)) bySourceDomain.set(sourceDomain, new Set()) + bySourceDomain.get(sourceDomain).add(normalizeDottedName(ref)) + } } - // Emit sorted import lines. const imports = [] for (const [sourceDomain, names] of [...bySourceDomain.entries()].sort()) { const sourceFile = DOMAIN_FILES[sourceDomain].replace('.ts', '.js') - const sorted = [...names].sort() - imports.push(`import type { ${sorted.join(', ')} } from './${sourceFile}'`) + imports.push(`import type { ${[...names].sort().join(', ')} } from './${sourceFile}'`) + imports.push(`import './${sourceFile}'`) } return imports } -function generateDomainFile({ - domain, - className, - types, - commands, - events, - enhancement, - specVersion, - typeNameToDomain, -}) { +/** + * Emits one schema type's TS declaration plus the runtime call that registers + * it for validation — see bidi/serialization/{record,enum,union}.js. Returns + * the runtime binding's own name and kind too, so a command method in the + * same domain can reference it directly (e.g. to validate its params before + * sending, or parse its result via fromWire()) rather than only reaching + * nested/cross-domain refs indirectly through the shared registry. + */ +function generateTypeDeclaration(name, node) { + const tsName = normalizeDottedName(name) + + if (node.kind === 'enum') { + const runtimeName = `${tsName}Enum` + const literal = node.values.map((v) => JSON.stringify(v)).join(' | ') + const ts = [ + `export type ${tsName} = ${literal}`, + `const ${runtimeName} = defineEnum<${tsName}>('${name}', ${JSON.stringify(node.values)})`, + ].join('\n') + return { ts, runtimeName, kind: 'enum' } + } + + if (node.kind === 'alias') { + const ts = [ + `export type ${tsName} = ${typeNodeToTs(node.type)}`, + `defineAlias('${name}', ${JSON.stringify(node.type)})`, + ].join('\n') + return { ts, runtimeName: null, kind: 'alias' } + } + + if (node.kind === 'record') { + // No suffix: an interface and a const may share one name in TS (they live in + // separate type/value spaces, and only the const survives compilation to JS) — + // verified this compiles clean. Two names for one concept was self-inflicted + // confusion, not a real requirement; Ruby's generator uses one name too. + const runtimeName = tsName + const lines = [`export interface ${tsName} {`] + for (const field of node.fields) { + // Tolerating a missing required field's absence is no longer required — + // fromWire() now rejects a missing required field the same as any other + // invalid value, so a required field is always present, nullable or not. + const optional = !field.required + lines.push(` ${field.name}${optional ? '?' : ''}: ${typeNodeToTs(field.type)}`) + } + lines.push(`}`) + const optionsArg = node.extensible ? `, ${JSON.stringify({ extensible: true })}` : '' + lines.push(`const ${runtimeName} = defineRecord<${tsName}>('${name}', ${JSON.stringify(node.fields)}${optionsArg})`) + return { ts: lines.join('\n'), runtimeName, kind: 'record' } + } + + if (node.kind === 'union') { + const runtimeName = `${tsName}Union` + const memberNames = node.variants.map((v) => normalizeDottedName(v)) + const options = {} + if (node.objectOnly) options.objectOnly = true + const optionsArg = Object.keys(options).length ? `, ${JSON.stringify(options)}` : '' + const ts = [ + `export type ${tsName} = ${memberNames.join(' | ')}`, + `const ${runtimeName} = defineUnion<${tsName}>('${name}', ${JSON.stringify(node.selector)}${optionsArg})`, + ].join('\n') + return { ts, runtimeName, kind: 'union' } + } + + return { ts: `export type ${tsName} = unknown`, runtimeName: null, kind: 'unknown' } +} + +/** + * Follows an alias chain (an alias can point at another alias) to the record/union + * it ultimately resolves to, within `types` — one domain's own type map, the same + * shape generateDomainFile already has, so this needs no extra generator plumbing. + * Only a same-domain target is resolvable this way: a cross-domain alias (by far the + * most common case — `-> EmptyResult`, used across every domain) would need the target's + * runtime binding imported as a *value*, not just a type, from another domain's compiled + * file — real plumbing this doesn't attempt. Left unresolved there deliberately: EmptyResult + * has no required fields, so an unchecked cast catches nothing a fromWire() call would + * have caught either way — there's no actual validation gap to close for that case, only + * for a same-domain alias to a record/union that has real fields (or variants) to check. + * @returns {{runtimeName: string, kind: 'record'|'union'}|null} + */ +function resolveAliasRuntime(node, types) { + const seen = new Set() + let current = node + while (current?.kind === 'alias' && current.type?.ref && !seen.has(current.type.ref)) { + seen.add(current.type.ref) + const targetName = current.type.ref + const target = types[targetName] + if (target === undefined) return null // not in this domain — cross-domain, left unresolved + if (target.kind === 'record') return { runtimeName: normalizeDottedName(targetName), kind: 'record' } + if (target.kind === 'union') return { runtimeName: `${normalizeDottedName(targetName)}Union`, kind: 'union' } + current = target // another alias — keep resolving + } + return null +} + +function generateDomainFile({ domain, className, types, commands, events, specVersion }) { const parts = [LICENSE_HEADER, '', GENERATED_NOTE] parts.push(`// Built from the WebDriver BiDi CDDL spec (v${specVersion}).`) parts.push(`// Source: https://github.com/w3c/webref/tree/main/ed/cddl`) parts.push('') - const filteredCommands = commands.filter((c) => !enhancement.excludeMethods?.includes(c.methodName)) - const filteredEvents = events.filter((e) => !enhancement.excludeMethods?.includes(e.eventName)) - const hasImplementation = className != null && (filteredCommands.length > 0 || filteredEvents.length > 0) + const hasImplementation = className != null && (commands.length > 0 || events.length > 0) - // Filter out excluded types before emitting. - let typeBlock = types - if (enhancement.excludeTypes?.length) { - typeBlock = filterExcludedTypes(typeBlock, enhancement.excludeTypes) - } - - // Compute cross-domain imports needed by this domain's type block. - // Types from other domains are referenced by name but live in separate files. - const crossDomainImports = computeCrossDomainImports(typeBlock, domain, typeNameToDomain) + const typeEntries = Object.entries(types) + const crossDomainImports = computeCrossDomainImports(Object.fromEntries(typeEntries), domain) if (crossDomainImports.length > 0) { - for (const line of crossDomainImports) { - parts.push(line) - } + for (const line of crossDomainImports) parts.push(line) + parts.push('') + } + + if (typeEntries.length > 0) { + parts.push(`import { defineRecord, defineAlias } from '../serialization/record.js'`) + parts.push(`import { defineEnum } from '../serialization/enum.js'`) + parts.push(`import { defineUnion } from '../serialization/union.js'`) parts.push('') } if (hasImplementation) { - // Define the BiDi connection interface inline so the generated file is - // self-contained for tsc and doesn't need to resolve ../index.js. - parts.push(`/** Minimal BiDi transport interface (satisfied structurally by bidi/index.js). */`) - parts.push(`interface BidiConnection {`) - parts.push(` send(command: Record): Promise`) - parts.push(` subscribe(event: string | string[], contexts?: string[]): Promise`) - parts.push(` on(event: string, listener: (params: unknown) => void): void`) - parts.push(`}`) + // Domain.connect(driver) reaches the one BiDi connection for `driver` + // through the real internal accessor (never the deprecated driver.getBidi()). + parts.push(`import { Domain, event, type EventDescriptor, DOMAIN_TOKEN } from '../domain.js'`) parts.push('') } - if (typeBlock) { + // tsName -> {runtimeName, kind, fields?}, so a command method in this same file + // can validate its own params/result directly (rather than only reaching + // nested/cross-domain refs indirectly through the shared registry) and document + // each params field in its JSDoc from the same field list, not a second copy. + const runtimeByTsName = new Map() + // Enum constants owned by this domain, exposed as a discoverable static property + // on the domain class (or a top-level export when there's no class to attach to) — + // e.g. Network.SameSite = { STRICT: 'strict', ... }, mirroring how Ruby's generator + // exposes Network::SAME_SITE as a real, inspectable constant rather than a bare string. + const enumConstants = [] + + if (typeEntries.length > 0) { parts.push(`// --- Types ---`) parts.push('') - parts.push(typeBlock) - parts.push('') + for (const [name, node] of typeEntries) { + const declared = generateTypeDeclaration(name, node) + const tsName = normalizeDottedName(name) + if (declared.runtimeName) { + runtimeByTsName.set(tsName, { + runtimeName: declared.runtimeName, + kind: declared.kind, + fields: node.kind === 'record' ? node.fields : undefined, + }) + } else if (declared.kind === 'alias') { + // An alias itself has no RecordClass/UnionClass of its own (declared.runtimeName + // is null, see generateTypeDeclaration) — but when it ultimately points at a + // same-domain record/union, a command whose result *is* that alias (e.g. + // browser.CreateUserContextResult -> browser.UserContextInfo) should still + // validate inbound through fromWire(), not silently cast an unchecked response. + // Resolved by naming convention (a record's runtime const is its own tsName; a + // union's is `${tsName}Union`), not by depending on the target having already + // been processed — types is a complete map regardless of iteration order. + const resolved = resolveAliasRuntime(node, types) + if (resolved) runtimeByTsName.set(tsName, { ...resolved, fields: undefined }) + } + if (declared.kind === 'enum') { + enumConstants.push({ propertyName: localSchemaName(name, tsName), values: node.values }) + } + parts.push(declared.ts) + parts.push('') + } } - if (enhancement.extraTypes) { - parts.push(`// --- Additional Types ---`) + if (!hasImplementation && enumConstants.length > 0) { + parts.push(`// --- Enum constants ---`) parts.push('') - parts.push(enhancement.extraTypes) + for (const { propertyName, values } of enumConstants) { + parts.push(`export const ${propertyName} = ${generateEnumConstantLiteral(values)}`) + } parts.push('') } @@ -973,9 +924,10 @@ function generateDomainFile({ parts.push( generateClass({ className, - commands: filteredCommands, - events: filteredEvents, - enhancement, + commands, + events, + runtimeByTsName, + enumConstants, }), ) } @@ -983,128 +935,179 @@ function generateDomainFile({ return parts.join('\n') + '\n' } -function filterExcludedTypes(typeBlock, excludeTypes) { - const lines = typeBlock.split('\n') - const output = [] - let i = 0 - - while (i < lines.length) { - const line = lines[i] - const match = line.match(/^export (?:type|interface) (\w+)/) - if (match && excludeTypes.includes(match[1])) { - if (line.includes('{') && !line.endsWith('{}') && !line.endsWith('{};')) { - let depth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length - i++ - while (i < lines.length && depth > 0) { - depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length - i++ - } - } else { - i++ - } - if (i < lines.length && lines[i] === '') i++ - continue - } - output.push(line) - i++ - } +// Disclaimer attached to every generated class — matches Java's BiDiGenerator. +const INTERNAL_API_DOC = [ + '/**', + ' * This is an unsupported API. No compatibility guarantees are provided.', + ' * It tracks the W3C WebDriver BiDi specification directly. As the specification', + ' * evolves, this API will change or be removed without prior notice.', + ' */', +].join('\n') + +// camelCase -> SCREAMING_SNAKE_CASE, for event descriptor constant names +// ("beforeRequestSent" -> "BEFORE_REQUEST_SENT") and enum constant keys +// ("beforeRequestSent" -> "BEFORE_REQUEST_SENT", "strict" -> "STRICT"). +function screamingSnakeCase(camel) { + return camel.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase() +} + +// The un-prefixed local segment of a dotted schema name, PascalCased — +// 'network.InterceptPhase' -> 'InterceptPhase'. Used for a static enum constant's +// property name on its owning domain class, where the class already implies the +// domain (Network.InterceptPhase, not Network.NetworkInterceptPhase). +function localSchemaName(name, tsNameFallback) { + const dotIdx = name.indexOf('.') + return dotIdx === -1 ? tsNameFallback : normalizeDottedName(name.slice(dotIdx + 1)) +} + +// A valid JS identifier for an enum constant's key, derived from its wire value. +// Unlike event/method names (always clean camelCase), some enum values are +// space- or hyphen-separated (error.ErrorCode: "invalid argument", emulation. +// ScreenOrientationType: "portrait-primary") or start with a character no JS +// identifier can (script.SpecialNumber: "-0", "-Infinity") — normalize those +// separators to underscores, and guard a still-invalid leading digit, before +// screamingSnakeCase's uppercasing, so every generated key actually parses. +function enumConstantKey(value) { + const key = screamingSnakeCase(String(value).replace(/[-\s]+/g, '_')) + return /^[0-9]/.test(key) ? `_${key}` : key +} + +// A frozen, inspectable object literal for an enum's values — the JS-idiomatic +// answer to Ruby's `Network::INTERCEPT_PHASE` constant (see +// serialization.rb's Serialization module): a real value a caller can log, +// autocomplete on, or iterate, instead of a bare string they have to already know. +// Raw string values (e.g. 'beforeRequestSent') keep working unchanged — this is +// purely an additional, discoverable way to reach the same values. +function generateEnumConstantLiteral(values) { + const entries = values.map((v) => `${enumConstantKey(v)}: ${JSON.stringify(v)}`).join(', ') + return `Object.freeze({ ${entries} } as const)` +} - return output.join('\n').trimEnd() +// The WebDriver BiDi spec's own anchor format for a command/event section +// (e.g. '#command-network-addIntercept', '#event-network-beforeRequestSent') — +// matches the @see links Ruby's generator emits for the same commands/events. +function specSectionUrl(methodStr, kind) { + const [domain, local] = methodStr.split('.') + return `https://w3c.github.io/webdriver-bidi/#${kind}-${domain}-${local}` } -function generateClass({ className, commands, events, enhancement }) { +function generateClass({ className, commands, events, runtimeByTsName, enumConstants }) { const lines = [] - lines.push(`export class ${className} {`) - lines.push(` private constructor(private readonly bidi: BidiConnection) {}`) + lines.push(INTERNAL_API_DOC) + lines.push(`export class ${className} extends Domain {`) + + for (const evt of events) { + lines.push('') + lines.push(generateEventDescriptor(evt, runtimeByTsName)) + } + + for (const { propertyName, values } of enumConstants) { + lines.push('') + lines.push(` static readonly ${propertyName} = ${generateEnumConstantLiteral(values)}`) + } + lines.push('') lines.push(` static async create(driver: unknown): Promise<${className}> {`) - lines.push( - ` const caps = await (driver as { getCapabilities(): Promise<{ get(key: string): unknown }> }).getCapabilities()`, - ) - lines.push(` if (!caps.get('webSocketUrl')) {`) - lines.push(` throw new Error('WebDriver instance must support BiDi protocol')`) - lines.push(` }`) - lines.push(` const bidi = await (driver as { getBidi(): Promise }).getBidi()`) - lines.push(` return new ${className}(bidi)`) + lines.push(` return new ${className}(await Domain.connect(driver), DOMAIN_TOKEN)`) lines.push(` }`) for (const cmd of commands) { - const override = enhancement.extraMethods?.[cmd.methodName] lines.push('') - lines.push(override ?? generateCommandMethod(cmd)) + lines.push(generateCommandMethod(cmd, runtimeByTsName)) } - for (const evt of events) { - const override = enhancement.extraMethods?.[evt.onMethodName] - lines.push('') - lines.push(override ?? generateEventMethod(evt)) - } + lines.push(`}`) + return lines.join('\n') +} - if (enhancement.extraMethods) { - const knownNames = new Set([...commands.map((c) => c.methodName), ...events.map((e) => e.onMethodName)]) - for (const [name, body] of Object.entries(enhancement.extraMethods)) { - if (!knownNames.has(name)) { - // Purely additive method not tied to a command or event. - lines.push('') - lines.push(body) - } +// A static EventDescriptor constant — no subscribe/dispatch method. Callers +// reach events via the inherited addCallback(descriptor, handler), never a +// per-event generated method. When the params type has a registered runtime +// (record/union), it's passed to event() so addCallback() can validate each +// delivered payload through fromWire() before the caller's handler runs. +function generateEventDescriptor(evt, runtimeByTsName) { + const { eventName, methodStr, paramsTypeName } = evt + const paramsType = paramsTypeName ?? 'unknown' + const constName = screamingSnakeCase(eventName) + const paramsRuntime = paramsTypeName ? runtimeByTsName.get(paramsTypeName) : undefined + const runtimeArg = + paramsRuntime?.kind === 'record' || paramsRuntime?.kind === 'union' ? `, ${paramsRuntime.runtimeName}` : '' + const doc = ` /** @see ${specSectionUrl(methodStr, 'event')} */` + return `${doc}\n static readonly ${constName}: EventDescriptor<${paramsType}> = event('${methodStr}'${runtimeArg})` +} + +// A JSDoc block for a generated command method — readable without any TypeScript +// tooling, since a plain-JS user gets no autocomplete from the sibling .d.ts unless +// their editor happens to resolve it. Unrolls each params field individually (from +// the same field list defineRecord() validates against, not a second copy of it) so +// required vs. optional is visible in an ordinary hover, matching what Ruby's +// generator gives for free via real keyword arguments in the method signature. +function generateCommandJsDoc(cmd, paramsRuntime, resultTypeName) { + const { methodStr, paramsTypeName, hasParams } = cmd + const lines = [' /**'] + if (hasParams) { + lines.push(` * @param {${paramsTypeName}} params`) + for (const field of paramsRuntime?.fields ?? []) { + const tsType = typeNodeToTs(field.type) + const tag = field.required ? `params.${field.name}` : `[params.${field.name}]` + lines.push(` * @param {${tsType}} ${tag}`) } } - - lines.push(`}`) + lines.push(` * @returns {Promise<${resultTypeName ?? 'void'}>}`) + lines.push(` * @see ${specSectionUrl(methodStr, 'command')}`) + lines.push(' */') return lines.join('\n') } -function generateCommandMethod(cmd) { +function generateCommandMethod(cmd, runtimeByTsName) { const { methodName, methodStr, paramsTypeName, hasParams, resultTypeName } = cmd const isVoid = resultTypeName === null const returnType = isVoid ? 'void' : resultTypeName // Use a double-cast (T as unknown as Record) so TypeScript // accepts the conversion even when the params type has no index signature. + // Only actually used when there's no RecordClass/UnionClass to build a validated + // instance from below (paramsRuntime undefined) — otherwise sendArg overrides it. const paramsCast = hasParams ? '(params as unknown as Record)' : '{}' + const paramsRuntime = hasParams ? runtimeByTsName.get(paramsTypeName) : undefined + const resultRuntime = !isVoid ? runtimeByTsName.get(resultTypeName) : undefined - const lines = [] + const lines = [generateCommandJsDoc(cmd, paramsRuntime, isVoid ? null : resultTypeName)] if (hasParams) { lines.push(` async ${methodName}(params: ${paramsTypeName}): Promise<${returnType}> {`) } else { lines.push(` async ${methodName}(): Promise<${returnType}> {`) } - // Both void and non-void commands go through the same error-check pattern. - // bidi/index.js always resolves (never rejects) regardless of response type, - // so we must inspect the payload ourselves and throw on error responses. - lines.push(` const response = await this.bidi.send({`) - lines.push(` method: '${methodStr}',`) - lines.push(` params: ${paramsCast},`) - lines.push(` }) as Record`) - lines.push(` if (response['type'] === 'error') {`) - lines.push(` throw new Error(\`\${response['error']}: \${response['message']}\`)`) - lines.push(` }`) - if (!isVoid) { - lines.push(` return (response as unknown as { result: ${resultTypeName} }).result`) + // Outbound validation: constructing/building the params record throws on a violation + // before anything reaches the wire. Takes the original typed `params` directly + // (matching RecordClass's `new (data: T)`), not the Record cast + // meant only for the wire send() call below. The *validated* instance — not the + // caller's original `params` — is what actually gets sent: its toJSON() is what + // converts JS-facing field names (params.prefersColorScheme) to their declared wire + // keys (prefers-color-scheme), including on any nested record/union field. + let sendArg = paramsCast + if (paramsRuntime?.kind === 'record') { + lines.push(` const validatedParams = new ${paramsRuntime.runtimeName}(params)`) + sendArg = '(validatedParams as unknown as Record)' + } else if (paramsRuntime?.kind === 'union') { + lines.push(` const validatedParams = ${paramsRuntime.runtimeName}.build(params)`) + sendArg = '(validatedParams as unknown as Record)' } - lines.push(` }`) - return lines.join('\n') -} - -function generateEventMethod(evt) { - const { onMethodName, methodStr, paramsTypeName } = evt - const cbType = paramsTypeName ? `(params: ${paramsTypeName}) => void` : `(params: unknown) => void` + // Domain.send() already checks for an error response and throws. Inbound + // validation then runs through the result's own fromWire() when one is + // registered; otherwise the result is cast as before. + if (isVoid) { + lines.push(` await this.send('${methodStr}', ${sendArg})`) + } else if (resultRuntime?.kind === 'record' || resultRuntime?.kind === 'union') { + lines.push(` const result = await this.send('${methodStr}', ${sendArg})`) + lines.push(` return ${resultRuntime.runtimeName}.fromWire(result) as unknown as ${resultTypeName}`) + } else { + lines.push(` return (await this.send('${methodStr}', ${sendArg})) as ${resultTypeName}`) + } - const lines = [] - lines.push(` async ${onMethodName}(callback: ${cbType}): Promise {`) - lines.push(` await this.bidi.subscribe('${methodStr}')`) - // bidi/index.js emits BiDi events by method name through its single shared - // message dispatcher (which already handles JSON parsing and closed-state - // guards). Using bidi.on() here avoids attaching a new ws.on('message', ...) - // listener on every subscription call, preventing listener accumulation and - // MaxListeners warnings. - lines.push(` this.bidi.on('${methodStr}', (params: unknown) => {`) - lines.push(` callback(${paramsTypeName ? `params as ${paramsTypeName}` : 'params'})`) - lines.push(` })`) lines.push(` }`) return lines.join('\n') } diff --git a/javascript/selenium-webdriver/private/BUILD.bazel b/javascript/selenium-webdriver/private/BUILD.bazel index 4b2b89c134e71..89c2e984f42c4 100644 --- a/javascript/selenium-webdriver/private/BUILD.bazel +++ b/javascript/selenium-webdriver/private/BUILD.bazel @@ -1,11 +1,3 @@ -load("@aspect_bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") - -copy_to_bin( - name = "bidi_enhancements_manifest", - srcs = ["bidi_enhancements_manifest.json"], - visibility = ["//javascript/selenium-webdriver:__subpackages__"], -) - -exports_files([ - "bidi_enhancements_manifest.json", -]) +# Marks this directory as a Bazel package so //javascript/selenium-webdriver/private:generate_bidi.bzl +# can be loaded by label — a load() using full package-label syntax requires the package to exist, +# even though generate_bidi.bzl itself declares no targets consumed from here directly. diff --git a/javascript/selenium-webdriver/private/bidi_enhancements_manifest.json b/javascript/selenium-webdriver/private/bidi_enhancements_manifest.json deleted file mode 100644 index 9d5fd561b3bc7..0000000000000 --- a/javascript/selenium-webdriver/private/bidi_enhancements_manifest.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "browser": { - "excludeTypes": [], - "excludeMethods": [], - "extraMethods": {}, - "extraTypes": "/** @deprecated Use {@link Browser.create} instead — will be removed in a future major version. */\nexport async function getBrowserInstance(driver: unknown): Promise {\n return Browser.create(driver)\n}\n" - }, - "browsingContext": { - "excludeTypes": [], - "excludeMethods": [], - "extraMethods": { - "back": " async back(context: BrowsingContextBrowsingContext): Promise {\n await this.traverseHistory({ context, delta: -1 })\n }", - "forward": " async forward(context: BrowsingContextBrowsingContext): Promise {\n await this.traverseHistory({ context, delta: 1 })\n }", - "getTopLevelContexts": " async getTopLevelContexts(): Promise {\n return this.getTree({})\n }", - "printPage": " async printPage(params: BrowsingContextPrintParameters): Promise {\n return this.print(params)\n }" - }, - "extraTypes": "/** @deprecated Use {@link BrowsingContext.create} instead — will be removed in a future major version. */\nexport async function getBrowsingContextInstance(driver: unknown): Promise {\n return BrowsingContext.create(driver)\n}\n" - }, - "emulation": { - "excludeTypes": [], - "excludeMethods": [], - "extraMethods": {}, - "extraTypes": "" - }, - "input": { - "excludeTypes": [], - "excludeMethods": [], - "extraMethods": { - "perform": " /** @deprecated Use {@link performActions} instead — will be removed in a future major version. */\n async perform(browsingContextId: string, actions: InputSourceActions[]): Promise {\n await this.performActions({ context: browsingContextId, actions })\n }", - "release": " /** @deprecated Use {@link releaseActions} instead — will be removed in a future major version. */\n async release(browsingContextId: string): Promise {\n await this.releaseActions({ context: browsingContextId })\n }" - }, - "extraTypes": "/** @deprecated Use {@link Input.create} instead — will be removed in a future major version. */\nexport async function getInputInstance(driver: unknown): Promise {\n return Input.create(driver)\n}\n" - }, - "log": { - "excludeTypes": [], - "excludeMethods": [], - "extraMethods": { - "onConsoleEntry": " async onConsoleEntry(callback: (params: LogEntry) => void): Promise {\n await this.onEntryAdded((entry) => {\n if (entry.type === 'console') callback(entry)\n })\n }", - "onJavascriptLog": " async onJavascriptLog(callback: (params: LogEntry) => void): Promise {\n await this.onEntryAdded((entry) => {\n if (entry.type === 'javascript') callback(entry)\n })\n }", - "onJavascriptException": " async onJavascriptException(callback: (params: LogEntry) => void): Promise {\n await this.onEntryAdded((entry) => {\n if (entry.type === 'javascript' && entry.level === 'error') callback(entry)\n })\n }", - "onLog": " async onLog(callback: (params: LogEntry) => void): Promise {\n await this.onEntryAdded(callback)\n }" - }, - "extraTypes": "/** @deprecated Use {@link Log.create} instead — will be removed in a future major version. */\nexport async function getLogInspectorInstance(driver: unknown): Promise {\n return Log.create(driver)\n}\n" - }, - "network": { - "excludeTypes": [], - "excludeMethods": [], - "extraMethods": { - "cancelAuth": " async cancelAuth(requestId: NetworkRequest): Promise {\n await this.continueWithAuth({ request: requestId, action: 'cancel' } as NetworkContinueWithAuthParameters)\n }", - "continueWithAuthNoCredentials": " async continueWithAuthNoCredentials(requestId: NetworkRequest): Promise {\n await this.continueWithAuth({ request: requestId, action: 'default' } as NetworkContinueWithAuthParameters)\n }", - "continueWithAuth": " async continueWithAuth(params: NetworkContinueWithAuthParameters): Promise\n /** @deprecated Pass a NetworkContinueWithAuthParameters object instead — will be removed in a future major version. */\n async continueWithAuth(requestId: NetworkRequest, username: string, password: string): Promise\n async continueWithAuth(\n paramsOrRequestId: NetworkContinueWithAuthParameters | NetworkRequest,\n username?: string,\n password?: string,\n ): Promise {\n let params: NetworkContinueWithAuthParameters\n if (typeof paramsOrRequestId === 'string') {\n if (username === undefined || password === undefined) {\n throw new Error('username and password are required when calling continueWithAuth with a request id')\n }\n params = {\n request: paramsOrRequestId,\n action: 'provideCredentials',\n credentials: { type: 'password', username, password },\n } as NetworkContinueWithAuthParameters\n } else {\n params = paramsOrRequestId as NetworkContinueWithAuthParameters\n }\n const response = await this.bidi.send({\n method: 'network.continueWithAuth',\n params: params as unknown as Record,\n }) as Record\n if (response['type'] === 'error') {\n throw new Error(`${response['error']}: ${response['message']}`)\n }\n }", - "beforeRequestSent": " /** @deprecated Use {@link onBeforeRequestSent} instead — will be removed in a future major version. */\n async beforeRequestSent(callback: (params: NetworkBeforeRequestSentParameters) => void): Promise {\n await this.onBeforeRequestSent(callback)\n }", - "authRequired": " /** @deprecated Use {@link onAuthRequired} instead — will be removed in a future major version. */\n async authRequired(callback: (params: NetworkAuthRequiredParameters) => void): Promise {\n await this.onAuthRequired(callback)\n }", - "fetchError": " /** @deprecated Use {@link onFetchError} instead — will be removed in a future major version. */\n async fetchError(callback: (params: NetworkFetchErrorParameters) => void): Promise {\n await this.onFetchError(callback)\n }", - "responseCompleted": " /** @deprecated Use {@link onResponseCompleted} instead — will be removed in a future major version. */\n async responseCompleted(callback: (params: NetworkResponseCompletedParameters) => void): Promise {\n await this.onResponseCompleted(callback)\n }", - "responseStarted": " /** @deprecated Use {@link onResponseStarted} instead — will be removed in a future major version. */\n async responseStarted(callback: (params: NetworkResponseStartedParameters) => void): Promise {\n await this.onResponseStarted(callback)\n }" - }, - "extraTypes": "/** @deprecated Use {@link Network.create} instead — will be removed in a future major version. */\nexport async function getNetworkInstance(driver: unknown): Promise {\n return Network.create(driver)\n}\n" - }, - "permissions": { - "excludeTypes": [], - "excludeMethods": [], - "extraMethods": {}, - "extraTypes": "" - }, - "script": { - "excludeTypes": [], - "excludeMethods": [], - "extraMethods": { - "getAllRealms": " async getAllRealms(): Promise {\n return this.getRealms({})\n }", - "getRealmsByType": " async getRealmsByType(type: ScriptRealmType): Promise {\n return this.getRealms({ type })\n }", - "getRealmsInBrowsingContext": " async getRealmsInBrowsingContext(context: BrowsingContextBrowsingContext): Promise {\n return this.getRealms({ context })\n }", - "getRealmsInBrowsingContextByType": " async getRealmsInBrowsingContextByType(context: BrowsingContextBrowsingContext, type: ScriptRealmType): Promise {\n return this.getRealms({ context, type })\n }", - "callFunctionInRealm": " async callFunctionInRealm(realmId: ScriptRealm, functionDeclaration: string, awaitPromise: boolean, args?: ScriptLocalValue[], resultOwnership?: ScriptResultOwnership, serializationOptions?: ScriptSerializationOptions, userActivation?: boolean): Promise {\n return this.callFunction({ functionDeclaration, awaitPromise, target: { realm: realmId }, arguments: args, resultOwnership, serializationOptions, userActivation })\n }", - "callFunctionInBrowsingContext": " async callFunctionInBrowsingContext(browsingContextId: BrowsingContextBrowsingContext, functionDeclaration: string, awaitPromise: boolean, args?: ScriptLocalValue[], sandbox?: string, resultOwnership?: ScriptResultOwnership, serializationOptions?: ScriptSerializationOptions, userActivation?: boolean): Promise {\n return this.callFunction({ functionDeclaration, awaitPromise, target: { context: browsingContextId, sandbox }, arguments: args, resultOwnership, serializationOptions, userActivation })\n }", - "evaluateFunctionInRealm": " async evaluateFunctionInRealm(realmId: ScriptRealm, expression: string, awaitPromise: boolean, resultOwnership?: ScriptResultOwnership, serializationOptions?: ScriptSerializationOptions, userActivation?: boolean): Promise {\n return this.evaluate({ expression, awaitPromise, target: { realm: realmId }, resultOwnership, serializationOptions, userActivation })\n }", - "evaluateFunctionInBrowsingContext": " async evaluateFunctionInBrowsingContext(browsingContextId: BrowsingContextBrowsingContext, expression: string, awaitPromise: boolean, sandbox?: string, resultOwnership?: ScriptResultOwnership, serializationOptions?: ScriptSerializationOptions, userActivation?: boolean): Promise {\n return this.evaluate({ expression, awaitPromise, target: { context: browsingContextId, sandbox }, resultOwnership, serializationOptions, userActivation })\n }", - "disownRealmScript": " async disownRealmScript(realmId: ScriptRealm, handles: ScriptHandle[]): Promise {\n await this.disown({ handles, target: { realm: realmId } })\n }", - "disownBrowsingContextScript": " async disownBrowsingContextScript(browsingContextId: BrowsingContextBrowsingContext, handles: ScriptHandle[], sandbox?: string): Promise {\n await this.disown({ handles, target: { context: browsingContextId, sandbox } })\n }" - }, - "extraTypes": "/** @deprecated Use {@link Script.create} instead — will be removed in a future major version. */\nexport async function getScriptManagerInstance(driver: unknown): Promise