Skip to content

Commit 09f7fa7

Browse files
committed
[js] Add binding-neutral BiDi schema with per-union selectors and fidelity gates
1 parent 4ac103a commit 09f7fa7

7 files changed

Lines changed: 2212 additions & 6 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_schema",
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: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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+
* schema artifact (and the AST, for cddl2ts) declared as Bazel data and read
30+
* relative to the package dir via chdir — so the test depends on, and therefore
31+
* exercises, the schema-generation CLI rather than re-projecting in-process.
32+
* Intentional differences live in KNOWN_DIFFERENCES with a reason; the check
33+
* flags an allowlist entry as stale once the difference disappears, so the list
34+
* cannot silently rot.
35+
*/
36+
37+
import assert from 'node:assert/strict'
38+
import { readFileSync } from 'node:fs'
39+
import { transform } from 'cddl2ts'
40+
41+
// Intentional, reviewed divergences from cddl2ts, keyed by schema type name.
42+
// `fields` are field names cddl2ts has that we deliberately do not (because we
43+
// keep the wire-faithful name, or nest them under a hoisted inline-record type).
44+
const KNOWN_DIFFERENCES = {
45+
// We preserve the wire name `namespaceURI`; cddl2ts mangles it to namespaceUri.
46+
'script.NodeProperties': { fields: ['namespaceUri'], reason: 'wire-faithful name namespaceURI' },
47+
}
48+
49+
// Union types whose collective field set intentionally differs from cddl2ts.
50+
const UNION_DIFFERENCES = {
51+
// The top-level protocol envelope composes the EventData union; method/params
52+
// live on the individual event types here, not on the envelope.
53+
Message: { fields: ['method', 'params'], reason: 'envelope composes EventData union' },
54+
}
55+
56+
// Composed records (cddl2ts `Base & {...}` intersection aliases) whose field set
57+
// intentionally differs. The Command/Event protocol envelopes compose the
58+
// Command/Event data unions, whose method/params live on the leaf types here.
59+
const RECORD_ALIAS_DIFFERENCES = {
60+
Command: { fields: ['method', 'params'], reason: 'envelope composes CommandData union' },
61+
Event: { fields: ['method', 'params'], reason: 'envelope composes EventData union' },
62+
}
63+
64+
/** dotted CDDL name → cddl2ts PascalCase name (mirrors normalizeDottedName). */
65+
function tsName(name) {
66+
return name
67+
.split('.')
68+
.map((part) => {
69+
const titled = part.charAt(0).toUpperCase() + part.slice(1)
70+
return titled.replace(/([A-Z]{2,})(?=[A-Z][a-z]|$)/g, (m) => m[0] + m.slice(1).toLowerCase())
71+
})
72+
.join('')
73+
}
74+
75+
const OPEN = '{(['
76+
const CLOSE = '})]'
77+
78+
/** Slice the brace-balanced body following the `{` at `from` (exclusive of braces). */
79+
function balancedBody(ts, from) {
80+
let depth = 1
81+
let i = from
82+
while (i < ts.length && depth > 0) {
83+
if (OPEN.includes(ts[i])) depth++
84+
else if (CLOSE.includes(ts[i])) depth--
85+
i++
86+
}
87+
return { body: ts.slice(from, i - 1), end: i }
88+
}
89+
90+
/** Remove the contents of nested `{...}` blocks, keeping tokens outside them. */
91+
function stripObjectBodies(s) {
92+
let out = ''
93+
let depth = 0
94+
for (const c of s) {
95+
if (c === '{') depth++
96+
else if (c === '}') depth--
97+
else if (depth === 0) out += c
98+
}
99+
return out
100+
}
101+
102+
/** Parse the top-level fields of an interface body (nested object types ignored). */
103+
function topLevelFields(body) {
104+
const fields = {}
105+
let i = 0
106+
let depth = 0
107+
while (i < body.length) {
108+
if (OPEN.includes(body[i])) {
109+
depth++
110+
i++
111+
continue
112+
}
113+
if (CLOSE.includes(body[i])) {
114+
depth--
115+
i++
116+
continue
117+
}
118+
const m = depth === 0 ? /^(\w+)(\??):\s*/.exec(body.slice(i)) : null
119+
if (!m) {
120+
i++
121+
continue
122+
}
123+
let j = i + m[0].length
124+
let d = 0
125+
while (j < body.length && !(d === 0 && body[j] === ';')) {
126+
if (OPEN.includes(body[j])) d++
127+
else if (CLOSE.includes(body[j])) d--
128+
j++
129+
}
130+
const type = body.slice(i + m[0].length, j).trim()
131+
// Detect the field's own nullability/array-ness from its type with nested
132+
// object bodies removed, so `null`/`[]` belonging to nested fields (e.g. an
133+
// inline `{ x: T | null }`) are not attributed to this field.
134+
const shallow = stripObjectBodies(type)
135+
fields[m[1]] = { optional: m[2] === '?', nullable: /\bnull\b/.test(shallow), array: /\[\]/.test(shallow) }
136+
i = j + 1
137+
}
138+
return fields
139+
}
140+
141+
/** Parse cddl2ts output into { interfaces, enums, aliases }. */
142+
function parseCddl2ts(ts) {
143+
const interfaces = {}
144+
for (const m of ts.matchAll(/export interface (\w+)\s*\{/g)) {
145+
const { body } = balancedBody(ts, m.index + m[0].length)
146+
interfaces[m[1]] = topLevelFields(body)
147+
}
148+
const aliases = {} // name → raw RHS expression (for union/intersection types)
149+
for (const m of ts.matchAll(/export type (\w+) = /g)) {
150+
let i = m.index + m[0].length
151+
let depth = 0
152+
const start = i
153+
while (i < ts.length && !(depth === 0 && ts[i] === ';')) {
154+
if (OPEN.includes(ts[i])) depth++
155+
else if (CLOSE.includes(ts[i])) depth--
156+
i++
157+
}
158+
aliases[m[1]] = ts.slice(start, i)
159+
}
160+
// Enums are the aliases whose RHS is a pure string-literal union. Derived from
161+
// the parsed aliases (linear) rather than a nested-quantifier regex.
162+
const enums = {}
163+
for (const [name, expr] of Object.entries(aliases)) {
164+
const parts = splitTopLevel(expr, '|').map((p) => p.trim())
165+
if (parts.length && parts.every((p) => /^"[^"]*"$/.test(p))) enums[name] = new Set(parts.map((p) => p.slice(1, -1)))
166+
}
167+
return { interfaces, enums, aliases }
168+
}
169+
170+
/** Split `expr` on `sep` at bracket depth 0. */
171+
function splitTopLevel(expr, sep) {
172+
const parts = []
173+
let depth = 0
174+
let cur = ''
175+
for (const c of expr) {
176+
if (OPEN.includes(c)) depth++
177+
else if (CLOSE.includes(c)) depth--
178+
if (c === sep && depth === 0) {
179+
parts.push(cur)
180+
cur = ''
181+
} else cur += c
182+
}
183+
parts.push(cur)
184+
return parts
185+
}
186+
187+
/**
188+
* Collect the flattened field names a cddl2ts union/intersection expression
189+
* contributes: `& {...}` common objects, inline-object members, and named
190+
* members resolved through interfaces and (recursively) composition aliases.
191+
*/
192+
function expectedUnionFields(expr, parsed, fields = new Set(), seen = new Set()) {
193+
let stripped = ''
194+
let i = 0
195+
while (i < expr.length) {
196+
if (expr[i] === '&') {
197+
let j = i + 1
198+
while (j < expr.length && /\s/.test(expr[j])) j++
199+
if (expr[j] === '{') {
200+
const { body, end } = balancedBody(expr, j + 1)
201+
Object.keys(topLevelFields(body)).forEach((f) => fields.add(f))
202+
i = end
203+
continue
204+
}
205+
}
206+
stripped += expr[i]
207+
i++
208+
}
209+
for (let part of splitTopLevel(stripped, '|')) {
210+
part = part.trim()
211+
if (part.startsWith('(')) expectedUnionFields(balancedBody(part, 1).body, parsed, fields, seen)
212+
else if (part.startsWith('{')) Object.keys(topLevelFields(balancedBody(part, 1).body)).forEach((f) => fields.add(f))
213+
else {
214+
const id = part.match(/^([A-Za-z]\w*)/)?.[1]
215+
if (!id || seen.has(id)) continue
216+
seen.add(id)
217+
if (parsed.interfaces[id]) Object.keys(parsed.interfaces[id]).forEach((f) => fields.add(f))
218+
else if (parsed.aliases[id]) expectedUnionFields(parsed.aliases[id], parsed, fields, seen)
219+
}
220+
}
221+
return fields
222+
}
223+
224+
/** Collect the flattened field names a schema type contributes (through unions and aliases). */
225+
function schemaTypeFields(name, types, fields = new Set(), seen = new Set()) {
226+
if (seen.has(name)) return fields
227+
seen.add(name)
228+
const t = types[name]
229+
if (!t) return fields
230+
if (t.kind === 'record') t.fields.forEach((f) => fields.add(f.name))
231+
else if (t.kind === 'union') t.variants.forEach((v) => schemaTypeFields(v, types, fields, seen))
232+
else if (t.kind === 'alias' && t.type?.ref) schemaTypeFields(t.type.ref, types, fields, seen)
233+
return fields
234+
}
235+
236+
/**
237+
* Compare the generated schema against the cddl2ts oracle.
238+
* @param {object} schema The generated schema artifact (`{commands, events, types}`).
239+
* @param {object[]} ast The parsed CDDL AST (fed to cddl2ts).
240+
* @returns {string[]} Difference messages; empty means the schema matches cddl2ts.
241+
*/
242+
function diffAgainstCddl2ts(schema, ast) {
243+
const parsed = parseCddl2ts(transform(ast))
244+
const { interfaces, enums, aliases } = parsed
245+
const errors = []
246+
247+
for (const [name, node] of Object.entries(schema.types)) {
248+
if (node.kind === 'record') {
249+
const oracle = interfaces[tsName(name)]
250+
if (!oracle) {
251+
const alias = aliases[tsName(name)]
252+
if (alias?.includes('&')) {
253+
// A composed record cddl2ts emits as `Base & {...}` — field-compare it,
254+
// so a dropped composition (e.g. an un-flattened base type) is caught.
255+
const expected = expectedUnionFields(alias, parsed)
256+
const mine = new Set(node.fields.map((f) => f.name))
257+
const allow = new Set(RECORD_ALIAS_DIFFERENCES[name]?.fields ?? [])
258+
const missing = [...expected].filter((f) => !mine.has(f) && !allow.has(f))
259+
if (missing.length) errors.push(`${name}: composed record missing fields cddl2ts has: ${missing.join(', ')}`)
260+
} else if (node.fields.length === 0 && !node.map && !node.extensible && alias) {
261+
// A fieldless record where cddl2ts emits a list/union alias means the
262+
// element type was dropped (e.g. a top-level `[*T]` or `a // b`).
263+
errors.push(
264+
`${name}: projected as an empty record but cddl2ts emits a type alias (dropped list/union element type)`,
265+
)
266+
}
267+
continue
268+
}
269+
const oracleNames = Object.keys(oracle)
270+
const mine = new Map(node.fields.map((f) => [f.name, f]))
271+
const allow = new Set(KNOWN_DIFFERENCES[name]?.fields ?? [])
272+
const missing = oracleNames.filter((f) => !mine.has(f) && !allow.has(f))
273+
const stale = [...allow].filter((f) => mine.has(f) || !(f in oracle))
274+
if (missing.length) errors.push(`${name}: missing fields cddl2ts has: ${missing.join(', ')}`)
275+
if (stale.length) errors.push(`${name}: stale KNOWN_DIFFERENCES fields (resolved, remove): ${stale.join(', ')}`)
276+
// Type fidelity for fields present in both: optional / nullable / array.
277+
for (const [fname, field] of mine) {
278+
const o = oracle[fname]
279+
if (!o) continue
280+
if (o.optional === field.required)
281+
errors.push(
282+
`${name}.${fname}: optional mismatch (cddl2ts optional=${o.optional}, schema required=${field.required})`,
283+
)
284+
if (o.nullable && !field.type?.nullable) errors.push(`${name}.${fname}: cddl2ts is nullable, schema is not`)
285+
if (o.array && !field.type?.list) errors.push(`${name}.${fname}: cddl2ts is array, schema is not`)
286+
}
287+
} else if (node.kind === 'enum') {
288+
const oracle = enums[tsName(name)]
289+
if (!oracle) continue // hoisted/synthetic enums have no named cddl2ts counterpart
290+
const mine = new Set(node.values)
291+
const missing = [...oracle].filter((v) => !mine.has(v))
292+
const extra = [...mine].filter((v) => !oracle.has(v))
293+
if (missing.length || extra.length)
294+
errors.push(`${name}: enum values differ (cddl2ts-only: [${missing}], schema-only: [${extra}])`)
295+
} else if (node.kind === 'union') {
296+
const alias = aliases[tsName(name)]
297+
if (!alias) continue // cddl2ts represents it some other way; nothing to compare
298+
const expected = expectedUnionFields(alias, parsed)
299+
const mine = schemaTypeFields(name, schema.types)
300+
const allow = new Set(UNION_DIFFERENCES[name]?.fields ?? [])
301+
const missing = [...expected].filter((f) => !mine.has(f) && !allow.has(f))
302+
const extra = [...mine].filter((f) => !expected.has(f))
303+
const stale = [...allow].filter((f) => mine.has(f) || !expected.has(f))
304+
if (missing.length) errors.push(`${name}: union missing fields cddl2ts has: ${missing.join(', ')}`)
305+
if (extra.length) errors.push(`${name}: union has fields cddl2ts does not: ${extra.join(', ')}`)
306+
if (stale.length) errors.push(`${name}: stale UNION_DIFFERENCES (resolved, remove): ${stale.join(', ')}`)
307+
} else if (node.kind === 'alias' && node.type?.list) {
308+
// A list alias must correspond to a cddl2ts array; otherwise an element
309+
// type was lost (the same class as the empty-record list bug).
310+
const alias = aliases[tsName(name)]
311+
if (alias !== undefined && !alias.includes('[]'))
312+
errors.push(`${name}: projected as a list but cddl2ts is not an array (${alias.slice(0, 40)})`)
313+
}
314+
}
315+
316+
// Stale whole-type allowlist entries (the type no longer exists / is no longer a record).
317+
for (const name of Object.keys(KNOWN_DIFFERENCES)) {
318+
if (!(name in schema.types)) errors.push(`stale KNOWN_DIFFERENCES type (gone, remove): ${name}`)
319+
}
320+
return errors
321+
}
322+
323+
describe('BiDi schema vs cddl2ts oracle', () => {
324+
it('matches cddl2ts on record fields, field types, enum values, and union members', () => {
325+
const schema = JSON.parse(readFileSync('create-bidi-src_schema.json', 'utf8'))
326+
const ast = JSON.parse(readFileSync('create-bidi-src_ast.json', 'utf8'))
327+
assert.deepEqual(diffAgainstCddl2ts(schema, ast), [])
328+
})
329+
})

0 commit comments

Comments
 (0)