@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.
253 lines (206 loc) • 8.21 kB
text/typescript
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('comlink', () => ({
expose: vi.fn(),
}));
describe('PythonWorker', () => {
const mockPyodide = {
FS: {
mkdirTree: vi.fn(),
chdir: vi.fn(),
writeFile: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
},
loadPackage: vi.fn(),
pyimport: vi.fn(),
loadPackagesFromImports: vi.fn(),
setStdout: vi.fn(),
setStderr: vi.fn(),
runPythonAsync: vi.fn(),
loadedPackages: {},
};
const mockMicropip = {
set_index_urls: vi.fn(),
install: vi.fn(),
};
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
// Setup minimal global mocks
vi.stubGlobal('importScripts', vi.fn());
vi.stubGlobal('loadPyodide', vi.fn().mockResolvedValue(mockPyodide));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
}),
);
mockPyodide.pyimport.mockReturnValue(mockMicropip);
mockPyodide.loadedPackages = {};
});
const importWorker = async () => {
const { PythonWorker } = await import('../worker');
return { PythonWorker };
};
describe('constructor', () => {
it('should initialize with default options', () => {
return importWorker().then(({ PythonWorker }) => {
const worker = new PythonWorker({});
expect(worker.pypiIndexUrl).toBe('PYPI');
expect(worker.pyodideIndexUrl).toBe('https://cdn.jsdelivr.net/pyodide/v0.28.2/full');
expect(worker.uploadedFiles).toEqual([]);
});
});
it('should initialize with custom options', () => {
const options = {
pyodideIndexUrl: 'https://test.cdn.com/pyodide',
pypiIndexUrl: 'https://test.pypi.org',
};
return importWorker().then(({ PythonWorker }) => {
const worker = new PythonWorker(options);
expect(worker.pypiIndexUrl).toBe('https://test.pypi.org');
expect(worker.pyodideIndexUrl).toBe('https://test.cdn.com/pyodide');
});
});
it('should call importScripts with pyodide.js', () => {
return importWorker().then(({ PythonWorker }) => {
new PythonWorker({});
expect(globalThis.importScripts).toHaveBeenCalledWith(
expect.stringContaining('/pyodide.js'),
);
});
});
});
describe('pyodide getter', () => {
it('should throw error when pyodide is not initialized', () => {
return importWorker().then(({ PythonWorker }) => {
const worker = new PythonWorker({});
expect(() => worker.pyodide).toThrow('Python interpreter not initialized');
});
});
it('should return pyodide when initialized', async () => {
const { PythonWorker } = await importWorker();
const worker = new PythonWorker({});
await worker.init();
expect(worker.pyodide).toBe(mockPyodide);
});
});
describe('init', () => {
it('should initialize pyodide and setup filesystem', async () => {
const { PythonWorker } = await importWorker();
const worker = new PythonWorker({
pyodideIndexUrl: 'https://test.cdn.com/pyodide',
});
await worker.init();
expect(globalThis.loadPyodide).toHaveBeenCalledWith({
indexURL: 'https://test.cdn.com/pyodide',
});
expect(mockPyodide.FS.mkdirTree).toHaveBeenCalledWith('/mnt/data');
expect(mockPyodide.FS.chdir).toHaveBeenCalledWith('/mnt/data');
});
});
describe('file operations', () => {
let worker: any;
beforeEach(async () => {
const { PythonWorker } = await importWorker();
worker = new PythonWorker({});
await worker.init();
});
it('should upload files correctly', async () => {
const mockFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
await worker.uploadFiles([mockFile]);
expect(mockPyodide.FS.writeFile).toHaveBeenCalledWith(
'/mnt/data/test.txt',
expect.any(Uint8Array),
);
expect(worker.uploadedFiles).toContain(mockFile);
});
it('should upload files with absolute path as-is', async () => {
const absFile = new File([Uint8Array.from([1, 2])], '/abs.txt');
await worker.uploadFiles([absFile]);
expect(mockPyodide.FS.writeFile).toHaveBeenCalledWith('/abs.txt', expect.any(Uint8Array));
});
it('should download new files from filesystem', async () => {
const mockFileContent = new Uint8Array([1, 2, 3, 4]);
mockPyodide.FS.readdir.mockReturnValue(['.', '..', 'output.txt']);
(mockPyodide.FS as any).readFile.mockReturnValue(mockFileContent);
const files = await worker.downloadFiles();
expect(files).toHaveLength(1);
expect(files[0].name).toBe('/mnt/data/output.txt');
});
it('should skip identical files in download (dedup)', async () => {
const same = new File([Uint8Array.from([7, 8])], 'same.txt');
await worker.uploadFiles([same]);
mockPyodide.FS.readdir.mockReturnValue(['.', '..', 'same.txt']);
(mockPyodide.FS as any).readFile.mockReturnValue(Uint8Array.from([7, 8]));
const files = await worker.downloadFiles();
expect(files).toHaveLength(0);
});
});
describe('runPython', () => {
let worker: any;
beforeEach(async () => {
const { PythonWorker } = await importWorker();
worker = new PythonWorker({});
await worker.init();
});
it('should execute python code successfully', async () => {
const code = 'print("Hello, World!")';
const expectedResult = 'Hello, World!';
mockPyodide.runPythonAsync.mockResolvedValue(expectedResult);
const result = await worker.runPython(code);
expect(result.success).toBe(true);
expect(result.result).toBe(expectedResult);
expect(mockPyodide.runPythonAsync).toHaveBeenCalledWith(code);
});
it('should call loadPackagesFromImports with code', async () => {
const code = 'print("x")';
mockPyodide.runPythonAsync.mockResolvedValue('x');
await worker.runPython(code);
expect(mockPyodide.loadPackagesFromImports).toHaveBeenCalledWith(code);
});
it('should handle python execution errors', async () => {
const error = new Error('SyntaxError: invalid syntax');
mockPyodide.runPythonAsync.mockRejectedValue(error);
const result = await worker.runPython('invalid code');
expect(result.success).toBe(false);
expect(result.output).toContainEqual({
data: 'SyntaxError: invalid syntax',
type: 'stderr',
});
});
it('should install packages using micropip', async () => {
const packages = ['numpy', 'pandas'];
await worker.installPackages(packages);
expect(mockPyodide.loadPackage).toHaveBeenCalledWith('micropip');
expect(mockMicropip.set_index_urls).toHaveBeenCalledWith([worker.pypiIndexUrl, 'PYPI']);
expect(mockMicropip.install).toHaveBeenCalledWith(packages);
});
it('should patch matplotlib when loaded', async () => {
mockPyodide.loadedPackages = { matplotlib: true } as any;
mockPyodide.runPythonAsync.mockResolvedValueOnce(undefined).mockResolvedValueOnce('ok');
const res = await worker.runPython('print(1)');
expect(res.success).toBe(true);
expect(mockPyodide.runPythonAsync).toHaveBeenNthCalledWith(
1,
expect.stringContaining('patch_matplotlib()'),
);
});
it('should write fonts into truetype directory before run', async () => {
mockPyodide.runPythonAsync.mockResolvedValue('ok');
await worker.runPython('print(1)');
expect(mockPyodide.FS.mkdirTree).toHaveBeenCalledWith('/usr/share/fonts/truetype');
expect(mockPyodide.FS.writeFile).toHaveBeenCalledWith(
expect.stringContaining('/usr/share/fonts/truetype/STSong.ttf'),
expect.any(Uint8Array),
);
});
it('should stringify non-string result', async () => {
mockPyodide.runPythonAsync.mockResolvedValue({ toString: () => '42' });
const r = await worker.runPython('1+41');
expect(r.success).toBe(true);
expect(r.result).toBe('42');
});
});
});