Added integration test

This commit is contained in:
delvedor
2018-10-30 17:27:07 +01:00
parent 55c507c423
commit 3962e2c031
4 changed files with 981 additions and 2 deletions

View File

@ -13,8 +13,8 @@
"search"
],
"scripts": {
"test": "npm run test:unit && npm run test:integration",
"test:unit": "tap test/unit/*.test.js -J -T --harmony",
"test": "npm run lint && npm run test:unit",
"test:unit": "tap test/unit/*.test.js -J -T",
"test:integration": "tap test/integration/index.js -T --harmony",
"lint": "standard",
"lint:fix": "standard --fix",

View File

@ -0,0 +1,52 @@
# `elasticsearch-js` integration test suite
> What? A README to explain how the integration test work??
Yes.
## Background
Elasticsearch offers its entire API via HTTP REST endpoints. You can find the whole API specification for every version [here](https://github.com/elastic/elasticsearch/tree/master/rest-api-spec/src/main/resources/rest-api-spec/api).<br/>
To support different languages at the same time, the Elasticsearch team decided to provide a [YAML specification](https://github.com/elastic/elasticsearch/tree/master/rest-api-spec/src/main/resources/rest-api-spec/test) to test every endpoint, body, headers, warning, error and so on.<br/>
This testing suite uses that specification to generate the test for the specified version of Elasticsearch on the fly.
## Run
Run the testing suite is very easy, you just need to run the preconfigured npm script:
```sh
npm run test:integration
```
The first time you run this command, the Elasticsearch repository will be cloned inside the integration test folder, to be able to access the YAML specification, so it might take some time *(luckily, only the first time)*.<br/>
Once the Elasticsearch repository has been cloned, the testing suite will connect to the provided Elasticsearch instance and then checkout the build hash in the repository. Finally, it will start running every test.
The specification does not allow the test to be run in parallel, so it might take a while to run the entire testing suite; on my machine, `MacBookPro15,2 core i7 2.7GHz 16GB of RAM` it takes around four minutes.
### Exit on the first failure
Bu default the suite will run all the test, even if one assertion has failed. If you want to stop the test at the first failure, use the bailout option:
```sh
npm run test:integration -- --bail
```
### Calculate the code coverage
If you want to calculate the code coverage just run the testing suite with the following parameters, once the test ends, it will open a browser window with the results.
```sh
npm run test:integration -- --cov --coverage-report=html
```
## How does this thing work?
At first sight, it might seem complicated, but once you understand what the moving parts are, it's quite easy.
1. Connects to the given Elasticsearch instance
1. Gets the ES version and build hash
1. Checkout to the given hash (and clone the repository if it is not present)
1. Reads the folder list and for each folder the yaml file list
1. Starts running folder by folder every file
1. Read and parse the yaml files
1. Creates a subtest structure to have a cleaner output
1. Runs the assertions
1. Repeat!
Inside the `index.js` file, you will find the connection, cloning, reading and parsing part of the test, while inside the `test-runner.js` file you will find the function to handle the assertions. Inside `test-runner.js`, we use a [queue](https://github.com/delvedor/workq) to be sure that everything is run in the correct order.
Checkout the [rest-api-spec readme](https://github.com/elastic/elasticsearch/blob/master/rest-api-spec/src/main/resources/rest-api-spec/test/README.asciidoc) if you want to know more about how the assertions work.
#### Why are we running the test with the `--harmony` flag?
Because on Node v6 the regex lookbehinds are not supported.

251
test/integration/index.js Normal file
View File

@ -0,0 +1,251 @@
'use strict'
const assert = require('assert')
const { readFileSync, accessSync, mkdirSync, readdirSync } = require('fs')
const { join } = require('path')
const yaml = require('js-yaml')
const Git = require('simple-git')
const ora = require('ora')
const minimist = require('minimist')
const tap = require('tap')
const elasticsearch = require('../../index')
const TestRunner = require('./test-runner')
const esRepo = 'https://github.com/elastic/elasticsearch.git'
const esFolder = join(__dirname, '..', '..', 'elasticsearch')
const yamlFolder = join(esFolder, 'rest-api-spec', 'src', 'main', 'resources', 'rest-api-spec', 'test')
function Runner (opts) {
if (!(this instanceof Runner)) {
return new Runner(opts)
}
opts = opts || {}
assert(opts.host, 'Missing host')
this.bailout = opts.bailout
this.client = new elasticsearch.Client({
host: opts.host
})
this.log = ora('Loading yaml suite').start()
}
/**
* Runs the test suite
*/
Runner.prototype.start = function () {
const getTest = this.getTest.bind(this)
const parse = this.parse.bind(this)
const client = this.client
// client.on('response', (request, response) => {
// console.log('\n\n')
// console.log('REQUEST', request)
// console.log('\n')
// console.log('RESPONSE', response)
// })
// Get the build hash of Elasticsearch
client.info((err, { body }) => {
if (err) {
this.log.fail(err.message)
return
}
const { number: version, build_hash: sha } = body.version
// Set the repository to the given sha and run the test suite
this.withSHA(sha, () => {
this.log.succeed('Done!')
runTest.call(this, version)
})
})
function runTest (version) {
const testFolders = getTest()
testFolders.forEach(runTestFolder.bind(this))
function runTestFolder (testFolder) {
// if (testFolder !== 'tasks.get') return
// create a subtest for the specific folder
tap.test(testFolder, { jobs: 1 }, tap1 => {
const files = getTest(testFolder)
files.forEach(file => {
// if (file !== '20_typed_keys.yml') return
// create a subtest for the specific folder + test file
tap1.test(file.slice(0, -4), { jobs: 1 }, tap2 => {
const path = join(yamlFolder, testFolder, file)
// read the yaml file
const data = readFileSync(path, 'utf8')
// get the test yaml (as object), some file has multiple yaml documents inside,
// every document is separated by '---', so we split on the separator
// and then we remove the empty strings, finally we parse them
const tests = data
.split('---')
.map(s => s.trim())
.filter(Boolean)
.map(parse)
// get setup and teardown if present
var setupTest = null
var teardownTest = null
tests.forEach(test => {
if (test.setup) setupTest = test.setup
if (test.teardown) teardownTest = test.teardown
})
// run the tests
tests.forEach(test => {
const name = Object.keys(test)[0]
if (name === 'setup' || name === 'teardown') return
// create a subtest for the specific folder + test file + test name
tap2.test(name, { jobs: 1, bail: this.bailout }, tap3 => {
const testRunner = TestRunner({ client, version, tap: tap3 })
testRunner.run(setupTest, test[name], teardownTest, () => tap3.end())
})
})
tap2.end()
})
})
tap1.end()
})
}
}
}
/**
* Parses a given yaml document
* @param {string} yaml document
* @returns {object}
*/
Runner.prototype.parse = function (data) {
try {
var doc = yaml.safeLoad(data)
} catch (err) {
this.log.fail(err.message)
return
}
return doc
}
/**
* Returns the content of the `yamlFolder`.
* If a folder name is given as parameter, it will return
* the content of join(yamlFolder, folder)
* @param {string} folder
* @returns {Array} The content of the given folder
*/
Runner.prototype.getTest = function (folder) {
const tests = readdirSync(join(yamlFolder, folder || ''))
return tests.filter(t => !/(README|TODO)/g.test(t))
}
/**
* Sets the elasticsearch repository to the given sha.
* If the repository is not present in `esFolder` it will
* clone the repository and the checkout the sha.
* If the repository is already present but it cannot checkout to
* the given sha, it will perform a pull and then try again.
* @param {string} sha
* @param {function} callback
*/
Runner.prototype.withSHA = function (sha, callback) {
var fresh = false
var retry = 0
var log = this.log
if (!this.pathExist(esFolder)) {
if (!this.createFolder(esFolder)) {
log.fail('Failed folder creation')
return
}
fresh = true
}
const git = Git(esFolder)
if (fresh) {
clone(checkout)
} else {
checkout()
}
function checkout () {
log.text = `Checking out sha '${sha}'`
git.checkout(sha, err => {
if (err) {
if (retry++ > 0) {
log.fail(`Cannot checkout sha '${sha}'`)
return
}
return pull(checkout)
}
callback()
})
}
function pull (cb) {
log.text = 'Pulling elasticsearch repository...'
git.pull(err => {
if (err) {
log.fail(err.message)
return
}
cb()
})
}
function clone (cb) {
log.text = 'Cloning elasticsearch repository...'
git.clone(esRepo, esFolder, err => {
if (err) {
log.fail(err.message)
return
}
cb()
})
}
}
/**
* Checks if the given path exists
* @param {string} path
* @returns {boolean} true if exists, false if not
*/
Runner.prototype.pathExist = function (path) {
try {
accessSync(path)
return true
} catch (err) {
return false
}
}
/**
* Creates the given folder
* @param {string} name
* @returns {boolean} true on success, false on failure
*/
Runner.prototype.createFolder = function (name) {
try {
mkdirSync(name)
return true
} catch (err) {
return false
}
}
if (require.main === module) {
const opts = minimist(process.argv.slice(2), {
string: ['host', 'version'],
boolean: ['bailout'],
default: {
host: 'http://localhost:9200',
version: '6.4',
bailout: false
}
})
const runner = Runner(opts)
runner.start()
}
module.exports = Runner

View File

@ -0,0 +1,676 @@
'use strict'
const t = require('tap')
const semver = require('semver')
const workq = require('workq')
const { ConfigurationError } = require('../../lib/errors')
const supportedFeatures = [
// 'gtelte',
// 'regex',
// 'benchmark',
// 'stash_in_path',
// 'groovy_scripting',
// 'headers'
]
function TestRunner (opts) {
if (!(this instanceof TestRunner)) {
return new TestRunner(opts)
}
opts = opts || {}
this.client = opts.client
this.esVersion = opts.version
this.response = null
this.stash = new Map()
this.tap = opts.tap || t
this.q = opts.q || workq()
}
/**
* Runs a cleanup, removes all indices and templates
* @param {queue}
* @param {function} done
*/
TestRunner.prototype.cleanup = function (q, done) {
this.tap.comment('Cleanup')
this.response = null
this.stash = new Map()
q.add((q, done) => {
this.client.indices.delete({ index: '*', ignore: 404 }, err => {
this.tap.error(err, 'should not error: indices.delete')
done()
})
})
q.add((q, done) => {
this.client.indices.deleteTemplate({ name: '*', ignore: 404 }, err => {
this.tap.error(err, 'should not error: indices.deleteTemplate')
done()
})
})
done()
}
/**
* Runs the given test.
* It runs the test components in the following order:
* - setup
* - the actual test
* - teardown
* - cleanup
* Internally uses a queue to guarantee the order of the test sections.
* @param {object} setup (null if not needed)
* @param {object} test
* @oaram {object} teardown (null if not needed)
* @param {function} end
*/
TestRunner.prototype.run = function (setup, test, teardown, end) {
// if we should skip a feature in the setup/teardown section
// we should skip the entire test file
const skip = getSkip(setup) || getSkip(teardown)
if (skip && this.shouldSkip(skip)) {
this.skip(skip)
return end()
}
if (setup) {
this.q.add((q, done) => {
this.exec('Setup', setup, q, done)
})
}
this.q.add((q, done) => {
this.exec('Test', test, q, done)
})
if (teardown) {
this.q.add((q, done) => {
this.exec('Teardown', teardown, q, done)
})
}
this.q.add((q, done) => {
this.cleanup(q, done)
})
this.q.add((q, done) => end() && done())
}
/**
* Logs a skip
* @param {object} the actions
* @returns {TestRunner}
*/
TestRunner.prototype.skip = function (action) {
if (action.reason && action.version) {
this.tap.skip(`Skip: ${action.reason} (${action.version})`)
} else if (action.features) {
this.tap.skip(`Skip: ${JSON.stringify(action.features)})`)
} else {
this.tap.skip('Skipped')
}
return this
}
/**
* Decides if a test should be skipped
* @param {object} the actions
* @returns {boolean}
*/
TestRunner.prototype.shouldSkip = function (action) {
var shouldSkip = false
// skip based on the version
if (action.version) {
if (action.version.trim() === 'all') return true
const [min, max] = action.version.split('-').map(v => v.trim())
// if both `min` and `max` are specified
if (min && max) {
shouldSkip = semver.satisfies(this.esVersion, action.version)
// if only `min` is specified
} else if (min) {
shouldSkip = semver.gte(this.esVersion, min)
// if only `max` is specified
} else if (max) {
shouldSkip = semver.lte(this.esVersion, max)
// something went wrong!
} else {
throw new Error(`skip: Bad version range: ${action.version}`)
}
}
if (shouldSkip) return true
if (action.features) {
if (!Array.isArray(action.features)) action.features = [action.features]
// returns true if one of the features is not present in the supportedFeatures
shouldSkip = !!action.features.filter(f => !~supportedFeatures.indexOf(f)).length
}
if (shouldSkip) return true
return false
}
/**
* Updates the array syntax of keys and values
* eg: 'hits.hits.1.stuff' to 'hits.hits[1].stuff'
* @param {object} the action to update
* @returns {obj} the updated action
*/
TestRunner.prototype.updateArraySyntax = function (obj) {
const newObj = {}
for (const key in obj) {
const newKey = key.replace(/\.\d{1,}\./g, v => `[${v.slice(1, -1)}].`)
const val = obj[key]
if (typeof val === 'string') {
newObj[newKey] = val.replace(/\.\d{1,}\./g, v => `[${v.slice(1, -1)}].`)
} else if (val !== null && typeof val === 'object') {
newObj[newKey] = this.updateArraySyntax(val)
} else {
newObj[newKey] = val
}
}
return newObj
}
/**
* Fill the stashed values of a command
* let's say the we have stashed the `master` value,
* is_true: nodes.$master.transport.profiles
* becomes
* is_true: nodes.new_value.transport.profiles
* @param {object|string} the action to update
* @returns {object|string} the updated action
*/
TestRunner.prototype.fillStashedValues = function (obj) {
if (typeof obj === 'string') {
return getStashedValues.call(this, obj)
}
// iterate every key of the object
for (const key in obj) {
const val = obj[key]
// if the key value is a string, and the string includes '$'
// we run the "update value" code
if (typeof val === 'string' && val.includes('$')) {
// update the key value
obj[key] = getStashedValues.call(this, val)
}
// go deep in the object
if (val !== null && typeof val === 'object') {
this.fillStashedValues(val)
}
}
return obj
function getStashedValues (str) {
return str
// we split the string on the dots
// handle the key with a dot inside that is not a part of the path
.split(/(?<!\\)\./g)
// we update every field that start with '$'
.map(part => {
if (part[0] === '$') {
const stashed = this.stash.get(part.slice(1))
if (stashed == null) {
throw new Error(`Cannot find stashed value '${part}' for '${JSON.stringify(obj)}'`)
}
return stashed
}
return part
})
// recreate the string value
.join('.')
}
}
/**
* Stashes a value
* @param {string} the key to search in the previous response
* @param {string} the name to identify the stashed value
* @returns {TestRunner}
*/
TestRunner.prototype.set = function (key, name) {
this.stash.set(name, delve(this.response, key))
return this
}
/**
* Runs a client command
* TODO: handle `action.warning`, `action.catch`...
* @param {object} the action to perform
* @param {Queue}
*/
TestRunner.prototype.do = function (action, done) {
const cmd = this.parseDo(action)
const api = delve(this.client, cmd.method).bind(this.client)
api(cmd.params, (err, { body }) => {
if (action.catch) {
this.tap.true(
parseDoError(err, action.catch),
`the error should be: ${action.catch}`
)
try {
this.response = JSON.parse(err.body)
} catch (e) {
this.response = err.body
}
} else {
this.tap.error(err, `should not error: ${cmd.method}`, action)
this.response = body
}
if (action.warning) {
this.tap.todo('Handle warnings')
}
done()
})
}
/**
* Runs an actual test
* @param {string} the name of the test
* @param {object} the actions to perform
* @param {Queue}
*/
TestRunner.prototype.exec = function (name, actions, q, done) {
this.tap.comment(name)
for (var i = 0; i < actions.length; i++) {
const action = actions[i]
if (action.skip) {
if (this.shouldSkip(action.skip)) {
this.skip(this.fillStashedValues(action.skip))
break
}
}
if (action.do) {
q.add((q, done) => {
this.do(this.fillStashedValues(action.do), done)
})
}
if (action.set) {
q.add((q, done) => {
const key = Object.keys(action.set)[0]
this.set(key, action.set[key])
done()
})
}
if (action.match) {
q.add((q, done) => {
const key = Object.keys(action.match)[0]
this.match(
// in some cases, the yaml refers to the body with an empty string
key === '$body' || key === ''
? this.response
: delve(this.response, this.fillStashedValues(key)),
key === '$body'
? action.match[key]
: this.fillStashedValues(action.match)[key]
)
done()
})
}
if (action.lt) {
q.add((q, done) => {
const key = Object.keys(action.lt)[0]
this.lt(
delve(this.response, this.fillStashedValues(key)),
this.fillStashedValues(action.lt)[key]
)
done()
})
}
if (action.gt) {
q.add((q, done) => {
const key = Object.keys(action.gt)[0]
this.gt(
delve(this.response, this.fillStashedValues(key)),
this.fillStashedValues(action.gt)[key]
)
done()
})
}
if (action.lte) {
q.add((q, done) => {
const key = Object.keys(action.lte)[0]
this.lte(
delve(this.response, this.fillStashedValues(key)),
this.fillStashedValues(action.lte)[key]
)
done()
})
}
if (action.gte) {
q.add((q, done) => {
const key = Object.keys(action.gte)[0]
this.gte(
delve(this.response, this.fillStashedValues(key)),
this.fillStashedValues(action.gte)[key]
)
done()
})
}
if (action.length) {
q.add((q, done) => {
const key = Object.keys(action.length)[0]
this.length(
delve(this.response, this.fillStashedValues(key)),
this.fillStashedValues(action.length)[key]
)
done()
})
}
if (action.is_true) {
q.add((q, done) => {
const isTrue = this.fillStashedValues(action.is_true)
this.is_true(
delve(this.response, isTrue),
isTrue
)
done()
})
}
if (action.is_false) {
q.add((q, done) => {
const isFalse = this.fillStashedValues(action.is_false)
this.is_false(
delve(this.response, isFalse),
isFalse
)
done()
})
}
}
done()
}
/**
* Asserts that the given value is truthy
* @param {any} the value to check
* @param {string} an optional message
*/
TestRunner.prototype.is_true = function (val, msg) {
this.tap.true(val, `expect truthy value: ${msg} - value: ${JSON.stringify(val)}`)
return this
}
/**
* Asserts that the given value is falsey
* @param {any} the value to check
* @param {string} an optional message
*/
TestRunner.prototype.is_false = function (val, msg) {
this.tap.false(val, `expect falsey value: ${msg} - value: ${JSON.stringify(val)}`)
return this
}
/**
* Asserts that two values are the same
* @param {any} the first value
* @param {any} the second value
* @returns {TestRunner}
*/
TestRunner.prototype.match = function (val1, val2) {
// both values are objects
if (typeof val1 === 'object' && typeof val2 === 'object') {
this.tap.strictDeepEqual(val1, val2)
// the first value is the body as string and the second a pattern string
} else if (
typeof val1 === 'string' && typeof val2 === 'string' &&
val2.startsWith('/') && (val2.endsWith('/\n') || val2.endsWith('/'))
) {
const regStr = val2
// match all comments within a "regexp" match arg
.replace(/([\S\s]?)#[^\n]*\n/g, (match, prevChar) => {
return prevChar === '\\' ? match : `${prevChar}\n`
})
// remove all whitespace from the expression, all meaningful
// whitespace is represented with \s
.replace(/\s/g, '')
.slice(1, -1)
// 'm' adds the support for multiline regex
this.tap.match(val1, new RegExp(regStr, 'm'), `should match pattern provided: ${val2}`)
// everything else
} else {
this.tap.strictEqual(val1, val2, `should be equal: ${val1} - ${val2}`)
}
return this
}
/**
* Asserts that the first value is less than the second
* It also verifies that the two values are numbers
* @param {any} the first value
* @param {any} the second value
* @returns {TestRunner}
*/
TestRunner.prototype.lt = function (val1, val2) {
;[val1, val2] = getNumbers(val1, val2)
this.tap.true(val1 < val2)
return this
}
/**
* Asserts that the first value is greater than the second
* It also verifies that the two values are numbers
* @param {any} the first value
* @param {any} the second value
* @returns {TestRunner}
*/
TestRunner.prototype.gt = function (val1, val2) {
;[val1, val2] = getNumbers(val1, val2)
this.tap.true(val1 > val2)
return this
}
/**
* Asserts that the first value is less than or equal the second
* It also verifies that the two values are numbers
* @param {any} the first value
* @param {any} the second value
* @returns {TestRunner}
*/
TestRunner.prototype.lte = function (val1, val2) {
;[val1, val2] = getNumbers(val1, val2)
this.tap.true(val1 <= val2)
return this
}
/**
* Asserts that the first value is greater than or equal the second
* It also verifies that the two values are numbers
* @param {any} the first value
* @param {any} the second value
* @returns {TestRunner}
*/
TestRunner.prototype.gte = function (val1, val2) {
;[val1, val2] = getNumbers(val1, val2)
this.tap.true(val1 >= val2)
return this
}
/**
* Asserts that the given value has the specified length
* @param {string|object|array} the object to check
* @param {number} the expected length
* @returns {TestRunner}
*/
TestRunner.prototype.length = function (val, len) {
if (typeof val === 'string' || Array.isArray(val)) {
this.tap.strictEqual(val.length, len)
} else if (typeof val === 'object' && val !== null) {
this.tap.strictEqual(Object.keys(val).length, len)
} else {
this.tap.fail(`length: the given value is invalid: ${val}`)
}
return this
}
/**
* Gets a `do` action object and returns a structured object,
* where the action is the key and the parameter is the value.
* Eg:
* {
* 'indices.create': {
* 'index': 'test'
* },
* 'warnings': [
* '[index] is deprecated'
* ]
* }
* becomes
* {
* method: 'indices.create',
* params: {
* index: 'test'
* },
* warnings: [
* '[index] is deprecated'
* ]
* }
* @param {object}
* @returns {object}
*/
TestRunner.prototype.parseDo = function (action) {
return Object.keys(action).reduce((acc, val) => {
switch (val) {
case 'catch':
acc.catch = action.catch
break
case 'warnings':
acc.warnings = action.warnings
break
case 'node_selector':
acc.node_selector = action.node_selector
break
default:
// converts underscore to camelCase
// eg: put_mapping => putMapping
acc.method = val.replace(/_([a-z])/g, g => g[1].toUpperCase())
acc.params = camelify(action[val])
}
return acc
}, {})
function camelify (obj) {
const newObj = {}
// TODO: add camelCase support for this fields
const doNotCamelify = ['copy_settings']
for (const key in obj) {
const val = obj[key]
var newKey = key
if (!~doNotCamelify.indexOf(key)) {
// if the key starts with `_` we should not camelify the first occurence
// eg: _source_include => _sourceInclude
newKey = key[0] === '_'
? '_' + key.slice(1).replace(/_([a-z])/g, k => k[1].toUpperCase())
: key.replace(/_([a-z])/g, k => k[1].toUpperCase())
}
if (
val !== null &&
typeof val === 'object' &&
!Array.isArray(val) &&
key !== 'body'
) {
newObj[newKey] = camelify(val)
} else {
newObj[newKey] = val
}
}
return newObj
}
}
function parseDoError (err, spec) {
const httpErrors = {
bad_request: 400,
unauthorized: 401,
forbidden: 403,
missing: 404,
request_timeout: 408,
conflict: 409,
unavailable: 503
}
if (httpErrors[spec]) {
return err.statusCode === httpErrors[spec]
}
if (spec === 'request') {
return err.statusCode >= 400 && err.statusCode < 600
}
if (spec.startsWith('/') && spec.endsWith('/')) {
return new RegExp(spec.slice(1, -1), 'g').test(JSON.stringify(err.body))
}
if (spec === 'param') {
return err instanceof ConfigurationError
}
return false
}
function getSkip (arr) {
if (!Array.isArray(arr)) return null
for (var i = 0; i < arr.length; i++) {
if (arr[i].skip) return arr[i].skip
}
return null
}
// code from https://github.com/developit/dlv
// needed to support an edge case: `a\.b`
// where `a.b` is a single field: { 'a.b': true }
function delve (obj, key, def, p) {
p = 0
// handle the key with a dot inside that is not a part of the path
// and removes the backslashes from the key
key = key.split
? key.split(/(?<!\\)\./g).map(k => k.replace(/\\/g, ''))
: key.replace(/\\/g, '')
while (obj && p < key.length) obj = obj[key[p++]]
return (obj === undefined || p < key.length) ? def : obj
}
// Gets two *maybe* numbers and returns two valida numbers
// it throws if one or both are not a valid number
// the returned value is an array with the new values
function getNumbers (val1, val2) {
const val1Numeric = Number(val1)
if (isNaN(val1Numeric)) {
throw new TypeError(`val1 is not a valid number: ${val1}`)
}
const val2Numeric = Number(val2)
if (isNaN(val2Numeric)) {
throw new TypeError(`val2 is not a valid number: ${val2}`)
}
return [val1Numeric, val2Numeric]
}
module.exports = TestRunner