kt-template-online-api/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts

1530 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { MODULE_METADATA } from '@nestjs/common/constants';
import { ConfigService } from '@nestjs/config';
import { QQBOT_PLUGIN_RUNTIME_FACTORY } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service';
import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service';
import { QqbotEventPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service';
import { QqbotPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service';
import { QqbotPluginPlatformModule } from '../../../../src/modules/qqbot/plugin-platform/plugin-platform.module';
import { QqbotBuiltinPluginPackageLoaderService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service';
import { QqbotBuiltinPluginWorkerRuntimeFactoryService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime';
const repoRoot = join(__dirname, '../../../..');
const readSource = (relativePath: string) =>
readFileSync(join(repoRoot, relativePath), 'utf8');
const collectSourceFiles = (relativePath: string): string[] => {
const absolutePath = join(repoRoot, relativePath);
if (!existsSync(absolutePath)) return [];
const stat = statSync(absolutePath);
if (stat.isFile()) return [absolutePath];
return readdirSync(absolutePath, { withFileTypes: true }).flatMap((entry) =>
collectSourceFiles(join(relativePath, entry.name)),
);
};
describe('QQBot plugin platform lifecycle runtime contract', () => {
it('registers the default worker runtime factory in the Nest module', () => {
const providers = Reflect.getMetadata(
MODULE_METADATA.PROVIDERS,
QqbotPluginPlatformModule,
) as unknown[];
expect(providers).toEqual(
expect.arrayContaining([
QqbotBuiltinPluginWorkerRuntimeFactoryService,
expect.objectContaining({
provide: QQBOT_PLUGIN_RUNTIME_FACTORY,
useExisting: QqbotBuiltinPluginWorkerRuntimeFactoryService,
}),
]),
);
});
it('keeps the default worker runtime factory dependency visible to Nest DI', () => {
const dependencies = Reflect.getMetadata(
'design:paramtypes',
QqbotBuiltinPluginWorkerRuntimeFactoryService,
);
expect(dependencies?.[0]).toBe(QqbotBuiltinPluginPackageLoaderService);
expect(dependencies?.[1]).toBe(ConfigService);
});
it('uses a real worker-thread boundary for built-in plugin runtimes', () => {
const source = readSource(
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts',
);
expect(source).toContain('node:worker_threads');
expect(source).toContain('QqbotBuiltinPluginWorkerThreadDriver');
expect(source).not.toContain('class QqbotBuiltinPluginWorkerDriver');
expect(source).not.toContain('new QqbotBuiltinPluginWorkerDriver');
});
it('uses BullMQ queues to serialize plugin worker requests instead of ad hoc in-memory chaining', () => {
const source = [
readSource('src/modules/qqbot/plugin-platform/plugin-platform.module.ts'),
readSource(
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/bullmq-plugin-worker-request.queue.ts',
),
readSource(
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts',
),
].join('\n');
expect(source).toContain('@nestjs/bullmq');
expect(source).toContain("from 'bullmq'");
expect(source).toContain('new Queue(');
expect(source).toContain('new Worker(');
expect(source).toContain('new QueueEvents(');
expect(source).toContain('concurrency: 1');
expect(source).toContain('installation.id');
expect(source).toContain('options.installationId');
expect(source).toContain("this.queue.on('error'");
expect(source).toContain("this.queueEvents.on('error'");
expect(source).toContain("this.worker.on('error'");
expect(source).toContain('expiresAt');
expect(source).toContain('workerInstanceId');
expect(source).toContain('worker-request-expired');
expect(source).not.toContain('previous.catch(() => undefined).then');
});
it('keeps API deployment as a single plugin queue consumer during releases', () => {
const source = readSource('k8s/prod/api.yaml');
expect(source).toContain('replicas: 1');
expect(source).toContain('type: Recreate');
expect(source).not.toContain('maxSurge: 1');
expect(source).not.toContain('maxUnavailable: 0');
});
it('pulls the plugin Redis runtime image from the local registry', () => {
const source = readSource('k8s/prod/api.yaml');
expect(source).toContain(
'image: k3d-kt-registry.localhost:5000/redis:7.4-alpine',
);
expect(source).not.toContain('image: redis:7.4-alpine');
});
it('uses dedicated lifecycle use cases instead of direct status flips', () => {
const controller = readSource(
'src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts',
);
const service = readSource(
'src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts',
);
const bannedDirectStatusFlips = [
controller.includes('setInstallationStatus')
? 'controller.setInstallationStatus'
: '',
service.includes('setInstallationStatus')
? 'service.setInstallationStatus'
: '',
].filter(Boolean);
const missingLifecycleMethods = [
'enableInstallation',
'disableInstallation',
'upgradeInstallation',
'uninstallInstallation',
].filter((methodName) => !service.includes(methodName));
expect(bannedDirectStatusFlips).toEqual([]);
expect(missingLifecycleMethods).toEqual([]);
});
it('activates workers and refreshes active registries during lifecycle transitions', () => {
const source = [
readSource(
'src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts',
),
readSource(
'src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/worker-runtime.ts',
),
].join('\n');
const missingRuntimeSignals = [
'QqbotPluginWorkerRuntime',
'activate',
'deactivate',
'dispose',
'refreshActive',
'activeOperation',
'activeEvent',
].filter((signal) => !source.includes(signal));
expect(missingRuntimeSignals).toEqual([]);
});
it('exposes operation executor and event dispatcher through the platform', () => {
const source = readSource(
'src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts',
);
const missingExecutorSignals = [
'executeOperation',
'dispatchEvent',
'runtimeEventRepository',
'command log',
].filter((signal) => !source.includes(signal));
expect(missingExecutorSignals).toEqual([]);
});
it('keeps event plugin dispatch generic instead of hard-coding repeater', () => {
const source = readSource(
'src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts',
);
expect(source).toContain('registerEventPlugin');
expect(source).not.toMatch(/pluginKey\s*===\s*['"]repeater['"]/);
expect(source).not.toContain('getRepeaterPlugin');
});
it('keeps plugin-specific argument parsing inside plugin packages', () => {
const sources = collectSourceFiles(
'src/modules/qqbot/plugin-platform/application/argument',
)
.concat(
collectSourceFiles(
'src/modules/qqbot/plugin-platform/infrastructure/integration/argument',
),
)
.map((file) => readFileSync(file, 'utf8'))
.join('\n');
expect(sources).not.toMatch(/DictService/);
expect(sources).not.toMatch(/modules\/qqbot\/plugins\/ff14-market/);
expect(sources).not.toMatch(/modules\/qqbot\/plugins\/fflogs/);
expect(sources).not.toMatch(
new RegExp([`parseQqbot${'Ff14'}`, `parseQqbot${'Fflogs'}`].join('|')),
);
});
it('loads, activates, health-checks, refreshes, and disposes workers during lifecycle transitions', async () => {
const manifest = {
assets: [],
configSchema: {},
entry: 'src/index.ts',
events: [],
legacyAliases: [],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [],
permissions: [],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 5000,
workerType: 'node-worker',
},
version: '0.1.0',
};
const installation = {
id: 'installation-1',
installedPath: 'D:/plugins/demo-plugin',
pluginId: '2060000000000000002',
runtimeStatus: 'stopped',
status: 'installed',
versionId: 'version-1',
};
const version = {
id: 'version-1',
manifestJson: manifest,
packageHash: 'hash',
pluginId: 'plugin-1',
version: '0.1.0',
};
const createRepository = (findOneValue?: unknown) => ({
find: jest.fn(async () => []),
findAndCount: jest.fn(async () => [[], 0]),
findOne: jest.fn(async () => findOneValue),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const pluginRepository = createRepository({
id: installation.pluginId,
pluginKey: 'demo-plugin',
});
const versionRepository = createRepository(version);
const installationRepository = createRepository(installation);
const operationRepository = createRepository();
const eventHandlerRepository = createRepository();
const accountBindingRepository = createRepository();
const configRepository = createRepository();
const assetRepository = createRepository();
const runtimeEventRepository = createRepository();
const runtimeEventBatches = [
[],
[
{
eventType: 'worker-dispose-finished',
level: 'info',
pluginKey: 'demo-plugin',
safeSummary: {
phase: 'dispose',
},
},
],
];
const worker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
drainRuntimeEvents: jest.fn(() => runtimeEventBatches.shift() || []),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const runtimeFactory = {
create: jest.fn(() => worker),
};
const pluginRegistry = {
setPluginActive: jest.fn(),
};
const eventPluginRegistry = {
setPluginActive: jest.fn(),
};
const service = new (QqbotPluginPlatformService as any)(
pluginRepository,
versionRepository,
installationRepository,
operationRepository,
eventHandlerRepository,
accountBindingRepository,
configRepository,
assetRepository,
runtimeEventRepository,
undefined,
runtimeFactory,
pluginRegistry,
eventPluginRegistry,
) as QqbotPluginPlatformService;
await expect(
service.enableInstallation({ id: installation.id }),
).resolves.toMatchObject({
id: installation.id,
runtimeStatus: 'healthy',
status: 'enabled',
});
expect(runtimeFactory.create).toHaveBeenCalledWith(installation, version);
expect(worker.load).toHaveBeenCalledWith(manifest);
expect(worker.activate).toHaveBeenCalled();
expect(worker.health).toHaveBeenCalled();
expect(operationRepository.update).toHaveBeenCalledWith(
{ pluginId: installation.pluginId },
{ enabled: true },
);
expect(eventHandlerRepository.update).toHaveBeenCalledWith(
{ pluginId: installation.pluginId },
{ enabled: true },
);
expect(pluginRegistry.setPluginActive).toHaveBeenCalledWith(
'demo-plugin',
true,
);
expect(eventPluginRegistry.setPluginActive).toHaveBeenCalledWith(
'demo-plugin',
true,
);
expect(runtimeEventRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
eventType: 'enable-finished',
installationId: installation.id,
pluginId: installation.pluginId,
}),
);
await service.disableInstallation({ id: installation.id });
expect(pluginRegistry.setPluginActive).toHaveBeenLastCalledWith(
'demo-plugin',
false,
);
expect(eventPluginRegistry.setPluginActive).toHaveBeenLastCalledWith(
'demo-plugin',
false,
);
expect(worker.deactivate).toHaveBeenCalled();
expect(worker.dispose).toHaveBeenCalled();
expect(runtimeEventRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
eventType: 'worker-dispose-finished',
installationId: installation.id,
pluginId: installation.pluginId,
}),
);
});
it('keeps worker cleanup best-effort when stop runtime event persistence fails', async () => {
const manifest = {
assets: [],
configSchema: {},
entry: 'src/index.ts',
events: [],
legacyAliases: [],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [],
permissions: [],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 5000,
workerType: 'node-worker',
},
version: '0.1.0',
};
const installation = {
id: 'installation-cleanup',
installedPath: 'D:/plugins/demo-plugin',
pluginId: '2060000000000000003',
runtimeStatus: 'stopped',
status: 'installed',
versionId: 'version-cleanup',
};
const version = {
id: 'version-cleanup',
manifestJson: manifest,
packageHash: 'hash',
pluginId: installation.pluginId,
version: '0.1.0',
};
const createRepository = (findOneValue?: unknown) => ({
find: jest.fn(async () => []),
findAndCount: jest.fn(async () => [[], 0]),
findOne: jest.fn(async () => findOneValue),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const runtimeEventRepository = createRepository();
runtimeEventRepository.save = jest.fn(async (value) => {
if (
value &&
typeof value === 'object' &&
(value as { eventType?: string }).eventType ===
'worker-dispose-finished'
) {
throw new Error('runtime event db unavailable');
}
return value;
});
const runtimeEventBatches = [
[],
[
{
eventType: 'worker-dispose-finished',
level: 'info',
pluginKey: 'demo-plugin',
safeSummary: {
phase: 'dispose',
},
},
],
];
const worker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
drainRuntimeEvents: jest.fn(() => runtimeEventBatches.shift() || []),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const service = new (QqbotPluginPlatformService as any)(
createRepository({
id: installation.pluginId,
pluginKey: 'demo-plugin',
}),
createRepository(version),
createRepository(installation),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
runtimeEventRepository,
undefined,
{
create: jest.fn(() => worker),
},
{
setPluginActive: jest.fn(),
},
{
setPluginActive: jest.fn(),
},
) as QqbotPluginPlatformService;
await service.enableInstallation({ id: installation.id });
await expect(
service.disableInstallation({ id: installation.id }),
).resolves.toMatchObject({
id: installation.id,
runtimeStatus: 'stopped',
status: 'disabled',
});
expect(worker.dispose).toHaveBeenCalled();
expect((service as any).activeWorkers.has(installation.id)).toBe(false);
expect((service as any).activeWorkerContexts.has(installation.id)).toBe(
false,
);
});
it('routes enabled command and message executions through active worker runtimes', async () => {
const manifest = {
assets: [],
configSchema: {},
entry: 'src/index.ts',
events: [
{
eventName: 'message',
handlerName: 'handleMessage',
key: 'demo-plugin.message',
name: '消息事件',
},
],
legacyAliases: ['demoLegacy'],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [
{
handlerName: 'echo',
key: 'demo-plugin.echo',
name: 'Echo',
permissions: ['qqbot.send'],
timeoutMs: 123,
},
],
permissions: ['qqbot.send', 'qqbot.event.receive'],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 456,
workerType: 'node-worker',
},
version: '0.1.0',
};
const installation = {
id: 'installation-execute',
installedPath: 'D:/plugins/demo-plugin',
pluginId: '2060000000000000001',
runtimeStatus: 'stopped',
status: 'installed',
versionId: 'version-execute',
};
const version = {
id: 'version-execute',
manifestJson: manifest,
packageHash: 'hash',
pluginId: installation.pluginId,
version: manifest.version,
};
const createRepository = (findOneValue?: unknown) => ({
find: jest.fn(async () => []),
findAndCount: jest.fn(async () => [[], 0]),
findOne: jest.fn(async () => findOneValue),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const runtimeEventRepository = createRepository();
const runtimeEventBatches = [
[
{
eventType: 'worker-recovered',
level: 'info',
pluginKey: 'demo-plugin',
safeSummary: {
status: 'active',
},
},
],
[],
];
const worker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
drainRuntimeEvents: jest.fn(() => runtimeEventBatches.shift() || []),
executeOperation: jest.fn(async () => ({ replyText: 'worker-ok' })),
handleEvent: jest.fn(async () => true),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const runtimeFactory = {
create: jest.fn(() => worker),
};
const argumentParser = {
normalizeInput: jest.fn(async () => ({ text: 'normalized' })),
};
const fallbackCommandRegistry = {
execute: jest.fn(),
listOperations: jest.fn(() => [
{
key: 'demo-plugin.echo',
name: 'Echo',
pluginKey: 'demo-plugin',
triggerMode: 'command',
},
]),
setPluginActive: jest.fn(),
};
const fallbackEventRegistry = {
dispatchMessage: jest.fn(),
listOperations: jest.fn(() => [
{
key: 'message',
name: '消息事件',
pluginKey: 'demo-plugin',
triggerMode: 'event',
},
]),
setPluginActive: jest.fn(),
};
const service = new (QqbotPluginPlatformService as any)(
createRepository({ id: installation.pluginId, pluginKey: 'demo-plugin' }),
createRepository(version),
createRepository(installation),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
runtimeEventRepository,
argumentParser,
runtimeFactory,
fallbackCommandRegistry,
fallbackEventRegistry,
) as QqbotPluginPlatformService;
await service.enableInstallation({ id: installation.id });
await expect(
service.executeOperation({
input: { text: 'raw' },
operationKey: 'demo-plugin.echo',
pluginKey: 'demoLegacy',
}),
).resolves.toEqual({ replyText: 'worker-ok' });
await expect(
service.dispatchEvent({
eventKey: 'message',
message: {
eventTime: new Date(),
messageId: 'msg-1',
messageText: 'hello',
messageType: 'group',
rawEvent: {},
rawMessage: 'hello',
selfId: '10000',
targetId: '20000',
userId: '30000',
},
}),
).resolves.toBe(true);
expect(worker.executeOperation).toHaveBeenCalledWith({
input: { text: 'normalized' },
operationId: 'demo-plugin.echo',
operationKey: 'demo-plugin.echo',
timeoutMs: 123,
});
expect(worker.handleEvent).toHaveBeenCalledWith({
event: expect.objectContaining({
messageId: 'msg-1',
}),
eventKey: 'message',
timeoutMs: 456,
});
expect(argumentParser.normalizeInput).toHaveBeenCalledWith(
expect.objectContaining({
operationKey: 'demo-plugin.echo',
pluginKey: 'demoLegacy',
}),
);
expect(fallbackCommandRegistry.execute).not.toHaveBeenCalled();
expect(fallbackEventRegistry.dispatchMessage).not.toHaveBeenCalled();
expect(runtimeEventRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
eventType: 'worker-recovered',
installationId: installation.id,
level: 'info',
pluginId: installation.pluginId,
safeSummary: {
status: 'active',
},
}),
);
});
it('does not execute workers for persisted disabled built-in plugins', async () => {
const manifest = {
assets: [],
configSchema: {},
entry: 'src/index.ts',
events: [],
legacyAliases: ['demoLegacy'],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [
{
handlerName: 'echo',
key: 'demo-plugin.echo',
name: 'Echo',
permissions: ['qqbot.send'],
timeoutMs: 123,
},
],
permissions: ['qqbot.send'],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 456,
workerType: 'node-worker',
},
version: '0.1.0',
};
const plugin = {
id: 'plugin-disabled',
pluginKey: 'demo-plugin',
};
const disabledInstallation = {
id: 'installation-disabled',
installedPath: 'D:/plugins/demo-plugin',
pluginId: plugin.id,
runtimeStatus: 'stopped',
status: 'disabled',
versionId: 'version-disabled',
};
const createRepository = (
rows: unknown[] = [],
findOneValue?: unknown,
) => ({
find: jest.fn(async () => rows),
findAndCount: jest.fn(async () => [rows, rows.length]),
findOne: jest.fn(async () => findOneValue || null),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const worker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
executeOperation: jest.fn(async () => ({ replyText: 'should-not-run' })),
handleEvent: jest.fn(async () => true),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const runtimeFactory = {
create: jest.fn(() => worker),
};
const service = new (QqbotPluginPlatformService as any)(
createRepository([plugin], plugin),
createRepository(),
createRepository([disabledInstallation], disabledInstallation),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
undefined,
runtimeFactory,
undefined,
undefined,
undefined,
{
loadBuiltinManifests: jest.fn(() => [manifest]),
},
) as QqbotPluginPlatformService;
await service.onModuleInit();
expect(runtimeFactory.create).not.toHaveBeenCalled();
expect(await service.listActiveOperations()).toEqual([]);
await expect(
service.executeOperation({
input: { text: 'raw' },
operationKey: 'demo-plugin.echo',
pluginKey: 'demoLegacy',
}),
).rejects.toMatchObject({
response: expect.objectContaining({
msg: 'QQBot 插件运行时未启用demoLegacy',
}),
});
expect(worker.executeOperation).not.toHaveBeenCalled();
});
it('clears built-in worker contexts when a persisted installation disables the same plugin', async () => {
const manifest = {
assets: [],
configSchema: {},
entry: 'src/index.ts',
events: [],
legacyAliases: ['demoLegacy'],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [
{
handlerName: 'echo',
key: 'demo-plugin.echo',
name: 'Echo',
permissions: ['qqbot.send'],
timeoutMs: 123,
},
],
permissions: ['qqbot.send'],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 456,
workerType: 'node-worker',
},
version: '0.1.0',
};
const plugin = {
id: 'plugin-enabled',
pluginKey: 'demo-plugin',
};
const installation = {
id: 'installation-enabled',
installedPath: 'D:/plugins/demo-plugin',
pluginId: plugin.id,
runtimeStatus: 'healthy',
status: 'enabled',
versionId: 'version-enabled',
};
const createRepository = (
rows: unknown[] = [],
findOneValue?: unknown,
) => ({
find: jest.fn(async () => rows),
findAndCount: jest.fn(async () => [rows, rows.length]),
findOne: jest.fn(async () => findOneValue || null),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const builtinWorker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
executeOperation: jest.fn(async () => ({ replyText: 'builtin' })),
handleEvent: jest.fn(async () => true),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const runtimeFactory = {
create: jest.fn(() => builtinWorker),
};
const service = new (QqbotPluginPlatformService as any)(
createRepository([plugin], plugin),
createRepository(),
createRepository([installation], installation),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
undefined,
runtimeFactory,
undefined,
undefined,
undefined,
{
loadBuiltinManifests: jest.fn(() => [manifest]),
},
) as QqbotPluginPlatformService;
await service.onModuleInit();
await expect(
service.executeOperation({
input: { text: 'raw' },
operationKey: 'demo-plugin.echo',
pluginKey: 'demoLegacy',
}),
).resolves.toEqual({ replyText: 'builtin' });
await service.disableInstallation({ id: installation.id });
await expect(
service.executeOperation({
input: { text: 'raw' },
operationKey: 'demo-plugin.echo',
pluginKey: 'demoLegacy',
}),
).rejects.toMatchObject({
response: expect.objectContaining({
msg: 'QQBot 插件运行时未启用demoLegacy',
}),
});
expect(builtinWorker.deactivate).toHaveBeenCalled();
expect(builtinWorker.dispose).toHaveBeenCalled();
expect(builtinWorker.executeOperation).toHaveBeenCalledTimes(1);
});
it('replaces the default built-in worker when a real installation is enabled later', async () => {
const manifest = {
assets: [],
configSchema: {},
entry: 'src/index.ts',
events: [
{
eventName: 'message',
handlerName: 'handleMessage',
key: 'demo-plugin.message',
name: '消息事件',
},
],
legacyAliases: ['demoLegacy'],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [
{
handlerName: 'echo',
key: 'demo-plugin.echo',
name: 'Echo',
permissions: ['qqbot.send'],
timeoutMs: 123,
},
],
permissions: ['qqbot.send', 'qqbot.event.receive'],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 456,
workerType: 'node-worker',
},
version: '0.1.0',
};
const plugin = {
id: 'plugin-late',
pluginKey: 'demo-plugin',
};
const installation = {
id: 'installation-late',
installedPath: 'D:/plugins/demo-plugin',
pluginId: plugin.id,
runtimeStatus: 'stopped',
status: 'installed',
versionId: 'version-late',
};
const version = {
id: 'version-late',
manifestJson: manifest,
packageHash: 'hash',
pluginId: plugin.id,
version: manifest.version,
};
const createRepository = (
rows: unknown[] = [],
findOneValue?: unknown,
) => ({
find: jest.fn(async () => rows),
findAndCount: jest.fn(async () => [rows, rows.length]),
findOne: jest.fn(async () => findOneValue || null),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const defaultWorker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
executeOperation: jest.fn(async () => ({ replyText: 'default' })),
handleEvent: jest.fn(async () => true),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const realWorker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
executeOperation: jest.fn(async () => ({ replyText: 'real' })),
handleEvent: jest.fn(async () => true),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const runtimeFactory = {
create: jest
.fn()
.mockReturnValueOnce(defaultWorker)
.mockReturnValueOnce(realWorker),
};
const service = new (QqbotPluginPlatformService as any)(
createRepository([plugin], plugin),
createRepository([], version),
createRepository([], installation),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
undefined,
runtimeFactory,
undefined,
undefined,
undefined,
{
loadBuiltinManifests: jest.fn(() => [manifest]),
},
) as QqbotPluginPlatformService;
await service.onModuleInit();
await expect(
service.executeOperation({
input: { text: 'raw' },
operationKey: 'demo-plugin.echo',
pluginKey: 'demoLegacy',
}),
).resolves.toEqual({ replyText: 'default' });
await service.enableInstallation({ id: installation.id });
await expect(
service.executeOperation({
input: { text: 'raw' },
operationKey: 'demo-plugin.echo',
pluginKey: 'demoLegacy',
}),
).resolves.toEqual({ replyText: 'real' });
expect(defaultWorker.deactivate).toHaveBeenCalled();
expect(defaultWorker.dispose).toHaveBeenCalled();
expect(realWorker.executeOperation).toHaveBeenCalledTimes(1);
const operations = await service.listActiveOperations();
expect(
operations.filter((operation) => operation.pluginKey === 'demo-plugin'),
).toHaveLength(2);
await service.dispatchEvent({
eventKey: 'message',
message: {
eventTime: new Date(),
messageId: 'msg-late',
messageText: 'hello',
messageType: 'group',
rawEvent: {},
rawMessage: 'hello',
selfId: '10000',
targetId: '20000',
userId: '30000',
},
});
expect(defaultWorker.handleEvent).not.toHaveBeenCalled();
expect(realWorker.handleEvent).toHaveBeenCalledTimes(1);
await service.disableInstallation({ id: installation.id });
expect(await service.listActiveOperations()).toEqual([]);
});
it('filters active operation pages by legacy plugin aliases', async () => {
const manifest = {
assets: [],
configSchema: {},
entry: 'src/index.ts',
events: [],
legacyAliases: ['demoLegacy'],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [
{
handlerName: 'echo',
key: 'demo-plugin.echo',
name: 'Echo',
permissions: ['qqbot.send'],
timeoutMs: 123,
},
],
permissions: ['qqbot.send'],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 456,
workerType: 'node-worker',
},
version: '0.1.0',
};
const createRepository = (findOneValue?: unknown) => ({
find: jest.fn(async () => []),
findAndCount: jest.fn(async () => [[], 0]),
findOne: jest.fn(async () => findOneValue || null),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const worker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
executeOperation: jest.fn(),
handleEvent: jest.fn(),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const service = new (QqbotPluginPlatformService as any)(
createRepository({ id: 'plugin-alias', pluginKey: 'demo-plugin' }),
createRepository({
id: 'version-alias',
manifestJson: manifest,
packageHash: 'hash',
pluginId: 'plugin-alias',
version: manifest.version,
}),
createRepository({
id: 'installation-alias',
installedPath: 'D:/plugins/demo-plugin',
pluginId: 'plugin-alias',
runtimeStatus: 'stopped',
status: 'installed',
versionId: 'version-alias',
}),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
undefined,
{
create: jest.fn(() => worker),
},
) as QqbotPluginPlatformService;
await service.enableInstallation({ id: 'installation-alias' });
await expect(
service.listOperationSummaries({ pluginKey: 'demoLegacy' }),
).resolves.toEqual([
expect.objectContaining({
key: 'demo-plugin.echo',
pluginKey: 'demo-plugin',
}),
]);
await expect(
service.pageOperationSummaries({
pageNo: 1,
pageSize: 10,
pluginKey: 'demoLegacy',
}),
).resolves.toMatchObject({
total: 1,
});
});
it('disposes the previous worker after a successful upgrade on the same installation', async () => {
const manifest = {
assets: [],
configSchema: {},
entry: 'src/index.ts',
events: [],
legacyAliases: ['demoLegacy'],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [
{
handlerName: 'echo',
key: 'demo-plugin.echo',
name: 'Echo',
permissions: ['qqbot.send'],
timeoutMs: 123,
},
],
permissions: ['qqbot.send'],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 456,
workerType: 'node-worker',
},
version: '0.1.0',
};
const installation = {
id: 'installation-upgrade-success',
installedPath: 'D:/plugins/demo-plugin',
pluginId: 'plugin-upgrade-success',
runtimeStatus: 'stopped',
status: 'installed',
versionId: 'version-upgrade-success',
};
const version = {
id: 'version-upgrade-success',
manifestJson: manifest,
packageHash: 'hash',
pluginId: installation.pluginId,
version: manifest.version,
};
const createRepository = (findOneValue?: unknown) => ({
find: jest.fn(async () => []),
findAndCount: jest.fn(async () => [[], 0]),
findOne: jest.fn(async () => findOneValue || null),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const oldWorker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
executeOperation: jest.fn(async () => ({ replyText: 'old' })),
handleEvent: jest.fn(async () => true),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const newWorker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
executeOperation: jest.fn(async () => ({ replyText: 'new' })),
handleEvent: jest.fn(async () => true),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const runtimeFactory = {
create: jest
.fn()
.mockReturnValueOnce(oldWorker)
.mockReturnValueOnce(newWorker),
};
const service = new (QqbotPluginPlatformService as any)(
createRepository({ id: installation.pluginId, pluginKey: 'demo-plugin' }),
createRepository(version),
createRepository(installation),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
undefined,
runtimeFactory,
) as QqbotPluginPlatformService;
await service.enableInstallation({ id: installation.id });
await expect(
service.executeOperation({
input: { text: 'raw' },
operationKey: 'demo-plugin.echo',
pluginKey: 'demoLegacy',
}),
).resolves.toEqual({ replyText: 'old' });
await service.upgradeInstallation({ id: installation.id });
expect(oldWorker.deactivate).toHaveBeenCalled();
expect(oldWorker.dispose).toHaveBeenCalled();
await expect(
service.executeOperation({
input: { text: 'raw' },
operationKey: 'demo-plugin.echo',
pluginKey: 'demoLegacy',
}),
).resolves.toEqual({ replyText: 'new' });
expect(oldWorker.executeOperation).toHaveBeenCalledTimes(1);
expect(newWorker.executeOperation).toHaveBeenCalledTimes(1);
});
it('blocks command execution and event dispatch for inactive plugins', async () => {
const commandPlugin = {
key: 'demo-plugin',
name: 'Demo Plugin',
operations: [
{
execute: jest.fn(async () => ({ replyText: 'ok' })),
key: 'demo.echo',
name: 'echo',
},
],
version: '0.1.0',
};
const commandRegistry = new QqbotPluginRegistryService({
loadCommandPlugins: () => [commandPlugin],
} as any);
await commandRegistry.onModuleInit();
await expect(
commandRegistry.execute('demo-plugin', 'demo.echo', {}, {}),
).resolves.toEqual({ replyText: 'ok' });
commandRegistry.setPluginActive('demo-plugin', false);
expect(commandRegistry.listOperations('demo-plugin')).toEqual([]);
await expect(
commandRegistry.execute('demo-plugin', 'demo.echo', {}, {}),
).rejects.toMatchObject({
response: expect.objectContaining({
msg: 'QQBot 插件未启用demo-plugin',
}),
});
expect(commandPlugin.operations[0].execute).toHaveBeenCalledTimes(1);
const repeaterPlugin = {
bind: jest.fn(async () => true),
getDefinition: () => ({
description: 'repeat messages',
key: 'repeater',
name: 'Repeater',
triggerType: 'message',
version: '0.1.0',
}),
getSummary: jest.fn(),
handleMessage: jest.fn(async () => true),
unbind: jest.fn(async () => true),
};
const eventRegistry = new QqbotEventPluginRegistryService(
{
allEnabled: jest.fn(async () => []),
findBySelfId: jest.fn(),
} as any,
{
loadEventPlugins: () => [repeaterPlugin],
} as any,
);
await expect(
eventRegistry.dispatchMessage({ messageType: 'group' } as any),
).resolves.toBe(true);
eventRegistry.setPluginActive('repeater', false);
expect(eventRegistry.listOperations('repeater')).toEqual([]);
await expect(
eventRegistry.dispatchMessage({ messageType: 'group' } as any),
).resolves.toBe(false);
await expect(eventRegistry.bind('repeater', '10000')).rejects.toMatchObject(
{
response: expect.objectContaining({
msg: 'QQBot 事件插件未启用repeater',
}),
},
);
expect(repeaterPlugin.handleMessage).toHaveBeenCalledTimes(1);
});
it('hydrates inactive command and event plugin keys from persisted installation state on startup', async () => {
const commandPlugin = {
key: 'demo-plugin',
name: 'Demo Plugin',
operations: [
{
execute: jest.fn(async () => ({ replyText: 'ok' })),
key: 'demo.echo',
name: 'echo',
},
],
version: '0.1.0',
};
const createRepository = (rows: unknown[] = []) => ({
find: jest.fn(async () => rows),
});
const pluginRepository = createRepository([
{ id: 'plugin-command', pluginKey: 'demo-plugin' },
{ id: 'plugin-event', pluginKey: 'repeater' },
]);
const installationRepository = createRepository([
{ pluginId: 'plugin-command', status: 'disabled' },
{ pluginId: 'plugin-event', status: 'disabled' },
]);
const commandRegistry = new (QqbotPluginRegistryService as any)(
{
loadCommandPlugins: () => [commandPlugin],
},
pluginRepository,
installationRepository,
) as QqbotPluginRegistryService;
await commandRegistry.onModuleInit();
expect(commandRegistry.listOperations('demo-plugin')).toEqual([]);
await expect(
commandRegistry.execute('demo-plugin', 'demo.echo', {}, {}),
).rejects.toMatchObject({
response: expect.objectContaining({
msg: 'QQBot 插件未启用demo-plugin',
}),
});
const repeaterPlugin = {
bind: jest.fn(async () => true),
getDefinition: () => ({
description: 'repeat messages',
key: 'repeater',
name: 'Repeater',
triggerType: 'message',
version: '0.1.0',
}),
getSummary: jest.fn(),
handleMessage: jest.fn(async () => true),
unbind: jest.fn(async () => true),
};
const eventRegistry = new (QqbotEventPluginRegistryService as any)(
{
allEnabled: jest.fn(async () => []),
findBySelfId: jest.fn(),
},
{
loadEventPlugins: () => [repeaterPlugin],
},
pluginRepository,
installationRepository,
) as QqbotEventPluginRegistryService;
await eventRegistry.onModuleInit();
expect(eventRegistry.listOperations('repeater')).toEqual([]);
await expect(
eventRegistry.dispatchMessage({ messageType: 'group' } as any),
).resolves.toBe(false);
await expect(eventRegistry.bind('repeater', '10000')).rejects.toMatchObject(
{
response: expect.objectContaining({
msg: 'QQBot 事件插件未启用repeater',
}),
},
);
});
it('does not activate legacy command plugins that are persisted inactive on startup', async () => {
const commandPlugin = {
activate: jest.fn(async () => undefined),
key: 'demo-plugin',
name: 'Demo Plugin',
operations: [
{
execute: jest.fn(async () => ({ replyText: 'ok' })),
key: 'demo.echo',
name: 'echo',
},
],
version: '0.1.0',
};
const createRepository = (rows: unknown[] = []) => ({
find: jest.fn(async () => rows),
});
const commandRegistry = new (QqbotPluginRegistryService as any)(
{
loadCommandPlugins: () => [commandPlugin],
},
createRepository([{ id: 'plugin-command', pluginKey: 'demo-plugin' }]),
createRepository([{ pluginId: 'plugin-command', status: 'disabled' }]),
) as QqbotPluginRegistryService;
await commandRegistry.onModuleInit();
expect(commandPlugin.activate).not.toHaveBeenCalled();
expect(commandRegistry.listOperations('demo-plugin')).toEqual([]);
});
it('keeps the previous active worker when upgrade health check fails', async () => {
const installation = {
id: 'installation-upgrade',
installedPath: 'D:/plugins/demo-plugin',
pluginId: 'plugin-upgrade',
runtimeStatus: 'healthy',
status: 'enabled',
versionId: 'version-upgrade',
};
const version = {
id: 'version-upgrade',
manifestJson: {
assets: [],
configSchema: {},
entry: 'src/index.ts',
events: [],
legacyAliases: [],
migrations: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [],
permissions: [],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 5000,
workerType: 'node-worker',
},
version: '0.2.0',
},
packageHash: 'hash',
pluginId: installation.pluginId,
version: '0.2.0',
};
const createRepository = (findOneValue?: unknown) => ({
find: jest.fn(async () => []),
findAndCount: jest.fn(async () => [[], 0]),
findOne: jest.fn(async () => findOneValue),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const installationRepository = createRepository(installation);
const versionRepository = createRepository(version);
const activeWorker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
health: jest.fn(async () => ({ ok: true })),
load: jest.fn(async () => ({ ok: true })),
};
const failingWorker = {
activate: jest.fn(async () => ({ ok: true })),
deactivate: jest.fn(async () => ({ ok: true })),
dispose: jest.fn(async () => undefined),
health: jest.fn(async () => {
throw new Error('health failed');
}),
load: jest.fn(async () => ({ ok: true })),
};
const runtimeFactory = {
create: jest
.fn()
.mockReturnValueOnce(activeWorker)
.mockReturnValueOnce(failingWorker),
};
const service = new (QqbotPluginPlatformService as any)(
createRepository(),
versionRepository,
installationRepository,
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
createRepository(),
undefined,
runtimeFactory,
) as QqbotPluginPlatformService;
await service.enableInstallation({ id: installation.id });
await expect(
service.upgradeInstallation({ id: installation.id }),
).rejects.toThrow('health failed');
expect(activeWorker.deactivate).not.toHaveBeenCalled();
expect(activeWorker.dispose).not.toHaveBeenCalled();
expect(failingWorker.dispose).toHaveBeenCalled();
expect(installationRepository.update).toHaveBeenLastCalledWith(
{ id: installation.id },
{ runtimeStatus: 'healthy', status: 'enabled' },
);
});
});