Files
Codeman/test/push-payload-host-title.test.ts
DDT f5e59283f9 fix(approvals): treat DeepSeek permission prompts as terminal-only alerts
Supervised-hook CLIs (DeepSeek Harness) raise Approvals Inbox items whose
answer route refuses keystrokes, but push notifications and the mobile/desktop
UI still offered Approve/Deny. Mark those items answerInTerminal, omit
approvalId (and therefore push actions) for supervised hooks, and show
"Answer in the terminal" / Open session instead of broken buttons.

Fixes #598 (functional part; docs gaps left for a follow-up).
2026-10-12 16:16:16 +08:00

251 lines
9.6 KiB
TypeScript

/**
* Verifies that web-push payloads include the hostname-aware `hostTitle`
* field so service-worker OS notifications can disambiguate Codeman
* instances on multiple machines (laptop / dev box / NAS).
*
* The in-page Notification path (notification-manager.js) prefixes with
* `${originalTitle}: ${title}` reading from `document.title`. The service
* worker has no access to document.title, so the server must ship the
* prefix in the push payload itself.
*
* Strategy: mock the `web-push` module, instantiate WebServer (no port
* binding — start() is never called), stub the push store with one fake
* subscription, then call the private sendPushNotifications and inspect
* the JSON payload handed to webpush.sendNotification.
*
* Port: N/A (no server start)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// vi.mock is hoisted to the top of the file, so factory captures must use
// vi.hoisted() to be initialized before the mocked import is evaluated.
const { sendNotification, setVapidDetails, generateVAPIDKeys } = vi.hoisted(() => ({
sendNotification: vi.fn(async () => undefined),
setVapidDetails: vi.fn(),
generateVAPIDKeys: vi.fn(() => ({
publicKey: 'test-public-key',
privateKey: 'test-private-key',
})),
}));
vi.mock('web-push', () => ({
default: { sendNotification, setVapidDetails, generateVAPIDKeys },
}));
import { WebServer } from '../src/web/server.js';
interface PushPayload {
title: string;
hostTitle?: string;
body: string;
tag: string;
sessionId: string;
urgency: string;
actions?: Array<{ action: string; title: string }>;
}
function makeServerWithHost(host: string): WebServer {
// Constructor only assigns fields — no network/disk activity until start().
// 4th arg is the bind host; the title hostname is the 5th arg.
const server = new WebServer(0, false, true, '127.0.0.1', host);
// Stub push store: one subscription with all events enabled.
const fakeSub = {
endpoint: 'https://push.example.com/abc',
keys: { p256dh: 'k1', auth: 'k2' },
pushPreferences: {} as Record<string, boolean>,
};
const stubStore = {
getAll: () => [fakeSub],
getVapidKeys: () => ({ publicKey: 'pub', privateKey: 'priv', generatedAt: 0 }),
removeByEndpoint: vi.fn(),
};
(server as unknown as { pushStore: typeof stubStore }).pushStore = stubStore;
return server;
}
function lastPayload(): PushPayload {
expect(sendNotification).toHaveBeenCalled();
const call = sendNotification.mock.calls[sendNotification.mock.calls.length - 1];
return JSON.parse(call[1] as string) as PushPayload;
}
describe('push payload hostTitle (Web Push hostname plumbing)', () => {
beforeEach(() => {
sendNotification.mockClear();
setVapidDetails.mockClear();
});
it('includes hostTitle = codeman:<titleHostname> in the payload', async () => {
const server = makeServerWithHost('laptop');
await (
server as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:idle_prompt', {
sessionId: 's-1',
sessionName: 'mysession',
});
const payload = lastPayload();
expect(payload.hostTitle).toBe('codeman:laptop');
// The bare event title is preserved separately so the SW can compose them.
expect(payload.title).toBe('Waiting for Input');
});
it('falls back to os.hostname() when --title-hostname is not provided', async () => {
const server = makeServerWithHost(''); // empty -> constructor uses getHostname()
await (
server as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:permission_prompt', {
sessionId: 's-2',
sessionName: 'sess',
tool_name: 'Bash',
});
const payload = lastPayload();
expect(payload.hostTitle).toMatch(/^codeman:.+/);
expect(payload.hostTitle).not.toBe('codeman:');
expect(payload.title).toBe('Permission Required');
});
it('different WebServer instances ship distinct hostTitles', async () => {
const a = makeServerWithHost('host-a');
const b = makeServerWithHost('host-b');
await (
a as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:stop', { sessionId: 's-a', sessionName: 'A' });
await (
b as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:stop', { sessionId: 's-b', sessionName: 'B' });
expect(sendNotification).toHaveBeenCalledTimes(2);
const first = JSON.parse(sendNotification.mock.calls[0][1] as string) as PushPayload;
const second = JSON.parse(sendNotification.mock.calls[1][1] as string) as PushPayload;
expect(first.hostTitle).toBe('codeman:host-a');
expect(second.hostTitle).toBe('codeman:host-b');
});
});
// ─── SW display-title formatting ─────────────────────────────────────────
// The SW logic at sw.js:130 composes the OS notification title from the
// payload. It's a 3-line conditional we mirror here so any future change
// (e.g. swapping the separator) shows up in this test instead of being
// caught only by users running multiple Codeman instances.
function computeSwDisplayTitle(payload: { title?: string; hostTitle?: string }): string {
const { title, hostTitle } = payload;
return hostTitle && title ? `${hostTitle}: ${title}` : title || hostTitle || 'Codeman';
}
describe('service worker displayTitle composition (mirrors sw.js)', () => {
it('joins host and title with ": " when both present', () => {
expect(computeSwDisplayTitle({ hostTitle: 'codeman:laptop', title: 'Permission Required' })).toBe(
'codeman:laptop: Permission Required'
);
});
it('falls back to bare title when hostTitle is missing (older server)', () => {
expect(computeSwDisplayTitle({ title: 'Permission Required' })).toBe('Permission Required');
});
it('falls back to hostTitle alone when title is missing', () => {
expect(computeSwDisplayTitle({ hostTitle: 'codeman:laptop' })).toBe('codeman:laptop');
});
it('defaults to "Codeman" when both missing', () => {
expect(computeSwDisplayTitle({})).toBe('Codeman');
});
});
// ─── Approvals Inbox gating ──────────────────────────────────────────────
// The Approve/Deny action buttons answer through the Approvals Inbox, so the
// payload ships them (and the approvalId they act on) only when the OPT-IN
// `approvalsInboxEnabled` setting is on. Pre-inbox these buttons rendered and
// did nothing; with the feature off they must not render at all.
interface ApprovalAwarePayload extends PushPayload {
approvalId?: string;
}
function setSettings(server: WebServer, settings: Record<string, unknown>): void {
(server as unknown as { readSettings: () => Promise<Record<string, unknown>> }).readSettings = async () => settings;
}
async function sendPermissionPush(server: WebServer): Promise<ApprovalAwarePayload> {
await (
server as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:permission_prompt', {
sessionId: 's-gate',
sessionName: 'sess',
tool_name: 'Bash',
approvalId: 's-gate:1',
});
return lastPayload() as ApprovalAwarePayload;
}
describe('push payload Approvals Inbox gating', () => {
beforeEach(() => {
sendNotification.mockClear();
});
it('strips actions and approvalId when the setting is off (the default)', async () => {
const server = makeServerWithHost('gate-off');
setSettings(server, {});
const payload = await sendPermissionPush(server);
expect(payload.actions).toBeUndefined();
expect(payload.approvalId).toBeUndefined();
// The notification itself still goes out; only the inbox parts are gated.
expect(payload.title).toBe('Permission Required');
});
it('ships Approve/Deny actions and the approvalId when the setting is on', async () => {
const server = makeServerWithHost('gate-on');
setSettings(server, { approvalsInboxEnabled: true });
const payload = await sendPermissionPush(server);
expect(payload.actions).toEqual([
{ action: 'approve', title: 'Approve' },
{ action: 'deny', title: 'Deny' },
]);
expect(payload.approvalId).toBe('s-gate:1');
});
it('ships no Approve/Deny actions when the inbox is on but approvalId is absent', async () => {
// Supervised-hook CLIs omit approvalId upstream so buttons cannot answer.
const server = makeServerWithHost('gate-no-id');
setSettings(server, { approvalsInboxEnabled: true });
await (
server as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:permission_prompt', {
sessionId: 's-gate',
sessionName: 'sess',
tool_name: 'Bash',
// deliberately no approvalId
});
const payload = lastPayload() as ApprovalAwarePayload;
expect(payload.actions).toBeUndefined();
expect(payload.approvalId).toBeUndefined();
expect(payload.title).toBe('Permission Required');
});
it('an explicit false behaves like the default (only true enables)', async () => {
const server = makeServerWithHost('gate-false');
setSettings(server, { approvalsInboxEnabled: false });
const payload = await sendPermissionPush(server);
expect(payload.actions).toBeUndefined();
expect(payload.approvalId).toBeUndefined();
});
});