Skip to content

Commit 3296137

Browse files
Merge pull request #4 from TotalTechGeek/feat/implement-pbt
Feat/implement pbt
2 parents 8a8112b + 4254b91 commit 3296137

13 files changed

Lines changed: 553 additions & 43 deletions

File tree

index.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const formatOption = new Option('-f, --format <format>', 'The output format').ch
1616

1717
program
1818
.name('pineapple')
19-
.version('0.8.0')
19+
.version('0.9.0')
2020
.option('-i, --include <files...>', 'Comma separated globs of files.')
2121
.option('-a, --accept-all', 'Accept all snapshots.')
2222
.option('-u, --update-all', 'Update all snapshots.')
@@ -74,7 +74,7 @@ async function main () {
7474

7575
testFile.addImportDeclaration({
7676
moduleSpecifier: specifier.join('/'),
77-
namedImports: ['run', 'addMethod', 'execute', 'hof'],
77+
namedImports: ['run', 'addMethod', 'addDefinitions', 'execute', 'hof'],
7878
isTypeOnly: false
7979
})
8080

@@ -102,6 +102,8 @@ async function main () {
102102
const { addedMethods, tests, beforeAll, afterAll } = functions.map(func => {
103103
const addedMethods = func.tags.filter(i => i.type === 'pineapple_import').map(i => {
104104
return `addMethod(${JSON.stringify(i.text || func.originalName || func.name)}, ${func.alias})\n`
105+
}).join('') + '\n' + func.tags.filter(i => i.type === 'pineapple_define').map(() => {
106+
return `addDefinitions(${func.alias})\n`
105107
}).join('')
106108

107109
const beforeAll = func.tags.filter(i => i.type === 'beforeAll').map(i => {
@@ -164,6 +166,7 @@ const TAG_TYPES = [
164166
'test',
165167
'test_static',
166168
'pineapple_import',
169+
'pineapple_define',
167170
'beforeAll',
168171
'afterAll',
169172
'before',

methods.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import { SpecialHoF } from './symbols.js'
77
import { askSnapshotUpdate, askSnapshot } from './inputs.js'
88
import { diff } from './utils.js'
99
import { serialize } from './snapshot.js'
10+
import fc from 'fast-check'
1011

1112
const engine = new AsyncLogicEngine()
1213
const ajv = new Ajv()
1314

15+
engine.addMethod('**', ([a, b]) => a ** b, { sync: true })
1416
engine.addMethod('===', ([a, b]) => equals(a, b), {
1517
sync: true,
1618
deterministic: true
@@ -82,6 +84,10 @@ engine.addMethod('snapshot', async ([inputs], context) => {
8284

8385
// @ts-ignore
8486
result.async = promise
87+
88+
// @ts-ignore
89+
if (context.fuzzed) result.input = inputs
90+
8591
const { exists, value } = await context.snap.find(context.id)
8692

8793
// @ts-ignore
@@ -138,9 +144,10 @@ function getDataSpecialSnapshot (data) {
138144
engine.addMethod('toParse', {
139145
asyncMethod: async ([inputs, output], context, above, engine) => {
140146
try {
141-
const result = getDataSpecial(context.func.apply(null, await engine.run(inputs, context)))
147+
inputs = await engine.run(inputs, context)
148+
const result = getDataSpecial(context.func.apply(null, inputs))
142149
if (result && result.then) return [result.catch(err => err), false, 'Function call returns a promise.']
143-
return [result, Boolean(await engine.run(output, { data: result, context: context.func.instance }))]
150+
return [result, Boolean(await engine.run(output, { data: result, context: context.func.instance, args: inputs }))]
144151
} catch (err) {
145152
return [err, false, `Could not execute condition as function threw ${generateErrorText(err)}`]
146153
}
@@ -173,9 +180,10 @@ engine.addMethod('resolves', async ([inputs, output], context) => {
173180
engine.addMethod('resolvesParse', {
174181
asyncMethod: async ([inputs, output], context, above, engine) => {
175182
try {
176-
const result = getDataSpecial(context.func.apply(null, await engine.run(inputs, context)))
183+
inputs = await engine.run(inputs, context)
184+
const result = getDataSpecial(context.func.apply(null, inputs))
177185
if (!result || !result.then) return [result, false, 'Was not a promise.']
178-
return [await result, Boolean(await engine.run(output, { data: await result, context: context.func.instance }))]
186+
return [await result, Boolean(await engine.run(output, { data: await result, context: context.func.instance, args: inputs }))]
179187
} catch (err) {
180188
return [err, false, `Could not execute condition as function rejected with ${generateErrorText(err)}`]
181189
}
@@ -201,7 +209,7 @@ engine.addMethod('execute', {
201209

202210
engine.addMethod('throws', async ([inputs, output], context) => {
203211
try {
204-
const result = getDataSpecial(context.func.apply(null, await engine.run(inputs, context)))
212+
const result = getDataSpecial(context.func.apply(null, inputs))
205213

206214
if (result && result.then) {
207215
try {
@@ -222,7 +230,7 @@ engine.addMethod('throws', async ([inputs, output], context) => {
222230
engine.addMethod('rejects', async ([inputs, output], context) => {
223231
try {
224232
let result
225-
try { result = getDataSpecial(context.func.apply(null, await engine.run(inputs, context))) } catch (err2) {
233+
try { result = getDataSpecial(context.func.apply(null, inputs)) } catch (err2) {
226234
return [err2, false, 'Async call threw synchronously.']
227235
}
228236
if (!result || !result.then) return [result, false, 'Was not a promise.']
@@ -236,4 +244,13 @@ engine.addMethod('rejects', async ([inputs, output], context) => {
236244
}
237245
})
238246

247+
Object.keys(fc).filter(i => typeof fc[i] === 'function' && i[0] === i[0].toLowerCase()).forEach(addition => {
248+
engine.addMethod('#' + addition, (data) => {
249+
if (data === undefined) return fc[addition]()
250+
return fc[addition](...[].concat(data))
251+
}, {
252+
sync: true
253+
})
254+
})
255+
239256
export default engine

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pineapple",
3-
"version": "0.8.0",
3+
"version": "0.9.0",
44
"description": "A testing framework for humans.",
55
"main": "index.js",
66
"scripts": {
@@ -19,6 +19,7 @@
1919
"ajv": "^8.11.0",
2020
"chalk": "^5.0.1",
2121
"commander": "^9.2.0",
22+
"fast-check": "^2.25.0",
2223
"inquirer": "^8.2.2",
2324
"jest-diff": "^27.5.1",
2425
"json-logic-engine": "^1.1.19",

parser/grammar.pegjs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,15 @@ TestExpressionLayered = a:TestExpression "~>" b:TestExpressionLayered {
135135

136136
TestExpression =
137137
ops:Operands _req ("resolves to" / "resolves") _req result:Expression _ {
138-
if (traverse(result, i => i && typeof i.var !== 'undefined')) {
138+
if (traverse(result, i => i && typeof i.var !== 'undefined' && (i.var.startsWith('data') || i.var.startsWith('context')))) {
139139
return {
140140
resolvesParse: [ops, result]
141141
}
142142
}
143143
return { resolves: [ops, result] }
144144
}
145145
/ ops:Operands _req ("to" / "is" / "returns") _req result:Expression _ {
146-
if (traverse(result, i => i && typeof i.var !== 'undefined')) {
146+
if (traverse(result, i => i && typeof i.var !== 'undefined' && (i.var.startsWith('data') || i.var.startsWith('context')))) {
147147
return {
148148
toParse: [ops, result]
149149
}
@@ -173,7 +173,7 @@ Operator
173173

174174
Operands
175175
=
176-
_ "$." call:FunctionCall { const key = Object.keys(call)[0]; const result = [key, ...[].concat(call[key])]; result.special = true; return [result] }
176+
_ "$." call:FunctionCall { const key = Object.keys(call)[0]; const result = [key, ...[].concat(call[key])]; result.special = true; return result }
177177
/ _ exp:Expression _ "," _ tail:Operands { return [exp, ...tail] }
178178
/ _ exp:Expression { return [ exp ]; }
179179

@@ -223,6 +223,7 @@ NonArithmeticExpression
223223
/ Numeric
224224
/ VarIdentifier
225225
/ ContextIdentifier
226+
/ ArgsIdentifier
226227
/ String
227228
/ FunctionCall
228229
/ Infinity
@@ -275,6 +276,12 @@ ArithmeticExpression0
275276
FunctionCall
276277
= _ id:Identifier _ '(' _ args:FunctionArgs _ ').' getId:Identifier {
277278
return { get: [{ [id]: args.length <= 1 ? args[0] : args }, getId] }
279+
}
280+
/ _ '#' id:Identifier _ '(' _ args:FunctionArgs _ ')' {
281+
return { ['#' + id]: args.length <= 1 ? args[0] : args }
282+
}
283+
/ _ '#' id:Identifier {
284+
return { ['#' + id]: undefined }
278285
}
279286
/ _ id:Identifier _ '(' _ args:FunctionArgs _ ')' {
280287
return { [id]: args.length <= 1 ? args[0] : args }
@@ -343,6 +350,9 @@ VarIdentifier "@-identifier"
343350
ContextIdentifier "$-identifier"
344351
= '$.' id:MemberIdentifier { return { var: `context.${id}` } }
345352
/ "$" { return { var: 'context' } }
353+
ArgsIdentifier "args-identifier"
354+
= 'args.' id:MemberIdentifier { return { var: `args.${id}` } }
355+
/ "args" { return { var: 'args' } }
346356
MemberIdentifier "member-identifier"
347357
= Identifier
348358
/ Integer { return text() }

run.js

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
/* eslint-disable no-prototype-builtins */
22
import engine from './methods.js'
33
import { parse } from './parser/dsl.js'
4-
import { snapshot } from './snapshot.js'
4+
import { serialize, snapshot } from './snapshot.js'
55
import { hash } from './hash.js'
6-
import { SpecialHoF } from './symbols.js'
6+
import { SpecialHoF, ConstantFunc } from './symbols.js'
77
import { failure, parseFailure, success, testRuntimeFailure } from './outputs.js'
8+
import fc from 'fast-check'
9+
import { argumentsToArbitraries } from './utils.js'
10+
import { always } from 'ramda'
811
const snap = snapshot()
912

1013
/**
@@ -16,6 +19,32 @@ export function addMethod (name, fn) {
1619
engine.addMethod(name, data => fn(...[].concat(data)))
1720
}
1821

22+
/**
23+
* Executes the method passed in, and adds the arbitraries to the engine.
24+
* @param {(...args: any[]) => ({ [key: string]: any })} fn
25+
*/
26+
export function addDefinitions (fn) {
27+
const definitions = fn()
28+
Object.keys(definitions).forEach(key => {
29+
if (typeof definitions[key] === 'function') {
30+
const method = definitions[key]
31+
engine.addMethod('#' + key, (data) => {
32+
if (data === undefined) return method()
33+
return method(...[].concat(data))
34+
}, { sync: true })
35+
} else {
36+
const arbitrary = definitions[key]
37+
if (arbitrary instanceof fc.Arbitrary) {
38+
engine.addMethod('#' + key, always(arbitrary), { sync: true })
39+
} else {
40+
const func = always(fc.constant(arbitrary))
41+
func[ConstantFunc] = true
42+
engine.addMethod('#' + key, func, { sync: true })
43+
}
44+
}
45+
})
46+
}
47+
1948
/**
2049
* Just executes the expression, used for "before" / "beforeEach" / "after" / "afterEach".
2150
* @param {string} input
@@ -25,6 +54,16 @@ export async function execute (input) {
2554
await engine.run(ast)
2655
}
2756

57+
class FuzzError extends Error {
58+
constructor (counterExample, seed, message, shrunk) {
59+
super()
60+
this.counterExample = counterExample
61+
this.seed = seed
62+
this.shrunk = shrunk
63+
this.message = message
64+
}
65+
}
66+
2867
/**
2968
* Runs the tests in the Pineapple JSON Logic Engine.
3069
* @param {string} input
@@ -46,16 +85,48 @@ export async function run (input, id, func, file) {
4685
const h = hash(input)
4786
let result = [func]
4887
let lastSpecial = false
88+
4989
for (const step of script) {
5090
// Override to break the special "hof" class thing.
5191
if (lastSpecial) {
5292
if (!Object.values(step)[0][0].special && typeof result[0].result === 'function') result[0] = result[0].result
5393
}
5494
const [current] = result
5595
if (typeof result[0] !== 'function') return [result[0], false, 'Does not return a function.']
56-
result = await engine.run(step, { func: current, id: (`${idName}(${input}) [${idHash}]`), snap, hash: h, rule: input, file })
96+
97+
const key = Object.keys(step)[0]
98+
const [inputs, expectation] = step[key]
99+
const arbs = await argumentsToArbitraries(...inputs)
100+
let failed = null
101+
try {
102+
let count = 0
103+
await fc.assert(fc.asyncProperty(...arbs, async (...args) => {
104+
count++
105+
const countStr = count > 1 ? `.${count}` : ''
106+
result = await engine.run({
107+
[key]: [{ preserve: args }, expectation]
108+
}, { func: current, id: (`${idName}(${input}) [${idHash}${countStr}]`), snap, hash: h, rule: input, file, args, context: current.instance, fuzzed: !arbs.constant })
109+
if (!result[1]) failed = result
110+
return result[1]
111+
}), {
112+
seed: key === 'snapshot' ? parseInt(h.substring(0, 16), 16) : undefined,
113+
numRuns: arbs.constant ? 1 : key === 'snapshot' ? 10 : undefined,
114+
reporter (out) {
115+
if (out.failed) {
116+
throw new FuzzError(out.counterexample, out.seed, out.error, out.numShrinks)
117+
}
118+
}
119+
})
120+
} catch (e) {
121+
if (e instanceof FuzzError && !arbs.constant) {
122+
failed[2] = (failed[2] || '') + `\nFailing Example: ${serialize(e.counterExample)}\nShrunk ${e.shrunk} times.\nSeed: ${e.seed}`
123+
}
124+
}
125+
57126
const [data, success, message] = result
127+
if (failed) return failed
58128
if (!success) return [data, false, message]
129+
59130
// Special Override for the Class-Based HoF thing.
60131
if (current[SpecialHoF]) {
61132
if (typeof result[0] !== 'function') result[0] = current
@@ -93,7 +164,7 @@ export async function run (input, id, func, file) {
93164
export function hof (ClassToUse, staticClass = false) {
94165
return (...args) => {
95166
const instance = staticClass ? ClassToUse : new ClassToUse(...args)
96-
const f = ([method, ...args]) => {
167+
const f = (method, ...args) => {
97168
if (!(method in instance && (instance.hasOwnProperty(method) || ClassToUse.prototype.hasOwnProperty(method)))) { throw new Error(`'${method}' is not a method of '${ClassToUse.name}'`) }
98169
f.result = instance[method](...args)
99170
return f

symbols.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export const SpecialHoF = Symbol('SpecialHoF')
2+
export const ConstantFunc = Symbol('ConstantFunc')

test/fuzz.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @test #array(#integer) returns @ as number
3+
* @test #array(#string, { minLength: 1 }) throws
4+
* @test [1, 2, 3] returns 6
5+
* @test [#integer, 2, 3] returns args.0.0 + 5
6+
* @test [] returns 0
7+
*/
8+
export function sum (values) {
9+
if (values.some(i => typeof i !== 'number')) throw new Error('An item in the array is not a number.')
10+
return values.reduce((a, b) => a + b, 0)
11+
}
12+
13+
/**
14+
* @test { name: #string, age: #integer(1, 20) } throws
15+
* @test { name: 'Jesse', age: #integer(21, 80) } returns cat(args.0.name, ' is drinking age.')
16+
*/
17+
export function drinkingAge ({ name, age }) {
18+
if (age >= 21) return `${name} is drinking age.`
19+
throw new Error(`${name} is not drinking age.`)
20+
}
21+
22+
/**
23+
* @test #integer(1, 10000) ** 2 returns true
24+
* @test #constantFrom(3, 5, 8, 13, 21, 1997) returns false
25+
*/
26+
export function isSquare (num) {
27+
return Math.sqrt(num) % 1 === 0
28+
}
29+
30+
/**
31+
* A simple template function.
32+
* @test 'Hello $0' ~> 'World' returns 'Hello World'
33+
* @test '$0 $0' ~> 'Hi' returns 'Hi Hi'
34+
* @test 'Hello $0' ~> #string returns cat('Hello ', args.0)
35+
* @param {string} templateString
36+
*/
37+
export function template (templateString) {
38+
/** @param {string} replace */
39+
return replace => templateString.replace(/\$0/g, replace.replace(/\$/g, '$$$$'))
40+
}

test/math.js

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
/**
2-
* @test 1, 2
3-
* @test '4', 3 throws
4-
* @test 1, '0' throws
5-
* @test -1, 1
6-
* @test -1, 1 to 0
7-
* @test -1, 1 to -1
8-
* @test -1, -1 to -2
9-
* @test -1, '-1' to -2
2+
* @test #integer, #integer returns @ as number
3+
* @test #integer, #string throws
4+
* @test #string, #integer throws
105
* @param {number} a
116
* @param {number} b
127
*/
@@ -46,8 +41,6 @@ export const fib = n => n <= 2 ? 1 : fib(n - 1) + fib(n - 2)
4641

4742
/**
4843
* @test 1, 3 resolves @ as number
49-
* @test 1, 5 resolves @ as string
50-
* @test 1, 5 resolves 7
5144
* @test 5n, 3n
5245
* @param {number} a
5346
* @param {number} b

0 commit comments

Comments
 (0)