Skip to content

Commit 00ea1b7

Browse files
authored
[js] Add binding-neutral BiDi schema with cddl2ts-gated fidelity (#17700)
* [js] Add binding-neutral BiDi schema with cddl2ts-gated fidelity * [js] Project NullValue's quoted "null" tag as a string const (works around webdriverio/cddl#64)
1 parent 6ea4c23 commit 00ea1b7

7 files changed

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

0 commit comments

Comments
 (0)