From 609b990fc5772e64568532c627eec045d72e26bb Mon Sep 17 00:00:00 2001 From: Saiyam Jain Date: Sun, 1 Nov 2026 18:08:59 +0530 Subject: [PATCH] [js] hold BiDi numbers to their declared ranges Validate numeric min, max, exclusiveMin, and exclusiveMax bounds in validateValue for primitive number/integer fields and resolved aliases. Part of #18195 --- .../bidi/serialization/record.js | 23 +++- .../test/bidi/serialization/record_test.js | 114 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/javascript/selenium-webdriver/bidi/serialization/record.js b/javascript/selenium-webdriver/bidi/serialization/record.js index 661329f061aa1..407ac9cdd6f2b 100644 --- a/javascript/selenium-webdriver/bidi/serialization/record.js +++ b/javascript/selenium-webdriver/bidi/serialization/record.js @@ -19,6 +19,24 @@ const { register, resolve } = require('./registry') class ValidationError extends Error {} +function checkBounds(typeNode, value, path) { + if (typeof value !== 'number') return + if (typeNode.min !== undefined) { + const ok = typeNode.exclusiveMin ? value > typeNode.min : value >= typeNode.min + if (!ok) { + const op = typeNode.exclusiveMin ? '>' : '>=' + throw new ValidationError(`${path}: expected ${op} ${typeNode.min}, got ${value}`) + } + } + if (typeNode.max !== undefined) { + const ok = typeNode.exclusiveMax ? value < typeNode.max : value <= typeNode.max + if (!ok) { + const op = typeNode.exclusiveMax ? '<' : '<=' + throw new ValidationError(`${path}: expected ${op} ${typeNode.max}, got ${value}`) + } + } +} + // Validates a *present* value against a resolved type node, and returns the value to // assign for it — usually the same value, but for a nested ref-to-record/union (the // freshly constructed/parsed instance — fromWire()/build() inbound, `new RecordClass(value)`/ @@ -60,6 +78,7 @@ function validateValue(typeNode, value, path, direction) { if (typeNode.primitive === 'integer' && !Number.isInteger(value)) { throw new ValidationError(`${path}: expected an integer, got ${value}`) } + checkBounds(typeNode, value, path) // An inline literal choice (project_bidi_schema.mjs's enumNode()) carries both // `primitive` and `enum` — the primitive check above narrows the type, but the // closed vocabulary below still needs to run, so only return early when there @@ -146,7 +165,9 @@ function validateValue(typeNode, value, path, direction) { } if (referenced.kind === 'alias') { - return validateValue(referenced.type, value, path, direction) + const result = validateValue(referenced.type, value, path, direction) + checkBounds(typeNode, result, path) + return result } return value diff --git a/javascript/selenium-webdriver/test/bidi/serialization/record_test.js b/javascript/selenium-webdriver/test/bidi/serialization/record_test.js index 484822a7718e3..5e84a68274653 100644 --- a/javascript/selenium-webdriver/test/bidi/serialization/record_test.js +++ b/javascript/selenium-webdriver/test/bidi/serialization/record_test.js @@ -493,4 +493,118 @@ describe('serialization/record', function () { assert.strictEqual(parsed.choice, false) }) }) + + describe('numeric range and bounds validation', function () { + defineAlias('test.record.JsUint', { + primitive: 'integer', + min: 0, + max: 9007199254740991, + }) + + const LocateNodesParameters = defineRecord('test.record.LocateNodesParameters', [ + { name: 'maxNodeCount', wire: 'maxNodeCount', required: false, type: { ref: 'test.record.JsUint', min: 1 } }, + ]) + + const ResponseData = defineRecord('test.record.ResponseData', [ + { name: 'status', wire: 'status', required: true, type: { ref: 'test.record.JsUint' } }, + ]) + + const ViewportParameters = defineRecord('test.record.ViewportParameters', [ + { + name: 'devicePixelRatio', + wire: 'devicePixelRatio', + required: false, + type: { primitive: 'number', min: 0, exclusiveMin: true, nullable: true }, + }, + ]) + + const GeolocationCoordinates = defineRecord('test.record.GeolocationCoordinates', [ + { + name: 'heading', + wire: 'heading', + required: false, + type: { primitive: 'number', min: 0, max: 360, exclusiveMax: true, nullable: true }, + }, + ]) + + const PrintParameters = defineRecord('test.record.PrintParameters', [ + { + name: 'pageRanges', + wire: 'pageRanges', + required: false, + type: { list: { union: [{ ref: 'test.record.JsUint' }, { primitive: 'string' }] } }, + }, + ]) + + it('holds an alias-typed field to its declared range outbound', function () { + const resp = new ResponseData({ status: 200 }) + assert.strictEqual(resp.status, 200) + + assert.throws(() => new ResponseData({ status: -1 }), /expected >= 0, got -1/) + assert.throws(() => new ResponseData({ status: 9007199254740992 }), /expected <= 9007199254740991/) + }) + + it('rejects a value outside the range of the alias it names on receipt', function () { + const parsed = ResponseData.fromWire({ status: 200 }) + assert.strictEqual(parsed.status, 200) + + assert.throws(() => ResponseData.fromWire({ status: -1 }), /expected >= 0, got -1/) + assert.throws(() => ResponseData.fromWire({ status: 9007199254740992 }), /expected <= 9007199254740991/) + }) + + it('holds a field narrowing an alias to its tightened range (e.g. maxNodeCount 0 vs 1)', function () { + const valid = new LocateNodesParameters({ maxNodeCount: 1 }) + assert.strictEqual(valid.maxNodeCount, 1) + + const parsed = LocateNodesParameters.fromWire({ maxNodeCount: 1 }) + assert.strictEqual(parsed.maxNodeCount, 1) + + assert.throws(() => new LocateNodesParameters({ maxNodeCount: 0 }), /expected >= 1, got 0/) + assert.throws(() => LocateNodesParameters.fromWire({ maxNodeCount: 0 }), /expected >= 1, got 0/) + + assert.throws(() => new LocateNodesParameters({ maxNodeCount: -1 }), /expected >= 0, got -1/) + assert.throws(() => LocateNodesParameters.fromWire({ maxNodeCount: -1 }), /expected >= 0, got -1/) + }) + + it('enforces exclusive min bound on floating point numbers (e.g. devicePixelRatio > 0)', function () { + const valid = new ViewportParameters({ devicePixelRatio: 0.5 }) + assert.strictEqual(valid.devicePixelRatio, 0.5) + + const parsed = ViewportParameters.fromWire({ devicePixelRatio: 1 }) + assert.strictEqual(parsed.devicePixelRatio, 1) + + const nullVal = new ViewportParameters({ devicePixelRatio: null }) + assert.strictEqual(nullVal.devicePixelRatio, null) + + assert.throws(() => new ViewportParameters({ devicePixelRatio: 0 }), /expected > 0, got 0/) + assert.throws(() => ViewportParameters.fromWire({ devicePixelRatio: 0 }), /expected > 0, got 0/) + assert.throws(() => new ViewportParameters({ devicePixelRatio: -0.1 }), /expected > 0, got -0.1/) + }) + + it('enforces exclusive max bound on floating point numbers (e.g. heading < 360)', function () { + const valid = new GeolocationCoordinates({ heading: 359.9 }) + assert.strictEqual(valid.heading, 359.9) + + const zero = new GeolocationCoordinates({ heading: 0 }) + assert.strictEqual(zero.heading, 0) + + const parsed = GeolocationCoordinates.fromWire({ heading: 180 }) + assert.strictEqual(parsed.heading, 180) + + assert.throws(() => new GeolocationCoordinates({ heading: 360 }), /expected < 360, got 360/) + assert.throws(() => GeolocationCoordinates.fromWire({ heading: 360 }), /expected < 360, got 360/) + assert.throws(() => new GeolocationCoordinates({ heading: -1 }), /expected >= 0, got -1/) + }) + + it('holds union list elements to their declared numeric ranges (e.g. pageRanges)', function () { + const valid = new PrintParameters({ pageRanges: ['1-3', 2] }) + assert.deepStrictEqual(valid.pageRanges, ['1-3', 2]) + + const parsed = PrintParameters.fromWire({ pageRanges: ['1-3', 2] }) + assert.deepStrictEqual(parsed.pageRanges, ['1-3', 2]) + + assert.throws(() => new PrintParameters({ pageRanges: ['1-3', -1] }), ValidationError) + assert.throws(() => PrintParameters.fromWire({ pageRanges: ['1-3', -1] }), ValidationError) + }) + }) })