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

256 lines
7.5 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import { runQqbotPluginCli } from '../../../../scripts/qqbot-plugin/cli';
import { parseQqbotPluginManifest } from '../../../../src/modules/qqbot/plugin-platform/domain/manifest';
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
const workspaceTmpRoot = path.resolve(
projectRoot,
'..',
'..',
'.kt-workspace',
'tmp',
);
/**
* 读取 QQBot 插件平台资源。
* @param filePath - 插件平台路径;转换 JSON 文本。
*/
const readJson = (filePath: string) => {
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, any>;
};
/**
* 执行 QQBot 插件平台流程。
* @param cwd - 当前工作目录;影响 silentCliOptions 的返回值。
*/
const silentCliOptions = (cwd: string) => ({
cwd,
stderr: jest.fn(),
stdout: jest.fn(),
});
describe('QQBot plugin CLI', () => {
let sandbox: string;
beforeEach(() => {
sandbox = path.join(
workspaceTmpRoot,
`qqbot-plugin-cli-${process.pid}-${Date.now()}`,
);
fs.mkdirSync(sandbox, { recursive: true });
});
afterEach(() => {
fs.rmSync(sandbox, { force: true, recursive: true });
});
it('registers the package script used by local and CI workflows', () => {
const packageJson = readJson(path.join(projectRoot, 'package.json'));
expect(packageJson.scripts['qqbot-plugin']).toBe(
'ts-node -r tsconfig-paths/register scripts/qqbot-plugin/cli.ts',
);
});
it('creates, validates, packs, and locally installs a plugin through one validation chain', async () => {
const created = await runQqbotPluginCli(
['create', 'demo-plugin'],
silentCliOptions(sandbox),
);
const pluginRoot = path.join(sandbox, 'plugins', 'demo-plugin');
expect(created).toMatchObject({
command: 'create',
exitCode: 0,
pluginRoot,
});
expect(fs.existsSync(path.join(pluginRoot, 'plugin.json'))).toBe(true);
expect(fs.existsSync(path.join(pluginRoot, 'src', 'index.ts'))).toBe(true);
expect(fs.existsSync(path.join(pluginRoot, 'migrations'))).toBe(true);
expect(fs.existsSync(path.join(pluginRoot, 'assets'))).toBe(true);
expect(() =>
parseQqbotPluginManifest(readJson(path.join(pluginRoot, 'plugin.json')), {
pluginRoot,
}),
).not.toThrow();
await expect(
runQqbotPluginCli(['validate', pluginRoot], silentCliOptions(sandbox)),
).resolves.toMatchObject({
command: 'validate',
exitCode: 0,
pluginKey: 'demo-plugin',
});
const packed = await runQqbotPluginCli(
['pack', pluginRoot],
silentCliOptions(sandbox),
);
expect(packed).toMatchObject({
command: 'pack',
exitCode: 0,
pluginKey: 'demo-plugin',
});
expect(packed.packagePath).toMatch(
/demo-plugin-0\.1\.0-[a-f0-9]{12}\.qqbot-plugin\.json$/,
);
const packageJson = readJson(packed.packagePath || '');
expect(packageJson).toMatchObject({
contentHash: expect.stringMatching(/^[a-f0-9]{64}$/),
manifest: expect.objectContaining({
pluginKey: 'demo-plugin',
version: '0.1.0',
}),
});
expect(packageJson.files).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'plugin.json',
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
}),
expect.objectContaining({
path: 'src/index.ts',
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
}),
]),
);
await expect(
runQqbotPluginCli(
['install-local', packed.packagePath || ''],
silentCliOptions(sandbox),
),
).resolves.toMatchObject({
command: 'install-local',
exitCode: 0,
packageHash: packageJson.contentHash,
pluginKey: 'demo-plugin',
});
});
it('honors explicit output paths used by CLI smoke workflows', async () => {
const pluginRoot = path.join(sandbox, 'smoke-plugin');
const packageRoot = path.join(sandbox, 'packages');
const created = await runQqbotPluginCli(
['create', 'smoke-plugin', '--out', pluginRoot],
silentCliOptions(projectRoot),
);
expect(created).toMatchObject({
command: 'create',
exitCode: 0,
pluginRoot,
});
expect(fs.existsSync(path.join(pluginRoot, 'plugin.json'))).toBe(true);
const packed = await runQqbotPluginCli(
['pack', pluginRoot, '--out', packageRoot],
silentCliOptions(projectRoot),
);
expect(packed).toMatchObject({
command: 'pack',
exitCode: 0,
pluginKey: 'smoke-plugin',
});
expect(path.dirname(packed.packagePath || '')).toBe(packageRoot);
});
it('allows Jenkins single-repo checkout artifact roots without root AGENTS.md', async () => {
const ciProjectRoot = path.join(
sandbox,
'agent',
'workspace',
'KT-Template_KT-Template-API_main',
);
fs.mkdirSync(ciProjectRoot, { recursive: true });
fs.writeFileSync(
path.join(ciProjectRoot, 'package.json'),
JSON.stringify({ name: 'kt-template-online-api' }),
);
const pluginRoot = path.resolve(
ciProjectRoot,
'..',
'..',
'.kt-workspace',
'tmp',
'smoke-plugin',
);
const created = await runQqbotPluginCli(
['create', 'smoke-plugin', '--out', pluginRoot],
silentCliOptions(ciProjectRoot),
);
expect(created).toMatchObject({
command: 'create',
exitCode: 0,
pluginRoot,
});
expect(fs.existsSync(path.join(pluginRoot, 'plugin.json'))).toBe(true);
});
it('rejects unsafe create output paths before writing files', async () => {
const outsideRoot = path.resolve(sandbox, '..', 'outside-plugin');
await expect(
runQqbotPluginCli(
['create', '../bad-plugin', '--out', outsideRoot],
silentCliOptions(sandbox),
),
).rejects.toThrow(/plugin key|output path/i);
expect(fs.existsSync(outsideRoot)).toBe(false);
});
it('validates plugin source boundaries before packaging', async () => {
const pluginRoot = path.join(sandbox, 'plugins', 'unsafe-plugin');
await runQqbotPluginCli(
['create', 'unsafe-plugin', '--out', pluginRoot],
silentCliOptions(sandbox),
);
fs.writeFileSync(
path.join(pluginRoot, 'src', 'index.ts'),
"import { Injectable } from '@nestjs/common';\nconst token = process.env.SECRET;\n",
);
await expect(
runQqbotPluginCli(['validate', pluginRoot], silentCliOptions(sandbox)),
).rejects.toThrow(/forbidden plugin source/i);
});
it('refuses hidden files, oversized files, and returns stable install ids', async () => {
const pluginRoot = path.join(sandbox, 'plugins', 'safe-plugin');
await runQqbotPluginCli(
['create', 'safe-plugin', '--out', pluginRoot],
silentCliOptions(sandbox),
);
expect(fs.existsSync(path.join(pluginRoot, 'src', 'operations'))).toBe(
true,
);
expect(fs.existsSync(path.join(pluginRoot, 'src', 'events'))).toBe(true);
fs.writeFileSync(path.join(pluginRoot, '.env'), 'SECRET=1\n');
await expect(
runQqbotPluginCli(['pack', pluginRoot], silentCliOptions(sandbox)),
).rejects.toThrow(/hidden/i);
fs.rmSync(path.join(pluginRoot, '.env'));
const packed = await runQqbotPluginCli(
['pack', pluginRoot],
silentCliOptions(sandbox),
);
await expect(
runQqbotPluginCli(
['install-local', packed.packagePath || ''],
silentCliOptions(sandbox),
),
).resolves.toMatchObject({
installationId: expect.stringMatching(/^[a-f0-9]{16}$/),
versionId: expect.stringMatching(/^[a-f0-9]{16}$/),
});
});
});