This commit is contained in:
302
packages/hive-mind-shim-core/tests/signal-emitter.test.ts
Normal file
302
packages/hive-mind-shim-core/tests/signal-emitter.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* AI-OS Phase 1D — signal emitter tests.
|
||||
*
|
||||
* Verifies: URL resolution order, request shape, fail-open semantics
|
||||
* on network errors, timeout enforcement, the maybeEmitDiscovery
|
||||
* policy helper.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
emitSignalToWaggleDance,
|
||||
maybeEmitDiscovery,
|
||||
type EmittedSignal,
|
||||
} from '../src/signal-emitter.js';
|
||||
|
||||
/**
|
||||
* Build a fake fetch that captures the request and returns a stub
|
||||
* 201 response with a synthetic signal echo.
|
||||
*/
|
||||
function makeOkFetch(): typeof fetch & { calls: Array<{ url: string; init: RequestInit | undefined }> } {
|
||||
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
||||
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
const sig: EmittedSignal = {
|
||||
id: 'srv-' + Math.random().toString(36).slice(2),
|
||||
teamId: 'personal::test',
|
||||
senderId: 'test',
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
referenceId: null,
|
||||
routing: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return new Response(JSON.stringify({ dispatched: true, message: sig }), {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch & { calls: typeof calls };
|
||||
impl.calls = calls;
|
||||
return impl;
|
||||
}
|
||||
|
||||
describe('emitSignalToWaggleDance', () => {
|
||||
it('POSTs to the default sidecar URL when no override', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { topic: 'x' },
|
||||
fetchImpl: f,
|
||||
});
|
||||
expect(f.calls).toHaveLength(1);
|
||||
expect(f.calls[0].url).toBe('http://127.0.0.1:3333/api/waggle-dance/signal');
|
||||
});
|
||||
|
||||
it('honors the url option', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: f,
|
||||
url: 'http://127.0.0.1:4000',
|
||||
});
|
||||
expect(f.calls[0].url).toBe('http://127.0.0.1:4000/api/waggle-dance/signal');
|
||||
});
|
||||
|
||||
it('reads WAGGLE_SIDECAR_URL env when no url option', async () => {
|
||||
const prev = process.env.WAGGLE_SIDECAR_URL;
|
||||
process.env.WAGGLE_SIDECAR_URL = 'http://127.0.0.1:9999';
|
||||
try {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: f,
|
||||
});
|
||||
expect(f.calls[0].url).toBe('http://127.0.0.1:9999/api/waggle-dance/signal');
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.WAGGLE_SIDECAR_URL;
|
||||
else process.env.WAGGLE_SIDECAR_URL = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes the body with the expected shape', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { tool: 'claude-code', topic: 'rotation' },
|
||||
senderId: 'claude-code-hook',
|
||||
fetchImpl: f,
|
||||
});
|
||||
const body = JSON.parse(f.calls[0].init?.body as string);
|
||||
expect(body).toMatchObject({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
senderId: 'claude-code-hook',
|
||||
content: { tool: 'claude-code', topic: 'rotation' },
|
||||
});
|
||||
});
|
||||
|
||||
it('authenticates with the narrow run token without putting it in the body', async () => {
|
||||
const f = makeOkFetch();
|
||||
const token = 'run-token-with-at-least-thirty-two-bytes-1234';
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: { topic: 'safe' },
|
||||
runToken: token,
|
||||
fetchImpl: f,
|
||||
});
|
||||
expect((f.calls[0].init?.headers as Record<string, string>)['x-waggle-run-token']).toBe(token);
|
||||
expect(f.calls[0].init?.body).not.toContain(token);
|
||||
});
|
||||
|
||||
it('reads WAGGLE_RUN_TOKEN for installed hook processes', async () => {
|
||||
const previous = process.env.WAGGLE_RUN_TOKEN;
|
||||
const token = 'environment-run-token-with-enough-entropy-1234';
|
||||
process.env.WAGGLE_RUN_TOKEN = token;
|
||||
try {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast', subtype: 'discovery', content: {}, fetchImpl: f,
|
||||
});
|
||||
expect((f.calls[0].init?.headers as Record<string, string>)['x-waggle-run-token']).toBe(token);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.WAGGLE_RUN_TOKEN;
|
||||
else process.env.WAGGLE_RUN_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults senderId to "hook" when not provided', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: f,
|
||||
});
|
||||
const body = JSON.parse(f.calls[0].init?.body as string);
|
||||
expect(body.senderId).toBe('hook');
|
||||
});
|
||||
|
||||
it('only includes optional fields when set', async () => {
|
||||
const f = makeOkFetch();
|
||||
await emitSignalToWaggleDance({
|
||||
type: 'response',
|
||||
subtype: 'knowledge_match',
|
||||
content: { matched: 1 },
|
||||
senderId: 'cursor',
|
||||
referenceId: 'r-1',
|
||||
fetchImpl: f,
|
||||
});
|
||||
const body = JSON.parse(f.calls[0].init?.body as string);
|
||||
expect(body.referenceId).toBe('r-1');
|
||||
expect(body).not.toHaveProperty('routing');
|
||||
expect(body).not.toHaveProperty('teamId');
|
||||
});
|
||||
|
||||
it('returns the server message on 201', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: f,
|
||||
});
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.id).toMatch(/^srv-/);
|
||||
});
|
||||
|
||||
it('returns null and warns on network errors (ECONNREFUSED simulation)', async () => {
|
||||
const warnCalls: string[] = [];
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: (async () => {
|
||||
throw new Error('ECONNREFUSED 127.0.0.1:3333');
|
||||
}) as typeof fetch,
|
||||
onWarn: (m) => warnCalls.push(m),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(warnCalls).toHaveLength(1);
|
||||
expect(warnCalls[0]).toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('returns null and warns on non-2xx response', async () => {
|
||||
const warnCalls: string[] = [];
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: (async () => new Response('bad request', { status: 400 })) as typeof fetch,
|
||||
onWarn: (m) => warnCalls.push(m),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(warnCalls[0]).toContain('400');
|
||||
});
|
||||
|
||||
it('returns null and warns on malformed response body', async () => {
|
||||
const warnCalls: string[] = [];
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
fetchImpl: (async () =>
|
||||
new Response('{"no-message-field": true}', {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})) as typeof fetch,
|
||||
onWarn: (m) => warnCalls.push(m),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(warnCalls[0]).toContain('malformed');
|
||||
});
|
||||
|
||||
it('enforces a per-request timeout', async () => {
|
||||
const warnCalls: string[] = [];
|
||||
const out = await emitSignalToWaggleDance({
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: {},
|
||||
timeoutMs: 50,
|
||||
fetchImpl: ((_url: string, init?: RequestInit) => {
|
||||
// Return a never-resolving promise; the abort signal should trigger.
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
});
|
||||
}) as typeof fetch,
|
||||
onWarn: (m) => warnCalls.push(m),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
expect(warnCalls[0]).toContain('sidecar unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeEmitDiscovery', () => {
|
||||
it('emits when Stop + high importance', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery(
|
||||
'stop',
|
||||
'high',
|
||||
{ topic: 'rotation' },
|
||||
{ fetchImpl: f, senderId: 'cc' },
|
||||
);
|
||||
expect(out).not.toBeNull();
|
||||
expect(f.calls).toHaveLength(1);
|
||||
const body = JSON.parse(f.calls[0].init?.body as string);
|
||||
expect(body.subtype).toBe('discovery');
|
||||
expect(body.content.eventType).toBe('stop');
|
||||
expect(body.content.importance).toBe('high');
|
||||
expect(body.content.topic).toBe('rotation');
|
||||
});
|
||||
|
||||
it('emits when PreCompact + critical', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery(
|
||||
'pre-compact',
|
||||
'critical',
|
||||
{},
|
||||
{ fetchImpl: f },
|
||||
);
|
||||
expect(out).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not emit on low importance', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery('stop', 'low', {}, { fetchImpl: f });
|
||||
expect(out).toBeNull();
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not emit on normal importance', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery('stop', 'normal', {}, { fetchImpl: f });
|
||||
expect(out).toBeNull();
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not emit on non-stop/pre-compact events', async () => {
|
||||
const f = makeOkFetch();
|
||||
const out = await maybeEmitDiscovery(
|
||||
'user-prompt-submit',
|
||||
'high',
|
||||
{},
|
||||
{ fetchImpl: f },
|
||||
);
|
||||
expect(out).toBeNull();
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Note: the end-to-end integration test against a real Waggle sidecar
|
||||
// lives in packages/server/tests/signal-emitter-integration.test.ts —
|
||||
// that's the correct layer to depend on @waggle/server. This module
|
||||
// (shim-core) stays a leaf with no inbound deps from the server, which
|
||||
// is what lets hook packages consume it without dragging the sidecar
|
||||
// in.
|
||||
Reference in New Issue
Block a user