blob: a4162526f200a1cbaedb25030566c4b6a027a163 [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 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,
spliceExtensionGroups,
} 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('hoists a nullable literal choice, keeping the null on the field and out of the enum', () => {
const ast = [def('x.T', [field('scrollbarType', [lit('classic'), lit('overlay'), 'null'])])]
const out = hoistInlineEnums(ast)
const enumName = 'x.TScrollbarType'
assert.deepEqual(byName(out, 'x.T').Properties[0].Type, [ref(enumName), 'null'])
assert.deepEqual(
byName(out, enumName).PropertyType.map((e) => e.Value),
['classic', 'overlay'],
)
})
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')
})
it('folds a //= choice addition into the retained base group', () => {
const addition = { ...def('x.Ext', [field('b', ['text'])]), IsChoiceAddition: true }
const ast = [def('x.Ext', [field('a', ['text'])]), addition]
const out = dedupeDefs(ast)
assert.equal(out.length, 1)
assert.deepEqual(
out[0].Properties.map((p) => p.Name),
['a', 'b'],
)
})
it('does not mutate the base def when folding', () => {
const base = def('x.Ext', [field('a', ['text'])])
const addition = { ...def('x.Ext', [field('b', ['text'])]), IsChoiceAddition: true }
dedupeDefs([base, addition])
assert.equal(base.Properties.length, 1)
})
})
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)
})
})
describe('spliceExtensionGroups', () => {
const via = 'webExtension.InstallParametersExtension'
const vendorField = (name) => ({
Name: name,
Occurrence: { n: 0, m: 1 },
Type: ['bool'],
Comments: [],
'x-selenium-vendor': 'moz',
'x-selenium-vendor-via': via,
})
const extension = (name, target, props) => ({ ...def(name, props), 'x-selenium-vendor-extends': target })
// The `-all` grammar defines a shared record twice (local + remote); both must be spliced.
const ast = [
def('webExtension.InstallParameters', [field('extensionData', [ref('webExtension.ExtensionData')])]),
def('webExtension.InstallParameters', [field('extensionData', [ref('webExtension.ExtensionData')])]),
def('webExtension.Untagged', [field('x', ['text'])]),
extension(via, 'webExtension.InstallParameters', [vendorField('moz:permanent')]),
]
it('appends a tagged extension group to every spec record it names, as an anonymous spread', () => {
const out = spliceExtensionGroups(ast)
const targets = out.filter((d) => d.Name === 'webExtension.InstallParameters')
assert.equal(targets.length, 2)
for (const t of targets) {
const spread = t.Properties.at(-1)
assert.equal(spread.Name, '')
assert.deepEqual(spread.Type, [ref(via)])
}
assert.equal(ast[0].Properties.length, 1, 'input untouched')
})
it('leaves a def without the tag alone', () => {
assert.deepEqual(byName(spliceExtensionGroups(ast), 'webExtension.Untagged'), ast[2])
})
it('resolves the spliced fields into the target through normalization, provenance intact', () => {
const [target] = normalizeAst(ast).filter((d) => d.Name === 'webExtension.InstallParameters')
assert.deepEqual(
target.Properties.map((p) => p.Name),
['extensionData', 'moz:permanent'],
)
assert.equal(target.Properties[1]['x-selenium-vendor-via'], via)
})
it('fails when a tagged extension group names no spec record', () => {
assert.throws(
() =>
spliceExtensionGroups([
extension('browsingContext.GoneParametersExtension', 'browsingContext.GoneParameters', []),
]),
/browsingContext.GoneParametersExtension has no spec record browsingContext.GoneParameters/,
)
})
})
describe('vendor provenance through normalization', () => {
const vendorDef = (name, props) => ({ ...def(name, props), 'x-selenium-vendor': 'moz' })
it('a def synthesized out of a vendor def inherits the vendor tag', () => {
const ast = [
vendorDef('session.CapabilityRequestFirefoxOptions', [
field('log', { Type: 'group', Name: '', Properties: [field('level', [lit('info'), lit('warn')])] }),
]),
]
const out = normalizeAst(ast)
assert.equal(byName(out, 'session.CapabilityRequestFirefoxOptionsLog')['x-selenium-vendor'], 'moz')
assert.equal(byName(out, 'session.CapabilityRequestFirefoxOptionsLevel')['x-selenium-vendor'], 'moz')
const spec = normalizeAst([
def('a.B', [field('c', { Type: 'group', Name: '', Properties: [field('d', ['text'])] })]),
])
assert.equal(byName(spec, 'a.BC')['x-selenium-vendor'], undefined)
})
it('keeps an extension-group field enum inline rather than hoisting it', () => {
const scope = {
Name: 'moz:scope',
Occurrence: { n: 0, m: 1 },
Type: [lit('chrome'), lit('content')],
Comments: [],
'x-selenium-vendor': 'moz',
'x-selenium-vendor-via': 'browsingContext.InfoExtension',
}
const out = normalizeAst([
def('browsingContext.Info', [field('url', ['text'])]),
{ ...def('browsingContext.InfoExtension', [scope]), 'x-selenium-vendor-extends': 'browsingContext.Info' },
])
assert.equal(
out.some((d) => d.Name.endsWith('MozScope')),
false,
)
const info = byName(out, 'browsingContext.Info')
assert.deepEqual(info.Properties.find((p) => p.Name === 'moz:scope').Type, [lit('chrome'), lit('content')])
})
})