WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
at main 167 lines 5.8 kB view raw
1import { EventEmitter } from 'node:events'; 2import * as fs from 'node:fs/promises'; 3import * as path from 'node:path'; 4import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 5import { readBodyInput } from '../../src/utils/body-input.js'; 6 7// Mutable reference updated per stdin test; null means fall through to real stdin 8let currentMockStdin: (EventEmitter & { resume: () => void }) | null = null; 9 10// Mock node:process so process.stdin can be swapped per test without needing 11// to redefine the non-configurable property on the real process object 12vi.mock('node:process', async (importOriginal) => { 13 const actual = await importOriginal<typeof import('node:process')>(); 14 return new Proxy(actual as object, { 15 get(target, prop, receiver) { 16 if (prop === 'stdin' && currentMockStdin !== null) { 17 return currentMockStdin; 18 } 19 return Reflect.get(target, prop, receiver); 20 }, 21 }); 22}); 23 24describe('readBodyInput', () => { 25 describe('direct string input', () => { 26 it('should return body string when provided', async () => { 27 const result = await readBodyInput('Test body content'); 28 expect(result).toBe('Test body content'); 29 }); 30 31 it('should return multiline body string', async () => { 32 const multiline = 'Line 1\nLine 2\nLine 3'; 33 const result = await readBodyInput(multiline); 34 expect(result).toBe(multiline); 35 }); 36 37 it('should return empty string', async () => { 38 const result = await readBodyInput(''); 39 expect(result).toBe(''); 40 }); 41 }); 42 43 describe('file input', () => { 44 let tempDir: string; 45 let testFile: string; 46 47 beforeEach(async () => { 48 // Create a temporary directory for test files 49 tempDir = path.join(process.cwd(), 'tests', 'fixtures', 'temp'); 50 await fs.mkdir(tempDir, { recursive: true }); 51 testFile = path.join(tempDir, 'test-body.txt'); 52 }); 53 54 afterEach(async () => { 55 // Clean up test files 56 try { 57 await fs.rm(tempDir, { recursive: true, force: true }); 58 } catch { 59 // Ignore cleanup errors 60 } 61 }); 62 63 it('should read content from file', async () => { 64 const content = 'File content here'; 65 await fs.writeFile(testFile, content, 'utf-8'); 66 67 const result = await readBodyInput(undefined, testFile); 68 expect(result).toBe(content); 69 }); 70 71 it('should read multiline content from file', async () => { 72 const content = 'Line 1\nLine 2\nLine 3'; 73 await fs.writeFile(testFile, content, 'utf-8'); 74 75 const result = await readBodyInput(undefined, testFile); 76 expect(result).toBe(content); 77 }); 78 79 it('should read empty file', async () => { 80 await fs.writeFile(testFile, '', 'utf-8'); 81 82 const result = await readBodyInput(undefined, testFile); 83 expect(result).toBe(''); 84 }); 85 86 it('should throw error when file does not exist', async () => { 87 const nonExistentFile = path.join(tempDir, 'does-not-exist.txt'); 88 89 await expect(readBodyInput(undefined, nonExistentFile)).rejects.toThrow( 90 `File not found: ${nonExistentFile}` 91 ); 92 }); 93 94 it('should throw error when path is a directory', async () => { 95 await expect(readBodyInput(undefined, tempDir)).rejects.toThrow( 96 `'${tempDir}' is a directory, not a file` 97 ); 98 }); 99 }); 100 101 describe('stdin input', () => { 102 afterEach(() => { 103 currentMockStdin = null; 104 }); 105 106 it('should read content from stdin when - is provided', async () => { 107 const mockStdin = Object.assign(new EventEmitter(), { resume: vi.fn() }); 108 currentMockStdin = mockStdin; 109 110 // readBodyInput registers handlers synchronously inside the Promise 111 // constructor before returning, so we can emit immediately after 112 const readPromise = readBodyInput(undefined, '-'); 113 mockStdin.emit('data', Buffer.from('hello from stdin')); 114 mockStdin.emit('end'); 115 116 expect(await readPromise).toBe('hello from stdin'); 117 }); 118 119 it('should concatenate multiple chunks from stdin', async () => { 120 const mockStdin = Object.assign(new EventEmitter(), { resume: vi.fn() }); 121 currentMockStdin = mockStdin; 122 123 const readPromise = readBodyInput(undefined, '-'); 124 mockStdin.emit('data', Buffer.from('chunk1')); 125 mockStdin.emit('data', Buffer.from(' chunk2')); 126 mockStdin.emit('end'); 127 128 expect(await readPromise).toBe('chunk1 chunk2'); 129 }); 130 131 it('should throw when stdin emits an error', async () => { 132 const mockStdin = Object.assign(new EventEmitter(), { resume: vi.fn() }); 133 currentMockStdin = mockStdin; 134 135 const readPromise = readBodyInput(undefined, '-'); 136 mockStdin.emit('error', new Error('read error')); 137 138 await expect(readPromise).rejects.toThrow('Failed to read from stdin: read error'); 139 }); 140 }); 141 142 describe('no input', () => { 143 it('should return undefined when no input provided', async () => { 144 const result = await readBodyInput(); 145 expect(result).toBeUndefined(); 146 }); 147 148 it('should return undefined when both params are undefined', async () => { 149 const result = await readBodyInput(undefined, undefined); 150 expect(result).toBeUndefined(); 151 }); 152 }); 153 154 describe('error cases', () => { 155 it('should throw error when both bodyString and bodyFilePath provided', async () => { 156 await expect(readBodyInput('body text', '/path/to/file')).rejects.toThrow( 157 'Cannot specify both --body and --body-file. Choose one input method.' 158 ); 159 }); 160 161 it('should throw error when both bodyString and stdin flag provided', async () => { 162 await expect(readBodyInput('body text', '-')).rejects.toThrow( 163 'Cannot specify both --body and --body-file. Choose one input method.' 164 ); 165 }); 166 }); 167});