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! :)
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});