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 261 lines 9.7 kB view raw
1import { beforeEach, describe, expect, it, vi } from 'vitest'; 2import { createConfigCommand } from '../../src/commands/config.js'; 3 4// Mock modules 5vi.mock('node:fs/promises'); 6vi.mock('simple-git'); 7vi.mock('../../src/lib/config.js'); 8 9// Import mocked modules 10import * as fs from 'node:fs/promises'; 11import { simpleGit } from 'simple-git'; 12import * as configModule from '../../src/lib/config.js'; 13 14describe('Config Command', () => { 15 let consoleLogSpy: ReturnType<typeof vi.fn>; 16 let consoleErrorSpy: ReturnType<typeof vi.fn>; 17 18 beforeEach(() => { 19 vi.clearAllMocks(); 20 21 // Mock console methods 22 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 23 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 24 25 // Mock process.exit to throw so tests don't actually exit 26 vi.spyOn(process, 'exit').mockImplementation((code) => { 27 throw new Error(`process.exit(${code})`); 28 }) as never; 29 }); 30 31 describe('list command', () => { 32 it('should list all available config keys with descriptions', async () => { 33 vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'origin' }); 34 35 const config = createConfigCommand(); 36 await config.parseAsync(['node', 'test', 'list']); 37 38 expect(consoleLogSpy).toHaveBeenCalledWith('Available configuration keys:\n'); 39 expect(consoleLogSpy).toHaveBeenCalledWith(' remote'); 40 expect(consoleLogSpy).toHaveBeenCalledWith( 41 expect.stringContaining('Default Git remote to use') 42 ); 43 expect(consoleLogSpy).toHaveBeenCalledWith( 44 expect.stringContaining('Current value: "origin"') 45 ); 46 }); 47 48 it('should show "(not set)" for unset keys', async () => { 49 vi.mocked(configModule.loadConfig).mockResolvedValue({}); 50 51 const config = createConfigCommand(); 52 await config.parseAsync(['node', 'test', 'list']); 53 54 expect(consoleLogSpy).toHaveBeenCalledWith( 55 expect.stringContaining('Current value: (not set)') 56 ); 57 }); 58 }); 59 60 describe('get command', () => { 61 it('should show all config values when no key specified', async () => { 62 vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'origin' }); 63 64 const config = createConfigCommand(); 65 await config.parseAsync(['node', 'test', 'get']); 66 67 expect(consoleLogSpy).toHaveBeenCalledWith('remote = origin'); 68 }); 69 70 it('should show "No configuration set" when config is empty', async () => { 71 vi.mocked(configModule.loadConfig).mockResolvedValue({}); 72 73 const config = createConfigCommand(); 74 await config.parseAsync(['node', 'test', 'get']); 75 76 expect(consoleLogSpy).toHaveBeenCalledWith('No configuration set'); 77 }); 78 79 it('should show specific key value', async () => { 80 vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'upstream' }); 81 82 const config = createConfigCommand(); 83 await config.parseAsync(['node', 'test', 'get', 'remote']); 84 85 expect(consoleLogSpy).toHaveBeenCalledWith('remote = upstream'); 86 }); 87 88 it('should show "(not set)" for undefined key', async () => { 89 vi.mocked(configModule.loadConfig).mockResolvedValue({}); 90 91 const config = createConfigCommand(); 92 await config.parseAsync(['node', 'test', 'get', 'remote']); 93 94 expect(consoleLogSpy).toHaveBeenCalledWith('remote = (not set)'); 95 }); 96 }); 97 98 describe('set command', () => { 99 it('should set local config value', async () => { 100 const mockGit = { 101 checkIsRepo: vi.fn().mockResolvedValue(true), 102 revparse: vi.fn().mockResolvedValue('/test/repo\n'), 103 }; 104 vi.mocked(simpleGit).mockReturnValue(mockGit as never); 105 vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); 106 vi.mocked(fs.mkdir).mockResolvedValue(undefined); 107 vi.mocked(fs.writeFile).mockResolvedValue(undefined); 108 109 const config = createConfigCommand(); 110 await config.parseAsync(['node', 'test', 'set', 'remote', 'origin']); 111 112 expect(fs.writeFile).toHaveBeenCalledWith( 113 '/test/repo/.tangledrc', 114 `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, 115 'utf-8' 116 ); 117 expect(consoleLogSpy).toHaveBeenCalledWith( 118 expect.stringContaining('Set remote to "origin" in local config') 119 ); 120 }); 121 122 it('should set global config value with --global flag', async () => { 123 vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); 124 vi.mocked(fs.mkdir).mockResolvedValue(undefined); 125 vi.mocked(fs.writeFile).mockResolvedValue(undefined); 126 127 const config = createConfigCommand(); 128 await config.parseAsync(['node', 'test', 'set', 'remote', 'origin', '--global']); 129 130 expect(fs.writeFile).toHaveBeenCalledWith( 131 expect.stringContaining('.tangledrc'), 132 `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, 133 'utf-8' 134 ); 135 expect(consoleLogSpy).toHaveBeenCalledWith( 136 expect.stringContaining('Set remote to "origin" in user config') 137 ); 138 }); 139 140 it('should error when not in Git repo for local config', async () => { 141 const mockGit = { 142 checkIsRepo: vi.fn().mockResolvedValue(false), 143 }; 144 vi.mocked(simpleGit).mockReturnValue(mockGit as never); 145 146 const config = createConfigCommand(); 147 await expect(config.parseAsync(['node', 'test', 'set', 'remote', 'origin'])).rejects.toThrow( 148 'process.exit' 149 ); 150 151 expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to set config')); 152 }); 153 154 it('should preserve existing config values', async () => { 155 const mockGit = { 156 checkIsRepo: vi.fn().mockResolvedValue(true), 157 revparse: vi.fn().mockResolvedValue('/test/repo\n'), 158 }; 159 vi.mocked(simpleGit).mockReturnValue(mockGit as never); 160 vi.mocked(fs.readFile).mockResolvedValue( 161 JSON.stringify({ remote: 'origin', other: 'value' }) 162 ); 163 vi.mocked(fs.mkdir).mockResolvedValue(undefined); 164 vi.mocked(fs.writeFile).mockResolvedValue(undefined); 165 166 const config = createConfigCommand(); 167 await config.parseAsync(['node', 'test', 'set', 'remote', 'upstream']); 168 169 expect(fs.writeFile).toHaveBeenCalledWith( 170 '/test/repo/.tangledrc', 171 `${JSON.stringify({ remote: 'upstream', other: 'value' }, null, 2)}\n`, 172 'utf-8' 173 ); 174 }); 175 }); 176 177 describe('unset command', () => { 178 it('should unset local config value', async () => { 179 const mockGit = { 180 checkIsRepo: vi.fn().mockResolvedValue(true), 181 revparse: vi.fn().mockResolvedValue('/test/repo\n'), 182 }; 183 vi.mocked(simpleGit).mockReturnValue(mockGit as never); 184 vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' })); 185 vi.mocked(fs.unlink).mockResolvedValue(undefined); 186 187 const config = createConfigCommand(); 188 await config.parseAsync(['node', 'test', 'unset', 'remote']); 189 190 expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc'); 191 expect(consoleLogSpy).toHaveBeenCalledWith( 192 expect.stringContaining('Cleared remote from local config') 193 ); 194 }); 195 196 it('should unset global config value with --global flag', async () => { 197 vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' })); 198 vi.mocked(fs.unlink).mockResolvedValue(undefined); 199 200 const config = createConfigCommand(); 201 await config.parseAsync(['node', 'test', 'unset', 'remote', '--global']); 202 203 expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('.tangledrc')); 204 expect(consoleLogSpy).toHaveBeenCalledWith( 205 expect.stringContaining('Cleared remote from user config') 206 ); 207 }); 208 209 it('should delete config file when last key is removed', async () => { 210 const mockGit = { 211 checkIsRepo: vi.fn().mockResolvedValue(true), 212 revparse: vi.fn().mockResolvedValue('/test/repo\n'), 213 }; 214 vi.mocked(simpleGit).mockReturnValue(mockGit as never); 215 vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' })); 216 vi.mocked(fs.unlink).mockResolvedValue(undefined); 217 218 const config = createConfigCommand(); 219 await config.parseAsync(['node', 'test', 'unset', 'remote']); 220 221 expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc'); 222 }); 223 224 it('should preserve other config values when unsetting one key', async () => { 225 const mockGit = { 226 checkIsRepo: vi.fn().mockResolvedValue(true), 227 revparse: vi.fn().mockResolvedValue('/test/repo\n'), 228 }; 229 vi.mocked(simpleGit).mockReturnValue(mockGit as never); 230 vi.mocked(fs.readFile).mockResolvedValue( 231 JSON.stringify({ remote: 'origin', other: 'value' }) 232 ); 233 vi.mocked(fs.writeFile).mockResolvedValue(undefined); 234 235 const config = createConfigCommand(); 236 await config.parseAsync(['node', 'test', 'unset', 'remote']); 237 238 expect(fs.writeFile).toHaveBeenCalledWith( 239 '/test/repo/.tangledrc', 240 `${JSON.stringify({ other: 'value' }, null, 2)}\n`, 241 'utf-8' 242 ); 243 }); 244 245 it('should handle unset when config does not exist', async () => { 246 const mockGit = { 247 checkIsRepo: vi.fn().mockResolvedValue(true), 248 revparse: vi.fn().mockResolvedValue('/test/repo\n'), 249 }; 250 vi.mocked(simpleGit).mockReturnValue(mockGit as never); 251 vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); 252 253 const config = createConfigCommand(); 254 await config.parseAsync(['node', 'test', 'unset', 'remote']); 255 256 expect(consoleLogSpy).toHaveBeenCalledWith( 257 expect.stringContaining('Cleared remote from local config') 258 ); 259 }); 260 }); 261});