Files
waggle-os/packages/server/tests/notification-gate.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

180 lines
6.8 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { EventEmitter } from 'node:events';
import { NotificationGate, materialFingerprint, getNotificationGate } from '../src/local/notification-gate.js';
import { emitNotification } from '../src/local/routes/notifications.js';
import { suggestCapabilities, type ProactiveContext } from '../src/local/proactive-handlers.js';
import { WorkspaceManager } from '@waggle/core';
const STORE_FILE = 'notification-fingerprints.json';
describe('materialFingerprint', () => {
it('is stable regardless of object key order', () => {
expect(materialFingerprint({ a: 1, b: 2, c: [3, 4] })).toBe(
materialFingerprint({ c: [3, 4], b: 2, a: 1 }),
);
});
it('changes when content changes', () => {
expect(materialFingerprint({ pending: 2 })).not.toBe(materialFingerprint({ pending: 3 }));
});
it('is order-sensitive for arrays', () => {
expect(materialFingerprint([1, 2])).not.toBe(materialFingerprint([2, 1]));
});
});
describe('NotificationGate', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-notif-gate-'));
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('fires on a new key', () => {
const gate = new NotificationGate(tmpDir);
expect(gate.shouldNotify('k1', 'hash-a')).toBe(true);
});
it('suppresses an unchanged hash for the same key', () => {
const gate = new NotificationGate(tmpDir);
expect(gate.shouldNotify('k1', 'hash-a')).toBe(true);
expect(gate.shouldNotify('k1', 'hash-a')).toBe(false);
});
it('fires again when the hash changes', () => {
const gate = new NotificationGate(tmpDir);
expect(gate.shouldNotify('k1', 'hash-a')).toBe(true);
expect(gate.shouldNotify('k1', 'hash-b')).toBe(true);
// The new hash is now the recorded one.
expect(gate.shouldNotify('k1', 'hash-b')).toBe(false);
});
it('persists across gate instances (survives restart)', () => {
new NotificationGate(tmpDir).shouldNotify('k1', 'hash-a');
expect(new NotificationGate(tmpDir).shouldNotify('k1', 'hash-a')).toBe(false);
});
it('recovers from a corrupt store file', () => {
fs.writeFileSync(path.join(tmpDir, STORE_FILE), '{not valid json');
const gate = new NotificationGate(tmpDir);
// Corrupt file is treated as empty — the key is new, so it fires and rewrites.
expect(gate.shouldNotify('k1', 'hash-a')).toBe(true);
expect(gate.shouldNotify('k1', 'hash-a')).toBe(false);
});
it('recovers when the store is a JSON array (not an object)', () => {
fs.writeFileSync(path.join(tmpDir, STORE_FILE), '[1,2,3]');
const gate = new NotificationGate(tmpDir);
expect(gate.shouldNotify('k1', 'hash-a')).toBe(true);
});
it('caps the store at 200 keys, evicting the oldest by last-updated', () => {
let clock = 1_000;
vi.spyOn(Date, 'now').mockImplementation(() => clock++);
const gate = new NotificationGate(tmpDir);
for (let i = 0; i < 205; i++) {
gate.shouldNotify(`key-${i}`, `hash-${i}`);
}
const store = JSON.parse(fs.readFileSync(path.join(tmpDir, STORE_FILE), 'utf-8')) as Record<string, unknown>;
const keys = Object.keys(store);
expect(keys.length).toBe(200);
// Oldest five evicted, newest retained.
expect(store['key-0']).toBeUndefined();
expect(store['key-4']).toBeUndefined();
expect(store['key-5']).toBeDefined();
expect(store['key-204']).toBeDefined();
});
});
describe('emitNotification anti-nag gate', () => {
let tmpDir: string;
function makeFastify() {
const eventBus = new EventEmitter();
const emitted: unknown[] = [];
eventBus.on('notification', (e) => emitted.push(e));
const saveNotification = vi.fn(() => 1);
return {
fastify: {
localConfig: { dataDir: tmpDir },
cronStore: { saveNotification },
eventBus,
log: { debug: vi.fn() },
} as never,
emitted,
saveNotification,
};
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-notif-emit-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('behaves exactly as before when no options are passed', () => {
const { fastify, emitted, saveNotification } = makeFastify();
const result = emitNotification(fastify, { title: 't', body: 'b', category: 'agent' });
expect(result).toEqual({ suppressed: false });
expect(saveNotification).toHaveBeenCalledTimes(1);
expect(emitted).toHaveLength(1);
});
it('emits the first time and suppresses an identical re-emit', () => {
const { fastify, emitted, saveNotification } = makeFastify();
const opts = { dedupeKey: 'k1', materialHash: materialFingerprint({ count: 2 }) };
const first = emitNotification(fastify, { title: 't', body: 'b', category: 'agent' }, opts);
expect(first).toEqual({ suppressed: false });
expect(saveNotification).toHaveBeenCalledTimes(1);
expect(emitted).toHaveLength(1);
const second = emitNotification(fastify, { title: 't', body: 'b', category: 'agent' }, opts);
expect(second).toEqual({ suppressed: true });
// No new row, no new broadcast.
expect(saveNotification).toHaveBeenCalledTimes(1);
expect(emitted).toHaveLength(1);
});
it('re-emits when the material hash changes', () => {
const { fastify, emitted } = makeFastify();
emitNotification(fastify, { title: 't', body: 'b', category: 'agent' }, { dedupeKey: 'k1', materialHash: 'a' });
emitNotification(fastify, { title: 't', body: 'b', category: 'agent' }, { dedupeKey: 'k1', materialHash: 'a' });
emitNotification(fastify, { title: 't', body: 'b', category: 'agent' }, { dedupeKey: 'k1', materialHash: 'b' });
expect(emitted).toHaveLength(2);
});
it('suppresses a repeated identical proactive payload on the second emit', () => {
const wsManager = new WorkspaceManager(tmpDir);
const ctx: ProactiveContext = {
dataDir: tmpDir,
workspaceManager: wsManager,
getWorkspaceMindDb: () => null,
};
// No skills installed → the "capability packs" suggestion, carrying a dedupeKey + hash.
const msg = suggestCapabilities(ctx);
expect(msg).not.toBeNull();
expect(msg!.dedupeKey).toBeDefined();
expect(msg!.materialHash).toBeDefined();
const { fastify, emitted } = makeFastify();
const opts = { dedupeKey: msg!.dedupeKey, materialHash: msg!.materialHash };
expect(emitNotification(fastify, { title: msg!.title, body: msg!.body, category: 'agent' }, opts).suppressed).toBe(false);
expect(emitNotification(fastify, { title: msg!.title, body: msg!.body, category: 'agent' }, opts).suppressed).toBe(true);
expect(emitted).toHaveLength(1);
});
});