@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
382 lines (289 loc) • 12.5 kB
text/typescript
import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { ProcessorError } from '../../types';
import { BaseProcessor } from '../BaseProcessor';
class TestProcessor extends BaseProcessor {
readonly name = 'TestProcessor';
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
return this.cloneContext(context);
}
}
class AbortingProcessor extends BaseProcessor {
readonly name = 'AbortingProcessor';
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
return this.abort(context, 'test abort reason');
}
}
class InvalidOutputProcessor extends BaseProcessor {
readonly name = 'InvalidOutputProcessor';
protected async doProcess(_context: PipelineContext): Promise<PipelineContext> {
return {} as PipelineContext;
}
}
class ErrorThrowingProcessor extends BaseProcessor {
readonly name = 'ErrorThrowingProcessor';
protected async doProcess(_context: PipelineContext): Promise<PipelineContext> {
throw new Error('test error');
}
}
class MessageCheckingProcessor extends BaseProcessor {
readonly name = 'MessageCheckingProcessor';
public lastEmptyCheck: boolean | null = null;
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const cloned = this.cloneContext(context);
// Test isEmptyMessage
if (cloned.messages.length > 0) {
const content = cloned.messages[0].content;
this.lastEmptyCheck = this.isEmptyMessage(typeof content === 'string' ? content : undefined);
}
return this.markAsExecuted(cloned);
}
}
describe('BaseProcessor', () => {
const createContext = (messages: any[] = []): PipelineContext => ({
initialState: {
messages: [],
model: 'test-model',
provider: 'test-provider',
},
isAborted: false,
messages,
metadata: {
maxTokens: 4000,
model: 'test-model',
},
});
describe('constructor', () => {
it('should initialize with options', () => {
const processor = new TestProcessor({ debug: true });
expect(processor.name).toBe('TestProcessor');
});
it('should initialize without options', () => {
const processor = new TestProcessor();
expect(processor.name).toBe('TestProcessor');
});
});
describe('process', () => {
it('should process valid context', async () => {
const processor = new TestProcessor();
const context = createContext([{ content: 'test', role: 'user' }]);
const result = await processor.process(context);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toBe('test');
});
it('should validate input context', async () => {
const processor = new TestProcessor();
const invalidContext = { messages: 'not an array' } as any;
await expect(processor.process(invalidContext)).rejects.toThrow(ProcessorError);
await expect(processor.process(invalidContext)).rejects.toThrow('无效的上下文');
});
it('should validate output context', async () => {
const processor = new InvalidOutputProcessor();
const context = createContext([{ content: 'test', role: 'user' }]);
await expect(processor.process(context)).rejects.toThrow(ProcessorError);
await expect(processor.process(context)).rejects.toThrow('无效的输出上下文');
});
it('should wrap errors in ProcessorError', async () => {
const processor = new ErrorThrowingProcessor();
const context = createContext();
await expect(processor.process(context)).rejects.toThrow(ProcessorError);
try {
await processor.process(context);
} catch (error) {
expect(error).toBeInstanceOf(ProcessorError);
if (error instanceof ProcessorError) {
expect(error.processorName).toBe('ErrorThrowingProcessor');
expect(error.originalError).toBeInstanceOf(Error);
expect(error.originalError?.message).toBe('test error');
}
}
});
it('should handle non-Error throws', async () => {
class StringThrowingProcessor extends BaseProcessor {
readonly name = 'StringThrowingProcessor';
protected async doProcess(_context: PipelineContext): Promise<PipelineContext> {
throw 'string error';
}
}
const processor = new StringThrowingProcessor();
const context = createContext();
await expect(processor.process(context)).rejects.toThrow(ProcessorError);
});
});
describe('validateInput', () => {
it('should reject null context', async () => {
const processor = new TestProcessor();
await expect(processor.process(null as any)).rejects.toThrow('无效的上下文');
});
it('should reject undefined context', async () => {
const processor = new TestProcessor();
await expect(processor.process(undefined as any)).rejects.toThrow('无效的上下文');
});
it('should reject context without messages array', async () => {
const processor = new TestProcessor();
const invalidContext = {
initialState: {},
isAborted: false,
metadata: {},
} as any;
await expect(processor.process(invalidContext)).rejects.toThrow('无效的上下文');
});
});
describe('cloneContext', () => {
it('should create shallow copy of context', async () => {
const processor = new TestProcessor();
const context = createContext([
{ content: 'test1', role: 'user' },
{ content: 'test2', role: 'assistant' },
]);
const result = await processor.process(context);
expect(result).not.toBe(context);
expect(result.messages).not.toBe(context.messages);
expect(result.metadata).not.toBe(context.metadata);
expect(result.initialState).toBe(context.initialState); // initialState is readonly, so it's shared
});
it('should preserve all context properties', async () => {
const processor = new TestProcessor();
const context = createContext([{ content: 'test', role: 'user' }]);
context.metadata.customField = 'customValue';
const result = await processor.process(context);
expect(result.isAborted).toBe(context.isAborted);
expect(result.metadata.model).toBe(context.metadata.model);
expect(result.metadata.maxTokens).toBe(context.metadata.maxTokens);
expect(result.metadata.customField).toBe('customValue');
});
});
describe('abort', () => {
it('should set isAborted to true', async () => {
const processor = new AbortingProcessor();
const context = createContext();
const result = await processor.process(context);
expect(result.isAborted).toBe(true);
});
it('should set abort reason', async () => {
const processor = new AbortingProcessor();
const context = createContext();
const result = await processor.process(context);
expect(result.abortReason).toBe('test abort reason');
});
it('should preserve other context properties', async () => {
const processor = new AbortingProcessor();
const context = createContext([{ content: 'test', role: 'user' }]);
const result = await processor.process(context);
expect(result.messages).toEqual(context.messages);
expect(result.metadata).toEqual(context.metadata);
});
});
describe('isEmptyMessage', () => {
it('should return true for empty string', async () => {
const processor = new MessageCheckingProcessor();
const context = createContext([{ content: '', role: 'user' }]);
await processor.process(context);
expect(processor.lastEmptyCheck).toBe(true);
});
it('should return true for whitespace-only string', async () => {
const processor = new MessageCheckingProcessor();
const context = createContext([{ content: ' \n\t ', role: 'user' }]);
await processor.process(context);
expect(processor.lastEmptyCheck).toBe(true);
});
it('should return true for null', async () => {
const processor = new MessageCheckingProcessor();
const context = createContext([{ content: null, role: 'user' }]);
await processor.process(context);
expect(processor.lastEmptyCheck).toBe(true);
});
it('should return true for undefined', async () => {
const processor = new MessageCheckingProcessor();
const context = createContext([{ content: undefined, role: 'user' }]);
await processor.process(context);
expect(processor.lastEmptyCheck).toBe(true);
});
it('should return false for non-empty string', async () => {
const processor = new MessageCheckingProcessor();
const context = createContext([{ content: 'hello', role: 'user' }]);
await processor.process(context);
expect(processor.lastEmptyCheck).toBe(false);
});
it('should return false for string with content after trim', async () => {
const processor = new MessageCheckingProcessor();
const context = createContext([{ content: ' hello ', role: 'user' }]);
await processor.process(context);
expect(processor.lastEmptyCheck).toBe(false);
});
});
describe('markAsExecuted', () => {
it('should return context unchanged by default', async () => {
const processor = new MessageCheckingProcessor();
const context = createContext([{ content: 'test', role: 'user' }]);
const result = await processor.process(context);
// Since markAsExecuted is called in doProcess, verify context is returned
expect(result.messages).toEqual(context.messages);
});
it('should be callable multiple times', async () => {
class MultiMarkProcessor extends BaseProcessor {
readonly name = 'MultiMarkProcessor';
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
let result = this.cloneContext(context);
result = this.markAsExecuted(result);
result = this.markAsExecuted(result);
return result;
}
}
const processor = new MultiMarkProcessor();
const context = createContext();
const result = await processor.process(context);
expect(result).toBeDefined();
expect(result.messages).toEqual([]);
});
});
describe('integration scenarios', () => {
it('should handle complex processing pipeline', async () => {
class ComplexProcessor extends BaseProcessor {
readonly name = 'ComplexProcessor';
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const cloned = this.cloneContext(context);
// Check first message
if (cloned.messages.length > 0) {
const firstContent = cloned.messages[0].content;
if (this.isEmptyMessage(typeof firstContent === 'string' ? firstContent : undefined)) {
return this.abort(cloned, 'First message is empty');
}
}
// Process messages
cloned.messages = cloned.messages.map((msg) => ({
...msg,
content: typeof msg.content === 'string' ? msg.content.toUpperCase() : msg.content,
}));
return this.markAsExecuted(cloned);
}
}
const processor = new ComplexProcessor();
const context = createContext([
{ content: 'hello', role: 'user' },
{ content: 'world', role: 'assistant' },
]);
const result = await processor.process(context);
expect(result.isAborted).toBe(false);
expect(result.messages[0].content).toBe('HELLO');
expect(result.messages[1].content).toBe('WORLD');
});
it('should abort when condition met', async () => {
class ConditionalAbortProcessor extends BaseProcessor {
readonly name = 'ConditionalAbortProcessor';
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const cloned = this.cloneContext(context);
if (cloned.messages.length === 0) {
return this.abort(cloned, 'No messages to process');
}
return cloned;
}
}
const processor = new ConditionalAbortProcessor();
const context = createContext([]);
const result = await processor.process(context);
expect(result.isAborted).toBe(true);
expect(result.abortReason).toBe('No messages to process');
});
});
});