From ca5110c7bdd97c8e5fc7205f06ff8f7b8d79e9d0 Mon Sep 17 00:00:00 2001 From: Puja Jagani Date: Tue, 18 Aug 2026 16:34:18 +0530 Subject: [PATCH] [js] Add serialization and domain layer --- javascript/selenium-webdriver/BUILD.bazel | 5 + .../selenium-webdriver/bidi/domain.d.ts | 37 +++ javascript/selenium-webdriver/bidi/domain.js | 76 +++++ .../bidi/serialization/enum.d.ts | 23 ++ .../bidi/serialization/enum.js | 31 ++ .../bidi/serialization/record.d.ts | 58 ++++ .../bidi/serialization/record.js | 258 +++++++++++++++++ .../bidi/serialization/registry.js | 34 +++ .../bidi/serialization/union.d.ts | 27 ++ .../bidi/serialization/union.js | 87 ++++++ .../test/bidi/domain_test.js | 98 +++++++ .../test/bidi/serialization/record_test.js | 191 +++++++++++++ .../test/bidi/serialization/union_test.js | 117 ++++++++ .../bidi/serialization/wire_contract_test.js | 267 ++++++++++++++++++ 14 files changed, 1309 insertions(+) create mode 100644 javascript/selenium-webdriver/bidi/domain.d.ts create mode 100644 javascript/selenium-webdriver/bidi/domain.js create mode 100644 javascript/selenium-webdriver/bidi/serialization/enum.d.ts create mode 100644 javascript/selenium-webdriver/bidi/serialization/enum.js create mode 100644 javascript/selenium-webdriver/bidi/serialization/record.d.ts create mode 100644 javascript/selenium-webdriver/bidi/serialization/record.js create mode 100644 javascript/selenium-webdriver/bidi/serialization/registry.js create mode 100644 javascript/selenium-webdriver/bidi/serialization/union.d.ts create mode 100644 javascript/selenium-webdriver/bidi/serialization/union.js create mode 100644 javascript/selenium-webdriver/test/bidi/domain_test.js create mode 100644 javascript/selenium-webdriver/test/bidi/serialization/record_test.js create mode 100644 javascript/selenium-webdriver/test/bidi/serialization/union_test.js create mode 100644 javascript/selenium-webdriver/test/bidi/serialization/wire_contract_test.js diff --git a/javascript/selenium-webdriver/BUILD.bazel b/javascript/selenium-webdriver/BUILD.bazel index 008d909e2aa1a..873626a93f8ce 100644 --- a/javascript/selenium-webdriver/BUILD.bazel +++ b/javascript/selenium-webdriver/BUILD.bazel @@ -150,6 +150,7 @@ js_library( "common/*.js", "bidi/*.js", "bidi/external/*.js", + "bidi/serialization/*.js", ]), deps = [ ":node_modules/@bazel/runfiles", @@ -197,7 +198,11 @@ pkg_tar( ) SMALL_TESTS = [ + "test/bidi/domain_test.js", "test/bidi/index_test.js", + "test/bidi/serialization/record_test.js", + "test/bidi/serialization/union_test.js", + "test/bidi/serialization/wire_contract_test.js", "test/io/io_test.js", "test/io/zip_test.js", "test/lib/bidi_connection_test.js", diff --git a/javascript/selenium-webdriver/bidi/domain.d.ts b/javascript/selenium-webdriver/bidi/domain.d.ts new file mode 100644 index 0000000000000..8f5a0be49d1cd --- /dev/null +++ b/javascript/selenium-webdriver/bidi/domain.d.ts @@ -0,0 +1,37 @@ +// 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. + +export interface EventDescriptor { + readonly method: string + readonly type?: { fromWire(payload: unknown): T } +} + +export function event(method: string, type?: { fromWire(payload: unknown): T }): EventDescriptor + +/** Internal construction guard — only a generated `Class.create(driver)` passes this. Never use directly. */ +export const DOMAIN_TOKEN: unique symbol + +export declare class Domain { + protected constructor(bidi: unknown, token: typeof DOMAIN_TOKEN) + protected static connect(driver: unknown): Promise + protected send(method: string, params: Record): Promise + addCallback( + descriptor: EventDescriptor, + handler: (params: T) => void, + ): Promise<{ id: string; unsubscribe(): Promise }> + removeCallback(subscriptionId: string): Promise +} diff --git a/javascript/selenium-webdriver/bidi/domain.js b/javascript/selenium-webdriver/bidi/domain.js new file mode 100644 index 0000000000000..cfedab53ecb9a --- /dev/null +++ b/javascript/selenium-webdriver/bidi/domain.js @@ -0,0 +1,76 @@ +// 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. + +const { getBidiConnection } = require('../lib/bidi_connection') + +// Gates Domain's constructor so `new Network(someRandomThing)` fails loudly +// instead of silently producing a broken instance. A Symbol can't be forged +// or guessed, so this is real runtime enforcement, not just a TS annotation — +// only a generated `Class.create(driver)` (and this package's own tests) may +// pass it. It's exported deliberately, not hidden: the point is to stop +// accidental misuse of the normal `new Network(x)` shape, not to defend +// against someone who deliberately imports and passes this. +const DOMAIN_TOKEN = Symbol('Domain internal construction token — obtained only via Class.create(driver)') + +/** + * @param {string} method + * @param {{fromWire(payload: unknown): unknown}} [type] Runtime record/union + * class for the event's params, if the schema declares one. When present, + * addCallback() parses each delivered payload through it before the + * caller's handler runs — inbound wire payloads are validated against + * their resolved type; an event's params is such a payload just as much + * as a command's result is. + * @returns {{method: string, type: ({fromWire(payload: unknown): unknown}|undefined)}} + */ +function event(method, type) { + return { method, type } +} + +/** Shared base for every generated BiDi domain class. See domain.d.ts for the typed surface. */ +class Domain { + #bidi + + constructor(bidi, token) { + if (token !== DOMAIN_TOKEN) { + throw new TypeError(`${new.target.name} must be constructed via ${new.target.name}.create(driver), not \`new\``) + } + this.#bidi = bidi + } + + static async connect(driver) { + return getBidiConnection(driver) + } + + async send(method, params) { + const response = await this.#bidi.send({ method, params }) + if (response?.error !== undefined) { + throw new Error(`${response.error}: ${response.message}`) + } + return response?.result + } + + async addCallback(descriptor, handler) { + const dispatch = descriptor.type === undefined ? handler : (params) => handler(descriptor.type.fromWire(params)) + return this.#bidi.addCallback(descriptor.method, dispatch) + } + + async removeCallback(subscriptionId) { + return this.#bidi.removeCallback(subscriptionId) + } +} + +module.exports = { Domain, event, DOMAIN_TOKEN } diff --git a/javascript/selenium-webdriver/bidi/serialization/enum.d.ts b/javascript/selenium-webdriver/bidi/serialization/enum.d.ts new file mode 100644 index 0000000000000..b40ca8531c46e --- /dev/null +++ b/javascript/selenium-webdriver/bidi/serialization/enum.d.ts @@ -0,0 +1,23 @@ +// 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. + +export interface EnumEntry { + readonly values: readonly T[] + includes(value: unknown): value is T +} + +export function defineEnum(name: string, values: readonly T[]): EnumEntry diff --git a/javascript/selenium-webdriver/bidi/serialization/enum.js b/javascript/selenium-webdriver/bidi/serialization/enum.js new file mode 100644 index 0000000000000..db16f0ca243a5 --- /dev/null +++ b/javascript/selenium-webdriver/bidi/serialization/enum.js @@ -0,0 +1,31 @@ +// 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. + +const { register } = require('./registry') + +/** + * @param {string} name Schema type name, e.g. 'network.InterceptPhase'. + * @param {string[]} values + */ +function defineEnum(name, values) { + const allowed = new Set(values) + const entry = { kind: 'enum', values, includes: (value) => allowed.has(value) } + register(name, entry) + return entry +} + +module.exports = { defineEnum } diff --git a/javascript/selenium-webdriver/bidi/serialization/record.d.ts b/javascript/selenium-webdriver/bidi/serialization/record.d.ts new file mode 100644 index 0000000000000..843e5d3e5909d --- /dev/null +++ b/javascript/selenium-webdriver/bidi/serialization/record.d.ts @@ -0,0 +1,58 @@ +// 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. + +// Mirrors bidi_schema.json's type-ref vocabulary (see project_bidi_schema.mjs). +export interface TypeNode { + primitive?: string + const?: unknown + ref?: string + enum?: string[] + list?: TypeNode + map?: TypeNode + union?: TypeNode[] + nullable?: boolean + // Present on an inline union with a bare-scalar arm — the primitive(s) that + // arm accepts (see unionNode() in project_bidi_schema.mjs). Not consumed by + // validateValue yet; declared so embedding a real schema node type-checks. + scalar?: string | string[] + // The exact `const` literals a bare-scalar union arm admits (e.g. + // input.Origin's "viewport"/"pointer") — see unionNode(). Same status as + // `scalar`: not yet consumed by validateValue, declared for the embed. + scalarValues?: unknown[] +} + +export interface FieldSpec { + name: string + wire: string + required: boolean + type: TypeNode +} + +export interface RecordOptions { + extensible?: boolean +} + +export declare class ValidationError extends Error {} + +export interface RecordClass { + new (data: T): Readonly + fromWire(payload: unknown): Readonly +} + +export function defineRecord(name: string, fields: FieldSpec[], options?: RecordOptions): RecordClass + +export function defineAlias(name: string, type: TypeNode): void diff --git a/javascript/selenium-webdriver/bidi/serialization/record.js b/javascript/selenium-webdriver/bidi/serialization/record.js new file mode 100644 index 0000000000000..b39a8c29af7d3 --- /dev/null +++ b/javascript/selenium-webdriver/bidi/serialization/record.js @@ -0,0 +1,258 @@ +// 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. + +const { register, resolve } = require('./registry') + +class ValidationError extends Error {} + +// Validates a *present* value against a resolved type node. This check is +// identical for outbound and inbound — a structurally wrong value is always +// an error, whether it's being sent or received. Only presence (required) +// and extras (undeclared properties) differ by direction, handled separately +// in the constructor and fromWire() below. +// `direction` only affects how a nested ref-to-record/union is itself validated. +function validateValue(typeNode, value, path, direction) { + if (value === null) { + if (typeNode.nullable) return + throw new ValidationError(`${path}: null is not allowed`) + } + + if (typeNode.primitive !== undefined) { + const expected = { string: 'string', integer: 'number', number: 'number', boolean: 'boolean' }[typeNode.primitive] + if (expected && typeof value !== expected) { + throw new ValidationError(`${path}: expected ${typeNode.primitive}, got ${typeof value}`) + } + // `number` admits any JSON number; `integer` rejects a fractional value + // (5.7) while still accepting one written 5.0 (Number.isInteger(5.0) is true). + if (typeNode.primitive === 'integer' && !Number.isInteger(value)) { + throw new ValidationError(`${path}: expected an integer, got ${value}`) + } + return + } + + if (typeNode.const !== undefined) { + if (value !== typeNode.const) { + throw new ValidationError( + `${path}: expected constant ${JSON.stringify(typeNode.const)}, got ${JSON.stringify(value)}`, + ) + } + return + } + + if (typeNode.enum !== undefined) { + if (!typeNode.enum.includes(value)) { + throw new ValidationError( + `${path}: "${value}" is not a valid value; expected one of: ${typeNode.enum.join(', ')}`, + ) + } + return + } + + if (typeNode.list !== undefined) { + if (!Array.isArray(value)) { + throw new ValidationError(`${path}: expected a list, got ${typeof value}`) + } + value.forEach((item, i) => validateValue(typeNode.list, item, `${path}[${i}]`, direction)) + return + } + + if (typeNode.map !== undefined) { + if (typeof value !== 'object' || Array.isArray(value) || value === null) { + throw new ValidationError(`${path}: expected an object, got ${typeof value}`) + } + for (const [key, entry] of Object.entries(value)) { + validateValue(typeNode.map, entry, `${path}.${key}`, direction) + } + return + } + + if (typeNode.ref !== undefined) { + const referenced = resolve(typeNode.ref) + if (referenced === undefined) return // not yet registered — best-effort, skip deep validation + + if (referenced.kind === 'enum') { + if (!referenced.includes(value)) { + throw new ValidationError( + `${path}: "${value}" is not a valid ${typeNode.ref} value; expected one of: ${referenced.values.join(', ')}`, + ) + } + return + } + + if (referenced.kind === 'record') { + if (value instanceof referenced.RecordClass) return // already validated + if (typeof value !== 'object' || Array.isArray(value) || value === null) { + throw new ValidationError(`${path}: expected an object, got ${typeof value}`) + } + // Recurse through the same-direction path so a nested field gets the + // same tolerance (inbound) or strictness (outbound) as its parent. + if (direction === 'inbound') { + referenced.RecordClass.fromWire(value) + } else { + new referenced.RecordClass(value) + } + return + } + + if (referenced.kind === 'union') { + if (direction === 'inbound') { + referenced.fromWire(value) + } else { + referenced.build(value) + } + return + } + + if (referenced.kind === 'alias') { + validateValue(referenced.type, value, path, direction) + return + } + + return + } + + if (typeNode.union !== undefined) { + const errors = [] + for (const variant of typeNode.union) { + try { + validateValue(variant, value, path, direction) + return + } catch (err) { + errors.push(err.message) + } + } + throw new ValidationError(`${path}: value did not match any variant (${errors.join('; ')})`) + } +} + +/** + * @param {string} name Schema type name, e.g. 'network.AddInterceptParameters'. + * @param {Array<{name: string, wire: string, required: boolean, type: object}>} fields + * @param {{extensible?: boolean}} [options] + */ +function defineRecord(name, fields, options = {}) { + const { extensible = false } = options + const byWire = new Map(fields.map((f) => [f.wire, f])) + + class Record { + // Outbound: strict. Any value that doesn't match its declared shape is an error here. + constructor(data) { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + throw new ValidationError(`${name}: expected an object`) + } + + for (const field of fields) { + if (!Object.hasOwn(data, field.wire)) { + if (field.required) { + throw new ValidationError(`${name}.${field.wire}: required field is missing`) + } + continue + } + const value = data[field.wire] + validateValue(field.type, value, `${name}.${field.wire}`, 'outbound') + this[field.name] = value + } + + for (const wireKey of Object.keys(data)) { + if (byWire.has(wireKey)) continue + if (!extensible) { + throw new ValidationError(`${name}: unknown property "${wireKey}"`) + } + // Object.defineProperty, not `this[wireKey] = ...`: wireKey is caller-supplied + // and a literal "__proto__" key assigned via bracket notation hijacks this + // instance's actual prototype instead of becoming a field (CWE-1321). + // defineProperty always creates a genuine own data property, regardless of name. + Object.defineProperty(this, wireKey, { + value: data[wireKey], // vendor extras reach the wire on an extensible type + enumerable: true, + writable: true, + configurable: true, + }) + } + + Object.freeze(this) + } + + // Inbound: tolerant of undeclared properties, but a missing required field + // is rejected just like a structurally invalid value — omission used to be + // tolerated here, but that's no longer required. + // Bypasses the constructor above entirely — a single constructor enforcing + // both directions symmetrically would make tolerated undeclared-property + // retention impossible. + static fromWire(payload) { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + throw new ValidationError(`${name}: expected an object on the wire, got ${typeof payload}`) + } + + const instance = Object.create(Record.prototype) + + for (const field of fields) { + if (!Object.hasOwn(payload, field.wire)) { + if (field.required) { + throw new ValidationError(`${name}.${field.wire}: required field is missing`) + } + continue // left genuinely absent, optional field + } + const value = payload[field.wire] + // A present value's shape is never tolerated, inbound or outbound. + validateValue(field.type, value, `${name}.${field.wire}`, 'inbound') + instance[field.name] = value + } + + for (const wireKey of Object.keys(payload)) { + if (byWire.has(wireKey)) continue + // An extensible type preserves an undeclared field silently — no narrower + // criterion than "extensible" itself (not, say, only fields that happen to be + // sendable back on some other type). A non-extensible type warns and drops it + // instead — the warning belongs only to the drop, not the retention. + if (extensible) { + // Object.defineProperty, not `instance[wireKey] = ...` — see the matching + // comment in the constructor above: a literal "__proto__" key from an + // untrusted wire payload must become a field, not swap the prototype (CWE-1321). + Object.defineProperty(instance, wireKey, { + value: payload[wireKey], + enumerable: true, + writable: true, + configurable: true, + }) + } else { + process.emitWarning(`${name}: undeclared property "${wireKey}"`, 'BiDiSchemaWarning') + } + } + + Object.freeze(instance) + return instance + } + } + + Object.defineProperty(Record, 'name', { value: name }) + register(name, { kind: 'record', RecordClass: Record }) + return Record +} + +/** + * Registers a schema `alias` — a name with no fields of its own, just a + * pointer to another type node (e.g. `network.Intercept` aliasing a plain + * string). A ref to an alias validates through the aliased type node. + * @param {string} name + * @param {object} type The schema's `type` node this name aliases. + */ +function defineAlias(name, type) { + register(name, { kind: 'alias', type }) +} + +module.exports = { defineRecord, defineAlias, ValidationError } diff --git a/javascript/selenium-webdriver/bidi/serialization/registry.js b/javascript/selenium-webdriver/bidi/serialization/registry.js new file mode 100644 index 0000000000000..0faaccbe4027d --- /dev/null +++ b/javascript/selenium-webdriver/bidi/serialization/registry.js @@ -0,0 +1,34 @@ +// 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. + +// Shared type registry: every generated type registers itself here by its +// exact schema name (e.g. 'network.InterceptPhase'), so a field whose type is +// a `ref` can look up what the referenced type actually is — without every +// domain file needing to import every other domain file directly, and without +// needing types defined in dependency order (resolution happens at validation +// time, not at define time, so forward and circular refs both work). +const types = new Map() + +function register(name, entry) { + types.set(name, entry) +} + +function resolve(name) { + return types.get(name) +} + +module.exports = { register, resolve } diff --git a/javascript/selenium-webdriver/bidi/serialization/union.d.ts b/javascript/selenium-webdriver/bidi/serialization/union.d.ts new file mode 100644 index 0000000000000..6f420168ce8af --- /dev/null +++ b/javascript/selenium-webdriver/bidi/serialization/union.d.ts @@ -0,0 +1,27 @@ +// 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. + +export interface UnionOptions { + objectOnly?: boolean +} + +export interface UnionClass { + build(data: unknown): Readonly + fromWire(payload: unknown): Readonly +} + +export function defineUnion(name: string, selector: unknown, options?: UnionOptions): UnionClass diff --git a/javascript/selenium-webdriver/bidi/serialization/union.js b/javascript/selenium-webdriver/bidi/serialization/union.js new file mode 100644 index 0000000000000..4c6c987484a9c --- /dev/null +++ b/javascript/selenium-webdriver/bidi/serialization/union.js @@ -0,0 +1,87 @@ +// 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. + +const { register, resolve } = require('./registry') +const { ValidationError } = require('./record') + +// Resolves the variant ref a value/payload matches, per the schema's selector shape: +// { by, variants: [{value, ref}], default? } - discriminated: match `data[by]` against +// each variant's value. +// { ordered: [{ref, requires}] } - structural: first variant whose `requires` +// keys are all present in `data`, in spec order. +function selectVariant(selector, data, hasKey) { + if (selector.by) { + const tag = hasKey(data, selector.by) ? data[selector.by] : undefined + const match = selector.variants.find((v) => v.value === tag) + if (match) return match.ref + return selector.default + } + if (selector.ordered) { + for (const variant of selector.ordered) { + if (variant.requires.every((key) => hasKey(data, key))) return variant.ref + } + return undefined + } + return undefined // correlated: resolved by request id elsewhere, not from the payload +} + +/** + * @param {string} name Schema type name, e.g. 'session.ProxyConfiguration'. + * @param {object} selector The schema's `selector` node for this union. + * @param {{objectOnly?: boolean}} [options] + */ +function defineUnion(name, selector, options = {}) { + const { objectOnly = false } = options + + const union = { + kind: 'union', + + // Outbound: resolve which variant `data` describes, then delegate to that + // variant's own (strict) constructor. + build(data) { + if (objectOnly && (typeof data !== 'object' || data === null || Array.isArray(data))) { + throw new ValidationError(`${name}: expected an object`) + } + const ref = selectVariant(selector, data, (d, key) => Object.hasOwn(d, key)) + if (ref === undefined) { + throw new ValidationError(`${name}: value does not match any known variant`) + } + const variant = resolve(ref) + return new variant.RecordClass(data) + }, + + // Inbound: resolve which variant `payload` matches. An unresolvable payload is a + // closed-vocabulary miss — always an error, never a warning, since there is no + // valid typed object to fall back to. + fromWire(payload) { + if (objectOnly && (typeof payload !== 'object' || payload === null || Array.isArray(payload))) { + throw new ValidationError(`${name}: expected an object on the wire, got ${typeof payload}`) + } + const ref = selectVariant(selector, payload, (d, key) => Object.hasOwn(d, key)) + if (ref === undefined) { + throw new ValidationError(`${name}: received a variant not in this binding's BiDi schema`) + } + const variant = resolve(ref) + return variant.RecordClass.fromWire(payload) + }, + } + + register(name, union) + return union +} + +module.exports = { defineUnion } diff --git a/javascript/selenium-webdriver/test/bidi/domain_test.js b/javascript/selenium-webdriver/test/bidi/domain_test.js new file mode 100644 index 0000000000000..5f605235220ee --- /dev/null +++ b/javascript/selenium-webdriver/test/bidi/domain_test.js @@ -0,0 +1,98 @@ +// 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. + +'use strict' + +const assert = require('node:assert') +const { Domain, event, DOMAIN_TOKEN } = require('selenium-webdriver/bidi/domain') +const { defineRecord } = require('selenium-webdriver/bidi/serialization/record') + +const EntryAdded = defineRecord('test.domain.EntryAdded', [ + { name: 'text', wire: 'text', required: true, type: { primitive: 'string' } }, +]) + +// Fake replacing the real BiDi transport — records what addCallback/removeCallback +// receive and lets the test drive delivery directly, without a socket. +function fakeBidi() { + return { + registered: undefined, + async addCallback(method, handler) { + this.registered = { method, handler } + return { id: 'sub-1', unsubscribe: async () => {} } + }, + async removeCallback(subscriptionId) { + this.removedId = subscriptionId + }, + } +} + +describe('Domain addCallback', function () { + it('parses delivered payloads through the descriptor type before the handler runs', async function () { + const bidi = fakeBidi() + const domain = new Domain(bidi, DOMAIN_TOKEN) + const descriptor = event('test.entryAdded', EntryAdded) + + const received = [] + await domain.addCallback(descriptor, (params) => received.push(params)) + + bidi.registered.handler({ text: 'hello' }) + + assert.strictEqual(received.length, 1) + assert.ok(received[0] instanceof EntryAdded) + assert.strictEqual(received[0].text, 'hello') + }) + + it('passes the raw payload through when the descriptor has no type', async function () { + const bidi = fakeBidi() + const domain = new Domain(bidi, DOMAIN_TOKEN) + const descriptor = event('test.untyped') + + const received = [] + await domain.addCallback(descriptor, (params) => received.push(params)) + + bidi.registered.handler({ anything: 'goes' }) + + assert.strictEqual(received.length, 1) + assert.deepStrictEqual(received[0], { anything: 'goes' }) + assert.ok(!(received[0] instanceof EntryAdded)) + }) + + it('removeCallback forwards the subscription id to the underlying transport', async function () { + const bidi = fakeBidi() + const domain = new Domain(bidi, DOMAIN_TOKEN) + + await domain.removeCallback('sub-1') + + assert.strictEqual(bidi.removedId, 'sub-1') + }) +}) + +describe('Domain construction guard', function () { + it('rejects `new Domain(bidi)` with no token', function () { + assert.throws(() => new Domain(fakeBidi()), TypeError) + }) + + it('rejects a forged token', function () { + assert.throws(() => new Domain(fakeBidi(), Symbol('not the real token')), TypeError) + }) + + it('does not expose the wrapped transport as an enumerable/own property', function () { + const domain = new Domain(fakeBidi(), DOMAIN_TOKEN) + assert.deepStrictEqual(Object.keys(domain), []) + assert.strictEqual(JSON.stringify(domain), '{}') + }) +}) diff --git a/javascript/selenium-webdriver/test/bidi/serialization/record_test.js b/javascript/selenium-webdriver/test/bidi/serialization/record_test.js new file mode 100644 index 0000000000000..6c4e8c05bfdfd --- /dev/null +++ b/javascript/selenium-webdriver/test/bidi/serialization/record_test.js @@ -0,0 +1,191 @@ +// 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. + +'use strict' + +const assert = require('node:assert') +const { defineEnum } = require('selenium-webdriver/bidi/serialization/enum') +const { defineRecord, ValidationError } = require('selenium-webdriver/bidi/serialization/record') + +// Real fixtures from the WebDriver BiDi schema (network.InterceptPhase, +// network.AddInterceptParameters, network.BeforeRequestSentParameters), +// inlined so this test doesn't depend on generating bidi_schema.json. +defineEnum('test.record.InterceptPhase', ['beforeRequestSent', 'responseStarted', 'authRequired']) + +const AddInterceptParameters = defineRecord('test.record.AddInterceptParameters', [ + { name: 'phases', wire: 'phases', required: true, type: { list: { ref: 'test.record.InterceptPhase' } } }, + { name: 'urlPatterns', wire: 'urlPatterns', required: false, type: { list: { primitive: 'string' } } }, +]) + +const BeforeRequestSentParameters = defineRecord('test.record.BeforeRequestSentParameters', [ + { + name: 'context', + wire: 'context', + required: true, + type: { ref: 'browsingContext.BrowsingContext', nullable: true }, + }, + { name: 'isBlocked', wire: 'isBlocked', required: true, type: { primitive: 'boolean' } }, + { name: 'timestamp', wire: 'timestamp', required: true, type: { primitive: 'integer' } }, +]) + +describe('serialization/record', function () { + describe('outbound (constructor)', function () { + it('accepts a valid object', function () { + const params = new AddInterceptParameters({ phases: ['beforeRequestSent'] }) + assert.deepStrictEqual(params.phases, ['beforeRequestSent']) + }) + + it('rejects an undefined enum value', function () { + assert.throws(() => new AddInterceptParameters({ phases: ['notARealPhase'] }), ValidationError) + }) + + it('lists the valid values in the error message', function () { + assert.throws( + () => new AddInterceptParameters({ phases: ['notARealPhase'] }), + (err) => { + assert.ok(err.message.includes('beforeRequestSent'), err.message) + assert.ok(err.message.includes('responseStarted'), err.message) + assert.ok(err.message.includes('authRequired'), err.message) + return true + }, + ) + }) + + it('rejects a missing required field', function () { + assert.throws(() => new AddInterceptParameters({}), ValidationError) + }) + + it('rejects an unknown property on a non-extensible type', function () { + assert.throws(() => new AddInterceptParameters({ phases: ['beforeRequestSent'], bogus: 'x' }), ValidationError) + }) + + it('produces an immutable instance', function () { + const params = new AddInterceptParameters({ phases: ['beforeRequestSent'] }) + assert.throws(() => { + params.phases = [] + }) + }) + }) + + describe('inbound (fromWire)', function () { + let warnings + + beforeEach(function () { + warnings = [] + process.on('warning', onWarning) + }) + + afterEach(function () { + process.off('warning', onWarning) + }) + + function onWarning(w) { + warnings.push(w.message) + } + + it('accepts an explicit null for a required+nullable field', function () { + const parsed = BeforeRequestSentParameters.fromWire({ context: null, isBlocked: true, timestamp: 1 }) + assert.strictEqual(parsed.context, null) + }) + + it('throws when a required field is missing', function () { + assert.throws(() => BeforeRequestSentParameters.fromWire({ context: null, timestamp: 1 }), ValidationError) + }) + + it('throws when a required, non-nullable field is explicitly null (corruption, not absence)', function () { + assert.throws( + () => BeforeRequestSentParameters.fromWire({ context: null, isBlocked: null, timestamp: 1 }), + ValidationError, + ) + }) + + it('warns (not throws) on an undeclared property', async function () { + BeforeRequestSentParameters.fromWire({ context: null, isBlocked: true, timestamp: 1, vendorAttr: 'x' }) + await new Promise((resolve) => setTimeout(resolve, 20)) + assert.ok(warnings.some((m) => m.includes('vendorAttr'))) + }) + + it('rejects a non-object payload', function () { + assert.throws(() => BeforeRequestSentParameters.fromWire('not an object'), ValidationError) + }) + }) + + describe('integer validation', function () { + it('accepts a whole number outbound', function () { + const params = new BeforeRequestSentParameters({ context: null, isBlocked: true, timestamp: 2.0 }) + assert.strictEqual(params.timestamp, 2) + }) + + it('rejects a fractional value outbound', function () { + assert.throws( + () => new BeforeRequestSentParameters({ context: null, isBlocked: true, timestamp: 1.5 }), + ValidationError, + ) + }) + + it('rejects a fractional value inbound', function () { + assert.throws( + () => BeforeRequestSentParameters.fromWire({ context: null, isBlocked: true, timestamp: 1.5 }), + ValidationError, + ) + }) + }) + + describe('extensible types', function () { + const ExtensibleParams = defineRecord( + 'test.record.ExtensibleParams', + [{ name: 'proxyType', wire: 'proxyType', required: true, type: { const: 'autodetect' } }], + { extensible: true }, + ) + + it('lets outbound vendor extras reach the wire', function () { + const params = new ExtensibleParams({ proxyType: 'autodetect', 'vendor:flag': true }) + assert.strictEqual(params['vendor:flag'], true) + }) + + it('retains an inbound undeclared property silently — every extensible type does', async function () { + const warnings = [] + const onWarning = (w) => warnings.push(w.message) + process.on('warning', onWarning) + const parsed = ExtensibleParams.fromWire({ proxyType: 'autodetect', 'vendor:flag': 'x' }) + await new Promise((resolve) => setTimeout(resolve, 20)) + process.off('warning', onWarning) + assert.strictEqual(parsed['vendor:flag'], 'x') + assert.ok(warnings.every((m) => !m.includes('vendor:flag'))) + }) + + // CWE-1321: a literal "__proto__" key is a real, iterable own property once + // JSON.parse builds an object from wire text — but assigning through it with + // `instance[key] = value` invokes Object.prototype's __proto__ accessor and + // hijacks the instance's actual prototype instead of storing a field. + it('does not let a "__proto__" wire key hijack the parsed instance inbound', function () { + const raw = '{"proxyType":"autodetect","__proto__":{"pwned":true}}' + const parsed = ExtensibleParams.fromWire(JSON.parse(raw)) + assert.strictEqual(Object.getPrototypeOf(parsed), ExtensibleParams.prototype) + assert.ok(parsed instanceof ExtensibleParams) + assert.deepStrictEqual(parsed.__proto__, { pwned: true }) // preserved as data, not applied as a prototype + }) + + it('does not let a "__proto__" key hijack the constructed instance outbound', function () { + const raw = '{"proxyType":"autodetect","__proto__":{"pwned":true}}' + const built = new ExtensibleParams(JSON.parse(raw)) + assert.strictEqual(Object.getPrototypeOf(built), ExtensibleParams.prototype) + assert.ok(built instanceof ExtensibleParams) + assert.deepStrictEqual(built.__proto__, { pwned: true }) + }) + }) +}) diff --git a/javascript/selenium-webdriver/test/bidi/serialization/union_test.js b/javascript/selenium-webdriver/test/bidi/serialization/union_test.js new file mode 100644 index 0000000000000..c8bbf5b1fe9fe --- /dev/null +++ b/javascript/selenium-webdriver/test/bidi/serialization/union_test.js @@ -0,0 +1,117 @@ +// 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. + +'use strict' + +const assert = require('node:assert') +const { defineRecord, ValidationError } = require('selenium-webdriver/bidi/serialization/record') +const { defineUnion } = require('selenium-webdriver/bidi/serialization/union') + +// Real fixtures: session.ProxyConfiguration (discriminated by `proxyType`) and +// script.RemoteReference (structural, presence-based — no shared discriminator). +const AutodetectProxyConfiguration = defineRecord( + 'test.union.AutodetectProxyConfiguration', + [{ name: 'proxyType', wire: 'proxyType', required: true, type: { const: 'autodetect' } }], + { extensible: true }, +) +const ManualProxyConfiguration = defineRecord( + 'test.union.ManualProxyConfiguration', + [ + { name: 'proxyType', wire: 'proxyType', required: true, type: { const: 'manual' } }, + { name: 'socksProxy', wire: 'socksProxy', required: true, type: { primitive: 'string' } }, + ], + { extensible: true }, +) +const ProxyConfiguration = defineUnion( + 'test.union.ProxyConfiguration', + { + by: 'proxyType', + variants: [ + { value: 'autodetect', ref: 'test.union.AutodetectProxyConfiguration' }, + { value: 'manual', ref: 'test.union.ManualProxyConfiguration' }, + ], + }, + { objectOnly: true }, +) + +const SharedReference = defineRecord('test.union.SharedReference', [ + { name: 'sharedId', wire: 'sharedId', required: true, type: { primitive: 'string' } }, +]) +const RemoteObjectReference = defineRecord('test.union.RemoteObjectReference', [ + { name: 'handle', wire: 'handle', required: true, type: { primitive: 'string' } }, +]) +const RemoteReference = defineUnion( + 'test.union.RemoteReference', + { + ordered: [ + { ref: 'test.union.SharedReference', requires: ['sharedId'] }, + { ref: 'test.union.RemoteObjectReference', requires: ['handle'] }, + ], + }, + { objectOnly: true }, +) + +describe('serialization/union', function () { + describe('discriminated (selector.by)', function () { + it('dispatches outbound to the variant matching the discriminator', function () { + const manual = ProxyConfiguration.build({ proxyType: 'manual', socksProxy: 'localhost:9' }) + assert.ok(manual instanceof ManualProxyConfiguration) + assert.strictEqual(manual.socksProxy, 'localhost:9') + }) + + it('rejects an outbound value with an unresolvable discriminator', function () { + assert.throws(() => ProxyConfiguration.build({ proxyType: 'bogus' }), ValidationError) + }) + + it('dispatches inbound to the variant matching the discriminator', function () { + const parsed = ProxyConfiguration.fromWire({ proxyType: 'manual', socksProxy: 'localhost:9' }) + assert.ok(parsed instanceof ManualProxyConfiguration) + }) + + it('errors (not warns) on an inbound payload whose discriminator matches no known variant', function () { + assert.throws(() => ProxyConfiguration.fromWire({ proxyType: 'notARealType' }), ValidationError) + }) + + it('retains extras on an extensible variant silently', async function () { + const warnings = [] + const onWarning = (w) => warnings.push(w.message) + process.on('warning', onWarning) + const parsed = ProxyConfiguration.fromWire({ proxyType: 'autodetect', 'vendor:flag': 'x' }) + await new Promise((resolve) => setTimeout(resolve, 20)) + process.off('warning', onWarning) + assert.ok(parsed instanceof AutodetectProxyConfiguration) + assert.strictEqual(parsed['vendor:flag'], 'x') + assert.ok(warnings.every((m) => !m.includes('vendor:flag'))) + }) + }) + + describe('structural (selector.ordered)', function () { + it('dispatches to the first variant whose required keys are present', function () { + const shared = RemoteReference.fromWire({ sharedId: 'abc' }) + assert.ok(shared instanceof SharedReference) + }) + + it('dispatches to a later variant when its required keys are present instead', function () { + const remoteObj = RemoteReference.fromWire({ handle: 'h1' }) + assert.ok(remoteObj instanceof RemoteObjectReference) + }) + + it('errors when no variant matches', function () { + assert.throws(() => RemoteReference.fromWire({ somethingElse: true }), ValidationError) + }) + }) +}) diff --git a/javascript/selenium-webdriver/test/bidi/serialization/wire_contract_test.js b/javascript/selenium-webdriver/test/bidi/serialization/wire_contract_test.js new file mode 100644 index 0000000000000..f7583bbb6c369 --- /dev/null +++ b/javascript/selenium-webdriver/test/bidi/serialization/wire_contract_test.js @@ -0,0 +1,267 @@ +// 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. + +'use strict' + +// One describe block per behavioral guarantee this layer makes about a value +// crossing the wire — how it's represented, what's enforced sending it out, what's +// enforced receiving it back. record_test.js/union_test.js cover the serialization +// primitives more exhaustively; this file exists to walk each guarantee in +// isolation, one realistic fixture at a time, rather than to avoid overlap with them. + +const assert = require('node:assert') +const { defineRecord, ValidationError } = require('selenium-webdriver/bidi/serialization/record') +const { defineEnum } = require('selenium-webdriver/bidi/serialization/enum') +const { Domain, DOMAIN_TOKEN } = require('selenium-webdriver/bidi/domain') + +defineEnum('test.contract.InterceptPhase', ['beforeRequestSent', 'responseStarted']) + +// Mirrors network.BeforeRequestSentParameters' shape closely enough to exercise every +// structural/vocabulary case that must be rejected: a nullable ref, a non-nullable +// primitive, an integer, a list, an enum, and a nullable const. +const RecordFixture = defineRecord('test.contract.Record', [ + { name: 'context', wire: 'context', required: true, type: { ref: 'test.contract.BrowsingContext', nullable: true } }, + { name: 'isBlocking', wire: 'isBlocking', required: true, type: { primitive: 'boolean' } }, + { name: 'timestamp', wire: 'timestamp', required: true, type: { primitive: 'integer' } }, + { name: 'headers', wire: 'headers', required: false, type: { list: { primitive: 'string' } } }, + { name: 'phase', wire: 'phase', required: false, type: { ref: 'test.contract.InterceptPhase' } }, + { name: 'proxyType', wire: 'proxyType', required: false, type: { const: 'autodetect', nullable: true } }, +]) + +const ExtensibleFixture = defineRecord( + 'test.contract.Extensible', + [{ name: 'acceptInsecureCerts', wire: 'acceptInsecureCerts', required: false, type: { primitive: 'boolean' } }], + { extensible: true }, +) + +// Never constructed outbound anywhere in this file — stands in for a received-only +// extensible type (e.g. a result type no command ever takes as params). Undeclared-field +// retention applies to every extensible type, not just ones a caller can also send. +const ReceivedOnlyExtensibleFixture = defineRecord( + 'test.contract.ReceivedOnlyExtensible', + [{ name: 'realm', wire: 'realm', required: true, type: { primitive: 'string' } }], + { extensible: true }, +) + +async function captureWarnings(fn) { + const warnings = [] + const onWarning = (w) => warnings.push(w.message) + process.on('warning', onWarning) + try { + return { result: await fn(), warnings: await settle(warnings) } + } finally { + process.off('warning', onWarning) + } +} +function settle(warnings) { + return new Promise((resolve) => setTimeout(() => resolve(warnings), 20)) +} + +describe('wire contract — representation', function () { + describe('typed objects, not raw maps', function () { + it('a record is a real typed instance, not a plain object', function () { + const parsed = RecordFixture.fromWire({ context: null, isBlocking: true, timestamp: 1 }) + assert.ok(parsed instanceof RecordFixture) + }) + + it('a non-extensible type has no map for undeclared fields — an extra key is rejected outbound', function () { + assert.throws( + () => new RecordFixture({ context: null, isBlocking: true, timestamp: 1, vendorFlag: true }), + ValidationError, + ) + }) + + it('an extensible type carries undeclared fields directly on the instance', function () { + const built = new ExtensibleFixture({ acceptInsecureCerts: true, 'vendor:flag': 'x' }) + assert.strictEqual(built['vendor:flag'], 'x') + }) + + it('a key the type declares can never be treated as an extra, even alongside it', function () { + const built = new ExtensibleFixture({ acceptInsecureCerts: true }) + assert.strictEqual(built.acceptInsecureCerts, true) + assert.strictEqual(Object.keys(built).includes('acceptInsecureCerts'), true) + }) + }) + + describe("mirror the spec's command and field names", function () { + // The wire key (what the spec/generator uses verbatim) and the JS-facing property + // name are deliberately separate slots in a FieldSpec — this is the mechanism that + // lets the generator mirror the spec's own key precisely, independent of whatever + // the language-idiomatic property name happens to be (for BiDi/JS these are the same + // string in practice, since BiDi's wire format is already camelCase). + const NameMirror = defineRecord('test.contract.NameMirror', [ + { name: 'jsPropertyName', wire: 'specWireKey', required: true, type: { primitive: 'string' } }, + ]) + + it('reads from the literal wire key, not the JS property name', function () { + const parsed = NameMirror.fromWire({ specWireKey: 'hello' }) + assert.strictEqual(parsed.jsPropertyName, 'hello') + assert.strictEqual(Object.hasOwn(parsed, 'specWireKey'), false) + }) + }) + + describe("preserve a numeric value's full range and precision", function () { + it('does not narrow or lose precision at the js-uint/js-int boundary', function () { + const parsed = RecordFixture.fromWire({ + context: null, + isBlocking: true, + timestamp: Number.MAX_SAFE_INTEGER, + }) + assert.strictEqual(parsed.timestamp, Number.MAX_SAFE_INTEGER) + }) + }) + + describe('hold a value strictly to its declared type', function () { + it('structural: null in a non-nullable field is invalid', function () { + assert.throws(() => RecordFixture.fromWire({ context: null, isBlocking: null, timestamp: 1 }), ValidationError) + }) + + it('structural: an incorrect primitive type is invalid', function () { + assert.throws(() => RecordFixture.fromWire({ context: null, isBlocking: 'yes', timestamp: 1 }), ValidationError) + }) + + it('structural: a fractional value where integer is declared is invalid', function () { + assert.throws(() => RecordFixture.fromWire({ context: null, isBlocking: true, timestamp: 1.5 }), ValidationError) + }) + + it('structural: a cardinality mismatch (single value where a list is declared) is invalid', function () { + assert.throws( + () => RecordFixture.fromWire({ context: null, isBlocking: true, timestamp: 1, headers: 'not-a-list' }), + ValidationError, + ) + }) + + it('vocabulary: an enum value outside its defined set is invalid', function () { + assert.throws( + () => RecordFixture.fromWire({ context: null, isBlocking: true, timestamp: 1, phase: 'notARealPhase' }), + ValidationError, + ) + }) + + it('vocabulary: a nullable constant set to anything other than its literal or null is invalid', function () { + assert.throws( + () => RecordFixture.fromWire({ context: null, isBlocking: true, timestamp: 1, proxyType: 'manual' }), + ValidationError, + ) + // ...but the literal and null are both fine. + assert.doesNotThrow(() => + RecordFixture.fromWire({ context: null, isBlocking: true, timestamp: 1, proxyType: 'autodetect' }), + ) + assert.doesNotThrow(() => + RecordFixture.fromWire({ context: null, isBlocking: true, timestamp: 1, proxyType: null }), + ) + }) + }) +}) + +describe('wire contract — outbound', function () { + describe('reject an invalid or missing value', function () { + it('rejects a missing required field', function () { + assert.throws(() => new RecordFixture({ context: null, timestamp: 1 }), ValidationError) + }) + + it('rejects an invalid value the same way inbound does', function () { + assert.throws(() => new RecordFixture({ context: null, isBlocking: true, timestamp: 1.5 }), ValidationError) + }) + }) + + describe('send an extra field only on an extensible type', function () { + it('a non-extensible type cannot represent an extra field outbound', function () { + assert.throws( + () => new RecordFixture({ context: null, isBlocking: true, timestamp: 1, vendorFlag: true }), + ValidationError, + ) + }) + + it('an extensible type serializes the extra field', function () { + const built = new ExtensibleFixture({ 'vendor:flag': true }) + assert.strictEqual(built['vendor:flag'], true) + }) + }) +}) + +describe('wire contract — inbound', function () { + describe('reject an invalid value', function () { + it('a present-but-invalid value always errors, never falls back to a placeholder', function () { + assert.throws( + () => RecordFixture.fromWire({ context: null, isBlocking: true, timestamp: 1, headers: 'nope' }), + ValidationError, + ) + }) + }) + + describe('a missing required field', function () { + it('rejects a missing required field, same as a present-but-invalid one', function () { + assert.throws(() => RecordFixture.fromWire({ context: null, timestamp: 1 }), ValidationError) + }) + }) + + describe('tolerate an undeclared field', function () { + it('an extensible type retains it silently — no warning on the preserved path', async function () { + const { result: parsed, warnings } = await captureWarnings(() => + ExtensibleFixture.fromWire({ 'vendor:flag': 'x' }), + ) + assert.strictEqual(parsed['vendor:flag'], 'x') + assert.ok(warnings.every((m) => !m.includes('vendor:flag'))) + }) + + it('a non-extensible type drops it and warns — the warning belongs to the drop', async function () { + const { result: parsed, warnings } = await captureWarnings(() => + RecordFixture.fromWire({ context: null, isBlocking: true, timestamp: 1, vendorFlag: 'x' }), + ) + assert.strictEqual(parsed.vendorFlag, undefined) + assert.ok(warnings.some((m) => m.includes('vendorFlag'))) + }) + + // Previously a known gap: the generator only retained extras on an extensible type + // that was also "re-sendable" (reachable from a command's params) — an unnecessarily + // narrow criterion. Closed once project_bidi_schema.mjs (#17864) stopped computing + // that heuristic and started deriving clean inbound/outbound reachability instead — + // retention now follows `extensible` alone, with no narrower criterion, for every + // type including one never sent as a command's params. + it('a received-only extensible type retains it too — no narrower criterion than "extensible"', async function () { + const { result: parsed, warnings } = await captureWarnings(() => + ReceivedOnlyExtensibleFixture.fromWire({ realm: 'realm-1', 'vendor:flag': 'x' }), + ) + assert.strictEqual(parsed['vendor:flag'], 'x') + assert.ok(warnings.every((m) => !m.includes('vendor:flag'))) + }) + }) + + describe('preserve received values faithfully', function () { + it('does not truncate, round, or re-case a value', async function () { + const { result: parsed } = await captureWarnings(() => + RecordFixture.fromWire({ + context: null, + isBlocking: true, + timestamp: 1732000000123, + headers: ['X-Custom-Header', 'Another-One'], + }), + ) + assert.strictEqual(parsed.timestamp, 1732000000123) + assert.deepStrictEqual(parsed.headers, ['X-Custom-Header', 'Another-One']) + }) + }) +}) + +describe('wire contract — error responses are processed before payload validation', function () { + it('surfaces the remote error without ever reaching payload validation', async function () { + const fakeBidi = { send: async () => ({ error: 'unknown command', message: 'not implemented' }) } + const domain = new Domain(fakeBidi, DOMAIN_TOKEN) + await assert.rejects(domain.send('network.addIntercept', {}), /unknown command: not implemented/) + }) +})