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 = {}): 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 { const sleep = vi.fn(async (_ms: number) => {}); (svc as unknown as { sleep(ms: number): Promise }).sleep = sleep; return sleep; } describe('OpencodeService.notify', () => { let fetchMock: ReturnType; 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/api/session/ses_pinned/prompt`); const opts = init as { method?: string; headers?: Record; 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 {"text": msg} — em-dash, not a hyphen — and a 2xx resolves notify as delivered expect(String(opts.body)).toBe( JSON.stringify({ 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({ 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/api/session/ses_pinned/prompt`, ); }); 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/api/session/ses_pinned/prompt`, ); }); 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( // v2 envelope {data, cursor}; the /elsewhere session is more recent but a different // directory, so the location.directory filter must exclude it json({ data: [ { id: 'ses_old', location: { directory: WS_PATH }, time: { updated: 100 }, }, { id: 'ses_other_ws', location: { directory: '/elsewhere' }, time: { updated: 300 }, }, { id: 'ses_new', location: { directory: WS_PATH }, time: { updated: 200 }, }, ], cursor: { previous: null, next: null }, }), ) .mockResolvedValueOnce(json({})); await svc.notify(7, 'hi'); // discovery GET hits the v2 route; the prompt POST goes to the most recently updated session of THIS directory expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`); expect(fetchMock.mock.calls[2][0]).toBe( `${API}/proxy/abc/api/session/ses_new/prompt`, ); }); 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({ data: [ { id: 'ses_a', location: { directory: WS_PATH } }, { id: 'ses_b', location: { directory: WS_PATH }, time: {} }, ], cursor: { previous: null, next: null }, }), ) .mockResolvedValueOnce(json({})); await svc.notify(7, 'hi'); expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`); expect(fetchMock.mock.calls[2][0]).toBe( `${API}/proxy/abc/api/session/ses_a/prompt`, ); }); 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 + prompt POST only — no /api/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/api/session/ses_pinned/prompt`, ); }); 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 prompt POST times out — a read timeout is still treated as delivered (safety net) 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', ); }); });