Files
jackwener__opencli/clis/deepseek/utils.test.js
Benjamin Liu 6469a02ea6 feat(deepseek): add detail and send commands for explicit conversation control (#1344)
* 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>
2026-05-06 19:13:54 +08:00

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