Skip to content
34 changes: 34 additions & 0 deletions javascript/selenium-webdriver/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,40 @@ js_binary(
entry_point = "generate_bidi.mjs",
)

# Projects the normalized, flat BiDi schema (commands + events + types) consumed
# by the generated Ruby / Java / Python clients. Pure JS β€” no npm dependencies.
js_binary(
name = "project_bidi_schema_script",
data = [
"normalize_bidi_ast.mjs",
"project_bidi_schema.mjs",
],
entry_point = "project_bidi_schema.mjs",
)

# Tests for the BiDi schema tooling: unit tests for the normalizer + projector
# transforms, plus the authoritative field-fidelity check that diffs the
# projected schema against cddl2ts (an independent generator over the same AST,
# run over the generated artifacts) β€” catching dropped/mistyped fields and enum
# drift that the structural validators cannot see.
mocha_test(
name = "bidi-schema-tests",
size = "small",
args = ["./*_test.mjs"],
chdir = package_name(),
data = [
"bidi_schema_diff_test.mjs",
"normalize_bidi_ast.mjs",
"normalize_bidi_ast_test.mjs",
"project_bidi_schema.mjs",
"project_bidi_schema_test.mjs",
":create-bidi-src_ast",
":create-bidi-src_schema",
":node_modules/cddl2ts",
":node_modules/mocha",
],
)

# Generate WebDriver BiDi TypeScript modules from CDDL specification.
# extra_cddl_files are merged with the primary BiDi spec before generation so that
# adjacent specs (Permissions, Prefetch, UA Client Hints, Web Bluetooth) are included.
Expand Down
344 changes: 344 additions & 0 deletions javascript/selenium-webdriver/bidi_schema_diff_test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

/**
* Differential fidelity check against cddl2ts.
*
* cddl2ts independently emits TypeScript types from the same AST, so it is an
* oracle for what each type should contain. This compares the projected schema
* to cddl2ts and fails on any difference that is not explicitly allowlisted β€”
* catching dropped/extra fields, field-type drift (optional/nullable/array) and
* enum drift, the class of bug the structural validators (checkSchema /
* checkCompleteness) cannot see.
*
* Mocha test; `describe`/`it` are mocha globals. It runs against the *generated*
* schema artifact (and the AST, for cddl2ts) declared as Bazel data and read
* relative to the package dir via chdir β€” so the test depends on, and therefore
* exercises, the schema-generation CLI rather than re-projecting in-process.
* Intentional differences live in KNOWN_DIFFERENCES with a reason; the check
* flags an allowlist entry as stale once the difference disappears, so the list
* cannot silently rot.
*/

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { transform } from 'cddl2ts'

// Intentional, reviewed divergences from cddl2ts, keyed by schema type name.
// `fields` are field names cddl2ts has that we deliberately do not (because we
// keep the wire-faithful name, or nest them under a hoisted inline-record type).
const KNOWN_DIFFERENCES = {
// We preserve the wire name `namespaceURI`; cddl2ts mangles it to namespaceUri.
'script.NodeProperties': { fields: ['namespaceUri'], reason: 'wire-faithful name namespaceURI' },
}

// Union types whose collective field set intentionally differs from cddl2ts.
const UNION_DIFFERENCES = {
// The top-level protocol envelope composes the EventData union; method/params
// live on the individual event types here, not on the envelope.
Message: { fields: ['method', 'params'], reason: 'envelope composes EventData union' },
}

// Composed records (cddl2ts `Base & {...}` intersection aliases) whose field set
// intentionally differs. The Command/Event protocol envelopes compose the
// Command/Event data unions, whose method/params live on the leaf types here.
const RECORD_ALIAS_DIFFERENCES = {
Command: { fields: ['method', 'params'], reason: 'envelope composes CommandData union' },
Event: { fields: ['method', 'params'], reason: 'envelope composes EventData union' },
}

// Fields cddl2ts reports as nullable that we intentionally do not. The cddl parser
// strips the quotes from the reserved word `"null"`, so cddl2ts reads NullValue's
// string-literal tag `type: "null"` as the JSON null type; we correctly project it
// as the string const "null" (the real wire discriminator), so it is not nullable.
const NULLABLE_DIFFERENCES = {
'script.NullValue': { fields: ['type'], reason: 'quoted "null" tag, not the null type' },
}

/** dotted CDDL name β†’ cddl2ts PascalCase name (mirrors normalizeDottedName). */
function tsName(name) {
return name
.split('.')
.map((part) => {
const titled = part.charAt(0).toUpperCase() + part.slice(1)
return titled.replace(/([A-Z]{2,})(?=[A-Z][a-z]|$)/g, (m) => m[0] + m.slice(1).toLowerCase())
})
.join('')
}

const OPEN = '{(['
const CLOSE = '})]'

/** Slice the brace-balanced body following the `{` at `from` (exclusive of braces). */
function balancedBody(ts, from) {
let depth = 1
let i = from
while (i < ts.length && depth > 0) {
if (OPEN.includes(ts[i])) depth++
else if (CLOSE.includes(ts[i])) depth--
i++
}
return { body: ts.slice(from, i - 1), end: i }
}

/** Remove the contents of nested `{...}` blocks, keeping tokens outside them. */
function stripObjectBodies(s) {
let out = ''
let depth = 0
for (const c of s) {
if (c === '{') depth++
else if (c === '}') depth--
else if (depth === 0) out += c
}
return out
}

/** Parse the top-level fields of an interface body (nested object types ignored). */
function topLevelFields(body) {
const fields = {}
let i = 0
let depth = 0
while (i < body.length) {
if (OPEN.includes(body[i])) {
depth++
i++
continue
}
if (CLOSE.includes(body[i])) {
depth--
i++
continue
}
const m = depth === 0 ? /^(\w+)(\??):\s*/.exec(body.slice(i)) : null
if (!m) {
i++
continue
}
let j = i + m[0].length
let d = 0
while (j < body.length && !(d === 0 && body[j] === ';')) {
if (OPEN.includes(body[j])) d++
else if (CLOSE.includes(body[j])) d--
j++
}
const type = body.slice(i + m[0].length, j).trim()
// Detect the field's own nullability/array-ness from its type with nested
// object bodies removed, so `null`/`[]` belonging to nested fields (e.g. an
// inline `{ x: T | null }`) are not attributed to this field.
const shallow = stripObjectBodies(type)
fields[m[1]] = { optional: m[2] === '?', nullable: /\bnull\b/.test(shallow), array: /\[\]/.test(shallow) }
i = j + 1
}
return fields
}

/** Parse cddl2ts output into { interfaces, enums, aliases }. */
function parseCddl2ts(ts) {
const interfaces = {}
for (const m of ts.matchAll(/export interface (\w+)\s*\{/g)) {
const { body } = balancedBody(ts, m.index + m[0].length)
interfaces[m[1]] = topLevelFields(body)
}
const aliases = {} // name β†’ raw RHS expression (for union/intersection types)
for (const m of ts.matchAll(/export type (\w+) = /g)) {
let i = m.index + m[0].length
let depth = 0
const start = i
while (i < ts.length && !(depth === 0 && ts[i] === ';')) {
if (OPEN.includes(ts[i])) depth++
else if (CLOSE.includes(ts[i])) depth--
i++
}
aliases[m[1]] = ts.slice(start, i)
}
// Enums are the aliases whose RHS is a pure string-literal union. Derived from
// the parsed aliases (linear) rather than a nested-quantifier regex.
const enums = {}
for (const [name, expr] of Object.entries(aliases)) {
const parts = splitTopLevel(expr, '|').map((p) => p.trim())
if (parts.length && parts.every((p) => /^"[^"]*"$/.test(p))) enums[name] = new Set(parts.map((p) => p.slice(1, -1)))
}
return { interfaces, enums, aliases }
}

/** Split `expr` on `sep` at bracket depth 0. */
function splitTopLevel(expr, sep) {
const parts = []
let depth = 0
let cur = ''
for (const c of expr) {
if (OPEN.includes(c)) depth++
else if (CLOSE.includes(c)) depth--
if (c === sep && depth === 0) {
parts.push(cur)
cur = ''
} else cur += c
}
parts.push(cur)
return parts
}

/**
* Collect the flattened field names a cddl2ts union/intersection expression
* contributes: `& {...}` common objects, inline-object members, and named
* members resolved through interfaces and (recursively) composition aliases.
*/
function expectedUnionFields(expr, parsed, fields = new Set(), seen = new Set()) {
let stripped = ''
let i = 0
while (i < expr.length) {
if (expr[i] === '&') {
let j = i + 1
while (j < expr.length && /\s/.test(expr[j])) j++
if (expr[j] === '{') {
const { body, end } = balancedBody(expr, j + 1)
Object.keys(topLevelFields(body)).forEach((f) => fields.add(f))
i = end
continue
}
}
stripped += expr[i]
i++
}
for (let part of splitTopLevel(stripped, '|')) {
part = part.trim()
if (part.startsWith('(')) expectedUnionFields(balancedBody(part, 1).body, parsed, fields, seen)
else if (part.startsWith('{')) Object.keys(topLevelFields(balancedBody(part, 1).body)).forEach((f) => fields.add(f))
else {
const id = part.match(/^([A-Za-z]\w*)/)?.[1]
if (!id || seen.has(id)) continue
seen.add(id)
if (parsed.interfaces[id]) Object.keys(parsed.interfaces[id]).forEach((f) => fields.add(f))
else if (parsed.aliases[id]) expectedUnionFields(parsed.aliases[id], parsed, fields, seen)
}
}
return fields
}

/** Collect the flattened field names a schema type contributes (through unions and aliases). */
function schemaTypeFields(name, types, fields = new Set(), seen = new Set()) {
if (seen.has(name)) return fields
seen.add(name)
const t = types[name]
if (!t) return fields
if (t.kind === 'record') t.fields.forEach((f) => fields.add(f.name))
else if (t.kind === 'union') t.variants.forEach((v) => schemaTypeFields(v, types, fields, seen))
else if (t.kind === 'alias' && t.type?.ref) schemaTypeFields(t.type.ref, types, fields, seen)
return fields
}

/**
* Compare the generated schema against the cddl2ts oracle.
* @param {object} schema The generated schema artifact (`{commands, events, types}`).
* @param {object[]} ast The parsed CDDL AST (fed to cddl2ts).
* @returns {string[]} Difference messages; empty means the schema matches cddl2ts.
*/
function diffAgainstCddl2ts(schema, ast) {
const parsed = parseCddl2ts(transform(ast))
const { interfaces, enums, aliases } = parsed
const errors = []

for (const [name, node] of Object.entries(schema.types)) {
if (node.kind === 'record') {
const oracle = interfaces[tsName(name)]
if (!oracle) {
const alias = aliases[tsName(name)]
if (alias?.includes('&')) {
// A composed record cddl2ts emits as `Base & {...}` β€” field-compare it,
// so a dropped composition (e.g. an un-flattened base type) is caught.
const expected = expectedUnionFields(alias, parsed)
const mine = new Set(node.fields.map((f) => f.name))
const allow = new Set(RECORD_ALIAS_DIFFERENCES[name]?.fields ?? [])
const missing = [...expected].filter((f) => !mine.has(f) && !allow.has(f))
if (missing.length) errors.push(`${name}: composed record missing fields cddl2ts has: ${missing.join(', ')}`)
} else if (node.fields.length === 0 && !node.map && !node.extensible && alias) {
// A fieldless record where cddl2ts emits a list/union alias means the
// element type was dropped (e.g. a top-level `[*T]` or `a // b`).
errors.push(
`${name}: projected as an empty record but cddl2ts emits a type alias (dropped list/union element type)`,
)
}
continue
}
const oracleNames = Object.keys(oracle)
const mine = new Map(node.fields.map((f) => [f.name, f]))
const allow = new Set(KNOWN_DIFFERENCES[name]?.fields ?? [])
const missing = oracleNames.filter((f) => !mine.has(f) && !allow.has(f))
const stale = [...allow].filter((f) => mine.has(f) || !(f in oracle))
if (missing.length) errors.push(`${name}: missing fields cddl2ts has: ${missing.join(', ')}`)
if (stale.length) errors.push(`${name}: stale KNOWN_DIFFERENCES fields (resolved, remove): ${stale.join(', ')}`)
// Type fidelity for fields present in both: optional / nullable / array.
const allowNullable = new Set(NULLABLE_DIFFERENCES[name]?.fields ?? [])
for (const [fname, field] of mine) {
const o = oracle[fname]
if (!o) continue
if (o.optional === field.required)
errors.push(
`${name}.${fname}: optional mismatch (cddl2ts optional=${o.optional}, schema required=${field.required})`,
)
if (o.nullable && !field.type?.nullable && !allowNullable.has(fname))
errors.push(`${name}.${fname}: cddl2ts is nullable, schema is not`)
if (field.type?.nullable && !o.nullable && !allowNullable.has(fname))
errors.push(`${name}.${fname}: schema is nullable, cddl2ts is not`)
if (o.array && !field.type?.list) errors.push(`${name}.${fname}: cddl2ts is array, schema is not`)
}
const staleNullable = [...allowNullable].filter((f) => !oracle[f]?.nullable || mine.get(f)?.type?.nullable)
if (staleNullable.length)
errors.push(`${name}: stale NULLABLE_DIFFERENCES (resolved, remove): ${staleNullable.join(', ')}`)
} else if (node.kind === 'enum') {
const oracle = enums[tsName(name)]
if (!oracle) continue // hoisted/synthetic enums have no named cddl2ts counterpart
const mine = new Set(node.values)
const missing = [...oracle].filter((v) => !mine.has(v))
const extra = [...mine].filter((v) => !oracle.has(v))
if (missing.length || extra.length)
errors.push(`${name}: enum values differ (cddl2ts-only: [${missing}], schema-only: [${extra}])`)
} else if (node.kind === 'union') {
const alias = aliases[tsName(name)]
if (!alias) continue // cddl2ts represents it some other way; nothing to compare
const expected = expectedUnionFields(alias, parsed)
const mine = schemaTypeFields(name, schema.types)
const allow = new Set(UNION_DIFFERENCES[name]?.fields ?? [])
const missing = [...expected].filter((f) => !mine.has(f) && !allow.has(f))
const extra = [...mine].filter((f) => !expected.has(f))
const stale = [...allow].filter((f) => mine.has(f) || !expected.has(f))
if (missing.length) errors.push(`${name}: union missing fields cddl2ts has: ${missing.join(', ')}`)
if (extra.length) errors.push(`${name}: union has fields cddl2ts does not: ${extra.join(', ')}`)
if (stale.length) errors.push(`${name}: stale UNION_DIFFERENCES (resolved, remove): ${stale.join(', ')}`)
} else if (node.kind === 'alias' && node.type?.list) {
// A list alias must correspond to a cddl2ts array; otherwise an element
// type was lost (the same class as the empty-record list bug).
const alias = aliases[tsName(name)]
if (alias !== undefined && !alias.includes('[]'))
errors.push(`${name}: projected as a list but cddl2ts is not an array (${alias.slice(0, 40)})`)
}
}

// Stale whole-type allowlist entries (the type no longer exists / is no longer a record).
for (const name of Object.keys(KNOWN_DIFFERENCES)) {
if (!(name in schema.types)) errors.push(`stale KNOWN_DIFFERENCES type (gone, remove): ${name}`)
}
return errors
}

describe('BiDi schema vs cddl2ts oracle', () => {
it('matches cddl2ts on record fields, field types, enum values, and union members', () => {
const schema = JSON.parse(readFileSync('create-bidi-src_schema.json', 'utf8'))
const ast = JSON.parse(readFileSync('create-bidi-src_ast.json', 'utf8'))
assert.deepEqual(diffAgainstCddl2ts(schema, ast), [])
})
})
Loading