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:
Devvyn committed 2026-10-29 21:46:06 +08:00
1 parent e3ca07d59d
commit 902d4491e5
15 files changed
+473 -76

No files matched your search

+101
View File
@@ -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
});
});
+113
View File
@@ -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],
]);
});
});
+25
View File
@@ -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`;