Files
apophis-fastify/src/test/formula.test.ts
T

903 lines
37 KiB
TypeScript
Raw Normal View History

import { test } from 'node:test'
import assert from 'node:assert'
import * as fc from 'fast-check'
import { parse, validateFormula } from '../formula/parser.js'
import { evaluate, evaluateAsync } from '../formula/evaluator.js'
import { substitute } from '../formula/substitutor.js'
// ============================================================================
// Helpers
// ============================================================================
function makeContext(overrides: Partial<EvalContext> = {}): EvalContext {
return {
request: {
body: null,
headers: {},
query: {},
params: {},
cookies: {},
...overrides.request
},
response: {
body: null,
headers: {},
statusCode: 200,
responseTime: 0,
...overrides.response
},
previous: overrides.previous
} as EvalContext
}
function evalFormula(formula: string, ctx: EvalContext = makeContext()): unknown {
const ast = parse(formula)
const result = evaluate(ast.ast, ctx)
if (!result.success) throw new Error(result.error)
return result.value
}
async function evalFormulaAsync(formula: string, ctx: EvalContext = makeContext()): Promise<unknown> {
const ast = parse(formula)
const result = await evaluateAsync(ast.ast, ctx)
if (!result.success) throw new Error(result.error)
return result.value
}
// ============================================================================
// Unit Tests: Parser
// ============================================================================
test('parse: literal true', () => {
const result = parse('true')
assert.deepStrictEqual(result.ast, { type: 'literal', value: true })
})
test('parse: literal false', () => {
const result = parse('false')
assert.deepStrictEqual(result.ast, { type: 'literal', value: false })
})
test('parse: literal null', () => {
const result = parse('null')
assert.deepStrictEqual(result.ast, { type: 'literal', value: null })
})
test('parse: literal string with single quotes', () => {
const result = parse("'hello world'")
assert.deepStrictEqual(result.ast, { type: 'literal', value: 'hello world' })
})
test('parse: literal string with double quotes', () => {
const result = parse('"hello world"')
assert.deepStrictEqual(result.ast, { type: 'literal', value: 'hello world' })
})
test('parse: literal integer', () => {
const result = parse('42')
assert.deepStrictEqual(result.ast, { type: 'literal', value: 42 })
})
test('parse: literal negative number', () => {
const result = parse('-3.14')
assert.deepStrictEqual(result.ast, { type: 'literal', value: -3.14 })
})
test('parse: operation response_body(this)', () => {
const result = parse('response_body(this)')
assert.deepStrictEqual(result.ast, {
type: 'operation',
header: 'response_body',
parameter: { type: 'this' },
accessor: undefined
})
})
test('parse: operation response_payload(this)', () => {
const result = parse('response_payload(this)')
assert.deepStrictEqual(result.ast, {
type: 'operation',
header: 'response_payload',
parameter: { type: 'this' },
accessor: undefined
})
})
test('parse: operation with accessor request_headers(this).x-foo', () => {
const result = parse('request_headers(this).x-foo')
assert.deepStrictEqual(result.ast, {
type: 'operation',
header: 'request_headers',
parameter: { type: 'this' },
accessor: ['x-foo']
})
})
test('parse: comparison ==', () => {
const result = parse('response_code(this) == 200')
assert.strictEqual(result.ast.type, 'comparison')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'comparison' }>).op, '==')
})
test('parse: comparison !=', () => {
const result = parse('response_code(this) != 500')
assert.strictEqual(result.ast.type, 'comparison')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'comparison' }>).op, '!=')
})
test('parse: comparison <=', () => {
const result = parse('response_time(this) <= 1000')
assert.strictEqual(result.ast.type, 'comparison')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'comparison' }>).op, '<=')
})
test('parse: comparison >=', () => {
const result = parse('response_time(this) >= 100')
assert.strictEqual(result.ast.type, 'comparison')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'comparison' }>).op, '>=')
})
test('parse: comparison <', () => {
const result = parse('response_time(this) < 500')
assert.strictEqual(result.ast.type, 'comparison')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'comparison' }>).op, '<')
})
test('parse: comparison >', () => {
const result = parse('response_time(this) > 50')
assert.strictEqual(result.ast.type, 'comparison')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'comparison' }>).op, '>')
})
test('parse: comparison matches', () => {
const result = parse("response_body(this) matches '\\d+'")
assert.strictEqual(result.ast.type, 'comparison')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'comparison' }>).op, 'matches')
})
test('parse: boolean &&', () => {
const result = parse('T && F')
assert.strictEqual(result.ast.type, 'boolean')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'boolean' }>).op, '&&')
})
test('parse: boolean ||', () => {
const result = parse('T || F')
assert.strictEqual(result.ast.type, 'boolean')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'boolean' }>).op, '||')
})
test('parse: boolean => (implication)', () => {
const result = parse('T => F')
assert.strictEqual(result.ast.type, 'boolean')
assert.strictEqual((result.ast as Extract<FormulaNode, { type: 'boolean' }>).op, '=>')
})
test('parse: boolean precedence keeps && tighter than ||', () => {
const result = parse('T || F && F')
assert.strictEqual(result.ast.type, 'boolean')
const root = result.ast as Extract<FormulaNode, { type: 'boolean' }>
assert.strictEqual(root.op, '||')
assert.strictEqual(root.right.type, 'boolean')
assert.strictEqual((root.right as Extract<FormulaNode, { type: 'boolean' }>).op, '&&')
})
test('parse: implication is right-associative', () => {
const result = parse('T => F => T')
assert.strictEqual(result.ast.type, 'boolean')
const root = result.ast as Extract<FormulaNode, { type: 'boolean' }>
assert.strictEqual(root.op, '=>')
assert.strictEqual(root.right.type, 'boolean')
assert.strictEqual((root.right as Extract<FormulaNode, { type: 'boolean' }>).op, '=>')
})
test('parse: conditional if/then/else', () => {
const result = parse('if T then 1 else 2')
assert.strictEqual(result.ast.type, 'conditional')
const cond = result.ast as Extract<FormulaNode, { type: 'conditional' }>
assert.deepStrictEqual(cond.condition, { type: 'literal', value: true })
assert.deepStrictEqual(cond.then, { type: 'literal', value: 1 })
assert.deepStrictEqual(cond.else, { type: 'literal', value: 2 })
})
test('parse: nested conditional with cross-operation call', () => {
const result = parse('if status:201 then if T then response_code(GET /users/{userId}) == 200 else F else T')
assert.strictEqual(result.ast.type, 'conditional')
const root = result.ast as Extract<FormulaNode, { type: 'conditional' }>
assert.strictEqual(root.then.type, 'conditional')
})
test('parse: quantified for/in', () => {
const result = parse('for item in response_body(this): item == 1')
assert.strictEqual(result.ast.type, 'quantified')
const q = result.ast as Extract<FormulaNode, { type: 'quantified' }>
assert.strictEqual(q.quantifier, 'for')
assert.strictEqual(q.variable, 'item')
})
test('parse: quantified exists/in', () => {
const result = parse('exists item in response_body(this): item == 1')
assert.strictEqual(result.ast.type, 'quantified')
const q = result.ast as Extract<FormulaNode, { type: 'quantified' }>
assert.strictEqual(q.quantifier, 'exists')
})
test('parse: quantified supports paper-style :- delimiter', () => {
const result = parse('for item in response_body(this):- item == 1')
assert.strictEqual(result.ast.type, 'quantified')
})
test('parse: previous() wrapper', () => {
const result = parse('previous(response_code(this))')
assert.strictEqual(result.ast.type, 'previous')
const prev = result.ast as Extract<FormulaNode, { type: 'previous' }>
assert.strictEqual(prev.inner.type, 'operation')
})
test('parse: pure GET operation call', () => {
const result = parse('response_code(GET /users/{userId}) == 200')
assert.strictEqual(result.ast.type, 'comparison')
const left = (result.ast as Extract<FormulaNode, { type: 'comparison' }>).left
assert.strictEqual(left.type, 'operation')
assert.deepStrictEqual((left as Extract<FormulaNode, { type: 'operation' }>).parameter, {
type: 'call',
method: 'GET',
path: [
{ type: 'text', value: '/users/' },
{ type: 'expression', expression: { type: 'variable', name: 'userId', accessor: undefined } },
],
})
})
test('parse: T shorthand for true', () => {
const result = parse('T')
assert.deepStrictEqual(result.ast, { type: 'literal', value: true })
})
test('parse: F shorthand for false', () => {
const result = parse('F')
assert.deepStrictEqual(result.ast, { type: 'literal', value: false })
})
test('parse: throws on empty formula', () => {
assert.throws(() => parse(''), /Empty formula/)
})
test('parse: throws on unexpected token', () => {
assert.throws(() => parse('T extra'), /Unexpected token/)
})
// ============================================================================
// Unit Tests: Evaluator
// ============================================================================
test('evaluate: literal true returns true', () => {
const ctx = makeContext()
const result = evalFormula('true', ctx)
assert.strictEqual(result, true)
})
test('evaluate: literal false returns false', () => {
const ctx = makeContext()
const result = evalFormula('false', ctx)
assert.strictEqual(result, false)
})
test('evaluate: operation resolves response_code', () => {
const ctx = makeContext({ response: { statusCode: 201, body: null, headers: {}, responseTime: 0 } })
const result = evalFormula('response_code(this)', ctx)
assert.strictEqual(result, 201)
})
test('evaluate: operation resolves response_body with accessor', () => {
const ctx = makeContext({ response: { body: { id: 42 }, headers: {}, statusCode: 200, responseTime: 0 } })
const result = evalFormula('response_body(this).id', ctx)
assert.strictEqual(result, 42)
})
test('evaluate: response_payload returns plain JSON body', () => {
const ctx = makeContext({ response: { body: { id: 'u1' }, headers: {}, statusCode: 200, responseTime: 0 } })
const result = evalFormula('response_payload(this).id', ctx)
assert.strictEqual(result, 'u1')
})
test('evaluate: response_payload unwraps LDF-style data field', () => {
const ctx = makeContext({
response: {
body: { data: { id: 'u2' }, controls: { self: { href: '/users/u2' } } },
headers: {},
statusCode: 200,
responseTime: 0
}
})
const result = evalFormula('response_payload(this).id', ctx)
assert.strictEqual(result, 'u2')
})
test('evaluate: response_payload falls back for null and primitive bodies', () => {
const nullCtx = makeContext({ response: { body: null, headers: {}, statusCode: 200, responseTime: 0 } })
const primitiveCtx = makeContext({ response: { body: 'ok', headers: {}, statusCode: 200, responseTime: 0 } })
assert.strictEqual(evalFormula('response_payload(this)', nullCtx), null)
assert.strictEqual(evalFormula('response_payload(this)', primitiveCtx), 'ok')
})
test('evaluate: comparison == with numbers', () => {
const ctx = makeContext({ response: { statusCode: 200, body: null, headers: {}, responseTime: 0 } })
const result = evalFormula('response_code(this) == 200', ctx)
assert.strictEqual(result, true)
})
test('evaluate: comparison !=', () => {
const ctx = makeContext({ response: { statusCode: 200, body: null, headers: {}, responseTime: 0 } })
const result = evalFormula('response_code(this) != 500', ctx)
assert.strictEqual(result, true)
})
test('evaluate: comparison < with response_time', () => {
const ctx = makeContext({ response: { responseTime: 50, body: null, headers: {}, statusCode: 200 } })
const result = evalFormula('response_time(this) < 100', ctx)
assert.strictEqual(result, true)
})
test('evaluate: comparison matches with regex', () => {
const ctx = makeContext({ response: { body: 'hello123world', headers: {}, statusCode: 200, responseTime: 0 } })
const result = evalFormula("response_body(this) matches '\\d+'", ctx)
assert.strictEqual(result, true)
})
test('evaluate: boolean && short-circuits correctly', () => {
const ctx = makeContext()
assert.strictEqual(evalFormula('T && T', ctx), true)
assert.strictEqual(evalFormula('T && F', ctx), false)
assert.strictEqual(evalFormula('F && T', ctx), false)
})
test('evaluate: boolean || works correctly', () => {
const ctx = makeContext()
assert.strictEqual(evalFormula('T || F', ctx), true)
assert.strictEqual(evalFormula('F || F', ctx), false)
})
test('evaluate: boolean && short-circuits errors on right branch', () => {
const ctx = makeContext()
assert.strictEqual(evalFormula('F && previous(response_code(this))', ctx), false)
})
test('evaluate: boolean || short-circuits errors on right branch', () => {
const ctx = makeContext()
assert.strictEqual(evalFormula('T || previous(response_code(this))', ctx), true)
})
test('evaluate: boolean => (implication) works correctly', () => {
const ctx = makeContext()
assert.strictEqual(evalFormula('T => T', ctx), true)
assert.strictEqual(evalFormula('T => F', ctx), false)
assert.strictEqual(evalFormula('F => T', ctx), true)
assert.strictEqual(evalFormula('F => F', ctx), true)
})
test('evaluate: implication short-circuits consequent when antecedent is false', () => {
const ctx = makeContext()
assert.strictEqual(evalFormula('F => previous(response_code(this))', ctx), true)
})
test('evaluate: conditional if true then X else Y returns X', () => {
const ctx = makeContext()
const result = evalFormula('if T then 42 else 0', ctx)
assert.strictEqual(result, 42)
})
test('evaluate: conditional if false then X else Y returns Y', () => {
const ctx = makeContext()
const result = evalFormula('if F then 42 else 0', ctx)
assert.strictEqual(result, 0)
})
test('evaluate: quantified for all items match condition', () => {
const ctx = makeContext({ response: { body: [1, 1, 1], headers: {}, statusCode: 200, responseTime: 0 } })
const result = evalFormula('for x in response_body(this): x == 1', ctx)
assert.strictEqual(result, true)
})
test('evaluate: quantified exists finds matching item', () => {
const ctx = makeContext({ response: { body: [1, 2, 3], headers: {}, statusCode: 200, responseTime: 0 } })
const result = evalFormula('exists x in response_body(this): x == 2', ctx)
assert.strictEqual(result, true)
})
test('evaluate: previous() resolves from previous context', () => {
const prevCtx = makeContext({ response: { statusCode: 200, body: null, headers: {}, responseTime: 0 } })
const ctx = makeContext({ response: { statusCode: 500, body: null, headers: {}, responseTime: 0 }, previous: prevCtx })
const result = evalFormula('previous(response_code(this))', ctx)
assert.strictEqual(result, 200)
})
test('evaluateAsync: pure GET operation call resolves through operation resolver', async () => {
const resolverCalls: string[] = []
const ctx: EvalContext = {
...makeContext({ request: { params: { userId: 'user-123' }, body: null, headers: {}, query: {}, cookies: {} } }),
operationResolver: {
cache: new Map(),
execute: async (method, url) => {
resolverCalls.push(`${method} ${url}`)
return makeContext({ response: { statusCode: 200, body: { id: 'user-123' }, headers: {}, responseTime: 0 } })
},
},
}
const result = await evalFormulaAsync('response_code(GET /users/{userId}) == 200', ctx)
assert.strictEqual(result, true)
assert.deepStrictEqual(resolverCalls, ['GET /users/user-123'])
})
test('evaluateAsync: previous() uses before-context for pure GET operation calls', async () => {
const beforeCache = new Map<string, EvalContext>([
['GET /plans/basic', makeContext({ response: { statusCode: 404, body: null, headers: {}, responseTime: 0 } })],
])
const beforeCtx: EvalContext = {
...makeContext({ request: { params: { planId: 'basic' }, body: null, headers: {}, query: {}, cookies: {} } }),
operationResolver: {
cache: beforeCache,
execute: async () => makeContext({ response: { statusCode: 404, body: null, headers: {}, responseTime: 0 } }),
},
}
const ctx: EvalContext = {
...makeContext({ request: { params: { planId: 'basic' }, body: null, headers: {}, query: {}, cookies: {} } }),
before: beforeCtx,
operationResolver: {
cache: new Map(),
execute: async () => makeContext({ response: { statusCode: 200, body: { id: 'basic' }, headers: {}, responseTime: 0 } }),
},
}
const result = await evalFormulaAsync('previous(response_code(GET /plans/{planId})) == 404', ctx)
assert.strictEqual(result, true)
})
test('evaluateAsync: previous() can bind path placeholders from current response', async () => {
const beforeCalls: string[] = []
const currentCalls: string[] = []
const prefetchedBeforeValue = makeContext({ response: { statusCode: 404, body: null, headers: {}, responseTime: 0 } })
const beforeCache = new Map<string, EvalContext>([['GET /plans/new-plan', prefetchedBeforeValue]])
const beforeCtx: EvalContext = {
...makeContext({
request: { params: { planId: 'basic' }, body: null, headers: {}, query: {}, cookies: {} },
response: { body: null, headers: {}, statusCode: 200, responseTime: 0 },
}),
operationResolver: {
cache: beforeCache,
execute: async (method, url) => {
beforeCalls.push(`${method} ${url}`)
return makeContext({ response: { statusCode: 404, body: null, headers: {}, responseTime: 0 } })
},
},
}
const ctx: EvalContext = {
...makeContext({
request: { params: { planId: 'basic' }, body: null, headers: {}, query: {}, cookies: {} },
response: { body: { id: 'new-plan' }, headers: {}, statusCode: 201, responseTime: 0 },
}),
before: beforeCtx,
operationResolver: {
cache: new Map(),
execute: async (method, url) => {
currentCalls.push(`${method} ${url}`)
return makeContext({ response: { statusCode: 200, body: { id: 'new-plan' }, headers: {}, responseTime: 0 } })
},
},
}
const result = await evalFormulaAsync('previous(response_code(GET /plans/{response_body(this).id})) == 404', ctx)
assert.strictEqual(result, true)
assert.deepStrictEqual(beforeCalls, [])
assert.deepStrictEqual(currentCalls, [])
})
test('evaluateAsync: previous() fails clearly when pure GET call was not prefetched', async () => {
const ctx: EvalContext = {
...makeContext({
request: { params: {}, body: null, headers: {}, query: {}, cookies: {} },
response: { body: { id: 'new-plan' }, headers: {}, statusCode: 201, responseTime: 0 },
}),
before: {
...makeContext(),
operationResolver: {
cache: new Map(),
execute: async () => makeContext({ response: { statusCode: 404, body: null, headers: {}, responseTime: 0 } }),
},
},
}
await assert.rejects(
() => evalFormulaAsync('previous(response_code(GET /plans/{response_body(this).id})) == 404', ctx),
/not prefetched/
)
})
test('evaluateAsync: boolean || short-circuits pure GET operation calls', async () => {
const resolverCalls: string[] = []
const ctx: EvalContext = {
...makeContext({ request: { params: { userId: 'user-123' }, body: null, headers: {}, query: {}, cookies: {} } }),
operationResolver: {
cache: new Map(),
execute: async (method, url) => {
resolverCalls.push(`${method} ${url}`)
return makeContext({ response: { statusCode: 200, body: {}, headers: {}, responseTime: 0 } })
},
},
}
const result = await evalFormulaAsync('T || response_code(GET /users/{userId}) == 200', ctx)
assert.strictEqual(result, true)
assert.deepStrictEqual(resolverCalls, [])
})
test('evaluateAsync: implication skips pure GET consequent when antecedent is false', async () => {
const resolverCalls: string[] = []
const ctx: EvalContext = {
...makeContext({ request: { params: { userId: 'user-123' }, body: null, headers: {}, query: {}, cookies: {} } }),
operationResolver: {
cache: new Map(),
execute: async (method, url) => {
resolverCalls.push(`${method} ${url}`)
return makeContext({ response: { statusCode: 200, body: {}, headers: {}, responseTime: 0 } })
},
},
}
const result = await evalFormulaAsync('F => response_code(GET /users/{userId}) == 200', ctx)
assert.strictEqual(result, true)
assert.deepStrictEqual(resolverCalls, [])
})
test('evaluateAsync: nested conditional evaluates cross-operation call', async () => {
const resolverCalls: string[] = []
const ctx: EvalContext = {
...makeContext({
request: { params: { userId: 'user-123' }, body: null, headers: {}, query: {}, cookies: {} },
response: { statusCode: 201, body: null, headers: {}, responseTime: 0 },
}),
operationResolver: {
cache: new Map(),
execute: async (method, url) => {
resolverCalls.push(`${method} ${url}`)
return makeContext({ response: { statusCode: 200, body: {}, headers: {}, responseTime: 0 } })
},
},
}
const result = await evalFormulaAsync(
'if status:201 then if T then response_code(GET /users/{userId}) == 200 else F else T',
ctx
)
assert.strictEqual(result, true)
assert.deepStrictEqual(resolverCalls, ['GET /users/user-123'])
})
test('evaluate: deeply nested conditionals are supported within stack limits', () => {
const depth = 64
let formula = 'T'
for (let i = 0; i < depth; i++) {
formula = `if T then ${formula} else F`
}
const result = evalFormula(formula, makeContext())
assert.strictEqual(result, true)
})
test('evaluate: variable resolves from request params', () => {
const ctx = makeContext({ request: { params: { userId: 42 }, body: null, headers: {}, query: {}, cookies: {} } })
const result = evalFormula('userId', ctx)
assert.strictEqual(result, 42)
})
test('evaluate: variable with accessor resolves nested property', () => {
const ctx = makeContext({ request: { params: { user: { name: 'alice' } }, body: null, headers: {}, query: {}, cookies: {} } })
const result = evalFormula('user.name', ctx)
assert.strictEqual(result, 'alice')
})
test('evaluate: returns error for missing previous context', () => {
const ast = parse('previous(response_code(this))')
const result = evaluate(ast.ast, makeContext())
assert.strictEqual(result.success, false)
assert.ok((result as { success: false; error: string }).error.includes('No previous context'))
})
test('evaluate: returns error for non-array in quantified expression', () => {
const ast = parse('for x in response_code(this): x == 1')
const result = evaluate(ast.ast, makeContext())
assert.strictEqual(result.success, false)
assert.ok((result as { success: false; error: string }).error.includes('array collection'))
})
// ============================================================================
// Unit Tests: Substitutor
// ============================================================================
test('substitute: replaces simple parameter', () => {
const result = substitute('x == {val}', { val: 42 })
assert.strictEqual(result, "x == 42")
})
test('substitute: replaces string parameter with escaping', () => {
const result = substitute("x == {val}", { val: "it's" })
assert.strictEqual(result, "x == 'it\\'s'")
})
test('substitute: replaces nested path parameter', () => {
const result = substitute('x == {t.id}', { t: { id: 99 } })
assert.strictEqual(result, "x == 99")
})
test('substitute: replaces null', () => {
const result = substitute('x == {val}', { val: null })
assert.strictEqual(result, "x == null")
})
test('substitute: replaces boolean', () => {
const result = substitute('x == {val}', { val: true })
assert.strictEqual(result, "x == true")
})
test('substitute: escapes newline in string', () => {
const result = substitute('x == {val}', { val: 'a\nb' })
assert.strictEqual(result, "x == 'a\\nb'")
})
test('substitute: escapes tab in string', () => {
const result = substitute('x == {val}', { val: 'a\tb' })
assert.strictEqual(result, "x == 'a\\tb'")
})
test('substitute: escapes backslash in string', () => {
const result = substitute('x == {val}', { val: 'a\\b' })
assert.strictEqual(result, "x == 'a\\\\b'")
})
test('substitute: replaces object with JSON string', () => {
const result = substitute('x == {val}', { val: { a: 1 } })
assert.strictEqual(result, "x == '{\"a\":1}'")
})
test('substitute: throws on missing parameter', () => {
assert.throws(() => substitute('x == {val}', {}), /Missing parameters: val/)
})
test('substitute: invalid parameter with special chars is not matched and preserved', () => {
// Special chars like @ are not matched by PARAM_PATTERN, so the text is preserved as-is
const result = substitute('x == {a@b}', { 'a@b': 1 })
assert.strictEqual(result, 'x == {a@b}')
})
// ============================================================================
// Property-Based Tests
// ============================================================================
const mockContext = makeContext()
// Helper to build a simple stringifier for round-trip tests
function stringifyNode(node: FormulaNode): string {
switch (node.type) {
case 'literal':
if (node.value === null) return 'null'
if (node.value === true) return 'true'
if (node.value === false) return 'false'
if (typeof node.value === 'number') return String(node.value)
if (typeof node.value === 'string') return "'" + node.value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t') + "'"
return String(node.value)
case 'operation':
return `${node.header}(this)${node.accessor ? `.${node.accessor}` : ''}`
case 'variable':
return `${node.name}${node.accessor ? `.${node.accessor}` : ''}`
case 'comparison':
return `${stringifyNode(node.left)} ${node.op} ${stringifyNode(node.right)}`
case 'boolean':
return `${stringifyNode(node.left)} ${node.op} ${stringifyNode(node.right)}`
case 'conditional':
return `if ${stringifyNode(node.condition)} then ${stringifyNode(node.then)} else ${stringifyNode(node.else)}`
case 'quantified':
return `${node.quantifier} ${node.variable} in ${node.collection.header}(this): ${stringifyNode(node.body)}`
case 'previous':
return `previous(${stringifyNode(node.inner)})`
default:
return ''
}
}
function nodesEqual(a: FormulaNode, b: FormulaNode): boolean {
if (a.type !== b.type) return false
switch (a.type) {
case 'literal':
return a.value === (b as typeof a).value
case 'operation':
return a.header === (b as typeof a).header &&
JSON.stringify(a.parameter) === JSON.stringify((b as typeof a).parameter) &&
a.accessor === (b as typeof a).accessor
case 'variable':
return a.name === (b as typeof a).name && a.accessor === (b as typeof a).accessor
case 'comparison':
case 'boolean':
return a.op === (b as typeof a).op &&
nodesEqual(a.left, (b as typeof a).left) &&
nodesEqual(a.right, (b as typeof a).right)
case 'conditional':
return nodesEqual(a.condition, (b as typeof a).condition) &&
nodesEqual(a.then, (b as typeof a).then) &&
nodesEqual(a.else, (b as typeof a).else)
case 'quantified':
return a.quantifier === (b as typeof a).quantifier &&
a.variable === (b as typeof a).variable &&
a.collection.header === (b as typeof a).collection.header &&
JSON.stringify(a.collection.parameter) === JSON.stringify((b as typeof a).collection.parameter) &&
a.collection.accessor === (b as typeof a).collection.accessor &&
nodesEqual(a.body, (b as typeof a).body)
case 'previous':
return nodesEqual(a.inner, (b as typeof a).inner)
default:
return false
}
}
// Arbitrary for generating simple formula ASTs
const literalArb = fc.oneof(
fc.constant(null),
fc.boolean(),
fc.integer(),
fc.string({ minLength: 0, maxLength: 10 }).filter(s => !s.includes("'") && !s.includes('\\') && !s.includes('\n') && !s.includes('\r') && !s.includes('\t'))
).map(v => ({ type: 'literal' as const, value: v }))
const operationArb = fc.constantFrom('request_body', 'response_body', 'response_payload', 'response_code', 'request_headers', 'response_headers', 'query_params', 'cookies', 'response_time').map(header => ({
type: 'operation' as const,
header: header as 'request_body' | 'response_body' | 'response_payload' | 'response_code' | 'request_headers' | 'response_headers' | 'query_params' | 'cookies' | 'response_time',
parameter: { type: 'this' as const },
accessor: undefined as string[] | undefined
}))
const simpleNodeArb = fc.oneof(literalArb, operationArb)
const comparisonArb = fc.tuple(simpleNodeArb, fc.constantFrom('==', '!=', '<=', '>=', '<', '>' as const), simpleNodeArb).map(([left, op, right]) => ({
type: 'comparison' as const,
op,
left,
right
}))
const booleanArb = fc.tuple(simpleNodeArb, fc.constantFrom('&&', '||' as const), simpleNodeArb).map(([left, op, right]) => ({
type: 'boolean' as const,
op,
left,
right
}))
const formulaNodeArb = fc.oneof(simpleNodeArb, comparisonArb, booleanArb)
test('property: parser round-trip for simple nodes', async () => {
await fc.assert(
fc.property(formulaNodeArb, (node) => {
const str = stringifyNode(node)
const parsed = parse(str)
return nodesEqual(parsed.ast, node)
}),
{ numRuns: 100 }
)
})
test('property: T is always true', async () => {
await fc.assert(
fc.property(fc.context(), (_ctx) => {
const ast = parse('T')
const result = evaluate(ast.ast, mockContext)
return result.success && result.value === true
}),
{ numRuns: 100 }
)
})
test('property: F is always false', async () => {
await fc.assert(
fc.property(fc.context(), (_ctx) => {
const ast = parse('F')
const result = evaluate(ast.ast, mockContext)
return result.success && result.value === false
}),
{ numRuns: 100 }
)
})
test('property: A == A is always true (reflexivity)', async () => {
await fc.assert(
fc.property(fc.integer(), (n) => {
const ast = parse(`${n} == ${n}`)
const result = evaluate(ast.ast, mockContext)
return result.success && result.value === true
}),
{ numRuns: 100 }
)
})
test('property: A && B == B && A (commutativity)', async () => {
await fc.assert(
fc.property(fc.boolean(), fc.boolean(), (a, b) => {
const ast1 = parse(`${a} && ${b}`)
const ast2 = parse(`${b} && ${a}`)
const result1 = evaluate(ast1.ast, mockContext)
const result2 = evaluate(ast2.ast, mockContext)
return result1.success && result2.success && result1.value === result2.value
}),
{ numRuns: 100 }
)
})
test('property: if true then X else Y == X', async () => {
await fc.assert(
fc.property(fc.integer(), fc.integer(), (x, y) => {
const ast = parse(`if true then ${x} else ${y}`)
const result = evaluate(ast.ast, mockContext)
return result.success && result.value === x
}),
{ numRuns: 100 }
)
})
test('property: if false then X else Y == Y', async () => {
await fc.assert(
fc.property(fc.integer(), fc.integer(), (x, y) => {
const ast = parse(`if false then ${x} else ${y}`)
const result = evaluate(ast.ast, mockContext)
return result.success && result.value === y
}),
{ numRuns: 100 }
)
})
test('property: negation !T == F and !F == T', async () => {
await fc.assert(
fc.property(fc.boolean(), (a) => {
// Negation: !A == if A then F else T
const boolLit = a ? 'T' : 'F'
const formula = `if ${boolLit} then F else T`
const ast = parse(formula)
const result = evaluate(ast.ast, mockContext)
return result.success && result.value === !a
}),
{ numRuns: 100 }
)
})
test('property: conditional identity if A then T else F == A', async () => {
await fc.assert(
fc.property(fc.boolean(), (a) => {
const boolLit = a ? 'T' : 'F'
const formula = `if ${boolLit} then T else F`
const ast = parse(formula)
const result = evaluate(ast.ast, mockContext)
return result.success && result.value === a
}),
{ numRuns: 100 }
)
})
test('property: substitute preserves non-parameter text', async () => {
await fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('{') && !s.includes('}')), (text) => {
const result = substitute(text, {})
return result === text
}),
{ numRuns: 100 }
)
})
test('property: substitute with numbers produces parseable literals', async () => {
await fc.assert(
fc.property(fc.integer(), (n) => {
const formula = substitute('x == {val}', { val: n })
const ast = parse(formula)
const cmp = ast.ast as Extract<FormulaNode, { type: 'comparison' }>
return ast.ast.type === 'comparison' &&
cmp.right.type === 'literal' &&
cmp.right.value === n
}),
{ numRuns: 100 }
)
})
// ============================================================================
// Parse Error Messages
// ============================================================================
test('parse error: includes position info', () => {
try {
parse('response_body(this).name == ')
assert.fail('should have thrown')
} catch (err) {
const message = (err as Error).message
assert.ok(message.includes('Parse error at position'), 'should include position')
assert.ok(message.includes('^'), 'should include pointer')
assert.ok(message.includes('Expected'), 'should include expected token')
}
})
test('parse error: shows unexpected token', () => {
try {
parse('status == 200 extra')
assert.fail('should have thrown')
} catch (err) {
const message = (err as Error).message
assert.ok(message.includes('Unexpected token'), 'should mention unexpected token')
assert.ok(message.includes('extra'), 'should show the extra token')
}
})
test('parse error: unterminated string', () => {
try {
parse("status == '")
assert.fail('should have thrown')
} catch (err) {
const message = (err as Error).message
assert.ok(message.includes('Unterminated string literal'), 'should mention unterminated string')
}
})
test('parse error: missing this', () => {
try {
parse('response_body( ).name == "test"')
assert.fail('should have thrown')
} catch (err) {
const message = (err as Error).message
assert.ok(message.includes("Expected 'this'"), 'should mention expected this')
}
})
test('parse error: unknown operation header includes extension guidance', () => {
try {
parse('route_exists(this).controls.self.href == true')
assert.fail('should have thrown')
} catch (err) {
const message = (err as Error).message
assert.ok(message.includes('Unknown operation header "route_exists"'))
assert.ok(message.includes('register the extension'))
}
})
// ============================================================================
// validateFormula: Friendly error messages
// ============================================================================
test('validateFormula: returns valid for correct formula', () => {
const result = validateFormula('status:200')
assert.strictEqual(result.valid, true)
if (result.valid) {
assert.strictEqual(result.ast.type, 'status')
}
})
test('validateFormula: returns structured error for bad formula', () => {
const result = validateFormula('response_body().name == "test"')
assert.strictEqual(result.valid, false)
if (!result.valid) {
assert.ok(result.error.length > 0)
assert.ok(result.position >= 0)
assert.ok(result.suggestion.includes('this'), 'should suggest using (this)')
}
})
test('validateFormula: suggests status format for status errors', () => {
const result = validateFormula('status : 200')
assert.strictEqual(result.valid, false)
if (!result.valid) {
assert.ok(result.suggestion.includes('status:200'), 'should suggest no spaces')
}
})
test('validateFormula: suggests equality operator', () => {
const result = validateFormula('response_body(this).name = "test"')
assert.strictEqual(result.valid, false)
if (!result.valid) {
assert.ok(result.suggestion.includes('=='), 'should suggest == operator')
}
})
// ============================================================================
// Parse Cache Tests
// ============================================================================
import { setParseCacheLimit, getParseCacheLimit, clearParseCache } from '../formula/parser.js'
test('parse cache: configurable limit', () => {
const original = getParseCacheLimit()
clearParseCache()
setParseCacheLimit(2)
assert.strictEqual(getParseCacheLimit(), 2)
parse('response_body(this) == 1')
parse('response_body(this) == 2')
parse('response_body(this) == 3')
// First entry should be evicted
setParseCacheLimit(1000)
clearParseCache()
})
test('parse cache: limit 0 disables caching', () => {
clearParseCache()
setParseCacheLimit(0)
parse('response_body(this) == 1')
parse('response_body(this) == 1') // Should re-parse
setParseCacheLimit(1000)
})
test('parse cache: negative limit throws', () => {
assert.throws(() => setParseCacheLimit(-1), /non-negative/)
})
import type { EvalContext } from '../types.js'
import type { FormulaNode } from '../domain/formula.js'