diff --git a/javascript/selenium-webdriver/BUILD.bazel b/javascript/selenium-webdriver/BUILD.bazel index a8c828d9e4e35..e6554bee0c2fc 100644 --- a/javascript/selenium-webdriver/BUILD.bazel +++ b/javascript/selenium-webdriver/BUILD.bazel @@ -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. diff --git a/javascript/selenium-webdriver/bidi_schema_diff_test.mjs b/javascript/selenium-webdriver/bidi_schema_diff_test.mjs new file mode 100644 index 0000000000000..24e184e9f0ffe --- /dev/null +++ b/javascript/selenium-webdriver/bidi_schema_diff_test.mjs @@ -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), []) + }) +}) diff --git a/javascript/selenium-webdriver/normalize_bidi_ast.mjs b/javascript/selenium-webdriver/normalize_bidi_ast.mjs new file mode 100644 index 0000000000000..3df64c136d8b9 --- /dev/null +++ b/javascript/selenium-webdriver/normalize_bidi_ast.mjs @@ -0,0 +1,485 @@ +// 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. + +/** + * Produce `selenium-bidi-ast.json` from the raw `bidi-ast.json`. + * + * The raw AST (from the `cddl` parser) is faithful but unnormalized: the same + * logical construct appears in several shapes that each binding generator then + * has to recognize on its own — and currently each gets some of them wrong. This + * stage rewrites a small number of nodes into ONE canonical shape per construct, + * staying within the parser's existing node vocabulary so a consumer's existing + * AST traversal keeps working — it just stops tripping on the awkward shapes. + * + * It is intentionally additive and surgical: the output is the input with a few + * dozen nodes rewritten plus synthetic defs appended. The raw AST is left + * untouched for `cddl2ts` (which consumes it directly for the JS type bindings). + * + * Transforms: + * 1. Inline string-literal unions on a field → a hoisted named enum def + * (the shape generators already handle for top-level enums), values verbatim. + * 2. Variant-union params (a record carrying an inline choice of variants, OR + * a top-level union) → one canonical `variable` union whose members are + * self-contained variant records (common fields merged in). The discriminator + * then lives inside each variant, so "field required only for this variant" + * holds by construction. + * + * Type-name refs use the dotted CDDL name (e.g. `network.Request`), matching the + * raw AST. Result/void handling stays in the model (`buildResultTypeNames`). + * + * Defs synthesized for anonymous constructs (hoisted enums/records, union arms) + * carry `x-selenium-synthetic` plus `x-selenium-owner` (the def they were lifted + * out of) and `x-selenium-label` (the member name within it). The projector turns + * these into `{ synthetic, owner, label }` so a consumer can name or nest the type + * idiomatically rather than reverse-engineering the synthetic def name. + */ + +/** Normalize a node's `Type` to an array of type entries (group/literal/string). */ +function typeList(t) { + if (Array.isArray(t)) return t + if (t === undefined || t === null) return [] + return [t] +} + +/** PascalCase a field name for use in a synthetic type name. */ +function pascal(name) { + const cleaned = (name || '').replace(/[^A-Za-z0-9]/g, ' ') + return cleaned + .split(' ') + .filter(Boolean) + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join('') +} + +/** Split a dotted CDDL name into `{ domain, local }` (domain '' when undotted). */ +function splitName(name) { + const i = name.indexOf('.') + return i === -1 ? { domain: '', local: name } : { domain: name.slice(0, i), local: name.slice(i + 1) } +} + +/** A factory for collision-free synthetic def names within a known name set. */ +function nameAllocator(existing) { + const taken = new Set(existing) + return (candidate) => { + let name = candidate + let n = 2 + while (taken.has(name)) name = `${candidate}${n++}` + taken.add(name) + return name + } +} + +/** True when `entry` is a reference to a named group (`{Type:'group', Value}`). */ +function isGroupRef(entry) { + return entry && typeof entry === 'object' && entry.Type === 'group' && typeof entry.Value === 'string' +} + +/** True when `entry` is an inline anonymous group (`{Type:'group', Properties}`). */ +function isInlineGroup(entry) { + return entry && typeof entry === 'object' && entry.Type === 'group' && Array.isArray(entry.Properties) +} + +function groupRef(value) { + return { Type: 'group', Value: value, Unwrapped: false } +} + +/** + * Drop the leading run of `label` that restates `ownerLocal`, backing off to a + * camelCase boundary, so `ContinueWithAuthParameters` + `ContinueWithAuthCredentials` + * → `Credentials` (and `DownloadBehavior` + `DownloadBehaviorAllowed` → `Allowed`). + */ +function trimRedundantPrefix(ownerLocal, label) { + let i = 0 + while (i < label.length && i < ownerLocal.length && label[i] === ownerLocal[i]) i++ + while (i > 0 && i < label.length && !(label[i] >= 'A' && label[i] <= 'Z')) i-- + const rest = label.slice(i) + return rest.length > 0 && rest[0] >= 'A' && rest[0] <= 'Z' ? rest : label +} + +/** Collect every named group referenced (`{Type:'group', Value}`) anywhere in the AST. */ +function collectReferencedNames(ast) { + const referenced = new Set() + const walk = (v) => { + if (Array.isArray(v)) { + v.forEach(walk) + } else if (v && typeof v === 'object') { + if (v.Type === 'group' && typeof v.Value === 'string') referenced.add(v.Value) + for (const key in v) if (key !== 'Name') walk(v[key]) + } + } + for (const def of ast) walk(def) + return referenced +} + +// ============================================================ +// Transform 1: hoist inline string-literal unions to named enums +// ============================================================ + +/** + * Visit every property object reachable through a def's `Properties`, including + * those nested inside inline groups and arrays and inside choice branches + * (array-wrapped elements). `fn(prop)` may mutate `prop` in place. + */ +function eachPropertyDeep(properties, fn) { + if (!Array.isArray(properties)) return + for (const element of properties) { + if (Array.isArray(element)) { + eachPropertyDeep(element, fn) + continue + } + if (!element || typeof element !== 'object') continue + fn(element) + for (const entry of typeList(element.Type)) { + if (isInlineGroup(entry)) eachPropertyDeep(entry.Properties, fn) + if (entry && entry.Type === 'array' && Array.isArray(entry.Values)) eachPropertyDeep(entry.Values, fn) + } + } +} + +/** + * Rewrite fields whose type is a union of >= 2 string literals into a reference + * to a synthetic enum def, and append those enum defs. Single-literal fields + * (discriminators) are left untouched. Returns a new AST array. + * @param {object[]} ast The AST to transform. + * @returns {object[]} A new AST array with inline enums hoisted to named defs. + */ +export function hoistInlineEnums(ast) { + const out = structuredClone(ast) + const alloc = nameAllocator(out.map((d) => d.Name)) + const created = [] + + for (const def of out) { + if (!def || typeof def !== 'object' || !Array.isArray(def.Properties)) continue + const owner = splitName(def.Name ?? '') + eachPropertyDeep(def.Properties, (prop) => { + const entries = typeList(prop.Type) + const allLiterals = + entries.length >= 2 && entries.every((e) => e && typeof e === 'object' && e.Type === 'literal') + if (!allLiterals) return + + const base = pascal(prop.Name) || `Value${created.length}` + const localName = `${owner.local}${base}` + const synthName = alloc(owner.domain ? `${owner.domain}.${localName}` : localName) + + created.push({ + Type: 'variable', + Name: synthName, + IsChoiceAddition: false, + PropertyType: entries.map((e) => structuredClone(e)), + Comments: prop.Comments ?? [], + 'x-selenium-synthetic': true, + 'x-selenium-owner': def.Name, + 'x-selenium-label': base, + }) + prop.Type = [groupRef(synthName)] + }) + } + + return [...out, ...created] +} + +/** + * Hoist a named field whose type is an inline anonymous record into a synthetic + * top-level def + a reference, so every structured type in the artifact is a + * named ref (no inline records left for consumers to special-case). Uses a + * worklist so records nested inside hoisted records are also lifted. + * @param {object[]} ast The AST to transform. + * @returns {object[]} A new AST array with inline records hoisted to named defs. + */ +export function hoistInlineRecords(ast) { + const out = structuredClone(ast) + const alloc = nameAllocator(out.map((d) => d.Name)) + const created = [] + const queue = [...out] + + while (queue.length) { + const def = queue.shift() + if (!def || typeof def !== 'object' || !Array.isArray(def.Properties)) continue + const owner = splitName(def.Name ?? '') + for (const prop of def.Properties.flat()) { + if (!prop || typeof prop !== 'object' || !prop.Name) continue + const entries = typeList(prop.Type) + const inline = entries.length === 1 && isInlineGroup(entries[0]) && !entries[0].Value + if (!inline) continue + const localName = `${owner.local}${pascal(prop.Name)}` + const synthName = alloc(owner.domain ? `${owner.domain}.${localName}` : localName) + const newDef = { + Type: 'group', + Name: synthName, + IsChoiceAddition: false, + Properties: entries[0].Properties, + Comments: prop.Comments ?? [], + 'x-selenium-synthetic': true, + 'x-selenium-owner': def.Name, + 'x-selenium-label': pascal(prop.Name), + } + created.push(newDef) + queue.push(newDef) + prop.Type = [groupRef(synthName)] + } + } + + return [...out, ...created] +} + +// ============================================================ +// Transform 3: canonicalize variant-union params +// ============================================================ + +/** + * Flatten a choice group's `Properties` into an ordered list of branch property + * objects. The parser encodes `a // b` as a mix of array-wrapped and bare + * elements; both are choice alternatives here. + */ +function choiceBranches(properties) { + const branches = [] + for (const element of properties ?? []) { + if (Array.isArray(element)) branches.push(...element.filter((e) => e && typeof e === 'object')) + else if (element && typeof element === 'object') branches.push(element) + } + return branches +} + +/** + * Detect a record with an inline variant choice: exactly one anonymous property + * (`Name === ''`) whose type is an inline group of >= 2 choice branches. Returns + * `{ commonFields, branches }` or null. + */ +function detectInlineVariant(def) { + if (def.Type !== 'group' || !Array.isArray(def.Properties)) return null + + const named = [] + let choice = null + for (const prop of def.Properties) { + if (Array.isArray(prop) || !prop || typeof prop !== 'object') return null + if (prop.Name === '' || prop.Name === undefined) { + const entry = typeList(prop.Type)[0] + if (isInlineGroup(entry)) { + const branches = choiceBranches(entry.Properties) + if (branches.length >= 2 && choice === null) { + choice = branches + continue + } + } + return null // anonymous-but-not-a-clean-choice: leave it alone + } + named.push(prop) + } + + return choice ? { commonFields: named, branches: choice } : null +} + +/** The single variant entry carried by a choice branch (ref or inline group). */ +function branchType(branch) { + return typeList(branch.Type).find((e) => isGroupRef(e) || isInlineGroup(e)) +} + +/** + * Build the fields for a self-contained variant record: the common fields plus + * the variant's own fields (inlined from a referenced record, or taken from an + * inline group). Returns `{ fields, label }` where label names the variant. + */ +function variantRecord(commonFields, entry, defMap, index) { + const common = commonFields.map((f) => structuredClone(f)) + if (isGroupRef(entry)) { + const target = defMap.get(entry.Value) + const ownFields = + target && Array.isArray(target.Properties) ? structuredClone(target.Properties) : [structuredClone({ ...entry })] + return { fields: [...common, ...ownFields], label: splitName(entry.Value).local, supersedes: entry.Value } + } + // inline group: label by its sole distinguishing field when there is one + const ownFields = structuredClone(entry.Properties) + const named = ownFields.filter((f) => f && typeof f === 'object' && f.Name) + const label = named.length === 1 ? pascal(named[0].Name) : `Variant${index}` + return { fields: [...common, ...ownFields], label } +} + +/** + * Rewrite variant-union params into a canonical `variable` union of + * self-contained variant records. Records that are already a `variable` union + * (a top-level union of group refs) are left as the canonical target. Returns a + * new AST array. + * @param {object[]} ast The AST to transform. + * @returns {object[]} A new AST array with variant-union params canonicalized. + */ +export function canonicalizeVariantParams(ast) { + const out = structuredClone(ast) + const defMap = new Map(out.map((d) => [d.Name, d])) + const alloc = nameAllocator(out.map((d) => d.Name)) + const created = [] + const superseded = new Set() + const supersededBy = new Map() + + for (const def of out) { + const detected = detectInlineVariant(def) + if (!detected) continue + + // Verify every branch is supported BEFORE allocating any names or emitting defs. + // Allocating up front would reserve synthetic names (skewing later numeric + // suffixes) and could leave orphaned defs if a later branch then bailed out, so + // the result would depend on the presence/order of unsupported branches. + const entries = detected.branches.map(branchType) + if (entries.some((e) => !e)) continue // unexpected branch shape: leave def untouched + + const owner = splitName(def.Name) + const memberRefs = entries.map((entry, i) => { + const { fields, label, supersedes } = variantRecord(detected.commonFields, entry, defMap, i) + const memberLabel = trimRedundantPrefix(owner.local, label) + const localName = `${owner.local}_${memberLabel}` + const synthName = alloc(owner.domain ? `${owner.domain}.${localName}` : localName) + created.push({ + Type: 'group', + Name: synthName, + IsChoiceAddition: false, + Properties: fields, + Comments: [], + 'x-selenium-synthetic': true, + 'x-selenium-owner': def.Name, + 'x-selenium-label': memberLabel, + }) + if (supersedes) { + superseded.add(supersedes) + supersededBy.set(supersedes, synthName) + } + return groupRef(synthName) + }) + + delete def.Properties + def.Type = 'variable' + def.PropertyType = memberRefs + def['x-selenium-union'] = true + } + + // Drop source variant defs that the merge inlined and nothing else references. + const result = [...out, ...created] + const referenced = collectReferencedNames(result) + const kept = result.filter((d) => !(superseded.has(d.Name) && !referenced.has(d.Name))) + + // A def hoisted out of a source variant (e.g. an enum) now belongs to the + // record that absorbed it, so re-point a synthetic owner the merge dropped. + const keptNames = new Set(kept.map((d) => d.Name)) + for (const def of kept) { + const owner = def['x-selenium-owner'] + if (owner && !keptNames.has(owner) && supersededBy.has(owner)) def['x-selenium-owner'] = supersededBy.get(owner) + } + return kept +} + +// ============================================================ +// Transform 4: flatten group composition +// ============================================================ + +// Dispatch-hierarchy defs (the command/event union machinery) are not data +// records and must not be flattened into. A command/event leaf is identified by +// a `method` property whose type is the literal method string (e.g. +// "log.entryAdded") — not a data field that merely happens to be named `method` +// (e.g. log.ConsoleLogEntry.method, a plain text field). +function isDispatchType(def) { + if (!def) return false + if (/Command$|Event$/.test(def.Name ?? '')) return true + return (def.Properties ?? []).flat().some((p) => { + if (p?.Name !== 'method') return false + const t = Array.isArray(p.Type) ? p.Type[0] : p.Type + return t?.Type === 'literal' + }) +} + +// A record group carries named fields (no top-level choice branches). +function isRecordGroup(def) { + return def && def.Type === 'group' && Array.isArray(def.Properties) && !def.Properties.some((p) => Array.isArray(p)) +} + +/** + * Inline anonymous group-ref spreads (CDDL group composition, e.g. a params + * record that includes `network.BaseParameters`) so every record carries its + * full field set — nothing composed-in is dropped. Spreading the `Extensible` + * marker inlines its `* text => any` wildcard, which the projector reads as + * `extensible`, so extensibility propagates for free. Spreads of unions or the + * dispatch hierarchy are left as-is. Recursion is memoized and cycle-guarded. + * @param {object[]} ast The AST to transform. + * @returns {object[]} A new AST array with group composition flattened. + */ +export function flattenGroupComposition(ast) { + const out = structuredClone(ast) + const defMap = new Map(out.map((d) => [d.Name, d])) + const cache = new Map() + + function mergedFields(name, seen) { + if (cache.has(name)) return cache.get(name) + const def = defMap.get(name) + if (!isRecordGroup(def) || isDispatchType(def) || seen.has(name)) return null + seen.add(name) + const fields = [] + for (const prop of def.Properties.flat()) { + if (!prop || typeof prop !== 'object') continue + const entry = typeList(prop.Type)[0] + const isSpread = !prop.Name && isGroupRef(entry) + const composed = isSpread ? mergedFields(entry.Value, seen) : null + if (composed) fields.push(...structuredClone(composed)) + else fields.push(prop) + } + seen.delete(name) + cache.set(name, fields) + return fields + } + + for (const def of out) { + if (!isRecordGroup(def) || isDispatchType(def)) continue + const fields = mergedFields(def.Name, new Set()) + if (fields) def.Properties = structuredClone(fields) + } + return out +} + +// ============================================================ +// Pipeline +// ============================================================ + +/** + * Drop duplicate definitions, keeping the first occurrence — the `*-all.cddl` + * input concatenates local + remote specs that both define shared types. This + * matches `buildModel`'s `buildDefMap` ("first wins") so the normalized artifact + * carries one def per name. + * @param {object[]} ast The AST to dedupe. + * @returns {object[]} A new AST array with duplicate-named defs removed (first wins). + */ +export function dedupeDefs(ast) { + const seen = new Set() + const out = [] + for (const def of ast) { + if (def && typeof def === 'object' && typeof def.Name === 'string') { + if (seen.has(def.Name)) continue + seen.add(def.Name) + } + out.push(def) + } + return out +} + +/** + * Apply all normalizations to a raw BiDi AST. Pure — does not mutate `ast`. + * @param {object[]} ast The parsed CDDL AST (array of definition nodes). + * @returns {object[]} A new, normalized AST array. + */ +export function normalizeAst(ast) { + let result = dedupeDefs(ast) + result = hoistInlineEnums(result) + result = canonicalizeVariantParams(result) + result = hoistInlineRecords(result) + result = flattenGroupComposition(result) + return result +} diff --git a/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs b/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs new file mode 100644 index 0000000000000..541efb59921d3 --- /dev/null +++ b/javascript/selenium-webdriver/normalize_bidi_ast_test.mjs @@ -0,0 +1,302 @@ +// 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. + +// Unit tests for the BiDi AST normalizer transforms. +// Mocha test; `describe`/`it` are mocha globals (run via the Bazel mocha target). +import assert from 'node:assert/strict' +import { + normalizeAst, + hoistInlineEnums, + canonicalizeVariantParams, + dedupeDefs, + flattenGroupComposition, +} from './normalize_bidi_ast.mjs' + +const lit = (v) => ({ Type: 'literal', Value: v, Unwrapped: false }) +const ref = (v) => ({ Type: 'group', Value: v, Unwrapped: false }) +const field = (name, type) => ({ Name: name, Occurrence: { n: 1, m: 1 }, Type: type, Comments: [] }) +const def = (name, props) => ({ Type: 'group', Name: name, Properties: props, IsChoiceAddition: false, Comments: [] }) +const byName = (ast, n) => ast.find((d) => d.Name === n) + +describe('hoistInlineEnums', () => { + it('hoists a multi-literal field to a named enum and rewrites the field to a ref', () => { + const ast = [def('net.SetCacheBehaviorParameters', [field('cacheBehavior', [lit('default'), lit('bypass')])])] + const out = hoistInlineEnums(ast) + + const enumName = 'net.SetCacheBehaviorParametersCacheBehavior' + assert.deepEqual(byName(out, 'net.SetCacheBehaviorParameters').Properties[0].Type, [ref(enumName)]) + const enumDef = byName(out, enumName) + assert.equal(enumDef.Type, 'variable') + assert.deepEqual( + enumDef.PropertyType.map((e) => e.Value), + ['default', 'bypass'], + ) + }) + + it('keeps hyphenated values verbatim (no case conversion)', () => { + const ast = [def('x.T', [field('state', [lit('powered-off'), lit('subscribe-to-notifications')])])] + const enumDef = byName(hoistInlineEnums(ast), 'x.TState') + assert.deepEqual( + enumDef.PropertyType.map((e) => e.Value), + ['powered-off', 'subscribe-to-notifications'], + ) + }) + + it('does NOT hoist a single-literal (discriminator) field', () => { + const ast = [def('x.T', [field('type', [lit('password')])])] + const out = hoistInlineEnums(ast) + assert.equal(out.length, 1) // no synthetic def + assert.deepEqual(out[0].Properties[0].Type, [lit('password')]) + }) + + it('leaves non-literal fields untouched', () => { + const ast = [def('x.T', [field('request', [ref('x.Request')])])] + assert.deepEqual(hoistInlineEnums(ast), ast) + }) +}) + +describe('canonicalizeVariantParams', () => { + it('converts a record-with-inline-choice into a union of self-contained variant records', () => { + const ast = [ + def('net.ContinueWithAuthParameters', [ + field('request', [ref('net.Request')]), + field('', { + Type: 'group', + Name: '', + Properties: [[field('', ref('net.Creds'))], field('', [ref('net.NoCreds')])], + }), + ]), + def('net.Creds', [ + field('action', [lit('provideCredentials')]), + field('credentials', [ref('net.AuthCredentials')]), + ]), + def('net.NoCreds', [field('action', [lit('default')])]), + ] + const out = canonicalizeVariantParams(ast) + const params = byName(out, 'net.ContinueWithAuthParameters') + + assert.equal(params.Type, 'variable') + assert.equal(params['x-selenium-union'], true) + assert.equal(params.PropertyType.length, 2) + + // Common field is merged into each variant; discriminator stays inside it. + const credsVariant = byName(out, params.PropertyType[0].Value) + assert.deepEqual( + credsVariant.Properties.map((p) => p.Name), + ['request', 'action', 'credentials'], + ) + const noCredsVariant = byName(out, params.PropertyType[1].Value) + assert.deepEqual( + noCredsVariant.Properties.map((p) => p.Name), + ['request', 'action'], + ) + }) + + it('removes merged-from source variant defs once they are unreferenced', () => { + const ast = [ + def('net.ContinueWithAuthParameters', [ + field('request', [ref('net.Request')]), + field('', { + Type: 'group', + Name: '', + Properties: [[field('', ref('net.Creds'))], field('', [ref('net.NoCreds')])], + }), + ]), + def('net.Creds', [field('action', [lit('provideCredentials')])]), + def('net.NoCreds', [field('action', [lit('default')])]), + ] + const out = canonicalizeVariantParams(ast) + assert.equal(byName(out, 'net.Creds'), undefined) + assert.equal(byName(out, 'net.NoCreds'), undefined) + }) + + it('keeps a source variant def that is still referenced elsewhere', () => { + const ast = [ + def('net.P', [ + field('', { Type: 'group', Name: '', Properties: [[field('', ref('net.A'))], field('', [ref('net.B')])] }), + ]), + def('net.A', [field('x', ['text'])]), + def('net.B', [field('y', ['text'])]), + def('net.AlsoUsesA', [field('a', [ref('net.A')])]), // independent reference + ] + const out = canonicalizeVariantParams(ast) + assert.ok(byName(out, 'net.A'), 'net.A is still referenced, must survive') + assert.equal(byName(out, 'net.B'), undefined, 'net.B is now orphaned, must be dropped') + }) + + it('trims a redundant variant-name prefix that restates the params base', () => { + const ast = [ + def('browser.DownloadBehavior', [ + field('', { + Type: 'group', + Name: '', + Properties: [ + [field('', ref('browser.DownloadBehaviorAllowed'))], + field('', [ref('browser.DownloadBehaviorDenied')]), + ], + }), + ]), + def('browser.DownloadBehaviorAllowed', [field('type', [lit('allowed')])]), + def('browser.DownloadBehaviorDenied', [field('type', [lit('denied')])]), + ] + const out = canonicalizeVariantParams(ast) + const members = byName(out, 'browser.DownloadBehavior').PropertyType.map((m) => m.Value) + assert.deepEqual(members, ['browser.DownloadBehavior_Allowed', 'browser.DownloadBehavior_Denied']) + + // Each synthesized arm carries its decomposition so a consumer need not parse the name. + const allowed = byName(out, 'browser.DownloadBehavior_Allowed') + assert.equal(allowed['x-selenium-synthetic'], true) + assert.equal(allowed['x-selenium-owner'], 'browser.DownloadBehavior') + assert.equal(allowed['x-selenium-label'], 'Allowed') + }) + + it('re-points a synthetic owner from a merged-away source variant to the absorbing record', () => { + // An enum hoisted out of net.NoCreds; the variant merge then drops net.NoCreds, + // so the enum's owner must follow into the synthesized variant record. + const ast = [ + def('net.ContinueWithAuthParameters', [ + field('request', [ref('net.Request')]), + field('', { + Type: 'group', + Name: '', + Properties: [[field('', ref('net.Creds'))], field('', [ref('net.NoCreds')])], + }), + ]), + def('net.Creds', [field('action', [lit('provideCredentials')])]), + def('net.NoCreds', [field('action', [lit('default')])]), + { + Type: 'variable', + Name: 'net.NoCredsAction', + IsChoiceAddition: false, + PropertyType: [lit('a'), lit('b')], + Comments: [], + 'x-selenium-synthetic': true, + 'x-selenium-owner': 'net.NoCreds', + 'x-selenium-label': 'Action', + }, + ] + const out = canonicalizeVariantParams(ast) + assert.equal(byName(out, 'net.NoCreds'), undefined, 'source variant was merged away') + const enumDef = byName(out, 'net.NoCredsAction') + assert.ok(byName(out, enumDef['x-selenium-owner']), 'owner now resolves to a surviving def') + assert.equal(enumDef['x-selenium-owner'], 'net.ContinueWithAuthParameters_NoCreds') + }) + + it('leaves an already-canonical top-level union (variable) unchanged', () => { + const ast = [ + { + Type: 'variable', + Name: 'session.UnsubscribeParameters', + PropertyType: [ref('session.ByAttrs'), ref('session.ById')], + Comments: [], + }, + ] + assert.deepEqual(canonicalizeVariantParams(ast), ast) + }) + + it('labels inline-group variants by their distinguishing field', () => { + const ast = [ + def('emu.SetGeolocationOverrideParameters', [ + field('', { + Type: 'group', + Name: '', + Properties: [ + [field('', { Type: 'group', Name: '', Properties: [field('coordinates', [ref('emu.Coords')])] })], + field('', { Type: 'group', Name: '', Properties: [field('error', [ref('emu.Err')])] }), + ], + }), + ]), + ] + const out = canonicalizeVariantParams(ast) + const members = byName(out, 'emu.SetGeolocationOverrideParameters').PropertyType.map((m) => m.Value) + assert.deepEqual(members, [ + 'emu.SetGeolocationOverrideParameters_Coordinates', + 'emu.SetGeolocationOverrideParameters_Error', + ]) + }) + + it('bails cleanly without leaking synthetic defs when a choice branch is unsupported', () => { + // The second branch carries a bare literal (no group-ref / inline group), so the + // def cannot be canonicalized. The first branch was already processed, but its + // staged synthetic def must NOT leak into the output, and the def stays untouched. + const ast = [ + def('x.P', [ + field('', { Type: 'group', Name: '', Properties: [[field('', ref('x.A'))], field('', [lit('oops')])] }), + ]), + def('x.A', [field('a', ['text'])]), + ] + const out = canonicalizeVariantParams(ast) + assert.equal(byName(out, 'x.P').Type, 'group', 'def left untouched (not converted to a union)') + assert.ok(byName(out, 'x.P').Properties, 'def still carries its original Properties') + assert.equal( + out.some((d) => d.Name?.startsWith('x.P_')), + false, + 'no orphaned synthetic variant def leaked', + ) + assert.deepEqual(out.map((d) => d.Name).sort(), ['x.A', 'x.P']) + }) +}) + +describe('flattenGroupComposition', () => { + const spread = (ref) => ({ Name: '', Occurrence: { n: 1, m: 1 }, Type: [ref], Comments: [] }) + const wildcard = { Name: 'text', Occurrence: { n: 0, m: null }, Type: ['any'], Comments: [] } + + it('inlines a spread-in base record so all composed fields are present', () => { + const ast = [ + def('net.AuthRequiredParameters', [spread(ref('net.BaseParameters')), field('response', [ref('net.Response')])]), + def('net.BaseParameters', [field('request', [ref('net.Request')]), field('isBlocked', ['bool'])]), + ] + const out = flattenGroupComposition(ast) + assert.deepEqual( + byName(out, 'net.AuthRequiredParameters').Properties.map((p) => p.Name), + ['request', 'isBlocked', 'response'], + ) + }) + + it('inlines the Extensible wildcard (so the projector can mark it extensible)', () => { + const ast = [def('x.Open', [field('a', ['text']), spread(ref('Extensible'))]), def('Extensible', [wildcard])] + const open = byName(flattenGroupComposition(ast), 'x.Open') + assert.ok(open.Properties.some((p) => p.Name === 'text' && p.Occurrence.m === null)) + }) + + it('does not flatten the dispatch hierarchy (names ending Command/Event)', () => { + const ast = [ + def('NetworkCommand', [spread(ref('net.AddIntercept'))]), + def('net.AddIntercept', [field('method', [lit('network.addIntercept')])]), + ] + const out = flattenGroupComposition(ast) + assert.equal(byName(out, 'NetworkCommand').Properties[0].Name, '') // spread left untouched + }) +}) + +describe('dedupeDefs', () => { + it('keeps the first occurrence of a duplicated def name', () => { + const ast = [def('x.T', [field('a', ['text'])]), def('x.T', [field('b', ['text'])])] + const out = dedupeDefs(ast) + assert.equal(out.length, 1) + assert.equal(out[0].Properties[0].Name, 'a') + }) +}) + +describe('normalizeAst', () => { + it('does not mutate its input', () => { + const ast = [def('x.SetCacheBehaviorParameters', [field('cacheBehavior', [lit('default'), lit('bypass')])])] + const snapshot = structuredClone(ast) + normalizeAst(ast) + assert.deepEqual(ast, snapshot) + }) +}) diff --git a/javascript/selenium-webdriver/private/generate_bidi.bzl b/javascript/selenium-webdriver/private/generate_bidi.bzl index 4da82197f3630..c90a40ebabf65 100644 --- a/javascript/selenium-webdriver/private/generate_bidi.bzl +++ b/javascript/selenium-webdriver/private/generate_bidi.bzl @@ -2,7 +2,8 @@ load("@aspect_rules_js//js:defs.bzl", "js_run_binary") -# Language bindings that may consume the shared bidi-ast.json / bidi-model.json artifacts. +# Language bindings consume the generated schema artifact; the ast/model are +# internal inputs to it (and to the JS generator) and stay package-private. _ARTIFACT_VISIBILITY = [ "//java:__subpackages__", "//py:__subpackages__", @@ -121,6 +122,7 @@ def generate_bidi_library( extra_cddl_files = [], enhancements_manifest = None, generator = None, + schema_generator = None, merge_tool = "//py/private:merge_cddl", spec_version = "1.0", output_path = "bidi/generated"): @@ -132,12 +134,15 @@ def generate_bidi_library( extra_cddl_files: Additional CDDL files merged before generation. enhancements_manifest: JSON manifest for per-domain customisations. generator: The generate_bidi.mjs js_binary label. Defaults to :generate_bidi_script. + schema_generator: The project_bidi_schema.mjs js_binary label. Defaults to :project_bidi_schema_script. merge_tool: Python binary that concatenates CDDL files (output first, then inputs). spec_version: Spec version string passed to the generator. output_path: Output path for generated files within the package (default: bidi/generated). """ if generator == None: generator = ":generate_bidi_script" + if schema_generator == None: + schema_generator = ":project_bidi_schema_script" pkg = native.package_name() ts_src_path = output_path + "_src" @@ -154,8 +159,8 @@ def generate_bidi_library( tool = merge_tool, ) - # Step 2: parse the merged CDDL once into the reusable AST artifact. - # Exposed to the other bindings so they can consume it directly. + # Step 2: parse the merged CDDL once into the reusable AST artifact. Internal + # input to the schema and the JS generator; not consumed by other bindings. ast_target = name + "_ast" ast_out = name + "_ast.json" js_run_binary( @@ -169,11 +174,10 @@ def generate_bidi_library( pkg + "/" + ast_out, ], tool = generator, - visibility = _ARTIFACT_VISIBILITY, ) - # Step 3: extract the binding-neutral command/event model from the AST. - # Exposed to the other bindings so they can consume it directly. + # Step 3: extract the binding-neutral command/event model from the AST. Folded + # into the schema below; still consumed directly by the JS generator in-package. json_target = name + "_json" model_out = name + "_model.json" js_run_binary( @@ -187,6 +191,27 @@ def generate_bidi_library( pkg + "/" + model_out, ], tool = generator, + ) + + # Step 3b: project the normalized, flat schema (commands + events + types) that + # the generated Ruby / Java / Python clients consume. The step validates the + # schema (referential integrity + input/output completeness) and fails the + # build on any error, so a dropped or dangling type cannot ship silently. + schema_target = name + "_schema" + schema_out = name + "_schema.json" + js_run_binary( + name = schema_target, + srcs = [":" + ast_target, ":" + json_target], + outs = [schema_out], + args = [ + "--ast", + "$(location :" + ast_target + ")", + "--model", + "$(location :" + json_target + ")", + "--dump-schema", + pkg + "/" + schema_out, + ], + tool = schema_generator, visibility = _ARTIFACT_VISIBILITY, ) diff --git a/javascript/selenium-webdriver/project_bidi_schema.mjs b/javascript/selenium-webdriver/project_bidi_schema.mjs new file mode 100644 index 0000000000000..44d2df730dffd --- /dev/null +++ b/javascript/selenium-webdriver/project_bidi_schema.mjs @@ -0,0 +1,659 @@ +// 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. + +/** + * Project the normalized BiDi AST + command/event model into one flat, + * binding-neutral schema for the generated Ruby / Java / Python clients. + * The normalizer has already removed the awkward CDDL shapes, so this is a + * straight mapping into a small vocabulary: + * + * type node: { kind: 'record', fields: [field], map?, extensible? } + * | { kind: 'enum', values: [string] } + * | { kind: 'union', variants: [ref], selector } + * | { kind: 'alias', type } + * selector: { by, variants: [{ value, ref }], default? } // discriminated + * | { ordered: [{ ref, requires: [key] }] } // structural, spec order + * | { correlated: true } // resolved by request id, not the payload + * field: { name, wire, required, type } + * type ref: { primitive } | { const } | { ref } | { enum } | { list } | { map, extensible? } | { union } + * any ref may also carry `nullable: true` (a `/ null` alternative). On a + * record node, `map` is the value type of `* key => value` entries and + * `extensible: true` marks an open `* text => any` record. + * + * Types the normalizer synthesized for anonymous CDDL constructs additionally + * carry `{ synthetic: true, owner, label }`: `owner` is the type the construct + * was lifted out of and `label` is the member name within it, so a binding can + * keep the flat name or nest it (e.g. `Owner::Label`) without parsing the key. + */ + +import { pathToFileURL } from 'node:url' +import { normalizeAst } from './normalize_bidi_ast.mjs' + +// Note: the CDDL has no prose-only "exactly one of" constraints — every +// mutual-exclusivity case (e.g. session.unsubscribe) is a CDDL choice, which the +// normalizer turns into a `union`. A scan of all spec comments confirms none, so +// no separate constraint representation is carried. + +// Events that parse into the AST but are not wired into the model because the +// upstream bluetooth spec does not fully define them. This is an external spec +// issue, not a Selenium/buildModel bug, and is intentionally not fixed here. +// Allowlisted so it does not fail the build; checkCompleteness() flags an entry +// as stale once it becomes emitted (e.g. after the spec is fixed upstream), so +// this list cannot silently rot. +const KNOWN_INCOMPLETE = new Set(['bluetooth.characteristicEventGenerated', 'bluetooth.descriptorEventGenerated']) + +const PRIMITIVES = { + text: 'string', + tstr: 'string', + uint: 'integer', + int: 'integer', + nint: 'integer', + float: 'number', + bool: 'boolean', + null: 'null', +} +// CDDL prelude types surface as group refs but are builtins, not defined types. +const PRELUDE = { + number: 'number', + any: 'any', + bytes: 'string', + bstr: 'string', + nil: 'null', + tdate: 'string', + uri: 'string', +} + +const typeList = (t) => (Array.isArray(t) ? t : t === undefined || t === null ? [] : [t]) +const isLiteral = (e) => e && typeof e === 'object' && e.Type === 'literal' +const isRef = (e) => e && typeof e === 'object' && e.Type === 'group' && typeof e.Value === 'string' + +// A `null` keyword or a `nil` prelude ref in a union means the value may be null. +const isNullAlt = (e) => + e === 'null' || (e && typeof e === 'object' && e.Type === 'group' && PRELUDE[e.Value] === 'null') + +function projectRef(type) { + const all = typeList(type) + // A missing type (undefined/empty input, e.g. a malformed array element or map + // value) is not a `null` value — fail closed so checkSchema's unknown guard + // catches it instead of silently producing a valid-looking `null`. + if (all.length === 0) return { primitive: 'unknown' } + const entries = all.filter((e) => !isNullAlt(e)) + // The only sole-`null` field in the spec is NullValue.type, whose CDDL source is + // the quoted string literal "null" (its discriminator tag); the cddl parser strips + // the quotes, collapsing it to the bareword null type. Genuine nullability is + // always `X / null` (handled below), so a sole null here is that string tag. + if (entries.length === 0) return { const: 'null' } + const node = + entries.length > 1 + ? entries.every(isLiteral) + ? { enum: entries.map((e) => e.Value) } + : { union: entries.map(projectEntry) } + : projectEntry(entries[0]) + if (entries.length < all.length) node.nullable = true // a `null` alternative means the value may be null + return node +} + +function projectEntry(e) { + if (typeof e === 'string') return { primitive: PRIMITIVES[e] ?? e } + if (!e || typeof e !== 'object') return { primitive: 'unknown' } + // A control operator (`.ge` / `.default` / `.le` …) wraps the real type as + // `{ Type: , Operator: {...} }`; the constraint does not change the + // type, so project the inner type. + if (e.Type && typeof e.Type === 'object') return projectEntry(e.Type) + if (e.Type === 'literal') return { const: e.Value } + if (e.Type === 'group' && e.Value) return e.Value in PRELUDE ? { primitive: PRELUDE[e.Value] } : { ref: e.Value } + if (e.Type === 'group' && Array.isArray(e.Properties)) { + // An inline group that only wraps anonymous ref(s) — e.g. a union arm + // `{ DateLocalValue }` — is that ref (or a union of them), not a record. + const refs = unionMemberRefs(e) + if (refs) return refs.length === 1 ? { ref: refs[0] } : { union: refs.map((r) => ({ ref: r })) } + return { + record: e.Properties.flat() + .filter((p) => p?.Name) + .map(projectField), + } + } + if (e.Type === 'array') return { list: projectRef(e.Values?.[0]?.Type) } + if (e.Type === 'map') return { map: projectRef(e.ValueType ?? e.Values?.[0]?.Type), extensible: true } + if (e.Type === 'range') { + const intRange = Number.isInteger(e.Value?.Min?.Value) && Number.isInteger(e.Value?.Max?.Value) + return { primitive: intRange ? 'integer' : 'number' } // e.g. js-uint (0..MAX) vs scale (0.1..2) + } + return { primitive: PRIMITIVES[e.Type] ?? 'unknown' } +} + +function projectField(prop) { + return { name: prop.Name, wire: prop.Name, required: (prop.Occurrence?.n ?? 1) >= 1, type: projectRef(prop.Type) } +} + +// A group whose members are all anonymous refs (a top-level `a // b // c` +// choice, e.g. session.ProxyConfiguration, or a single-member dispatch root +// like LogEvent) carries those refs, not named fields. Returns the ref names, +// or null if it is a normal record. +function unionMemberRefs(def) { + const flat = (def.Properties ?? []).flat() + if (flat.length < 1) return null + const refs = [] + for (const p of flat) { + if (!p || typeof p !== 'object' || p.Name) return null + const e = Array.isArray(p.Type) ? p.Type[0] : p.Type + if (!e || e.Type !== 'group' || !e.Value) return null + refs.push(e.Value) + } + return refs +} + +function projectType(def) { + if (def.Type === 'variable') { + const pt = def.PropertyType ?? [] + if (pt.length && pt.every(isLiteral)) return { kind: 'enum', values: pt.map((e) => e.Value) } + // A union of refs is a union even when some arms are inline groups wrapping a + // ref (e.g. script.LocalValue's date/regexp arms): projectRef resolves those to + // refs, so promote the all-ref result to a first-class union (it gets a selector) + // rather than leaving it an alias-to-union the bindings would have to re-detect. + const projected = projectRef(def.PropertyType) + if (projected.union?.every((m) => m.ref) && !projected.nullable) + return { kind: 'union', variants: projected.union.map((m) => m.ref) } + return { kind: 'alias', type: projected } + } + if (def.Type === 'group') { + const refs = unionMemberRefs(def) + if (refs) return refs.length === 1 ? { kind: 'alias', type: { ref: refs[0] } } : { kind: 'union', variants: refs } + return projectRecord(def) + } + // Top-level list/map (or any non-group, non-variable def) becomes an alias to + // its element type, so the element type is not lost (e.g. script.ListLocalValue). + return { kind: 'alias', type: projectEntry(def) } +} + +/** + * Project a CDDL group into a record. A property with `Occurrence.m === null` is + * an unbounded entry (`* key => value`), not a scalar field: `* text => any` marks + * the record extensible, `* text => T` becomes a typed map, and an unbounded group + * spread is folded in. Everything else is a normal field. + */ +function projectRecord(def) { + const record = { kind: 'record', fields: [] } + for (const prop of (def.Properties ?? []).flat()) { + if (!prop || typeof prop !== 'object') continue + // `m === null` is overloaded in this parser: a key-typed entry is a map + // (`* text => value`); an anonymous entry is a structural spread; everything + // else is just an optional field (the `?` quantifier). Only the first two + // are not real fields. + if (prop.Occurrence?.m === null && (!prop.Name || prop.Name in PRIMITIVES || prop.Name in PRELUDE)) { + if (prop.Name in PRIMITIVES || prop.Name in PRELUDE) { + const value = projectRef(prop.Type) + if (value.primitive === 'any') record.extensible = true + else record.map = value + } + continue + } + if (prop.Name) record.fields.push(projectField(prop)) + } + return record +} + +const typeRef = (name) => (name ? { ref: name } : null) + +// Map a command's method to the params type its normalized envelope record carries +// (skipping EmptyParams, which means no real params). This recovers params the +// model builder drops when a command declares an inline `params: { ... }` object +// instead of a named group ref (the normalizer hoists that object to a real type, +// but the model still records `params: null`). +function commandEnvelopeParams(types) { + const params = new Map() + for (const t of Object.values(types)) { + if (t.kind !== 'record') continue + const method = t.fields.find((f) => f.name === 'method' && f.type.const !== undefined)?.type.const + const ref = t.fields.find((f) => f.name === 'params')?.type.ref + if (method && ref && ref !== 'EmptyParams') params.set(method, ref) + } + return params +} + +// Resolve a union member to its leaf record names, following nested unions and +// single-ref aliases. Every BiDi union bottoms out in records, so this is total. +function unionLeaves(ref, types, seen = new Set()) { + if (seen.has(ref)) return [] + seen.add(ref) + const t = types[ref] + if (!t) return [] + if (t.kind === 'record') return [ref] + if (t.kind === 'union') return t.variants.flatMap((v) => unionLeaves(v, types, seen)) + if (t.kind === 'alias' && t.type?.ref) return unionLeaves(t.type.ref, types, seen) + return [] +} + +// The constant value a record pins on wire key `k`, as `{ value }` (a string or +// `null`), or `{ open: true }` when the field exists but is not constant (a base +// type acting as the catch-all, e.g. log.GenericLogEntry.type), or null when the +// key is absent. +function discriminatorValue(rec, k) { + const f = rec.fields.find((x) => x.name === k) + if (!f) return null + if (f.type.const !== undefined) return { value: f.type.const } + if (f.type.primitive === 'null') return { value: null } + return { open: true } +} + +// What an immediate union member contributes to a discriminator on `key`: +// { tagged: [{ value, ref }] } — it (or, for a sub-union, each of its leaves) +// pins a constant value on `key`; a clean tagged sub-union is flattened up. +// { default: ref } — it carries no `key` (e.g. RemoteReference inside LocalValue) +// or an open base type on `key` (e.g. log.GenericLogEntry): the catch-all. +// null — it neither tags cleanly nor defaults cleanly, so `key` is not a usable +// discriminator for this union. +function tagContribution(ref, key, types) { + const t = types[ref] + if (!t) return null + if (t.kind === 'alias' && t.type?.ref) return tagContribution(t.type.ref, key, types) + if (t.kind === 'record') { + const d = discriminatorValue(t, key) + if (!d || d.open) return { default: ref } + return { tagged: [{ value: d.value, ref }] } + } + if (t.kind === 'union') { + const leaves = unionLeaves(ref, types) + const ds = leaves.map((l) => discriminatorValue(types[l], key)) + if (ds.every((d) => d?.value !== undefined)) + return { tagged: leaves.map((l, i) => ({ value: ds[i].value, ref: l })) } + if (ds.every((d) => d === null)) return { default: ref } // a whole sub-union with no `key` at all + return null + } + return null +} + +/** + * Derive how a wire payload selects one variant of a union, so every binding runs + * the same dispatch instead of re-deriving it (and silently depending on emit + * order). Two shapes: + * { by, variants: [{ value, ref }], default? } — a discriminated union: look up + * payload[by] among `variants` (value is a string or null), else `default`. + * `default` may itself be a union (e.g. LocalValue's untyped RemoteReference + * arm), whose own selector finishes the dispatch. + * { ordered: [{ ref, requires }] } — a structural union with no shared + * discriminator: the first variant whose `requires` keys are all present wins. + * Order is the CDDL choice order (the spec's priority), made explicit here. + */ +function unionSelector(name, types) { + const variants = types[name].variants + const constKeys = new Set() + for (const leaf of variants.flatMap((v) => unionLeaves(v, types))) + for (const f of types[leaf].fields) + if (discriminatorValue(types[leaf], f.name)?.value !== undefined) constKeys.add(f.name) + + for (const key of constKeys) { + const contributions = variants.map((v) => tagContribution(v, key, types)) + if (contributions.some((c) => c === null)) continue // some member can't be placed on this key + const tagged = contributions.flatMap((c) => c.tagged ?? []) + const defaults = contributions.filter((c) => c.default).map((c) => c.default) + if (defaults.length > 1 || tagged.length === 0) continue // ambiguous catch-all, or nothing to tag + const values = tagged.map((e) => JSON.stringify(e.value)) + if (new Set(values).size !== values.length) continue // values collide — not a clean tag + const selector = { by: key, variants: tagged } + if (defaults.length === 1) selector.default = defaults[0] + return selector + } + + // No shared discriminator: dispatch by required-field presence, in spec order. + // Resolve each variant through aliases/sub-unions to its leaf records (as the + // discriminator path does) and require the fields required in every leaf, so an + // alias-to-record variant is not left with an empty (always-matching) predicate. + const requiresOf = (ref) => { + const leaves = unionLeaves(ref, types).map( + (l) => new Set(types[l].fields.filter((f) => f.required).map((f) => f.name)), + ) + return leaves.length ? [...leaves[0]].filter((k) => leaves.every((s) => s.has(k))) : [] + } + return { ordered: variants.map((ref) => ({ ref, requires: requiresOf(ref) })) } +} + +// A structural selector can dispatch a payload only when every arm has a required +// field to test AND no arm's `requires` is a subset of a later arm's (which would +// shadow it under first-match) — the same validity checkSelector enforces, used +// here so the correlated walk treats an undispatchable union as a result grouping. +function orderedIsDispatchable(ordered) { + for (let i = 0; i < ordered.length; i++) { + if (ordered[i].requires.length === 0) return false + for (let j = i + 1; j < ordered.length; j++) + if (ordered[j].requires.length && ordered[i].requires.every((k) => ordered[j].requires.includes(k))) return false + } + return true +} + +// The command-response envelope is the record that pairs a `result` union with the +// request `id` that correlates it — that id is what makes its result request- +// dispatched rather than payload-dispatched. Returns the result union's name, or +// null. The `id` requirement is what excludes a plain payload type that merely has +// a `result` field (e.g. script.EvaluateResultSuccess, which has no request id). +function envelopeResultUnion(record, types) { + if (record?.kind !== 'record' || !record.fields.some((f) => f.name === 'id' && f.required)) return null + const result = record.fields.find( + (f) => f.name === 'result' && f.required && f.type.ref && types[f.type.ref]?.kind === 'union', + ) + return result ? result.type.ref : null +} + +// The command-result hierarchy is dispatched by request id, not by inspecting the +// payload (a response is matched to the command that produced it), so those unions +// must not carry a payload selector. They can't be found from the model alone — +// void commands record `result: null`, erasing whole result unions (e.g. every +// emulation result) — so identify them structurally from the response envelope's +// `result` union (envelopeResultUnion), then walk the variant tree, marking each +// union that has no payload discriminator. The discriminator guard stops the walk +// at a result that IS payload-dispatched (e.g. script.EvaluateResult on `type`), +// leaving its selector intact. Requires provisional selectors to already be set. +function correlatedUnions(types) { + const roots = new Set() + for (const t of Object.values(types)) { + const root = envelopeResultUnion(t, types) + if (root) roots.add(root) + } + // A union is payload-dispatched — and so must keep its selector — when it has a + // discriminator OR a structural selector that can actually distinguish its arms + // (matching the validity the gate enforces). The result groupings fail this + // (their arms share/lack distinguishing fields), so the walk passes through them. + const payloadDispatched = (sel) => Boolean(sel?.by || (sel?.ordered && orderedIsDispatchable(sel.ordered))) + const correlated = new Set() + const mark = (name) => { + const t = types[name] + if (!t || t.kind !== 'union' || correlated.has(name) || payloadDispatched(t.selector)) return + correlated.add(name) + t.variants.forEach(mark) + } + roots.forEach(mark) + return correlated +} + +/** + * Build the flat, binding-neutral schema from the raw AST and command/event model. + * @param {object[]} ast The parsed CDDL AST (array of definition nodes). + * @param {object} model The binding-neutral command/event model (per-domain). + * @returns {{schemaVersion: number, commands: object[], events: object[], types: object}} The schema. + */ +export function projectSchema(ast, model) { + const types = {} + for (const def of normalizeAst(ast)) { + if (!def?.Name) continue + const node = projectType(def) + // Types the normalizer minted for anonymous CDDL constructs (hoisted enums / + // inline records, union arms) carry their decomposition so a binding can name + // or nest them idiomatically without parsing the synthetic key. `owner` is the + // type the construct was lifted out of; `label` is the member name within it. + if (def['x-selenium-synthetic']) { + node.synthetic = true + node.owner = def['x-selenium-owner'] + node.label = def['x-selenium-label'] + } + types[def.Name] = node + } + for (const [name, node] of Object.entries(types)) + if (node.kind === 'union') node.selector = unionSelector(name, types) + // Override the result-grouping unions: they are dispatched by request id, so a + // payload selector for them is meaningless (and would be empty/ambiguous). + for (const name of correlatedUnions(types)) types[name].selector = { correlated: true } + + const commands = [] + const events = [] + const envelopeParams = commandEnvelopeParams(types) + for (const [domain, entry] of Object.entries(model)) { + for (const c of entry.commands ?? []) + commands.push({ + domain, + method: c.method, + name: c.name, + // Prefer the envelope's params (it captures inline params the model drops). + params: typeRef(envelopeParams.get(c.method) ?? c.params), + result: typeRef(c.result), + }) + for (const e of entry.events ?? []) + events.push({ domain, method: e.method, name: e.name, params: typeRef(envelopeParams.get(e.method) ?? e.params) }) + } + + return { schemaVersion: 1, commands, events, types } +} + +/** + * Fail-closed validation: every type reference resolves, and no type projects to + * `unknown` (which would mean an unhandled CDDL form) — across command/event + * params and results, record fields, record maps, union variants, and aliases. + * @param {object} schema The projected schema (`{commands, events, types}`). + * @returns {string[]} One message per problem; empty when valid. + */ +export function checkSchema(schema) { + const errors = [] + const has = (name) => Object.hasOwn(schema.types, name) + const refsIn = (node) => + !node + ? [] + : node.ref + ? [node.ref] + : node.list + ? refsIn(node.list) + : node.map + ? refsIn(node.map) + : node.union + ? node.union.flatMap(refsIn) + : node.record + ? node.record.flatMap((f) => refsIn(f.type)) + : [] + const hasUnknown = (node) => + !node + ? false + : node.primitive === 'unknown' + ? true + : node.list + ? hasUnknown(node.list) + : node.map + ? hasUnknown(node.map) + : node.union + ? node.union.some(hasUnknown) + : node.record + ? node.record.some((f) => hasUnknown(f.type)) + : false + const hasEmptyInlineRecord = (node) => + !node + ? false + : Array.isArray(node.record) + ? node.record.length === 0 || node.record.some((f) => hasEmptyInlineRecord(f.type)) + : node.list + ? hasEmptyInlineRecord(node.list) + : node.map + ? hasEmptyInlineRecord(node.map) + : node.union + ? node.union.some(hasEmptyInlineRecord) + : false + const report = (where, node) => { + for (const r of refsIn(node)) if (!has(r)) errors.push(`${where}: unresolved type ${r}`) + if (hasUnknown(node)) errors.push(`${where}: projected to an unknown primitive (unhandled CDDL type)`) + if (hasEmptyInlineRecord(node)) errors.push(`${where}: projected an empty inline record (dropped type reference)`) + } + + for (const c of [...schema.commands, ...schema.events]) { + report(c.method, c.params) + report(c.method, c.result ?? null) + } + // A command/event whose envelope record carries real params must surface them — + // guards the model builder's gap where an inline `params: {...}` (vs a named ref) + // was dropped, leaving it parameterless while its type still required them. + const envelopeParams = commandEnvelopeParams(schema.types) + for (const c of [...schema.commands, ...schema.events]) { + const expected = envelopeParams.get(c.method) + if (expected && c.params?.ref !== expected) + errors.push(`${c.method}: params ${c.params?.ref ?? 'null'} does not match required envelope params ${expected}`) + } + for (const [name, node] of Object.entries(schema.types)) { + if (node.synthetic && !has(node.owner)) errors.push(`${name}: synthetic owner ${node.owner} does not resolve`) + if (node.kind === 'record') { + for (const f of node.fields) report(`${name}.${f.name}`, f.type) + if (node.map) report(`${name}.*`, node.map) + } else if (node.kind === 'union') { + for (const v of node.variants) if (!has(v)) errors.push(`${name}: unresolved variant ${v}`) + errors.push(...checkSelector(name, node.selector, has)) + } else if (node.kind === 'alias') { + report(name, node.type) + } + } + + // A `correlated` union is resolved by request id, which only holds at the command + // response envelope's `result` position. If one is reachable anywhere else — a + // non-`result` field, a `result` field on a record that is not the envelope (no + // request id), a map/list/nested element, an alias, or a variant of a + // non-correlated union — it would actually need payload dispatch, and marking it + // correlated silently drops its selector. Fail closed so a misclassification (or + // a too-broad envelope match) cannot ship. + const correlated = new Set( + Object.entries(schema.types) + .filter(([, t]) => t.kind === 'union' && t.selector?.correlated) + .map(([n]) => n), + ) + const leak = (where, r) => + errors.push(`${where}: correlated union ${r} is reachable as a value (needs a payload selector)`) + for (const [name, node] of Object.entries(schema.types)) { + if (node.kind === 'record') { + const envelopeRoot = envelopeResultUnion(node, schema.types) + for (const f of node.fields) + for (const r of refsIn(f.type)) + if (correlated.has(r) && !(f.name === 'result' && f.type.ref === r && r === envelopeRoot)) + leak(`${name}.${f.name}`, r) + if (node.map) for (const r of refsIn(node.map)) if (correlated.has(r)) leak(`${name}.*`, r) + } else if (node.kind === 'union' && !node.selector?.correlated) { + for (const v of node.variants) if (correlated.has(v)) leak(name, v) + } else if (node.kind === 'alias') { + for (const r of refsIn(node.type)) if (correlated.has(r)) leak(name, r) + } + } + return errors +} + +// Validate a union's selector: every referenced variant resolves, a discriminated +// selector has distinct values and at most one default, a structural selector +// dispatches on something. Keeps a malformed selector from shipping silently. +function checkSelector(name, selector, has) { + const errors = [] + if (!selector) return [`${name}: union has no selector`] + if (selector.correlated) return [] // resolved by request id, not the payload — nothing to dispatch + if (selector.by) { + const values = selector.variants.map((v) => JSON.stringify(v.value)) + if (new Set(values).size !== values.length) errors.push(`${name}: selector has duplicate discriminator values`) + for (const v of selector.variants) + if (!has(v.ref)) errors.push(`${name}: selector variant ${v.ref} does not resolve`) + if (selector.default && !has(selector.default)) + errors.push(`${name}: selector default ${selector.default} does not resolve`) + } else if (selector.ordered) { + // A structural selector must actually dispatch from the payload: every arm needs + // a distinguishing required field, and no arm's `requires` may be a subset of a + // later arm's — that would shadow the later arm under first-match. A union that + // cannot satisfy this is not payload-dispatchable and must be `correlated`. + selector.ordered.forEach((v, i) => { + if (!has(v.ref)) errors.push(`${name}: selector variant ${v.ref} does not resolve`) + if (!v.requires.length) + errors.push(`${name}: structural selector arm ${v.ref} has no required fields to dispatch on`) + for (let j = i + 1; j < selector.ordered.length; j++) { + const w = selector.ordered[j] + if (v.requires.length && w.requires.length && v.requires.every((k) => w.requires.includes(k))) + errors.push(`${name}: structural selector arm ${v.ref} shadows ${w.ref} (requires is a subset)`) + } + }) + } else { + errors.push(`${name}: selector is neither discriminated, structural, nor correlated`) + } + return errors +} + +// ============================================================ +// CLI: raw ast + model → flat schema (validated) +// node project_bidi_schema.mjs --ast --model --dump-schema +// ============================================================ + +async function main() { + const { parseArgs } = await import('node:util') + const { readFileSync, writeFileSync } = await import('node:fs') + const { resolve } = await import('node:path') + + // Under Bazel the js_binary wrapper chdir's to BAZEL_BINDIR, but $(location) + // inputs are execroot-relative and already carry that prefix — strip it so the + // path is not doubled. Mirrors resolveInputPath() in generate_bidi.mjs. + const resolveInput = (p) => { + if (!process.env.BAZEL_BINDIR) return resolve(p) + const prefix = process.env.BAZEL_BINDIR.replaceAll('\\', '/') + '/' + const norm = p.replaceAll('\\', '/') + return resolve(norm.startsWith(prefix) ? norm.slice(prefix.length) : norm) + } + + const { values: args } = parseArgs({ + options: { ast: { type: 'string' }, model: { type: 'string' }, 'dump-schema': { type: 'string' } }, + }) + if (!args.ast || !args.model || !args['dump-schema']) { + console.error('Usage: project_bidi_schema.mjs --ast --model --dump-schema ') + process.exit(1) + } + + const ast = JSON.parse(readFileSync(resolveInput(args.ast), 'utf8')) + const model = JSON.parse(readFileSync(resolveInput(args.model), 'utf8')) + const schema = projectSchema(ast, model) + + // Generation is the gate: a broken or incomplete schema fails the build. + const errors = [...checkSchema(schema), ...checkCompleteness(ast, schema)] + if (errors.length) { + console.error('BiDi schema validation failed:') + errors.forEach((e) => console.error(` ${e}`)) + process.exit(1) + } + + writeFileSync(resolve(args['dump-schema']), JSON.stringify(schema, null, 2) + '\n', 'utf8') + console.log( + ` ${schema.commands.length} commands, ${schema.events.length} events, ${Object.keys(schema.types).length} types → ${args['dump-schema']}`, + ) +} + +// Run main() when invoked as the entry module. Uses an argv comparison rather +// than `import.meta.main`, which is only available on newer Node versions. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} + +/** + * Independent completeness check: re-derive every command/event method straight + * from the raw AST (a leaf def carries a literal `method` property) and assert it + * survived into the schema. This compares input to output without trusting the + * generator, so a dropped command/event fails the build even if generation and + * its own checkSchema agree. Run as a Bazel test over committed fixtures. + * @param {object[]} rawAst The parsed CDDL AST (pre-normalization). + * @param {object} schema The projected schema to check against. + * @returns {string[]} One message per dropped or stale-allowlisted method; empty when complete. + */ +export function checkCompleteness(rawAst, schema) { + const emitted = new Set([...schema.commands, ...schema.events].map((c) => c.method)) + const errors = [] + for (const def of rawAst) { + const methodProp = (def.Properties ?? []).flat().find((p) => p?.Name === 'method') + const literal = methodProp && (Array.isArray(methodProp.Type) ? methodProp.Type[0] : methodProp.Type) + if (literal?.Type !== 'literal') continue + if (!emitted.has(literal.Value) && !KNOWN_INCOMPLETE.has(literal.Value)) + errors.push(`dropped from schema: ${literal.Value}`) + } + // Self-cleaning: if a known-incomplete method is now emitted, the entry is + // stale and must be removed — so the allowlist cannot silently rot. + for (const known of KNOWN_INCOMPLETE) { + if (emitted.has(known)) errors.push(`stale KNOWN_INCOMPLETE entry (now emitted, remove it): ${known}`) + } + return errors +} diff --git a/javascript/selenium-webdriver/project_bidi_schema_test.mjs b/javascript/selenium-webdriver/project_bidi_schema_test.mjs new file mode 100644 index 0000000000000..7c0c45e7d02ec --- /dev/null +++ b/javascript/selenium-webdriver/project_bidi_schema_test.mjs @@ -0,0 +1,599 @@ +// 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. + +// Unit tests for the schema projector + validators. +// Mocha test; `describe`/`it` are mocha globals (run via the Bazel mocha target). +// The completeness test is the "compare input to output independent of +// generation" gate — it re-derives expected methods from the raw AST, not the model. +import assert from 'node:assert/strict' +import { projectSchema, checkSchema, checkCompleteness } from './project_bidi_schema.mjs' + +const lit = (v) => ({ Type: 'literal', Value: v, Unwrapped: false }) +const ref = (v) => ({ Type: 'group', Value: v, Unwrapped: false }) +const field = (name, type, occ = { n: 1, m: 1 }) => ({ Name: name, Occurrence: occ, Type: type, Comments: [] }) +const group = (name, props) => ({ Type: 'group', Name: name, Properties: props, IsChoiceAddition: false, Comments: [] }) +const leaf = (cddlName, method, paramsRef) => + group(cddlName, [field('method', [lit(method)]), field('params', [ref(paramsRef)])]) + +// A tiny but representative AST + model. +const AST = [ + leaf('network.SetCacheBehavior', 'network.setCacheBehavior', 'network.SetCacheBehaviorParameters'), + group('network.SetCacheBehaviorParameters', [field('cacheBehavior', [lit('default'), lit('bypass')])]), + group('session.Caps', [field('extra', { Type: 'group', Name: '', Properties: [field('webSocketUrl', ['bool'])] })]), + group('x.OpenMap', [field('text', ['any'], { n: 0, m: null })]), +] +const MODEL = { + network: { + commands: [ + { + method: 'network.setCacheBehavior', + name: 'setCacheBehavior', + params: 'network.SetCacheBehaviorParameters', + result: null, + }, + ], + events: [], + }, +} + +describe('projectSchema', () => { + const schema = projectSchema(AST, MODEL) + + it('emits a clean enum for an inline string-literal union, tagged with its origin', () => { + assert.deepEqual(schema.types['network.SetCacheBehaviorParametersCacheBehavior'], { + kind: 'enum', + values: ['default', 'bypass'], + synthetic: true, + owner: 'network.SetCacheBehaviorParameters', + label: 'CacheBehavior', + }) + assert.deepEqual(schema.types['network.SetCacheBehaviorParameters'].fields[0].type, { + ref: 'network.SetCacheBehaviorParametersCacheBehavior', + }) + }) + + it('hoists an inline record so the field is a plain ref (no inline records), tagged with its origin', () => { + assert.deepEqual(schema.types['session.Caps'].fields[0].type, { ref: 'session.CapsExtra' }) + const extra = schema.types['session.CapsExtra'] + assert.ok(extra, 'inline record was hoisted to a named type') + assert.equal(extra.synthetic, true) + assert.equal(extra.owner, 'session.Caps') + assert.equal(extra.label, 'Extra') + }) + + it('marks `* text => any` extensible instead of emitting a phantom field', () => { + const open = schema.types['x.OpenMap'] + assert.equal(open.extensible, true) + assert.equal(open.fields.length, 0) + }) + + it('passes both validators on a well-formed schema', () => { + assert.deepEqual(checkSchema(schema), []) + assert.deepEqual(checkCompleteness(AST, schema), []) + }) + + it('recovers command params from the envelope when the model dropped an inline params object', () => { + // The command declares an inline `params: { count }` (not a named ref); the model + // builder records params: null, but the normalizer hoists it to x.FooCommandParams + // and the envelope record points at it — so the command entry must surface it. + const ast = [ + group('x.FooCommand', [ + field('method', [lit('x.foo')]), + field('params', { Type: 'group', Name: '', Properties: [field('count', ['uint'])] }), + ]), + ] + const model = { x: { commands: [{ method: 'x.foo', name: 'foo', params: null, result: null }], events: [] } } + const out = projectSchema(ast, model) + assert.deepEqual(out.commands[0].params, { ref: 'x.FooCommandParams' }) + assert.deepEqual(checkSchema(out), []) + }) +}) + +describe('projectType (list / union / alias defs)', () => { + const anon = (v) => ({ Name: '', Occurrence: { n: 1, m: 1 }, Type: ref(v), Comments: [] }) + const ast = [ + { + Type: 'array', + Name: 'x.Items', + Values: [{ Name: '', Occurrence: { n: 0, m: null }, Type: [ref('x.Item')], Comments: [] }], + }, + group('x.Item', [field('a', ['text'])]), + group('x.Other', [field('b', ['text'])]), + { + Type: 'group', + Name: 'x.Choice', + Properties: [[anon('x.Item'), anon('x.Other')]], + IsChoiceAddition: false, + Comments: [], + }, + { Type: 'group', Name: 'x.FooEvent', Properties: [anon('x.Item')], IsChoiceAddition: false, Comments: [] }, + ] + const schema = projectSchema(ast, {}) + + it('projects a top-level array def as an alias to a list (keeps the element type)', () => { + assert.deepEqual(schema.types['x.Items'], { kind: 'alias', type: { list: { ref: 'x.Item' } } }) + }) + it('fails closed (unknown, not null) when an array element type is missing', () => { + const s = projectSchema([{ Type: 'array', Name: 'x.Bad', Values: [] }], {}) + assert.deepEqual(s.types['x.Bad'], { kind: 'alias', type: { list: { primitive: 'unknown' } } }) + assert.ok( + checkSchema(s).some((e) => /unknown primitive/.test(e)), + 'a missing element type must trip the unknown-primitive guard', + ) + }) + it('projects a multi-member choice group as a union with a structural selector', () => { + assert.deepEqual(schema.types['x.Choice'], { + kind: 'union', + variants: ['x.Item', 'x.Other'], + selector: { + ordered: [ + { ref: 'x.Item', requires: ['a'] }, + { ref: 'x.Other', requires: ['b'] }, + ], + }, + }) + }) + it('projects a single-member dispatch choice group as an alias to its ref', () => { + assert.deepEqual(schema.types['x.FooEvent'], { kind: 'alias', type: { ref: 'x.Item' } }) + }) + + it('projects an integer range as integer and a float range as number', () => { + const s = projectSchema( + [ + { + Type: 'variable', + Name: 'x.U', + PropertyType: [{ Type: 'range', Value: { Min: { Value: 0 }, Max: { Value: 100 } } }], + }, + { + Type: 'variable', + Name: 'x.F', + PropertyType: [{ Type: 'range', Value: { Min: { Value: 0.1 }, Max: { Value: 2 } } }], + }, + ], + {}, + ) + assert.deepEqual(s.types['x.U'], { kind: 'alias', type: { primitive: 'integer' } }) + assert.deepEqual(s.types['x.F'], { kind: 'alias', type: { primitive: 'number' } }) + }) + + it('unwraps a control-operator (.default / .ge) wrapped field type to its inner type', () => { + const wrapped = { + Name: 'n', + Occurrence: { n: 1, m: 1 }, + Type: [{ Type: ref('x.Inner'), Operator: { Type: 'default', Value: lit('a') } }], + Comments: [], + } + const s = projectSchema([group('x.R', [wrapped]), group('x.Inner', [field('z', ['text'])])], {}) + assert.deepEqual(s.types['x.R'].fields[0].type, { ref: 'x.Inner' }) + }) + + it('promotes a union whose arms are inline groups wrapping refs (LocalValue date/regexp arms)', () => { + const inlineArm = { + Type: 'group', + Name: '', + Properties: [{ Name: '', Occurrence: { n: 1, m: 1 }, Type: [ref('x.B')], Comments: [] }], + } + const s = projectSchema( + [ + { Type: 'variable', Name: 'x.U', PropertyType: [ref('x.A'), inlineArm] }, + group('x.A', [field('a', ['text'])]), + group('x.B', [field('b', ['text'])]), + ], + {}, + ) + // The inline-group arm resolves to x.B, so x.U is a first-class union (not an + // alias-to-union) and both arms are reachable variants. + assert.deepEqual(s.types['x.U'].kind, 'union') + assert.deepEqual(s.types['x.U'].variants, ['x.A', 'x.B']) + }) +}) + +describe('unionSelector', () => { + const recAst = (name, typeConst, extra = []) => ({ + Type: 'group', + Name: name, + IsChoiceAddition: false, + Comments: [], + Properties: [{ Name: 'type', Occurrence: { n: 1, m: 1 }, Type: [lit(typeConst)], Comments: [] }, ...extra], + }) + const union = (name, refs) => ({ + Type: 'variable', + Name: name, + IsChoiceAddition: false, + Comments: [], + PropertyType: refs.map(ref), + }) + + it('discriminates on a constant key, flattening a tagged sub-union and recording an untyped default', () => { + // x.Value = ( x.Prim / x.Date / x.Reference ); x.Prim is itself type-tagged, + // x.Reference has no `type` (dispatched structurally) → it is the default. + const ast = [ + union('x.Value', ['x.Prim', 'x.Date', 'x.Reference']), + union('x.Prim', ['x.StringValue', 'x.NullValue']), + recAst('x.StringValue', 'string'), + { ...recAst('x.NullValue', null), Properties: [field('type', ['null'])] }, + recAst('x.Date', 'date'), + group('x.Reference', [field('refId', ['text'])]), + ] + const s = projectSchema(ast, {}) + const sel = s.types['x.Value'].selector + assert.equal(sel.by, 'type') + assert.equal(sel.default, 'x.Reference') + // A sole bareword `null` field is the quoted string tag "null", not the JSON + // null type, so NullValue dispatches on the string "null" (not JSON null). + assert.deepEqual(s.types['x.NullValue'].fields[0].type, { const: 'null' }) + assert.deepEqual( + new Map(sel.variants.map((v) => [JSON.stringify(v.value), v.ref])), + new Map([ + ['"string"', 'x.StringValue'], + ['"null"', 'x.NullValue'], + ['"date"', 'x.Date'], + ]), + ) + }) + + it('uses an open base-type arm as the discriminator default (log.Entry shape)', () => { + const ast = [ + union('x.Entry', ['x.Generic', 'x.Console']), + group('x.Generic', [field('type', ['text'])]), // open `type` → catch-all + recAst('x.Console', 'console'), + ] + const sel = projectSchema(ast, {}).types['x.Entry'].selector + assert.equal(sel.by, 'type') + assert.equal(sel.default, 'x.Generic') + assert.deepEqual(sel.variants, [{ value: 'console', ref: 'x.Console' }]) + }) + + it('falls back to an ordered structural selector when no shared discriminator exists', () => { + const ast = [ + union('x.Ref', ['x.Shared', 'x.Remote']), + group('x.Shared', [ + field('sharedId', ['text']), + { Name: 'handle', Occurrence: { n: 0, m: 1 }, Type: ['text'], Comments: [] }, + ]), + group('x.Remote', [field('handle', ['text'])]), + ] + const sel = projectSchema(ast, {}).types['x.Ref'].selector + assert.deepEqual(sel, { + ordered: [ + { ref: 'x.Shared', requires: ['sharedId'] }, + { ref: 'x.Remote', requires: ['handle'] }, + ], + }) + }) + + // The response envelope is a record pairing a `result` union with the request `id`. + const envelope = (resultRef) => group('x.CommandResponse', [field('id', ['uint']), field('result', [ref(resultRef)])]) + + it('marks an undispatchable result-grouping union (reached via `result`) as correlated', () => { + // CommandResponse.result -> x.ResultData, a union of result records that cannot + // be told apart from the payload (no required fields): it is dispatched by + // request id, so it carries no selector. + const ast = [ + envelope('x.ResultData'), + union('x.ResultData', ['x.FooResult', 'x.BarResult']), + group('x.FooResult', []), + group('x.BarResult', []), + ] + assert.deepEqual(projectSchema(ast, {}).types['x.ResultData'].selector, { correlated: true }) + }) + + it('keeps a structurally-dispatchable result reached via `result` (not correlated)', () => { + // Distinguishable result records (each has its own required field) stay + // payload-dispatched even though they are reached through the envelope. + const ast = [ + envelope('x.ResultData'), + union('x.ResultData', ['x.FooResult', 'x.BarResult']), + group('x.FooResult', [field('foo', ['text'])]), + group('x.BarResult', [field('bar', ['text'])]), + ] + assert.deepEqual(projectSchema(ast, {}).types['x.ResultData'].selector, { + ordered: [ + { ref: 'x.FooResult', requires: ['foo'] }, + { ref: 'x.BarResult', requires: ['bar'] }, + ], + }) + }) + + it('does not mark a discriminated result reached via `result` as correlated (e.g. EvaluateResult)', () => { + const ast = [ + envelope('x.EvalResult'), + union('x.EvalResult', ['x.Success', 'x.Failure']), + recAst('x.Success', 'success'), + recAst('x.Failure', 'exception'), + ] + const sel = projectSchema(ast, {}).types['x.EvalResult'].selector + assert.equal(sel.by, 'type') // payload-dispatched, selector preserved + }) + + it('does not correlate a union reached via a non-envelope `result` field (no request id)', () => { + // A plain payload type has a `result` field but no request `id`, so it is not the + // response envelope; its union must keep its payload selector, not be correlated. + const ast = [ + group('x.EvaluateResultSuccess', [field('result', [ref('x.ResultData')]), field('realm', ['text'])]), + union('x.ResultData', ['x.FooResult', 'x.BarResult']), + group('x.FooResult', []), + group('x.BarResult', []), + ] + const sel = projectSchema(ast, {}).types['x.ResultData'].selector + assert.ok(sel.ordered, 'a non-envelope `result` field must not request-correlate its union') + assert.equal(sel.correlated, undefined) + }) + + it('does not treat a record with an optional id/result as the response envelope', () => { + // The real envelope has a REQUIRED request id and result; an optional id means + // this is not the response envelope, so its union must keep its payload selector. + const optional = { n: 0, m: 1 } + const ast = [ + group('x.CommandResponse', [field('id', ['uint'], optional), field('result', [ref('x.ResultData')])]), + union('x.ResultData', ['x.FooResult', 'x.BarResult']), + group('x.FooResult', []), + group('x.BarResult', []), + ] + const sel = projectSchema(ast, {}).types['x.ResultData'].selector + assert.equal(sel.correlated, undefined) + assert.ok(sel.ordered) + }) + + it('resolves an alias variant to its leaf record when building structural requires', () => { + // x.Alias is a single-ref alias to x.A; its ordered `requires` must reflect + // x.A's required fields, not be left empty (which would always match). + const ast = [ + union('x.U', ['x.Alias', 'x.B']), + { Type: 'variable', Name: 'x.Alias', IsChoiceAddition: false, Comments: [], PropertyType: [ref('x.A')] }, + group('x.A', [field('a', ['text'])]), + group('x.B', [field('b', ['text'])]), + ] + assert.deepEqual(projectSchema(ast, {}).types['x.U'].selector, { + ordered: [ + { ref: 'x.Alias', requires: ['a'] }, + { ref: 'x.B', requires: ['b'] }, + ], + }) + }) +}) + +describe('checkCompleteness (input vs output, generator-independent)', () => { + it('fails when a command/event present in the AST is missing from the schema', () => { + const astWithExtra = [ + ...AST, + leaf('network.DroppedCmd', 'network.droppedCmd', 'network.SetCacheBehaviorParameters'), + ] + const schema = projectSchema(AST, MODEL) // model does NOT know about droppedCmd + const errors = checkCompleteness(astWithExtra, schema) + assert.deepEqual(errors, ['dropped from schema: network.droppedCmd']) + }) + + it('does not fail for a known-incomplete (allowlisted) drop', () => { + const astWithKnown = [ + ...AST, + leaf('bluetooth.X', 'bluetooth.characteristicEventGenerated', 'network.SetCacheBehaviorParameters'), + ] + assert.deepEqual(checkCompleteness(astWithKnown, projectSchema(AST, MODEL)), []) + }) + + it('flags an allowlisted method as stale once it is emitted', () => { + const schema = projectSchema(AST, MODEL) + schema.events.push({ + domain: 'bluetooth', + method: 'bluetooth.characteristicEventGenerated', + name: 'characteristicEventGenerated', + params: null, + }) + assert.deepEqual(checkCompleteness(AST, schema), [ + 'stale KNOWN_INCOMPLETE entry (now emitted, remove it): bluetooth.characteristicEventGenerated', + ]) + }) +}) + +describe('checkSchema (referential integrity)', () => { + it('catches an unresolved ref nested inside a record field', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { + 'x.T': { kind: 'record', fields: [{ name: 'a', wire: 'a', required: true, type: { ref: 'x.Missing' } }] }, + }, + } + assert.deepEqual(checkSchema(schema), ['x.T.a: unresolved type x.Missing']) + }) + + it('catches an unresolved ref inside an alias', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { 'x.A': { kind: 'alias', type: { ref: 'x.Missing' } } }, + } + assert.deepEqual(checkSchema(schema), ['x.A: unresolved type x.Missing']) + }) + + it('catches an unresolved ref inside a record map', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { 'x.T': { kind: 'record', fields: [], map: { ref: 'x.Missing' } } }, + } + assert.deepEqual(checkSchema(schema), ['x.T.*: unresolved type x.Missing']) + }) + + it('flags a field that projected to an unknown primitive (unhandled CDDL type)', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { + 'x.T': { kind: 'record', fields: [{ name: 'a', wire: 'a', required: true, type: { primitive: 'unknown' } }] }, + }, + } + assert.deepEqual(checkSchema(schema), ['x.T.a: projected to an unknown primitive (unhandled CDDL type)']) + }) + + it('flags a synthetic type whose owner does not resolve', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { + 'x.E': { kind: 'enum', values: ['a', 'b'], synthetic: true, owner: 'x.Gone', label: 'E' }, + }, + } + assert.deepEqual(checkSchema(schema), ['x.E: synthetic owner x.Gone does not resolve']) + }) + + it('flags an empty inline record in a union arm (dropped type reference)', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { + 'x.U': { kind: 'alias', type: { union: [{ ref: 'x.A' }, { record: [] }] } }, + 'x.A': { kind: 'record', fields: [] }, // a legitimately-empty top-level record is NOT flagged + }, + } + assert.deepEqual(checkSchema(schema), ['x.U: projected an empty inline record (dropped type reference)']) + }) + + it('rejects a structural selector whose arm shadows a later arm (subset requires)', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { + 'x.U': { + kind: 'union', + variants: ['x.A', 'x.B'], + selector: { + ordered: [ + { ref: 'x.A', requires: ['data'] }, + { ref: 'x.B', requires: ['data', 'more'] }, + ], + }, + }, + 'x.A': { kind: 'record', fields: [] }, + 'x.B': { kind: 'record', fields: [] }, + }, + } + assert.deepEqual(checkSchema(schema), ['x.U: structural selector arm x.A shadows x.B (requires is a subset)']) + }) + + it('rejects a structural selector arm with no required fields to dispatch on', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { + 'x.U': { + kind: 'union', + variants: ['x.A', 'x.B'], + selector: { + ordered: [ + { ref: 'x.A', requires: [] }, + { ref: 'x.B', requires: ['b'] }, + ], + }, + }, + 'x.A': { kind: 'record', fields: [] }, + 'x.B': { kind: 'record', fields: [] }, + }, + } + assert.deepEqual(checkSchema(schema), ['x.U: structural selector arm x.A has no required fields to dispatch on']) + }) + + it('accepts a correlated selector (resolved by request id, not the payload)', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { + 'x.ResultData': { kind: 'union', variants: ['x.A'], selector: { correlated: true } }, + 'x.A': { kind: 'record', fields: [] }, + }, + } + assert.deepEqual(checkSchema(schema), []) + }) + + it('exempts the envelope `result` position but flags any other correlated reference', () => { + const make = (fieldName, withId = true) => ({ + schemaVersion: 1, + commands: [], + events: [], + types: { + Envelope: { + kind: 'record', + fields: [ + ...(withId ? [{ name: 'id', wire: 'id', required: true, type: { primitive: 'integer' } }] : []), + { name: fieldName, wire: fieldName, required: true, type: { ref: 'x.ResultData' } }, + ], + }, + 'x.ResultData': { kind: 'union', variants: ['x.A'], selector: { correlated: true } }, + 'x.A': { kind: 'record', fields: [] }, + }, + }) + assert.deepEqual(checkSchema(make('result')), []) // the envelope's correlation point — allowed + assert.deepEqual(checkSchema(make('payload')), [ + 'Envelope.payload: correlated union x.ResultData is reachable as a value (needs a payload selector)', + ]) + // A `result` field on a record that is NOT the envelope (no request id) gets no + // free pass — otherwise a too-broad envelope match could ship silently. + assert.deepEqual(checkSchema(make('result', false)), [ + 'Envelope.result: correlated union x.ResultData is reachable as a value (needs a payload selector)', + ]) + }) + + it('flags a correlated union used as a variant of a non-correlated union', () => { + const schema = { + schemaVersion: 1, + commands: [], + events: [], + types: { + 'x.Value': { + kind: 'union', + variants: ['x.ResultData'], + selector: { ordered: [{ ref: 'x.ResultData', requires: ['k'] }] }, + }, + 'x.ResultData': { kind: 'union', variants: ['x.A'], selector: { correlated: true } }, + 'x.A': { kind: 'record', fields: [{ name: 'k', wire: 'k', required: true, type: { primitive: 'string' } }] }, + }, + } + assert.deepEqual(checkSchema(schema), [ + 'x.Value: correlated union x.ResultData is reachable as a value (needs a payload selector)', + ]) + }) + + it('flags a command that emits null params while its envelope requires them', () => { + const schema = { + schemaVersion: 1, + commands: [{ domain: 'x', method: 'x.foo', name: 'foo', params: null, result: null }], + events: [], + types: { + 'x.FooCommand': { + kind: 'record', + fields: [ + { name: 'method', wire: 'method', required: true, type: { const: 'x.foo' } }, + { name: 'params', wire: 'params', required: true, type: { ref: 'x.FooParams' } }, + ], + }, + 'x.FooParams': { kind: 'record', fields: [] }, + }, + } + assert.deepEqual(checkSchema(schema), ['x.foo: params null does not match required envelope params x.FooParams']) + }) +})