Makelore 2.0 initial clean snapshot
This commit is contained in:
825
tests/unit/opencode-client.test.ts
Normal file
825
tests/unit/opencode-client.test.ts
Normal file
@@ -0,0 +1,825 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user