vimonkey 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chaos/index.mjs +121 -0
- package/dist/fuzz/index.mjs +2 -0
- package/dist/index.mjs +37 -0
- package/dist/plugin.mjs +14 -0
- package/dist/test-fuzz-ZgFKEVq-.mjs +550 -0
- package/package.json +24 -9
- package/src/chaos/index.ts +0 -180
- package/src/env.ts +0 -63
- package/src/fuzz/context.ts +0 -59
- package/src/fuzz/gen.ts +0 -157
- package/src/fuzz/index.ts +0 -90
- package/src/fuzz/regression.ts +0 -115
- package/src/fuzz/shrink.ts +0 -115
- package/src/fuzz/test-fuzz.ts +0 -241
- package/src/index.ts +0 -37
- package/src/plugin.ts +0 -96
- package/src/random.ts +0 -181
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from "vitest";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
//#region src/random.ts
|
|
6
|
+
const LCG_A = 1103515245;
|
|
7
|
+
const LCG_C = 12345;
|
|
8
|
+
const LCG_M = 2 ** 31;
|
|
9
|
+
/**
|
|
10
|
+
* Pick a random element from weighted tuples using a pre-generated random float.
|
|
11
|
+
*
|
|
12
|
+
* @param tuples - Array of [weight, value] pairs
|
|
13
|
+
* @param rand - Random float in [0, 1)
|
|
14
|
+
* @returns The selected value
|
|
15
|
+
*/
|
|
16
|
+
function weightedPickFromTuples(tuples, rand) {
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (const [w] of tuples) total += w;
|
|
19
|
+
let r = rand * total;
|
|
20
|
+
for (const [weight, value] of tuples) {
|
|
21
|
+
r -= weight;
|
|
22
|
+
if (r <= 0) return value;
|
|
23
|
+
}
|
|
24
|
+
return tuples[tuples.length - 1][1];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Create a seeded random number generator
|
|
28
|
+
*
|
|
29
|
+
* @param seed - Initial seed (uses Date.now() if not provided)
|
|
30
|
+
* @returns Seeded random number generator
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const random = createSeededRandom(12345)
|
|
35
|
+
*
|
|
36
|
+
* // Same seed = same sequence
|
|
37
|
+
* random.int(0, 100) // always 47 with seed 12345
|
|
38
|
+
* random.pick(['a', 'b', 'c']) // always 'b' with seed 12345
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
function createSeededRandom(seed) {
|
|
42
|
+
let state = seed ?? Date.now();
|
|
43
|
+
function next() {
|
|
44
|
+
state = (LCG_A * state + LCG_C) % LCG_M;
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
const random = {
|
|
48
|
+
get seed() {
|
|
49
|
+
return state;
|
|
50
|
+
},
|
|
51
|
+
float() {
|
|
52
|
+
return next() / LCG_M;
|
|
53
|
+
},
|
|
54
|
+
int(min, max) {
|
|
55
|
+
return Math.floor(random.float() * (max - min + 1)) + min;
|
|
56
|
+
},
|
|
57
|
+
pick(array) {
|
|
58
|
+
if (array.length === 0) throw new Error("Cannot pick from empty array");
|
|
59
|
+
return array[random.int(0, array.length - 1)];
|
|
60
|
+
},
|
|
61
|
+
weightedPick(items, weights) {
|
|
62
|
+
return weightedPickFromTuples(items.map((item) => [weights[item] ?? 1, item]), random.float());
|
|
63
|
+
},
|
|
64
|
+
shuffle(array) {
|
|
65
|
+
const result = [...array];
|
|
66
|
+
for (let i = result.length - 1; i > 0; i--) {
|
|
67
|
+
const j = random.int(0, i);
|
|
68
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
},
|
|
72
|
+
array(length, generator) {
|
|
73
|
+
return Array.from({ length }, generator);
|
|
74
|
+
},
|
|
75
|
+
bool(probability = .5) {
|
|
76
|
+
return random.float() < probability;
|
|
77
|
+
},
|
|
78
|
+
fork() {
|
|
79
|
+
return createSeededRandom(next());
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return random;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Parse seed from environment or generate random
|
|
86
|
+
*/
|
|
87
|
+
function parseSeed(source = "env") {
|
|
88
|
+
if (source === "env") {
|
|
89
|
+
const envSeed = process.env.FUZZ_SEED;
|
|
90
|
+
if (envSeed) {
|
|
91
|
+
const parsed = parseInt(envSeed, 10);
|
|
92
|
+
if (!isNaN(parsed)) return parsed;
|
|
93
|
+
console.warn(`Invalid FUZZ_SEED: "${envSeed}", using random seed`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return Date.now();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Parse FUZZ_REPEATS from environment (default: 1)
|
|
100
|
+
*
|
|
101
|
+
* Controls how many times each test.fuzz() test runs with different seeds.
|
|
102
|
+
* Use FUZZ_REPEATS=10000 for CI nightly runs.
|
|
103
|
+
*/
|
|
104
|
+
function parseRepeats() {
|
|
105
|
+
const env = process.env.FUZZ_REPEATS;
|
|
106
|
+
if (env) {
|
|
107
|
+
const parsed = parseInt(env, 10);
|
|
108
|
+
if (!isNaN(parsed) && parsed > 0) return parsed;
|
|
109
|
+
console.warn(`Invalid FUZZ_REPEATS: "${env}", using 1`);
|
|
110
|
+
}
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Derive N unique seeds from a base seed using LCG
|
|
115
|
+
*/
|
|
116
|
+
function deriveSeeds(baseSeed, count) {
|
|
117
|
+
const seeds = [];
|
|
118
|
+
let state = baseSeed;
|
|
119
|
+
for (let i = 0; i < count; i++) {
|
|
120
|
+
state = (LCG_A * state + LCG_C) % LCG_M;
|
|
121
|
+
seeds.push(state);
|
|
122
|
+
}
|
|
123
|
+
return seeds;
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/fuzz/context.ts
|
|
127
|
+
/**
|
|
128
|
+
* Fuzz test context using AsyncLocalStorage for auto-tracking
|
|
129
|
+
*
|
|
130
|
+
* When running inside test.fuzz(), take() automatically records
|
|
131
|
+
* yielded values for shrinking and regression testing.
|
|
132
|
+
*/
|
|
133
|
+
/** AsyncLocalStorage for fuzz test context */
|
|
134
|
+
const fuzzContext = new AsyncLocalStorage();
|
|
135
|
+
/**
|
|
136
|
+
* Create a new fuzz context
|
|
137
|
+
*/
|
|
138
|
+
function createFuzzContext(seed) {
|
|
139
|
+
return {
|
|
140
|
+
history: [],
|
|
141
|
+
replaySequence: null,
|
|
142
|
+
replayIndex: 0,
|
|
143
|
+
seed
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Create a replay context from a saved sequence
|
|
148
|
+
*/
|
|
149
|
+
function createReplayContext(sequence, seed) {
|
|
150
|
+
return {
|
|
151
|
+
history: [],
|
|
152
|
+
replaySequence: sequence,
|
|
153
|
+
replayIndex: 0,
|
|
154
|
+
seed
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Check if we're currently in a fuzz context
|
|
159
|
+
*/
|
|
160
|
+
function isInFuzzContext() {
|
|
161
|
+
return fuzzContext.getStore() !== void 0;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Get the current fuzz context or undefined
|
|
165
|
+
*/
|
|
166
|
+
function getFuzzContext() {
|
|
167
|
+
return fuzzContext.getStore();
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/fuzz/gen.ts
|
|
171
|
+
/**
|
|
172
|
+
* Ergonomic generator API for fuzz testing
|
|
173
|
+
*
|
|
174
|
+
* gen(picker) creates an infinite async generator from a picker.
|
|
175
|
+
* take(generator, n) limits iterations and auto-tracks in test.fuzz().
|
|
176
|
+
*/
|
|
177
|
+
/**
|
|
178
|
+
* Type guard for weighted tuples
|
|
179
|
+
*/
|
|
180
|
+
function isWeightedTuple(picker) {
|
|
181
|
+
return Array.isArray(picker) && picker.length > 0 && Array.isArray(picker[0]) && typeof picker[0][0] === "number";
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Type guard for iterables (excluding strings)
|
|
185
|
+
*/
|
|
186
|
+
function isIterable(value) {
|
|
187
|
+
return value !== null && typeof value === "object" && Symbol.iterator in value && typeof value !== "string";
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Flatten picker result: single value, array, or iterable → individual items
|
|
191
|
+
*/
|
|
192
|
+
function* flatten(result) {
|
|
193
|
+
if (Array.isArray(result)) for (const item of result) yield item;
|
|
194
|
+
else if (isIterable(result)) for (const item of result) yield item;
|
|
195
|
+
else yield result;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Create a picker function from various picker specs
|
|
199
|
+
*/
|
|
200
|
+
function createPicker(picker, random) {
|
|
201
|
+
if (typeof picker === "function") return picker;
|
|
202
|
+
if (isWeightedTuple(picker)) {
|
|
203
|
+
const items = picker;
|
|
204
|
+
return () => weightedPickFromTuples(items, random.float());
|
|
205
|
+
}
|
|
206
|
+
return () => picker[Math.floor(random.float() * picker.length)];
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Create an infinite async generator from a picker
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* // Random from array
|
|
213
|
+
* gen(['j', 'k', 'h', 'l'])
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* // Weighted random
|
|
217
|
+
* gen([[40, 'j'], [40, 'k'], [20, 'Enter']])
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* // Custom picker function
|
|
221
|
+
* gen(({ random }) => random.pick(['j', 'k']))
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* // Picker returns array (flattened)
|
|
225
|
+
* gen(() => ['j', 'j', 'Enter']) // yields: j, j, Enter, j, j, Enter, ...
|
|
226
|
+
*/
|
|
227
|
+
async function* gen(picker, seed) {
|
|
228
|
+
const ctx = fuzzContext.getStore();
|
|
229
|
+
const random = createSeededRandom(seed ?? ctx?.seed ?? Date.now());
|
|
230
|
+
const pick = createPicker(picker, random);
|
|
231
|
+
let iteration = 0;
|
|
232
|
+
while (true) yield* flatten(await pick({
|
|
233
|
+
random,
|
|
234
|
+
iteration: iteration++
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Limit an async generator to n iterations
|
|
239
|
+
*
|
|
240
|
+
* When running inside test.fuzz(), automatically tracks yielded values
|
|
241
|
+
* for shrinking and regression testing.
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* for await (const key of take(gen(['j', 'k']), 100)) {
|
|
245
|
+
* await handle.press(key)
|
|
246
|
+
* }
|
|
247
|
+
*/
|
|
248
|
+
async function* take(generator, n) {
|
|
249
|
+
const ctx = fuzzContext.getStore();
|
|
250
|
+
let i = 0;
|
|
251
|
+
if (ctx?.replaySequence) {
|
|
252
|
+
while (i < n && ctx.replayIndex < ctx.replaySequence.length) {
|
|
253
|
+
yield ctx.replaySequence[ctx.replayIndex++];
|
|
254
|
+
i++;
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
for await (const item of generator) {
|
|
259
|
+
if (i++ >= n) break;
|
|
260
|
+
ctx?.history.push(item);
|
|
261
|
+
yield item;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region src/fuzz/shrink.ts
|
|
266
|
+
/**
|
|
267
|
+
* Shrink a failing sequence to its minimal form
|
|
268
|
+
*
|
|
269
|
+
* Uses delta debugging algorithm:
|
|
270
|
+
* 1. Try removing first half - if still fails, keep only first half
|
|
271
|
+
* 2. Try removing second half - if still fails, keep only second half
|
|
272
|
+
* 3. Try removing individual elements from start/end
|
|
273
|
+
* 4. Repeat until no reduction possible
|
|
274
|
+
*
|
|
275
|
+
* @param sequence - The original failing sequence
|
|
276
|
+
* @param runTest - Function that returns true if sequence passes, false if fails
|
|
277
|
+
* @param options - Shrinking options
|
|
278
|
+
*/
|
|
279
|
+
async function shrinkSequence(sequence, runTest, options = {}) {
|
|
280
|
+
const { maxAttempts = 100, minLength = 1 } = options;
|
|
281
|
+
let current = [...sequence];
|
|
282
|
+
let attempts = 0;
|
|
283
|
+
let changed = true;
|
|
284
|
+
while (changed && attempts < maxAttempts && current.length > minLength) {
|
|
285
|
+
changed = false;
|
|
286
|
+
if (current.length > 1) {
|
|
287
|
+
const half = Math.ceil(current.length / 2);
|
|
288
|
+
const secondHalf = current.slice(half);
|
|
289
|
+
attempts++;
|
|
290
|
+
if (secondHalf.length >= minLength && !await runTest(secondHalf)) {
|
|
291
|
+
current = secondHalf;
|
|
292
|
+
changed = true;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (current.length > 1) {
|
|
297
|
+
const half = Math.floor(current.length / 2);
|
|
298
|
+
const firstHalf = current.slice(0, half);
|
|
299
|
+
attempts++;
|
|
300
|
+
if (firstHalf.length >= minLength && !await runTest(firstHalf)) {
|
|
301
|
+
current = firstHalf;
|
|
302
|
+
changed = true;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (let i = 0; i < current.length && attempts < maxAttempts; i++) {
|
|
307
|
+
const without = [...current.slice(0, i), ...current.slice(i + 1)];
|
|
308
|
+
attempts++;
|
|
309
|
+
if (without.length >= minLength && !await runTest(without)) {
|
|
310
|
+
current = without;
|
|
311
|
+
changed = true;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
original: sequence,
|
|
318
|
+
shrunk: current,
|
|
319
|
+
attempts,
|
|
320
|
+
reduced: current.length < sequence.length
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Format shrink result for display
|
|
325
|
+
*/
|
|
326
|
+
function formatShrinkResult(result) {
|
|
327
|
+
const reduction = result.original.length - result.shrunk.length;
|
|
328
|
+
const percent = Math.round(reduction / result.original.length * 100);
|
|
329
|
+
if (!result.reduced) return `Could not reduce sequence (${result.original.length} steps, ${result.attempts} attempts)`;
|
|
330
|
+
return `Shrunk from ${result.original.length} to ${result.shrunk.length} steps (${percent}% reduction, ${result.attempts} attempts)`;
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/fuzz/regression.ts
|
|
334
|
+
/**
|
|
335
|
+
* Regression testing - save and load failing sequences
|
|
336
|
+
*
|
|
337
|
+
* Failing fuzz test sequences are saved to __fuzz_cases__/ directory
|
|
338
|
+
* like Jest/Vitest snapshots. On subsequent runs, saved sequences
|
|
339
|
+
* are replayed first to ensure bugs don't regress.
|
|
340
|
+
*/
|
|
341
|
+
/**
|
|
342
|
+
* Get the __fuzz_cases__ directory path for a test file
|
|
343
|
+
*/
|
|
344
|
+
function getFuzzCasesDir(testFilePath) {
|
|
345
|
+
return join(dirname(testFilePath), "__fuzz_cases__", basename(testFilePath));
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Generate a filename for a saved case
|
|
349
|
+
*/
|
|
350
|
+
function getCaseFilename(testName) {
|
|
351
|
+
return `${testName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 50)}-${Date.now()}.json`;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Save a failing case to __fuzz_cases__/
|
|
355
|
+
*/
|
|
356
|
+
function saveCase(testFilePath, testName, failure) {
|
|
357
|
+
const dir = getFuzzCasesDir(testFilePath);
|
|
358
|
+
mkdirSync(dir, { recursive: true });
|
|
359
|
+
const filepath = join(dir, getCaseFilename(testName));
|
|
360
|
+
writeFileSync(filepath, JSON.stringify(failure, null, 2));
|
|
361
|
+
return filepath;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Load all saved cases for a test file
|
|
365
|
+
*/
|
|
366
|
+
function loadCases(testFilePath) {
|
|
367
|
+
const dir = getFuzzCasesDir(testFilePath);
|
|
368
|
+
if (!existsSync(dir)) return [];
|
|
369
|
+
const cases = [];
|
|
370
|
+
for (const file of readdirSync(dir)) {
|
|
371
|
+
if (!file.endsWith(".json")) continue;
|
|
372
|
+
try {
|
|
373
|
+
const content = readFileSync(join(dir, file), "utf-8");
|
|
374
|
+
cases.push(JSON.parse(content));
|
|
375
|
+
} catch {}
|
|
376
|
+
}
|
|
377
|
+
return cases;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Load saved cases for a specific test name
|
|
381
|
+
*/
|
|
382
|
+
function loadCasesForTest(testFilePath, testName) {
|
|
383
|
+
return loadCases(testFilePath).filter((c) => c.test === testName);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Delete a saved case (when bug is fixed)
|
|
387
|
+
*/
|
|
388
|
+
function deleteCase(testFilePath, filename) {
|
|
389
|
+
const filepath = join(getFuzzCasesDir(testFilePath), filename);
|
|
390
|
+
if (existsSync(filepath)) unlinkSync(filepath);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Clear all saved cases for a test file
|
|
394
|
+
*/
|
|
395
|
+
function clearCases(testFilePath) {
|
|
396
|
+
const dir = getFuzzCasesDir(testFilePath);
|
|
397
|
+
if (!existsSync(dir)) return;
|
|
398
|
+
for (const file of readdirSync(dir)) if (file.endsWith(".json")) unlinkSync(join(dir, file));
|
|
399
|
+
}
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region src/fuzz/test-fuzz.ts
|
|
402
|
+
/**
|
|
403
|
+
* test.fuzz wrapper with auto-tracking, shrinking, and regression
|
|
404
|
+
*
|
|
405
|
+
* Usage:
|
|
406
|
+
* ```typescript
|
|
407
|
+
* import { test } from 'vimonkey/fuzz'
|
|
408
|
+
*
|
|
409
|
+
* test.fuzz('cursor invariants', async () => {
|
|
410
|
+
* const handle = await run(<Board />, { cols: 80, rows: 24 })
|
|
411
|
+
* for await (const key of take(gen(['j','k','h','l']), 100)) {
|
|
412
|
+
* await handle.press(key)
|
|
413
|
+
* expect(handle.locator('[data-cursor]').count()).toBe(1)
|
|
414
|
+
* }
|
|
415
|
+
* })
|
|
416
|
+
* ```
|
|
417
|
+
*/
|
|
418
|
+
/** Error with fuzz context attached */
|
|
419
|
+
var FuzzError = class extends Error {
|
|
420
|
+
sequence;
|
|
421
|
+
seed;
|
|
422
|
+
originalError;
|
|
423
|
+
constructor(originalError, info) {
|
|
424
|
+
const msg = `Fuzz test failed after ${info.original} steps (shrunk to ${info.shrunk})
|
|
425
|
+
Minimal failing sequence: ${JSON.stringify(info.sequence)}
|
|
426
|
+
Seed: ${info.seed} (reproduce with FUZZ_SEED=${info.seed})
|
|
427
|
+
|
|
428
|
+
Original error: ${originalError.message}`;
|
|
429
|
+
super(msg);
|
|
430
|
+
this.name = "FuzzError";
|
|
431
|
+
this.sequence = info.sequence;
|
|
432
|
+
this.seed = info.seed;
|
|
433
|
+
this.originalError = originalError;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
/**
|
|
437
|
+
* Get the test file path from error stack
|
|
438
|
+
* This is a heuristic - it looks for .test.ts or .fuzz.ts files
|
|
439
|
+
*/
|
|
440
|
+
function getTestFilePath() {
|
|
441
|
+
const stack = (/* @__PURE__ */ new Error()).stack?.split("\n") ?? [];
|
|
442
|
+
for (const line of stack) {
|
|
443
|
+
const match = line.match(/\(([^)]+\.(test|fuzz)\.(ts|tsx|js|jsx)):\d+:\d+\)/);
|
|
444
|
+
if (match) return match[1];
|
|
445
|
+
const match2 = line.match(/at\s+([^\s]+\.(test|fuzz)\.(ts|tsx|js|jsx)):\d+:\d+/);
|
|
446
|
+
if (match2) return match2[1];
|
|
447
|
+
}
|
|
448
|
+
return process.cwd() + "/unknown.test.ts";
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Run a single fuzz test body with a specific seed.
|
|
452
|
+
* Handles replay, shrinking, and saving of failing cases.
|
|
453
|
+
*/
|
|
454
|
+
async function runFuzzBody(name, fn, seed, opts) {
|
|
455
|
+
const testFilePath = getTestFilePath();
|
|
456
|
+
if (opts.replay) {
|
|
457
|
+
const savedCases = loadCasesForTest(testFilePath, name);
|
|
458
|
+
for (const savedCase of savedCases) {
|
|
459
|
+
const replayCtx = createReplayContext(savedCase.sequence, savedCase.seed);
|
|
460
|
+
try {
|
|
461
|
+
await fuzzContext.run(replayCtx, fn);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
throw new FuzzError(error, {
|
|
464
|
+
original: savedCase.originalLength ?? savedCase.sequence.length,
|
|
465
|
+
shrunk: savedCase.sequence.length,
|
|
466
|
+
sequence: savedCase.sequence,
|
|
467
|
+
seed: savedCase.seed
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const ctx = createFuzzContext(seed);
|
|
473
|
+
try {
|
|
474
|
+
await fuzzContext.run(ctx, fn);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (ctx.history.length > 0) {
|
|
477
|
+
let minimalSequence = ctx.history;
|
|
478
|
+
let shrinkResult;
|
|
479
|
+
if (opts.shrink) {
|
|
480
|
+
const runWithSequence = async (seq) => {
|
|
481
|
+
const replayCtx = createReplayContext(seq, seed);
|
|
482
|
+
try {
|
|
483
|
+
await fuzzContext.run(replayCtx, fn);
|
|
484
|
+
return true;
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
shrinkResult = await shrinkSequence(ctx.history, runWithSequence, { maxAttempts: opts.maxShrinkAttempts });
|
|
490
|
+
minimalSequence = shrinkResult.shrunk;
|
|
491
|
+
console.log(formatShrinkResult(shrinkResult));
|
|
492
|
+
}
|
|
493
|
+
if (opts.save) {
|
|
494
|
+
const filepath = saveCase(testFilePath, name, {
|
|
495
|
+
test: name,
|
|
496
|
+
seed,
|
|
497
|
+
sequence: minimalSequence,
|
|
498
|
+
error: String(error),
|
|
499
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
500
|
+
originalLength: ctx.history.length
|
|
501
|
+
});
|
|
502
|
+
console.log(`Saved failing case to: ${filepath}`);
|
|
503
|
+
}
|
|
504
|
+
throw new FuzzError(error, {
|
|
505
|
+
original: ctx.history.length,
|
|
506
|
+
shrunk: minimalSequence.length,
|
|
507
|
+
sequence: minimalSequence,
|
|
508
|
+
seed
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Create the test.fuzz wrapper
|
|
516
|
+
*
|
|
517
|
+
* When FUZZ_REPEATS > 1, registers multiple vitest tests — one per seed —
|
|
518
|
+
* so each gets its own result in the reporter and failures are independently
|
|
519
|
+
* visible. Seeds are deterministically derived from the base seed.
|
|
520
|
+
*/
|
|
521
|
+
function createFuzzTest(name, fn, options = {}) {
|
|
522
|
+
const { seed = parseSeed("env"), shrink = true, save = true, replay = true, maxShrinkAttempts = 100, ...testOptions } = options;
|
|
523
|
+
const repeats = parseRepeats();
|
|
524
|
+
const bodyOpts = {
|
|
525
|
+
shrink,
|
|
526
|
+
save,
|
|
527
|
+
replay,
|
|
528
|
+
maxShrinkAttempts
|
|
529
|
+
};
|
|
530
|
+
if (repeats <= 1) return test(name, testOptions, async () => {
|
|
531
|
+
await runFuzzBody(name, fn, seed, bodyOpts);
|
|
532
|
+
});
|
|
533
|
+
const seeds = deriveSeeds(seed, repeats);
|
|
534
|
+
for (let i = 0; i < seeds.length; i++) {
|
|
535
|
+
const s = seeds[i];
|
|
536
|
+
test(`${name} [seed=${s}]`, testOptions, async () => {
|
|
537
|
+
await runFuzzBody(name, fn, s, bodyOpts);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const fuzz = (name, fnOrOptions, optionsOrFn) => {
|
|
542
|
+
if (typeof fnOrOptions === "function") return createFuzzTest(name, fnOrOptions, optionsOrFn);
|
|
543
|
+
else return createFuzzTest(name, optionsOrFn, fnOrOptions);
|
|
544
|
+
};
|
|
545
|
+
/**
|
|
546
|
+
* Extended test object with fuzz method
|
|
547
|
+
*/
|
|
548
|
+
const test$1 = Object.assign(test, { fuzz });
|
|
549
|
+
//#endregion
|
|
550
|
+
export { getFuzzContext as C, parseRepeats as D, deriveSeeds as E, parseSeed as O, fuzzContext as S, createSeededRandom as T, shrinkSequence as _, beforeEach as a, createFuzzContext as b, it as c, deleteCase as d, getFuzzCasesDir as f, formatShrinkResult as g, saveCase as h, beforeAll as i, weightedPickFromTuples as k, test$1 as l, loadCasesForTest as m, afterAll as n, describe as o, loadCases as p, afterEach as r, expect as s, FuzzError as t, clearCases as u, gen as v, isInFuzzContext as w, createReplayContext as x, take as y };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vimonkey",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Fuzz testing with auto-shrinking and composable chaos stream transformers for Vitest",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"async-generator",
|
|
@@ -23,21 +23,20 @@
|
|
|
23
23
|
"url": "https://github.com/beorn/vimonkey/issues"
|
|
24
24
|
},
|
|
25
25
|
"license": "MIT",
|
|
26
|
-
"author": "
|
|
26
|
+
"author": "Bjørn Stabell <bjorn@stabell.org>",
|
|
27
27
|
"repository": {
|
|
28
28
|
"type": "git",
|
|
29
29
|
"url": "https://github.com/beorn/vimonkey.git"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
|
-
"
|
|
32
|
+
"dist"
|
|
33
33
|
],
|
|
34
34
|
"type": "module",
|
|
35
|
-
"module": "./src/index.ts",
|
|
36
35
|
"exports": {
|
|
37
|
-
".": "./
|
|
38
|
-
"./plugin": "./
|
|
39
|
-
"./fuzz": "./
|
|
40
|
-
"./chaos": "./
|
|
36
|
+
".": "./dist/index.mjs",
|
|
37
|
+
"./plugin": "./dist/plugin.mjs",
|
|
38
|
+
"./fuzz": "./dist/fuzz/index.mjs",
|
|
39
|
+
"./chaos": "./dist/chaos/index.mjs"
|
|
41
40
|
},
|
|
42
41
|
"publishConfig": {
|
|
43
42
|
"access": "public"
|
|
@@ -53,11 +52,27 @@
|
|
|
53
52
|
"peerDependencies": {
|
|
54
53
|
"vitest": ">=4.0.0"
|
|
55
54
|
},
|
|
55
|
+
"tsdown": {
|
|
56
|
+
"clean": true,
|
|
57
|
+
"dts": false,
|
|
58
|
+
"entry": [
|
|
59
|
+
"src/index.ts",
|
|
60
|
+
"src/plugin.ts",
|
|
61
|
+
"src/fuzz/index.ts",
|
|
62
|
+
"src/chaos/index.ts"
|
|
63
|
+
],
|
|
64
|
+
"external": [
|
|
65
|
+
"vitest",
|
|
66
|
+
"vitest/node",
|
|
67
|
+
"debug"
|
|
68
|
+
],
|
|
69
|
+
"format": "esm"
|
|
70
|
+
},
|
|
56
71
|
"engines": {
|
|
57
72
|
"node": ">=23.6.0"
|
|
58
73
|
},
|
|
59
74
|
"scripts": {
|
|
60
|
-
"build": "
|
|
75
|
+
"build": "tsdown",
|
|
61
76
|
"test": "vitest",
|
|
62
77
|
"test:run": "vitest run"
|
|
63
78
|
}
|