commanderAdapter.test.ts
1 import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 import { Command } from 'commander'; 3 import type { CliCommand } from './registry.js'; 4 import { EmptyResultError, SelectorError } from './errors.js'; 5 6 const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({ 7 mockExecuteCommand: vi.fn(), 8 mockRenderOutput: vi.fn(), 9 })); 10 11 vi.mock('./execution.js', async () => { 12 const actual = await vi.importActual<typeof import('./execution.js')>('./execution.js'); 13 return { 14 ...actual, 15 executeCommand: mockExecuteCommand, 16 }; 17 }); 18 19 vi.mock('./output.js', () => ({ 20 render: mockRenderOutput, 21 })); 22 23 import { registerCommandToProgram } from './commanderAdapter.js'; 24 25 describe('commanderAdapter arg passing', () => { 26 const cmd: CliCommand = { 27 site: 'paperreview', 28 name: 'submit', 29 description: 'Submit a PDF', 30 browser: false, 31 args: [ 32 { name: 'pdf', positional: true, required: true, help: 'Path to the paper PDF' }, 33 { name: 'dry-run', type: 'bool', default: false, help: 'Validate only' }, 34 { name: 'prepare-only', type: 'bool', default: false, help: 'Prepare only' }, 35 ], 36 func: vi.fn(), 37 }; 38 39 beforeEach(() => { 40 mockExecuteCommand.mockReset(); 41 mockExecuteCommand.mockResolvedValue([]); 42 mockRenderOutput.mockReset(); 43 delete process.env.OPENCLI_VERBOSE; 44 process.exitCode = undefined; 45 }); 46 47 it('passes bool flag values through to executeCommand for coercion', async () => { 48 const program = new Command(); 49 const siteCmd = program.command('paperreview'); 50 registerCommandToProgram(siteCmd, cmd); 51 52 await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--dry-run', 'false']); 53 54 expect(mockExecuteCommand).toHaveBeenCalled(); 55 const kwargs = mockExecuteCommand.mock.calls[0][1]; 56 expect(kwargs.pdf).toBe('./paper.pdf'); 57 expect(kwargs).toHaveProperty('dry-run'); 58 }); 59 60 it('passes valueless bool flags as true to executeCommand', async () => { 61 const program = new Command(); 62 const siteCmd = program.command('paperreview'); 63 registerCommandToProgram(siteCmd, cmd); 64 65 await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--prepare-only']); 66 67 expect(mockExecuteCommand).toHaveBeenCalled(); 68 const kwargs = mockExecuteCommand.mock.calls[0][1]; 69 expect(kwargs.pdf).toBe('./paper.pdf'); 70 expect(kwargs['prepare-only']).toBe(true); 71 }); 72 73 it('passes option value sources through for adapters that need explicit-vs-default semantics', async () => { 74 const program = new Command(); 75 const siteCmd = program.command('paperreview'); 76 registerCommandToProgram(siteCmd, cmd); 77 78 await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--prepare-only']); 79 80 expect(mockExecuteCommand).toHaveBeenCalled(); 81 const kwargs = mockExecuteCommand.mock.calls[0][1]; 82 expect(kwargs.__opencliOptionSources).toMatchObject({ 83 'prepare-only': 'cli', 84 }); 85 }); 86 87 it('rejects invalid bool values before calling executeCommand', async () => { 88 const program = new Command(); 89 const siteCmd = program.command('paperreview'); 90 registerCommandToProgram(siteCmd, cmd); 91 92 await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--dry-run', 'maybe']); 93 94 // prepareCommandArgs validates bools before dispatch; executeCommand should not be reached 95 expect(mockExecuteCommand).not.toHaveBeenCalled(); 96 }); 97 }); 98 99 describe('commanderAdapter boolean alias support', () => { 100 const cmd: CliCommand = { 101 site: 'reddit', 102 name: 'save', 103 description: 'Save a post', 104 browser: false, 105 args: [ 106 { name: 'post-id', positional: true, required: true, help: 'Post ID' }, 107 { name: 'undo', type: 'boolean', default: false, help: 'Unsave instead of save' }, 108 ], 109 func: vi.fn(), 110 }; 111 112 beforeEach(() => { 113 mockExecuteCommand.mockReset(); 114 mockExecuteCommand.mockResolvedValue([]); 115 mockRenderOutput.mockReset(); 116 delete process.env.OPENCLI_VERBOSE; 117 process.exitCode = undefined; 118 }); 119 120 it('coerces default false for boolean args to a real boolean', async () => { 121 const program = new Command(); 122 const siteCmd = program.command('reddit'); 123 registerCommandToProgram(siteCmd, cmd); 124 125 await program.parseAsync(['node', 'opencli', 'reddit', 'save', 't3_abc123']); 126 127 expect(mockExecuteCommand).toHaveBeenCalled(); 128 const kwargs = mockExecuteCommand.mock.calls[0][1]; 129 expect(kwargs['post-id']).toBe('t3_abc123'); 130 expect(kwargs.undo).toBe(false); 131 }); 132 133 it('coerces explicit false for boolean args to a real boolean', async () => { 134 const program = new Command(); 135 const siteCmd = program.command('reddit'); 136 registerCommandToProgram(siteCmd, cmd); 137 138 await program.parseAsync(['node', 'opencli', 'reddit', 'save', 't3_abc123', '--undo', 'false']); 139 140 expect(mockExecuteCommand).toHaveBeenCalled(); 141 const kwargs = mockExecuteCommand.mock.calls[0][1]; 142 expect(kwargs.undo).toBe(false); 143 }); 144 }); 145 146 describe('commanderAdapter value-required optional options', () => { 147 const cmd: CliCommand = { 148 site: 'instagram', 149 name: 'post', 150 description: 'Post to Instagram', 151 browser: true, 152 args: [ 153 { name: 'image', valueRequired: true, help: 'Single image path' }, 154 { name: 'images', valueRequired: true, help: 'Comma-separated image paths' }, 155 { name: 'content', positional: true, required: false, help: 'Caption text' }, 156 ], 157 validateArgs: (kwargs) => { 158 if (!kwargs.image && !kwargs.images) { 159 throw new Error('media required'); 160 } 161 }, 162 func: vi.fn(), 163 }; 164 165 beforeEach(() => { 166 mockExecuteCommand.mockReset(); 167 mockExecuteCommand.mockResolvedValue([]); 168 mockRenderOutput.mockReset(); 169 delete process.env.OPENCLI_VERBOSE; 170 process.exitCode = undefined; 171 }); 172 173 it('requires a value when --image is present', async () => { 174 const program = new Command(); 175 program.exitOverride(); 176 const siteCmd = program.command('instagram'); 177 registerCommandToProgram(siteCmd, cmd); 178 179 await expect( 180 program.parseAsync(['node', 'opencli', 'instagram', 'post', '--image']), 181 ).rejects.toMatchObject({ code: 'commander.optionMissingArgument' }); 182 expect(mockExecuteCommand).not.toHaveBeenCalled(); 183 }); 184 185 it('runs validateArgs before executeCommand so missing media does not dispatch the browser command', async () => { 186 const program = new Command(); 187 const siteCmd = program.command('instagram'); 188 registerCommandToProgram(siteCmd, cmd); 189 190 await program.parseAsync(['node', 'opencli', 'instagram', 'post', 'caption only']); 191 192 expect(mockExecuteCommand).not.toHaveBeenCalled(); 193 expect(process.exitCode).toBeDefined(); 194 }); 195 }); 196 197 describe('commanderAdapter command aliases', () => { 198 const cmd: CliCommand = { 199 site: 'notebooklm', 200 name: 'get', 201 aliases: ['metadata'], 202 description: 'Get notebook metadata', 203 browser: false, 204 args: [], 205 func: vi.fn(), 206 }; 207 208 beforeEach(() => { 209 mockExecuteCommand.mockReset(); 210 mockExecuteCommand.mockResolvedValue([]); 211 mockRenderOutput.mockReset(); 212 delete process.env.OPENCLI_VERBOSE; 213 process.exitCode = undefined; 214 }); 215 216 it('registers aliases with Commander so compatibility names execute the same command', async () => { 217 const program = new Command(); 218 const siteCmd = program.command('notebooklm'); 219 registerCommandToProgram(siteCmd, cmd); 220 221 await program.parseAsync(['node', 'opencli', 'notebooklm', 'metadata']); 222 223 expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false, { prepared: true }); 224 }); 225 }); 226 227 describe('commanderAdapter validation preparation', () => { 228 beforeEach(() => { 229 mockExecuteCommand.mockReset(); 230 mockExecuteCommand.mockResolvedValue([]); 231 mockRenderOutput.mockReset(); 232 delete process.env.OPENCLI_VERBOSE; 233 process.exitCode = undefined; 234 }); 235 236 it('prepares args once before dispatching to executeCommand', async () => { 237 const validateArgs = vi.fn(); 238 const program = new Command(); 239 const siteCmd = program.command('test'); 240 241 registerCommandToProgram(siteCmd, { 242 site: 'test', 243 name: 'run', 244 description: 'Run test command', 245 browser: false, 246 args: [{ name: 'count', default: '1', help: 'Count' }], 247 validateArgs, 248 func: vi.fn(), 249 }); 250 251 await program.parseAsync(['node', 'opencli', 'test', 'run']); 252 253 expect(validateArgs).toHaveBeenCalledTimes(1); 254 expect(mockExecuteCommand).toHaveBeenCalledWith( 255 expect.objectContaining({ site: 'test', name: 'run' }), 256 { count: '1' }, 257 false, 258 { prepared: true }, 259 ); 260 }); 261 }); 262 263 describe('commanderAdapter default formats', () => { 264 const cmd: CliCommand = { 265 site: 'gemini', 266 name: 'ask', 267 description: 'Ask Gemini', 268 browser: false, 269 args: [], 270 columns: ['response'], 271 defaultFormat: 'plain', 272 func: vi.fn(), 273 }; 274 275 beforeEach(() => { 276 mockExecuteCommand.mockReset(); 277 mockExecuteCommand.mockResolvedValue([{ response: 'hello' }]); 278 mockRenderOutput.mockReset(); 279 delete process.env.OPENCLI_VERBOSE; 280 process.exitCode = undefined; 281 }); 282 283 it('uses the command defaultFormat when the user keeps the default table format', async () => { 284 const program = new Command(); 285 const siteCmd = program.command('gemini'); 286 registerCommandToProgram(siteCmd, cmd); 287 288 await program.parseAsync(['node', 'opencli', 'gemini', 'ask']); 289 290 expect(mockRenderOutput).toHaveBeenCalledWith( 291 [{ response: 'hello' }], 292 expect.objectContaining({ fmt: 'plain' }), 293 ); 294 }); 295 296 it('respects an explicit user format over the command defaultFormat', async () => { 297 const program = new Command(); 298 const siteCmd = program.command('gemini'); 299 registerCommandToProgram(siteCmd, cmd); 300 301 await program.parseAsync(['node', 'opencli', 'gemini', 'ask', '--format', 'json']); 302 303 expect(mockRenderOutput).toHaveBeenCalledWith( 304 [{ response: 'hello' }], 305 expect.objectContaining({ fmt: 'json' }), 306 ); 307 }); 308 }); 309 310 describe('commanderAdapter error envelope output', () => { 311 const cmd: CliCommand = { 312 site: 'xiaohongshu', 313 name: 'note', 314 description: 'Read one note', 315 browser: false, 316 args: [ 317 { name: 'note-id', positional: true, required: true, help: 'Note ID' }, 318 ], 319 func: vi.fn(), 320 }; 321 322 beforeEach(() => { 323 mockExecuteCommand.mockReset(); 324 mockRenderOutput.mockReset(); 325 delete process.env.OPENCLI_VERBOSE; 326 process.exitCode = undefined; 327 }); 328 329 it('outputs YAML error envelope with adapter hint to stderr', async () => { 330 const program = new Command(); 331 const siteCmd = program.command('xiaohongshu'); 332 registerCommandToProgram(siteCmd, cmd); 333 334 const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); 335 mockExecuteCommand.mockRejectedValueOnce( 336 new EmptyResultError( 337 'xiaohongshu/note', 338 'Pass the full search_result URL with xsec_token instead of a bare note ID.', 339 ), 340 ); 341 342 await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']); 343 344 const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); 345 expect(output).toContain('ok: false'); 346 expect(output).toContain('code: EMPTY_RESULT'); 347 expect(output).toContain('xsec_token'); 348 349 stderrSpy.mockRestore(); 350 }); 351 352 it('outputs YAML error envelope for selector errors', async () => { 353 const program = new Command(); 354 const siteCmd = program.command('xiaohongshu'); 355 registerCommandToProgram(siteCmd, cmd); 356 357 const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); 358 mockExecuteCommand.mockRejectedValueOnce( 359 new SelectorError('.note-title', 'The note title selector no longer matches the current page.'), 360 ); 361 362 await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']); 363 364 const output = stderrSpy.mock.calls.map(c => String(c[0])).join(''); 365 expect(output).toContain('ok: false'); 366 expect(output).toContain('code: SELECTOR'); 367 expect(output).toContain('selector no longer matches'); 368 369 stderrSpy.mockRestore(); 370 }); 371 });