vimonkey 0.0.1 → 0.2.1

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.
@@ -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
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * test.fuzz wrapper with auto-tracking, shrinking, and regression
3
+ *
4
+ * Usage:
5
+ * ```typescript
6
+ * import { test } from 'vimonkey/fuzz'
7
+ *
8
+ * test.fuzz('cursor invariants', async () => {
9
+ * const handle = await run(<Board />, { cols: 80, rows: 24 })
10
+ * for await (const key of take(gen(['j','k','h','l']), 100)) {
11
+ * await handle.press(key)
12
+ * expect(handle.locator('[data-cursor]').count()).toBe(1)
13
+ * }
14
+ * })
15
+ * ```
16
+ */
17
+
18
+ import { test as vitestTest, type TestOptions } from "vitest"
19
+ import { fuzzContext, createFuzzContext, createReplayContext, type FuzzContext } from "./context.js"
20
+ import { shrinkSequence, formatShrinkResult } from "./shrink.js"
21
+ import { saveCase, loadCasesForTest, type SavedCase } from "./regression.js"
22
+ import { parseSeed, parseRepeats, deriveSeeds } from "../random.js"
23
+
24
+ /** Options for test.fuzz */
25
+ export interface FuzzTestOptions extends TestOptions {
26
+ /** Seed for reproducibility (default: from FUZZ_SEED env or random) */
27
+ seed?: number
28
+ /** Whether to shrink failing sequences (default: true) */
29
+ shrink?: boolean
30
+ /** Whether to save failing sequences to __fuzz_cases__/ (default: true) */
31
+ save?: boolean
32
+ /** Whether to replay saved failing sequences first (default: true) */
33
+ replay?: boolean
34
+ /** Maximum shrinking attempts (default: 100) */
35
+ maxShrinkAttempts?: number
36
+ }
37
+
38
+ /** Error with fuzz context attached */
39
+ export class FuzzError extends Error {
40
+ readonly sequence: unknown[]
41
+ readonly seed: number
42
+ readonly originalError: Error
43
+
44
+ constructor(
45
+ originalError: Error,
46
+ info: {
47
+ original: number
48
+ shrunk: number
49
+ sequence: unknown[]
50
+ seed: number
51
+ },
52
+ ) {
53
+ const msg = `Fuzz test failed after ${info.original} steps (shrunk to ${info.shrunk})
54
+ Minimal failing sequence: ${JSON.stringify(info.sequence)}
55
+ Seed: ${info.seed} (reproduce with FUZZ_SEED=${info.seed})
56
+
57
+ Original error: ${originalError.message}`
58
+
59
+ super(msg)
60
+ this.name = "FuzzError"
61
+ this.sequence = info.sequence
62
+ this.seed = info.seed
63
+ this.originalError = originalError
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get the test file path from error stack
69
+ * This is a heuristic - it looks for .test.ts or .fuzz.ts files
70
+ */
71
+ function getTestFilePath(): string {
72
+ const err = new Error()
73
+ const stack = err.stack?.split("\n") ?? []
74
+
75
+ for (const line of stack) {
76
+ // Look for test file patterns
77
+ const match = line.match(/\(([^)]+\.(test|fuzz)\.(ts|tsx|js|jsx)):\d+:\d+\)/)
78
+ if (match) return match[1]!
79
+
80
+ // Also try without parentheses
81
+ const match2 = line.match(/at\s+([^\s]+\.(test|fuzz)\.(ts|tsx|js|jsx)):\d+:\d+/)
82
+ if (match2) return match2[1]!
83
+ }
84
+
85
+ // Fallback to current working directory
86
+ return process.cwd() + "/unknown.test.ts"
87
+ }
88
+
89
+ /**
90
+ * Run a single fuzz test body with a specific seed.
91
+ * Handles replay, shrinking, and saving of failing cases.
92
+ */
93
+ async function runFuzzBody(
94
+ name: string,
95
+ fn: () => Promise<void>,
96
+ seed: number,
97
+ opts: { shrink: boolean; save: boolean; replay: boolean; maxShrinkAttempts: number },
98
+ ) {
99
+ const testFilePath = getTestFilePath()
100
+
101
+ // Replay saved failing sequences first
102
+ if (opts.replay) {
103
+ const savedCases = loadCasesForTest(testFilePath, name)
104
+ for (const savedCase of savedCases) {
105
+ const replayCtx = createReplayContext(savedCase.sequence, savedCase.seed)
106
+ try {
107
+ await fuzzContext.run(replayCtx, fn)
108
+ // If replay passes, the bug might be fixed - but we still run the main test
109
+ } catch (error) {
110
+ // Replay still fails - throw with saved context
111
+ throw new FuzzError(error as Error, {
112
+ original: savedCase.originalLength ?? savedCase.sequence.length,
113
+ shrunk: savedCase.sequence.length,
114
+ sequence: savedCase.sequence,
115
+ seed: savedCase.seed,
116
+ })
117
+ }
118
+ }
119
+ }
120
+
121
+ // Run the main fuzz test
122
+ const ctx = createFuzzContext(seed)
123
+
124
+ try {
125
+ await fuzzContext.run(ctx, fn)
126
+ } catch (error) {
127
+ // Test failed - attempt shrinking
128
+ if (ctx.history.length > 0) {
129
+ let minimalSequence = ctx.history
130
+ let shrinkResult
131
+
132
+ if (opts.shrink) {
133
+ // Define the test runner for shrinking
134
+ const runWithSequence = async (seq: unknown[]) => {
135
+ const replayCtx = createReplayContext(seq, seed)
136
+ try {
137
+ await fuzzContext.run(replayCtx, fn)
138
+ return true // passed
139
+ } catch {
140
+ return false // still fails
141
+ }
142
+ }
143
+
144
+ shrinkResult = await shrinkSequence(ctx.history, runWithSequence, {
145
+ maxAttempts: opts.maxShrinkAttempts,
146
+ })
147
+ minimalSequence = shrinkResult.shrunk
148
+
149
+ // Log shrink result
150
+ console.log(formatShrinkResult(shrinkResult))
151
+ }
152
+
153
+ // Save failing case
154
+ if (opts.save) {
155
+ const savedCase: SavedCase = {
156
+ test: name,
157
+ seed,
158
+ sequence: minimalSequence,
159
+ error: String(error),
160
+ timestamp: new Date().toISOString(),
161
+ originalLength: ctx.history.length,
162
+ }
163
+ const filepath = saveCase(testFilePath, name, savedCase)
164
+ console.log(`Saved failing case to: ${filepath}`)
165
+ }
166
+
167
+ throw new FuzzError(error as Error, {
168
+ original: ctx.history.length,
169
+ shrunk: minimalSequence.length,
170
+ sequence: minimalSequence,
171
+ seed,
172
+ })
173
+ }
174
+
175
+ throw error
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Create the test.fuzz wrapper
181
+ *
182
+ * When FUZZ_REPEATS > 1, registers multiple vitest tests — one per seed —
183
+ * so each gets its own result in the reporter and failures are independently
184
+ * visible. Seeds are deterministically derived from the base seed.
185
+ */
186
+ function createFuzzTest(name: string, fn: () => Promise<void>, options: FuzzTestOptions = {}) {
187
+ const {
188
+ seed = parseSeed("env"),
189
+ shrink = true,
190
+ save = true,
191
+ replay = true,
192
+ maxShrinkAttempts = 100,
193
+ ...testOptions
194
+ } = options
195
+
196
+ const repeats = parseRepeats()
197
+ const bodyOpts = { shrink, save, replay, maxShrinkAttempts }
198
+
199
+ if (repeats <= 1) {
200
+ // Single run (default) — original behavior
201
+ return vitestTest(name, testOptions, async () => {
202
+ await runFuzzBody(name, fn, seed, bodyOpts)
203
+ })
204
+ }
205
+
206
+ // Multiple runs — register one test per seed
207
+ const seeds = deriveSeeds(seed, repeats)
208
+ for (let i = 0; i < seeds.length; i++) {
209
+ const s = seeds[i]!
210
+ vitestTest(`${name} [seed=${s}]`, testOptions, async () => {
211
+ await runFuzzBody(name, fn, s, bodyOpts)
212
+ })
213
+ }
214
+ }
215
+
216
+ // Type for the fuzz function
217
+ type FuzzFn = {
218
+ (name: string, fn: () => Promise<void>, options?: FuzzTestOptions): void
219
+ (name: string, options: FuzzTestOptions, fn: () => Promise<void>): void
220
+ }
221
+
222
+ // Create the fuzz function with overloads
223
+ const fuzz: FuzzFn = (
224
+ name: string,
225
+ fnOrOptions: (() => Promise<void>) | FuzzTestOptions,
226
+ optionsOrFn?: FuzzTestOptions | (() => Promise<void>),
227
+ ) => {
228
+ if (typeof fnOrOptions === "function") {
229
+ return createFuzzTest(name, fnOrOptions, optionsOrFn as FuzzTestOptions)
230
+ } else {
231
+ return createFuzzTest(name, optionsOrFn as () => Promise<void>, fnOrOptions)
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Extended test object with fuzz method
237
+ */
238
+ export const test: typeof vitestTest & { fuzz: typeof fuzz } = Object.assign(vitestTest, { fuzz })
239
+
240
+ // Re-export vitest test types
241
+ export { describe, expect, it, beforeAll, afterAll, beforeEach, afterEach } from "vitest"
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * vimonkey — Fuzz testing and chaos streams for Vitest
3
+ */
4
+
5
+ // Fuzz API (primary)
6
+ export { gen, take, type Picker, type PickerContext } from "./fuzz/gen.js"
7
+ export { test, FuzzError, type FuzzTestOptions } from "./fuzz/test-fuzz.js"
8
+ export { describe, expect, it, beforeAll, afterAll, beforeEach, afterEach } from "./fuzz/test-fuzz.js"
9
+ export {
10
+ fuzzContext,
11
+ getFuzzContext,
12
+ isInFuzzContext,
13
+ createFuzzContext,
14
+ createReplayContext,
15
+ type FuzzContext,
16
+ } from "./fuzz/context.js"
17
+ export { shrinkSequence, formatShrinkResult, type ShrinkOptions, type ShrinkResult } from "./fuzz/shrink.js"
18
+ export {
19
+ saveCase,
20
+ loadCases,
21
+ loadCasesForTest,
22
+ deleteCase,
23
+ clearCases,
24
+ getFuzzCasesDir,
25
+ type SavedCase,
26
+ } from "./fuzz/regression.js"
27
+
28
+ // Utilities
29
+ export {
30
+ createSeededRandom,
31
+ weightedPickFromTuples,
32
+ parseSeed,
33
+ parseRepeats,
34
+ deriveSeeds,
35
+ type SeededRandom,
36
+ } from "./random.js"
37
+ export { getTestSys, type TestSys } from "./env.js"
package/src/plugin.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @todo Planned vitest plugin — stub only. Implement config injection and
3
+ * custom CLI mode interception (vitest fuzz, vitest ai, vitest doc).
4
+ *
5
+ * Vitest plugin for vimonkey
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // vitest.config.ts
10
+ * import { defineConfig } from 'vitest/config'
11
+ * import { viMonkey } from 'vimonkey/plugin'
12
+ *
13
+ * export default defineConfig({
14
+ * plugins: [
15
+ * viMonkey({
16
+ * fuzz: { iterations: 100 },
17
+ * ai: { model: 'claude-sonnet' },
18
+ * doc: { pattern: '**\/*.test.md' },
19
+ * })
20
+ * ]
21
+ * })
22
+ * ```
23
+ */
24
+
25
+ /** Minimal Vite plugin interface — avoids requiring vite as a dependency */
26
+ interface Plugin {
27
+ name: string
28
+ config?(): void
29
+ configureServer?(server: unknown): void
30
+ }
31
+
32
+ export interface ViMonkeyFuzzOptions {
33
+ /** Number of actions per test run (default: 100) */
34
+ iterations?: number
35
+ /** Seed source: 'env' reads FUZZ_SEED, 'random' generates new (default: 'env') */
36
+ seed?: "env" | "random"
37
+ /** Stop after first failure (default: true) */
38
+ failFast?: boolean
39
+ /** Shrinking settings */
40
+ shrink?: {
41
+ enabled?: boolean
42
+ maxAttempts?: number
43
+ }
44
+ }
45
+
46
+ export interface ViMonkeyAiOptions {
47
+ /** Model identifier (Vercel AI SDK format) */
48
+ model?: string
49
+ /** Temperature for LLM (0 = deterministic) */
50
+ temperature?: number
51
+ /** Maximum actions per exploration */
52
+ maxSteps?: number
53
+ /** Token budget per file */
54
+ maxTokens?: number
55
+ /** Directory to save discovered tests */
56
+ saveDir?: string
57
+ /** Use Claude Code provider */
58
+ provider?: "openai" | "anthropic" | "claude-code"
59
+ }
60
+
61
+ export interface ViMonkeyDocOptions {
62
+ /** Glob pattern for mdtest files */
63
+ pattern?: string
64
+ }
65
+
66
+ export interface ViMonkeyOptions {
67
+ /** Fuzz mode configuration */
68
+ fuzz?: ViMonkeyFuzzOptions
69
+ /** AI mode configuration */
70
+ ai?: ViMonkeyAiOptions
71
+ /** Doc mode configuration (mdtest) */
72
+ doc?: ViMonkeyDocOptions
73
+ }
74
+
75
+ /**
76
+ * Creates the vimonkey plugin for Vitest
77
+ */
78
+ export function viMonkey(options: ViMonkeyOptions = {}): Plugin {
79
+ const { fuzz = {}, ai = {}, doc = {} } = options
80
+
81
+ return {
82
+ name: "vimonkey",
83
+
84
+ config() {
85
+ // Configure vitest for custom modes
86
+ // Returns void for now - will be populated as modes are implemented
87
+ },
88
+
89
+ configureServer(server) {
90
+ // Handle custom CLI modes: vitest fuzz, vitest ai, vitest doc
91
+ // Implementation will intercept vitest CLI commands
92
+ },
93
+ }
94
+ }
95
+
96
+ export default viMonkey