Path: blob/trunk/javascript/selenium-webdriver/generate_bidi.mjs
11810 views
// Licensed to the Software Freedom Conservancy (SFC) under one1// or more contributor license agreements. See the NOTICE file2// distributed with this work for additional information3// regarding copyright ownership. The SFC licenses this file4// to you under the Apache License, Version 2.0 (the5// "License"); you may not use this file except in compliance6// with the License. You may obtain a copy of the License at7//8// http://www.apache.org/licenses/LICENSE-2.09//10// Unless required by applicable law or agreed to in writing,11// software distributed under the License is distributed on an12// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY13// KIND, either express or implied. See the License for the14// specific language governing permissions and limitations15// under the License.1617/**18* Generate the shared WebDriver BiDi artifacts and TypeScript bindings from a19* merged CDDL spec, as a three-stage pipeline — one stage per invocation:20*21* 1. parse --cddl <f> --dump-ast <f> CDDL → AST22* 2. model --ast <f> --dump-model <f> AST → command/event model23* 3. generate --ast <f> --model <f> --output-dir <d> AST + model → one TS module per domain24* [--enhancements <f>] [--spec-version <v>]25*/2627import { parse } from 'cddl'28import { transform } from 'cddl2ts'29import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'30import { join, resolve } from 'node:path'31import { parseArgs } from 'node:util'3233// ============================================================34// Domain configuration35// ============================================================3637// Maps the domain segment in a BiDi method string (e.g. "browsingContext"38// from "browsingContext.activate") to a canonical domain key.39const METHOD_DOMAIN_MAP = {40browser: 'browser',41browsingContext: 'browsingContext',42emulation: 'emulation',43input: 'input',44log: 'log',45network: 'network',46permissions: 'permissions',47script: 'script',48session: 'session',49speculation: 'speculation',50storage: 'storage',51userAgentClientHints: 'userAgentClientHints',52webExtension: 'webExtension',53bluetooth: 'bluetooth',54}5556// Maps TypeScript export name prefixes to domain keys.57// Ordered longest-first so the most specific prefix always wins.58const NAME_PREFIX_TO_DOMAIN = [59['UserAgentClientHints', 'userAgentClientHints'],60['BrowsingContext', 'browsingContext'],61['WebExtension', 'webExtension'],62['Permissions', 'permissions'],63['Bluetooth', 'bluetooth'],64['Emulation', 'emulation'],65['Speculation', 'speculation'],66['Storage', 'storage'],67['Session', 'session'],68['Network', 'network'],69['Script', 'script'],70['Input', 'input'],71['Browser', 'browser'],72['Log', 'log'],73]7475// Output filename for each domain key.76const DOMAIN_FILES = {77browser: 'browser.ts',78browsingContext: 'browsing_context.ts',79emulation: 'emulation.ts',80input: 'input.ts',81log: 'log.ts',82network: 'network.ts',83permissions: 'permissions.ts',84script: 'script.ts',85session: 'session.ts',86speculation: 'speculation.ts',87storage: 'storage.ts',88userAgentClientHints: 'user_agent_client_hints.ts',89webExtension: 'webextension.ts',90bluetooth: 'bluetooth.ts',91common: 'common.ts',92}9394// Implementation class name for each domain key.95// Domains absent from this map only receive type definitions (no class).96const DOMAIN_CLASSES = {97browser: 'Browser',98browsingContext: 'BrowsingContext',99emulation: 'Emulation',100input: 'Input',101log: 'Log',102network: 'Network',103permissions: 'Permissions',104script: 'Script',105session: 'Session',106speculation: 'Speculation',107storage: 'Storage',108userAgentClientHints: 'UserAgentClientHints',109webExtension: 'WebExtension',110bluetooth: 'Bluetooth',111}112113// ============================================================114// Path helpers115// ============================================================116117/**118* Resolve a path that came from a Bazel $(location …) expansion.119*120* When a js_binary runs inside a js_run_binary action Bazel sets BAZEL_BINDIR121* and the js_binary wrapper calls process.chdir(BAZEL_BINDIR) before handing122* control to the script. $(location) values are relative to the *execroot*,123* so they already contain the BAZEL_BINDIR prefix. Stripping that prefix124* makes them relative to the CWD, after which path.resolve() works correctly.125* Outside Bazel (BAZEL_BINDIR unset) paths are resolved normally.126*/127function resolveInputPath(p) {128if (!p) return null129if (!process.env.BAZEL_BINDIR) return resolve(p)130// Normalize both strings to forward slashes before prefix-stripping so that131// mixed separators on Windows (BAZEL_BINDIR uses '\', $(location) uses '/')132// do not cause the startsWith check to silently fail.133const normalizedP = p.replaceAll('\\', '/')134const normalizedBindir = process.env.BAZEL_BINDIR.replaceAll('\\', '/')135const prefix = normalizedBindir + '/'136return resolve(normalizedP.startsWith(prefix) ? normalizedP.slice(prefix.length) : normalizedP)137}138139// ============================================================140// Main141// ============================================================142143async function main() {144const { values: args } = parseArgs({145options: {146cddl: { type: 'string' },147ast: { type: 'string' },148model: { type: 'string' },149'dump-ast': { type: 'string' },150'dump-model': { type: 'string' },151enhancements: { type: 'string' },152'output-dir': { type: 'string' },153'spec-version': { type: 'string', default: '1.0' },154},155})156157// One pipeline stage per invocation; the flags select the stage.158if (args['dump-ast'] && args.cddl) {159writeJson(args['dump-ast'], parseCddl(args.cddl), 'ast')160} else if (args['dump-model'] && args.ast) {161writeJson(args['dump-model'], buildModel(readJson(args.ast, 'AST')), 'model', true)162} else if (args['output-dir'] && args.ast && args.model) {163generateTypeScript(readJson(args.ast, 'AST'), readJson(args.model, 'model'), args)164} else {165console.error(166'Usage (one stage per invocation):\n' +167' generate_bidi.mjs --cddl <file> --dump-ast <file>\n' +168' generate_bidi.mjs --ast <file> --dump-model <file>\n' +169' generate_bidi.mjs --ast <file> --model <file> --output-dir <dir> [--enhancements <file>] [--spec-version <v>]',170)171process.exit(1)172}173}174175function parseCddl(cddlArg) {176const cddlPath = resolveInputPath(cddlArg)177if (!existsSync(cddlPath)) {178console.error(`Error: CDDL file not found: ${cddlPath}`)179process.exit(1)180}181console.log(`Parsing CDDL: ${cddlPath}`)182const ast = parse(cddlPath)183console.log(` ${ast.length} top-level definitions`)184return ast185}186187function readJson(fileArg, label) {188const path = resolveInputPath(fileArg)189if (!existsSync(path)) {190console.error(`Error: ${label} file not found: ${path}`)191process.exit(1)192}193return JSON.parse(readFileSync(path, 'utf8'))194}195196function writeJson(fileArg, data, label, pretty = false) {197const out = resolve(fileArg)198writeFileSync(out, pretty ? JSON.stringify(data, null, 2) + '\n' : JSON.stringify(data), 'utf8')199console.log(` → ${out} (${label})`)200}201202/** Emit one TS module per domain: types from the AST (cddl2ts), methods from the model. */203function generateTypeScript(ast, model, args) {204const outputDir = resolve(args['output-dir'])205const specVersion = args['spec-version']206const enhancements = loadEnhancements(args.enhancements)207208console.log('Pass 1: generating types via cddl2ts…')209const rawTypes = transform(ast)210const cleanTypes = postProcessTypes(rawTypes)211const typesByDomain = splitTypesByDomain(cleanTypes)212const typeNameToDomain = buildTypeNameToDomainMap(typesByDomain)213214console.log('Pass 2: building commands and events from model…')215const allCommands = modelToCommands(model)216const allEvents = modelToEvents(model)217console.log(` ${allCommands.length} commands, ${allEvents.length} events`)218219mkdirSync(outputDir, { recursive: true })220221for (const [domainKey, filename] of Object.entries(DOMAIN_FILES)) {222const types = typesByDomain[domainKey] ?? ''223const commands = allCommands.filter((c) => c.domain === domainKey)224const events = allEvents.filter((e) => e.domain === domainKey)225const enhancement = enhancements[domainKey] ?? {}226const className = DOMAIN_CLASSES[domainKey]227228const content = generateDomainFile({229domain: domainKey,230className,231types,232commands,233events,234enhancement,235specVersion,236typeNameToDomain,237})238239const outPath = join(outputDir, filename)240writeFileSync(outPath, content, 'utf8')241console.log(` → ${outPath}`)242}243244console.log('Done.')245}246247// ============================================================248// Enhancements manifest249// ============================================================250251function loadEnhancements(manifestPath) {252if (!manifestPath) return {}253const fullPath = resolveInputPath(manifestPath)254if (!existsSync(fullPath)) {255console.warn(`Warning: enhancements manifest not found: ${fullPath}`)256return {}257}258let parsed259try {260parsed = JSON.parse(readFileSync(fullPath, 'utf8'))261} catch (err) {262throw new Error(`Failed to parse enhancements manifest at ${fullPath}: ${err.message}`)263}264if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {265throw new Error(266`Enhancements manifest at ${fullPath} must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,267)268}269return parsed270}271272// ============================================================273// Pass 1: type post-processing274// ============================================================275276/**277* Remove duplicate export declarations (cddl2ts emits them when the278* `*-all.cddl` input concatenates local + remote definitions that both279* define the same shared types) and replace `any` with `unknown`.280*/281function postProcessTypes(rawTs) {282const seen = new Set()283const output = []284const lines = rawTs.split('\n')285let i = 0286287while (i < lines.length) {288const line = lines[i]289const match = line.match(/^export (?:type|interface) (\w+)/)290291if (match) {292const name = match[1]293294if (seen.has(name)) {295// Determine end of this declaration before skipping it.296if (line.includes('{') && !line.endsWith('{}') && !line.endsWith('{};')) {297// Multi-line block: skip until braces balance back to zero.298let depth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length299i++300while (i < lines.length && depth > 0) {301depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length302i++303}304} else {305i++ // single-line declaration306}307// Consume the trailing blank line that follows every declaration.308if (i < lines.length && lines[i] === '') i++309continue310}311312seen.add(name)313}314315// Replace any → unknown.316const cleaned = line317.replace(/Record<string, any>/g, 'Record<string, unknown>')318.replace(/: any([;,)\s\[])/g, ': unknown$1')319320output.push(cleaned)321i++322}323324return output.join('\n')325}326327// ============================================================328// Domain splitting329// ============================================================330331function getDomainForExportName(name) {332for (const [prefix, domain] of NAME_PREFIX_TO_DOMAIN) {333if (name.startsWith(prefix)) return domain334}335return 'common'336}337338/**339* Partition the flat cddl2ts TypeScript output into per-domain strings,340* treating each blank-line-separated block as one export declaration.341*/342function splitTypesByDomain(cleanTypes) {343const domainLines = {}344345const lines = cleanTypes.split('\n')346let blockLines = []347348// Flush one accumulated block, splitting it further by individual exports349// so that consecutive single-line declarations (no blank line between them)350// each land in the correct domain rather than all being bucketed under the351// first declaration's domain.352const flushBlock = () => {353if (blockLines.length === 0) return354355let exportLines = []356let exportDomain = null357358const commitExport = () => {359if (exportLines.length === 0) return360const domain = exportDomain ?? 'common'361if (!domainLines[domain]) domainLines[domain] = []362domainLines[domain].push(...exportLines, '')363exportLines = []364exportDomain = null365}366367for (const line of blockLines) {368const m = line.match(/^export (?:type|interface) (\w+)/)369if (m) {370commitExport()371exportDomain = getDomainForExportName(m[1])372}373exportLines.push(line)374}375commitExport()376377blockLines = []378}379380for (const line of lines) {381// Skip cddl2ts source comment headers.382if (line.startsWith('// GENERATED CONTENT') || line.startsWith('// Source:')) {383flushBlock()384continue385}386if (line === '' && blockLines.length > 0) {387flushBlock()388} else if (line !== '') {389blockLines.push(line)390}391}392flushBlock()393394const result = {}395for (const [domain, dl] of Object.entries(domainLines)) {396result[domain] = dl.join('\n').trimEnd()397}398return result399}400401// ============================================================402// Pass 2: AST analysis403// ============================================================404405/**406* Returns the set of group names that carry no named parameters.407* This includes truly empty groups AND groups whose only properties are408* anonymous inclusions (e.g. `EmptyParams = { Extensible }`) — those are409* extensibility markers with no protocol fields of their own.410*/411function buildEmptyParamTypes(ast) {412const empty = new Set()413for (const def of ast) {414if (def.Type !== 'group' || !Array.isArray(def.Properties)) continue415const flat = def.Properties.flatMap((p) => (Array.isArray(p) ? p : [p]))416const hasNamedProp = flat.some((p) => p.Name && p.Name !== '')417if (!hasNamedProp) empty.add(def.Name)418}419return empty420}421422/**423* Convert a dotted CDDL name to PascalCase TypeScript name.424* "browsingContext.Info" → "BrowsingContextInfo"425*/426function normalizeDottedName(name) {427return name428.split('.')429.map((part) => {430const titled = part.charAt(0).toUpperCase() + part.slice(1)431// Normalize acronym runs to match cddl2ts output:432// CSPParameters → CspParameters HTMLCollection → HtmlCollection433// Rule: 2+ uppercase letters followed by an uppercase+lowercase pair (or end434// of string) → keep only the first uppercase and lowercase the rest.435return titled.replace(/([A-Z]{2,})(?=[A-Z][a-z]|$)/g, (m) => m[0] + m.slice(1).toLowerCase())436})437.join('')438}439440/**441* Walk the `CommandData` or `EventData` union type hierarchy and collect all442* leaf definition names (the actual command/event group names).443*444* The CDDL AST represents union groups with Properties that can be:445* - An array of choice objects (each with a Type.Value pointing to the next level)446* - A single property object with Type as an array or direct object447*448* A leaf is a definition that itself has a `method` property (string literal).449*/450function collectUnionMembers(rootName, defMap, visited = new Set()) {451if (visited.has(rootName)) return new Set()452visited.add(rootName)453454const def = defMap.get(rootName)455if (!def) return new Set()456457const members = new Set()458459// Flatten Properties — each element is either a choice-array or a property object.460const rawProps = def.Properties ?? []461const allChoices = []462for (const prop of rawProps) {463if (Array.isArray(prop)) {464allChoices.push(...prop)465} else {466allChoices.push(prop)467}468}469470for (const choice of allChoices) {471// choice.Type can be a single object or an array of type alternatives.472const typeEntries = Array.isArray(choice.Type) ? choice.Type : [choice.Type]473474for (const entry of typeEntries) {475if (entry?.Type !== 'group' || !entry.Value) continue476const childName = entry.Value477const childDef = defMap.get(childName)478if (!childDef) continue479480// A leaf has a `method` property — it is the actual command or event definition.481const childProps = childDef.Properties ?? []482const flat = childProps.flatMap((p) => (Array.isArray(p) ? p : [p]))483if (flat.some((p) => p.Name === 'method')) {484members.add(childName)485} else {486// Intermediate union — recurse.487for (const m of collectUnionMembers(childName, defMap, visited)) {488members.add(m)489}490}491}492}493494return members495}496497/**498* Build a name → definition map from the AST (deduplicated — first wins).499*/500function buildDefMap(ast) {501const map = new Map()502for (const def of ast) {503if (def.Name && !map.has(def.Name)) map.set(def.Name, def)504}505return map506}507508/** Extract {domain, methodStr, operationName, paramsCddl} from a command/event leaf def. */509function parseLeafDef(def) {510const flatProps = (def.Properties ?? []).flatMap((p) => (Array.isArray(p) ? p : [p]))511512const methodProp = flatProps.find((p) => p.Name === 'method')513const paramsProp = flatProps.find((p) => p.Name === 'params')514if (!methodProp || !paramsProp) return null515516const methodLiteral = Array.isArray(methodProp.Type) ? methodProp.Type : [methodProp.Type]517if (methodLiteral[0]?.Type !== 'literal') return null518519const methodStr = methodLiteral[0].Value // e.g. "browser.createUserContext"520const dotIdx = methodStr.indexOf('.')521if (dotIdx === -1) return null522523const domainRaw = methodStr.slice(0, dotIdx)524const operationName = methodStr.slice(dotIdx + 1)525const domain = METHOD_DOMAIN_MAP[domainRaw] ?? 'common'526527const paramsTypeEntries = Array.isArray(paramsProp.Type) ? paramsProp.Type : [paramsProp.Type]528let paramsCddl = null529if (paramsTypeEntries[0]?.Type === 'group' && paramsTypeEntries[0]?.Value) {530paramsCddl = paramsTypeEntries[0].Value531}532533return { domain, methodStr, operationName, paramsCddl }534}535536/**537* Collect all leaf command/event names from every XxxCommand / XxxEvent538* union that can be reached from either the core BiDi root (`CommandData` /539* `EventData`) or from extension-spec roots (e.g. `PermissionsCommand`,540* `SpeculationEvent`). Extension specs are not wired into `CommandData` /541* `EventData` inside the core BiDi CDDL, so a second pass is required.542*/543function collectAllMembers(defMap, rootSuffix) {544const members = new Set()545546// Primary traversal from the core BiDi root.547const rootName = rootSuffix === 'Command' ? 'CommandData' : 'EventData'548for (const m of collectUnionMembers(rootName, defMap)) members.add(m)549550// Secondary traversal: pick up any XxxCommand / XxxEvent unions in551// extension specs whose members were not already found above.552for (const [name, def] of defMap) {553if (!name.endsWith(rootSuffix) || name === rootName) continue554if (def.Type !== 'variable' && def.Type !== 'group') continue555for (const m of collectUnionMembers(name, defMap)) members.add(m)556}557558return members559}560561/** Extract all BiDi commands by traversing CommandData and extension XxxCommand unions. */562function extractCommands(ast) {563const defMap = buildDefMap(ast)564const emptyParamTypes = buildEmptyParamTypes(ast)565const commandNames = collectAllMembers(defMap, 'Command')566const commands = []567568for (const name of commandNames) {569const def = defMap.get(name)570if (!def) continue571572const parsed = parseLeafDef(def)573if (!parsed) continue574575const { domain, methodStr, operationName: methodName, paramsCddl } = parsed576// emptyParamTypes holds raw CDDL group names, so compare the raw name (not the normalized one).577const hasParams = paramsCddl !== null && !emptyParamTypes.has(paramsCddl)578579commands.push({580domain,581cddlName: name,582methodStr,583methodName,584paramsCddl,585hasParams,586})587}588589return commands590}591592/** Extract all BiDi events by traversing EventData and extension XxxEvent unions. */593function extractEvents(ast) {594const defMap = buildDefMap(ast)595const eventNames = collectAllMembers(defMap, 'Event')596const events = []597598for (const name of eventNames) {599const def = defMap.get(name)600if (!def) continue601602const parsed = parseLeafDef(def)603if (!parsed) continue604605const { domain, methodStr, operationName: eventName, paramsCddl } = parsed606607events.push({608domain,609methodStr,610eventName,611paramsCddl,612})613}614615return events616}617618// ============================================================619// Binding-neutral model620// ============================================================621622/**623* Build the binding-neutral model from the AST. Type refs are CDDL names.624* Shape per domain key:625* { commands: [{ method, name, params, result }],626* events: [{ method, name, params }] }627* `params`/`result` are null when there are no params / no return value.628*/629function buildModel(ast) {630const model = {}631const resultTypes = buildResultTypeNames(ast)632const ensure = (domain) => (model[domain] ??= { commands: [], events: [] })633634for (const c of extractCommands(ast)) {635const result = c.cddlName + 'Result'636ensure(c.domain).commands.push({637method: c.methodStr,638name: c.methodName,639params: c.hasParams ? c.paramsCddl : null,640result: resultTypes.has(result) ? result : null,641})642}643644for (const e of extractEvents(ast)) {645ensure(e.domain).events.push({646method: e.methodStr,647name: e.eventName,648params: e.paramsCddl || null,649})650}651652return model653}654655/** Result type names the spec defines with a value; an absent or `EmptyResult`-aliased result is void. */656function buildResultTypeNames(ast) {657const emptyAlias = new Set()658for (const d of ast) {659const pt = d.PropertyType660if (d.Name && d.Type === 'variable' && Array.isArray(pt) && pt.length === 1 && pt[0]?.Value === 'EmptyResult') {661emptyAlias.add(d.Name)662}663}664const names = new Set()665for (const d of ast) {666if (d.Name && d.Name.endsWith('Result') && !emptyAlias.has(d.Name)) names.add(d.Name)667}668return names669}670671/** Map model commands to the generator's command-entry shape. */672function modelToCommands(model) {673const commands = []674for (const [domain, entry] of Object.entries(model)) {675for (const c of entry.commands) {676commands.push({677domain,678methodStr: c.method,679methodName: c.name,680paramsTypeName: c.params !== null ? normalizeDottedName(c.params) : null,681hasParams: c.params !== null,682resultTypeName: c.result !== null ? normalizeDottedName(c.result) : null,683})684}685}686return commands687}688689/** Map model events to the generator's event-entry shape. */690function modelToEvents(model) {691const events = []692for (const [domain, entry] of Object.entries(model)) {693for (const e of entry.events) {694events.push({695domain,696methodStr: e.method,697eventName: e.name,698paramsTypeName: e.params !== null ? normalizeDottedName(e.params) : null,699onMethodName: 'on' + e.name.charAt(0).toUpperCase() + e.name.slice(1),700})701}702}703return events704}705706// ============================================================707// Code generation708// ============================================================709710const LICENSE_HEADER = `\711// Licensed to the Software Freedom Conservancy (SFC) under one712// or more contributor license agreements. See the NOTICE file713// distributed with this work for additional information714// regarding copyright ownership. The SFC licenses this file715// to you under the Apache License, Version 2.0 (the716// "License"); you may not use this file except in compliance717// with the License. You may obtain a copy of the License at718//719// http://www.apache.org/licenses/LICENSE-2.0720//721// Unless required by applicable law or agreed to in writing,722// software distributed under the License is distributed on an723// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY724// KIND, either express or implied. See the License for the725// specific language governing permissions and limitations726// under the License.`727728// ============================================================729// Type-map helpers for cross-domain import generation730// ============================================================731732/**733* Returns a Map from exported type name → domain key.734* Used to generate cross-domain import statements.735*/736function buildTypeNameToDomainMap(typesByDomain) {737const map = new Map()738for (const [domain, typeBlock] of Object.entries(typesByDomain)) {739for (const line of typeBlock.split('\n')) {740const m = line.match(/^export (?:type|interface) (\w+)/)741if (m) map.set(m[1], domain)742}743}744return map745}746747/**748* Scans a domain's type block for references to types that live in OTHER749* domains and returns the import statements needed to make the file compile.750*751* Only PascalCase identifiers that exist in typeNameToDomain and belong to a752* different domain are considered. Built-in TypeScript types (string, number,753* boolean, …) never appear in the map, so they are naturally excluded.754*/755function computeCrossDomainImports(typeBlock, domain, typeNameToDomain) {756if (!typeBlock) return []757758// Collect all PascalCase identifiers referenced in the type block.759const referenced = new Set()760for (const match of typeBlock.matchAll(/\b([A-Z][A-Za-z0-9]*)\b/g)) {761referenced.add(match[1])762}763764// Group by source domain (skip same-domain types and unknown types).765const bySourceDomain = new Map()766for (const name of referenced) {767const sourceDomain = typeNameToDomain.get(name)768if (!sourceDomain || sourceDomain === domain) continue769if (!bySourceDomain.has(sourceDomain)) bySourceDomain.set(sourceDomain, new Set())770bySourceDomain.get(sourceDomain).add(name)771}772773// Emit sorted import lines.774const imports = []775for (const [sourceDomain, names] of [...bySourceDomain.entries()].sort()) {776const sourceFile = DOMAIN_FILES[sourceDomain].replace('.ts', '.js')777const sorted = [...names].sort()778imports.push(`import type { ${sorted.join(', ')} } from './${sourceFile}'`)779}780return imports781}782783function generateDomainFile({784domain,785className,786types,787commands,788events,789enhancement,790specVersion,791typeNameToDomain,792}) {793const parts = [LICENSE_HEADER, '']794795parts.push(`// Auto-generated from WebDriver BiDi CDDL spec (v${specVersion}) — DO NOT EDIT MANUALLY`)796parts.push(`// Source: https://github.com/w3c/webref/tree/main/ed/cddl`)797parts.push('')798799const filteredCommands = commands.filter((c) => !enhancement.excludeMethods?.includes(c.methodName))800const filteredEvents = events.filter((e) => !enhancement.excludeMethods?.includes(e.eventName))801const hasImplementation = className != null && (filteredCommands.length > 0 || filteredEvents.length > 0)802803// Filter out excluded types before emitting.804let typeBlock = types805if (enhancement.excludeTypes?.length) {806typeBlock = filterExcludedTypes(typeBlock, enhancement.excludeTypes)807}808809// Compute cross-domain imports needed by this domain's type block.810// Types from other domains are referenced by name but live in separate files.811const crossDomainImports = computeCrossDomainImports(typeBlock, domain, typeNameToDomain)812813if (crossDomainImports.length > 0) {814for (const line of crossDomainImports) {815parts.push(line)816}817parts.push('')818}819820if (hasImplementation) {821// Define the BiDi connection interface inline so the generated file is822// self-contained for tsc and doesn't need to resolve ../index.js.823parts.push(`/** Minimal BiDi transport interface (satisfied structurally by bidi/index.js). */`)824parts.push(`interface BidiConnection {`)825parts.push(` send(command: Record<string, unknown>): Promise<unknown>`)826parts.push(` subscribe(event: string | string[], contexts?: string[]): Promise<void>`)827parts.push(` on(event: string, listener: (params: unknown) => void): void`)828parts.push(`}`)829parts.push('')830}831832if (typeBlock) {833parts.push(`// --- Types ---`)834parts.push('')835parts.push(typeBlock)836parts.push('')837}838839if (enhancement.extraTypes) {840parts.push(`// --- Additional Types ---`)841parts.push('')842parts.push(enhancement.extraTypes)843parts.push('')844}845846if (hasImplementation) {847parts.push(`// --- Implementation ---`)848parts.push('')849parts.push(850generateClass({851className,852commands: filteredCommands,853events: filteredEvents,854enhancement,855}),856)857}858859return parts.join('\n') + '\n'860}861862function filterExcludedTypes(typeBlock, excludeTypes) {863const lines = typeBlock.split('\n')864const output = []865let i = 0866867while (i < lines.length) {868const line = lines[i]869const match = line.match(/^export (?:type|interface) (\w+)/)870if (match && excludeTypes.includes(match[1])) {871if (line.includes('{') && !line.endsWith('{}') && !line.endsWith('{};')) {872let depth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length873i++874while (i < lines.length && depth > 0) {875depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length876i++877}878} else {879i++880}881if (i < lines.length && lines[i] === '') i++882continue883}884output.push(line)885i++886}887888return output.join('\n').trimEnd()889}890891function generateClass({ className, commands, events, enhancement }) {892const lines = []893894lines.push(`export class ${className} {`)895lines.push(` private constructor(private readonly bidi: BidiConnection) {}`)896lines.push('')897lines.push(` static async create(driver: unknown): Promise<${className}> {`)898lines.push(899` const caps = await (driver as { getCapabilities(): Promise<{ get(key: string): unknown }> }).getCapabilities()`,900)901lines.push(` if (!caps.get('webSocketUrl')) {`)902lines.push(` throw new Error('WebDriver instance must support BiDi protocol')`)903lines.push(` }`)904lines.push(` const bidi = await (driver as { getBidi(): Promise<BidiConnection> }).getBidi()`)905lines.push(` return new ${className}(bidi)`)906lines.push(` }`)907908for (const cmd of commands) {909const override = enhancement.extraMethods?.[cmd.methodName]910lines.push('')911lines.push(override ?? generateCommandMethod(cmd))912}913914for (const evt of events) {915const override = enhancement.extraMethods?.[evt.onMethodName]916lines.push('')917lines.push(override ?? generateEventMethod(evt))918}919920if (enhancement.extraMethods) {921const knownNames = new Set([...commands.map((c) => c.methodName), ...events.map((e) => e.onMethodName)])922for (const [name, body] of Object.entries(enhancement.extraMethods)) {923if (!knownNames.has(name)) {924// Purely additive method not tied to a command or event.925lines.push('')926lines.push(body)927}928}929}930931lines.push(`}`)932return lines.join('\n')933}934935function generateCommandMethod(cmd) {936const { methodName, methodStr, paramsTypeName, hasParams, resultTypeName } = cmd937const isVoid = resultTypeName === null938const returnType = isVoid ? 'void' : resultTypeName939940// Use a double-cast (T as unknown as Record<string,unknown>) so TypeScript941// accepts the conversion even when the params type has no index signature.942const paramsCast = hasParams ? '(params as unknown as Record<string, unknown>)' : '{}'943944const lines = []945if (hasParams) {946lines.push(` async ${methodName}(params: ${paramsTypeName}): Promise<${returnType}> {`)947} else {948lines.push(` async ${methodName}(): Promise<${returnType}> {`)949}950951// Both void and non-void commands go through the same error-check pattern.952// bidi/index.js always resolves (never rejects) regardless of response type,953// so we must inspect the payload ourselves and throw on error responses.954lines.push(` const response = await this.bidi.send({`)955lines.push(` method: '${methodStr}',`)956lines.push(` params: ${paramsCast},`)957lines.push(` }) as Record<string, unknown>`)958lines.push(` if (response['type'] === 'error') {`)959lines.push(` throw new Error(\`\${response['error']}: \${response['message']}\`)`)960lines.push(` }`)961if (!isVoid) {962lines.push(` return (response as unknown as { result: ${resultTypeName} }).result`)963}964965lines.push(` }`)966return lines.join('\n')967}968969function generateEventMethod(evt) {970const { onMethodName, methodStr, paramsTypeName } = evt971const cbType = paramsTypeName ? `(params: ${paramsTypeName}) => void` : `(params: unknown) => void`972973const lines = []974lines.push(` async ${onMethodName}(callback: ${cbType}): Promise<void> {`)975lines.push(` await this.bidi.subscribe('${methodStr}')`)976// bidi/index.js emits BiDi events by method name through its single shared977// message dispatcher (which already handles JSON parsing and closed-state978// guards). Using bidi.on() here avoids attaching a new ws.on('message', ...)979// listener on every subscription call, preventing listener accumulation and980// MaxListeners warnings.981lines.push(` this.bidi.on('${methodStr}', (params: unknown) => {`)982lines.push(` callback(${paramsTypeName ? `params as ${paramsTypeName}` : 'params'})`)983lines.push(` })`)984lines.push(` }`)985return lines.join('\n')986}987988// ============================================================989// Entry point990// ============================================================991992main().catch((err) => {993console.error(err)994process.exit(1)995})996997998