Files
nanoclaw/.claude/skills/add-reactions/add/src/status-tracker.test.ts
Yonatan Azrielant ab9abbb21a feat(skill): add WhatsApp reactions skill (emoji reactions + status tracker) (#509)
* feat(skill): add reactions skill (emoji reactions + status tracker)

* refactor(reactions): minimize overlays per upstream review

Address gavrielc's review on qwibitai/nanoclaw#509:
- SKILL.md: remove all inline code, follow add-telegram/add-whatsapp pattern (465→79 lines)
- Rebuild overlays as minimal deltas against upstream/main base
- ipc-mcp-stdio.ts: upstream base + only react_to_message tool (8% delta)
- ipc.ts: upstream base + only reactions delta (14% delta)
- group-queue.test.ts: upstream base + isActive tests only (5% delta)
- Remove group-queue.ts overlay (isActive provided by container-hardening)
- Remove group-queue.ts from manifest modifies list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:02:20 +02:00

451 lines
16 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
default: {
...actual,
existsSync: vi.fn(() => false),
writeFileSync: vi.fn(),
readFileSync: vi.fn(() => '[]'),
mkdirSync: vi.fn(),
},
};
});
vi.mock('./logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { StatusTracker, StatusState, StatusTrackerDeps } from './status-tracker.js';
function makeDeps() {
return {
sendReaction: vi.fn<StatusTrackerDeps['sendReaction']>(async () => {}),
sendMessage: vi.fn<StatusTrackerDeps['sendMessage']>(async () => {}),
isMainGroup: vi.fn<StatusTrackerDeps['isMainGroup']>((jid) => jid === 'main@s.whatsapp.net'),
isContainerAlive: vi.fn<StatusTrackerDeps['isContainerAlive']>(() => true),
};
}
describe('StatusTracker', () => {
let tracker: StatusTracker;
let deps: ReturnType<typeof makeDeps>;
beforeEach(() => {
deps = makeDeps();
tracker = new StatusTracker(deps);
});
afterEach(() => {
vi.useRealTimers();
});
describe('forward-only transitions', () => {
it('transitions RECEIVED -> THINKING -> WORKING -> DONE', async () => {
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markThinking('msg1');
tracker.markWorking('msg1');
tracker.markDone('msg1');
// Wait for all reaction sends to complete
await tracker.flush();
expect(deps.sendReaction).toHaveBeenCalledTimes(4);
const emojis = deps.sendReaction.mock.calls.map((c) => c[2]);
expect(emojis).toEqual(['\u{1F440}', '\u{1F4AD}', '\u{1F504}', '\u{2705}']);
});
it('rejects backward transitions (WORKING -> THINKING is no-op)', async () => {
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markThinking('msg1');
tracker.markWorking('msg1');
const result = tracker.markThinking('msg1');
expect(result).toBe(false);
await tracker.flush();
expect(deps.sendReaction).toHaveBeenCalledTimes(3);
});
it('rejects duplicate transitions (DONE -> DONE is no-op)', async () => {
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markDone('msg1');
const result = tracker.markDone('msg1');
expect(result).toBe(false);
await tracker.flush();
expect(deps.sendReaction).toHaveBeenCalledTimes(2);
});
it('allows FAILED from any non-terminal state', async () => {
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markFailed('msg1');
await tracker.flush();
const emojis = deps.sendReaction.mock.calls.map((c) => c[2]);
expect(emojis).toEqual(['\u{1F440}', '\u{274C}']);
});
it('rejects FAILED after DONE', async () => {
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markDone('msg1');
const result = tracker.markFailed('msg1');
expect(result).toBe(false);
await tracker.flush();
expect(deps.sendReaction).toHaveBeenCalledTimes(2);
});
});
describe('main group gating', () => {
it('ignores messages from non-main groups', async () => {
tracker.markReceived('msg1', 'group@g.us', false);
await tracker.flush();
expect(deps.sendReaction).not.toHaveBeenCalled();
});
});
describe('duplicate tracking', () => {
it('rejects duplicate markReceived for same messageId', async () => {
const first = tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
const second = tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
expect(first).toBe(true);
expect(second).toBe(false);
await tracker.flush();
expect(deps.sendReaction).toHaveBeenCalledTimes(1);
});
});
describe('unknown message handling', () => {
it('returns false for transitions on untracked messages', () => {
expect(tracker.markThinking('unknown')).toBe(false);
expect(tracker.markWorking('unknown')).toBe(false);
expect(tracker.markDone('unknown')).toBe(false);
expect(tracker.markFailed('unknown')).toBe(false);
});
});
describe('batch operations', () => {
it('markAllDone transitions all tracked messages for a chatJid', async () => {
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markReceived('msg2', 'main@s.whatsapp.net', false);
tracker.markAllDone('main@s.whatsapp.net');
await tracker.flush();
const doneCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '\u{2705}');
expect(doneCalls).toHaveLength(2);
});
it('markAllFailed transitions all tracked messages and sends error message', async () => {
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markReceived('msg2', 'main@s.whatsapp.net', false);
tracker.markAllFailed('main@s.whatsapp.net', 'Task crashed');
await tracker.flush();
const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '\u{274C}');
expect(failCalls).toHaveLength(2);
expect(deps.sendMessage).toHaveBeenCalledWith('main@s.whatsapp.net', '[system] Task crashed');
});
});
describe('serialized sends', () => {
it('sends reactions in order even when transitions are rapid', async () => {
const order: string[] = [];
deps.sendReaction.mockImplementation(async (_jid, _key, emoji) => {
await new Promise((r) => setTimeout(r, Math.random() * 10));
order.push(emoji);
});
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markThinking('msg1');
tracker.markWorking('msg1');
tracker.markDone('msg1');
await tracker.flush();
expect(order).toEqual(['\u{1F440}', '\u{1F4AD}', '\u{1F504}', '\u{2705}']);
});
});
describe('recover', () => {
it('marks orphaned non-terminal entries as failed and sends error message', async () => {
const fs = await import('fs');
const persisted = JSON.stringify([
{ messageId: 'orphan1', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 0, terminal: null, trackedAt: 1000 },
{ messageId: 'orphan2', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 2, terminal: null, trackedAt: 2000 },
{ messageId: 'done1', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 3, terminal: 'done', trackedAt: 3000 },
]);
(fs.default.existsSync as ReturnType<typeof vi.fn>).mockReturnValue(true);
(fs.default.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
await tracker.recover();
// Should send ❌ reaction for the 2 non-terminal entries only
const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌');
expect(failCalls).toHaveLength(2);
// Should send one error message per chatJid
expect(deps.sendMessage).toHaveBeenCalledWith(
'main@s.whatsapp.net',
'[system] Restarted — reprocessing your message.',
);
expect(deps.sendMessage).toHaveBeenCalledTimes(1);
});
it('handles missing persistence file gracefully', async () => {
const fs = await import('fs');
(fs.default.existsSync as ReturnType<typeof vi.fn>).mockReturnValue(false);
await tracker.recover(); // should not throw
expect(deps.sendReaction).not.toHaveBeenCalled();
});
it('skips error message when sendErrorMessage is false', async () => {
const fs = await import('fs');
const persisted = JSON.stringify([
{ messageId: 'orphan1', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 1, terminal: null, trackedAt: 1000 },
]);
(fs.default.existsSync as ReturnType<typeof vi.fn>).mockReturnValue(true);
(fs.default.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
await tracker.recover(false);
// Still sends ❌ reaction
expect(deps.sendReaction).toHaveBeenCalledTimes(1);
expect(deps.sendReaction.mock.calls[0][2]).toBe('❌');
// But no text message
expect(deps.sendMessage).not.toHaveBeenCalled();
});
});
describe('heartbeatCheck', () => {
it('marks messages as failed when container is dead', async () => {
deps.isContainerAlive.mockReturnValue(false);
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markThinking('msg1');
tracker.heartbeatCheck();
await tracker.flush();
const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌');
expect(failCalls).toHaveLength(1);
expect(deps.sendMessage).toHaveBeenCalledWith(
'main@s.whatsapp.net',
'[system] Task crashed — retrying.',
);
});
it('does nothing when container is alive', async () => {
deps.isContainerAlive.mockReturnValue(true);
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markThinking('msg1');
tracker.heartbeatCheck();
await tracker.flush();
// Only the 👀 and 💭 reactions, no ❌
expect(deps.sendReaction).toHaveBeenCalledTimes(2);
const emojis = deps.sendReaction.mock.calls.map((c) => c[2]);
expect(emojis).toEqual(['👀', '💭']);
});
it('skips RECEIVED messages within grace period even if container is dead', async () => {
vi.useFakeTimers();
deps.isContainerAlive.mockReturnValue(false);
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
// Only 10s elapsed — within 30s grace period
vi.advanceTimersByTime(10_000);
tracker.heartbeatCheck();
await tracker.flush();
// Only the 👀 reaction, no ❌
expect(deps.sendReaction).toHaveBeenCalledTimes(1);
expect(deps.sendReaction.mock.calls[0][2]).toBe('👀');
});
it('fails RECEIVED messages after grace period when container is dead', async () => {
vi.useFakeTimers();
deps.isContainerAlive.mockReturnValue(false);
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
// 31s elapsed — past 30s grace period
vi.advanceTimersByTime(31_000);
tracker.heartbeatCheck();
await tracker.flush();
const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌');
expect(failCalls).toHaveLength(1);
expect(deps.sendMessage).toHaveBeenCalledWith(
'main@s.whatsapp.net',
'[system] Task crashed — retrying.',
);
});
it('does NOT fail RECEIVED messages after grace period when container is alive', async () => {
vi.useFakeTimers();
deps.isContainerAlive.mockReturnValue(true);
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
// 31s elapsed but container is alive — don't fail
vi.advanceTimersByTime(31_000);
tracker.heartbeatCheck();
await tracker.flush();
expect(deps.sendReaction).toHaveBeenCalledTimes(1);
expect(deps.sendReaction.mock.calls[0][2]).toBe('👀');
});
it('detects stuck messages beyond timeout', async () => {
vi.useFakeTimers();
deps.isContainerAlive.mockReturnValue(true); // container "alive" but hung
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markThinking('msg1');
// Advance time beyond container timeout (default 1800000ms = 30min)
vi.advanceTimersByTime(1_800_001);
tracker.heartbeatCheck();
await tracker.flush();
const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌');
expect(failCalls).toHaveLength(1);
expect(deps.sendMessage).toHaveBeenCalledWith(
'main@s.whatsapp.net',
'[system] Task timed out — retrying.',
);
});
it('does not timeout messages queued long in RECEIVED before reaching THINKING', async () => {
vi.useFakeTimers();
deps.isContainerAlive.mockReturnValue(true);
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
// Message sits in RECEIVED for longer than CONTAINER_TIMEOUT (queued, waiting for slot)
vi.advanceTimersByTime(2_000_000);
// Now container starts — trackedAt resets on THINKING transition
tracker.markThinking('msg1');
// Check immediately — should NOT timeout (trackedAt was just reset)
tracker.heartbeatCheck();
await tracker.flush();
const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌');
expect(failCalls).toHaveLength(0);
// Advance past CONTAINER_TIMEOUT from THINKING — NOW it should timeout
vi.advanceTimersByTime(1_800_001);
tracker.heartbeatCheck();
await tracker.flush();
const failCallsAfter = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌');
expect(failCallsAfter).toHaveLength(1);
});
});
describe('cleanup', () => {
it('removes terminal messages after delay', async () => {
vi.useFakeTimers();
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markDone('msg1');
// Message should still be tracked
expect(tracker.isTracked('msg1')).toBe(true);
// Advance past cleanup delay
vi.advanceTimersByTime(6000);
expect(tracker.isTracked('msg1')).toBe(false);
});
});
describe('reaction retry', () => {
it('retries failed sends with exponential backoff (2s, 4s)', async () => {
vi.useFakeTimers();
let callCount = 0;
deps.sendReaction.mockImplementation(async () => {
callCount++;
if (callCount <= 2) throw new Error('network error');
});
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
// First attempt fires immediately
await vi.advanceTimersByTimeAsync(0);
expect(callCount).toBe(1);
// After 2s: second attempt (first retry delay = 2s)
await vi.advanceTimersByTimeAsync(2000);
expect(callCount).toBe(2);
// After 1s more (3s total): still waiting for 4s delay
await vi.advanceTimersByTimeAsync(1000);
expect(callCount).toBe(2);
// After 3s more (6s total): third attempt fires (second retry delay = 4s)
await vi.advanceTimersByTimeAsync(3000);
expect(callCount).toBe(3);
await tracker.flush();
});
it('gives up after max retries', async () => {
vi.useFakeTimers();
let callCount = 0;
deps.sendReaction.mockImplementation(async () => {
callCount++;
throw new Error('permanent failure');
});
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
await vi.advanceTimersByTimeAsync(10_000);
await tracker.flush();
expect(callCount).toBe(3); // MAX_RETRIES = 3
});
});
describe('batch transitions', () => {
it('markThinking can be called on multiple messages independently', async () => {
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markReceived('msg2', 'main@s.whatsapp.net', false);
tracker.markReceived('msg3', 'main@s.whatsapp.net', false);
// Mark all as thinking (simulates batch behavior)
tracker.markThinking('msg1');
tracker.markThinking('msg2');
tracker.markThinking('msg3');
await tracker.flush();
const thinkingCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '💭');
expect(thinkingCalls).toHaveLength(3);
});
it('markWorking can be called on multiple messages independently', async () => {
tracker.markReceived('msg1', 'main@s.whatsapp.net', false);
tracker.markReceived('msg2', 'main@s.whatsapp.net', false);
tracker.markThinking('msg1');
tracker.markThinking('msg2');
tracker.markWorking('msg1');
tracker.markWorking('msg2');
await tracker.flush();
const workingCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '🔄');
expect(workingCalls).toHaveLength(2);
});
});
});