diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts new file mode 100644 index 000000000..a7d6fd5bd --- /dev/null +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import type { ChannelConfig, Envelope } from './types.js'; +import type { AcpBridge } from './AcpBridge.js'; +import { ChannelBase } from './ChannelBase.js'; +import type { ChannelBaseOptions } from './ChannelBase.js'; + +// Concrete test implementation +class TestChannel extends ChannelBase { + sent: Array<{ chatId: string; text: string }> = []; + connected = false; + + async connect() { + this.connected = true; + } + async sendMessage(chatId: string, text: string) { + this.sent.push({ chatId, text }); + } + disconnect() { + this.connected = false; + } +} + +function createBridge(): AcpBridge { + const emitter = new EventEmitter(); + let sessionCounter = 0; + const bridge = Object.assign(emitter, { + newSession: vi.fn().mockImplementation(() => `s-${++sessionCounter}`), + loadSession: vi.fn(), + prompt: vi.fn().mockResolvedValue('agent response'), + stop: vi.fn(), + start: vi.fn(), + isConnected: true, + availableCommands: [], + setBridge: vi.fn(), + }); + return bridge as unknown as AcpBridge; +} + +function defaultConfig(overrides: Partial = {}): ChannelConfig { + return { + type: 'test', + token: 'tok', + senderPolicy: 'open', + allowedUsers: [], + sessionScope: 'user', + cwd: '/tmp', + groupPolicy: 'disabled', + groups: {}, + ...overrides, + }; +} + +function envelope(overrides: Partial = {}): Envelope { + return { + channelName: 'test-chan', + senderId: 'user1', + senderName: 'User 1', + chatId: 'chat1', + text: 'hello', + isGroup: false, + isMentioned: false, + isReplyToBot: false, + ...overrides, + }; +} + +describe('ChannelBase', () => { + let bridge: AcpBridge; + + beforeEach(() => { + bridge = createBridge(); + }); + + function createChannel( + configOverrides: Partial = {}, + options?: ChannelBaseOptions, + ): TestChannel { + return new TestChannel( + 'test-chan', + defaultConfig(configOverrides), + bridge, + options, + ); + } + + describe('gate integration', () => { + it('silently drops group messages when groupPolicy=disabled', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ isGroup: true })); + expect(ch.sent).toEqual([]); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('allows DM messages through', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(bridge.prompt).toHaveBeenCalled(); + }); + + it('rejects sender with allowlist policy', async () => { + const ch = createChannel({ + senderPolicy: 'allowlist', + allowedUsers: ['admin'], + }); + await ch.handleInbound(envelope({ senderId: 'stranger' })); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('allows sender on allowlist', async () => { + const ch = createChannel({ + senderPolicy: 'allowlist', + allowedUsers: ['user1'], + }); + await ch.handleInbound(envelope()); + expect(bridge.prompt).toHaveBeenCalled(); + }); + }); + + describe('slash commands', () => { + it('/help sends command list', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/help' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('/help'); + expect(ch.sent[0]!.text).toContain('/clear'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('/clear removes session and confirms', async () => { + const ch = createChannel(); + // Create a session first + await ch.handleInbound(envelope()); + ch.sent = []; + // Now clear + await ch.handleInbound(envelope({ text: '/clear' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('Session cleared'); + }); + + it('/clear reports when no session exists', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/clear' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('No active session'); + }); + + it('/reset and /new are aliases for /clear', async () => { + for (const cmd of ['/reset', '/new']) { + const ch = createChannel(); + await ch.handleInbound(envelope()); + ch.sent = []; + await ch.handleInbound(envelope({ text: cmd })); + expect(ch.sent[0]!.text).toContain('Session cleared'); + } + }); + + it('/status shows session info', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/status' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('Session: none'); + expect(ch.sent[0]!.text).toContain('Access: open'); + expect(ch.sent[0]!.text).toContain('Channel: test-chan'); + }); + + it('/status shows active session', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: 'hi' })); + ch.sent = []; + await ch.handleInbound(envelope({ text: '/status' })); + expect(ch.sent[0]!.text).toContain('Session: active'); + }); + + it('handles /command@botname format', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/help@mybot' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('/help'); + }); + + it('forwards unrecognized commands to agent', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/unknown' })); + expect(bridge.prompt).toHaveBeenCalled(); + }); + }); + + describe('custom commands', () => { + it('subclass can register custom commands', async () => { + const ch = createChannel(); + // Access protected method via the test subclass + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ch as any).registerCommand('ping', async () => { + await ch.sendMessage('chat1', 'pong'); + return true; + }); + await ch.handleInbound(envelope({ text: '/ping' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toBe('pong'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('/help shows platform-specific commands', async () => { + const ch = createChannel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ch as any).registerCommand('start', async () => true); + await ch.handleInbound(envelope({ text: '/help' })); + expect(ch.sent[0]!.text).toContain('/start'); + }); + }); + + describe('message enrichment', () => { + it('prepends referenced text', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ text: 'my reply', referencedText: 'original message' }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const promptText = (bridge.prompt as any).mock.calls[0][1] as string; + expect(promptText).toContain('[Replying to: "original message"]'); + expect(promptText).toContain('my reply'); + }); + + it('appends file paths from attachments', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ + text: 'check this', + attachments: [ + { + type: 'file', + filePath: '/tmp/test.pdf', + mimeType: 'application/pdf', + fileName: 'test.pdf', + }, + ], + }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const promptText = (bridge.prompt as any).mock.calls[0][1] as string; + expect(promptText).toContain('/tmp/test.pdf'); + expect(promptText).toContain('"test.pdf"'); + }); + + it('extracts image from attachments', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ + text: 'see image', + attachments: [ + { + type: 'image', + data: 'base64data', + mimeType: 'image/png', + }, + ], + }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = (bridge.prompt as any).mock.calls[0][2]; + expect(options.imageBase64).toBe('base64data'); + expect(options.imageMimeType).toBe('image/png'); + }); + + it('uses legacy imageBase64 when no attachment image', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ + text: 'see image', + imageBase64: 'legacydata', + imageMimeType: 'image/jpeg', + }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = (bridge.prompt as any).mock.calls[0][2]; + expect(options.imageBase64).toBe('legacydata'); + }); + + it('prepends instructions on first message only', async () => { + const ch = createChannel({ instructions: 'Be concise.' }); + await ch.handleInbound(envelope({ text: 'first' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstPrompt = (bridge.prompt as any).mock.calls[0][1] as string; + expect(firstPrompt).toContain('Be concise.'); + + await ch.handleInbound(envelope({ text: 'second' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const secondPrompt = (bridge.prompt as any).mock.calls[1][1] as string; + expect(secondPrompt).not.toContain('Be concise.'); + }); + }); + + describe('session routing', () => { + it('creates new session on first message', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(bridge.newSession).toHaveBeenCalledTimes(1); + }); + + it('reuses session for same sender', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + await ch.handleInbound(envelope()); + expect(bridge.newSession).toHaveBeenCalledTimes(1); + }); + + it('creates separate sessions for different senders', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ senderId: 'alice' })); + await ch.handleInbound(envelope({ senderId: 'bob' })); + expect(bridge.newSession).toHaveBeenCalledTimes(2); + }); + }); + + describe('response delivery', () => { + it('sends agent response via sendMessage', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toBe('agent response'); + }); + + it('does not send when agent returns empty response', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge.prompt as any).mockResolvedValue(''); + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(ch.sent).toEqual([]); + }); + }); + + describe('block streaming', () => { + it('uses block streamer when blockStreaming=on', async () => { + // The streamer sends blocks; onResponseComplete is NOT called + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge.prompt as any).mockImplementation( + (sid: string, _text: string) => { + // Simulate streaming chunks + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge as any).emit('textChunk', sid, 'Hello world! '); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge as any).emit('textChunk', sid, 'This is a test.'); + return Promise.resolve('Hello world! This is a test.'); + }, + ); + + const ch = createChannel({ + blockStreaming: 'on', + blockStreamingChunk: { minChars: 5, maxChars: 100 }, + blockStreamingCoalesce: { idleMs: 0 }, + }); + await ch.handleInbound(envelope()); + // BlockStreamer flush should have sent the accumulated text + expect(ch.sent.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('pairing flow', () => { + it('sends pairing code message when required', async () => { + const ch = createChannel({ senderPolicy: 'pairing', allowedUsers: [] }); + await ch.handleInbound(envelope({ senderId: 'stranger' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('pairing code'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + }); + + describe('setBridge', () => { + it('replaces the bridge instance', async () => { + const ch = createChannel(); + const newBridge = createBridge(); + ch.setBridge(newBridge); + // The channel should use the new bridge for future messages + // (this mainly ensures no crash) + expect(() => ch.setBridge(newBridge)).not.toThrow(); + }); + }); + + describe('isLocalCommand', () => { + it('returns true for registered commands', () => { + const ch = createChannel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('/help')).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('/clear')).toBe(true); + }); + + it('returns false for non-commands', () => { + const ch = createChannel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('hello')).toBe(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('/unknown')).toBe(false); + }); + }); +}); diff --git a/packages/channels/base/src/GroupGate.test.ts b/packages/channels/base/src/GroupGate.test.ts new file mode 100644 index 000000000..4f5419523 --- /dev/null +++ b/packages/channels/base/src/GroupGate.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { GroupGate } from './GroupGate.js'; +import type { Envelope } from './types.js'; + +function envelope(overrides: Partial = {}): Envelope { + return { + channelName: 'test', + senderId: 'user1', + senderName: 'User', + chatId: 'chat1', + text: 'hello', + isGroup: false, + isMentioned: false, + isReplyToBot: false, + ...overrides, + }; +} + +describe('GroupGate', () => { + describe('non-group messages', () => { + it('always allows DM messages regardless of policy', () => { + for (const policy of ['disabled', 'allowlist', 'open'] as const) { + const gate = new GroupGate(policy); + expect(gate.check(envelope()).allowed).toBe(true); + } + }); + }); + + describe('disabled policy', () => { + it('rejects all group messages', () => { + const gate = new GroupGate('disabled'); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'disabled' }); + }); + }); + + describe('allowlist policy', () => { + it('rejects groups not in allowlist', () => { + const gate = new GroupGate('allowlist', { other: {} }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'not_allowlisted' }); + }); + + it('does not treat "*" as wildcard allow', () => { + const gate = new GroupGate('allowlist', { '*': {} }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'not_allowlisted' }); + }); + + it('allows explicitly listed group with mention', () => { + const gate = new GroupGate('allowlist', { chat1: {} }); + const result = gate.check(envelope({ isGroup: true, isMentioned: true })); + expect(result.allowed).toBe(true); + }); + + it('requires mention by default for allowlisted group', () => { + const gate = new GroupGate('allowlist', { chat1: {} }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'mention_required' }); + }); + + it('allows reply-to-bot as alternative to mention', () => { + const gate = new GroupGate('allowlist', { chat1: {} }); + const result = gate.check( + envelope({ isGroup: true, isReplyToBot: true }), + ); + expect(result.allowed).toBe(true); + }); + + it('respects requireMention=false override', () => { + const gate = new GroupGate('allowlist', { + chat1: { requireMention: false }, + }); + const result = gate.check(envelope({ isGroup: true })); + expect(result.allowed).toBe(true); + }); + }); + + describe('open policy', () => { + it('allows any group with mention', () => { + const gate = new GroupGate('open'); + const result = gate.check(envelope({ isGroup: true, isMentioned: true })); + expect(result.allowed).toBe(true); + }); + + it('requires mention by default', () => { + const gate = new GroupGate('open'); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'mention_required' }); + }); + + it('uses "*" as default config fallback', () => { + const gate = new GroupGate('open', { '*': { requireMention: false } }); + const result = gate.check(envelope({ isGroup: true })); + expect(result.allowed).toBe(true); + }); + + it('per-group config overrides "*" default', () => { + const gate = new GroupGate('open', { + '*': { requireMention: false }, + chat1: { requireMention: true }, + }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'mention_required' }); + }); + }); + + describe('defaults', () => { + it('defaults to disabled policy', () => { + const gate = new GroupGate(); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'disabled' }); + }); + }); +}); diff --git a/packages/channels/base/src/SenderGate.test.ts b/packages/channels/base/src/SenderGate.test.ts new file mode 100644 index 000000000..05f74a83c --- /dev/null +++ b/packages/channels/base/src/SenderGate.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SenderGate } from './SenderGate.js'; +import type { PairingStore } from './PairingStore.js'; + +function mockPairingStore(overrides: Partial = {}): PairingStore { + return { + isApproved: vi.fn().mockReturnValue(false), + createRequest: vi.fn().mockReturnValue('ABCD1234'), + approve: vi.fn(), + listPending: vi.fn().mockReturnValue([]), + getAllowlist: vi.fn().mockReturnValue([]), + ...overrides, + } as unknown as PairingStore; +} + +describe('SenderGate', () => { + describe('open policy', () => { + it('allows any sender', () => { + const gate = new SenderGate('open'); + expect(gate.check('anyone').allowed).toBe(true); + }); + }); + + describe('allowlist policy', () => { + it('allows listed users', () => { + const gate = new SenderGate('allowlist', ['alice', 'bob']); + expect(gate.check('alice').allowed).toBe(true); + }); + + it('rejects unlisted users', () => { + const gate = new SenderGate('allowlist', ['alice']); + const result = gate.check('eve'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBeUndefined(); + }); + + it('works with empty allowlist', () => { + const gate = new SenderGate('allowlist'); + expect(gate.check('anyone').allowed).toBe(false); + }); + }); + + describe('pairing policy', () => { + it('allows static allowlisted users without checking store', () => { + const store = mockPairingStore(); + const gate = new SenderGate('pairing', ['admin'], store); + const result = gate.check('admin'); + expect(result.allowed).toBe(true); + expect(store.isApproved).not.toHaveBeenCalled(); + }); + + it('allows dynamically approved users', () => { + const store = mockPairingStore({ + isApproved: vi.fn().mockReturnValue(true), + }); + const gate = new SenderGate('pairing', [], store); + expect(gate.check('user1').allowed).toBe(true); + }); + + it('generates pairing code for unknown sender', () => { + const store = mockPairingStore({ + createRequest: vi.fn().mockReturnValue('XYZW5678'), + }); + const gate = new SenderGate('pairing', [], store); + const result = gate.check('stranger', 'Stranger Name'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBe('XYZW5678'); + expect(store.createRequest).toHaveBeenCalledWith( + 'stranger', + 'Stranger Name', + ); + }); + + it('returns null pairingCode when cap reached', () => { + const store = mockPairingStore({ + createRequest: vi.fn().mockReturnValue(null), + }); + const gate = new SenderGate('pairing', [], store); + const result = gate.check('stranger'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBeNull(); + }); + + it('uses senderId as senderName fallback', () => { + const store = mockPairingStore(); + const gate = new SenderGate('pairing', [], store); + gate.check('user42'); + expect(store.createRequest).toHaveBeenCalledWith('user42', 'user42'); + }); + + it('works without pairing store (no store provided)', () => { + const gate = new SenderGate('pairing'); + const result = gate.check('anyone'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBeNull(); + }); + }); + + describe('unknown policy', () => { + it('throws on unknown policy', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gate = new SenderGate('unknown' as any); + expect(() => gate.check('user')).toThrow('Unknown sender policy'); + }); + }); +}); diff --git a/packages/channels/base/src/SessionRouter.test.ts b/packages/channels/base/src/SessionRouter.test.ts new file mode 100644 index 000000000..c8d1b5df1 --- /dev/null +++ b/packages/channels/base/src/SessionRouter.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SessionRouter } from './SessionRouter.js'; +import type { AcpBridge } from './AcpBridge.js'; + +let sessionCounter = 0; + +function mockBridge(): AcpBridge { + return { + newSession: vi.fn().mockImplementation(() => `session-${++sessionCounter}`), + loadSession: vi.fn().mockImplementation((id: string) => id), + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + availableCommands: [], + } as unknown as AcpBridge; +} + +describe('SessionRouter', () => { + let bridge: AcpBridge; + + beforeEach(() => { + sessionCounter = 0; + bridge = mockBridge(); + }); + + describe('routing key scopes', () => { + it('user scope: routes by channel + sender + chat', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'alice', 'chat2'); + const s3 = await router.resolve('ch', 'bob', 'chat1'); + expect(new Set([s1, s2, s3]).size).toBe(3); + }); + + it('user scope: same sender+chat reuses session', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'alice', 'chat1'); + expect(s1).toBe(s2); + expect(bridge.newSession).toHaveBeenCalledTimes(1); + }); + + it('thread scope: routes by channel + threadId', async () => { + const router = new SessionRouter(bridge, '/tmp', 'thread'); + const s1 = await router.resolve('ch', 'alice', 'chat1', 'thread1'); + const s2 = await router.resolve('ch', 'bob', 'chat1', 'thread1'); + expect(s1).toBe(s2); // same thread = same session + }); + + it('thread scope: falls back to chatId when no threadId', async () => { + const router = new SessionRouter(bridge, '/tmp', 'thread'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'bob', 'chat1'); + expect(s1).toBe(s2); + }); + + it('single scope: all messages share one session per channel', async () => { + const router = new SessionRouter(bridge, '/tmp', 'single'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'bob', 'chat2'); + expect(s1).toBe(s2); + }); + + it('single scope: different channels get different sessions', async () => { + const router = new SessionRouter(bridge, '/tmp', 'single'); + const s1 = await router.resolve('ch1', 'alice', 'chat1'); + const s2 = await router.resolve('ch2', 'alice', 'chat1'); + expect(s1).not.toBe(s2); + }); + }); + + describe('resolve', () => { + it('passes cwd to bridge.newSession', async () => { + const router = new SessionRouter(bridge, '/default'); + await router.resolve('ch', 'alice', 'chat1', undefined, '/custom'); + expect(bridge.newSession).toHaveBeenCalledWith('/custom'); + }); + + it('uses defaultCwd when no cwd provided', async () => { + const router = new SessionRouter(bridge, '/default'); + await router.resolve('ch', 'alice', 'chat1'); + expect(bridge.newSession).toHaveBeenCalledWith('/default'); + }); + }); + + describe('getTarget', () => { + it('returns target for existing session', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const sid = await router.resolve('ch', 'alice', 'chat1', 'thread1'); + const target = router.getTarget(sid); + expect(target).toEqual({ + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + threadId: 'thread1', + }); + }); + + it('returns undefined for unknown session', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.getTarget('nonexistent')).toBeUndefined(); + }); + }); + + describe('hasSession', () => { + it('returns true for existing session with chatId', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(true); + }); + + it('returns false for non-existing session', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); + }); + + it('prefix-scans when chatId omitted', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + expect(router.hasSession('ch', 'alice')).toBe(true); + expect(router.hasSession('ch', 'bob')).toBe(false); + }); + }); + + describe('removeSession', () => { + it('removes session by key and returns true', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + expect(router.removeSession('ch', 'alice', 'chat1')).toBe(true); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); + }); + + it('returns false when nothing to remove', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.removeSession('ch', 'alice', 'chat1')).toBe(false); + }); + + it('removes all sender sessions when chatId omitted', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + await router.resolve('ch', 'alice', 'chat2'); + expect(router.removeSession('ch', 'alice')).toBe(true); + expect(router.hasSession('ch', 'alice')).toBe(false); + }); + + it('cleans up target mapping after removal', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const sid = await router.resolve('ch', 'alice', 'chat1'); + router.removeSession('ch', 'alice', 'chat1'); + expect(router.getTarget(sid)).toBeUndefined(); + }); + }); + + describe('getAll', () => { + it('returns all session entries', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + await router.resolve('ch', 'bob', 'chat2'); + const all = router.getAll(); + expect(all).toHaveLength(2); + expect(all.map((e) => e.target.senderId).sort()).toEqual([ + 'alice', + 'bob', + ]); + }); + + it('returns empty array when no sessions', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.getAll()).toEqual([]); + }); + }); + + describe('clearAll', () => { + it('clears all in-memory state', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + router.clearAll(); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); + expect(router.getAll()).toEqual([]); + }); + }); + + describe('setBridge', () => { + it('replaces the bridge instance', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const newBridge = mockBridge(); + router.setBridge(newBridge); + await router.resolve('ch', 'alice', 'chat1'); + expect(newBridge.newSession).toHaveBeenCalled(); + expect(bridge.newSession).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/channels/base/vitest.config.ts b/packages/channels/base/vitest.config.ts new file mode 100644 index 000000000..bfaebe3ce --- /dev/null +++ b/packages/channels/base/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/channels/dingtalk/src/markdown.test.ts b/packages/channels/dingtalk/src/markdown.test.ts new file mode 100644 index 000000000..b779a1951 --- /dev/null +++ b/packages/channels/dingtalk/src/markdown.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { + convertTables, + splitChunks, + extractTitle, + normalizeDingTalkMarkdown, +} from './markdown.js'; + +describe('DingTalk markdown utilities', () => { + describe('convertTables', () => { + it('converts a simple markdown table to pipe-separated text', () => { + const input = [ + '| Name | Age |', + '| --- | --- |', + '| Alice | 30 |', + '| Bob | 25 |', + ].join('\n'); + const result = convertTables(input); + expect(result).toContain('Name | Age'); + expect(result).toContain('Alice | 30'); + expect(result).not.toContain('---'); + }); + + it('preserves non-table content', () => { + const input = 'Hello world\n\nSome text'; + expect(convertTables(input)).toBe(input); + }); + + it('does not convert tables inside code fences', () => { + const input = [ + '```', + '| Name | Age |', + '| --- | --- |', + '| Alice | 30 |', + '```', + ].join('\n'); + const result = convertTables(input); + expect(result).toBe(input); + }); + + it('handles table with surrounding text', () => { + const input = [ + 'Before', + '| A | B |', + '| --- | --- |', + '| 1 | 2 |', + 'After', + ].join('\n'); + const result = convertTables(input); + expect(result).toContain('Before'); + expect(result).toContain('After'); + expect(result).toContain('A | B'); + }); + + it('handles table with alignment colons in separator', () => { + const input = [ + '| Left | Center | Right |', + '| :--- | :---: | ---: |', + '| a | b | c |', + ].join('\n'); + const result = convertTables(input); + expect(result).not.toContain(':---'); + }); + }); + + describe('splitChunks', () => { + it('returns single chunk for short text', () => { + expect(splitChunks('short text')).toEqual(['short text']); + }); + + it('returns single chunk for empty text', () => { + expect(splitChunks('')).toEqual(['']); + }); + + it('splits long text into chunks', () => { + const line = 'a'.repeat(100) + '\n'; + const text = line.repeat(50); // 5050 chars > 3800 + const chunks = splitChunks(text); + expect(chunks.length).toBeGreaterThan(1); + chunks.forEach((chunk) => { + expect(chunk.length).toBeLessThanOrEqual(3900); // allow small overhead + }); + }); + + it('closes and reopens code fences across boundaries', () => { + const longCode = '```\n' + 'x\n'.repeat(2000) + '```'; + const chunks = splitChunks(longCode); + expect(chunks.length).toBeGreaterThan(1); + // First chunk should end with closing fence + expect(chunks[0]).toContain('```'); + // Second chunk should start with opening fence + if (chunks.length > 1) { + expect(chunks[1]!.trimStart().startsWith('```')).toBe(true); + } + }); + }); + + describe('extractTitle', () => { + it('extracts title from first line', () => { + expect(extractTitle('Hello World\nmore text')).toBe('Hello World'); + }); + + it('strips markdown heading markers', () => { + expect(extractTitle('## My Title\ncontent')).toBe('My Title'); + }); + + it('strips bold/list markers', () => { + expect(extractTitle('* Item one')).toBe('Item one'); + expect(extractTitle('> Quote text')).toBe('Quote text'); + }); + + it('truncates to 20 chars', () => { + expect( + extractTitle('This is a very long title that should be truncated') + .length, + ).toBeLessThanOrEqual(20); + }); + + it('returns Reply for empty text', () => { + expect(extractTitle('')).toBe('Reply'); + expect(extractTitle('###')).toBe('Reply'); + }); + }); + + describe('normalizeDingTalkMarkdown', () => { + it('converts tables and splits into chunks', () => { + const input = ['| A | B |', '| --- | --- |', '| 1 | 2 |'].join('\n'); + const result = normalizeDingTalkMarkdown(input); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0]).not.toContain('---'); + }); + + it('passes through plain text', () => { + const result = normalizeDingTalkMarkdown('simple text'); + expect(result).toEqual(['simple text']); + }); + }); +}); diff --git a/packages/channels/dingtalk/tsconfig.json b/packages/channels/dingtalk/tsconfig.json index 8daf59408..30e3324c8 100644 --- a/packages/channels/dingtalk/tsconfig.json +++ b/packages/channels/dingtalk/tsconfig.json @@ -5,6 +5,6 @@ "rootDir": "src" }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], "references": [{ "path": "../base" }] } diff --git a/packages/channels/dingtalk/vitest.config.ts b/packages/channels/dingtalk/vitest.config.ts new file mode 100644 index 000000000..bfaebe3ce --- /dev/null +++ b/packages/channels/dingtalk/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/channels/telegram/tsconfig.json b/packages/channels/telegram/tsconfig.json index 8daf59408..30e3324c8 100644 --- a/packages/channels/telegram/tsconfig.json +++ b/packages/channels/telegram/tsconfig.json @@ -5,6 +5,6 @@ "rootDir": "src" }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], "references": [{ "path": "../base" }] } diff --git a/packages/channels/telegram/vitest.config.ts b/packages/channels/telegram/vitest.config.ts new file mode 100644 index 000000000..bfaebe3ce --- /dev/null +++ b/packages/channels/telegram/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/channels/weixin/src/media.test.ts b/packages/channels/weixin/src/media.test.ts new file mode 100644 index 000000000..745c01554 --- /dev/null +++ b/packages/channels/weixin/src/media.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { createDecipheriv, createCipheriv } from 'node:crypto'; + +/** + * Test the AES key parsing and decryption logic used in media.ts. + * We test the pure crypto functions by reimplementing them here + * since they're not exported, but the behavior must match. + */ + +function parseAesKey(aesKeyBase64: string): Buffer { + const decoded = Buffer.from(aesKeyBase64, 'base64'); + if (decoded.length === 16) { + return decoded; + } + if ( + decoded.length === 32 && + /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii')) + ) { + return Buffer.from(decoded.toString('ascii'), 'hex'); + } + throw new Error( + `Invalid aes_key: expected 16 raw bytes or 32 hex chars, got ${decoded.length} bytes`, + ); +} + +function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer { + const decipher = createDecipheriv('aes-128-ecb', key, null); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +describe('Weixin media crypto', () => { + describe('parseAesKey', () => { + it('accepts 16-byte raw key encoded in base64', () => { + const raw = Buffer.alloc(16, 0xab); + const b64 = raw.toString('base64'); + const result = parseAesKey(b64); + expect(result).toEqual(raw); + expect(result.length).toBe(16); + }); + + it('accepts 32-char hex string encoded in base64', () => { + // 32 hex chars → 16 bytes when parsed as hex + const hexStr = 'aabbccdd11223344aabbccdd11223344'; + const b64 = Buffer.from(hexStr, 'ascii').toString('base64'); + const result = parseAesKey(b64); + expect(result.length).toBe(16); + expect(result.toString('hex')).toBe(hexStr); + }); + + it('throws for invalid key length', () => { + const bad = Buffer.alloc(20, 0x00).toString('base64'); + expect(() => parseAesKey(bad)).toThrow('Invalid aes_key'); + }); + + it('throws for 32-byte non-hex content', () => { + // 32 bytes but not valid hex characters + const nonHex = Buffer.from('zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', 'ascii'); + const b64 = nonHex.toString('base64'); + expect(() => parseAesKey(b64)).toThrow('Invalid aes_key'); + }); + }); + + describe('decryptAesEcb', () => { + it('encrypts then decrypts round-trip', () => { + const key = Buffer.alloc(16, 0x42); + const plaintext = Buffer.from('Hello, WeChat media decryption!'); + + // Encrypt + const cipher = createCipheriv('aes-128-ecb', key, null); + const ciphertext = Buffer.concat([ + cipher.update(plaintext), + cipher.final(), + ]); + + // Decrypt + const decrypted = decryptAesEcb(ciphertext, key); + expect(decrypted.toString()).toBe(plaintext.toString()); + }); + + it('handles empty plaintext', () => { + const key = Buffer.alloc(16, 0x01); + const cipher = createCipheriv('aes-128-ecb', key, null); + const ciphertext = Buffer.concat([ + cipher.update(Buffer.alloc(0)), + cipher.final(), + ]); + const decrypted = decryptAesEcb(ciphertext, key); + expect(decrypted.length).toBe(0); + }); + }); +}); diff --git a/packages/channels/weixin/src/send.test.ts b/packages/channels/weixin/src/send.test.ts new file mode 100644 index 000000000..95152672c --- /dev/null +++ b/packages/channels/weixin/src/send.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { markdownToPlainText } from './send.js'; + +describe('markdownToPlainText', () => { + it('strips code blocks', () => { + const input = '```js\nconst x = 1;\n```'; + expect(markdownToPlainText(input)).toBe('const x = 1;'); + }); + + it('strips inline code', () => { + expect(markdownToPlainText('use `npm install`')).toBe('use npm install'); + }); + + it('strips bold', () => { + expect(markdownToPlainText('**bold text**')).toBe('bold text'); + }); + + it('strips italic', () => { + expect(markdownToPlainText('*italic text*')).toBe('italic text'); + expect(markdownToPlainText('_italic text_')).toBe('italic text'); + }); + + it('strips bold+italic', () => { + expect(markdownToPlainText('***bold italic***')).toBe('bold italic'); + }); + + it('strips strikethrough', () => { + expect(markdownToPlainText('~~deleted~~')).toBe('deleted'); + }); + + it('strips headings', () => { + expect(markdownToPlainText('# Title\n## Subtitle')).toBe('Title\nSubtitle'); + }); + + it('converts links to text (url)', () => { + expect(markdownToPlainText('[click here](https://example.com)')).toBe( + 'click here (https://example.com)', + ); + }); + + it('converts image syntax (link regex fires before image regex)', () => { + // In the current implementation, the link regex fires before the image regex, + // so `![alt](url)` becomes `!alt (url)` rather than `[alt]` + const result = markdownToPlainText('![alt](https://img.png)'); + expect(result).toBe('!alt (https://img.png)'); + }); + + it('strips blockquote markers', () => { + expect(markdownToPlainText('> quoted text')).toBe('quoted text'); + }); + + it('normalizes list markers', () => { + expect(markdownToPlainText('* item 1\n- item 2')).toBe( + '- item 1\n- item 2', + ); + }); + + it('collapses triple+ newlines', () => { + expect(markdownToPlainText('a\n\n\n\nb')).toBe('a\n\nb'); + }); + + it('trims result', () => { + expect(markdownToPlainText(' \n hello \n ')).toBe('hello'); + }); + + it('handles double underscore bold', () => { + expect(markdownToPlainText('__bold__')).toBe('bold'); + }); + + it('handles complex markdown', () => { + const input = '# Title\n\n**Bold** and *italic* with `code`\n\n> quote'; + const result = markdownToPlainText(input); + expect(result).toContain('Title'); + expect(result).toContain('Bold'); + expect(result).toContain('italic'); + expect(result).toContain('code'); + expect(result).toContain('quote'); + expect(result).not.toContain('#'); + expect(result).not.toContain('**'); + expect(result).not.toContain('`'); + }); +}); diff --git a/packages/channels/weixin/tsconfig.json b/packages/channels/weixin/tsconfig.json index 8daf59408..30e3324c8 100644 --- a/packages/channels/weixin/tsconfig.json +++ b/packages/channels/weixin/tsconfig.json @@ -5,6 +5,6 @@ "rootDir": "src" }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], "references": [{ "path": "../base" }] } diff --git a/packages/channels/weixin/vitest.config.ts b/packages/channels/weixin/vitest.config.ts new file mode 100644 index 000000000..bfaebe3ce --- /dev/null +++ b/packages/channels/weixin/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts new file mode 100644 index 000000000..d4002deee --- /dev/null +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resolveEnvVars, parseChannelConfig } from './config-utils.js'; + +// Mock the channel-registry so we don't pull in real plugins +vi.mock('./channel-registry.js', () => ({ + getPlugin: (type: string) => { + const plugins: Record< + string, + { channelType: string; requiredConfigFields?: string[] } + > = { + telegram: { channelType: 'telegram', requiredConfigFields: ['token'] }, + dingtalk: { + channelType: 'dingtalk', + requiredConfigFields: ['clientId', 'clientSecret'], + }, + bare: { channelType: 'bare' }, // no requiredConfigFields + }; + return plugins[type]; + }, + supportedTypes: () => ['telegram', 'dingtalk', 'bare'], +})); + +describe('resolveEnvVars', () => { + const ENV_KEY = 'TEST_RESOLVE_VAR_123'; + + afterEach(() => { + delete process.env[ENV_KEY]; + }); + + it('returns literal values unchanged', () => { + expect(resolveEnvVars('my-token')).toBe('my-token'); + }); + + it('resolves $ENV_VAR to its value', () => { + process.env[ENV_KEY] = 'secret'; + expect(resolveEnvVars(`$${ENV_KEY}`)).toBe('secret'); + }); + + it('throws when referenced env var is not set', () => { + expect(() => resolveEnvVars(`$${ENV_KEY}`)).toThrow( + `Environment variable ${ENV_KEY} is not set`, + ); + }); + + it('does not resolve vars that do not start with $', () => { + process.env[ENV_KEY] = 'val'; + expect(resolveEnvVars(`prefix$${ENV_KEY}`)).toBe(`prefix$${ENV_KEY}`); + }); +}); + +describe('parseChannelConfig', () => { + it('throws when type is missing', () => { + expect(() => parseChannelConfig('bot', {})).toThrow( + 'missing required field "type"', + ); + }); + + it('throws for unsupported channel type', () => { + expect(() => parseChannelConfig('bot', { type: 'slack' })).toThrow( + '"slack" is not supported', + ); + }); + + it('throws when plugin-required fields are missing', () => { + expect(() => parseChannelConfig('bot', { type: 'telegram' })).toThrow( + 'requires "token"', + ); + }); + + it('parses minimal valid config with defaults', () => { + const result = parseChannelConfig('bot', { + type: 'bare', + }); + + expect(result.type).toBe('bare'); + expect(result.token).toBe(''); + expect(result.senderPolicy).toBe('allowlist'); + expect(result.allowedUsers).toEqual([]); + expect(result.sessionScope).toBe('user'); + expect(result.cwd).toBe(process.cwd()); + expect(result.groupPolicy).toBe('disabled'); + expect(result.groups).toEqual({}); + }); + + it('resolves env vars in token, clientId, clientSecret', () => { + process.env.TEST_TOKEN = 'tok123'; + process.env.TEST_CID = 'cid456'; + process.env.TEST_SEC = 'sec789'; + + const result = parseChannelConfig('bot', { + type: 'bare', + token: '$TEST_TOKEN', + clientId: '$TEST_CID', + clientSecret: '$TEST_SEC', + }); + + expect(result.token).toBe('tok123'); + expect(result.clientId).toBe('cid456'); + expect(result.clientSecret).toBe('sec789'); + + delete process.env.TEST_TOKEN; + delete process.env.TEST_CID; + delete process.env.TEST_SEC; + }); + + it('preserves explicit config values over defaults', () => { + const result = parseChannelConfig('bot', { + type: 'bare', + token: 'literal-tok', + senderPolicy: 'open', + allowedUsers: ['alice'], + sessionScope: 'thread', + cwd: '/custom', + approvalMode: 'auto', + instructions: 'Be helpful', + model: 'qwen-coder', + groupPolicy: 'open', + groups: { g1: { mentionKeywords: ['@bot'] } }, + }); + + expect(result.token).toBe('literal-tok'); + expect(result.senderPolicy).toBe('open'); + expect(result.allowedUsers).toEqual(['alice']); + expect(result.sessionScope).toBe('thread'); + expect(result.cwd).toBe('/custom'); + expect(result.approvalMode).toBe('auto'); + expect(result.instructions).toBe('Be helpful'); + expect(result.model).toBe('qwen-coder'); + expect(result.groupPolicy).toBe('open'); + expect(result.groups).toEqual({ g1: { mentionKeywords: ['@bot'] } }); + }); + + it('spreads extra fields from raw config', () => { + const result = parseChannelConfig('bot', { + type: 'bare', + customField: 42, + }); + expect((result as Record)['customField']).toBe(42); + }); +}); diff --git a/packages/cli/src/commands/channel/pidfile.test.ts b/packages/cli/src/commands/channel/pidfile.test.ts new file mode 100644 index 000000000..6e0d0398e --- /dev/null +++ b/packages/cli/src/commands/channel/pidfile.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// vi.hoisted runs before vi.mock hoisting, so fsStore is available in the factory +const fsStore = vi.hoisted(() => { + const store: Record = {}; + return store; +}); + +vi.mock('node:fs', () => { + const mock = { + existsSync: (p: string) => p in fsStore, + readFileSync: (p: string) => { + if (!(p in fsStore)) throw new Error('ENOENT'); + return fsStore[p]; + }, + writeFileSync: (p: string, data: string) => { + fsStore[p] = data; + }, + mkdirSync: () => {}, + unlinkSync: (p: string) => { + delete fsStore[p]; + }, + }; + return { ...mock, default: mock }; +}); + +import { + readServiceInfo, + writeServiceInfo, + removeServiceInfo, + signalService, + waitForExit, +} from './pidfile.js'; + +// We need to mock process.kill for isProcessAlive / signalService +const originalKill = process.kill; + +function getPidFilePath() { + return join(homedir(), '.qwen', 'channels', 'service.pid'); +} + +beforeEach(() => { + for (const k of Object.keys(fsStore)) delete fsStore[k]; +}); + +afterEach(() => { + process.kill = originalKill; +}); + +describe('writeServiceInfo + readServiceInfo', () => { + it('writes and reads back service info for a live process', () => { + // Mock process.kill(pid, 0) to indicate alive + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + + writeServiceInfo(['telegram', 'dingtalk']); + const info = readServiceInfo(); + + expect(info).not.toBeNull(); + expect(info!.pid).toBe(process.pid); + expect(info!.channels).toEqual(['telegram', 'dingtalk']); + expect(info!.startedAt).toBeTruthy(); + }); + + it('returns null when no PID file exists', () => { + const info = readServiceInfo(); + expect(info).toBeNull(); + }); + + it('cleans up and returns null for corrupt PID file', () => { + const filePath = getPidFilePath(); + fsStore[filePath] = 'not-json!!!'; + + const info = readServiceInfo(); + expect(info).toBeNull(); + // File should be cleaned up + expect(filePath in fsStore).toBe(false); + }); + + it('cleans up and returns null for stale PID (dead process)', () => { + // First write with alive process + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + writeServiceInfo(['telegram']); + + // Now simulate dead process + + process.kill = vi.fn(() => { + throw new Error('ESRCH'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + const info = readServiceInfo(); + expect(info).toBeNull(); + }); +}); + +describe('removeServiceInfo', () => { + it('removes existing PID file', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + writeServiceInfo(['test']); + removeServiceInfo(); + + const info = readServiceInfo(); + expect(info).toBeNull(); + }); + + it('is a no-op when no PID file exists', () => { + expect(() => removeServiceInfo()).not.toThrow(); + }); +}); + +describe('signalService', () => { + it('returns true when signal is delivered', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + expect(signalService(1234, 'SIGTERM')).toBe(true); + expect(process.kill).toHaveBeenCalledWith(1234, 'SIGTERM'); + }); + + it('returns false when process is not found', () => { + + process.kill = vi.fn(() => { + throw new Error('ESRCH'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + expect(signalService(9999)).toBe(false); + }); + + it('defaults to SIGTERM', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + signalService(1234); + expect(process.kill).toHaveBeenCalledWith(1234, 'SIGTERM'); + }); +}); + +describe('waitForExit', () => { + it('returns true immediately if process is already dead', async () => { + + process.kill = vi.fn(() => { + throw new Error('ESRCH'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + const result = await waitForExit(9999, 1000, 50); + expect(result).toBe(true); + }); + + it('returns true when process dies within timeout', async () => { + let alive = true; + + process.kill = vi.fn(() => { + if (!alive) throw new Error('ESRCH'); + return true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + // Kill after 100ms + setTimeout(() => { + alive = false; + }, 100); + + const result = await waitForExit(1234, 2000, 50); + expect(result).toBe(true); + }); + + it('returns false on timeout when process stays alive', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + + const result = await waitForExit(1234, 150, 50); + expect(result).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 88cded8b8..339420a56 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ 'packages/core', 'packages/vscode-ide-companion', 'packages/sdk-typescript', + 'packages/channels/base', + 'packages/channels/dingtalk', + 'packages/channels/telegram', + 'packages/channels/weixin', 'integration-tests', 'scripts', ],