Files
jackwener__opencli/clis/claude/commands.test.js
Benjamin Liu 284c96133b feat(claude): add Claude adapter (#1252)
* feat(claude): add Claude adapter

Adds a Claude (claude.ai) browser adapter family with seven commands
modeled on the existing clis/deepseek/ pattern: ask, send, new, status,
read, history, detail.

Closes #1251

* feat(claude): align send command columns with doubao

Match the established Status / SubmittedBy / InjectedText shape used by
doubao send so agent loops can rely on a consistent fire-and-forget
output across AI chat adapters.

* fix(claude): preserve DOM order in getVisibleMessages

The previous implementation queried user-message and assistant-message
nodes in two passes, which serialized as [u1, u2, u3, a1, a2, a3] for
multi-turn chats instead of the correct conversation order. Single
combined query preserves DOM order so claude read / detail return
turns in the order the user reads them on the page.

* docs(claude): note --live requirement for read across invocations

* fix(claude): fail fast on auth and empty states

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-03 14:55:41 +08:00

119 lines
4.8 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
const {
mockEnsureOnClaude,
mockEnsureClaudeComposer,
mockEnsureClaudeLogin,
mockSendMessage,
mockParseBoolFlag,
mockRequireNonEmptyPrompt,
mockGetVisibleMessages,
mockGetConversationList,
mockRequirePositiveInt,
mockRequireConversationId,
mockWithRetry,
} = vi.hoisted(() => ({
mockEnsureOnClaude: vi.fn(),
mockEnsureClaudeComposer: vi.fn(),
mockEnsureClaudeLogin: vi.fn(),
mockSendMessage: vi.fn(),
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
mockGetVisibleMessages: vi.fn(),
mockGetConversationList: vi.fn(),
mockRequirePositiveInt: vi.fn((v) => Number(v)),
mockRequireConversationId: vi.fn((v) => String(v ?? '').trim()),
mockWithRetry: vi.fn(async (fn) => fn()),
}));
vi.mock('./utils.js', () => ({
CLAUDE_DOMAIN: 'claude.ai',
CLAUDE_URL: 'https://claude.ai/new',
ensureOnClaude: mockEnsureOnClaude,
ensureClaudeComposer: mockEnsureClaudeComposer,
ensureClaudeLogin: mockEnsureClaudeLogin,
sendMessage: mockSendMessage,
parseBoolFlag: mockParseBoolFlag,
requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
getVisibleMessages: mockGetVisibleMessages,
getConversationList: mockGetConversationList,
requirePositiveInt: mockRequirePositiveInt,
requireConversationId: mockRequireConversationId,
withRetry: mockWithRetry,
}));
import { sendCommand } from './send.js';
import { newCommand } from './new.js';
import { readCommand } from './read.js';
import { historyCommand } from './history.js';
import { detailCommand } from './detail.js';
describe('claude command-level fail-fast contracts', () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
vi.clearAllMocks();
mockEnsureOnClaude.mockResolvedValue(false);
mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
mockEnsureClaudeLogin.mockResolvedValue({ isLoggedIn: true });
mockSendMessage.mockResolvedValue({ ok: true });
mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
mockGetVisibleMessages.mockResolvedValue([{ Index: 0, Role: 'assistant', Text: 'hi' }]);
mockGetConversationList.mockResolvedValue([{ Index: 1, Id: 'abc', Title: 'Hi', Url: 'https://claude.ai/chat/abc' }]);
mockRequirePositiveInt.mockImplementation((v) => Number(v));
mockRequireConversationId.mockImplementation((v) => String(v ?? '').trim());
});
it('send rejects empty prompt via ArgumentError', async () => {
mockRequireNonEmptyPrompt.mockImplementation(() => {
throw new ArgumentError('claude send prompt cannot be empty');
});
await expect(sendCommand.func(page, { prompt: '', new: false })).rejects.toThrow(ArgumentError);
});
it('send surfaces auth failure from composer readiness', async () => {
mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude send requires a logged-in Claude session.'));
await expect(sendCommand.func(page, { prompt: 'hi', new: false })).rejects.toThrow(AuthRequiredError);
});
it('new no longer false-succeeds on login wall', async () => {
mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude new requires a logged-in Claude session with a visible composer.'));
await expect(newCommand.func(page)).rejects.toThrow(AuthRequiredError);
});
it('read throws EmptyResultError instead of a placeholder row', async () => {
mockGetVisibleMessages.mockResolvedValue([]);
await expect(readCommand.func(page)).rejects.toThrow(EmptyResultError);
});
it('history rejects invalid --limit values instead of silently coercing them', async () => {
mockRequirePositiveInt.mockImplementation(() => {
throw new ArgumentError('claude history --limit must be a positive integer');
});
await expect(historyCommand.func(page, { limit: 0 })).rejects.toThrow(ArgumentError);
});
it('history throws EmptyResultError on an empty /recents page', async () => {
mockGetConversationList.mockResolvedValue([]);
await expect(historyCommand.func(page, { limit: 20 })).rejects.toThrow(EmptyResultError);
});
it('detail rejects a missing conversation id', async () => {
mockRequireConversationId.mockImplementation(() => {
throw new ArgumentError('claude detail requires a conversation id');
});
await expect(detailCommand.func(page, { id: '' })).rejects.toThrow(ArgumentError);
});
});