feat(base-driver): Remove dependency to validate.js (#21175)

This commit is contained in:
Mykola Mokhnach
2025-04-04 22:28:53 +02:00
committed by GitHub
parent d904726d56
commit 661c5d0bd3
10 changed files with 243 additions and 221 deletions
+5 -5
View File
@@ -17260,6 +17260,7 @@
},
"node_modules/validate.js": {
"version": "0.13.1",
"dev": true,
"license": "MIT"
},
"node_modules/vary": {
@@ -18368,8 +18369,7 @@
"path-to-regexp": "8.2.0",
"serve-favicon": "2.5.0",
"source-map-support": "0.5.21",
"type-fest": "4.38.0",
"validate.js": "0.13.1"
"type-fest": "4.38.0"
},
"engines": {
"node": "^20.9.0 || >=22.11.0",
@@ -20313,8 +20313,7 @@
"serve-favicon": "2.5.0",
"source-map-support": "0.5.21",
"spdy": "4.0.2",
"type-fest": "4.38.0",
"validate.js": "0.13.1"
"type-fest": "4.38.0"
},
"dependencies": {
"accepts": {
@@ -32929,7 +32928,8 @@
"dev": true
},
"validate.js": {
"version": "0.13.1"
"version": "0.13.1",
"dev": true
},
"vary": {
"version": "1.1.2"
+65 -41
View File
@@ -5,7 +5,6 @@
const path = require('path');
const yaml = require('yaml');
const {fs, util, logger} = require('@appium/support');
const validate = require('validate.js');
const Handlebars = require('handlebars');
const _ = require('lodash');
const {asyncify} = require('asyncbox');
@@ -13,47 +12,48 @@ const url = require('url');
const log = logger.getLogger('YamlParser');
validate.validators.array = function array(value, options, key, attributes) {
if (attributes[key] && !validate.isArray(attributes[key])) {
return `must be an array`;
}
};
const validators = {
isArray: (value, options) => {
if (_.isUndefined(value) || _.isArray(value) || !options) {
return null;
}
return 'must be an array';
},
presence: (value, options) => {
if (_.isUndefined(value) && options) {
return 'must be present';
}
return null;
},
hasAttributes: (value, options) => {
if (!value || !options) {
return null;
}
validate.validators.hasAttributes = function hasAttributes(value, options) {
if (!value) {
return;
}
if (!_.isArray(value)) {
value = [value];
}
for (const item of value) {
for (const option of options) {
if (_.isUndefined(item[option])) {
return `must have attributes: ${options}`;
for (const item of (_.isArray(value) ? value : [value])) {
for (const option of options) {
if (!_.has(item, option)) {
return `must have attributes: ${options}`;
}
}
}
}
};
return null;
},
hasPossibleAttributes(value, options) {
if (!value || !_.isArray(value)) {
// if just a bare value or empty, allow it through
return null;
}
validate.validators.hasPossibleAttributes = function hasPossibleAttributes(value, options) {
if (!value) {
return;
}
// if just a bare value, allow it through
if (!_.isArray(value)) {
return;
}
for (const item of value) {
for (const key of _.keys(item)) {
if (!options.includes(key)) {
return `must not include '${key}'. Available options: ${options}`;
for (const item of value) {
for (const key of _.keys(item)) {
if (!_.includes(options, key)) {
return `must not include '${key}'. Available options: ${options}`;
}
}
}
}
return null;
},
};
const CLIENT_URL_TYPES = {
@@ -62,7 +62,7 @@ const CLIENT_URL_TYPES = {
ios: 'iOS',
};
const validator = {
const constraints = {
name: {presence: true},
short_description: {presence: true},
example_usage: {},
@@ -87,16 +87,16 @@ const validator = {
driver_support: {presence: true},
'endpoint.url': {presence: true},
'endpoint.url_parameters': {
array: true,
isArray: true,
hasAttributes: ['name', 'description'],
},
'endpoint.json_parameters': {
array: true,
isArray: true,
hasAttributes: ['name', 'description'],
},
'endpoint.response': {hasAttributes: ['type', 'description']},
specifications: {presence: true},
links: {array: true, hasAttributes: ['name', 'url']},
links: {isArray: true, hasAttributes: ['name', 'url']},
};
// What range of platforms do the driver's support
@@ -273,6 +273,30 @@ async function registerSpecUrlHelper() {
const YAML_DIR = path.join(__dirname, '..', 'commands-yml');
function validate(values) {
const result = {};
for (const [key, constraint] of _.toPairs(constraints)) {
const value = values[key];
for (const [validatorName, options] of _.toPairs(constraint)) {
if (!(validatorName in validators)) {
continue;
}
const validationError = validators[validatorName](value, options, key);
if (_.isNil(validationError)) {
continue;
}
if (key in result) {
result[key].push(validationError);
} else {
result[key] = [validationError];
}
}
}
return _.isEmpty(result) ? null : result;
}
async function generateCommands() {
await registerSpecUrlHelper();
@@ -295,7 +319,7 @@ async function generateCommands() {
const inputYML = await fs.readFile(filename, 'utf8');
const inputJSON = yaml.parse(inputYML);
inputJSON.ymlFileName = `/${path.relative(rootFolder, filename)}`;
const validationErrors = validate(inputJSON, validator);
const validationErrors = validate(inputJSON);
if (validationErrors) {
throw new Error(`Data validation error for ${filename}: ${JSON.stringify(validationErrors)}`);
}
+1 -1
View File
@@ -81,7 +81,7 @@ describe('utils', function () {
const err = parseCapsForInnerDriver(W3C_CAPS, {
hello: {presence: true},
}).error;
err.message.should.match(/'hello' can't be blank/);
err.message.should.match(/required/);
_.isError(err).should.be.true;
});
it('should only accept W3C caps that have passing constraints', function () {
@@ -10,7 +10,7 @@ import type {
MergeExclusive,
} from 'type-fest';
import _ from 'lodash';
import {validator} from './desired-caps';
import {validator} from './validation';
import {util} from '@appium/support';
import log from './logger';
import {errors} from '../protocol/errors';
@@ -94,9 +94,7 @@ export function validateCaps<C extends Constraints>(
)
) as C;
const validationErrors = validator.validate(_.pickBy(caps, util.hasValue), constraints, {
fullMessages: false,
});
const validationErrors = validator.validate(_.pickBy(caps, util.hasValue), constraints);
if (validationErrors) {
const message: string[] = [];
@@ -1,103 +0,0 @@
import log from './logger';
import _validator from 'validate.js';
import B from 'bluebird';
export const validator =
/** @type {import('validate.js').ValidateJS & {promise: typeof import('bluebird')}} */ (
_validator
);
validator.validators.isString = function isString(value) {
if (typeof value === 'string') {
return null;
}
if (typeof value === 'undefined') {
return null;
}
return 'must be of type string';
};
validator.validators.isNumber = function isNumber(value) {
if (typeof value === 'number') {
return null;
}
if (typeof value === 'undefined') {
return null;
}
// allow a string value
if (typeof value === 'string' && !isNaN(Number(value))) {
log.warn('Number capability passed in as string. Functionality may be compromised.');
return null;
}
return 'must be of type number';
};
validator.validators.isBoolean = function isBoolean(value) {
if (typeof value === 'boolean') {
return null;
}
// allow a string value
if (typeof value === 'string' && ['true', 'false', ''].includes(value)) {
return null;
}
if (typeof value === 'undefined') {
return null;
}
return 'must be of type boolean';
};
validator.validators.isObject = function isObject(value) {
if (typeof value === 'object') {
return null;
}
if (typeof value === 'undefined') {
return null;
}
return 'must be of type object';
};
validator.validators.isArray = function isArray(value) {
if (Array.isArray(value)) {
return null;
}
if (typeof value === 'undefined') {
return null;
}
return 'must be of type array';
};
validator.validators.deprecated = function deprecated(value, options, key) {
// do not print caps that hasn't been provided.
if (typeof value !== 'undefined' && options) {
log.warn(
`The '${key}' capability has been deprecated and must not be used anymore. ` +
`Please check the driver documentation for possible alternatives.`
);
}
return null;
};
validator.validators.inclusionCaseInsensitive = function inclusionCaseInsensitive(value, options) {
if (typeof value === 'undefined') {
return null;
} else if (typeof value !== 'string') {
return 'unrecognised';
}
for (let option of options) {
if (option.toLowerCase() === value.toLowerCase()) {
return null;
}
}
return `${value} not part of ${options.toString()}`;
};
validator.promise = B;
validator.prettify = function prettify(val) {
return val;
};
@@ -410,9 +410,9 @@ export class BaseDriver<
} catch (e) {
throw this.log.errorWithException(
new errors.SessionNotCreatedError(
`The desiredCapabilities object was not valid for the ` +
`following reason(s): ${e.message}`,
),
`Session capabilities were not valid for the ` +
`following reason(s): ${e.message}`, e
)
);
}
+145
View File
@@ -0,0 +1,145 @@
import type { Constraint } from '@appium/types';
import log from './logger';
import _ from 'lodash';
export class Validator {
private readonly _validators: Record<
keyof Constraint,
(value: any, options?: any, key?: string) => string | null
> = {
isString: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
return null;
}
if (_.isString(value)) {
return options ? null : 'must not be of type string';
}
return options ? 'must be of type string' : null;
},
isNumber: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
return null;
}
if (_.isNumber(value)) {
return options ? null : 'must not be of type number';
}
// allow a string value
if (options && _.isString(value) && !isNaN(Number(value))) {
log.warn('Number capability passed in as string. Functionality may be compromised.');
return null;
}
return options ? 'must be of type number' : null;
},
isBoolean: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
return null;
}
if (_.isBoolean(value)) {
return options ? null : 'must not be of type boolean';
}
// allow a string value
if (options && _.isString(value) && ['true', 'false', ''].includes(value)) {
return null;
}
return options ? 'must be of type boolean' : null;
},
isObject: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
return null;
}
if (_.isPlainObject(value)) {
return options ? null : 'must not be a plain object';
}
return options ? 'must be a plain object' : null;
},
isArray: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || _.isNil(options)) {
return null;
}
if (_.isArray(value)) {
return options ? null : 'must not be of type array';
}
return options ? 'must be of type array' : null;
},
deprecated: (value: any, options?: any, key?: string): string | null => {
if (!_.isUndefined(value) && options) {
log.warn(
`The '${key}' capability has been deprecated and must not be used anymore. ` +
`Please check the driver documentation for possible alternatives.`
);
}
return null;
},
inclusion: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || !options) {
return null;
}
const optionsArr = _.isArray(options) ? options : [options];
if (optionsArr.some((opt) => opt === value)) {
return null;
}
return `must be contained by ${JSON.stringify(optionsArr)}`;
},
inclusionCaseInsensitive: (value: any, options?: any): string | null => {
if (_.isUndefined(value) || !options) {
return null;
}
const optionsArr = _.isArray(options) ? options : [options];
if (optionsArr.some((opt) => _.toLower(opt) === _.toLower(value))) {
return null;
}
return `must be contained by ${JSON.stringify(optionsArr)}`;
},
presence: (value: any, options?: any): string | null => {
if (_.isUndefined(value) && options) {
return 'is required to be present';
}
if (
!options?.allowEmpty &&
((!_.isUndefined(value) && _.isEmpty(value)) || (_.isString(value) && !_.trim(value)))
) {
return 'must not be empty or blank';
}
return null;
}
};
validate(values: Record<string, any>, constraints: Record<string, Constraint>): Record<string, string[]> | null {
const result: Record<string, string[]> = {};
for (const [key, constraint] of _.toPairs(constraints)) {
const value = values[key];
for (const [validatorName, options] of _.toPairs(constraint)) {
if (!(validatorName in this._validators)) {
continue;
}
const validationError = this._validators[validatorName](value, options, key);
if (_.isNil(validationError)) {
continue;
}
if (key in result) {
result[key].push(validationError);
} else {
result[key] = [validationError];
}
}
}
return _.isEmpty(result) ? null : result;
}
}
export const validator = new Validator();
+1 -2
View File
@@ -62,8 +62,7 @@
"path-to-regexp": "8.2.0",
"serve-favicon": "2.5.0",
"source-map-support": "0.5.21",
"type-fest": "4.38.0",
"validate.js": "0.13.1"
"type-fest": "4.38.0"
},
"optionalDependencies": {
"spdy": "4.0.2"
@@ -39,7 +39,7 @@ describe('caps', function () {
describe('throws errors if constraints are not met', function () {
it('returns invalid argument error if "present" constraint not met on property', function () {
(() => validateCaps({}, {foo: {presence: true}})).should.throw(/'foo' can't be blank/);
(() => validateCaps({}, {foo: {presence: true}})).should.throw(/'foo' is required/);
});
it('returns the capability that was passed in if "skipPresenceConstraint" is false', function () {
@@ -69,13 +69,13 @@ describe('caps', function () {
it('returns invalid argument error if "inclusion" constraint not met on property', function () {
(() =>
validateCaps({foo: '3'}, {foo: {inclusionCaseInsensitive: ['1', '2']}})).should.throw(
/'foo' 3 not part of 1,2/
/'foo' must be contained/
);
});
it('returns invalid argument error if "inclusionCaseInsensitive" constraint not met on property', function () {
(() => validateCaps({foo: 'a'}, {foo: {inclusion: ['A', 'B', 'C']}})).should.throw(
/'foo' a is not included in the list/
/'foo' must be contained/
);
});
});
@@ -361,7 +361,7 @@ describe('caps', function () {
presence: true,
},
}
)).should.throw(/'missingCap' can't be blank/);
)).should.throw(/'missingCap' is required/);
});
describe('validate Appium constraints', function () {
@@ -2,7 +2,7 @@
import B from 'bluebird';
import {BaseDriver, errors} from '../../../lib/index';
import {validator} from '../../../lib/basedriver/desired-caps';
import {validator} from '../../../lib/basedriver/validation';
// eslint-disable-next-line import/named
import {createSandbox} from 'sinon';
@@ -29,7 +29,7 @@ describe('Desired Capabilities', function () {
d = new BaseDriver();
sandbox = createSandbox();
logWarnSpy = sandbox.spy(d.log, 'warn');
deprecatedStub = sandbox.stub(validator.validators, 'deprecated');
deprecatedStub = sandbox.stub(validator._validators, 'deprecated');
});
afterEach(function () {
@@ -344,66 +344,25 @@ describe('Desired Capabilities', function () {
}
});
it('should still validate null/undefined/empty caps whose presence is required', async function () {
d.desiredCapConstraints = {
foo: {
presence: true,
},
};
await d
.createSession({
alwaysMatch: {
platformName: 'iOS',
'appium:foo': null,
for (const capValue of [null, '', {}, [], ' ']) {
it(`should still validate ${JSON.stringify(capValue)} whose presence is required`, async function () {
d.desiredCapConstraints = {
foo: {
presence: true,
},
firstMatch: [{}],
})
.should.be.rejectedWith(/blank/);
};
await d
// @ts-expect-error `null` is not actually allowed here
.createSession(null, {
alwaysMatch: {
platformName: 'iOS',
'appium:foo': '',
},
firstMatch: [{}],
})
.should.be.rejectedWith(/blank/);
await d
.createSession({
firstMatch: [
{
await d
.createSession({
alwaysMatch: {
platformName: 'iOS',
'appium:foo': {},
'appium:foo': capValue,
},
],
alwaysMatch: {},
})
.should.be.rejectedWith(/blank/);
await d
.createSession({
alwaysMatch: {
platformName: 'iOS',
'appium:foo': [],
},
firstMatch: [{}],
})
.should.be.rejectedWith(/blank/);
await d
.createSession({
alwaysMatch: {
platformName: 'iOS',
'appium:foo': ' ',
},
firstMatch: [{}],
})
.should.be.rejectedWith(/blank/);
});
firstMatch: [{}],
})
.should.be.rejectedWith(/(blank|required)/);
});
}
describe('w3c', function () {
it('should accept w3c capabilities', async function () {