chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,902 @@
|
||||
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'
|
||||
Reference in New Issue
Block a user