483 lines
16 KiB
JavaScript
483 lines
16 KiB
JavaScript
/*
|
|
* Copyright Elasticsearch B.V. and contributors
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
'use strict'
|
|
|
|
const { join, sep } = require('node:path')
|
|
const { readFileSync, writeFileSync, promises } = require('node:fs')
|
|
const yaml = require('js-yaml')
|
|
const { rimraf } = require('rimraf')
|
|
const { mkdir } = promises
|
|
|
|
const generatedTestsPath = join(__dirname, '..', '..', 'generated-tests')
|
|
|
|
const stackSkips = [
|
|
// test definition bug: response is empty string
|
|
'cat/fielddata.yml',
|
|
// test definition bug: response is empty string
|
|
'cluster/delete_voting_config_exclusions.yml',
|
|
// test definition bug: response is empty string
|
|
'cluster/voting_config_exclusions.yml',
|
|
// client bug: ILM request takes a "body" param, but "body" is a special keyword in the JS client
|
|
'ilm/10_basic.yml',
|
|
// health report is... not healthy
|
|
'health_report.yml',
|
|
// TODO: `contains` action only supports checking for primitives inside arrays or strings inside strings, not referenced values like objects inside arrays
|
|
'entsearch/10_basic.yml',
|
|
// test definition bug: error message does not match
|
|
'entsearch/30_sync_jobs_stack.yml',
|
|
// no handler found for uri [/knn_test/_knn_search]
|
|
'knn_search.yml',
|
|
// TODO: fix license on ES startup - "Operation failed: Current license is basic."
|
|
'license/10_stack.yml',
|
|
// response.body should be truthy. found: ""
|
|
'logstash/10_basic.yml',
|
|
// test definition bug? security_exception: unable to authenticate user [x_pack_rest_user] for REST request [/_ml/trained_models/test_model/definition/0]
|
|
'machine_learning/clear_tm_deployment_cache.yml',
|
|
// client bug: 0.99995 does not equal 0.5
|
|
'machine_learning/data_frame_evaluate.yml',
|
|
// test definition bug? regex has whitespace, maybe needs to be removed
|
|
'machine_learning/explain_data_frame_analytics.yml',
|
|
// client bug: 4 != 227
|
|
'machine_learning/preview_datafeed.yml',
|
|
// test definition bug: error message does not match
|
|
'machine_learning/revert_model_snapshot.yml',
|
|
// test definition bug: error message does not match
|
|
'machine_learning/update_model_snapshot.yml',
|
|
// version_conflict_engine_exception
|
|
'machine_learning/jobs_crud.yml',
|
|
// test definition bug: error message does not match
|
|
'machine_learning/model_snapshots.yml',
|
|
// test definition bug: error message does not match
|
|
'query_rules/30_test.yml',
|
|
// client bug: 0 != 0.1
|
|
'script/10_basic.yml',
|
|
// client bug: request takes a "body" param, but "body" is a special keyword in the JS client
|
|
'searchable_snapshots/10_basic.yml',
|
|
// test builder bug: does `match` action need to support "array contains value"?
|
|
'security/10_api_key_basic.yml',
|
|
// test definition bug: error message does not match
|
|
'security/140_user.yml',
|
|
// test definition bug: error message does not match
|
|
'security/30_privileges_stack.yml',
|
|
// unknown issue: $profile.enabled path doesn't exist in response
|
|
'security/130_user_profile.yml',
|
|
// test definition bug: error message does not match
|
|
'security/change_password.yml',
|
|
// test builder bug: media_type_header_exception
|
|
'simulate/ingest.yml',
|
|
// client bug: request takes a "body" param, but "body" is a special keyword in the JS client
|
|
'snapshot/10_basic.yml',
|
|
// test definition bug: illegal_argument_exception
|
|
'sql/10_basic.yml',
|
|
// test definition bug: illegal_argument_exception
|
|
'text_structure/10_basic.yml',
|
|
// test definition bug: illegal_argument_exception
|
|
'transform/10_basic.yml',
|
|
]
|
|
|
|
const serverlessSkips = [
|
|
// TODO: sql.getAsync does not set a content-type header but ES expects one
|
|
// transport only sets a content-type if the body is not empty
|
|
'sql/10_basic.yml',
|
|
// TODO: bulk call in setup fails due to "malformed action/metadata line"
|
|
// bulk body is being sent as a Buffer, unsure if related.
|
|
'transform/10_basic.yml',
|
|
// TODO: scripts_painless_execute expects {"result":"0.1"}, gets {"result":"0"}
|
|
// body sent as Buffer, unsure if related
|
|
'script/10_basic.yml',
|
|
// TODO: expects {"outlier_detection.auc_roc.value":0.99995}, gets {"outlier_detection.auc_roc.value":0.5}
|
|
// remove if/when https://github.com/elastic/elasticsearch-clients-tests/issues/37 is resolved
|
|
'machine_learning/data_frame_evaluate.yml',
|
|
// TODO: Cannot perform requested action because job [job-crud-test-apis] is not open
|
|
'machine_learning/jobs_crud.yml',
|
|
// TODO: test runner needs to support ignoring 410 errors
|
|
'enrich/10_basic.yml',
|
|
// TODO: parameter `enabled` is not allowed in source
|
|
// Same underlying problem as https://github.com/elastic/elasticsearch-clients-tests/issues/55
|
|
'cluster/component_templates.yml',
|
|
// TODO: expecting `ct_field` field mapping to be returned, but instead only finds `field`
|
|
'indices/simulate_template.yml',
|
|
'indices/simulate_index_template.yml',
|
|
// TODO: test currently times out
|
|
'inference/10_basic.yml',
|
|
// TODO: Fix: "Trained model deployment [test_model] is not allocated to any nodes"
|
|
'machine_learning/20_trained_model_serverless.yml',
|
|
// TODO: query_rules api not available yet
|
|
'query_rules/10_query_rules.yml',
|
|
'query_rules/20_rulesets.yml',
|
|
'query_rules/30_test.yml',
|
|
// TODO: security.putRole API not available
|
|
'security/50_roles_serverless.yml',
|
|
// TODO: expected undefined to equal 'some_table'
|
|
'entsearch/50_connector_updates.yml',
|
|
// TODO: resource_not_found_exception
|
|
'tasks_serverless.yml',
|
|
]
|
|
|
|
function parse (data) {
|
|
let doc
|
|
try {
|
|
doc = yaml.load(data, { schema: yaml.CORE_SCHEMA })
|
|
} catch (err) {
|
|
console.error(err)
|
|
return
|
|
}
|
|
return doc
|
|
}
|
|
|
|
async function build (yamlFiles, clientOptions) {
|
|
await rimraf(generatedTestsPath)
|
|
await mkdir(generatedTestsPath, { recursive: true })
|
|
|
|
for (const file of yamlFiles) {
|
|
const apiName = file.split(`${sep}tests${sep}`)[1]
|
|
const data = readFileSync(file, 'utf8')
|
|
|
|
const tests = data
|
|
.split('\n---\n')
|
|
.map(s => s.trim())
|
|
// empty strings
|
|
.filter(Boolean)
|
|
.map(parse)
|
|
// null values
|
|
.filter(Boolean)
|
|
|
|
let code = "import { test } from 'tap'\n"
|
|
code += "import { Client } from '@elastic/elasticsearch'\n\n"
|
|
|
|
const requires = tests.find(test => test.requires != null)
|
|
let skip = new Set()
|
|
if (requires != null) {
|
|
const { serverless = true, stack = true } = requires.requires
|
|
if (!serverless) skip.add('process.env.TEST_ES_SERVERLESS === "1"')
|
|
if (!stack) skip.add('process.env.TEST_ES_STACK === "1"')
|
|
}
|
|
|
|
if (stackSkips.includes(apiName)) skip.add('process.env.TEST_ES_STACK === "1"')
|
|
if (serverlessSkips.includes(apiName)) skip.add('process.env.TEST_ES_SERVERLESS === "1"')
|
|
|
|
if (skip.size > 0) {
|
|
code += `test('${apiName}', { skip: ${Array.from(skip).join(' || ')} }, t => {\n`
|
|
} else {
|
|
code += `test('${apiName}', t => {\n`
|
|
}
|
|
|
|
for (const test of tests) {
|
|
if (test.setup != null) {
|
|
code += ' t.before(async () => {\n'
|
|
code += indent(buildActions(test.setup), 4)
|
|
code += ' })\n\n'
|
|
}
|
|
|
|
if (test.teardown != null) {
|
|
code += ' t.after(async () => {\n'
|
|
code += indent(buildActions(test.teardown), 4)
|
|
code += ' })\n\n'
|
|
}
|
|
|
|
for (const key of Object.keys(test).filter(k => !['setup', 'teardown', 'requires'].includes(k))) {
|
|
if (test[key].find(action => Object.keys(action)[0] === 'skip') != null) {
|
|
code += ` t.test('${key}', { skip: true }, async t => {\n`
|
|
} else {
|
|
code += ` t.test('${key}', async t => {\n`
|
|
}
|
|
code += indent(buildActions(test[key]), 4)
|
|
code += '\n t.end()\n'
|
|
code += ' })\n'
|
|
}
|
|
// if (test.requires != null) requires = test.requires
|
|
}
|
|
|
|
code += '\n t.end()\n'
|
|
code += '})\n'
|
|
|
|
const testDir = join(generatedTestsPath, apiName.split(sep).slice(0, -1).join(sep))
|
|
const testFile = join(testDir, apiName.split(sep).pop().replace(/\.ya?ml$/, '.mjs'))
|
|
await mkdir(testDir, { recursive: true })
|
|
writeFileSync(testFile, code, 'utf8')
|
|
}
|
|
|
|
function buildActions (actions) {
|
|
let code = `const client = new Client(${JSON.stringify(clientOptions, null, 2)})\n`
|
|
code += 'let response\n\n'
|
|
|
|
const vars = new Set()
|
|
|
|
for (const action of actions) {
|
|
const key = Object.keys(action)[0]
|
|
switch (key) {
|
|
case 'do':
|
|
code += buildDo(action.do)
|
|
break
|
|
case 'set':
|
|
const setResult = buildSet(action.set, vars)
|
|
vars.add(setResult.varName)
|
|
code += setResult.code
|
|
break
|
|
case 'transform_and_set':
|
|
code += buildTransformAndSet(action.transform_and_set)
|
|
break
|
|
case 'match':
|
|
code += buildMatch(action.match)
|
|
break
|
|
case 'lt':
|
|
code += buildLt(action.lt)
|
|
break
|
|
case 'lte':
|
|
code += buildLte(action.lte)
|
|
break
|
|
case 'gt':
|
|
code += buildGt(action.gt)
|
|
break
|
|
case 'gte':
|
|
code += buildGte(action.gte)
|
|
break
|
|
case 'length':
|
|
code += buildLength(action.length)
|
|
break
|
|
case 'is_true':
|
|
code += buildIsTrue(action.is_true)
|
|
break
|
|
case 'is_false':
|
|
code += buildIsFalse(action.is_false)
|
|
break
|
|
case 'contains':
|
|
code += buildContains(action.contains)
|
|
break
|
|
case 'exists':
|
|
code += buildExists(action.exists)
|
|
break
|
|
case 'skip':
|
|
break
|
|
default:
|
|
console.warn(`Action not supported: ${key}`)
|
|
break
|
|
}
|
|
}
|
|
return code
|
|
}
|
|
}
|
|
|
|
function buildDo (action) {
|
|
let code = ''
|
|
const keys = Object.keys(action)
|
|
if (keys.includes('catch')) {
|
|
code += 'try {\n'
|
|
code += indent(buildRequest(action), 2)
|
|
code += '} catch (err) {\n'
|
|
code += ` t.match(err.toString(), ${buildValLiteral(action.catch)})\n`
|
|
code += '}\n'
|
|
} else {
|
|
code += buildRequest(action)
|
|
}
|
|
return code
|
|
}
|
|
|
|
function buildRequest(action) {
|
|
let code = ''
|
|
|
|
const options = { meta: true }
|
|
|
|
for (const key of Object.keys(action)) {
|
|
if (key === 'catch') continue
|
|
|
|
if (key === 'headers') {
|
|
options.headers = action.headers
|
|
continue
|
|
}
|
|
|
|
const params = action[key]
|
|
if (params.ignore != null) {
|
|
if (Array.isArray(params.ignore)) {
|
|
options.ignore = params.ignore
|
|
} else {
|
|
options.ignore = [params.ignore]
|
|
}
|
|
}
|
|
|
|
code += `response = await client.${toCamelCase(key)}(${buildApiParams(action[key])}, ${JSON.stringify(options)})\n`
|
|
}
|
|
return code
|
|
}
|
|
|
|
function buildSet (action, vars) {
|
|
const key = Object.keys(action)[0]
|
|
const varName = action[key]
|
|
const lookup = buildLookup(key)
|
|
|
|
let code = ''
|
|
if (vars.has(varName)) {
|
|
code = `${varName} = ${lookup}\n`
|
|
} else {
|
|
code =`let ${varName} = ${lookup}\n`
|
|
}
|
|
return { code, varName }
|
|
}
|
|
|
|
function buildTransformAndSet (action) {
|
|
return `// TODO buildTransformAndSet: ${JSON.stringify(action)}\n`
|
|
}
|
|
|
|
function buildMatch (action) {
|
|
const key = Object.keys(action)[0]
|
|
let lookup = buildLookup(key)
|
|
const val = buildValLiteral(action[key])
|
|
return `t.match(${lookup}, ${val})\n`
|
|
}
|
|
|
|
function buildLt (action) {
|
|
const key = Object.keys(action)[0]
|
|
const lookup = buildLookup(key)
|
|
const val = buildValLiteral(action[key])
|
|
return `t.ok(${lookup} < ${val})\n`
|
|
}
|
|
|
|
function buildLte (action) {
|
|
const key = Object.keys(action)[0]
|
|
const lookup = buildLookup(key)
|
|
const val = buildValLiteral(action[key])
|
|
return `t.ok(${lookup} <= ${val})\n`
|
|
}
|
|
|
|
function buildGt (action) {
|
|
const key = Object.keys(action)[0]
|
|
const lookup = buildLookup(key)
|
|
const val = buildValLiteral(action[key])
|
|
return `t.ok(${lookup} > ${val})\n`
|
|
}
|
|
|
|
function buildGte (action) {
|
|
const key = Object.keys(action)[0]
|
|
const lookup = buildLookup(key)
|
|
const val = buildValLiteral(action[key])
|
|
return `t.ok(${lookup} >= ${val})\n`
|
|
}
|
|
|
|
function buildLength (action) {
|
|
const key = Object.keys(action)[0]
|
|
const lookup = buildLookup(key)
|
|
const val = buildValLiteral(action[key])
|
|
|
|
let code = ''
|
|
code += `if (typeof ${lookup} === 'object' && !Array.isArray(${lookup})) {\n`
|
|
code += ` t.equal(Object.keys(${lookup}).length, ${val})\n`
|
|
code += `} else {\n`
|
|
code += ` t.equal(${lookup}.length, ${val})\n`
|
|
code += `}\n`
|
|
return code
|
|
}
|
|
|
|
function buildIsTrue (action) {
|
|
let lookup = `${buildLookup(action)}`
|
|
let errMessage = `\`${action} should be truthy. found: '\$\{JSON.stringify(${lookup})\}'\``
|
|
if (lookup.includes('JSON.stringify')) errMessage = `\`${action} should be truthy. found: '\$\{${lookup}\}'\``
|
|
return `t.ok(${lookup} === "true" || (Boolean(${lookup}) && ${lookup} !== "false"), ${errMessage})\n`
|
|
}
|
|
|
|
function buildIsFalse (action) {
|
|
let lookup = `${buildLookup(action)}`
|
|
let errMessage = `\`${action} should be falsy. found: '\$\{JSON.stringify(${lookup})\}'\``
|
|
if (lookup.includes('JSON.stringify')) errMessage = `\`${action} should be falsy. found: '\$\{${lookup}\}'\``
|
|
return `t.ok(${lookup} === "false" || !Boolean(${lookup}), ${errMessage})\n`
|
|
}
|
|
|
|
function buildContains (action) {
|
|
const key = Object.keys(action)[0]
|
|
const lookup = buildLookup(key)
|
|
const val = buildValLiteral(action[key])
|
|
return `t.ok(${lookup}.includes(${val}), '${JSON.stringify(val)} not found in ${key}')\n`
|
|
}
|
|
|
|
function buildExists (keyName) {
|
|
const lookup = buildLookup(keyName)
|
|
return `t.ok(${lookup} != null, \`Key "${keyName}" not found in response body: \$\{JSON.stringify(response.body, null, 2)\}\`)\n`
|
|
}
|
|
|
|
function buildApiParams (params) {
|
|
if (Object.keys(params).length === 0) {
|
|
return 'undefined'
|
|
} else {
|
|
const out = {}
|
|
Object.keys(params).filter(k => k !== 'ignore' && k !== 'headers').forEach(k => out[k] = params[k])
|
|
return buildValLiteral(out)
|
|
}
|
|
}
|
|
|
|
function toCamelCase (name) {
|
|
return name.replace(/_([a-z])/g, g => g[1].toUpperCase())
|
|
}
|
|
|
|
function indent (str, spaces) {
|
|
const tabs = ' '.repeat(spaces)
|
|
return str.replace(/\s+$/, '').split('\n').map(l => `${tabs}${l}`).join('\n') + '\n'
|
|
}
|
|
|
|
function buildLookup (path) {
|
|
if (path === '$body') return '(typeof response.body === "string" ? response.body : JSON.stringify(response.body))'
|
|
|
|
const outPath = path.split('.').map(step => {
|
|
if (parseInt(step, 10).toString() === step) {
|
|
return `[${step}]`
|
|
} else if (step.match(/^\$[a-zA-Z0-9_]+$/)) {
|
|
const lookup = step.replace(/^\$/, '')
|
|
if (lookup === 'body') return ''
|
|
return `[${lookup}]`
|
|
} else if (step === '') {
|
|
return ''
|
|
} else {
|
|
return `['${step}']`
|
|
}
|
|
}).join('')
|
|
return `response.body${outPath}`
|
|
}
|
|
|
|
function buildValLiteral (val) {
|
|
if (typeof val === 'string') val = val.trim()
|
|
if (isRegExp(val)) {
|
|
return JSON.stringify(val).replace(/^"/, '').replace(/"$/, '').replaceAll('\\\\', '\\')
|
|
} else if (isVariable(val)) {
|
|
if (val === '$body') return 'JSON.stringify(response.body)'
|
|
return val.replace(/^\$/, '')
|
|
} else if (isPlainObject(val)) {
|
|
return JSON.stringify(cleanObject(val), null, 2).replace(/"\$([a-zA-Z0-9_]+)"/g, '$1')
|
|
} else {
|
|
return JSON.stringify(val)
|
|
}
|
|
}
|
|
|
|
function isRegExp (str) {
|
|
return typeof str === 'string' && str.startsWith('/') && str.endsWith('/')
|
|
}
|
|
|
|
function isVariable (str) {
|
|
return typeof str === 'string' && str.match(/^\$[a-zA-Z0-9_]+$/) != null
|
|
}
|
|
|
|
function cleanObject (obj) {
|
|
Object.keys(obj).forEach(key => {
|
|
let val = obj[key]
|
|
if (typeof val === 'string' && val.trim().startsWith('{') && val.trim().endsWith('}')) {
|
|
// attempt to parse as object
|
|
try {
|
|
val = JSON.parse(val)
|
|
} catch {
|
|
}
|
|
} else if (isPlainObject(val)) {
|
|
val = cleanObject(val)
|
|
} else if (Array.isArray(val)) {
|
|
val = val.map(item => isPlainObject(item) ? cleanObject(item) : item)
|
|
}
|
|
obj[key] = val
|
|
})
|
|
return obj
|
|
}
|
|
|
|
function isPlainObject(obj) {
|
|
return typeof obj === 'object' && !Array.isArray(obj) && obj != null
|
|
}
|
|
|
|
module.exports = build
|