371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
import { join, relative } from 'path';
|
|
|
|
const repoRoot = join(__dirname, '../../../..');
|
|
const pluginRoot = join(repoRoot, 'src/modules/qqbot/plugins');
|
|
|
|
/**
|
|
* 执行 测试断言流程。
|
|
* @param root - root 输入;驱动 `readdirSync()`、`join()` 的 测试步骤。
|
|
* @returns 测试断言渲染后的图片、画布或文本。
|
|
*/
|
|
const collectTsFiles = (root: string): string[] => {
|
|
if (!existsSync(root)) return [];
|
|
|
|
return readdirSync(root).flatMap((name) => {
|
|
const filePath = join(root, name);
|
|
const stat = statSync(filePath);
|
|
if (stat.isDirectory()) return collectTsFiles(filePath);
|
|
return filePath.endsWith('.ts') ? [filePath] : [];
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 执行 测试断言流程。
|
|
* @param root - root 输入;驱动 `readdirSync()`、`join()` 的 测试步骤。
|
|
* @returns 测试断言渲染后的图片、画布或文本。
|
|
*/
|
|
const collectFiles = (root: string): string[] => {
|
|
if (!existsSync(root)) return [];
|
|
|
|
return readdirSync(root).flatMap((name) => {
|
|
const filePath = join(root, name);
|
|
const stat = statSync(filePath);
|
|
if (stat.isDirectory()) return collectFiles(filePath);
|
|
return [filePath];
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 执行 测试断言流程。
|
|
* @param filePath - 测试路径;驱动 `relative()` 的 测试步骤。
|
|
*/
|
|
const toRepoPath = (filePath: string) =>
|
|
relative(repoRoot, filePath).replace(/\\/g, '/');
|
|
|
|
const requiredPluginPaths = [
|
|
'plugin.json',
|
|
'src/index.ts',
|
|
'src/application',
|
|
'src/config',
|
|
'src/domain',
|
|
'src/infrastructure/integration',
|
|
];
|
|
|
|
const requiredCommandPluginPaths = ['src/operations'];
|
|
const requiredEventPluginPaths = ['src/events'];
|
|
|
|
describe('QQBot plugin package boundary', () => {
|
|
it('does not keep built-in plugin transfer services under plugin-platform', () => {
|
|
expect(
|
|
existsSync(
|
|
join(
|
|
repoRoot,
|
|
'src/modules/qqbot/plugin-platform/infrastructure/integration/builtins',
|
|
),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('does not hard-code built-in plugin packages in platform application services', () => {
|
|
const applicationFiles = collectTsFiles(
|
|
join(repoRoot, 'src/modules/qqbot/plugin-platform/application'),
|
|
);
|
|
|
|
const violations = applicationFiles.flatMap((filePath) => {
|
|
const source = readFileSync(filePath, 'utf8');
|
|
const file = toRepoPath(filePath);
|
|
return [
|
|
source.includes('@/modules/qqbot/plugins/')
|
|
? `${file} :: concrete plugin import`
|
|
: '',
|
|
/from ['"]node:fs['"]|from ['"]fs['"]|require\(['"](?:node:)?fs['"]\)/.test(
|
|
source,
|
|
)
|
|
? `${file} :: direct host file IO`
|
|
: '',
|
|
].filter(Boolean);
|
|
});
|
|
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
it('uses only approved built-in plugin package keys as directory names', () => {
|
|
const pluginDirs = readdirSync(pluginRoot)
|
|
.filter((name) => statSync(join(pluginRoot, name)).isDirectory())
|
|
.sort();
|
|
|
|
expect(pluginDirs).toEqual([
|
|
'bangdream',
|
|
'bilibili-card',
|
|
'ff14-market',
|
|
'fflogs',
|
|
'repeater',
|
|
]);
|
|
});
|
|
|
|
it('uses the same package shape for every built-in plugin', () => {
|
|
const missing = [
|
|
'bangdream',
|
|
'bilibili-card',
|
|
'ff14-market',
|
|
'fflogs',
|
|
'repeater',
|
|
].flatMap((pluginKey) => {
|
|
const manifest = JSON.parse(
|
|
readFileSync(join(pluginRoot, pluginKey, 'plugin.json'), 'utf8'),
|
|
) as { events?: unknown[]; operations?: unknown[] };
|
|
const requiredPaths = [
|
|
...requiredPluginPaths,
|
|
...((manifest.operations || []).length
|
|
? requiredCommandPluginPaths
|
|
: []),
|
|
...((manifest.events || []).length ? requiredEventPluginPaths : []),
|
|
];
|
|
return requiredPaths
|
|
.map((pathName) => `${pluginKey}/${pathName}`)
|
|
.filter((pathName) => !existsSync(join(pluginRoot, pathName)));
|
|
});
|
|
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it('does not keep third-phase package directories as empty shells', () => {
|
|
const emptyRequiredDirs = [
|
|
'bangdream',
|
|
'bilibili-card',
|
|
'ff14-market',
|
|
'fflogs',
|
|
'repeater',
|
|
].flatMap((pluginKey) => {
|
|
const manifest = JSON.parse(
|
|
readFileSync(join(pluginRoot, pluginKey, 'plugin.json'), 'utf8'),
|
|
) as { events?: unknown[]; operations?: unknown[] };
|
|
const requiredDirs = [
|
|
'src/application',
|
|
'src/config',
|
|
'src/domain',
|
|
'src/infrastructure/integration',
|
|
...((manifest.operations || []).length ? ['src/operations'] : []),
|
|
...((manifest.events || []).length ? ['src/events'] : []),
|
|
];
|
|
return requiredDirs
|
|
.map((pathName) => join(pluginRoot, pluginKey, pathName))
|
|
.filter((pathName) => collectTsFiles(pathName).length === 0)
|
|
.map(toRepoPath);
|
|
});
|
|
|
|
expect(emptyRequiredDirs).toEqual([]);
|
|
});
|
|
|
|
it('does not keep empty .gitkeep placeholder shells in built-in plugin packages', () => {
|
|
const placeholders = readdirSync(pluginRoot)
|
|
.filter((name) => statSync(join(pluginRoot, name)).isDirectory())
|
|
.flatMap((pluginKey) =>
|
|
collectFiles(join(pluginRoot, pluginKey)).filter((filePath) =>
|
|
filePath.endsWith('.gitkeep'),
|
|
),
|
|
)
|
|
.map(toRepoPath);
|
|
|
|
expect(placeholders).toEqual([]);
|
|
});
|
|
|
|
it('keeps plugin runtime source independent from host internals and direct IO', () => {
|
|
const banned = [
|
|
{
|
|
name: '@nestjs',
|
|
pattern: /@nestjs\//,
|
|
},
|
|
{
|
|
name: 'Admin module import',
|
|
pattern: /@\/modules\/admin\//,
|
|
},
|
|
{
|
|
name: 'QQBot core import',
|
|
pattern: /@\/modules\/qqbot\/core\//,
|
|
},
|
|
{
|
|
name: 'ConfigService',
|
|
pattern: /\bConfigService\b/,
|
|
},
|
|
{
|
|
name: 'DictService',
|
|
pattern: /\bDictService\b/,
|
|
},
|
|
{
|
|
name: 'process.env',
|
|
pattern: /process\.env/,
|
|
},
|
|
{
|
|
name: 'direct axios',
|
|
pattern: /\baxios\b/,
|
|
},
|
|
{
|
|
name: 'direct fetch',
|
|
pattern: /\bfetch\(/,
|
|
},
|
|
{
|
|
name: 'direct fs',
|
|
pattern: /from ['"](?:node:)?fs['"]|require\(['"](?:node:)?fs['"]\)/,
|
|
},
|
|
{
|
|
name: 'import-time timer',
|
|
pattern: /\bset(?:Interval|Timeout)\(/,
|
|
},
|
|
];
|
|
|
|
const violations = collectTsFiles(pluginRoot).flatMap((filePath) => {
|
|
const source = readFileSync(filePath, 'utf8');
|
|
return banned
|
|
.filter(({ pattern }) => pattern.test(source))
|
|
.map(({ name }) => `${toRepoPath(filePath)} :: ${name}`);
|
|
});
|
|
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
it('uses plugin.json as the only operation metadata source', () => {
|
|
const duplicateMetadataFiles = collectTsFiles(pluginRoot)
|
|
.filter((filePath) => {
|
|
const file = toRepoPath(filePath);
|
|
if (file.endsWith('/src/index.ts')) return false;
|
|
const source = readFileSync(filePath, 'utf8');
|
|
return (
|
|
/operation-registry|COMMAND_DEFINITIONS|OperationDefinition/.test(
|
|
file,
|
|
) ||
|
|
/operation-registry|COMMAND_DEFINITIONS|OperationDefinition/.test(
|
|
source,
|
|
)
|
|
);
|
|
})
|
|
.map(toRepoPath);
|
|
|
|
expect(duplicateMetadataFiles).toEqual([]);
|
|
});
|
|
|
|
it('keeps plugin package entrypoints limited to createPlugin', () => {
|
|
const extraExports = [
|
|
'bangdream',
|
|
'bilibili-card',
|
|
'ff14-market',
|
|
'fflogs',
|
|
'repeater',
|
|
].flatMap((pluginKey) => {
|
|
const entryPath = join(pluginRoot, pluginKey, 'src/index.ts');
|
|
const source = readFileSync(entryPath, 'utf8');
|
|
return source
|
|
.split(/\r?\n/)
|
|
.map((line, index) => ({ index: index + 1, line: line.trim() }))
|
|
.filter(({ line }) => /^export\s+/.test(line))
|
|
.filter(({ line }) => !/^export\s+function\s+createPlugin\b/.test(line))
|
|
.map(
|
|
({ index, line }) => `${toRepoPath(entryPath)}:${index} :: ${line}`,
|
|
);
|
|
});
|
|
|
|
expect(extraExports).toEqual([]);
|
|
});
|
|
|
|
it('does not keep registry-named BangDream runtime files or types', () => {
|
|
const bangdreamSourceRoot = join(pluginRoot, 'bangdream', 'src');
|
|
const violations = collectTsFiles(bangdreamSourceRoot).flatMap(
|
|
(filePath) => {
|
|
const file = toRepoPath(filePath);
|
|
const source = readFileSync(filePath, 'utf8');
|
|
return [
|
|
file.includes('.registry.ts') ? `${file} :: registry file` : '',
|
|
/\b[A-Za-z0-9]+Registry\b/.test(source)
|
|
? `${file} :: Registry type name`
|
|
: '',
|
|
].filter(Boolean);
|
|
},
|
|
);
|
|
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
it('does not keep pure transfer TypeScript files in plugin packages', () => {
|
|
const transferFiles = collectTsFiles(pluginRoot)
|
|
.filter((filePath) => {
|
|
const source = readFileSync(filePath, 'utf8')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\/\/.*$/gm, '')
|
|
.trim();
|
|
return (
|
|
source.length > 0 &&
|
|
source
|
|
.split(/\r?\n/)
|
|
.every((line) => /^export\s+(?:type\s+)?\*?\s*/.test(line.trim()))
|
|
);
|
|
})
|
|
.map(toRepoPath);
|
|
|
|
expect(transferFiles).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('plugin platform package decoupling', () => {
|
|
const platformRoot = join(repoRoot, 'src/modules/qqbot/plugin-platform');
|
|
const forbiddenPattern = new RegExp(
|
|
[
|
|
`@/modules/qqbot/${'plugins'}`,
|
|
['src', 'modules', 'qqbot', 'plugins'].join('/'),
|
|
`Qqbot${'Builtin'}PluginPackageLoaderService`,
|
|
`BUILTIN_${'PLUGIN_KEYS'}`,
|
|
`get${'ConfigKeysForPlugin'}`,
|
|
`create${'BangDream'}Plugin`,
|
|
`create${'Ff14Market'}Plugin`,
|
|
`create${'Fflogs'}Plugin`,
|
|
`create${'Repeater'}Plugin`,
|
|
`builtin-plugin-${'worker'}`,
|
|
`builtin-plugin-${'package-loader'}`,
|
|
]
|
|
.map(escapeRegExp)
|
|
.join('|'),
|
|
);
|
|
const forbiddenBranchPattern =
|
|
/pluginKey\s*(?:={2,3})\s*['"`](bangdream|bilibili-card|ff14-market|fflogs|repeater)['"`]|case\s+['"`](bangdream|bilibili-card|ff14-market|fflogs|repeater)['"`]/;
|
|
|
|
/**
|
|
* Escapes a literal token before it is placed into the architecture gate regexp.
|
|
* @param token - Forbidden source token built without leaving the raw token in test source.
|
|
* @returns RegExp-safe literal token.
|
|
*/
|
|
function escapeRegExp(token: string): string {
|
|
return token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/**
|
|
* Walks plugin-platform source files for concrete coupling assertions.
|
|
* @param dir - Directory being scanned for TypeScript platform sources.
|
|
* @returns TypeScript source files under the platform tree.
|
|
*/
|
|
function walkSourceFiles(dir: string): string[] {
|
|
return readdirSync(dir).flatMap((name) => {
|
|
const file = join(dir, name);
|
|
const stat = statSync(file);
|
|
if (stat.isDirectory()) {
|
|
return walkSourceFiles(file);
|
|
}
|
|
return file.endsWith('.ts') ? [file] : [];
|
|
});
|
|
}
|
|
|
|
it('does not import, instantiate, or branch on concrete built-in plugins', () => {
|
|
const offenders = walkSourceFiles(platformRoot)
|
|
.map((file) => ({
|
|
file,
|
|
source: readFileSync(file, 'utf8'),
|
|
}))
|
|
.filter(
|
|
({ source }) =>
|
|
forbiddenPattern.test(source) || forbiddenBranchPattern.test(source),
|
|
)
|
|
.map(({ file }) => toRepoPath(file));
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
});
|