diff --git a/javascript/selenium-webdriver/project_bidi_schema.mjs b/javascript/selenium-webdriver/project_bidi_schema.mjs index 4fd850e2ea0a9..d3e3cacd08437 100644 --- a/javascript/selenium-webdriver/project_bidi_schema.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema.mjs @@ -57,6 +57,13 @@ * (input.Origin's "viewport" / "pointer"), so a binding can reject a wrong string, not just a * wrong primitive — the tightest check the schema affords for a bare-scalar union arm. * + * Each structured (`record` / `union`) type additionally carries `outbound` / + * `inbound`: reachable (by a pure `ref` walk) from some command's `params`, and from + * some command's `result` or an event's `params`, respectively. A binding gives a + * send-side accessor only to `outbound` types. Both flags are independent, so all four + * combinations occur — including `(false, false)` for a type in no message (a flattened + * base, an envelope), which correctly gets no accessor. + * * 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 @@ -533,6 +540,54 @@ export function buildSpecLinks(dfnsDocs, anchors = {}) { } } +// Every type name a *type expression* references (the value of a `field.type`, or a +// list element / map value type), descending through list, map, inline union arms, and +// inline record fields. Shared by refsOfNode and checkSchema. +function refsInType(node) { + if (!node) return [] + if (node.ref) return [node.ref] + if (node.list) return refsInType(node.list) + if (node.map) return refsInType(node.map) + if (node.union) return node.union.flatMap(refsInType) + if (node.record) return node.record.flatMap((f) => refsInType(f.type)) + return [] +} + +// Every type name a projected *type node* (a named `schema.types` entry) references: a +// record's field and map-value refs, a union's variant (and selector) refs, an alias's +// target refs. Composition is already resolved upstream, so this ref adjacency is +// complete for a reachability walk. +function refsOfNode(node) { + if (!node) return [] + if (node.kind === 'record') { + const refs = node.fields.flatMap((f) => refsInType(f.type)) + if (node.map) refs.push(...refsInType(node.map)) + return refs + } + if (node.kind === 'union') { + const refs = [...node.variants] + if (node.selector?.variants) refs.push(...node.selector.variants.map((v) => v.ref)) + if (node.selector?.default) refs.push(node.selector.default) + return refs + } + if (node.kind === 'alias') return refsInType(node.type) + return [] +} + +// The transitive closure of a set of root type names over refsOfNode. An unknown name +// (a ref with no type entry) terminates that branch. +function reachableTypes(roots, types) { + const seen = new Set() + const stack = [...roots] + while (stack.length) { + const name = stack.pop() + if (seen.has(name) || !types[name]) continue + seen.add(name) + for (const r of refsOfNode(types[name])) stack.push(r) + } + return seen +} + /** * 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). @@ -602,6 +657,19 @@ export function projectSchema(ast, model, links = {}) { } } + // Per-type directionality (see the header block): reachable from a command's params + // (outbound) vs from a command's result or an event's params (inbound), closed over + // the same ref edges the integrity check walks — no name heuristics. + const outboundRoots = commands.map((c) => c.params?.ref).filter(Boolean) + const inboundRoots = [...commands.map((c) => c.result?.ref), ...events.map((e) => e.params?.ref)].filter(Boolean) + const outboundReach = reachableTypes(outboundRoots, types) + const inboundReach = reachableTypes(inboundRoots, types) + for (const [name, node] of Object.entries(types)) + if (node.kind === 'record' || node.kind === 'union') { + node.outbound = outboundReach.has(name) + node.inbound = inboundReach.has(name) + } + // Per-domain module links, for a binding that emits one class/namespace per domain. const domains = {} for (const domain of Object.keys(model)) { @@ -668,20 +736,6 @@ function extractVendor(types) { 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 @@ -709,7 +763,7 @@ export function checkSchema(schema) { ? node.union.some(hasEmptyInlineRecord) : false const report = (where, node) => { - for (const r of refsIn(node)) if (!has(r)) errors.push(`${where}: unresolved type ${r}`) + for (const r of refsInType(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)`) } @@ -758,14 +812,14 @@ export function checkSchema(schema) { if (node.kind === 'record') { const envelopeRoot = envelopeResultUnion(node, schema.types) for (const f of node.fields) - for (const r of refsIn(f.type)) + for (const r of refsInType(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) + if (node.map) for (const r of refsInType(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) + for (const r of refsInType(node.type)) if (correlated.has(r)) leak(name, r) } } return errors @@ -905,5 +959,13 @@ export function checkCompleteness(rawAst, schema) { for (const known of KNOWN_INCOMPLETE) { if (emitted.has(known)) errors.push(`stale KNOWN_INCOMPLETE entry (now emitted, remove it): ${known}`) } + // Every structured type must carry both directionality flags — a missing one means + // the pass skipped a node. `(false, false)` is a valid combination (a type in no + // message: an envelope, a grouping union, a flattened base), not an error. + for (const [name, node] of Object.entries(schema.types)) { + if (node.kind !== 'record' && node.kind !== 'union') continue + if (typeof node.outbound !== 'boolean' || typeof node.inbound !== 'boolean') + errors.push(`${name}: missing directionality flag (inbound/outbound)`) + } return errors } diff --git a/javascript/selenium-webdriver/project_bidi_schema_test.mjs b/javascript/selenium-webdriver/project_bidi_schema_test.mjs index efda52c7a73ed..03f543e382292 100644 --- a/javascript/selenium-webdriver/project_bidi_schema_test.mjs +++ b/javascript/selenium-webdriver/project_bidi_schema_test.mjs @@ -163,6 +163,8 @@ describe('projectType (list / union / alias defs)', () => { ], }, 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', () => { @@ -485,6 +487,106 @@ describe('schema signals (objectOnly / extensible / enum primitive)', () => { }) }) +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 = [