mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
6469a02ea6
* feat(deepseek): add detail and send commands for explicit conversation control
doubao already ships `detail <id>` and `send` for ID-explicit conversation
read/write; deepseek had only `read` (current page only) plus the
implicit-resume `ask`. Adding both gives users a stable handle when they
know the conversation ID, without going through `ask`'s resume detection
or its full prompt-then-wait pipeline.
`deepseek detail <id>`:
- parses a bare UUID or any URL containing `/a/chat/s/<id>`,
- rejects malformed input via `ArgumentError` before any browser
navigation,
- navigates to `https://chat.deepseek.com/a/chat/s/<id>` and returns
the visible message list,
- throws `EmptyResultError` when the conversation has no rendered
messages.
`deepseek send <id> <prompt>`:
- takes the conversation id as a required positional, because the
framework runs each browser command in an ephemeral per-command
workspace (a fresh tab) and there is no shared "current conversation"
across commands; the navigation must be explicit,
- drives input through CDP `Input.insertText` via `page.nativeType`,
mirroring the doubao adapter (#1278); `execCommand('insertText')` plus
a synthesised input event leaves the React-controlled state desynced
on a freshly-opened tab and the resulting click silently no-ops,
- keeps the verification loop inside the same `page.evaluate` so the
framework cannot close the tab mid-flight; counts user-class bubbles
by text-match (DeepSeek virtualises the message list, so a numeric
bubble-count check is unreliable),
- throws `CommandExecutionError` with a specific reason when the
textarea did not populate, the send button stayed disabled, the
bubble never settled, or the optimistic render rolled back during
a 3s settle window,
- treats "Promise was collected" from the post-click eval as success,
matching the existing pattern in `ask --file`.
Helper `parseDeepSeekConversationId` is exported from utils.js so the
same parser feeds both commands and round-trips the canonical lower-case
ID.
Tests:
- utils.test.js: 5 cases covering bare UUID, upper-case
normalisation, URL extraction with and without query string, empty /
null / whitespace input, and non-UUID rejection.
- detail.test.js: 5 cases covering registration, navigation +
message return, URL normalisation, ArgumentError before browser
navigation, and EmptyResultError on no-messages.
- send.test.js: 7 cases covering registration, ArgumentError on bad
id, full happy-path through nativeType + IIFE verification, the
textarea-mount timeout, missing nativeType helper, focus failure,
IIFE-reason translation to CommandExecutionError, and the
"Promise was collected" success path.
Manifest auto-regenerated to register both commands.
Live-verified end-to-end against my own DeepSeek session:
- `detail` returns the canonical message list for a bare UUID, parses
a full chat URL, and rejects malformed IDs before any browser
navigation,
- `send` lands the prompt as the latest user message in the target
conversation and gets an AI response back; reload of the
conversation page in a separate tab confirms the message persisted
server-side.
* docs(deepseek): document detail and send commands
---------
Co-authored-by: jackwener <jakevingoo@gmail.com>
371 lines
13 KiB
JavaScript
371 lines
13 KiB
JavaScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
import {
|
|
selectModel,
|
|
sendWithFile,
|
|
parseThinkingResponse,
|
|
parseDeepSeekConversationId,
|
|
pickResumeUrl,
|
|
} from './utils.js';
|
|
|
|
describe('deepseek parseDeepSeekConversationId', () => {
|
|
const id = '749e6bbd-6a45-4440-beaa-ae5238bf06d8';
|
|
|
|
it('returns a bare UUID unchanged', () => {
|
|
expect(parseDeepSeekConversationId(id)).toBe(id);
|
|
});
|
|
|
|
it('lowercases an upper-case UUID', () => {
|
|
expect(parseDeepSeekConversationId(id.toUpperCase())).toBe(id);
|
|
});
|
|
|
|
it('extracts the UUID from a full /a/chat/s/<id> URL', () => {
|
|
expect(parseDeepSeekConversationId(`https://chat.deepseek.com/a/chat/s/${id}`)).toBe(id);
|
|
expect(parseDeepSeekConversationId(`https://chat.deepseek.com/a/chat/s/${id}?from=share`)).toBe(id);
|
|
expect(parseDeepSeekConversationId(`/a/chat/s/${id}`)).toBe(id);
|
|
});
|
|
|
|
it('throws ArgumentError on empty input', () => {
|
|
expect(() => parseDeepSeekConversationId('')).toThrow(ArgumentError);
|
|
expect(() => parseDeepSeekConversationId(null)).toThrow(ArgumentError);
|
|
expect(() => parseDeepSeekConversationId(undefined)).toThrow(ArgumentError);
|
|
expect(() => parseDeepSeekConversationId(' ')).toThrow(ArgumentError);
|
|
});
|
|
|
|
it('throws ArgumentError on non-UUID input', () => {
|
|
expect(() => parseDeepSeekConversationId('not-an-id')).toThrow(ArgumentError);
|
|
expect(() => parseDeepSeekConversationId('123')).toThrow(ArgumentError);
|
|
// URL with the wrong path shape must not silently fall through to "use raw input".
|
|
expect(() => parseDeepSeekConversationId('https://chat.deepseek.com/somewhere/else')).toThrow(ArgumentError);
|
|
});
|
|
});
|
|
|
|
describe('deepseek parseThinkingResponse', () => {
|
|
it('returns plain response when no thinking header is present', () => {
|
|
const rawText = 'This is a regular response without thinking.';
|
|
const result = parseThinkingResponse(rawText);
|
|
|
|
expect(result).toEqual({
|
|
response: rawText,
|
|
thinking: null,
|
|
thinking_time: null,
|
|
});
|
|
});
|
|
|
|
it('parses English thinking header — all content after header is thinking', () => {
|
|
const rawText = 'Thought for 3.5 seconds\n\nLet me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.';
|
|
const result = parseThinkingResponse(rawText);
|
|
|
|
// Text-level parser no longer splits on \n\n; everything after header is thinking.
|
|
// DOM-level extraction in waitForResponse() handles the actual separation.
|
|
expect(result).toEqual({
|
|
response: '',
|
|
thinking: 'Let me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.',
|
|
thinking_time: '3.5',
|
|
});
|
|
});
|
|
|
|
it('parses Chinese thinking header — all content after header is thinking', () => {
|
|
const rawText = '已思考(用时 2.3 秒)\n\n让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。';
|
|
const result = parseThinkingResponse(rawText);
|
|
|
|
expect(result).toEqual({
|
|
response: '',
|
|
thinking: '让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。',
|
|
thinking_time: '2.3',
|
|
});
|
|
});
|
|
|
|
it('multi-paragraph thinking without final answer is not corrupted', () => {
|
|
const rawText = 'Thought for 1.2 seconds\n\nFirst paragraph.\n\nSecond paragraph.';
|
|
const result = parseThinkingResponse(rawText);
|
|
|
|
// Both paragraphs must stay in thinking; response is empty.
|
|
expect(result).toEqual({
|
|
response: '',
|
|
thinking: 'First paragraph.\n\nSecond paragraph.',
|
|
thinking_time: '1.2',
|
|
});
|
|
});
|
|
|
|
it('multi-paragraph final answer is not split by text parser', () => {
|
|
const rawText = 'Thought for 3 seconds\n\nreasoning\n\nAnswer para 1.\n\nAnswer para 2.';
|
|
const result = parseThinkingResponse(rawText);
|
|
|
|
// Text parser treats everything as thinking; DOM handles separation.
|
|
expect(result).toEqual({
|
|
response: '',
|
|
thinking: 'reasoning\n\nAnswer para 1.\n\nAnswer para 2.',
|
|
thinking_time: '3',
|
|
});
|
|
});
|
|
|
|
it('handles thinking without final response', () => {
|
|
const rawText = 'Thought for 1.2 seconds\n\nThinking process here...';
|
|
const result = parseThinkingResponse(rawText);
|
|
|
|
expect(result).toEqual({
|
|
response: '',
|
|
thinking: 'Thinking process here...',
|
|
thinking_time: '1.2',
|
|
});
|
|
});
|
|
|
|
it('returns null for empty input', () => {
|
|
const result = parseThinkingResponse('');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null for null input', () => {
|
|
const result = parseThinkingResponse(null);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
|
|
describe('deepseek sendWithFile', () => {
|
|
const tempDirs = [];
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
while (tempDirs.length) {
|
|
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('prefers page.setFileInput over base64-in-evaluate when supported', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'report.txt');
|
|
fs.writeFileSync(filePath, 'hello');
|
|
|
|
const page = {
|
|
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn()
|
|
.mockResolvedValueOnce(undefined) // sidebar collapse
|
|
.mockResolvedValueOnce(true) // waitForFilePreview
|
|
.mockResolvedValueOnce(true) // send button enabled check
|
|
.mockResolvedValueOnce({ ok: true }), // sendMessage
|
|
};
|
|
|
|
const result = await sendWithFile(page, filePath, 'summarize this');
|
|
|
|
expect(result).toEqual({ ok: true }); expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
|
|
});
|
|
|
|
it('fails closed when upload preview appears but send button never enables', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'report.txt');
|
|
fs.writeFileSync(filePath, 'hello');
|
|
|
|
const page = {
|
|
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn()
|
|
.mockResolvedValueOnce(undefined) // sidebar collapse
|
|
.mockResolvedValueOnce(true) // waitForFilePreview
|
|
.mockResolvedValue(false), // send button never enables
|
|
};
|
|
|
|
const result = await sendWithFile(page, filePath, 'summarize this');
|
|
|
|
expect(result).toEqual({ ok: false, reason: 'send button did not enable after upload' });
|
|
expect(page.evaluate).toHaveBeenCalledTimes(17);
|
|
});
|
|
});
|
|
|
|
describe('deepseek selectModel', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete global.document;
|
|
});
|
|
|
|
it('fails expert selection when only one radio is present', async () => {
|
|
const instantRadio = {
|
|
getAttribute: vi.fn(() => 'true'),
|
|
click: vi.fn(),
|
|
};
|
|
global.document = {
|
|
querySelectorAll: vi.fn(() => [instantRadio]),
|
|
};
|
|
const page = {
|
|
evaluate: vi.fn(async (script) => eval(script)),
|
|
};
|
|
|
|
const result = await selectModel(page, 'expert');
|
|
|
|
expect(result).toEqual({ ok: false });
|
|
expect(instantRadio.click).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('selects the correct radio for each model', async () => {
|
|
const radios = [0, 1, 2].map(() => ({
|
|
getAttribute: vi.fn(() => 'false'),
|
|
click: vi.fn(),
|
|
}));
|
|
global.document = {
|
|
querySelectorAll: vi.fn(() => radios),
|
|
};
|
|
const page = {
|
|
evaluate: vi.fn(async (script) => eval(script)),
|
|
};
|
|
|
|
await selectModel(page, 'instant');
|
|
expect(radios[0].click).toHaveBeenCalled();
|
|
expect(radios[1].click).not.toHaveBeenCalled();
|
|
expect(radios[2].click).not.toHaveBeenCalled();
|
|
|
|
radios.forEach(r => r.click.mockClear());
|
|
await selectModel(page, 'expert');
|
|
expect(radios[1].click).toHaveBeenCalled();
|
|
|
|
radios.forEach(r => r.click.mockClear());
|
|
await selectModel(page, 'vision');
|
|
expect(radios[2].click).toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects unknown model names', async () => {
|
|
const radios = [0, 1, 2].map(() => ({
|
|
getAttribute: vi.fn(() => 'false'),
|
|
click: vi.fn(),
|
|
}));
|
|
global.document = {
|
|
querySelectorAll: vi.fn(() => radios),
|
|
};
|
|
const page = {
|
|
evaluate: vi.fn(async (script) => eval(script)),
|
|
};
|
|
|
|
const result = await selectModel(page, 'turbo');
|
|
expect(result).toEqual({ ok: false });
|
|
});
|
|
});
|
|
|
|
describe('deepseek sendWithFile Not allowed fallback', () => {
|
|
const tempDirs = [];
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
while (tempDirs.length) {
|
|
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('falls back to DataTransfer when setFileInput throws Not allowed', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'image.png');
|
|
fs.writeFileSync(filePath, 'fake-png');
|
|
|
|
const page = {
|
|
setFileInput: vi.fn().mockRejectedValue(new Error('Not allowed')),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn()
|
|
.mockResolvedValueOnce(undefined) // sidebar collapse
|
|
.mockResolvedValueOnce({ ok: true }) // DataTransfer fallback
|
|
.mockResolvedValueOnce(true) // waitForFilePreview
|
|
.mockResolvedValueOnce(true) // send button enabled
|
|
.mockResolvedValueOnce({ ok: true }),// sendMessage
|
|
};
|
|
|
|
const result = await sendWithFile(page, filePath, 'describe');
|
|
|
|
expect(page.setFileInput).toHaveBeenCalled();
|
|
expect(page.evaluate).toHaveBeenCalledTimes(5);
|
|
expect(result).toEqual({ ok: true });
|
|
});
|
|
|
|
it('does not treat send-button enablement alone as image upload proof', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'image.png');
|
|
fs.writeFileSync(filePath, 'fake-png');
|
|
|
|
const page = {
|
|
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn()
|
|
.mockResolvedValueOnce(undefined) // sidebar collapse
|
|
.mockResolvedValue(false), // no filename / thumbnail preview
|
|
};
|
|
|
|
const result = await sendWithFile(page, filePath, 'describe');
|
|
|
|
expect(result).toEqual({ ok: false, reason: 'file preview did not appear' });
|
|
expect(page.evaluate.mock.calls[1][0]).toContain('img[src], canvas, video');
|
|
expect(page.evaluate.mock.calls[1][0]).not.toContain("aria-disabled') === 'false'");
|
|
});
|
|
});
|
|
|
|
describe('deepseek pickResumeUrl', () => {
|
|
function createPage() {
|
|
return {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn(),
|
|
};
|
|
}
|
|
|
|
it('returns the URL on the first attempt when the sidebar is already populated', async () => {
|
|
const page = createPage();
|
|
// First evaluate is the sidebar-expand no-op; subsequent ones look up the resume URL.
|
|
page.evaluate
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/recent-id');
|
|
|
|
const url = await pickResumeUrl(page);
|
|
|
|
expect(url).toBe('https://chat.deepseek.com/a/chat/s/recent-id');
|
|
// Sidebar expand + one resume lookup. No retry needed.
|
|
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
|
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('polls until the sidebar finishes loading and returns the late URL', async () => {
|
|
const page = createPage();
|
|
page.evaluate
|
|
.mockResolvedValueOnce(undefined) // sidebar-expand
|
|
.mockResolvedValueOnce(null) // attempt 1: nothing
|
|
.mockResolvedValueOnce(null) // attempt 2: nothing
|
|
.mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/late-id'); // attempt 3: ready
|
|
|
|
const url = await pickResumeUrl(page);
|
|
|
|
expect(url).toBe('https://chat.deepseek.com/a/chat/s/late-id');
|
|
expect(page.evaluate).toHaveBeenCalledTimes(4);
|
|
expect(page.wait).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('returns null after exhausting all retries instead of falling through silently', async () => {
|
|
const page = createPage();
|
|
// Sidebar expand + 5 polling attempts, each returning null.
|
|
page.evaluate.mockResolvedValue(null);
|
|
|
|
const url = await pickResumeUrl(page);
|
|
|
|
expect(url).toBeNull();
|
|
expect(page.evaluate).toHaveBeenCalledTimes(6);
|
|
expect(page.wait).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
it('embeds the pinned-section text matcher inside the resume lookup', async () => {
|
|
const page = createPage();
|
|
page.evaluate.mockResolvedValue(null);
|
|
|
|
await pickResumeUrl(page);
|
|
|
|
const lookupSrc = page.evaluate.mock.calls
|
|
.map(([js]) => js)
|
|
.find((js) => js.includes('firstElementChild'));
|
|
expect(lookupSrc, 'expected a resume-lookup evaluate call').toBeDefined();
|
|
// Pinned detection must be text-based on the section header (CSS-module class names are randomized per build).
|
|
expect(lookupSrc).toContain('置');
|
|
expect(lookupSrc).toContain('Pinned');
|
|
expect(lookupSrc).toContain('firstElementChild');
|
|
});
|
|
});
|