325 lines
9.9 KiB
TypeScript
325 lines
9.9 KiB
TypeScript
import { Logger } from '@nestjs/common';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { OpencodeService } from './opencode.service.js';
|
|
|
|
const API = 'http://172.24.0.1:9898';
|
|
const WS_PATH = '/home/krzyw/projects/sandbox/mesh-bot';
|
|
// the workspace UUID is regenerated on host restarts — only path/name/proxyPath matter
|
|
const WORKSPACE = {
|
|
id: 'ws-uuid',
|
|
path: WS_PATH,
|
|
name: 'mesh-bot',
|
|
status: 'ready',
|
|
proxyPath: '/proxy/abc',
|
|
};
|
|
|
|
function json(body: unknown): Response {
|
|
return { ok: true, status: 200, json: async () => body } as Response;
|
|
}
|
|
|
|
function timeoutError(): Error {
|
|
return new DOMException('signal timed out', 'TimeoutError');
|
|
}
|
|
|
|
/** Fresh service with a clean env (creds unset by default — pass them explicitly). */
|
|
function makeService(env: Record<string, string> = {}): OpencodeService {
|
|
process.env.OPENCODE_API = API;
|
|
process.env.OPENCODE_WORKSPACE_PATH = WS_PATH;
|
|
for (const k of ['OPENCODE_SESSION', 'OPENCODE_USER', 'OPENCODE_PASS'])
|
|
delete process.env[k];
|
|
Object.assign(process.env, env);
|
|
return new OpencodeService();
|
|
}
|
|
|
|
function stubSleep(svc: OpencodeService): ReturnType<typeof vi.fn> {
|
|
const sleep = vi.fn(async (_ms: number) => {});
|
|
(svc as unknown as { sleep(ms: number): Promise<void> }).sleep = sleep;
|
|
return sleep;
|
|
}
|
|
|
|
describe('OpencodeService.notify', () => {
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
for (const k of [
|
|
'OPENCODE_API',
|
|
'OPENCODE_WORKSPACE_PATH',
|
|
'OPENCODE_SESSION',
|
|
'OPENCODE_USER',
|
|
'OPENCODE_PASS',
|
|
])
|
|
delete process.env[k];
|
|
});
|
|
|
|
it('posts the exact wrapper text with `from` (em-dash) to the resolved session', async () => {
|
|
const svc = makeService({
|
|
OPENCODE_USER: 'u',
|
|
OPENCODE_PASS: 'p',
|
|
OPENCODE_SESSION: 'ses_pinned',
|
|
});
|
|
fetchMock
|
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
|
.mockResolvedValueOnce(json({}));
|
|
|
|
await svc.notify(7, 'hi', 42);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
const [url, init] = fetchMock.mock.calls[1];
|
|
expect(url).toBe(`${API}/proxy/abc/session/ses_pinned/message`);
|
|
const opts = init as {
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
body?: string;
|
|
};
|
|
expect(opts.method).toBe('POST');
|
|
expect(opts.headers?.['content-type']).toBe('application/json');
|
|
expect(opts.headers?.authorization).toBe(
|
|
'Basic ' + Buffer.from('u:p').toString('base64'),
|
|
);
|
|
// body is EXACTLY {"parts":[{"type":"text","text":msg}]} — em-dash, not a hyphen
|
|
expect(String(opts.body)).toBe(
|
|
JSON.stringify({
|
|
parts: [
|
|
{
|
|
type: 'text',
|
|
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('omits the `from node` part when there is no sender', async () => {
|
|
const svc = makeService({
|
|
OPENCODE_USER: 'u',
|
|
OPENCODE_PASS: 'p',
|
|
OPENCODE_SESSION: 'ses_pinned',
|
|
});
|
|
fetchMock
|
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
|
.mockResolvedValueOnce(json({}));
|
|
|
|
await svc.notify(7, 'hi');
|
|
|
|
const [, init] = fetchMock.mock.calls[1];
|
|
expect(String((init as { body?: string }).body)).toBe(
|
|
JSON.stringify({
|
|
parts: [
|
|
{
|
|
type: 'text',
|
|
text: 'New mesh message (id 7): "hi" — please react to it.',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('matches the workspace by path, preferring ready entries when several match', async () => {
|
|
const svc = makeService({
|
|
OPENCODE_USER: 'u',
|
|
OPENCODE_PASS: 'p',
|
|
OPENCODE_SESSION: 'ses_pinned',
|
|
});
|
|
fetchMock.mockResolvedValueOnce(
|
|
json([
|
|
{
|
|
...WORKSPACE,
|
|
id: 'other',
|
|
status: 'starting',
|
|
proxyPath: '/proxy/not-ready',
|
|
}, // same path but not ready — listed first
|
|
WORKSPACE,
|
|
{
|
|
id: 'x',
|
|
name: 'mesh-bot',
|
|
status: 'ready',
|
|
proxyPath: '/proxy/other',
|
|
}, // different path — must lose to the path match
|
|
]),
|
|
);
|
|
fetchMock.mockResolvedValueOnce(json({}));
|
|
|
|
await svc.notify(7, 'hi');
|
|
|
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
`${API}/proxy/abc/session/ses_pinned/message`,
|
|
);
|
|
});
|
|
|
|
it('falls back to the workspace name when no path matches', async () => {
|
|
const svc = makeService({
|
|
OPENCODE_USER: 'u',
|
|
OPENCODE_PASS: 'p',
|
|
OPENCODE_SESSION: 'ses_pinned',
|
|
});
|
|
fetchMock.mockResolvedValueOnce(
|
|
json([
|
|
{
|
|
id: 'a',
|
|
path: '/elsewhere',
|
|
name: 'other',
|
|
status: 'ready',
|
|
proxyPath: '/proxy/a',
|
|
},
|
|
{
|
|
id: 'b',
|
|
path: '/renamed',
|
|
name: 'mesh-bot',
|
|
status: 'ready',
|
|
proxyPath: '/proxy/b',
|
|
},
|
|
]),
|
|
);
|
|
fetchMock.mockResolvedValueOnce(json({}));
|
|
|
|
await svc.notify(7, 'hi');
|
|
|
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
`${API}/proxy/b/session/ses_pinned/message`,
|
|
);
|
|
});
|
|
|
|
it('discovers the most recently updated session when none is pinned', async () => {
|
|
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
|
fetchMock
|
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
|
.mockResolvedValueOnce(
|
|
json([
|
|
{ id: 'ses_old', time: { updated: 100 } },
|
|
{ id: 'ses_new', time: { updated: 200 } },
|
|
]),
|
|
)
|
|
.mockResolvedValueOnce(json({}));
|
|
|
|
await svc.notify(7, 'hi');
|
|
|
|
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`);
|
|
expect(fetchMock.mock.calls[2][0]).toBe(
|
|
`${API}/proxy/abc/session/ses_new/message`,
|
|
);
|
|
});
|
|
|
|
it('uses the first session when none carry time.updated', async () => {
|
|
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
|
fetchMock
|
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
|
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }]))
|
|
.mockResolvedValueOnce(json({}));
|
|
|
|
await svc.notify(7, 'hi');
|
|
|
|
expect(fetchMock.mock.calls[2][0]).toBe(
|
|
`${API}/proxy/abc/session/ses_a/message`,
|
|
);
|
|
});
|
|
|
|
it('skips session discovery entirely when a session is pinned', async () => {
|
|
const svc = makeService({
|
|
OPENCODE_USER: 'u',
|
|
OPENCODE_PASS: 'p',
|
|
OPENCODE_SESSION: 'ses_pinned',
|
|
});
|
|
fetchMock
|
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
|
.mockResolvedValueOnce(json({}));
|
|
|
|
await svc.notify(7, 'hi');
|
|
|
|
// workspaces GET + message POST only — no /session discovery GET
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
|
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
`${API}/proxy/abc/session/ses_pinned/message`,
|
|
);
|
|
});
|
|
|
|
it('treats a final-POST read timeout as delivered success and does not retry', async () => {
|
|
const svc = makeService({
|
|
OPENCODE_USER: 'u',
|
|
OPENCODE_PASS: 'p',
|
|
OPENCODE_SESSION: 'ses_pinned',
|
|
});
|
|
fetchMock
|
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
|
.mockRejectedValueOnce(timeoutError()); // the message POST times out — opencode holds the response until the turn finishes
|
|
|
|
const logSpy = vi.spyOn(Logger.prototype, 'log');
|
|
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
|
|
|
// exactly 2 calls: no retry after a timeout-as-delivered
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(logSpy.mock.calls.map((c) => String(c[0]))).toContain(
|
|
'opencode notify accepted (response still pending after 15 s)',
|
|
);
|
|
});
|
|
|
|
it('retries a failed workspace GET and succeeds on the next attempt', async () => {
|
|
const svc = makeService({
|
|
OPENCODE_USER: 'u',
|
|
OPENCODE_PASS: 'p',
|
|
OPENCODE_SESSION: 'ses_pinned',
|
|
});
|
|
const sleep = stubSleep(svc);
|
|
fetchMock
|
|
.mockRejectedValueOnce(new Error('boom')) // attempt 1: workspaces GET fails
|
|
.mockResolvedValueOnce(json([WORKSPACE])) // attempt 2: workspaces ok
|
|
.mockResolvedValueOnce(json({})); // message POST ok
|
|
|
|
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
|
|
});
|
|
|
|
it('gives up after 4 attempts and logs opencode notify dropped', async () => {
|
|
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
|
const sleep = stubSleep(svc);
|
|
fetchMock.mockRejectedValue(new Error('down'));
|
|
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
|
|
|
|
await expect(svc.notify('hi')).resolves.toBeUndefined(); // never throws — fire-and-forget safe
|
|
|
|
// one workspaces GET per attempt: exactly 4 attempts total
|
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
expect(sleep).toHaveBeenNthCalledWith(1, 2_000);
|
|
expect(sleep).toHaveBeenNthCalledWith(2, 10_000);
|
|
expect(sleep).toHaveBeenNthCalledWith(3, 30_000);
|
|
const warns = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
expect(warns.some((w) => w.includes('attempt 1/4'))).toBe(true);
|
|
// the GET failure is wrapped with the URL for a clear reason — match prefix + cause
|
|
expect(
|
|
warns.some(
|
|
(w) =>
|
|
w.startsWith('opencode notify dropped after 4 attempts:') &&
|
|
w.includes(': down'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('is a no-op without credentials (empty or unset)', async () => {
|
|
for (const env of [{}, { OPENCODE_USER: '', OPENCODE_PASS: 'p' }] as Record<
|
|
string,
|
|
string
|
|
>[]) {
|
|
const svc = makeService(env);
|
|
await expect(svc.notify(7, 'hi', 42)).resolves.toBeUndefined();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('logs once at construction when disabled', () => {
|
|
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
|
|
makeService({}); // no creds
|
|
expect(warnSpy.mock.calls.map((c) => String(c[0]))).toContain(
|
|
'opencode notifier disabled: OPENCODE_USER/OPENCODE_PASS not set',
|
|
);
|
|
});
|
|
});
|