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