blob: f1c43a2dbe4fceb4a96b8c0c59dc1465cb9ffae2 [file]
// 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,
buildSpecHrefs,
buildSpecLinks,
} from './project_bidi_schema.mjs'
import { extractAnchors } from './extract_bidi_anchors.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'],
primitive: 'string',
synthetic: true,
owner: 'network.SetCacheBehaviorParameters',
label: 'CacheBehavior',
})
assert.deepEqual(schema.types['network.SetCacheBehaviorParameters'].fields[0].type, {
ref: 'network.SetCacheBehaviorParametersCacheBehavior',
})
})
it('tags a numeric enum with its value primitive, so a binding reads the type rather than deriving it', () => {
const ast = [group('x.T', [field('grid', [lit(0), lit(1)])])]
const [, def] = Object.entries(projectSchema(ast, {}).types).find(([, t]) => t.kind === 'enum')
assert.deepEqual(def.values, [0, 1])
assert.equal(def.primitive, 'integer')
})
it('camelCases a quoted wire key into an identifier, keeping the wire name exact', () => {
const ast = [group('x.T', [field('prefers-color-scheme', ['text'], { n: 0, m: 1 })])]
const [f] = projectSchema(ast, {}).types['x.T'].fields
assert.equal(f.name, 'prefersColorScheme')
assert.equal(f.wire, 'prefers-color-scheme')
})
it('leaves a wire key that is already an identifier verbatim (namespaceURI is not mangled)', () => {
const ast = [group('x.T', [field('namespaceURI', ['text'])])]
const [f] = projectSchema(ast, {}).types['x.T'].fields
assert.equal(f.name, 'namespaceURI')
assert.equal(f.wire, 'namespaceURI')
})
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('treats an unbounded occurrence as extensible whether m is null or Infinity', () => {
// The cddl parser emits the `*` upper bound as Infinity; only the AST's JSON
// round-trip renders it as null. Projecting an AST directly (no round-trip) must
// still recognize it, not emit a `text` field.
const ast = [group('x.RawOpenMap', [field('text', ['any'], { n: 0, m: Infinity })])]
const open = projectSchema(ast, {}).types['x.RawOpenMap']
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'] },
],
},
objectOnly: true, // both arms are records
inbound: false, // no model → reachable from no message root
outbound: false,
})
})
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 } } }],
},
{
// `(0.0..1.0)` — integral bounds, but the `IsFloat` marker makes it a number range.
Type: 'variable',
Name: 'x.W',
PropertyType: [
{ Type: 'range', Value: { Min: { Value: 0, IsFloat: true }, Max: { Value: 1, IsFloat: true } } },
],
},
],
{},
)
assert.deepEqual(s.types['x.U'], { kind: 'alias', type: { primitive: 'integer' } })
assert.deepEqual(s.types['x.F'], { kind: 'alias', type: { primitive: 'number' } })
assert.deepEqual(s.types['x.W'], { 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'),
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')
// NullValue's `type` is the string literal "null" (its discriminator tag), so it
// dispatches on the string "null" — not the JSON null type.
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('schema signals (objectOnly / extensible / enum primitive)', () => {
const rec = (name, typeConst) => group(name, [field('type', [lit(typeConst)])])
const union = (name, refs) => ({
Type: 'variable',
Name: name,
IsChoiceAddition: false,
Comments: [],
PropertyType: refs.map(ref),
})
const enumDef = (name, values) => ({
Type: 'variable',
Name: name,
IsChoiceAddition: false,
Comments: [],
PropertyType: values.map(lit),
})
it('flags a union whose every arm is an object type as objectOnly', () => {
const s = projectSchema([union('x.U', ['x.A', 'x.B']), rec('x.A', 'a'), rec('x.B', 'b')], {})
assert.equal(s.types['x.U'].objectOnly, true)
assert.deepEqual(checkSchema(s), [])
})
it('does not flag a first-class union with an enum (scalar) arm as objectOnly', () => {
const s = projectSchema([union('x.U', ['x.A', 'x.E']), rec('x.A', 'a'), enumDef('x.E', ['one', 'two'])], {})
assert.equal(s.types['x.E'].kind, 'enum')
assert.equal(s.types['x.U'].objectOnly, undefined)
})
it('does not flag a bare-scalar alias-union (input.Origin shape) as objectOnly', () => {
// "viewport" / "pointer" / ElementOrigin — the const arms keep it an alias-union
// that must still pass a bare-string payload through, so it stays unflagged.
const origin = {
Type: 'variable',
Name: 'x.Origin',
IsChoiceAddition: false,
Comments: [],
PropertyType: [lit('viewport'), lit('pointer'), ref('x.Element')],
}
const s = projectSchema([origin, group('x.Element', [field('type', [lit('element')]), field('id', ['text'])])], {})
assert.equal(s.types['x.Origin'].kind, 'alias')
assert.equal(s.types['x.Origin'].objectOnly, undefined)
// The const arms' literals are pinned so a binding can reject a wrong string, not just a wrong primitive.
assert.equal(s.types['x.Origin'].type.scalar, 'string')
assert.deepEqual(s.types['x.Origin'].type.scalarValues, ['viewport', 'pointer'])
})
it('marks every extensible type extensible, regardless of send/receive reachability', () => {
// Extensibility is the whole signal: a type reachable only through a command's result
// keeps its extras store just as one reachable through params does. Send-reachability
// ("retain extras only where they can be sent back") is deliberately not a factor.
const ast = [
group('x.SetParams', [field('cfg', [ref('x.Config')])]),
group('x.Config', [field('text', ['any'], { n: 0, m: null })]),
group('x.GetResult', [field('info', [ref('x.Info')])]),
group('x.Info', [field('text', ['any'], { n: 0, m: null })]),
]
const model = { x: { commands: [{ method: 'x.set', name: 'set', params: 'x.SetParams', result: 'x.GetResult' }] } }
const s = projectSchema(ast, model)
assert.equal(s.types['x.Config'].extensible, true) // reachable through the command's params
assert.equal(s.types['x.Info'].extensible, true) // reachable only through the result
assert.deepEqual(checkSchema(s), [])
})
it('hoists a nullable literal choice to a named enum, referenced with the null preserved', () => {
// A nullable literal choice (`("classic" / "overlay") / null`) is hoisted (normalize_bidi_ast)
// to a named enum and referenced with the null kept on the field — a nullable enum ref, not an
// inline enum carrying a primitive.
const s = projectSchema([group('x.R', [field('kind', [lit('classic'), lit('overlay'), 'null'])])], {})
assert.deepEqual(s.types['x.R'].fields[0].type, { ref: 'x.RKind', nullable: true })
assert.equal(s.types['x.RKind'].kind, 'enum')
assert.deepEqual(s.types['x.RKind'].values, ['classic', 'overlay'])
assert.deepEqual(checkSchema(s), [])
})
it('flags an inline union with a bare-scalar arm as scalar-tolerant (map key: Ref / text)', () => {
const ast = [
group('x.R', [field('entry', [ref('x.U'), 'text']), field('objects', [ref('x.A'), ref('x.B')])]),
union('x.U', ['x.A', 'x.B']),
rec('x.A', 'a'),
rec('x.B', 'b'),
]
const s = projectSchema(ast, {}).types['x.R'].fields
// The `Ref / text` arm makes the union scalar-tolerant, carrying the arm's primitive...
assert.deepEqual(s[0].type, { union: [{ ref: 'x.U' }, { primitive: 'string' }], scalar: 'string' })
// ...but an all-object union is not flagged (no scalar arm to pass through).
assert.deepEqual(s[1].type, { union: [{ ref: 'x.A' }, { ref: 'x.B' }] })
})
})
describe('directionality (inbound / outbound per structured type)', () => {
const union = (name, refs) => ({
Type: 'variable',
Name: name,
IsChoiceAddition: false,
Comments: [],
PropertyType: refs.map(ref),
})
// A command (params x.DoParams → result x.DoResult) and an event (params x.HappenedParams)
// seed the walk. x.Both is referenced from both params and result; x.NoMessage from neither.
// x.LocalNode and x.RemoteNode are structural look-alikes (same `type: "node"`) reached
// only through params vs only through result, so they must land on opposite sides.
const ast = [
group('x.DoParams', [
field('cfg', [ref('x.OutOnly')]),
field('shared', [ref('x.Both')]),
field('lv', [ref('x.LocalValue')]),
]),
group('x.OutOnly', [field('a', ['text'])]),
group('x.Both', [field('b', ['text'])]),
group('x.DoResult', [
field('info', [ref('x.InOnly')]),
field('note', [ref('x.Both')]),
field('rv', [ref('x.RemoteValue')]),
]),
group('x.InOnly', [field('c', ['text'])]),
group('x.HappenedParams', [field('d', ['text'])]),
group('x.NoMessage', [field('e', ['text'])]),
union('x.LocalValue', ['x.LocalNode', 'x.LocalString']),
group('x.LocalNode', [field('type', [lit('node')]), field('v', ['text'])]),
group('x.LocalString', [field('type', [lit('string')]), field('v', ['text'])]),
union('x.RemoteValue', ['x.RemoteNode', 'x.RemoteString']),
group('x.RemoteNode', [field('type', [lit('node')]), field('v', ['text'])]),
group('x.RemoteString', [field('type', [lit('string')]), field('v', ['text'])]),
]
const model = {
x: {
commands: [{ method: 'x.doThing', name: 'doThing', params: 'x.DoParams', result: 'x.DoResult' }],
events: [{ method: 'x.happened', name: 'happened', params: 'x.HappenedParams' }],
},
}
const schema = projectSchema(ast, model)
const dir = (n) => ({ inbound: schema.types[n].inbound, outbound: schema.types[n].outbound })
it('marks a params-only record outbound (send side)', () => {
assert.deepEqual(dir('x.OutOnly'), { inbound: false, outbound: true })
assert.deepEqual(dir('x.DoParams'), { inbound: false, outbound: true })
})
it('marks a result/event-only payload inbound (receive side)', () => {
assert.deepEqual(dir('x.InOnly'), { inbound: true, outbound: false })
assert.deepEqual(dir('x.DoResult'), { inbound: true, outbound: false })
assert.deepEqual(dir('x.HappenedParams'), { inbound: true, outbound: false })
})
it('marks a type reached from both params and result as both (Cookie-shaped)', () => {
assert.deepEqual(dir('x.Both'), { inbound: true, outbound: true })
})
it('leaves a type reachable from no message at (false, false)', () => {
assert.deepEqual(dir('x.NoMessage'), { inbound: false, outbound: false })
})
it('splits structural look-alikes by reachability, not by name (LocalValue vs RemoteValue variant)', () => {
assert.deepEqual(dir('x.LocalNode'), { inbound: false, outbound: true }) // reached via params
assert.deepEqual(dir('x.RemoteNode'), { inbound: true, outbound: false }) // reached via result
assert.deepEqual(dir('x.LocalValue'), { inbound: false, outbound: true })
assert.deepEqual(dir('x.RemoteValue'), { inbound: true, outbound: false })
})
it('passes both validators (flags present on every structured type, (false,false) not an error)', () => {
assert.deepEqual(checkSchema(schema), [])
assert.deepEqual(checkCompleteness(ast, schema), [])
})
it('fails completeness when a structured type is missing a directionality flag', () => {
const broken = projectSchema(ast, model)
delete broken.types['x.OutOnly'].outbound
assert.ok(
checkCompleteness(ast, broken).some((e) => /x\.OutOnly: missing directionality flag/.test(e)),
'a stripped flag must fail closed',
)
})
it('does not flag enums or aliases (only record/union carry directionality)', () => {
// An enum and an alias are leaves/pass-throughs, not constructed message parts.
const s = projectSchema(
[
{ Type: 'variable', Name: 'x.E', IsChoiceAddition: false, Comments: [], PropertyType: [lit('a'), lit('b')] },
{ Type: 'variable', Name: 'x.A', IsChoiceAddition: false, Comments: [], PropertyType: [ref('x.OutOnly')] },
group('x.OutOnly', [field('a', ['text'])]),
],
{},
)
assert.equal(s.types['x.E'].inbound, undefined)
assert.equal(s.types['x.A'].outbound, undefined)
assert.deepEqual(checkCompleteness([], s), [])
})
})
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'])
})
})
describe('specHref (spec-definition links from the webref dfns index)', () => {
// Shape mirrors a real webref ed/dfns/<spec>.json: { spec, dfns: [{ type, linkingText, href }] }.
const dfnsDoc = (url, entries) => ({
spec: { url },
dfns: entries.map(([type, name, href]) => ({ type, linkingText: [name], href })),
})
it('maps cddl-type entries by name, ignoring keys/values/other dfn types', () => {
const hrefs = buildSpecHrefs([
dfnsDoc('https://w3c.github.io/webdriver-bidi/', [
[
'cddl-type',
'session.CapabilityRequest',
'https://w3c.github.io/webdriver-bidi/#cddl-type-sessioncapabilityrequest',
],
['cddl-key', 'proxy', 'https://w3c.github.io/webdriver-bidi/#cddl-key-sessioncapabilityrequest-proxy'],
['cddl-value', 'default', 'https://w3c.github.io/webdriver-bidi/#cddl-value-networksamesite-default'],
['dfn', 'wait queue', 'https://w3c.github.io/webdriver-bidi/#wait-queue'],
]),
])
assert.deepEqual(hrefs, {
'session.CapabilityRequest': 'https://w3c.github.io/webdriver-bidi/#cddl-type-sessioncapabilityrequest',
})
})
it('merges multiple indexes and keeps absolute per-spec origins (first wins on clash)', () => {
const hrefs = buildSpecHrefs([
dfnsDoc('https://w3c.github.io/webdriver-bidi/', [['cddl-type', 'x.Shared', 'https://a.example/#x']]),
dfnsDoc('https://w3c.github.io/permissions/', [
['cddl-type', 'x.Shared', 'https://b.example/#x'], // clash: first index wins
['cddl-type', 'permissions.Foo', 'https://w3c.github.io/permissions/#cddl-type-permissionsfoo'],
]),
])
assert.equal(hrefs['x.Shared'], 'https://a.example/#x')
assert.equal(hrefs['permissions.Foo'], 'https://w3c.github.io/permissions/#cddl-type-permissionsfoo')
})
it('is empty and harmless on missing/malformed input', () => {
assert.deepEqual(buildSpecHrefs(), {})
assert.deepEqual(buildSpecHrefs([null, {}, { dfns: null }]), {})
})
})
describe('buildSpecLinks (merge CDDL fallback with prose anchors)', () => {
const dfnsDoc = (entries) => ({ dfns: entries.map(([t, name, href]) => ({ type: t, linkingText: [name], href })) })
it('lowercases CDDL keys and lets a prose section override the CDDL production', () => {
const dfns = [
dfnsDoc([
['cddl-type', 'network.Cookie', 'CDDL_COOKIE'],
['cddl-type', 'network.OtherType', 'CDDL_OTHER'],
]),
]
const links = buildSpecLinks(dfns, { types: { 'network.cookie': 'PROSE_COOKIE' } })
assert.equal(links.types['network.cookie'], 'PROSE_COOKIE') // prose wins
assert.equal(links.types['network.othertype'], 'CDDL_OTHER') // CDDL fallback, lowercased key
})
it('passes command/event/module anchor maps through to their buckets', () => {
const links = buildSpecLinks([], {
modules: { network: 'M' },
commands: { 'network.setcachebehavior': 'C' },
events: { 'log.entryadded': 'E' },
})
assert.equal(links.domains.network, 'M')
assert.equal(links.commands['network.setcachebehavior'], 'C')
assert.equal(links.events['log.entryadded'], 'E')
})
it('is harmless with no anchors (CDDL-only) or no inputs', () => {
assert.deepEqual(buildSpecLinks(), { types: {}, commands: {}, events: {}, domains: {} })
})
})
describe('extractAnchors (prose-anchor index from spec HTML)', () => {
const base = 'https://w3c.github.io/webdriver-bidi/'
const idx = extractAnchors(
[
'<h2 id="module-session">Session</h2>',
"<h3 id='command-session-subscribe'>subscribe</h3>", // single-quoted ids are matched too
'<h3 id="type-session-CapabilityRequest">CapabilityRequest</h3>',
'<h3 id="event-log-entryAdded">entryAdded</h3>',
'<h4 id="module-browser-commands">a sub-section, not a module</h4>',
'<h4 id="cddl-type-sessionstatus">a CDDL production, ignored</h4>',
].join('\n'),
base,
)
it('indexes module/type/command/event prose sections with lowercased dotted keys', () => {
assert.equal(idx.modules.session, `${base}#module-session`)
assert.equal(idx.commands['session.subscribe'], `${base}#command-session-subscribe`)
assert.equal(idx.types['session.capabilityrequest'], `${base}#type-session-CapabilityRequest`)
assert.equal(idx.events['log.entryadded'], `${base}#event-log-entryAdded`)
})
it('ignores module sub-sections and CDDL productions', () => {
assert.equal(idx.modules.browser, undefined) // module-browser-commands is a sub-section
assert.equal(
Object.values(idx.types).some((h) => h.includes('cddl-type')),
false,
)
})
})
describe('spec links attached to the schema (types, commands, events, domains)', () => {
const B = 'https://w3c.github.io/webdriver-bidi/'
const links = {
types: { 'network.setcachebehaviorparameters': `${B}#type-network-SetCacheBehaviorParameters` },
commands: { 'network.setcachebehavior': `${B}#command-network-setCacheBehavior` },
events: {},
domains: { network: `${B}#module-network` },
}
const schema = projectSchema(AST, MODEL, links)
it('links a type (case-insensitively) and a domain, omitting the unlinked', () => {
assert.equal(
schema.types['network.SetCacheBehaviorParameters'].specHref,
links.types['network.setcachebehaviorparameters'],
)
assert.equal(schema.domains.network.specHref, links.domains.network)
// A synthetic type (hoisted enum) has no spec definition → no specHref.
assert.equal(schema.types['network.SetCacheBehaviorParametersCacheBehavior'].specHref, undefined)
// A type/domain absent from the maps is untouched.
assert.equal(schema.types['session.Caps'].specHref, undefined)
})
it('links a command by its wire method, and omits an event with no anchor', () => {
assert.equal(schema.commands[0].specHref, links.commands['network.setcachebehavior'])
})
it('is fully optional: the two-arg call emits no specHref and an empty domains map', () => {
const bare = projectSchema(AST, MODEL)
for (const node of Object.values(bare.types)) assert.equal(node.specHref, undefined)
assert.equal(bare.commands[0].specHref, undefined)
assert.deepEqual(bare.domains, {})
assert.deepEqual(checkSchema(bare), [])
})
})
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('flags two wire keys projecting to one field name', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: {
'x.T': {
kind: 'record',
fields: [
{ name: 'colorGamut', wire: 'colorGamut', required: false, type: { primitive: 'string' } },
{ name: 'colorGamut', wire: 'color-gamut', required: false, type: { primitive: 'string' } },
],
},
},
}
assert.deepEqual(checkSchema(schema), [
'x.T: wire keys colorGamut and color-gamut both project to field name colorGamut',
])
})
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'])
})
})
describe('vendor section (extension groups and vendor types routed out of the shared schema)', () => {
const tagged = (name, type, via) => ({
...field(name, type, { n: 0, m: 1 }),
'x-selenium-vendor': 'moz',
'x-selenium-vendor-via': via,
})
const base = [
leaf('session.New', 'session.new', 'session.CapabilityRequest'),
group('session.CapabilityRequest', [field('browserName', ['text'], { n: 0, m: 1 })]),
group('browsingContext.Info', [field('url', ['text'])]),
]
const extension = (target, props) => ({ ...group(`${target}Extension`, props), 'x-selenium-vendor-extends': target })
const vendorDefs = [
extension('session.CapabilityRequest', [
tagged(
'moz:firefoxOptions',
[ref('session.CapabilityRequestFirefoxOptions')],
'session.CapabilityRequestExtension',
),
]),
extension('browsingContext.Info', [
tagged('moz:scope', [lit('chrome'), lit('content')], 'browsingContext.InfoExtension'),
]),
{
...group('session.CapabilityRequestFirefoxOptions', [
field('log', { Type: 'group', Name: '', Properties: [field('level', [lit('info'), lit('warn')])] }),
]),
'x-selenium-vendor': 'moz',
},
]
const model = {
session: {
commands: [{ method: 'session.new', name: 'new', params: 'session.CapabilityRequest', result: null }],
events: [],
},
}
const schema = projectSchema([...base, ...vendorDefs], model)
it('keeps the shared sections exactly upstream: no vendor field, group, or type', () => {
assert.deepEqual(
schema.types['session.CapabilityRequest'].fields.map((f) => f.name),
['browserName'],
)
assert.deepEqual(
schema.types['browsingContext.Info'].fields.map((f) => f.name),
['url'],
)
assert.equal(
Object.keys(schema.types).some((n) => n.endsWith('Extension') || n.includes('FirefoxOptions')),
false,
)
const { vendor, ...shared } = schema
assert.deepEqual(shared, projectSchema(base, model))
})
it('routes extension fields into extends (wire names, enums inline) and vendor types beside them', () => {
const moz = schema.vendor.moz
assert.deepEqual(moz.extends['browsingContext.Info'], {
via: 'browsingContext.InfoExtension',
fields: [
{
name: 'moz:scope',
wire: 'moz:scope',
required: false,
type: { enum: ['chrome', 'content'], primitive: 'string' },
},
],
})
assert.deepEqual(moz.extends['session.CapabilityRequest'].fields[0].type, {
ref: 'session.CapabilityRequestFirefoxOptions',
})
assert.deepEqual(Object.keys(moz.types).sort(), [
'session.CapabilityRequestFirefoxOptions',
'session.CapabilityRequestFirefoxOptionsLevel',
'session.CapabilityRequestFirefoxOptionsLog',
])
assert.equal(
moz.types['session.CapabilityRequestFirefoxOptionsLog'].owner,
'session.CapabilityRequestFirefoxOptions',
)
})
it('validates across both sections: the extended type is a shared record and refs resolve', () => {
assert.deepEqual(checkSchema(schema), [])
const dangling = structuredClone(schema)
delete dangling.vendor.moz.types['session.CapabilityRequestFirefoxOptions']
assert.ok(
checkSchema(dangling).includes(
'moz:session.CapabilityRequest.moz:firefoxOptions: unresolved type session.CapabilityRequestFirefoxOptions',
),
)
const orphan = structuredClone(schema)
orphan.vendor.moz.extends['session.Gone'] = orphan.vendor.moz.extends['session.CapabilityRequest']
assert.ok(checkSchema(orphan).includes('moz: extends session.Gone, which is not a shared record'))
})
})
describe('vendor modules (commands, events, and types filed under the namespace)', () => {
const vendorType = (name, props) => ({ ...group(name, props), 'x-selenium-vendor': 'moz' })
const union = (name, refs) => ({
Type: 'variable',
Name: name,
IsChoiceAddition: false,
Comments: [],
PropertyType: refs.map(ref),
'x-selenium-vendor': 'moz',
})
const base = [group('EmptyResult', [field('text', ['any'], { n: 0, m: null })])]
const vendorDefs = [
{ ...leaf('mozProfiler.Stop', 'moz:profiler.stop', 'mozProfiler.StopParameters'), 'x-selenium-vendor': 'moz' },
vendorType('mozProfiler.StopParameters', [field('discard', ['bool'], { n: 0, m: 1 })]),
vendorType('mozProfiler.StopResult', [field('path', ['text'])]),
union('mozProfiler.StartResult', ['EmptyResult']),
union('MozProfilerResult', ['mozProfiler.StartResult', 'mozProfiler.StopResult']),
]
const ast = [...base, ...vendorDefs]
const vendorModel = {
'moz:profiler': {
commands: [
{
method: 'moz:profiler.stop',
name: 'stop',
params: 'mozProfiler.StopParameters',
result: 'mozProfiler.StopResult',
vendor: 'moz',
},
],
events: [],
},
}
const schema = projectSchema(ast, {}, {}, vendorModel)
const moz = schema.vendor.moz
it('files a vendor module under its wire domain, in the top-level shape', () => {
assert.deepEqual(moz.commands, [
{
domain: 'moz:profiler',
method: 'moz:profiler.stop',
name: 'stop',
params: { ref: 'mozProfiler.StopParameters' },
result: { ref: 'mozProfiler.StopResult' },
},
])
assert.deepEqual(moz.events, [])
assert.deepEqual(schema.commands, [])
assert.deepEqual(Object.keys(schema.types), ['EmptyResult'])
assert.equal(moz.types['mozProfiler.StopParameters'].outbound, true)
assert.equal(moz.types['mozProfiler.StopResult'].inbound, true)
})
it('treats a bare <Module>Result grouping as request-correlated, like the core result unions', () => {
assert.deepEqual(moz.types.MozProfilerResult.selector, { correlated: true })
})
it('validates across both sections and counts vendor methods as emitted', () => {
assert.deepEqual(checkSchema(schema), [])
assert.deepEqual(checkCompleteness(ast, schema), [])
const dropped = structuredClone(schema)
dropped.vendor.moz.commands = []
assert.deepEqual(checkCompleteness(ast, dropped), ['dropped from schema: moz:profiler.stop'])
const dangling = structuredClone(schema)
delete dangling.vendor.moz.types['mozProfiler.StopParameters']
assert.ok(checkSchema(dangling).some((e) => e.includes('unresolved type mozProfiler.StopParameters')))
})
it('yields no vendor modules from a vendor-free model', () => {
assert.deepEqual(projectSchema(base, {}, {}, {}), projectSchema(base, {}))
})
})