import { QqbotPluginRuntimeError, QqbotPluginWorkerRuntime, type QqbotPluginWorkerDriver, type QqbotPluginWorkerRequest, } from '../../../../src/modules/qqbot/plugin-platform/runtime'; import { createQqbotPluginSdk } from '../../../../src/modules/qqbot/plugin-platform/sdk'; class RecordingDriver implements QqbotPluginWorkerDriver { disposed = false; readonly requests: QqbotPluginWorkerRequest[] = []; readonly responses = new Map(); constructor(private readonly rejectTypes = new Map()) {} async dispose() { this.disposed = true; } async request(message: QqbotPluginWorkerRequest): Promise { this.requests.push(message); const rejection = this.rejectTypes.get(message.type); if (rejection) throw rejection; if (this.responses.has(message.type)) { return this.responses.get(message.type); } return { ok: true, type: message.type, }; } } const createRuntime = (driver = new RecordingDriver()) => { const runtime = new QqbotPluginWorkerRuntime(driver, { defaultTimeoutMs: 50, installationId: 'install-1', pluginKey: 'demo-plugin', }); return { driver, runtime }; }; describe('QQBot plugin worker runtime', () => { it('sends lifecycle and execution RPC messages with correlation IDs and safe input summaries', async () => { const { driver, runtime } = createRuntime(); driver.responses.set('executeOperation', { replyText: 'ok' }); driver.responses.set('handleEvent', { handled: true }); await runtime.load({ entry: 'src/index.ts', pluginKey: 'demo-plugin', version: '0.1.0', }); await runtime.activate(); await expect( runtime.executeOperation({ input: { accessToken: 'raw-secret', text: 'hello', }, operationId: 'op-1', operationKey: 'demo-plugin.echo', timeoutMs: 30, }), ).resolves.toEqual({ replyText: 'ok' }); await expect( runtime.handleEvent({ event: { message: 'hello', rawEvent: { token: 'another-secret', }, }, eventKey: 'demo-plugin.message', }), ).resolves.toEqual({ handled: true }); await runtime.health(); await runtime.deactivate(); await runtime.dispose(); expect(driver.requests.map((request) => request.type)).toEqual([ 'load', 'activate', 'executeOperation', 'handleEvent', 'health', 'deactivate', 'dispose', ]); expect(driver.requests.every((request) => request.correlationId)).toBe( true, ); expect(driver.requests[2]).toMatchObject({ operationId: 'op-1', operationKey: 'demo-plugin.echo', safeInputSummary: { fieldCount: 2, keys: ['accessToken', 'text'], }, timeoutMs: 30, type: 'executeOperation', }); expect(JSON.stringify(driver.requests[2].safeInputSummary)).not.toContain( 'raw-secret', ); expect(JSON.stringify(driver.requests[3].safeInputSummary)).not.toContain( 'another-secret', ); expect(driver.disposed).toBe(true); }); it('isolates worker crashes as plugin runtime events without throwing raw errors', async () => { const { runtime } = createRuntime( new RecordingDriver( new Map([['executeOperation', new Error('worker crashed')]]), ), ); await expect( runtime.executeOperation({ input: { text: 'boom' }, operationId: 'op-crash', operationKey: 'demo-plugin.echo', }), ).rejects.toMatchObject({ code: 'PLUGIN_WORKER_CRASH', pluginKey: 'demo-plugin', }); expect(runtime.status).toBe('failed'); expect(runtime.listRuntimeEvents()).toEqual([ expect.objectContaining({ eventType: 'worker-crash', level: 'error', safeSummary: expect.objectContaining({ message: 'worker crashed', operationId: 'op-crash', }), }), ]); }); it('times out slow worker calls and disposes the driver boundary', async () => { const driver: QqbotPluginWorkerDriver = { dispose: jest.fn(async () => undefined), request: jest.fn( () => new Promise((resolve) => setTimeout(resolve, 1000)), ), }; const runtime = new QqbotPluginWorkerRuntime(driver, { defaultTimeoutMs: 5, installationId: 'install-timeout', pluginKey: 'demo-plugin', }); await expect( runtime.executeOperation({ input: { text: 'slow' }, operationId: 'op-timeout', operationKey: 'demo-plugin.echo', }), ).rejects.toBeInstanceOf(QqbotPluginRuntimeError); await expect( runtime.executeOperation({ input: { text: 'slow' }, operationId: 'op-timeout-2', operationKey: 'demo-plugin.echo', }), ).rejects.toMatchObject({ code: 'PLUGIN_WORKER_TIMEOUT', }); expect(driver.dispose).toHaveBeenCalled(); expect(runtime.status).toBe('failed'); }); }); describe('QQBot plugin SDK contract', () => { it('exposes only host-controlled capabilities to plugin code', () => { const sdk = createQqbotPluginSdk({ assets: { readAsset: jest.fn(), }, config: { getConfig: jest.fn(), setConfig: jest.fn(), }, eventContext: { eventKey: 'demo-plugin.message', }, events: { emitRuntimeEvent: jest.fn(), }, http: { request: jest.fn(), }, operationContext: { operationKey: 'demo-plugin.echo', }, sendQueue: { sendMessage: jest.fn(), }, storage: { get: jest.fn(), set: jest.fn(), }, }); expect(Object.keys(sdk).sort()).toEqual([ 'assets', 'config', 'eventContext', 'events', 'http', 'operationContext', 'sendQueue', 'storage', ]); expect('env' in sdk).toBe(false); expect('fs' in sdk).toBe(false); expect('nest' in sdk).toBe(false); expect('repository' in sdk).toBe(false); }); });