vimonkey 0.0.1 → 0.2.0

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/dist/chaos/index.d.ts +81 -0
  4. package/dist/chaos/index.d.ts.map +1 -0
  5. package/dist/chaos/index.js +148 -0
  6. package/dist/env.d.ts +38 -0
  7. package/dist/env.d.ts.map +1 -0
  8. package/dist/env.js +52 -0
  9. package/dist/fuzz/context.d.ts +36 -0
  10. package/dist/fuzz/context.d.ts.map +1 -0
  11. package/dist/fuzz/context.js +43 -0
  12. package/dist/fuzz/gen.d.ts +70 -0
  13. package/dist/fuzz/gen.d.ts.map +1 -0
  14. package/dist/fuzz/gen.js +113 -0
  15. package/dist/fuzz/index.d.ts +57 -0
  16. package/dist/fuzz/index.d.ts.map +1 -0
  17. package/dist/fuzz/index.js +62 -0
  18. package/dist/fuzz/regression.d.ts +47 -0
  19. package/dist/fuzz/regression.d.ts.map +1 -0
  20. package/dist/fuzz/regression.js +91 -0
  21. package/dist/fuzz/shrink.d.ts +41 -0
  22. package/dist/fuzz/shrink.d.ts.map +1 -0
  23. package/dist/fuzz/shrink.js +80 -0
  24. package/dist/fuzz/test-fuzz.d.ts +55 -0
  25. package/dist/fuzz/test-fuzz.d.ts.map +1 -0
  26. package/dist/fuzz/test-fuzz.js +178 -0
  27. package/dist/index.d.ts +12 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +13 -0
  30. package/dist/plugin.d.ts +74 -0
  31. package/dist/plugin.d.ts.map +1 -0
  32. package/dist/plugin.js +41 -0
  33. package/dist/random.d.ts +66 -0
  34. package/dist/random.d.ts.map +1 -0
  35. package/dist/random.js +137 -0
  36. package/package.json +60 -3
  37. package/src/chaos/index.ts +180 -0
  38. package/src/env.ts +63 -0
  39. package/src/fuzz/context.ts +59 -0
  40. package/src/fuzz/gen.ts +157 -0
  41. package/src/fuzz/index.ts +90 -0
  42. package/src/fuzz/regression.ts +115 -0
  43. package/src/fuzz/shrink.ts +115 -0
  44. package/src/fuzz/test-fuzz.ts +241 -0
  45. package/src/index.ts +37 -0
  46. package/src/plugin.ts +96 -0
  47. package/src/random.ts +181 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Ergonomic generator API for fuzz testing
3
+ *
4
+ * gen(picker) creates an infinite async generator from a picker.
5
+ * take(generator, n) limits iterations and auto-tracks in test.fuzz().
6
+ */
7
+
8
+ import { createSeededRandom, weightedPickFromTuples, type SeededRandom } from "../random.js"
9
+ import { fuzzContext } from "./context.js"
10
+
11
+ /**
12
+ * Picker context passed to picker functions
13
+ */
14
+ export interface PickerContext {
15
+ /** Seeded random number generator */
16
+ random: SeededRandom
17
+ /** Current iteration (0-indexed) */
18
+ iteration: number
19
+ }
20
+
21
+ /**
22
+ * Result type for picker functions - can return single value, array, or iterable
23
+ */
24
+ type PickerResult<T> = T | T[] | Iterable<T>
25
+
26
+ /**
27
+ * Sync picker function
28
+ */
29
+ type SyncPickerFn<T> = (ctx: PickerContext) => PickerResult<T>
30
+
31
+ /**
32
+ * Async picker function (for AI mode)
33
+ */
34
+ type AsyncPickerFn<T> = (ctx: PickerContext) => Promise<PickerResult<T>>
35
+
36
+ /**
37
+ * Picker types:
38
+ * - T[] - random from array
39
+ * - [number, T][] - weighted random (pairs of [weight, value])
40
+ * - (ctx) => T | T[] | Iterable<T> - sync function
41
+ * - (ctx) => Promise<T | T[] | Iterable<T>> - async function
42
+ */
43
+ export type Picker<T> = T[] | [number, T][] | SyncPickerFn<T> | AsyncPickerFn<T>
44
+
45
+ /**
46
+ * Type guard for weighted tuples
47
+ */
48
+ function isWeightedTuple<T>(picker: unknown): picker is [number, T][] {
49
+ return Array.isArray(picker) && picker.length > 0 && Array.isArray(picker[0]) && typeof picker[0][0] === "number"
50
+ }
51
+
52
+ /**
53
+ * Type guard for iterables (excluding strings)
54
+ */
55
+ function isIterable<T>(value: unknown): value is Iterable<T> {
56
+ return value !== null && typeof value === "object" && Symbol.iterator in value && typeof value !== "string"
57
+ }
58
+
59
+ /**
60
+ * Flatten picker result: single value, array, or iterable → individual items
61
+ */
62
+ function* flatten<T>(result: PickerResult<T>): Generator<T> {
63
+ if (Array.isArray(result)) {
64
+ for (const item of result) yield item
65
+ } else if (isIterable(result)) {
66
+ for (const item of result) yield item
67
+ } else {
68
+ yield result
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Create a picker function from various picker specs
74
+ */
75
+ function createPicker<T>(
76
+ picker: Picker<T>,
77
+ random: SeededRandom,
78
+ ): (ctx: PickerContext) => PickerResult<T> | Promise<PickerResult<T>> {
79
+ // Function picker - use as-is
80
+ if (typeof picker === "function") {
81
+ return picker
82
+ }
83
+
84
+ // Weighted tuple picker
85
+ if (isWeightedTuple<T>(picker)) {
86
+ const items = picker
87
+ return () => weightedPickFromTuples(items, random.float())
88
+ }
89
+
90
+ // Array picker - random from array
91
+ return () => picker[Math.floor(random.float() * picker.length)]!
92
+ }
93
+
94
+ /**
95
+ * Create an infinite async generator from a picker
96
+ *
97
+ * @example
98
+ * // Random from array
99
+ * gen(['j', 'k', 'h', 'l'])
100
+ *
101
+ * @example
102
+ * // Weighted random
103
+ * gen([[40, 'j'], [40, 'k'], [20, 'Enter']])
104
+ *
105
+ * @example
106
+ * // Custom picker function
107
+ * gen(({ random }) => random.pick(['j', 'k']))
108
+ *
109
+ * @example
110
+ * // Picker returns array (flattened)
111
+ * gen(() => ['j', 'j', 'Enter']) // yields: j, j, Enter, j, j, Enter, ...
112
+ */
113
+ export async function* gen<T>(picker: Picker<T>, seed?: number): AsyncGenerator<T> {
114
+ // Use context seed if available, otherwise provided seed or Date.now()
115
+ const ctx = fuzzContext.getStore()
116
+ const random = createSeededRandom(seed ?? ctx?.seed ?? Date.now())
117
+ const pick = createPicker(picker, random)
118
+ let iteration = 0
119
+
120
+ while (true) {
121
+ const pickerCtx: PickerContext = { random, iteration: iteration++ }
122
+ const result = await pick(pickerCtx)
123
+ yield* flatten(result)
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Limit an async generator to n iterations
129
+ *
130
+ * When running inside test.fuzz(), automatically tracks yielded values
131
+ * for shrinking and regression testing.
132
+ *
133
+ * @example
134
+ * for await (const key of take(gen(['j', 'k']), 100)) {
135
+ * await handle.press(key)
136
+ * }
137
+ */
138
+ export async function* take<T>(generator: AsyncIterable<T>, n: number): AsyncGenerator<T> {
139
+ const ctx = fuzzContext.getStore()
140
+ let i = 0
141
+
142
+ // Replay mode: yield from saved sequence instead of generator
143
+ if (ctx?.replaySequence) {
144
+ while (i < n && ctx.replayIndex < ctx.replaySequence.length) {
145
+ yield ctx.replaySequence[ctx.replayIndex++] as T
146
+ i++
147
+ }
148
+ return
149
+ }
150
+
151
+ // Normal mode: yield from generator, optionally record
152
+ for await (const item of generator) {
153
+ if (i++ >= n) break
154
+ ctx?.history.push(item) // Track if in fuzz context
155
+ yield item
156
+ }
157
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Fuzz testing API
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { test, gen, take, createSeededRandom } from 'vimonkey/fuzz'
7
+ *
8
+ * // Simple random from array
9
+ * test('navigation', async () => {
10
+ * const handle = await run(<Board />, { cols: 80, rows: 24 })
11
+ * for await (const key of take(gen(['j','k','h','l']), 100)) {
12
+ * await handle.press(key)
13
+ * expect(handle.locator('[data-cursor]').count()).toBe(1)
14
+ * }
15
+ * })
16
+ *
17
+ * // Weighted random
18
+ * test('weighted', async () => {
19
+ * for await (const key of take(gen([[40,'j'], [40,'k'], [20,'Enter']]), 100)) {
20
+ * await handle.press(key)
21
+ * }
22
+ * })
23
+ *
24
+ * // Stateful with closure
25
+ * test('stateful', async () => {
26
+ * const handle = await app.run(<Board />)
27
+ * const random = createSeededRandom(Date.now())
28
+ *
29
+ * const keys = async function*() {
30
+ * while (true) {
31
+ * const state = handle.store.getState()
32
+ * yield state.cursor === 0 ? random.pick(['j','l']) : random.pick(['j','k','h','l'])
33
+ * }
34
+ * }
35
+ *
36
+ * for await (const key of take(keys(), 100)) {
37
+ * await handle.press(key)
38
+ * }
39
+ * })
40
+ *
41
+ * // With auto-tracking and shrinking
42
+ * test.fuzz('cursor invariants', async () => {
43
+ * for await (const key of take(gen(['j','k']), 100)) {
44
+ * await handle.press(key)
45
+ * expect(...) // On failure: shrinks, saves to __fuzz_cases__/
46
+ * }
47
+ * })
48
+ * ```
49
+ */
50
+
51
+ // Core API
52
+ export { gen, take, type Picker, type PickerContext } from "./gen.js"
53
+
54
+ // test.fuzz wrapper
55
+ export { test, FuzzError, type FuzzTestOptions } from "./test-fuzz.js"
56
+ export { describe, expect, it, beforeAll, afterAll, beforeEach, afterEach } from "./test-fuzz.js"
57
+
58
+ // Context (for advanced use)
59
+ export {
60
+ fuzzContext,
61
+ getFuzzContext,
62
+ isInFuzzContext,
63
+ createFuzzContext,
64
+ createReplayContext,
65
+ type FuzzContext,
66
+ } from "./context.js"
67
+
68
+ // Shrinking (for advanced use)
69
+ export { shrinkSequence, formatShrinkResult, type ShrinkOptions, type ShrinkResult } from "./shrink.js"
70
+
71
+ // Regression (for advanced use)
72
+ export {
73
+ saveCase,
74
+ loadCases,
75
+ loadCasesForTest,
76
+ deleteCase,
77
+ clearCases,
78
+ getFuzzCasesDir,
79
+ type SavedCase,
80
+ } from "./regression.js"
81
+
82
+ // Re-export random utilities
83
+ export {
84
+ createSeededRandom,
85
+ weightedPickFromTuples,
86
+ parseSeed,
87
+ parseRepeats,
88
+ deriveSeeds,
89
+ type SeededRandom,
90
+ } from "../random.js"
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Regression testing - save and load failing sequences
3
+ *
4
+ * Failing fuzz test sequences are saved to __fuzz_cases__/ directory
5
+ * like Jest/Vitest snapshots. On subsequent runs, saved sequences
6
+ * are replayed first to ensure bugs don't regress.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from "node:fs"
10
+ import { dirname, join, basename } from "node:path"
11
+
12
+ /** Failure case saved to disk */
13
+ export interface SavedCase {
14
+ /** Test name */
15
+ test: string
16
+ /** Seed used for generation */
17
+ seed: number
18
+ /** Minimal failing sequence */
19
+ sequence: unknown[]
20
+ /** Error message */
21
+ error: string
22
+ /** When the failure was recorded */
23
+ timestamp: string
24
+ /** Original sequence length before shrinking */
25
+ originalLength?: number
26
+ }
27
+
28
+ /**
29
+ * Get the __fuzz_cases__ directory path for a test file
30
+ */
31
+ export function getFuzzCasesDir(testFilePath: string): string {
32
+ const dir = dirname(testFilePath)
33
+ const file = basename(testFilePath)
34
+ return join(dir, "__fuzz_cases__", file)
35
+ }
36
+
37
+ /**
38
+ * Generate a filename for a saved case
39
+ */
40
+ function getCaseFilename(testName: string): string {
41
+ // Sanitize test name for filesystem
42
+ const safe = testName
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-|-$/g, "")
46
+ .slice(0, 50)
47
+ const timestamp = Date.now()
48
+ return `${safe}-${timestamp}.json`
49
+ }
50
+
51
+ /**
52
+ * Save a failing case to __fuzz_cases__/
53
+ */
54
+ export function saveCase(testFilePath: string, testName: string, failure: SavedCase): string {
55
+ const dir = getFuzzCasesDir(testFilePath)
56
+ mkdirSync(dir, { recursive: true })
57
+
58
+ const filename = getCaseFilename(testName)
59
+ const filepath = join(dir, filename)
60
+
61
+ writeFileSync(filepath, JSON.stringify(failure, null, 2))
62
+ return filepath
63
+ }
64
+
65
+ /**
66
+ * Load all saved cases for a test file
67
+ */
68
+ export function loadCases(testFilePath: string): SavedCase[] {
69
+ const dir = getFuzzCasesDir(testFilePath)
70
+ if (!existsSync(dir)) return []
71
+
72
+ const cases: SavedCase[] = []
73
+ for (const file of readdirSync(dir)) {
74
+ if (!file.endsWith(".json")) continue
75
+ try {
76
+ const content = readFileSync(join(dir, file), "utf-8")
77
+ cases.push(JSON.parse(content) as SavedCase)
78
+ } catch {
79
+ // Skip invalid files
80
+ }
81
+ }
82
+ return cases
83
+ }
84
+
85
+ /**
86
+ * Load saved cases for a specific test name
87
+ */
88
+ export function loadCasesForTest(testFilePath: string, testName: string): SavedCase[] {
89
+ return loadCases(testFilePath).filter((c) => c.test === testName)
90
+ }
91
+
92
+ /**
93
+ * Delete a saved case (when bug is fixed)
94
+ */
95
+ export function deleteCase(testFilePath: string, filename: string): void {
96
+ const dir = getFuzzCasesDir(testFilePath)
97
+ const filepath = join(dir, filename)
98
+ if (existsSync(filepath)) {
99
+ unlinkSync(filepath)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Clear all saved cases for a test file
105
+ */
106
+ export function clearCases(testFilePath: string): void {
107
+ const dir = getFuzzCasesDir(testFilePath)
108
+ if (!existsSync(dir)) return
109
+
110
+ for (const file of readdirSync(dir)) {
111
+ if (file.endsWith(".json")) {
112
+ unlinkSync(join(dir, file))
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Shrinking - find minimal failing sequence via delta debugging
3
+ *
4
+ * Uses binary search to reduce a failing sequence to the smallest
5
+ * subsequence that still triggers the failure.
6
+ */
7
+
8
+ export interface ShrinkOptions {
9
+ /** Maximum shrinking attempts (default: 100) */
10
+ maxAttempts?: number
11
+ /** Minimum sequence length to try (default: 1) */
12
+ minLength?: number
13
+ }
14
+
15
+ export interface ShrinkResult<T> {
16
+ /** Original sequence */
17
+ original: T[]
18
+ /** Shrunk (minimal) sequence */
19
+ shrunk: T[]
20
+ /** Number of shrinking attempts made */
21
+ attempts: number
22
+ /** Whether shrinking found a smaller sequence */
23
+ reduced: boolean
24
+ }
25
+
26
+ /**
27
+ * Shrink a failing sequence to its minimal form
28
+ *
29
+ * Uses delta debugging algorithm:
30
+ * 1. Try removing first half - if still fails, keep only first half
31
+ * 2. Try removing second half - if still fails, keep only second half
32
+ * 3. Try removing individual elements from start/end
33
+ * 4. Repeat until no reduction possible
34
+ *
35
+ * @param sequence - The original failing sequence
36
+ * @param runTest - Function that returns true if sequence passes, false if fails
37
+ * @param options - Shrinking options
38
+ */
39
+ export async function shrinkSequence<T>(
40
+ sequence: T[],
41
+ runTest: (seq: T[]) => Promise<boolean>,
42
+ options: ShrinkOptions = {},
43
+ ): Promise<ShrinkResult<T>> {
44
+ const { maxAttempts = 100, minLength = 1 } = options
45
+
46
+ let current = [...sequence]
47
+ let attempts = 0
48
+ let changed = true
49
+
50
+ while (changed && attempts < maxAttempts && current.length > minLength) {
51
+ changed = false
52
+
53
+ // Try removing first half
54
+ if (current.length > 1) {
55
+ const half = Math.ceil(current.length / 2)
56
+ const secondHalf = current.slice(half)
57
+ attempts++
58
+
59
+ if (secondHalf.length >= minLength && !(await runTest(secondHalf))) {
60
+ // Second half alone still fails
61
+ current = secondHalf
62
+ changed = true
63
+ continue
64
+ }
65
+ }
66
+
67
+ // Try removing second half
68
+ if (current.length > 1) {
69
+ const half = Math.floor(current.length / 2)
70
+ const firstHalf = current.slice(0, half)
71
+ attempts++
72
+
73
+ if (firstHalf.length >= minLength && !(await runTest(firstHalf))) {
74
+ // First half alone still fails
75
+ current = firstHalf
76
+ changed = true
77
+ continue
78
+ }
79
+ }
80
+
81
+ // Try removing individual elements
82
+ for (let i = 0; i < current.length && attempts < maxAttempts; i++) {
83
+ const without = [...current.slice(0, i), ...current.slice(i + 1)]
84
+ attempts++
85
+
86
+ if (without.length >= minLength && !(await runTest(without))) {
87
+ // Removing element i still fails
88
+ current = without
89
+ changed = true
90
+ break
91
+ }
92
+ }
93
+ }
94
+
95
+ return {
96
+ original: sequence,
97
+ shrunk: current,
98
+ attempts,
99
+ reduced: current.length < sequence.length,
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Format shrink result for display
105
+ */
106
+ export function formatShrinkResult<T>(result: ShrinkResult<T>): string {
107
+ const reduction = result.original.length - result.shrunk.length
108
+ const percent = Math.round((reduction / result.original.length) * 100)
109
+
110
+ if (!result.reduced) {
111
+ return `Could not reduce sequence (${result.original.length} steps, ${result.attempts} attempts)`
112
+ }
113
+
114
+ return `Shrunk from ${result.original.length} to ${result.shrunk.length} steps (${percent}% reduction, ${result.attempts} attempts)`
115
+ }