import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadPrefs,
savePrefs,
applyConfigDefaults,
shouldNotify,
notificationsSupported,
initNotificationPermissionPrompt,
maybeNotifyTurnFinished,
disposeNotifications,
type NotificationPrefs,
type TurnFinishedInfo,
} from './notifications.ts';
const DEFAULTS: NotificationPrefs = {
enabled: true,
minDurationSecs: 8,
backgroundOnly: true,
};
function makePrefs(over: Partial<NotificationPrefs> = {}): NotificationPrefs {
return { ...DEFAULTS, ...over };
}
function makeInfo(over: Partial<TurnFinishedInfo> = {}): TurnFinishedInfo {
const info: TurnFinishedInfo = {
stopReason: 'natural',
sessionId: 'session-1',
durationMs: 60_000,
...over,
};
info.dedupeKey ??= `${info.sessionId ?? 'unknown'}:turn-1`;
return info;
}
class FakeStore {
private map = new Map<string, string>();
getItem(key: string): string | null {
return this.map.has(key) ? this.map.get(key)! : null;
}
setItem(key: string, value: string): void {
this.map.set(key, value);
}
removeItem(key: string): void {
this.map.delete(key);
}
clear(): void {
this.map.clear();
}
}
interface FakeNotificationInstance {
title: string;
options: Record<string, unknown>;
onclick: ((ev: Event) => void) | null;
close: () => void;
}
let fakeNotificationCtor: { new (title: string, options?: Record<string, unknown>): FakeNotificationInstance; permission: NotificationPermission } | null = null;
function installNotification(permission: NotificationPermission = 'granted'): {
instances: FakeNotificationInstance[];
} {
const instances: FakeNotificationInstance[] = [];
fakeNotificationCtor = class {
static permission = permission;
title: string;
options: Record<string, unknown>;
onclick: ((ev: Event) => void) | null = null;
constructor(title: string, options?: Record<string, unknown>) {
this.title = title;
this.options = options ?? {};
instances.push(this);
}
close() {
}
} as unknown as typeof fakeNotificationCtor;
(globalThis as Record<string, unknown>).Notification = fakeNotificationCtor;
return { instances };
}
function withBrowserGlobals(
fn: () => void,
opts: { permission?: NotificationPermission } = {},
): void {
const origNotification = (globalThis as Record<string, unknown>).Notification;
const origDocument = (globalThis as Record<string, unknown>).document;
const origWindow = (globalThis as Record<string, unknown>).window;
const origLocalStorage = (globalThis as Record<string, unknown>).localStorage;
const origBroadcastChannel = (globalThis as Record<string, unknown>).BroadcastChannel;
try {
installNotification(opts.permission);
(globalThis as Record<string, unknown>).Notification = fakeNotificationCtor;
(globalThis as Record<string, unknown>).document = {
visibilityState: 'hidden',
};
(globalThis as Record<string, unknown>).window = { isSecureContext: true };
(globalThis as Record<string, unknown>).localStorage = new FakeStore();
(globalThis as Record<string, unknown>).BroadcastChannel = FakeBroadcastChannel;
disposeNotifications();
resetBroadcastChannels();
fn();
} finally {
if (origNotification === undefined) {
delete (globalThis as Record<string, unknown>).Notification;
} else {
(globalThis as Record<string, unknown>).Notification = origNotification;
}
if (origDocument === undefined) {
delete (globalThis as Record<string, unknown>).document;
} else {
(globalThis as Record<string, unknown>).document = origDocument;
}
if (origWindow === undefined) {
delete (globalThis as Record<string, unknown>).window;
} else {
(globalThis as Record<string, unknown>).window = origWindow;
}
if (origLocalStorage === undefined) {
delete (globalThis as Record<string, unknown>).localStorage;
} else {
(globalThis as Record<string, unknown>).localStorage = origLocalStorage;
}
if (origBroadcastChannel === undefined) {
delete (globalThis as Record<string, unknown>).BroadcastChannel;
} else {
(globalThis as Record<string, unknown>).BroadcastChannel = origBroadcastChannel;
}
fakeNotificationCtor = null;
}
}
class FakeBroadcastChannel {
static instances: FakeBroadcastChannel[] = [];
name: string;
onmessage: ((event: { data: unknown }) => void) | null = null;
sent: unknown[] = [];
closed = false;
constructor(name: string) {
this.name = name;
FakeBroadcastChannel.instances.push(this);
}
postMessage(data: unknown): void {
this.sent.push(data);
}
close(): void {
this.closed = true;
}
}
function resetBroadcastChannels(): void {
FakeBroadcastChannel.instances = [];
}
test('shouldNotify: all conditions satisfied → true', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs(),
hidden: true,
info: makeInfo(),
lastNotify: null,
now: 1000,
}),
true,
);
});
test('shouldNotify: unsupported (LAN HTTP) → false', () => {
assert.equal(
shouldNotify({
supported: false,
permission: 'granted',
prefs: makePrefs(),
hidden: true,
info: makeInfo(),
lastNotify: null,
now: 1000,
}),
false,
);
});
test('shouldNotify: permission not granted → false', () => {
for (const permission of ['default', 'denied'] as const) {
assert.equal(
shouldNotify({
supported: true,
permission,
prefs: makePrefs(),
hidden: true,
info: makeInfo(),
lastNotify: null,
now: 1000,
}),
false,
`permission=${permission}`,
);
}
});
test('shouldNotify: disabled → false', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs({ enabled: false }),
hidden: true,
info: makeInfo(),
lastNotify: null,
now: 1000,
}),
false,
);
});
test('shouldNotify: backgroundOnly + visible tab → false', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs({ backgroundOnly: true }),
hidden: false,
info: makeInfo(),
lastNotify: null,
now: 1000,
}),
false,
);
});
test('shouldNotify: backgroundOnly=false + visible tab → true', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs({ backgroundOnly: false }),
hidden: false,
info: makeInfo(),
lastNotify: null,
now: 1000,
}),
true,
);
});
test('shouldNotify: turn shorter than min_duration → false', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs({ minDurationSecs: 8 }),
hidden: true,
info: makeInfo({ durationMs: 5000 }),
lastNotify: null,
now: 1000,
}),
false,
);
});
test('shouldNotify: turn exactly at min_duration → true', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs({ minDurationSecs: 8 }),
hidden: true,
info: makeInfo({ durationMs: 8000 }),
lastNotify: null,
now: 1000,
}),
true,
);
});
test('shouldNotify: missing duration fails closed when a minimum is configured', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs({ minDurationSecs: 8 }),
hidden: true,
info: makeInfo({ durationMs: undefined }),
lastNotify: null,
now: 1000,
}),
false,
);
});
test('shouldNotify: same session within 5s dedup window → false', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs(),
hidden: true,
info: makeInfo({ sessionId: 's1' }),
lastNotify: { sessionId: 's1', dedupeKey: 's1:turn-1', ts: 900 },
now: 1000,
}),
false,
);
});
test('shouldNotify: same session beyond 5s window → true', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs(),
hidden: true,
info: makeInfo({ sessionId: 's1' }),
lastNotify: { sessionId: 's1', dedupeKey: 's1:turn-1', ts: 1000 - 6000 },
now: 1000,
}),
true,
);
});
test('shouldNotify: different session ignores last notify → true', () => {
assert.equal(
shouldNotify({
supported: true,
permission: 'granted',
prefs: makePrefs(),
hidden: true,
info: makeInfo({ sessionId: 's2' }),
lastNotify: { sessionId: 's1', dedupeKey: 'another-turn', ts: 900 },
now: 1000,
}),
true,
);
});
test('loadPrefs: no localStorage → defaults', () => {
withBrowserGlobals(() => {
const prefs = loadPrefs();
assert.deepEqual(prefs, DEFAULTS);
});
});
test('loadPrefs: empty storage → defaults', () => {
withBrowserGlobals(() => {
const ls = (globalThis as Record<string, unknown>).localStorage as FakeStore;
assert.equal(ls.getItem('atomcode.webui_notify'), null);
assert.deepEqual(loadPrefs(), DEFAULTS);
});
});
test('loadPrefs: corrupt JSON → defaults', () => {
withBrowserGlobals(() => {
const ls = (globalThis as Record<string, unknown>).localStorage as FakeStore;
ls.setItem('atomcode.webui_notify', '{not-json');
assert.deepEqual(loadPrefs(), DEFAULTS);
});
});
test('loadPrefs: partial entry merges defaults', () => {
withBrowserGlobals(() => {
const ls = (globalThis as Record<string, unknown>).localStorage as FakeStore;
ls.setItem('atomcode.webui_notify', JSON.stringify({ enabled: false }));
const prefs = loadPrefs();
assert.equal(prefs.enabled, false);
assert.equal(prefs.minDurationSecs, 8);
assert.equal(prefs.backgroundOnly, true);
});
});
test('savePrefs round-trips through loadPrefs', () => {
withBrowserGlobals(() => {
savePrefs(makePrefs({ enabled: false, minDurationSecs: 15, backgroundOnly: false }));
assert.deepEqual(loadPrefs(), { enabled: false, minDurationSecs: 15, backgroundOnly: false });
});
});
test('applyConfigDefaults seeds daemon defaults when user never set prefs', () => {
withBrowserGlobals(() => {
const ls = (globalThis as Record<string, unknown>).localStorage as FakeStore;
assert.equal(ls.getItem('atomcode.webui_notify'), null);
applyConfigDefaults({ enabled: false, min_duration_secs: 30 });
assert.deepEqual(loadPrefs(), { enabled: false, minDurationSecs: 30, backgroundOnly: true });
});
});
test('applyConfigDefaults does not overwrite an explicit user setting', () => {
withBrowserGlobals(() => {
savePrefs(makePrefs({ enabled: true, minDurationSecs: 5 }));
applyConfigDefaults({ enabled: false, min_duration_secs: 30 });
assert.deepEqual(loadPrefs(), { enabled: true, minDurationSecs: 5, backgroundOnly: true });
});
});
test('applyConfigDefaults ignores undefined config', () => {
withBrowserGlobals(() => {
const ls = (globalThis as Record<string, unknown>).localStorage as FakeStore;
applyConfigDefaults(undefined);
assert.equal(ls.getItem('atomcode.webui_notify'), null);
});
});
test('notificationsSupported: false without Notification global', () => {
withBrowserGlobals(() => {
delete (globalThis as Record<string, unknown>).Notification;
assert.equal(notificationsSupported(), false);
});
});
test('notificationsSupported: false on insecure window', () => {
withBrowserGlobals(() => {
(globalThis as Record<string, unknown>).window = { isSecureContext: false };
assert.equal(notificationsSupported(), false);
});
});
test('notificationsSupported: true on localhost-like secure context', () => {
withBrowserGlobals(() => {
assert.equal(notificationsSupported(), true);
});
});
test('initNotificationPermissionPrompt requests permission on first interaction only', () => {
withBrowserGlobals(() => {
const listeners = new Map<string, Set<() => void>>();
const fakeWindow = {
isSecureContext: true,
addEventListener(type: string, listener: () => void) {
const set = listeners.get(type) ?? new Set<() => void>();
set.add(listener);
listeners.set(type, set);
},
removeEventListener(type: string, listener: () => void) {
listeners.get(type)?.delete(listener);
},
};
(globalThis as Record<string, unknown>).window = fakeWindow;
let requests = 0;
const ctor = fakeNotificationCtor! as typeof fakeNotificationCtor & {
requestPermission: () => Promise<NotificationPermission>;
};
ctor.permission = 'default';
ctor.requestPermission = async () => {
requests += 1;
return 'granted';
};
initNotificationPermissionPrompt();
assert.equal(listeners.get('pointerdown')?.size, 1);
assert.equal(listeners.get('keydown')?.size, 1);
listeners.get('pointerdown')?.forEach((listener) => listener());
listeners.get('keydown')?.forEach((listener) => listener());
assert.equal(requests, 1);
assert.equal(listeners.get('pointerdown')?.size, 0);
assert.equal(listeners.get('keydown')?.size, 0);
}, { permission: 'default' });
});
test('initNotificationPermissionPrompt cleanup removes pending listeners', () => {
withBrowserGlobals(() => {
const listeners = new Map<string, Set<() => void>>();
(globalThis as Record<string, unknown>).window = {
isSecureContext: true,
addEventListener(type: string, listener: () => void) {
const set = listeners.get(type) ?? new Set<() => void>();
set.add(listener);
listeners.set(type, set);
},
removeEventListener(type: string, listener: () => void) {
listeners.get(type)?.delete(listener);
},
};
fakeNotificationCtor!.permission = 'default';
const cleanup = initNotificationPermissionPrompt();
cleanup();
assert.equal(listeners.get('pointerdown')?.size, 0);
assert.equal(listeners.get('keydown')?.size, 0);
}, { permission: 'default' });
});
test('initNotificationPermissionPrompt rechecks disabled preference before requesting', () => {
withBrowserGlobals(() => {
const listeners = new Map<string, Set<() => void>>();
(globalThis as Record<string, unknown>).window = {
isSecureContext: true,
addEventListener(type: string, listener: () => void) {
const set = listeners.get(type) ?? new Set<() => void>();
set.add(listener);
listeners.set(type, set);
},
removeEventListener(type: string, listener: () => void) {
listeners.get(type)?.delete(listener);
},
};
let requests = 0;
const ctor = fakeNotificationCtor! as typeof fakeNotificationCtor & {
requestPermission: () => Promise<NotificationPermission>;
};
ctor.permission = 'default';
ctor.requestPermission = async () => {
requests += 1;
return 'granted';
};
initNotificationPermissionPrompt();
savePrefs(makePrefs({ enabled: false }));
listeners.get('pointerdown')?.forEach((listener) => listener());
assert.equal(requests, 0);
assert.equal(listeners.get('pointerdown')?.size, 0);
assert.equal(listeners.get('keydown')?.size, 0);
}, { permission: 'default' });
});
test('maybeNotifyTurnFinished: creates a notification with mirrored title/body', () => {
withBrowserGlobals(() => {
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ stopReason: 'natural', durationMs: 60_000 }));
assert.equal(instances.length, 1);
assert.equal(instances[0].title, 'AtomCode done');
assert.match(instances[0].options.body as string, /^Done · 60\.0s/);
assert.equal(instances[0].options.tag, 'session-1');
assert.equal(instances[0].options.icon, '/favicon.png');
});
});
test('maybeNotifyTurnFinished: title mapping for every stop reason', () => {
withBrowserGlobals(() => {
const cases: [string | undefined, string, string][] = [
['natural', 'AtomCode done', /^Done/],
['cancelled', 'AtomCode cancelled', /^Cancelled/],
['error', 'AtomCode failed', /^Failed/],
['turn_limit', 'AtomCode stopped', /^Stopped/],
['step_limit', 'AtomCode stopped', /^Stopped/],
[undefined, 'AtomCode finished', /^Finished/],
];
let i = 0;
for (const [reason, expectedTitle, bodyRe] of cases) {
i += 1;
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ stopReason: reason, sessionId: `map-${i}` }));
assert.equal(instances.length, 1, `stopReason=${String(reason)}`);
assert.equal(instances[0].title, expectedTitle, `stopReason=${String(reason)}`);
assert.match(instances[0].options.body as string, bodyRe);
}
});
});
test('maybeNotifyTurnFinished: respects min_duration', () => {
withBrowserGlobals(() => {
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ durationMs: 2000, stopReason: 'natural' }));
assert.equal(instances.length, 0);
});
});
test('maybeNotifyTurnFinished: respects enabled=false', () => {
withBrowserGlobals(() => {
const ls = (globalThis as Record<string, unknown>).localStorage as FakeStore;
ls.setItem('atomcode.webui_notify', JSON.stringify({ enabled: false }));
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo());
assert.equal(instances.length, 0);
});
});
test('maybeNotifyTurnFinished: no-op when permission denied', () => {
withBrowserGlobals(
() => {
const { instances } = installNotification('denied');
maybeNotifyTurnFinished(makeInfo());
assert.equal(instances.length, 0);
},
{ permission: 'denied' },
);
});
test('maybeNotifyTurnFinished: no-op on visible tab when backgroundOnly', () => {
withBrowserGlobals(() => {
(globalThis as Record<string, unknown>).document = { visibilityState: 'visible' };
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo());
assert.equal(instances.length, 0);
});
});
test('maybeNotifyTurnFinished: writes dedup record and broadcasts to peers', () => {
withBrowserGlobals(() => {
resetBroadcastChannels();
const ls = (globalThis as Record<string, unknown>).localStorage as FakeStore;
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1' }));
const record = JSON.parse(ls.getItem('atomcode.webui_last_notify')!);
assert.equal(record.sessionId, 's1');
assert.equal(record.dedupeKey, 's1:turn-1');
assert.equal(typeof record.ts, 'number');
const channels = FakeBroadcastChannel.instances;
assert.ok(channels.length > 0);
assert.ok(channels.some((c) => c.sent.length > 0 && (c.sent[0] as { sessionId: string }).sessionId === 's1'));
});
});
test('maybeNotifyTurnFinished: second call for same session within 5s → single notification', () => {
withBrowserGlobals(() => {
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1' }));
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1' }));
assert.equal(instances.length, 1);
});
});
test('maybeNotifyTurnFinished: distinct turns in the same session are not suppressed', () => {
withBrowserGlobals(() => {
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1', dedupeKey: 's1:turn-1' }));
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1', dedupeKey: 's1:turn-2' }));
assert.equal(instances.length, 2);
});
});
test('maybeNotifyTurnFinished: different session after a notify → notifies again', () => {
withBrowserGlobals(() => {
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1' }));
maybeNotifyTurnFinished(makeInfo({ sessionId: 's2' }));
assert.equal(instances.length, 2);
});
});
test('maybeNotifyTurnFinished: peer broadcast suppresses same session only', () => {
withBrowserGlobals(() => {
resetBroadcastChannels();
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1' }));
assert.equal(instances.length, 1);
const ch = FakeBroadcastChannel.instances.find((c) => !c.closed);
assert.ok(ch, 'channel should exist');
const peerTs = Date.now();
ch!.onmessage?.({
data: { sessionId: 's1', dedupeKey: 's1:turn-1', ts: peerTs },
});
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1' }));
assert.equal(instances.length, 1, 'same session should be suppressed');
maybeNotifyTurnFinished(makeInfo({ sessionId: 's2' }));
assert.equal(instances.length, 2, 'different session must not be suppressed');
});
});
test('maybeNotifyTurnFinished: peer broadcast outside 5s window allows same session', () => {
withBrowserGlobals(() => {
resetBroadcastChannels();
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1' }));
const ch = FakeBroadcastChannel.instances.find((c) => !c.closed);
assert.ok(ch, 'channel should exist');
const ls = (globalThis as Record<string, unknown>).localStorage as FakeStore;
ls.setItem('atomcode.webui_last_notify', JSON.stringify({
sessionId: 's1',
dedupeKey: 's1:turn-1',
ts: Date.now() - 6000,
}));
ch!.onmessage?.({
data: {
sessionId: 's1',
dedupeKey: 's1:turn-1',
ts: Date.now() - 6000,
},
});
maybeNotifyTurnFinished(makeInfo({ sessionId: 's1' }));
assert.equal(instances.length, 2, 'stale peer notify should not suppress');
});
});
test('maybeNotifyTurnFinished: body truncates long message', () => {
withBrowserGlobals(() => {
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ message: 'x'.repeat(500) }));
assert.equal(instances.length, 1);
const body = instances[0].options.body as string;
assert.ok(body.length < 140, `body too long: ${body.length}`);
assert.match(body, /x{120}$/);
});
});
test('disposeNotifications closes the channel it created', () => {
withBrowserGlobals(() => {
resetBroadcastChannels();
maybeNotifyTurnFinished(makeInfo());
const created = FakeBroadcastChannel.instances;
assert.ok(created.length > 0, 'should have created a channel');
assert.ok(created.every((c) => !c.closed), 'channel should start open');
disposeNotifications();
assert.ok(created.every((c) => c.closed), 'dispose should close every channel');
const { instances } = installNotification('granted');
maybeNotifyTurnFinished(makeInfo({ sessionId: 's2' }));
const reopened = FakeBroadcastChannel.instances.filter((c) => !c.closed);
assert.ok(reopened.length >= 1, 'dispose 后再次通知应新建可用通道');
assert.equal(instances.length, 1);
});
});