mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
test(channels): add comprehensive test suites for channel adapters
- Add ChannelBase, GroupGate, SenderGate, SessionRouter tests - Add DingTalk markdown utility tests - Add Weixin media and send helper tests - Add CLI channel config-utils and pidfile tests - Configure vitest for all channel packages - Exclude test files from TypeScript build Tests cover attachment handling, block streaming, gating policies, session routing, markdown conversion, config parsing, and service management. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
39103eea5f
commit
d84675e86f
17 changed files with 1479 additions and 3 deletions
397
packages/channels/base/src/ChannelBase.test.ts
Normal file
397
packages/channels/base/src/ChannelBase.test.ts
Normal file
|
|
@ -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> = {}): ChannelConfig {
|
||||
return {
|
||||
type: 'test',
|
||||
token: 'tok',
|
||||
senderPolicy: 'open',
|
||||
allowedUsers: [],
|
||||
sessionScope: 'user',
|
||||
cwd: '/tmp',
|
||||
groupPolicy: 'disabled',
|
||||
groups: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(overrides: Partial<Envelope> = {}): 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<ChannelConfig> = {},
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
115
packages/channels/base/src/GroupGate.test.ts
Normal file
115
packages/channels/base/src/GroupGate.test.ts
Normal file
|
|
@ -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> = {}): 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/channels/base/src/SenderGate.test.ts
Normal file
106
packages/channels/base/src/SenderGate.test.ts
Normal file
|
|
@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
193
packages/channels/base/src/SessionRouter.test.ts
Normal file
193
packages/channels/base/src/SessionRouter.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
8
packages/channels/base/vitest.config.ts
Normal file
8
packages/channels/base/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
138
packages/channels/dingtalk/src/markdown.test.ts
Normal file
138
packages/channels/dingtalk/src/markdown.test.ts
Normal file
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,6 @@
|
|||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
|
||||
"references": [{ "path": "../base" }]
|
||||
}
|
||||
|
|
|
|||
8
packages/channels/dingtalk/vitest.config.ts
Normal file
8
packages/channels/dingtalk/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -5,6 +5,6 @@
|
|||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
|
||||
"references": [{ "path": "../base" }]
|
||||
}
|
||||
|
|
|
|||
8
packages/channels/telegram/vitest.config.ts
Normal file
8
packages/channels/telegram/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
91
packages/channels/weixin/src/media.test.ts
Normal file
91
packages/channels/weixin/src/media.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
82
packages/channels/weixin/src/send.test.ts
Normal file
82
packages/channels/weixin/src/send.test.ts
Normal file
|
|
@ -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 `` becomes `!alt (url)` rather than `[alt]`
|
||||
const result = markdownToPlainText('');
|
||||
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('`');
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,6 @@
|
|||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
|
||||
"references": [{ "path": "../base" }]
|
||||
}
|
||||
|
|
|
|||
8
packages/channels/weixin/vitest.config.ts
Normal file
8
packages/channels/weixin/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
140
packages/cli/src/commands/channel/config-utils.test.ts
Normal file
140
packages/cli/src/commands/channel/config-utils.test.ts
Normal file
|
|
@ -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<string, unknown>)['customField']).toBe(42);
|
||||
});
|
||||
});
|
||||
178
packages/cli/src/commands/channel/pidfile.test.ts
Normal file
178
packages/cli/src/commands/channel/pidfile.test.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue