mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Closes the Web Push gap left by #82: in-page Notification API and tab title flash both showed `codeman:<host>` after that PR, but OS-level notifications dispatched via the service worker — the surface that matters most when the tab is closed and the user is reading their system notification center across multiple Codeman instances — still hardcoded the literal "Codeman" prefix. Service workers run in an isolated context with no access to document.title or any in-page state, so the hostname has to ride along in the push payload itself. Server (server.ts:sendPushNotifications): emit `hostTitle: this.windowTitle` in the JSON payload alongside the existing `title` (event-specific text like "Permission Required"). The two stay separate so the SW can compose them — the server knows the host, the SW knows the OS context. Service worker (sw.js): compose `${hostTitle}: ${title}` when both present, mirroring the in-page Notification format from notification-manager.js. Fall back to `title || hostTitle || 'Codeman'` so older servers (which omit hostTitle) keep working — the field is purely additive on the wire. Tests (test/push-payload-host-title.test.ts): mock the `web-push` module via vi.hoisted(), instantiate WebServer without binding a port, stub the push store with one fake subscription, and verify the JSON payload shipped to webpush.sendNotification carries the right hostTitle for both --title-hostname overrides and the os.hostname() default. Also mirrors the SW's title-composition logic in a small helper so any future change to the format breaks the test instead of being caught only by users running multiple Codeman instances. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
6.1 KiB
TypeScript
166 lines
6.1 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().
|
|
const server = new WebServer(0, false, true, 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', () => {
|
|
const server = makeServerWithHost('laptop');
|
|
(
|
|
server as unknown as {
|
|
sendPushNotifications: (e: string, d: Record<string, unknown>) => 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', () => {
|
|
const server = makeServerWithHost(''); // empty -> constructor uses getHostname()
|
|
(
|
|
server as unknown as {
|
|
sendPushNotifications: (e: string, d: Record<string, unknown>) => 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', () => {
|
|
const a = makeServerWithHost('host-a');
|
|
const b = makeServerWithHost('host-b');
|
|
|
|
(
|
|
a as unknown as {
|
|
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
|
|
}
|
|
).sendPushNotifications('hook:stop', { sessionId: 's-a', sessionName: 'A' });
|
|
(
|
|
b as unknown as {
|
|
sendPushNotifications: (e: string, d: Record<string, unknown>) => 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');
|
|
});
|
|
});
|