826 lines
27 KiB
TypeScript
826 lines
27 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
canonicalizeOpencodeDirectory,
|
|
createOpencodeClient,
|
|
decorateOpencodeRequest,
|
|
} from '@electron/opencode/client';
|
|
import { logger } from '@electron/utils/logger';
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('opencode client', () => {
|
|
it('uses the filesystem real path for Windows directory-scoped requests', () => {
|
|
const realpath = vi.fn(() => 'D:\\Datas\\ScriptProjects\\ShotGame\\测试顶蘑菇');
|
|
|
|
const directory = canonicalizeOpencodeDirectory(
|
|
'd:\\datas\\scriptprojects\\shotgame\\测试顶蘑菇',
|
|
realpath,
|
|
);
|
|
|
|
expect(realpath).toHaveBeenCalledWith('d:\\datas\\scriptprojects\\shotgame\\测试顶蘑菇');
|
|
expect(directory).toBe('D:\\Datas\\ScriptProjects\\ShotGame\\测试顶蘑菇');
|
|
});
|
|
|
|
it('decorates requests with the canonical directory used by runtime sessions', () => {
|
|
const request = decorateOpencodeRequest(
|
|
{
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
path: '/session',
|
|
directory: 'd:\\datas\\scriptprojects\\shotgame\\测试顶蘑菇',
|
|
},
|
|
() => 'D:\\Datas\\ScriptProjects\\ShotGame\\测试顶蘑菇',
|
|
);
|
|
const url = new URL(request.url);
|
|
|
|
expect(url.searchParams.get('directory')).toBe(
|
|
'D:\\Datas\\ScriptProjects\\ShotGame\\测试顶蘑菇',
|
|
);
|
|
expect(request.headers['x-opencode-directory']).toBe(
|
|
encodeURIComponent('D:\\Datas\\ScriptProjects\\ShotGame\\测试顶蘑菇'),
|
|
);
|
|
});
|
|
|
|
it('adds directory as query and header for scoped requests', () => {
|
|
const request = decorateOpencodeRequest({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
path: '/session',
|
|
directory: 'D:/work/app',
|
|
});
|
|
|
|
const url = new URL(request.url);
|
|
|
|
expect(url.pathname).toBe('/session');
|
|
expect(url.searchParams.get('directory')).toBe('D:/work/app');
|
|
expect(request.headers['x-opencode-directory']).toBe(
|
|
encodeURIComponent('D:/work/app'),
|
|
);
|
|
});
|
|
|
|
it('lists sessions scoped to the selected folder', async () => {
|
|
const sessions = [{ id: 'ses_123', title: 'Runtime Foundation' }];
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(JSON.stringify(sessions), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.listSessions()).resolves.toEqual(sessions);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('lists skills scoped to the selected folder', async () => {
|
|
const skills = [{
|
|
name: 'repo-audit',
|
|
description: 'Use when auditing repository structure.',
|
|
location: 'D:/work/app/.opencode/skills/repo-audit/SKILL.md',
|
|
content: '# Repo audit',
|
|
}];
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(JSON.stringify(skills), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.listSkills()).resolves.toEqual(skills);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/skill?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('posts a text message to a session scoped to the selected folder', async () => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(JSON.stringify({ id: 'msg_1', role: 'user' }), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.sendSessionMessage('ses_123', { text: 'Ship it' })).resolves.toEqual({
|
|
id: 'msg_1',
|
|
role: 'user',
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123/message?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
parts: [
|
|
{ type: 'text', text: 'Ship it' },
|
|
],
|
|
}),
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('starts an async prompt for a session scoped to the selected folder', async () => {
|
|
const logInfo = vi.spyOn(logger, 'info').mockImplementation(() => undefined);
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.promptSessionAsync('ses_123', { text: 'Ship it', system: '当前用户叫小明', agent: 'game-design' })).resolves.toBeUndefined();
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123/prompt_async?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
parts: [
|
|
{ type: 'text', text: 'Ship it' },
|
|
],
|
|
system: '当前用户叫小明',
|
|
agent: 'game-design',
|
|
}),
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
expect(logInfo).toHaveBeenCalledWith(
|
|
'[opencode-client] Forwarding request to runtime',
|
|
{
|
|
method: 'POST',
|
|
pathname: '/session/ses_123/prompt_async',
|
|
},
|
|
);
|
|
});
|
|
|
|
it('includes an explicit model when starting an async prompt', async () => {
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.promptSessionAsync('ses_123', {
|
|
text: 'Ship it',
|
|
model: {
|
|
providerID: 'niancode-user-models',
|
|
modelID: 'deepseek-chat',
|
|
},
|
|
})).resolves.toBeUndefined();
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123/prompt_async?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
parts: [
|
|
{ type: 'text', text: 'Ship it' },
|
|
],
|
|
model: {
|
|
providerID: 'niancode-user-models',
|
|
modelID: 'deepseek-chat',
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('forwards uploaded image files as opencode file parts when starting an async prompt', async () => {
|
|
const logInfo = vi.spyOn(logger, 'info').mockImplementation(() => undefined);
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.promptSessionAsync('ses_123', {
|
|
text: 'Describe this image',
|
|
files: [{
|
|
type: 'file',
|
|
filename: 'wireframe.png',
|
|
mime: 'image/png',
|
|
url: 'data:image/png;base64,AAA',
|
|
}],
|
|
})).resolves.toBeUndefined();
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123/prompt_async?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
parts: [
|
|
{ type: 'text', text: 'Describe this image' },
|
|
{
|
|
type: 'file',
|
|
filename: 'wireframe.png',
|
|
mime: 'image/png',
|
|
url: 'data:image/png;base64,AAA',
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
expect(logInfo).toHaveBeenCalledWith(
|
|
'[opencode-client] Sending prompt_async payload',
|
|
{
|
|
sessionID: 'ses_123',
|
|
model: null,
|
|
textLength: 19,
|
|
partCount: 2,
|
|
filePartCount: 1,
|
|
fileMimes: ['image/png'],
|
|
},
|
|
);
|
|
expect(JSON.stringify(logInfo.mock.calls)).not.toContain('wireframe.png');
|
|
expect(JSON.stringify(logInfo.mock.calls)).not.toContain('data:image/png;base64,AAA');
|
|
});
|
|
|
|
it('renames a session inside the selected folder', async () => {
|
|
const updatedSession = { id: 'ses_123', title: 'Polish context rail' };
|
|
const fetchImpl = vi.fn(async () => new Response(JSON.stringify(updatedSession), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.updateSession('ses_123', { title: 'Polish context rail' })).resolves.toEqual(updatedSession);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ title: 'Polish context rail' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('deletes a session inside the selected folder', async () => {
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.deleteSession('ses_123')).resolves.toBeUndefined();
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('aborts a running session inside the selected folder', async () => {
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.abortSession('ses_123')).resolves.toBeUndefined();
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123/abort?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('loads a session diff inside the selected folder', async () => {
|
|
const diff = [
|
|
{
|
|
path: 'src/pages/Chat/OpencodeChatPanel.tsx',
|
|
status: 'modified',
|
|
additions: 3,
|
|
deletions: 1,
|
|
before: 'const status = "idle";',
|
|
after: 'const status = "busy";',
|
|
},
|
|
];
|
|
const fetchImpl = vi.fn(async () => new Response(JSON.stringify(diff), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.getSessionDiff('ses_123')).resolves.toEqual(diff);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123/diff?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('reverts a session message inside the selected folder', async () => {
|
|
const fetchImpl = vi.fn(async () => new Response(JSON.stringify(true), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.revertSessionMessage('ses_123', { messageID: 'msg_456' })).resolves.toBe(true);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123/revert?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ messageID: 'msg_456' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('restores reverted session changes inside the selected folder', async () => {
|
|
const fetchImpl = vi.fn(async () => new Response(JSON.stringify(true), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.unrevertSession('ses_123')).resolves.toBe(true);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123/unrevert?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('loads session todos inside the selected folder', async () => {
|
|
const todos = [
|
|
{ id: 'todo_1', content: 'Inspect the store', status: 'in_progress', priority: 'high' },
|
|
];
|
|
const fetchImpl = vi.fn(async () => new Response(JSON.stringify(todos), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.getSessionTodos('ses_123')).resolves.toEqual(todos);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/ses_123/todo?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('loads session statuses for the selected folder', async () => {
|
|
const statuses = {
|
|
ses_123: { type: 'busy' },
|
|
ses_456: { type: 'retry', attempt: 2, message: 'Rate limited', next: 1747051200 },
|
|
};
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(JSON.stringify(statuses), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.getSessionStatuses()).resolves.toEqual(statuses);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/session/status?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('lists pending questions for the selected folder', async () => {
|
|
const questions = [
|
|
{
|
|
id: 'que_123',
|
|
sessionID: 'ses_123',
|
|
questions: [
|
|
{
|
|
header: 'Need a decision',
|
|
question: 'How should I proceed?',
|
|
options: [
|
|
{ label: 'Apply patch', description: 'Make the change now.' },
|
|
{ label: 'Skip patch', description: 'Leave the file unchanged.' },
|
|
],
|
|
custom: true,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(JSON.stringify(questions), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.listQuestions()).resolves.toEqual(questions);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/question?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('replies to a pending question inside the selected folder', async () => {
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.replyQuestion('que_123', [['Apply patch']])).resolves.toBeUndefined();
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/question/que_123/reply?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ answers: [['Apply patch']] }),
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('rejects a pending question inside the selected folder', async () => {
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.rejectQuestion('que_123')).resolves.toBeUndefined();
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/question/que_123/reject?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('lists pending permissions for the selected folder', async () => {
|
|
const permissions = [
|
|
{
|
|
id: 'per_123',
|
|
sessionID: 'ses_123',
|
|
title: 'Run command',
|
|
metadata: { command: 'npm test' },
|
|
},
|
|
];
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(JSON.stringify(permissions), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.listPermissions()).resolves.toEqual(permissions);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/permission?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('replies to a pending permission inside the selected folder', async () => {
|
|
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.replyPermission('per_123', 'once', 'Looks safe.')).resolves.toBeUndefined();
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/permission/per_123/reply?directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ reply: 'once', message: 'Looks safe.' }),
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('finds files by fuzzy query inside the selected folder', async () => {
|
|
const files = ['src/pages/Chat/OpencodeChatPanel.tsx', 'src/stores/opencode.ts'];
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(JSON.stringify(files), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.findFiles('opencode', { limit: 20 })).resolves.toEqual(files);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/find/file?query=opencode&limit=20&directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('searches project content by pattern inside the selected folder', async () => {
|
|
const matches = [
|
|
{
|
|
path: 'src/stores/opencode.ts',
|
|
line_number: 128,
|
|
lines: { text: 'const fallbackText = typeof input.text === \'string\'' },
|
|
submatches: [{ match: { text: 'input.text' }, start: 33, end: 43 }],
|
|
},
|
|
];
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(JSON.stringify(matches), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.searchText('input.text')).resolves.toEqual(matches);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/find?pattern=input.text&directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('reads file content scoped to the selected folder', async () => {
|
|
const file = {
|
|
path: 'src/pages/Chat/OpencodeChatPanel.tsx',
|
|
content: 'export function Chat() {}',
|
|
};
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(JSON.stringify(file), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(client.getFileContent('src/pages/Chat/OpencodeChatPanel.tsx')).resolves.toEqual(file);
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/file/content?path=src%2Fpages%2FChat%2FOpencodeChatPanel.tsx&directory=D%3A%2Fwork%2Fapp',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'x-opencode-directory': encodeURIComponent('D:/work/app'),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('forwards typed command and session operations without logging payload bodies', async () => {
|
|
const logInfo = vi.spyOn(logger, 'info').mockImplementation(() => undefined);
|
|
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
const pathname = new URL(String(url)).pathname;
|
|
if (pathname === '/command' && init?.method !== 'POST') {
|
|
return new Response(JSON.stringify([{
|
|
name: 'review',
|
|
template: 'SECRET TEMPLATE BODY',
|
|
}]), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({ id: 'ses_result' }), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/work/app',
|
|
fetchImpl,
|
|
});
|
|
|
|
await client.listCommands();
|
|
await client.forkSession('ses_1', { messageID: 'msg_1' });
|
|
await client.shareSession('ses_1');
|
|
await client.unshareSession('ses_1');
|
|
await client.summarizeSession('ses_1', {
|
|
providerID: 'niancode-user-models',
|
|
modelID: 'qwen3.7-plus',
|
|
});
|
|
await client.executeSessionCommand('ses_1', {
|
|
command: 'review',
|
|
arguments: ' PRIVATE ARGUMENTS ',
|
|
agent: 'game-development',
|
|
model: 'niancode-user-models/qwen3.7-plus',
|
|
variant: 'high',
|
|
parts: [
|
|
{
|
|
type: 'text',
|
|
text: 'Read PRIVATE D:/repo/spec.md context',
|
|
synthetic: true,
|
|
},
|
|
{
|
|
type: 'file',
|
|
mime: 'image/webp',
|
|
filename: 'PRIVATE-screen.webp',
|
|
url: 'data:image/webp;base64,UVVFTi1QUklWQVRF',
|
|
},
|
|
],
|
|
});
|
|
|
|
const requests = fetchImpl.mock.calls.map(([url, init]) => ({
|
|
path: new URL(String(url)).pathname,
|
|
method: init?.method ?? 'GET',
|
|
body: init?.body,
|
|
}));
|
|
expect(requests).toEqual([
|
|
expect.objectContaining({ path: '/command', method: 'GET' }),
|
|
expect.objectContaining({
|
|
path: '/session/ses_1/fork',
|
|
method: 'POST',
|
|
body: JSON.stringify({ messageID: 'msg_1' }),
|
|
}),
|
|
expect.objectContaining({ path: '/session/ses_1/share', method: 'POST' }),
|
|
expect.objectContaining({ path: '/session/ses_1/share', method: 'DELETE' }),
|
|
expect.objectContaining({
|
|
path: '/session/ses_1/summarize',
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
providerID: 'niancode-user-models',
|
|
modelID: 'qwen3.7-plus',
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
path: '/session/ses_1/command',
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
command: 'review',
|
|
arguments: ' PRIVATE ARGUMENTS ',
|
|
agent: 'game-development',
|
|
model: 'niancode-user-models/qwen3.7-plus',
|
|
variant: 'high',
|
|
parts: [
|
|
{
|
|
type: 'text',
|
|
text: 'Read PRIVATE D:/repo/spec.md context',
|
|
synthetic: true,
|
|
},
|
|
{
|
|
type: 'file',
|
|
mime: 'image/webp',
|
|
filename: 'PRIVATE-screen.webp',
|
|
url: 'data:image/webp;base64,UVVFTi1QUklWQVRF',
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
]);
|
|
const logged = JSON.stringify(logInfo.mock.calls);
|
|
for (const secret of [
|
|
'SECRET TEMPLATE BODY',
|
|
'PRIVATE ARGUMENTS',
|
|
'D:/repo/spec.md',
|
|
'PRIVATE-screen.webp',
|
|
'UVVFTi1QUklWQVRF',
|
|
'D:/work/app',
|
|
'D%3A%2Fwork%2Fapp',
|
|
'directory=',
|
|
]) {
|
|
expect(logged).not.toContain(secret);
|
|
}
|
|
expect(logged).toContain('/session/ses_1/command');
|
|
});
|
|
|
|
it('logs only request methods and pathnames for query and path inputs', async () => {
|
|
const logInfo = vi.spyOn(logger, 'info').mockImplementation(() => undefined);
|
|
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([]), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}));
|
|
const client = createOpencodeClient({
|
|
baseUrl: 'http://127.0.0.1:4096',
|
|
directory: 'D:/PRIVATE/project',
|
|
fetchImpl,
|
|
});
|
|
|
|
await client.findFiles('PRIVATE query');
|
|
await client.searchText('PRIVATE pattern');
|
|
await client.getFileContent('PRIVATE/path/context.ts');
|
|
|
|
expect(logInfo).toHaveBeenCalledWith(
|
|
'[opencode-client] Forwarding request to runtime',
|
|
{ method: 'GET', pathname: '/find/file' },
|
|
);
|
|
expect(logInfo).toHaveBeenCalledWith(
|
|
'[opencode-client] Forwarding request to runtime',
|
|
{ method: 'GET', pathname: '/find' },
|
|
);
|
|
expect(logInfo).toHaveBeenCalledWith(
|
|
'[opencode-client] Forwarding request to runtime',
|
|
{ method: 'GET', pathname: '/file/content' },
|
|
);
|
|
const logged = JSON.stringify(logInfo.mock.calls);
|
|
for (const secret of [
|
|
'PRIVATE query',
|
|
'PRIVATE+query',
|
|
'PRIVATE pattern',
|
|
'PRIVATE+pattern',
|
|
'PRIVATE/path/context.ts',
|
|
'PRIVATE%2Fpath%2Fcontext.ts',
|
|
'D:/PRIVATE/project',
|
|
'D%3A%2FPRIVATE%2Fproject',
|
|
'directory=',
|
|
]) {
|
|
expect(logged).not.toContain(secret);
|
|
}
|
|
});
|
|
});
|