mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
feat(discord): testable codeman notify, short-id route, wiki page, docs and glue tests
Moves the notify command into src/cli-notify.ts with tests, lets discord-notify take the 8-character id a Docker case exports, adds the wiki page and index entries, rewrites the stale changeset and security notes, and pins the WebServer wiring.
This commit is contained in:
1 parent
e3ca07d59d
commit
902d4491e5
15 files changed
+473
-76
No files matched your search
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* @fileoverview `codeman notify`: what it sends, what it prints and the exit code, driven with a
|
||||
* recording fake transport plus the real `postJson` against a local server. Port: ephemeral 127.0.0.1.
|
||||
*/
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js';
|
||||
|
||||
const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' };
|
||||
|
||||
function fake(status: number, error?: string) {
|
||||
const post = vi.fn<JsonPost>(async () => ({ status, error }));
|
||||
return post;
|
||||
}
|
||||
|
||||
describe('runNotify', () => {
|
||||
it('refuses outside a Codeman session, before any request', async () => {
|
||||
const post = fake(200);
|
||||
const r = await runNotify({ message: 'hi' }, {}, '/work', post);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/inside a Codeman session/);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts the message to this session with the session id URL-encoded', async () => {
|
||||
const post = fake(200);
|
||||
const r = await runNotify({ message: 'press the reset button' }, ENV, '/work', post);
|
||||
expect(r).toEqual({ code: 0, out: 'Posted to Discord' });
|
||||
expect(post).toHaveBeenCalledWith('http://127.0.0.1:3000', '/api/sessions/abc12345-full-id/discord-notify', {
|
||||
message: 'press the reset button',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds urgent, and turns a relative --file into an absolute path under the working folder', async () => {
|
||||
const post = fake(200);
|
||||
await runNotify({ message: 'log', urgent: true, file: 'out/run.log' }, ENV, '/work', post);
|
||||
expect(post.mock.calls[0][2]).toEqual({ message: 'log', urgent: true, file: '/work/out/run.log' });
|
||||
await runNotify({ message: 'log', file: '/abs/run.log' }, ENV, '/work', post);
|
||||
expect(post.mock.calls[1][2]).toEqual({ message: 'log', file: '/abs/run.log' });
|
||||
});
|
||||
|
||||
it('lets --session and --url override the environment', async () => {
|
||||
const post = fake(200);
|
||||
await runNotify({ message: 'x', session: 'other', url: 'http://h:1' }, ENV, '/work', post);
|
||||
expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']);
|
||||
});
|
||||
|
||||
it('treats a disabled bridge (409) as success that says so', async () => {
|
||||
const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409));
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/bridge is off/);
|
||||
});
|
||||
|
||||
it('fails with the server error, or the status, or a plain message when nothing answered', async () => {
|
||||
expect(
|
||||
(await runNotify({ message: 'x' }, ENV, '/w', fake(400, 'The file must be inside the session folder'))).err
|
||||
).toBe('Could not post to Discord: The file must be inside the session folder');
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(500))).err).toBe('Could not post to Discord (HTTP 500)');
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(0))).err).toBe('Could not post to Discord');
|
||||
});
|
||||
});
|
||||
|
||||
describe('postJson (real transport)', () => {
|
||||
let server: Server;
|
||||
afterEach(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
||||
async function serve(status: number, reply: unknown) {
|
||||
const seen: { method?: string; url?: string; body: string; type?: string } = { body: '' };
|
||||
server = createServer((req, res) => {
|
||||
req.on('data', (c) => (seen.body += c));
|
||||
req.on('end', () => {
|
||||
seen.method = req.method;
|
||||
seen.url = req.url;
|
||||
seen.type = req.headers['content-type'];
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(reply));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return { seen, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
|
||||
}
|
||||
|
||||
it('sends a JSON POST and reports the status and the server error text', async () => {
|
||||
const { seen, url } = await serve(400, { success: false, error: 'nope' });
|
||||
expect(await postJson(url, '/api/sessions/s1/discord-notify', { message: 'hi' })).toEqual({
|
||||
status: 400,
|
||||
error: 'nope',
|
||||
});
|
||||
expect(seen).toMatchObject({ method: 'POST', url: '/api/sessions/s1/discord-notify', type: 'application/json' });
|
||||
expect(JSON.parse(seen.body)).toEqual({ message: 'hi' });
|
||||
});
|
||||
|
||||
it('answers status 0 when nothing is listening', async () => {
|
||||
const { url } = await serve(200, {});
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
expect((await postJson(url, '/x', {})).status).toBe(0);
|
||||
server = createServer().listen(0); // so afterEach has something to close
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* @fileoverview The glue between WebServer and the Discord bridge: alerts reach it even with no
|
||||
* Web Push subscription (with the rendered screen as the tail), and tab events reach it routed by
|
||||
* kind. The bridge and routes are tested on their own; this pins the lines that make the feature
|
||||
* exist. A real WebServer, never started, with the bridge replaced by a recorder.
|
||||
*
|
||||
* Port: N/A (no server start).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
|
||||
vi.mock('web-push', () => ({
|
||||
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
|
||||
}));
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
|
||||
type Internals = {
|
||||
pushStore: { getAll: () => never[] };
|
||||
discordBridge: Record<string, ReturnType<typeof vi.fn>>;
|
||||
sessions: Map<string, unknown>;
|
||||
mux: { capturePaneText?: (name: string) => string | null };
|
||||
sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>;
|
||||
broadcast: (event: string, data: unknown) => void;
|
||||
};
|
||||
|
||||
function server(): Internals {
|
||||
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals;
|
||||
s.pushStore = { getAll: () => [] };
|
||||
s.discordBridge = {
|
||||
notify: vi.fn(async () => null),
|
||||
sessionCreated: vi.fn(async () => undefined),
|
||||
sessionRenamed: vi.fn(async () => undefined),
|
||||
sessionDeleted: vi.fn(async () => undefined),
|
||||
sessionIdle: vi.fn(async () => undefined),
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
beforeEach(() => sendNotification.mockClear());
|
||||
|
||||
describe('sendPushNotifications -> Discord', () => {
|
||||
it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' });
|
||||
s.mux = { capturePaneText: () => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n' };
|
||||
await s.sendPushNotifications('hook:permission_prompt', {
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
tool_name: 'Bash',
|
||||
});
|
||||
expect(s.discordBridge.notify).toHaveBeenCalledTimes(1);
|
||||
expect(s.discordBridge.notify.mock.calls[0][0]).toMatchObject({
|
||||
event: 'hook:permission_prompt',
|
||||
title: 'Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
urgency: 'critical',
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
tail: 'Which option?\n 1. Cap GPUs\n 2. Cap CPU',
|
||||
});
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' });
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2' });
|
||||
expect(s.discordBridge.notify.mock.calls[0][0].tail).toBe('plain answer');
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 'gone', sessionName: 'w3' });
|
||||
expect(s.discordBridge.notify.mock.calls[1][0].tail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends nothing for an event Web Push does not carry', async () => {
|
||||
const s = server();
|
||||
await s.sendPushNotifications('session:created', { sessionId: 's1', sessionName: 'w1' });
|
||||
expect(s.discordBridge.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast -> Discord tab lifecycle', () => {
|
||||
it('routes created, updated and deleted by kind, reading both payload shapes', () => {
|
||||
const s = server();
|
||||
s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } });
|
||||
expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1', 123);
|
||||
s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' });
|
||||
expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed');
|
||||
s.broadcast(SseEvent.SessionDeleted, { id: 's1' });
|
||||
expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
|
||||
it('ignores an event with no session id', () => {
|
||||
const s = server();
|
||||
s.broadcast(SseEvent.SessionDeleted, {});
|
||||
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the bridge whether an idle session is an external CLI: codex yes, claude and shell no', () => {
|
||||
const s = server();
|
||||
s.sessions.set('cx', { mode: 'codex' });
|
||||
s.sessions.set('cl', { mode: 'claude' });
|
||||
s.sessions.set('sh', { mode: 'shell' });
|
||||
for (const id of ['cx', 'cl', 'sh', 'missing']) s.broadcast(SseEvent.SessionIdle, { id });
|
||||
expect(s.discordBridge.sessionIdle.mock.calls).toEqual([
|
||||
['cx', true],
|
||||
['cl', false],
|
||||
['sh', false],
|
||||
['missing', false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,31 @@ describe('POST /api/sessions/:id/discord-notify', () => {
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => {
|
||||
const { app, bridge, ctx } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-ses/discord-notify',
|
||||
payload: { message: 'hi' },
|
||||
});
|
||||
expect(ok.statusCode).toBe(200);
|
||||
ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!);
|
||||
const ambiguous = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-ses/discord-notify',
|
||||
payload: { message: 'hi' },
|
||||
});
|
||||
expect(ambiguous.statusCode).toBe(404);
|
||||
const short = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test/discord-notify',
|
||||
payload: { message: 'hi' },
|
||||
});
|
||||
expect(short.statusCode).toBe(404);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('rejects an empty or oversized message', async () => {
|
||||
const { app } = await harness();
|
||||
const url = `/api/sessions/test-session-1/discord-notify`;
|
||||
|
||||
Reference in new issue
Block a user