Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
seleniumhq
GitHub Repository: seleniumhq/selenium
Path: blob/trunk/javascript/selenium-webdriver/generate_bidi.mjs
11810 views
1
// Licensed to the Software Freedom Conservancy (SFC) under one
2
// or more contributor license agreements. See the NOTICE file
3
// distributed with this work for additional information
4
// regarding copyright ownership. The SFC licenses this file
5
// to you under the Apache License, Version 2.0 (the
6
// "License"); you may not use this file except in compliance
7
// with the License. You may obtain a copy of the License at
8
//
9
// http://www.apache.org/licenses/LICENSE-2.0
10
//
11
// Unless required by applicable law or agreed to in writing,
12
// software distributed under the License is distributed on an
13
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
// KIND, either express or implied. See the License for the
15
// specific language governing permissions and limitations
16
// under the License.
17
18
/**
19
* Generate the shared WebDriver BiDi artifacts and TypeScript bindings from a
20
* merged CDDL spec, as a three-stage pipeline — one stage per invocation:
21
*
22
* 1. parse --cddl <f> --dump-ast <f> CDDL → AST
23
* 2. model --ast <f> --dump-model <f> AST → command/event model
24
* 3. generate --ast <f> --model <f> --output-dir <d> AST + model → one TS module per domain
25
* [--enhancements <f>] [--spec-version <v>]
26
*/
27
28
import { parse } from 'cddl'
29
import { transform } from 'cddl2ts'
30
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
31
import { join, resolve } from 'node:path'
32
import { parseArgs } from 'node:util'
33
34
// ============================================================
35
// Domain configuration
36
// ============================================================
37
38
// Maps the domain segment in a BiDi method string (e.g. "browsingContext"
39
// from "browsingContext.activate") to a canonical domain key.
40
const METHOD_DOMAIN_MAP = {
41
browser: 'browser',
42
browsingContext: 'browsingContext',
43
emulation: 'emulation',
44
input: 'input',
45
log: 'log',
46
network: 'network',
47
permissions: 'permissions',
48
script: 'script',
49
session: 'session',
50
speculation: 'speculation',
51
storage: 'storage',
52
userAgentClientHints: 'userAgentClientHints',
53
webExtension: 'webExtension',
54
bluetooth: 'bluetooth',
55
}
56
57
// Maps TypeScript export name prefixes to domain keys.
58
// Ordered longest-first so the most specific prefix always wins.
59
const NAME_PREFIX_TO_DOMAIN = [
60
['UserAgentClientHints', 'userAgentClientHints'],
61
['BrowsingContext', 'browsingContext'],
62
['WebExtension', 'webExtension'],
63
['Permissions', 'permissions'],
64
['Bluetooth', 'bluetooth'],
65
['Emulation', 'emulation'],
66
['Speculation', 'speculation'],
67
['Storage', 'storage'],
68
['Session', 'session'],
69
['Network', 'network'],
70
['Script', 'script'],
71
['Input', 'input'],
72
['Browser', 'browser'],
73
['Log', 'log'],
74
]
75
76
// Output filename for each domain key.
77
const DOMAIN_FILES = {
78
browser: 'browser.ts',
79
browsingContext: 'browsing_context.ts',
80
emulation: 'emulation.ts',
81
input: 'input.ts',
82
log: 'log.ts',
83
network: 'network.ts',
84
permissions: 'permissions.ts',
85
script: 'script.ts',
86
session: 'session.ts',
87
speculation: 'speculation.ts',
88
storage: 'storage.ts',
89
userAgentClientHints: 'user_agent_client_hints.ts',
90
webExtension: 'webextension.ts',
91
bluetooth: 'bluetooth.ts',
92
common: 'common.ts',
93
}
94
95
// Implementation class name for each domain key.
96
// Domains absent from this map only receive type definitions (no class).
97
const DOMAIN_CLASSES = {
98
browser: 'Browser',
99
browsingContext: 'BrowsingContext',
100
emulation: 'Emulation',
101
input: 'Input',
102
log: 'Log',
103
network: 'Network',
104
permissions: 'Permissions',
105
script: 'Script',
106
session: 'Session',
107
speculation: 'Speculation',
108
storage: 'Storage',
109
userAgentClientHints: 'UserAgentClientHints',
110
webExtension: 'WebExtension',
111
bluetooth: 'Bluetooth',
112
}
113
114
// ============================================================
115
// Path helpers
116
// ============================================================
117
118
/**
119
* Resolve a path that came from a Bazel $(location …) expansion.
120
*
121
* When a js_binary runs inside a js_run_binary action Bazel sets BAZEL_BINDIR
122
* and the js_binary wrapper calls process.chdir(BAZEL_BINDIR) before handing
123
* control to the script. $(location) values are relative to the *execroot*,
124
* so they already contain the BAZEL_BINDIR prefix. Stripping that prefix
125
* makes them relative to the CWD, after which path.resolve() works correctly.
126
* Outside Bazel (BAZEL_BINDIR unset) paths are resolved normally.
127
*/
128
function resolveInputPath(p) {
129
if (!p) return null
130
if (!process.env.BAZEL_BINDIR) return resolve(p)
131
// Normalize both strings to forward slashes before prefix-stripping so that
132
// mixed separators on Windows (BAZEL_BINDIR uses '\', $(location) uses '/')
133
// do not cause the startsWith check to silently fail.
134
const normalizedP = p.replaceAll('\\', '/')
135
const normalizedBindir = process.env.BAZEL_BINDIR.replaceAll('\\', '/')
136
const prefix = normalizedBindir + '/'
137
return resolve(normalizedP.startsWith(prefix) ? normalizedP.slice(prefix.length) : normalizedP)
138
}
139
140
// ============================================================
141
// Main
142
// ============================================================
143
144
async function main() {
145
const { values: args } = parseArgs({
146
options: {
147
cddl: { type: 'string' },
148
ast: { type: 'string' },
149
model: { type: 'string' },
150
'dump-ast': { type: 'string' },
151
'dump-model': { type: 'string' },
152
enhancements: { type: 'string' },
153
'output-dir': { type: 'string' },
154
'spec-version': { type: 'string', default: '1.0' },
155
},
156
})
157
158
// One pipeline stage per invocation; the flags select the stage.
159
if (args['dump-ast'] && args.cddl) {
160
writeJson(args['dump-ast'], parseCddl(args.cddl), 'ast')
161
} else if (args['dump-model'] && args.ast) {
162
writeJson(args['dump-model'], buildModel(readJson(args.ast, 'AST')), 'model', true)
163
} else if (args['output-dir'] && args.ast && args.model) {
164
generateTypeScript(readJson(args.ast, 'AST'), readJson(args.model, 'model'), args)
165
} else {
166
console.error(
167
'Usage (one stage per invocation):\n' +
168
' generate_bidi.mjs --cddl <file> --dump-ast <file>\n' +
169
' generate_bidi.mjs --ast <file> --dump-model <file>\n' +
170
' generate_bidi.mjs --ast <file> --model <file> --output-dir <dir> [--enhancements <file>] [--spec-version <v>]',
171
)
172
process.exit(1)
173
}
174
}
175
176
function parseCddl(cddlArg) {
177
const cddlPath = resolveInputPath(cddlArg)
178
if (!existsSync(cddlPath)) {
179
console.error(`Error: CDDL file not found: ${cddlPath}`)
180
process.exit(1)
181
}
182
console.log(`Parsing CDDL: ${cddlPath}`)
183
const ast = parse(cddlPath)
184
console.log(` ${ast.length} top-level definitions`)
185
return ast
186
}
187
188
function readJson(fileArg, label) {
189
const path = resolveInputPath(fileArg)
190
if (!existsSync(path)) {
191
console.error(`Error: ${label} file not found: ${path}`)
192
process.exit(1)
193
}
194
return JSON.parse(readFileSync(path, 'utf8'))
195
}
196
197
function writeJson(fileArg, data, label, pretty = false) {
198
const out = resolve(fileArg)
199
writeFileSync(out, pretty ? JSON.stringify(data, null, 2) + '\n' : JSON.stringify(data), 'utf8')
200
console.log(` → ${out} (${label})`)
201
}
202
203
/** Emit one TS module per domain: types from the AST (cddl2ts), methods from the model. */
204
function generateTypeScript(ast, model, args) {
205
const outputDir = resolve(args['output-dir'])
206
const specVersion = args['spec-version']
207
const enhancements = loadEnhancements(args.enhancements)
208
209
console.log('Pass 1: generating types via cddl2ts…')
210
const rawTypes = transform(ast)
211
const cleanTypes = postProcessTypes(rawTypes)
212
const typesByDomain = splitTypesByDomain(cleanTypes)
213
const typeNameToDomain = buildTypeNameToDomainMap(typesByDomain)
214
215
console.log('Pass 2: building commands and events from model…')
216
const allCommands = modelToCommands(model)
217
const allEvents = modelToEvents(model)
218
console.log(` ${allCommands.length} commands, ${allEvents.length} events`)
219
220
mkdirSync(outputDir, { recursive: true })
221
222
for (const [domainKey, filename] of Object.entries(DOMAIN_FILES)) {
223
const types = typesByDomain[domainKey] ?? ''
224
const commands = allCommands.filter((c) => c.domain === domainKey)
225
const events = allEvents.filter((e) => e.domain === domainKey)
226
const enhancement = enhancements[domainKey] ?? {}
227
const className = DOMAIN_CLASSES[domainKey]
228
229
const content = generateDomainFile({
230
domain: domainKey,
231
className,
232
types,
233
commands,
234
events,
235
enhancement,
236
specVersion,
237
typeNameToDomain,
238
})
239
240
const outPath = join(outputDir, filename)
241
writeFileSync(outPath, content, 'utf8')
242
console.log(` → ${outPath}`)
243
}
244
245
console.log('Done.')
246
}
247
248
// ============================================================
249
// Enhancements manifest
250
// ============================================================
251
252
function loadEnhancements(manifestPath) {
253
if (!manifestPath) return {}
254
const fullPath = resolveInputPath(manifestPath)
255
if (!existsSync(fullPath)) {
256
console.warn(`Warning: enhancements manifest not found: ${fullPath}`)
257
return {}
258
}
259
let parsed
260
try {
261
parsed = JSON.parse(readFileSync(fullPath, 'utf8'))
262
} catch (err) {
263
throw new Error(`Failed to parse enhancements manifest at ${fullPath}: ${err.message}`)
264
}
265
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
266
throw new Error(
267
`Enhancements manifest at ${fullPath} must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
268
)
269
}
270
return parsed
271
}
272
273
// ============================================================
274
// Pass 1: type post-processing
275
// ============================================================
276
277
/**
278
* Remove duplicate export declarations (cddl2ts emits them when the
279
* `*-all.cddl` input concatenates local + remote definitions that both
280
* define the same shared types) and replace `any` with `unknown`.
281
*/
282
function postProcessTypes(rawTs) {
283
const seen = new Set()
284
const output = []
285
const lines = rawTs.split('\n')
286
let i = 0
287
288
while (i < lines.length) {
289
const line = lines[i]
290
const match = line.match(/^export (?:type|interface) (\w+)/)
291
292
if (match) {
293
const name = match[1]
294
295
if (seen.has(name)) {
296
// Determine end of this declaration before skipping it.
297
if (line.includes('{') && !line.endsWith('{}') && !line.endsWith('{};')) {
298
// Multi-line block: skip until braces balance back to zero.
299
let depth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length
300
i++
301
while (i < lines.length && depth > 0) {
302
depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length
303
i++
304
}
305
} else {
306
i++ // single-line declaration
307
}
308
// Consume the trailing blank line that follows every declaration.
309
if (i < lines.length && lines[i] === '') i++
310
continue
311
}
312
313
seen.add(name)
314
}
315
316
// Replace any → unknown.
317
const cleaned = line
318
.replace(/Record<string, any>/g, 'Record<string, unknown>')
319
.replace(/: any([;,)\s\[])/g, ': unknown$1')
320
321
output.push(cleaned)
322
i++
323
}
324
325
return output.join('\n')
326
}
327
328
// ============================================================
329
// Domain splitting
330
// ============================================================
331
332
function getDomainForExportName(name) {
333
for (const [prefix, domain] of NAME_PREFIX_TO_DOMAIN) {
334
if (name.startsWith(prefix)) return domain
335
}
336
return 'common'
337
}
338
339
/**
340
* Partition the flat cddl2ts TypeScript output into per-domain strings,
341
* treating each blank-line-separated block as one export declaration.
342
*/
343
function splitTypesByDomain(cleanTypes) {
344
const domainLines = {}
345
346
const lines = cleanTypes.split('\n')
347
let blockLines = []
348
349
// Flush one accumulated block, splitting it further by individual exports
350
// so that consecutive single-line declarations (no blank line between them)
351
// each land in the correct domain rather than all being bucketed under the
352
// first declaration's domain.
353
const flushBlock = () => {
354
if (blockLines.length === 0) return
355
356
let exportLines = []
357
let exportDomain = null
358
359
const commitExport = () => {
360
if (exportLines.length === 0) return
361
const domain = exportDomain ?? 'common'
362
if (!domainLines[domain]) domainLines[domain] = []
363
domainLines[domain].push(...exportLines, '')
364
exportLines = []
365
exportDomain = null
366
}
367
368
for (const line of blockLines) {
369
const m = line.match(/^export (?:type|interface) (\w+)/)
370
if (m) {
371
commitExport()
372
exportDomain = getDomainForExportName(m[1])
373
}
374
exportLines.push(line)
375
}
376
commitExport()
377
378
blockLines = []
379
}
380
381
for (const line of lines) {
382
// Skip cddl2ts source comment headers.
383
if (line.startsWith('// GENERATED CONTENT') || line.startsWith('// Source:')) {
384
flushBlock()
385
continue
386
}
387
if (line === '' && blockLines.length > 0) {
388
flushBlock()
389
} else if (line !== '') {
390
blockLines.push(line)
391
}
392
}
393
flushBlock()
394
395
const result = {}
396
for (const [domain, dl] of Object.entries(domainLines)) {
397
result[domain] = dl.join('\n').trimEnd()
398
}
399
return result
400
}
401
402
// ============================================================
403
// Pass 2: AST analysis
404
// ============================================================
405
406
/**
407
* Returns the set of group names that carry no named parameters.
408
* This includes truly empty groups AND groups whose only properties are
409
* anonymous inclusions (e.g. `EmptyParams = { Extensible }`) — those are
410
* extensibility markers with no protocol fields of their own.
411
*/
412
function buildEmptyParamTypes(ast) {
413
const empty = new Set()
414
for (const def of ast) {
415
if (def.Type !== 'group' || !Array.isArray(def.Properties)) continue
416
const flat = def.Properties.flatMap((p) => (Array.isArray(p) ? p : [p]))
417
const hasNamedProp = flat.some((p) => p.Name && p.Name !== '')
418
if (!hasNamedProp) empty.add(def.Name)
419
}
420
return empty
421
}
422
423
/**
424
* Convert a dotted CDDL name to PascalCase TypeScript name.
425
* "browsingContext.Info" → "BrowsingContextInfo"
426
*/
427
function normalizeDottedName(name) {
428
return name
429
.split('.')
430
.map((part) => {
431
const titled = part.charAt(0).toUpperCase() + part.slice(1)
432
// Normalize acronym runs to match cddl2ts output:
433
// CSPParameters → CspParameters HTMLCollection → HtmlCollection
434
// Rule: 2+ uppercase letters followed by an uppercase+lowercase pair (or end
435
// of string) → keep only the first uppercase and lowercase the rest.
436
return titled.replace(/([A-Z]{2,})(?=[A-Z][a-z]|$)/g, (m) => m[0] + m.slice(1).toLowerCase())
437
})
438
.join('')
439
}
440
441
/**
442
* Walk the `CommandData` or `EventData` union type hierarchy and collect all
443
* leaf definition names (the actual command/event group names).
444
*
445
* The CDDL AST represents union groups with Properties that can be:
446
* - An array of choice objects (each with a Type.Value pointing to the next level)
447
* - A single property object with Type as an array or direct object
448
*
449
* A leaf is a definition that itself has a `method` property (string literal).
450
*/
451
function collectUnionMembers(rootName, defMap, visited = new Set()) {
452
if (visited.has(rootName)) return new Set()
453
visited.add(rootName)
454
455
const def = defMap.get(rootName)
456
if (!def) return new Set()
457
458
const members = new Set()
459
460
// Flatten Properties — each element is either a choice-array or a property object.
461
const rawProps = def.Properties ?? []
462
const allChoices = []
463
for (const prop of rawProps) {
464
if (Array.isArray(prop)) {
465
allChoices.push(...prop)
466
} else {
467
allChoices.push(prop)
468
}
469
}
470
471
for (const choice of allChoices) {
472
// choice.Type can be a single object or an array of type alternatives.
473
const typeEntries = Array.isArray(choice.Type) ? choice.Type : [choice.Type]
474
475
for (const entry of typeEntries) {
476
if (entry?.Type !== 'group' || !entry.Value) continue
477
const childName = entry.Value
478
const childDef = defMap.get(childName)
479
if (!childDef) continue
480
481
// A leaf has a `method` property — it is the actual command or event definition.
482
const childProps = childDef.Properties ?? []
483
const flat = childProps.flatMap((p) => (Array.isArray(p) ? p : [p]))
484
if (flat.some((p) => p.Name === 'method')) {
485
members.add(childName)
486
} else {
487
// Intermediate union — recurse.
488
for (const m of collectUnionMembers(childName, defMap, visited)) {
489
members.add(m)
490
}
491
}
492
}
493
}
494
495
return members
496
}
497
498
/**
499
* Build a name → definition map from the AST (deduplicated — first wins).
500
*/
501
function buildDefMap(ast) {
502
const map = new Map()
503
for (const def of ast) {
504
if (def.Name && !map.has(def.Name)) map.set(def.Name, def)
505
}
506
return map
507
}
508
509
/** Extract {domain, methodStr, operationName, paramsCddl} from a command/event leaf def. */
510
function parseLeafDef(def) {
511
const flatProps = (def.Properties ?? []).flatMap((p) => (Array.isArray(p) ? p : [p]))
512
513
const methodProp = flatProps.find((p) => p.Name === 'method')
514
const paramsProp = flatProps.find((p) => p.Name === 'params')
515
if (!methodProp || !paramsProp) return null
516
517
const methodLiteral = Array.isArray(methodProp.Type) ? methodProp.Type : [methodProp.Type]
518
if (methodLiteral[0]?.Type !== 'literal') return null
519
520
const methodStr = methodLiteral[0].Value // e.g. "browser.createUserContext"
521
const dotIdx = methodStr.indexOf('.')
522
if (dotIdx === -1) return null
523
524
const domainRaw = methodStr.slice(0, dotIdx)
525
const operationName = methodStr.slice(dotIdx + 1)
526
const domain = METHOD_DOMAIN_MAP[domainRaw] ?? 'common'
527
528
const paramsTypeEntries = Array.isArray(paramsProp.Type) ? paramsProp.Type : [paramsProp.Type]
529
let paramsCddl = null
530
if (paramsTypeEntries[0]?.Type === 'group' && paramsTypeEntries[0]?.Value) {
531
paramsCddl = paramsTypeEntries[0].Value
532
}
533
534
return { domain, methodStr, operationName, paramsCddl }
535
}
536
537
/**
538
* Collect all leaf command/event names from every XxxCommand / XxxEvent
539
* union that can be reached from either the core BiDi root (`CommandData` /
540
* `EventData`) or from extension-spec roots (e.g. `PermissionsCommand`,
541
* `SpeculationEvent`). Extension specs are not wired into `CommandData` /
542
* `EventData` inside the core BiDi CDDL, so a second pass is required.
543
*/
544
function collectAllMembers(defMap, rootSuffix) {
545
const members = new Set()
546
547
// Primary traversal from the core BiDi root.
548
const rootName = rootSuffix === 'Command' ? 'CommandData' : 'EventData'
549
for (const m of collectUnionMembers(rootName, defMap)) members.add(m)
550
551
// Secondary traversal: pick up any XxxCommand / XxxEvent unions in
552
// extension specs whose members were not already found above.
553
for (const [name, def] of defMap) {
554
if (!name.endsWith(rootSuffix) || name === rootName) continue
555
if (def.Type !== 'variable' && def.Type !== 'group') continue
556
for (const m of collectUnionMembers(name, defMap)) members.add(m)
557
}
558
559
return members
560
}
561
562
/** Extract all BiDi commands by traversing CommandData and extension XxxCommand unions. */
563
function extractCommands(ast) {
564
const defMap = buildDefMap(ast)
565
const emptyParamTypes = buildEmptyParamTypes(ast)
566
const commandNames = collectAllMembers(defMap, 'Command')
567
const commands = []
568
569
for (const name of commandNames) {
570
const def = defMap.get(name)
571
if (!def) continue
572
573
const parsed = parseLeafDef(def)
574
if (!parsed) continue
575
576
const { domain, methodStr, operationName: methodName, paramsCddl } = parsed
577
// emptyParamTypes holds raw CDDL group names, so compare the raw name (not the normalized one).
578
const hasParams = paramsCddl !== null && !emptyParamTypes.has(paramsCddl)
579
580
commands.push({
581
domain,
582
cddlName: name,
583
methodStr,
584
methodName,
585
paramsCddl,
586
hasParams,
587
})
588
}
589
590
return commands
591
}
592
593
/** Extract all BiDi events by traversing EventData and extension XxxEvent unions. */
594
function extractEvents(ast) {
595
const defMap = buildDefMap(ast)
596
const eventNames = collectAllMembers(defMap, 'Event')
597
const events = []
598
599
for (const name of eventNames) {
600
const def = defMap.get(name)
601
if (!def) continue
602
603
const parsed = parseLeafDef(def)
604
if (!parsed) continue
605
606
const { domain, methodStr, operationName: eventName, paramsCddl } = parsed
607
608
events.push({
609
domain,
610
methodStr,
611
eventName,
612
paramsCddl,
613
})
614
}
615
616
return events
617
}
618
619
// ============================================================
620
// Binding-neutral model
621
// ============================================================
622
623
/**
624
* Build the binding-neutral model from the AST. Type refs are CDDL names.
625
* Shape per domain key:
626
* { commands: [{ method, name, params, result }],
627
* events: [{ method, name, params }] }
628
* `params`/`result` are null when there are no params / no return value.
629
*/
630
function buildModel(ast) {
631
const model = {}
632
const resultTypes = buildResultTypeNames(ast)
633
const ensure = (domain) => (model[domain] ??= { commands: [], events: [] })
634
635
for (const c of extractCommands(ast)) {
636
const result = c.cddlName + 'Result'
637
ensure(c.domain).commands.push({
638
method: c.methodStr,
639
name: c.methodName,
640
params: c.hasParams ? c.paramsCddl : null,
641
result: resultTypes.has(result) ? result : null,
642
})
643
}
644
645
for (const e of extractEvents(ast)) {
646
ensure(e.domain).events.push({
647
method: e.methodStr,
648
name: e.eventName,
649
params: e.paramsCddl || null,
650
})
651
}
652
653
return model
654
}
655
656
/** Result type names the spec defines with a value; an absent or `EmptyResult`-aliased result is void. */
657
function buildResultTypeNames(ast) {
658
const emptyAlias = new Set()
659
for (const d of ast) {
660
const pt = d.PropertyType
661
if (d.Name && d.Type === 'variable' && Array.isArray(pt) && pt.length === 1 && pt[0]?.Value === 'EmptyResult') {
662
emptyAlias.add(d.Name)
663
}
664
}
665
const names = new Set()
666
for (const d of ast) {
667
if (d.Name && d.Name.endsWith('Result') && !emptyAlias.has(d.Name)) names.add(d.Name)
668
}
669
return names
670
}
671
672
/** Map model commands to the generator's command-entry shape. */
673
function modelToCommands(model) {
674
const commands = []
675
for (const [domain, entry] of Object.entries(model)) {
676
for (const c of entry.commands) {
677
commands.push({
678
domain,
679
methodStr: c.method,
680
methodName: c.name,
681
paramsTypeName: c.params !== null ? normalizeDottedName(c.params) : null,
682
hasParams: c.params !== null,
683
resultTypeName: c.result !== null ? normalizeDottedName(c.result) : null,
684
})
685
}
686
}
687
return commands
688
}
689
690
/** Map model events to the generator's event-entry shape. */
691
function modelToEvents(model) {
692
const events = []
693
for (const [domain, entry] of Object.entries(model)) {
694
for (const e of entry.events) {
695
events.push({
696
domain,
697
methodStr: e.method,
698
eventName: e.name,
699
paramsTypeName: e.params !== null ? normalizeDottedName(e.params) : null,
700
onMethodName: 'on' + e.name.charAt(0).toUpperCase() + e.name.slice(1),
701
})
702
}
703
}
704
return events
705
}
706
707
// ============================================================
708
// Code generation
709
// ============================================================
710
711
const LICENSE_HEADER = `\
712
// Licensed to the Software Freedom Conservancy (SFC) under one
713
// or more contributor license agreements. See the NOTICE file
714
// distributed with this work for additional information
715
// regarding copyright ownership. The SFC licenses this file
716
// to you under the Apache License, Version 2.0 (the
717
// "License"); you may not use this file except in compliance
718
// with the License. You may obtain a copy of the License at
719
//
720
// http://www.apache.org/licenses/LICENSE-2.0
721
//
722
// Unless required by applicable law or agreed to in writing,
723
// software distributed under the License is distributed on an
724
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
725
// KIND, either express or implied. See the License for the
726
// specific language governing permissions and limitations
727
// under the License.`
728
729
// ============================================================
730
// Type-map helpers for cross-domain import generation
731
// ============================================================
732
733
/**
734
* Returns a Map from exported type name → domain key.
735
* Used to generate cross-domain import statements.
736
*/
737
function buildTypeNameToDomainMap(typesByDomain) {
738
const map = new Map()
739
for (const [domain, typeBlock] of Object.entries(typesByDomain)) {
740
for (const line of typeBlock.split('\n')) {
741
const m = line.match(/^export (?:type|interface) (\w+)/)
742
if (m) map.set(m[1], domain)
743
}
744
}
745
return map
746
}
747
748
/**
749
* Scans a domain's type block for references to types that live in OTHER
750
* domains and returns the import statements needed to make the file compile.
751
*
752
* Only PascalCase identifiers that exist in typeNameToDomain and belong to a
753
* different domain are considered. Built-in TypeScript types (string, number,
754
* boolean, …) never appear in the map, so they are naturally excluded.
755
*/
756
function computeCrossDomainImports(typeBlock, domain, typeNameToDomain) {
757
if (!typeBlock) return []
758
759
// Collect all PascalCase identifiers referenced in the type block.
760
const referenced = new Set()
761
for (const match of typeBlock.matchAll(/\b([A-Z][A-Za-z0-9]*)\b/g)) {
762
referenced.add(match[1])
763
}
764
765
// Group by source domain (skip same-domain types and unknown types).
766
const bySourceDomain = new Map()
767
for (const name of referenced) {
768
const sourceDomain = typeNameToDomain.get(name)
769
if (!sourceDomain || sourceDomain === domain) continue
770
if (!bySourceDomain.has(sourceDomain)) bySourceDomain.set(sourceDomain, new Set())
771
bySourceDomain.get(sourceDomain).add(name)
772
}
773
774
// Emit sorted import lines.
775
const imports = []
776
for (const [sourceDomain, names] of [...bySourceDomain.entries()].sort()) {
777
const sourceFile = DOMAIN_FILES[sourceDomain].replace('.ts', '.js')
778
const sorted = [...names].sort()
779
imports.push(`import type { ${sorted.join(', ')} } from './${sourceFile}'`)
780
}
781
return imports
782
}
783
784
function generateDomainFile({
785
domain,
786
className,
787
types,
788
commands,
789
events,
790
enhancement,
791
specVersion,
792
typeNameToDomain,
793
}) {
794
const parts = [LICENSE_HEADER, '']
795
796
parts.push(`// Auto-generated from WebDriver BiDi CDDL spec (v${specVersion}) — DO NOT EDIT MANUALLY`)
797
parts.push(`// Source: https://github.com/w3c/webref/tree/main/ed/cddl`)
798
parts.push('')
799
800
const filteredCommands = commands.filter((c) => !enhancement.excludeMethods?.includes(c.methodName))
801
const filteredEvents = events.filter((e) => !enhancement.excludeMethods?.includes(e.eventName))
802
const hasImplementation = className != null && (filteredCommands.length > 0 || filteredEvents.length > 0)
803
804
// Filter out excluded types before emitting.
805
let typeBlock = types
806
if (enhancement.excludeTypes?.length) {
807
typeBlock = filterExcludedTypes(typeBlock, enhancement.excludeTypes)
808
}
809
810
// Compute cross-domain imports needed by this domain's type block.
811
// Types from other domains are referenced by name but live in separate files.
812
const crossDomainImports = computeCrossDomainImports(typeBlock, domain, typeNameToDomain)
813
814
if (crossDomainImports.length > 0) {
815
for (const line of crossDomainImports) {
816
parts.push(line)
817
}
818
parts.push('')
819
}
820
821
if (hasImplementation) {
822
// Define the BiDi connection interface inline so the generated file is
823
// self-contained for tsc and doesn't need to resolve ../index.js.
824
parts.push(`/** Minimal BiDi transport interface (satisfied structurally by bidi/index.js). */`)
825
parts.push(`interface BidiConnection {`)
826
parts.push(` send(command: Record<string, unknown>): Promise<unknown>`)
827
parts.push(` subscribe(event: string | string[], contexts?: string[]): Promise<void>`)
828
parts.push(` on(event: string, listener: (params: unknown) => void): void`)
829
parts.push(`}`)
830
parts.push('')
831
}
832
833
if (typeBlock) {
834
parts.push(`// --- Types ---`)
835
parts.push('')
836
parts.push(typeBlock)
837
parts.push('')
838
}
839
840
if (enhancement.extraTypes) {
841
parts.push(`// --- Additional Types ---`)
842
parts.push('')
843
parts.push(enhancement.extraTypes)
844
parts.push('')
845
}
846
847
if (hasImplementation) {
848
parts.push(`// --- Implementation ---`)
849
parts.push('')
850
parts.push(
851
generateClass({
852
className,
853
commands: filteredCommands,
854
events: filteredEvents,
855
enhancement,
856
}),
857
)
858
}
859
860
return parts.join('\n') + '\n'
861
}
862
863
function filterExcludedTypes(typeBlock, excludeTypes) {
864
const lines = typeBlock.split('\n')
865
const output = []
866
let i = 0
867
868
while (i < lines.length) {
869
const line = lines[i]
870
const match = line.match(/^export (?:type|interface) (\w+)/)
871
if (match && excludeTypes.includes(match[1])) {
872
if (line.includes('{') && !line.endsWith('{}') && !line.endsWith('{};')) {
873
let depth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length
874
i++
875
while (i < lines.length && depth > 0) {
876
depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length
877
i++
878
}
879
} else {
880
i++
881
}
882
if (i < lines.length && lines[i] === '') i++
883
continue
884
}
885
output.push(line)
886
i++
887
}
888
889
return output.join('\n').trimEnd()
890
}
891
892
function generateClass({ className, commands, events, enhancement }) {
893
const lines = []
894
895
lines.push(`export class ${className} {`)
896
lines.push(` private constructor(private readonly bidi: BidiConnection) {}`)
897
lines.push('')
898
lines.push(` static async create(driver: unknown): Promise<${className}> {`)
899
lines.push(
900
` const caps = await (driver as { getCapabilities(): Promise<{ get(key: string): unknown }> }).getCapabilities()`,
901
)
902
lines.push(` if (!caps.get('webSocketUrl')) {`)
903
lines.push(` throw new Error('WebDriver instance must support BiDi protocol')`)
904
lines.push(` }`)
905
lines.push(` const bidi = await (driver as { getBidi(): Promise<BidiConnection> }).getBidi()`)
906
lines.push(` return new ${className}(bidi)`)
907
lines.push(` }`)
908
909
for (const cmd of commands) {
910
const override = enhancement.extraMethods?.[cmd.methodName]
911
lines.push('')
912
lines.push(override ?? generateCommandMethod(cmd))
913
}
914
915
for (const evt of events) {
916
const override = enhancement.extraMethods?.[evt.onMethodName]
917
lines.push('')
918
lines.push(override ?? generateEventMethod(evt))
919
}
920
921
if (enhancement.extraMethods) {
922
const knownNames = new Set([...commands.map((c) => c.methodName), ...events.map((e) => e.onMethodName)])
923
for (const [name, body] of Object.entries(enhancement.extraMethods)) {
924
if (!knownNames.has(name)) {
925
// Purely additive method not tied to a command or event.
926
lines.push('')
927
lines.push(body)
928
}
929
}
930
}
931
932
lines.push(`}`)
933
return lines.join('\n')
934
}
935
936
function generateCommandMethod(cmd) {
937
const { methodName, methodStr, paramsTypeName, hasParams, resultTypeName } = cmd
938
const isVoid = resultTypeName === null
939
const returnType = isVoid ? 'void' : resultTypeName
940
941
// Use a double-cast (T as unknown as Record<string,unknown>) so TypeScript
942
// accepts the conversion even when the params type has no index signature.
943
const paramsCast = hasParams ? '(params as unknown as Record<string, unknown>)' : '{}'
944
945
const lines = []
946
if (hasParams) {
947
lines.push(` async ${methodName}(params: ${paramsTypeName}): Promise<${returnType}> {`)
948
} else {
949
lines.push(` async ${methodName}(): Promise<${returnType}> {`)
950
}
951
952
// Both void and non-void commands go through the same error-check pattern.
953
// bidi/index.js always resolves (never rejects) regardless of response type,
954
// so we must inspect the payload ourselves and throw on error responses.
955
lines.push(` const response = await this.bidi.send({`)
956
lines.push(` method: '${methodStr}',`)
957
lines.push(` params: ${paramsCast},`)
958
lines.push(` }) as Record<string, unknown>`)
959
lines.push(` if (response['type'] === 'error') {`)
960
lines.push(` throw new Error(\`\${response['error']}: \${response['message']}\`)`)
961
lines.push(` }`)
962
if (!isVoid) {
963
lines.push(` return (response as unknown as { result: ${resultTypeName} }).result`)
964
}
965
966
lines.push(` }`)
967
return lines.join('\n')
968
}
969
970
function generateEventMethod(evt) {
971
const { onMethodName, methodStr, paramsTypeName } = evt
972
const cbType = paramsTypeName ? `(params: ${paramsTypeName}) => void` : `(params: unknown) => void`
973
974
const lines = []
975
lines.push(` async ${onMethodName}(callback: ${cbType}): Promise<void> {`)
976
lines.push(` await this.bidi.subscribe('${methodStr}')`)
977
// bidi/index.js emits BiDi events by method name through its single shared
978
// message dispatcher (which already handles JSON parsing and closed-state
979
// guards). Using bidi.on() here avoids attaching a new ws.on('message', ...)
980
// listener on every subscription call, preventing listener accumulation and
981
// MaxListeners warnings.
982
lines.push(` this.bidi.on('${methodStr}', (params: unknown) => {`)
983
lines.push(` callback(${paramsTypeName ? `params as ${paramsTypeName}` : 'params'})`)
984
lines.push(` })`)
985
lines.push(` }`)
986
return lines.join('\n')
987
}
988
989
// ============================================================
990
// Entry point
991
// ============================================================
992
993
main().catch((err) => {
994
console.error(err)
995
process.exit(1)
996
})
997
998