Skip to content

Commit 5a7fca3

Browse files
titusfortnerclaude
andcommitted
[js] Add binding-neutral BiDi schema with cddl2ts-gated fidelity
Normalize the parsed BiDi AST and project a flat, binding-neutral schema (commands + events + types) for the generated Ruby/Java/Python clients, so each binding consumes one explicit artifact instead of re-deriving the awkward CDDL shapes from the raw AST. Normalizer (normalize_bidi_ast.mjs): hoist inline string-literal unions to named enums, canonicalize variant-union params into self-contained variant records, hoist inline records, and flatten group composition (base types + Extensible). Projector (project_bidi_schema.mjs): map to a small vocabulary (record/enum/union/alias + ref/primitive/const/list/map), preserving wire names and nullability verbatim; generation validates and fails on dangling refs or dropped commands/events. Fidelity is gated against cddl2ts, an independent generator over the same AST, comparing record fields, field types (optional/nullable/array) and enum values; only one intentional difference remains (wire-faithful namespaceURI). Wired as Bazel targets create-bidi-src_schema (output name derived from the target) and the bidi-schema-{tests,diff-test}. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d4832d7 commit 5a7fca3

7 files changed

Lines changed: 1468 additions & 0 deletions

File tree

javascript/selenium-webdriver/BUILD.bazel

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,40 @@ js_binary(
2525
entry_point = "generate_bidi.mjs",
2626
)
2727

28+
# Projects the normalized, flat BiDi schema (commands + events + types) consumed
29+
# by the generated Ruby / Java / Python clients. Pure JS — no npm dependencies.
30+
js_binary(
31+
name = "project_bidi_schema_script",
32+
data = [
33+
"normalize_bidi_ast.mjs",
34+
"project_bidi_schema.mjs",
35+
],
36+
entry_point = "project_bidi_schema.mjs",
37+
)
38+
39+
# Tests for the BiDi schema tooling: unit tests for the normalizer + projector
40+
# transforms, plus the authoritative field-fidelity check that diffs the
41+
# projected schema against cddl2ts (an independent generator over the same AST,
42+
# run over the generated artifacts) — catching dropped/mistyped fields and enum
43+
# drift that the structural validators cannot see.
44+
mocha_test(
45+
name = "bidi-schema-tests",
46+
size = "small",
47+
args = ["./*_test.mjs"],
48+
chdir = package_name(),
49+
data = [
50+
"bidi_schema_diff_test.mjs",
51+
"normalize_bidi_ast.mjs",
52+
"normalize_bidi_ast_test.mjs",
53+
"project_bidi_schema.mjs",
54+
"project_bidi_schema_test.mjs",
55+
":create-bidi-src_ast",
56+
":create-bidi-src_json",
57+
":node_modules/cddl2ts",
58+
":node_modules/mocha",
59+
],
60+
)
61+
2862
# Generate WebDriver BiDi TypeScript modules from CDDL specification.
2963
# extra_cddl_files are merged with the primary BiDi spec before generation so that
3064
# adjacent specs (Permissions, Prefetch, UA Client Hints, Web Bluetooth) are included.
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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+
* Differential fidelity check against cddl2ts.
20+
*
21+
* cddl2ts independently emits TypeScript types from the same AST, so it is an
22+
* oracle for what each type should contain. This compares the projected schema
23+
* to cddl2ts and fails on any difference that is not explicitly allowlisted —
24+
* catching dropped/extra fields, field-type drift (optional/nullable/array) and
25+
* enum drift, the class of bug the structural validators (checkSchema /
26+
* checkCompleteness) cannot see.
27+
*
28+
* Mocha test; `describe`/`it` are mocha globals. It runs against the generated
29+
* AST + model artifacts (declared as Bazel data, read relative to the package
30+
* dir via chdir). Intentional differences live in KNOWN_DIFFERENCES with a
31+
* reason; the check flags an allowlist entry as stale once the difference
32+
* disappears, so the list cannot silently rot.
33+
*/
34+
35+
import assert from 'node:assert/strict'
36+
import { readFileSync } from 'node:fs'
37+
import { transform } from 'cddl2ts'
38+
import { projectSchema } from './project_bidi_schema.mjs'
39+
40+
// Intentional, reviewed divergences from cddl2ts, keyed by schema type name.
41+
// `fields` are field names cddl2ts has that we deliberately do not (because we
42+
// keep the wire-faithful name, or nest them under a hoisted inline-record type).
43+
const KNOWN_DIFFERENCES = {
44+
// We preserve the wire name `namespaceURI`; cddl2ts mangles it to namespaceUri.
45+
'script.NodeProperties': { fields: ['namespaceUri'], reason: 'wire-faithful name namespaceURI' },
46+
}
47+
48+
// Union types whose collective field set intentionally differs from cddl2ts.
49+
const UNION_DIFFERENCES = {
50+
// The top-level protocol envelope composes the EventData union; method/params
51+
// live on the individual event types here, not on the envelope.
52+
Message: { fields: ['method', 'params'], reason: 'envelope composes EventData union' },
53+
}
54+
55+
/** dotted CDDL name → cddl2ts PascalCase name (mirrors normalizeDottedName). */
56+
function tsName(name) {
57+
return name
58+
.split('.')
59+
.map((part) => {
60+
const titled = part.charAt(0).toUpperCase() + part.slice(1)
61+
return titled.replace(/([A-Z]{2,})(?=[A-Z][a-z]|$)/g, (m) => m[0] + m.slice(1).toLowerCase())
62+
})
63+
.join('')
64+
}
65+
66+
const OPEN = '{(['
67+
const CLOSE = '})]'
68+
69+
/** Slice the brace-balanced body following the `{` at `from` (exclusive of braces). */
70+
function balancedBody(ts, from) {
71+
let depth = 1
72+
let i = from
73+
while (i < ts.length && depth > 0) {
74+
if (OPEN.includes(ts[i])) depth++
75+
else if (CLOSE.includes(ts[i])) depth--
76+
i++
77+
}
78+
return { body: ts.slice(from, i - 1), end: i }
79+
}
80+
81+
/** Remove the contents of nested `{...}` blocks, keeping tokens outside them. */
82+
function stripObjectBodies(s) {
83+
let out = ''
84+
let depth = 0
85+
for (const c of s) {
86+
if (c === '{') depth++
87+
else if (c === '}') depth--
88+
else if (depth === 0) out += c
89+
}
90+
return out
91+
}
92+
93+
/** Parse the top-level fields of an interface body (nested object types ignored). */
94+
function topLevelFields(body) {
95+
const fields = {}
96+
let i = 0
97+
let depth = 0
98+
while (i < body.length) {
99+
if (OPEN.includes(body[i])) {
100+
depth++
101+
i++
102+
continue
103+
}
104+
if (CLOSE.includes(body[i])) {
105+
depth--
106+
i++
107+
continue
108+
}
109+
const m = depth === 0 ? /^(\w+)(\??):\s*/.exec(body.slice(i)) : null
110+
if (!m) {
111+
i++
112+
continue
113+
}
114+
let j = i + m[0].length
115+
let d = 0
116+
while (j < body.length && !(d === 0 && body[j] === ';')) {
117+
if (OPEN.includes(body[j])) d++
118+
else if (CLOSE.includes(body[j])) d--
119+
j++
120+
}
121+
const type = body.slice(i + m[0].length, j).trim()
122+
// Detect the field's own nullability/array-ness from its type with nested
123+
// object bodies removed, so `null`/`[]` belonging to nested fields (e.g. an
124+
// inline `{ x: T | null }`) are not attributed to this field.
125+
const shallow = stripObjectBodies(type)
126+
fields[m[1]] = { optional: m[2] === '?', nullable: /\bnull\b/.test(shallow), array: /\[\]/.test(shallow) }
127+
i = j + 1
128+
}
129+
return fields
130+
}
131+
132+
/** Parse cddl2ts output into { interfaces, enums, aliases }. */
133+
function parseCddl2ts(ts) {
134+
const interfaces = {}
135+
for (const m of ts.matchAll(/export interface (\w+)\s*\{/g)) {
136+
const { body } = balancedBody(ts, m.index + m[0].length)
137+
interfaces[m[1]] = topLevelFields(body)
138+
}
139+
const enums = {}
140+
for (const m of ts.matchAll(/export type (\w+) = ((?:\s*"[^"]*"\s*\|?)+);/g)) {
141+
enums[m[1]] = new Set([...m[2].matchAll(/"([^"]*)"/g)].map((x) => x[1]))
142+
}
143+
const aliases = {} // name → raw RHS expression (for union/intersection types)
144+
for (const m of ts.matchAll(/export type (\w+) = /g)) {
145+
let i = m.index + m[0].length
146+
let depth = 0
147+
const start = i
148+
while (i < ts.length && !(depth === 0 && ts[i] === ';')) {
149+
if (OPEN.includes(ts[i])) depth++
150+
else if (CLOSE.includes(ts[i])) depth--
151+
i++
152+
}
153+
aliases[m[1]] = ts.slice(start, i)
154+
}
155+
return { interfaces, enums, aliases }
156+
}
157+
158+
/** Split `expr` on `sep` at bracket depth 0. */
159+
function splitTopLevel(expr, sep) {
160+
const parts = []
161+
let depth = 0
162+
let cur = ''
163+
for (const c of expr) {
164+
if (OPEN.includes(c)) depth++
165+
else if (CLOSE.includes(c)) depth--
166+
if (c === sep && depth === 0) {
167+
parts.push(cur)
168+
cur = ''
169+
} else cur += c
170+
}
171+
parts.push(cur)
172+
return parts
173+
}
174+
175+
/**
176+
* Collect the flattened field names a cddl2ts union/intersection expression
177+
* contributes: `& {...}` common objects, inline-object members, and named
178+
* members resolved through interfaces and (recursively) composition aliases.
179+
*/
180+
function expectedUnionFields(expr, parsed, fields = new Set(), seen = new Set()) {
181+
let stripped = ''
182+
let i = 0
183+
while (i < expr.length) {
184+
if (expr[i] === '&') {
185+
let j = i + 1
186+
while (j < expr.length && /\s/.test(expr[j])) j++
187+
if (expr[j] === '{') {
188+
const { body, end } = balancedBody(expr, j + 1)
189+
Object.keys(topLevelFields(body)).forEach((f) => fields.add(f))
190+
i = end
191+
continue
192+
}
193+
}
194+
stripped += expr[i]
195+
i++
196+
}
197+
for (let part of splitTopLevel(stripped, '|')) {
198+
part = part.trim()
199+
if (part.startsWith('(')) expectedUnionFields(balancedBody(part, 1).body, parsed, fields, seen)
200+
else if (part.startsWith('{')) Object.keys(topLevelFields(balancedBody(part, 1).body)).forEach((f) => fields.add(f))
201+
else {
202+
const id = part.match(/^([A-Za-z]\w*)/)?.[1]
203+
if (!id || seen.has(id)) continue
204+
seen.add(id)
205+
if (parsed.interfaces[id]) Object.keys(parsed.interfaces[id]).forEach((f) => fields.add(f))
206+
else if (parsed.aliases[id]) expectedUnionFields(parsed.aliases[id], parsed, fields, seen)
207+
}
208+
}
209+
return fields
210+
}
211+
212+
/** Collect the flattened field names a schema type contributes (through unions and aliases). */
213+
function schemaTypeFields(name, types, fields = new Set(), seen = new Set()) {
214+
if (seen.has(name)) return fields
215+
seen.add(name)
216+
const t = types[name]
217+
if (!t) return fields
218+
if (t.kind === 'record') t.fields.forEach((f) => fields.add(f.name))
219+
else if (t.kind === 'union') t.variants.forEach((v) => schemaTypeFields(v, types, fields, seen))
220+
else if (t.kind === 'alias' && t.type?.ref) schemaTypeFields(t.type.ref, types, fields, seen)
221+
return fields
222+
}
223+
224+
/** Returns an array of difference strings; empty means the schema matches cddl2ts. */
225+
function diffAgainstCddl2ts(ast, model) {
226+
const schema = projectSchema(ast, model)
227+
const parsed = parseCddl2ts(transform(ast))
228+
const { interfaces, enums, aliases } = parsed
229+
const errors = []
230+
231+
for (const [name, node] of Object.entries(schema.types)) {
232+
if (node.kind === 'record') {
233+
const oracle = interfaces[tsName(name)]
234+
if (!oracle) continue
235+
const oracleNames = Object.keys(oracle)
236+
const mine = new Map(node.fields.map((f) => [f.name, f]))
237+
const allow = new Set(KNOWN_DIFFERENCES[name]?.fields ?? [])
238+
const missing = oracleNames.filter((f) => !mine.has(f) && !allow.has(f))
239+
const stale = [...allow].filter((f) => mine.has(f) || !(f in oracle))
240+
if (missing.length) errors.push(`${name}: missing fields cddl2ts has: ${missing.join(', ')}`)
241+
if (stale.length) errors.push(`${name}: stale KNOWN_DIFFERENCES fields (resolved, remove): ${stale.join(', ')}`)
242+
// Type fidelity for fields present in both: optional / nullable / array.
243+
for (const [fname, field] of mine) {
244+
const o = oracle[fname]
245+
if (!o) continue
246+
if (o.optional === field.required)
247+
errors.push(
248+
`${name}.${fname}: optional mismatch (cddl2ts optional=${o.optional}, schema required=${field.required})`,
249+
)
250+
if (o.nullable && !field.type?.nullable) errors.push(`${name}.${fname}: cddl2ts is nullable, schema is not`)
251+
if (o.array && !field.type?.list) errors.push(`${name}.${fname}: cddl2ts is array, schema is not`)
252+
}
253+
} else if (node.kind === 'enum') {
254+
const oracle = enums[tsName(name)]
255+
if (!oracle) continue // hoisted/synthetic enums have no named cddl2ts counterpart
256+
const mine = new Set(node.values)
257+
const missing = [...oracle].filter((v) => !mine.has(v))
258+
const extra = [...mine].filter((v) => !oracle.has(v))
259+
if (missing.length || extra.length)
260+
errors.push(`${name}: enum values differ (cddl2ts-only: [${missing}], schema-only: [${extra}])`)
261+
} else if (node.kind === 'union') {
262+
const alias = aliases[tsName(name)]
263+
if (!alias) continue // cddl2ts represents it some other way; nothing to compare
264+
const expected = expectedUnionFields(alias, parsed)
265+
const mine = schemaTypeFields(name, schema.types)
266+
const allow = new Set(UNION_DIFFERENCES[name]?.fields ?? [])
267+
const missing = [...expected].filter((f) => !mine.has(f) && !allow.has(f))
268+
const extra = [...mine].filter((f) => !expected.has(f))
269+
const stale = [...allow].filter((f) => mine.has(f) || !expected.has(f))
270+
if (missing.length) errors.push(`${name}: union missing fields cddl2ts has: ${missing.join(', ')}`)
271+
if (extra.length) errors.push(`${name}: union has fields cddl2ts does not: ${extra.join(', ')}`)
272+
if (stale.length) errors.push(`${name}: stale UNION_DIFFERENCES (resolved, remove): ${stale.join(', ')}`)
273+
}
274+
}
275+
276+
// Stale whole-type allowlist entries (the type no longer exists / is no longer a record).
277+
for (const name of Object.keys(KNOWN_DIFFERENCES)) {
278+
if (!(name in schema.types)) errors.push(`stale KNOWN_DIFFERENCES type (gone, remove): ${name}`)
279+
}
280+
return errors
281+
}
282+
283+
describe('BiDi schema vs cddl2ts oracle', () => {
284+
it('matches cddl2ts on record fields, field types, enum values, and union members', () => {
285+
const ast = JSON.parse(readFileSync('create-bidi-src_ast.json', 'utf8'))
286+
const model = JSON.parse(readFileSync('create-bidi-src_model.json', 'utf8'))
287+
assert.deepEqual(diffAgainstCddl2ts(ast, model), [])
288+
})
289+
})

0 commit comments

Comments
 (0)