9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
|
|
const {
|
|
mockEnsureOnClaude,
|
|
mockEnsureClaudeComposer,
|
|
mockSelectModel,
|
|
mockSetAdaptiveThinking,
|
|
mockSendMessage,
|
|
mockSendWithFile,
|
|
mockGetBubbleCount,
|
|
mockWaitForResponse,
|
|
mockParseBoolFlag,
|
|
mockRequireNonEmptyPrompt,
|
|
mockRequirePositiveInt,
|
|
mockWithRetry,
|
|
} = vi.hoisted(() => ({
|
|
mockEnsureOnClaude: vi.fn(),
|
|
mockEnsureClaudeComposer: vi.fn(),
|
|
mockSelectModel: vi.fn(),
|
|
mockSetAdaptiveThinking: vi.fn(),
|
|
mockSendMessage: vi.fn(),
|
|
mockSendWithFile: vi.fn(),
|
|
mockGetBubbleCount: vi.fn(),
|
|
mockWaitForResponse: vi.fn(),
|
|
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
|
|
mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
|
|
mockRequirePositiveInt: vi.fn((v) => Number(v)),
|
|
mockWithRetry: vi.fn(async (fn) => fn()),
|
|
}));
|
|
|
|
vi.mock('./utils.js', () => ({
|
|
CLAUDE_DOMAIN: 'claude.ai',
|
|
CLAUDE_URL: 'https://claude.ai/new',
|
|
ensureOnClaude: mockEnsureOnClaude,
|
|
ensureClaudeComposer: mockEnsureClaudeComposer,
|
|
selectModel: mockSelectModel,
|
|
setAdaptiveThinking: mockSetAdaptiveThinking,
|
|
sendMessage: mockSendMessage,
|
|
sendWithFile: mockSendWithFile,
|
|
getBubbleCount: mockGetBubbleCount,
|
|
waitForResponse: mockWaitForResponse,
|
|
parseBoolFlag: mockParseBoolFlag,
|
|
requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
|
|
requirePositiveInt: mockRequirePositiveInt,
|
|
withRetry: mockWithRetry,
|
|
}));
|
|
|
|
import { askCommand } from './ask.js';
|
|
|
|
describe('claude ask basic flow', () => {
|
|
const page = {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
page.evaluate.mockResolvedValue('https://claude.ai/new');
|
|
mockEnsureOnClaude.mockResolvedValue(false);
|
|
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
|
|
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
|
|
mockSendMessage.mockResolvedValue({ ok: true });
|
|
mockSendWithFile.mockResolvedValue({ ok: true });
|
|
mockGetBubbleCount.mockResolvedValue(0);
|
|
mockWaitForResponse.mockResolvedValue('hello there');
|
|
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
|
|
mockRequirePositiveInt.mockImplementation((v) => Number(v));
|
|
});
|
|
|
|
it('returns the assistant response on a fresh chat', async () => {
|
|
const rows = await askCommand.func(page, {
|
|
prompt: 'hi',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
});
|
|
|
|
expect(rows).toEqual([{ response: 'hello there' }]);
|
|
expect(mockSendMessage).toHaveBeenCalledWith(page, 'hi');
|
|
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 0, 'hi', 120000);
|
|
});
|
|
|
|
it('navigates to /new when --new is set', async () => {
|
|
await askCommand.func(page, {
|
|
prompt: 'hi',
|
|
timeout: 120,
|
|
new: true,
|
|
model: 'sonnet',
|
|
think: false,
|
|
});
|
|
|
|
expect(page.goto).toHaveBeenCalledWith('https://claude.ai/new');
|
|
expect(mockEnsureOnClaude).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws EmptyResultError when waitForResponse yields nothing', async () => {
|
|
mockWaitForResponse.mockResolvedValue(null);
|
|
|
|
await expect(askCommand.func(page, {
|
|
prompt: 'hi',
|
|
timeout: 60,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
})).rejects.toThrow(EmptyResultError);
|
|
});
|
|
|
|
it('throws CommandExecutionError when send fails', async () => {
|
|
mockSendMessage.mockResolvedValue({ ok: false, reason: 'composer not found' });
|
|
|
|
await expect(askCommand.func(page, {
|
|
prompt: 'hi',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
})).rejects.toThrow(/composer not found/);
|
|
});
|
|
});
|
|
|
|
describe('claude ask --model handling', () => {
|
|
const page = {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockEnsureOnClaude.mockResolvedValue(false);
|
|
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
|
|
mockSendMessage.mockResolvedValue({ ok: true });
|
|
mockGetBubbleCount.mockResolvedValue(0);
|
|
mockWaitForResponse.mockResolvedValue('reply');
|
|
});
|
|
|
|
it('rejects --model opus on free tier with usage-error guidance', async () => {
|
|
page.evaluate.mockResolvedValue('https://claude.ai/new');
|
|
mockSelectModel.mockResolvedValue({ ok: false, upgrade: true });
|
|
|
|
await expect(askCommand.func(page, {
|
|
prompt: 'hi',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'opus',
|
|
think: false,
|
|
})).rejects.toMatchObject(new ArgumentError(
|
|
'opus model requires a paid Claude plan.',
|
|
'Pick --model sonnet or --model haiku, or upgrade your account.',
|
|
));
|
|
});
|
|
|
|
it('skips model selection inside an existing conversation', async () => {
|
|
page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
|
|
|
|
const rows = await askCommand.func(page, {
|
|
prompt: 'continue',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
});
|
|
|
|
expect(rows).toEqual([{ response: 'reply' }]);
|
|
expect(mockSelectModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails fast when --model is explicit inside an existing conversation', async () => {
|
|
page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
|
|
|
|
await expect(askCommand.func(page, {
|
|
prompt: 'continue',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'opus',
|
|
think: false,
|
|
__opencliOptionSources: { model: 'cli' },
|
|
})).rejects.toMatchObject(new ArgumentError(
|
|
'Cannot switch to opus model inside an existing conversation.',
|
|
'Re-run with --new to start a fresh chat before selecting a model.',
|
|
));
|
|
|
|
expect(mockSelectModel).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('claude ask --think', () => {
|
|
const page = {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockEnsureOnClaude.mockResolvedValue(false);
|
|
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
mockSendMessage.mockResolvedValue({ ok: true });
|
|
mockGetBubbleCount.mockResolvedValue(0);
|
|
mockWaitForResponse.mockResolvedValue('reply');
|
|
});
|
|
|
|
it('toggles Adaptive thinking when --think is set', async () => {
|
|
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: true });
|
|
|
|
await askCommand.func(page, {
|
|
prompt: 'reason carefully',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: true,
|
|
});
|
|
|
|
expect(mockSetAdaptiveThinking).toHaveBeenCalledWith(page, true);
|
|
});
|
|
|
|
it('throws when --think requested but toggle fails', async () => {
|
|
mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
|
|
|
|
await expect(askCommand.func(page, {
|
|
prompt: 'reason carefully',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: true,
|
|
})).rejects.toThrow(/Adaptive thinking/);
|
|
});
|
|
|
|
it('does not throw when --think is false and toggle returns ok=false', async () => {
|
|
mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
|
|
|
|
await expect(askCommand.func(page, {
|
|
prompt: 'hi',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
})).resolves.toEqual([{ response: 'reply' }]);
|
|
});
|
|
|
|
it('fails fast when prompt validation rejects an empty prompt', async () => {
|
|
mockRequireNonEmptyPrompt.mockImplementation(() => {
|
|
throw new ArgumentError('claude ask prompt cannot be empty');
|
|
});
|
|
|
|
await expect(askCommand.func(page, {
|
|
prompt: '',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
})).rejects.toThrow(ArgumentError);
|
|
});
|
|
|
|
it('fails fast when timeout validation rejects a non-positive value', async () => {
|
|
mockRequirePositiveInt.mockImplementation(() => {
|
|
throw new ArgumentError('claude ask --timeout must be a positive integer');
|
|
});
|
|
|
|
await expect(askCommand.func(page, {
|
|
prompt: 'hi',
|
|
timeout: 0,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
})).rejects.toThrow(ArgumentError);
|
|
});
|
|
});
|
|
|
|
describe('claude ask --file', () => {
|
|
const page = {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockEnsureOnClaude.mockResolvedValue(false);
|
|
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
|
|
mockSendWithFile.mockResolvedValue({ ok: true });
|
|
mockGetBubbleCount.mockResolvedValue(3);
|
|
mockWaitForResponse.mockResolvedValue('the image shows a cat');
|
|
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
|
|
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
|
|
mockRequirePositiveInt.mockImplementation((v) => Number(v));
|
|
});
|
|
|
|
it('routes through sendWithFile and captures baseline before sending', async () => {
|
|
const rows = await askCommand.func(page, {
|
|
prompt: 'describe this',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
file: '/tmp/cat.png',
|
|
});
|
|
|
|
expect(rows).toEqual([{ response: 'the image shows a cat' }]);
|
|
expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
|
|
expect(mockSendWithFile).toHaveBeenCalledWith(page, '/tmp/cat.png', 'describe this');
|
|
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 3, 'describe this', 120000);
|
|
});
|
|
|
|
it('surfaces file upload failure as CommandExecutionError', async () => {
|
|
mockSendWithFile.mockResolvedValue({ ok: false, reason: 'file preview did not appear' });
|
|
|
|
await expect(askCommand.func(page, {
|
|
prompt: 'describe this',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
file: '/tmp/cat.png',
|
|
})).rejects.toThrow(/file preview did not appear/);
|
|
});
|
|
|
|
it('absorbs "Promise was collected" SPA navigation error after send', async () => {
|
|
mockSendWithFile.mockRejectedValue(new Error('Promise was collected'));
|
|
|
|
const rows = await askCommand.func(page, {
|
|
prompt: 'describe this',
|
|
timeout: 120,
|
|
new: false,
|
|
model: 'sonnet',
|
|
think: false,
|
|
file: '/tmp/cat.png',
|
|
});
|
|
|
|
expect(rows).toEqual([{ response: 'the image shows a cat' }]);
|
|
});
|
|
});
|