/ clis / deepseek / ask.test.js
ask.test.js
  1  import { beforeEach, describe, expect, it, vi } from 'vitest';
  2  import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
  3  
  4  const {
  5    mockEnsureOnDeepSeek,
  6    mockSelectModel,
  7    mockSetFeature,
  8    mockSendMessage,
  9    mockSendWithFile,
 10    mockGetBubbleCount,
 11    mockWaitForResponse,
 12    mockParseBoolFlag,
 13    mockWithRetry,
 14  } = vi.hoisted(() => ({
 15    mockEnsureOnDeepSeek: vi.fn(),
 16    mockSelectModel: vi.fn(),
 17    mockSetFeature: vi.fn(),
 18    mockSendMessage: vi.fn(),
 19    mockSendWithFile: vi.fn(),
 20    mockGetBubbleCount: vi.fn(),
 21    mockWaitForResponse: vi.fn(),
 22    mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
 23    mockWithRetry: vi.fn(async (fn) => fn()),
 24  }));
 25  
 26  vi.mock('./utils.js', () => ({
 27    DEEPSEEK_DOMAIN: 'chat.deepseek.com',
 28    DEEPSEEK_URL: 'https://chat.deepseek.com/',
 29    ensureOnDeepSeek: mockEnsureOnDeepSeek,
 30    selectModel: mockSelectModel,
 31    setFeature: mockSetFeature,
 32    sendMessage: mockSendMessage,
 33    sendWithFile: mockSendWithFile,
 34    getBubbleCount: mockGetBubbleCount,
 35    waitForResponse: mockWaitForResponse,
 36    parseBoolFlag: mockParseBoolFlag,
 37    withRetry: mockWithRetry,
 38  }));
 39  
 40  import { askCommand } from './ask.js';
 41  
 42  describe('deepseek ask --file', () => {
 43    const page = {
 44      wait: vi.fn().mockResolvedValue(undefined),
 45      goto: vi.fn().mockResolvedValue(undefined),
 46      evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
 47    };
 48  
 49    beforeEach(() => {
 50      vi.clearAllMocks();
 51      page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
 52      mockEnsureOnDeepSeek.mockResolvedValue(false);
 53      mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
 54      mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
 55      mockSendWithFile.mockResolvedValue({ ok: true });
 56      mockGetBubbleCount.mockResolvedValue(7);
 57      mockWaitForResponse.mockResolvedValue('new reply');
 58    });
 59  
 60    it('captures the existing baseline before sending a file prompt', async () => {
 61      const rows = await askCommand.func(page, {
 62        prompt: 'summarize this',
 63        timeout: 120,
 64        file: './report.pdf',
 65        new: false,
 66        model: 'instant',
 67        think: false,
 68        search: false,
 69      });
 70  
 71      expect(rows).toEqual([{ response: 'new reply' }]);
 72      expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
 73      expect(mockSendWithFile).toHaveBeenCalledWith(page, './report.pdf', 'summarize this');
 74      expect(mockWaitForResponse).toHaveBeenCalledWith(page, 7, 'summarize this', 120000, false);
 75    });
 76  
 77    it('still fails when explicit instant model selection cannot be verified', async () => {
 78      mockSelectModel.mockResolvedValue({ ok: false });
 79  
 80      await expect(askCommand.func(page, {
 81        prompt: 'summarize this',
 82        timeout: 120,
 83        new: false,
 84        model: 'instant',
 85        think: false,
 86        search: false,
 87      })).rejects.toThrow(new CommandExecutionError('Could not switch to instant model'));
 88    });
 89  });
 90  
 91  describe('deepseek ask --think', () => {
 92    const page = {
 93      wait: vi.fn().mockResolvedValue(undefined),
 94      goto: vi.fn().mockResolvedValue(undefined),
 95      evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
 96    };
 97  
 98    beforeEach(() => {
 99      vi.clearAllMocks();
100      page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
101      mockEnsureOnDeepSeek.mockResolvedValue(false);
102      mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
103      mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
104      mockSendMessage.mockResolvedValue({ ok: true });
105      mockGetBubbleCount.mockResolvedValue(5);
106    });
107  
108    it('returns separate thinking and response fields when --think is enabled', async () => {
109      mockWaitForResponse.mockResolvedValue({
110        response: 'The answer is 42.',
111        thinking: 'Let me analyze this...',
112        thinking_time: '2.5',
113      });
114  
115      const rows = await askCommand.func(page, {
116        prompt: 'what is the answer?',
117        timeout: 120,
118        new: false,
119        model: 'instant',
120        think: true,
121        search: false,
122      });
123  
124      expect(rows).toEqual([{
125        response: 'The answer is 42.',
126        thinking: 'Let me analyze this...',
127        thinking_time: '2.5',
128      }]);
129      expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, true);
130    });
131  
132    it('returns plain response when --think is disabled', async () => {
133      mockWaitForResponse.mockResolvedValue('The answer is 42.');
134  
135      const rows = await askCommand.func(page, {
136        prompt: 'what is the answer?',
137        timeout: 120,
138        new: false,
139        model: 'instant',
140        think: false,
141        search: false,
142      });
143  
144      expect(rows).toEqual([{ response: 'The answer is 42.' }]);
145      expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, false);
146    });
147  
148    it('does not declare static columns (derived from row keys)', () => {
149      // columns should be undefined so the renderer infers from row keys,
150      // avoiding empty trailing columns on non-think output.
151      expect(askCommand.columns).toBeUndefined();
152    });
153  
154    it('non-think rows only contain response key', async () => {
155      mockWaitForResponse.mockResolvedValue('Plain answer.');
156  
157      const rows = await askCommand.func(page, {
158        prompt: 'hello',
159        timeout: 120,
160        new: false,
161        model: 'instant',
162        think: false,
163        search: false,
164      });
165  
166      // Row keys drive rendered columns; no thinking/thinking_time present.
167      expect(Object.keys(rows[0])).toEqual(['response']);
168    });
169  });
170  
171  describe('deepseek ask conversation resume', () => {
172    const page = {
173      wait: vi.fn().mockResolvedValue(undefined),
174      goto: vi.fn().mockResolvedValue(undefined),
175      evaluate: vi.fn(),
176    };
177  
178    beforeEach(() => {
179      vi.clearAllMocks();
180      mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
181      mockSendMessage.mockResolvedValue({ ok: true });
182      mockGetBubbleCount.mockResolvedValue(2);
183      mockWaitForResponse.mockResolvedValue('follow-up reply');
184    });
185  
186    it('resumes the most recent conversation and skips model selection', async () => {
187      mockEnsureOnDeepSeek.mockResolvedValue(true);
188      // first evaluate: sidebar resume click (returns undefined)
189      page.evaluate.mockResolvedValueOnce(undefined);
190      // second evaluate: URL check (now inside a conversation)
191      page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/abc-123');
192  
193      const rows = await askCommand.func(page, {
194        prompt: 'follow up',
195        timeout: 120,
196        new: false,
197        model: 'instant',
198        think: false,
199        search: false,
200      });
201  
202      expect(rows).toEqual([{ response: 'follow-up reply' }]);
203      expect(mockSelectModel).not.toHaveBeenCalled();
204      expect(mockSendMessage).toHaveBeenCalled();
205    });
206  
207    it('skips model selection when already inside an existing conversation', async () => {
208      mockEnsureOnDeepSeek.mockResolvedValue(false);
209      page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
210  
211      const rows = await askCommand.func(page, {
212        prompt: 'continue',
213        timeout: 120,
214        new: false,
215        model: 'expert',
216        think: false,
217        search: false,
218      });
219  
220      expect(rows).toEqual([{ response: 'follow-up reply' }]);
221      expect(mockSelectModel).not.toHaveBeenCalled();
222    });
223  
224    it('fails fast when --model is explicitly requested inside an existing conversation', async () => {
225      mockEnsureOnDeepSeek.mockResolvedValue(false);
226      page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
227  
228      await expect(askCommand.func(page, {
229        prompt: 'continue',
230        timeout: 120,
231        new: false,
232        model: 'expert',
233        think: false,
234        search: false,
235        __opencliOptionSources: { model: 'cli' },
236      })).rejects.toMatchObject(new CliError(
237        'ARGUMENT',
238        'Cannot switch to expert model inside an existing conversation.',
239        'Re-run with --new to start a fresh chat before selecting a model.',
240        EXIT_CODES.USAGE_ERROR,
241      ));
242  
243      expect(mockSelectModel).not.toHaveBeenCalled();
244    });
245  
246    it('still selects model when no conversation to resume', async () => {
247      mockEnsureOnDeepSeek.mockResolvedValue(true);
248      mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
249      // first evaluate: sidebar resume click (no link found)
250      page.evaluate.mockResolvedValueOnce(undefined);
251      // second evaluate: URL check (still on root page)
252      page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/');
253  
254      const rows = await askCommand.func(page, {
255        prompt: 'hello',
256        timeout: 120,
257        new: false,
258        model: 'instant',
259        think: false,
260        search: false,
261      });
262  
263      expect(rows).toEqual([{ response: 'follow-up reply' }]);
264      expect(mockSelectModel).toHaveBeenCalled();
265    });
266  });