227 lines
6.0 KiB
TypeScript
227 lines
6.0 KiB
TypeScript
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<string, unknown>();
|
|
|
|
constructor(private readonly rejectTypes = new Map<string, Error>()) {}
|
|
|
|
async dispose() {
|
|
this.disposed = true;
|
|
}
|
|
|
|
async request(message: QqbotPluginWorkerRequest): Promise<unknown> {
|
|
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);
|
|
});
|
|
});
|