import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CliError, CommandExecutionError, EXIT_CODES, TimeoutError } from '@jackwener/opencli/errors'; const { mockEnsureOnDeepSeek, mockSelectModel, mockSetFeature, mockSendMessage, mockSendWithFile, mockGetBubbleCount, mockWaitForResponse, mockParseBoolFlag, mockWithRetry, mockPickResumeUrl, } = vi.hoisted(() => ({ mockEnsureOnDeepSeek: vi.fn(), mockSelectModel: vi.fn(), mockSetFeature: vi.fn(), mockSendMessage: vi.fn(), mockSendWithFile: vi.fn(), mockGetBubbleCount: vi.fn(), mockWaitForResponse: vi.fn(), mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'), mockWithRetry: vi.fn(async (fn) => fn()), mockPickResumeUrl: vi.fn(), })); vi.mock('./utils.js', () => ({ DEEPSEEK_DOMAIN: 'chat.deepseek.com', DEEPSEEK_URL: 'https://chat.deepseek.com/', ensureOnDeepSeek: mockEnsureOnDeepSeek, selectModel: mockSelectModel, setFeature: mockSetFeature, sendMessage: mockSendMessage, sendWithFile: mockSendWithFile, getBubbleCount: mockGetBubbleCount, waitForResponse: mockWaitForResponse, parseBoolFlag: mockParseBoolFlag, withRetry: mockWithRetry, pickResumeUrl: mockPickResumeUrl, })); import { askCommand } from './ask.js'; describe('deepseek ask --file', () => { const page = { wait: vi.fn().mockResolvedValue(undefined), goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'), }; beforeEach(() => { vi.clearAllMocks(); page.evaluate.mockResolvedValue('https://chat.deepseek.com/'); mockEnsureOnDeepSeek.mockResolvedValue(false); mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); mockSetFeature.mockResolvedValue({ ok: true, toggled: false }); mockSendWithFile.mockResolvedValue({ ok: true }); mockGetBubbleCount.mockResolvedValue(7); mockWaitForResponse.mockResolvedValue('new reply'); }); it('captures the existing baseline before sending a file prompt', async () => { const rows = await askCommand.func(page, { prompt: 'summarize this', timeout: 120, file: './report.pdf', new: false, model: 'instant', think: false, search: false, }); expect(rows).toEqual([{ response: 'new reply' }]); expect(mockGetBubbleCount).toHaveBeenCalledTimes(1); expect(mockSendWithFile).toHaveBeenCalledWith(page, './report.pdf', 'summarize this'); expect(mockWaitForResponse).toHaveBeenCalledWith(page, 7, 'summarize this', 120000, false); }); it('still fails when explicit instant model selection cannot be verified', async () => { mockSelectModel.mockResolvedValue({ ok: false }); await expect(askCommand.func(page, { prompt: 'summarize this', timeout: 120, new: false, model: 'instant', think: false, search: false, })).rejects.toThrow(new CommandExecutionError('Could not switch to instant model')); }); }); describe('deepseek ask --think', () => { const page = { wait: vi.fn().mockResolvedValue(undefined), goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'), }; beforeEach(() => { vi.clearAllMocks(); page.evaluate.mockResolvedValue('https://chat.deepseek.com/'); mockEnsureOnDeepSeek.mockResolvedValue(false); mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); mockSetFeature.mockResolvedValue({ ok: true, toggled: false }); mockSendMessage.mockResolvedValue({ ok: true }); mockGetBubbleCount.mockResolvedValue(5); }); it('returns separate thinking and response fields when --think is enabled', async () => { mockWaitForResponse.mockResolvedValue({ response: 'The answer is 42.', thinking: 'Let me analyze this...', thinking_time: '2.5', }); const rows = await askCommand.func(page, { prompt: 'what is the answer?', timeout: 120, new: false, model: 'instant', think: true, search: false, }); expect(rows).toEqual([{ response: 'The answer is 42.', thinking: 'Let me analyze this...', thinking_time: '2.5', }]); expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, true); }); it('returns plain response when --think is disabled', async () => { mockWaitForResponse.mockResolvedValue('The answer is 42.'); const rows = await askCommand.func(page, { prompt: 'what is the answer?', timeout: 120, new: false, model: 'instant', think: false, search: false, }); expect(rows).toEqual([{ response: 'The answer is 42.' }]); expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, false); }); it('does not declare static columns (derived from row keys)', () => { // columns should be undefined so the renderer infers from row keys, // avoiding empty trailing columns on non-think output. expect(askCommand.columns).toBeUndefined(); }); it('non-think rows only contain response key', async () => { mockWaitForResponse.mockResolvedValue('Plain answer.'); const rows = await askCommand.func(page, { prompt: 'hello', timeout: 120, new: false, model: 'instant', think: false, search: false, }); // Row keys drive rendered columns; no thinking/thinking_time present. expect(Object.keys(rows[0])).toEqual(['response']); }); }); describe('deepseek ask conversation resume', () => { const page = { wait: vi.fn().mockResolvedValue(undefined), goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn(), }; beforeEach(() => { vi.clearAllMocks(); mockSetFeature.mockResolvedValue({ ok: true, toggled: false }); mockSendMessage.mockResolvedValue({ ok: true }); mockGetBubbleCount.mockResolvedValue(2); mockWaitForResponse.mockResolvedValue('follow-up reply'); }); it('resumes the most recent conversation and skips model selection', async () => { mockEnsureOnDeepSeek.mockResolvedValue(true); mockPickResumeUrl.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123'); // URL check after resume navigation: now inside a conversation. page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/abc-123'); const rows = await askCommand.func(page, { prompt: 'follow up', timeout: 120, new: false, model: 'instant', think: false, search: false, }); expect(rows).toEqual([{ response: 'follow-up reply' }]); expect(page.goto).toHaveBeenCalledWith('https://chat.deepseek.com/a/chat/s/abc-123'); expect(mockSelectModel).not.toHaveBeenCalled(); expect(mockSendMessage).toHaveBeenCalled(); }); it('skips model selection when already inside an existing conversation', async () => { mockEnsureOnDeepSeek.mockResolvedValue(false); page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123'); const rows = await askCommand.func(page, { prompt: 'continue', timeout: 120, new: false, model: 'expert', think: false, search: false, }); expect(rows).toEqual([{ response: 'follow-up reply' }]); expect(mockSelectModel).not.toHaveBeenCalled(); }); it('fails fast when --model is explicitly requested inside an existing conversation', async () => { mockEnsureOnDeepSeek.mockResolvedValue(false); page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123'); await expect(askCommand.func(page, { prompt: 'continue', timeout: 120, new: false, model: 'expert', think: false, search: false, __opencliOptionSources: { model: 'cli' }, })).rejects.toMatchObject(new CliError( 'ARGUMENT', 'Cannot switch to expert model inside an existing conversation.', 'Re-run with --new to start a fresh chat before selecting a model.', EXIT_CODES.USAGE_ERROR, )); expect(mockSelectModel).not.toHaveBeenCalled(); }); it('fails fast when the workspace was recycled but no conversation surfaces in time', async () => { mockEnsureOnDeepSeek.mockResolvedValue(true); mockPickResumeUrl.mockResolvedValue(null); await expect(askCommand.func(page, { prompt: 'hello', timeout: 120, new: false, model: 'instant', think: false, search: false, })).rejects.toBeInstanceOf(CommandExecutionError); expect(page.goto).not.toHaveBeenCalled(); expect(mockSelectModel).not.toHaveBeenCalled(); expect(mockSendMessage).not.toHaveBeenCalled(); }); it('skips search toggle in vision mode when search is not requested', async () => { mockEnsureOnDeepSeek.mockResolvedValue(false); mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); mockSetFeature.mockResolvedValue({ ok: true, toggled: false }); mockSendMessage.mockResolvedValue({ ok: true }); mockGetBubbleCount.mockResolvedValue(0); mockWaitForResponse.mockResolvedValue('vision reply'); page.evaluate.mockResolvedValue('https://chat.deepseek.com/'); const rows = await askCommand.func(page, { prompt: 'describe', timeout: 120, new: false, model: 'vision', think: false, search: false, }); expect(rows).toEqual([{ response: 'vision reply' }]); expect(mockSetFeature).toHaveBeenCalledTimes(1); expect(mockSetFeature).toHaveBeenCalledWith(expect.anything(), 'DeepThink', false); }); it('fails fast instead of silently ignoring --search in vision mode', async () => { mockEnsureOnDeepSeek.mockResolvedValue(false); mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); page.evaluate.mockResolvedValue('https://chat.deepseek.com/'); await expect(askCommand.func(page, { prompt: 'describe', timeout: 120, new: false, model: 'vision', think: false, search: true, })).rejects.toMatchObject(new CliError( 'ARGUMENT', 'DeepSeek vision mode does not support --search.', 'Run without --search, or use --model instant for web search.', EXIT_CODES.USAGE_ERROR, )); expect(page.goto).not.toHaveBeenCalled(); expect(mockEnsureOnDeepSeek).not.toHaveBeenCalled(); expect(mockSelectModel).not.toHaveBeenCalled(); expect(mockSetFeature).not.toHaveBeenCalled(); expect(mockSendMessage).not.toHaveBeenCalled(); expect(mockSendWithFile).not.toHaveBeenCalled(); }); it('skips search toggle in expert mode when search is not requested', async () => { mockEnsureOnDeepSeek.mockResolvedValue(false); mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); mockSetFeature.mockResolvedValue({ ok: true, toggled: false }); mockSendMessage.mockResolvedValue({ ok: true }); mockGetBubbleCount.mockResolvedValue(0); mockWaitForResponse.mockResolvedValue('expert reply'); page.evaluate.mockResolvedValue('https://chat.deepseek.com/'); const rows = await askCommand.func(page, { prompt: 'analyze', timeout: 120, new: false, model: 'expert', think: false, search: false, }); expect(rows).toEqual([{ response: 'expert reply' }]); expect(mockSetFeature).toHaveBeenCalledTimes(1); expect(mockSetFeature).toHaveBeenCalledWith(expect.anything(), 'DeepThink', false); }); it('fails fast instead of silently ignoring --search in expert mode', async () => { mockEnsureOnDeepSeek.mockResolvedValue(false); mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); page.evaluate.mockResolvedValue('https://chat.deepseek.com/'); await expect(askCommand.func(page, { prompt: 'analyze', timeout: 120, new: false, model: 'expert', think: false, search: true, })).rejects.toMatchObject(new CliError( 'ARGUMENT', 'DeepSeek expert mode does not support --search.', 'Run without --search, or use --model instant for web search.', EXIT_CODES.USAGE_ERROR, )); expect(page.goto).not.toHaveBeenCalled(); expect(mockEnsureOnDeepSeek).not.toHaveBeenCalled(); expect(mockSelectModel).not.toHaveBeenCalled(); expect(mockSetFeature).not.toHaveBeenCalled(); expect(mockSendMessage).not.toHaveBeenCalled(); expect(mockSendWithFile).not.toHaveBeenCalled(); }); }); describe('deepseek ask timeout surfaces TimeoutError (not a silent sentinel row)', () => { const page = { wait: vi.fn().mockResolvedValue(undefined), goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'), }; beforeEach(() => { vi.clearAllMocks(); page.evaluate.mockResolvedValue('https://chat.deepseek.com/'); mockEnsureOnDeepSeek.mockResolvedValue(false); mockSelectModel.mockResolvedValue({ ok: true, toggled: false }); mockSetFeature.mockResolvedValue({ ok: true, toggled: false }); mockSendMessage.mockResolvedValue({ ok: true }); mockSendWithFile.mockResolvedValue({ ok: true }); mockGetBubbleCount.mockResolvedValue(3); // No reply within the window. mockWaitForResponse.mockResolvedValue(null); }); it('throws TimeoutError on the normal send path when no reply arrives', async () => { await expect(askCommand.func(page, { prompt: 'hello', timeout: 30, new: false, model: 'default', think: false, search: false, })).rejects.toThrow(TimeoutError); }); it('throws TimeoutError on the --file path when no reply arrives', async () => { await expect(askCommand.func(page, { prompt: 'summarize', timeout: 30, file: './report.pdf', new: false, model: 'instant', think: false, search: false, })).rejects.toThrow(TimeoutError); }); });