mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-04 22:51:08 +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
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue