/ src / commanderAdapter.test.ts
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  });