mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
test: enforce extension dependency ownership
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import fs from "node:fs";
|
||||
import { builtinModules } from "node:module";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const EXTENSION_ROOT = "extensions";
|
||||
const EXTENSION_RUNTIME_FILE_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".ts", ".tsx"]);
|
||||
const BUILTIN_MODULES = new Set(builtinModules.map((moduleId) => moduleId.replace(/^node:/, "")));
|
||||
const OPTIONAL_UNDECLARED_RUNTIME_IMPORTS = new Map<string, Set<string>>([
|
||||
[
|
||||
"extensions/discord",
|
||||
// Prefer the pure-JS opusscript decoder, but keep the optional native decoder
|
||||
// fallback for users who install it themselves.
|
||||
new Set(["@discordjs/opus"]),
|
||||
],
|
||||
]);
|
||||
const INDIRECT_RUNTIME_DEPENDENCIES = new Map<string, Set<string>>([
|
||||
[
|
||||
"extensions/whatsapp",
|
||||
// Baileys loads jimp as an optional peer when it needs media thumbnails.
|
||||
new Set(["jimp"]),
|
||||
],
|
||||
]);
|
||||
|
||||
type PackageManifest = {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
function toPosixPath(filePath: string): string {
|
||||
return filePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function readPackageManifest(filePath: string): PackageManifest {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as PackageManifest;
|
||||
}
|
||||
|
||||
function listPackageManifests(root: string): string[] {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
const manifests: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const manifestPath = path.join(root, entry.name, "package.json");
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
manifests.push(manifestPath);
|
||||
}
|
||||
}
|
||||
return manifests.toSorted();
|
||||
}
|
||||
|
||||
function shouldSkipRuntimeFile(filePath: string): boolean {
|
||||
const normalized = toPosixPath(filePath);
|
||||
if (
|
||||
normalized.includes("/node_modules/") ||
|
||||
normalized.includes("/dist/") ||
|
||||
normalized.includes("/coverage/") ||
|
||||
normalized.includes("/assets/") ||
|
||||
normalized.endsWith("/web/vite.config.ts")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /(\.(test|spec|d)\.(ts|tsx|js|jsx|mjs|cjs)$|\/(test|tests|__tests__|test-support)\/|test-(helpers|support|harness|mocks|fixtures|runtime|shared|utils)|\.test-(helpers|support|harness|mocks|fixtures|runtime|shared|utils)|fixture-test-support|mock-setup|test-fixtures|test-runtime-mocks|\.harness\.|e2e-harness|\.mock\.|-mock\.|-mocks\.|mocks-test-support|\.fixture|\.fixtures)/.test(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
function listRuntimeFiles(root: string): string[] {
|
||||
const files: string[] = [];
|
||||
const visit = (dir: string) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const filePath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!shouldSkipRuntimeFile(filePath)) {
|
||||
visit(filePath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
EXTENSION_RUNTIME_FILE_EXTENSIONS.has(path.extname(entry.name)) &&
|
||||
!shouldSkipRuntimeFile(filePath)
|
||||
) {
|
||||
files.push(filePath);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return files.toSorted();
|
||||
}
|
||||
|
||||
function packageNameForSpecifier(specifier: string): string | null {
|
||||
if (
|
||||
specifier.startsWith("$") ||
|
||||
specifier.includes("${") ||
|
||||
specifier.startsWith(".") ||
|
||||
specifier.startsWith("/") ||
|
||||
specifier.startsWith("node:")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (specifier.startsWith("@")) {
|
||||
const [scope, name] = specifier.split("/");
|
||||
return scope && name ? `${scope}/${name}` : specifier;
|
||||
}
|
||||
return specifier.split("/")[0] ?? null;
|
||||
}
|
||||
|
||||
function isTypeOnlyClause(clause: string | undefined): boolean {
|
||||
const trimmed = clause?.trim() ?? "";
|
||||
if (trimmed.startsWith("type ")) {
|
||||
return true;
|
||||
}
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return false;
|
||||
}
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.every((part) => part.startsWith("type "));
|
||||
}
|
||||
|
||||
function collectRuntimeImports(filePath: string): string[] {
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const imports = new Set<string>();
|
||||
const importRegex =
|
||||
/(import|export)\s+([^'";]*?\s+from\s+)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)|require\s*\(\s*["']([^"']+)["']\s*\)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = importRegex.exec(source))) {
|
||||
const clause = match[2];
|
||||
const specifier = match[3] ?? match[4] ?? match[5];
|
||||
if (!specifier || (match[1] && isTypeOnlyClause(clause))) {
|
||||
continue;
|
||||
}
|
||||
const packageName = packageNameForSpecifier(specifier);
|
||||
if (packageName) {
|
||||
imports.add(packageName);
|
||||
}
|
||||
}
|
||||
return [...imports].toSorted();
|
||||
}
|
||||
|
||||
function runtimeDependencyNames(manifest: PackageManifest): Set<string> {
|
||||
return new Set([
|
||||
...Object.keys(manifest.dependencies ?? {}),
|
||||
...Object.keys(manifest.optionalDependencies ?? {}),
|
||||
...Object.keys(manifest.peerDependencies ?? {}),
|
||||
]);
|
||||
}
|
||||
|
||||
function allDependencyNames(manifest: PackageManifest): string[] {
|
||||
return [
|
||||
...Object.keys(manifest.dependencies ?? {}),
|
||||
...Object.keys(manifest.devDependencies ?? {}),
|
||||
...Object.keys(manifest.optionalDependencies ?? {}),
|
||||
...Object.keys(manifest.peerDependencies ?? {}),
|
||||
].toSorted();
|
||||
}
|
||||
|
||||
function isDiscordPackageDependency(dependencyName: string): boolean {
|
||||
return (
|
||||
dependencyName === "@buape/carbon" ||
|
||||
dependencyName === "discord-api-types" ||
|
||||
dependencyName === "opusscript" ||
|
||||
dependencyName.startsWith("@discordjs/") ||
|
||||
dependencyName.startsWith("@snazzah/")
|
||||
);
|
||||
}
|
||||
|
||||
describe("Discord dependency ownership", () => {
|
||||
it("keeps Discord packages out of the root manifest", () => {
|
||||
const manifest = readPackageManifest("package.json");
|
||||
const discordDependencies = allDependencyNames(manifest).filter(isDiscordPackageDependency);
|
||||
|
||||
expect(discordDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
for (const manifestPath of listPackageManifests(EXTENSION_ROOT)) {
|
||||
const extensionDir = toPosixPath(path.dirname(manifestPath));
|
||||
|
||||
if (extensionDir === "extensions/discord") {
|
||||
continue;
|
||||
}
|
||||
|
||||
it(`${extensionDir} does not own Discord package dependencies`, () => {
|
||||
const manifest = readPackageManifest(manifestPath);
|
||||
const discordDependencies = allDependencyNames(manifest).filter(isDiscordPackageDependency);
|
||||
|
||||
expect(discordDependencies).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("extension runtime dependency manifests", () => {
|
||||
for (const manifestPath of listPackageManifests(EXTENSION_ROOT)) {
|
||||
const extensionDir = toPosixPath(path.dirname(manifestPath));
|
||||
|
||||
it(`${extensionDir} declares every runtime package import`, () => {
|
||||
const manifest = readPackageManifest(manifestPath);
|
||||
const declared = runtimeDependencyNames(manifest);
|
||||
const allowedOptional =
|
||||
OPTIONAL_UNDECLARED_RUNTIME_IMPORTS.get(extensionDir) ?? new Set<string>();
|
||||
const missing = new Map<string, string[]>();
|
||||
|
||||
for (const filePath of listRuntimeFiles(extensionDir)) {
|
||||
for (const packageName of collectRuntimeImports(filePath)) {
|
||||
if (
|
||||
packageName === "openclaw" ||
|
||||
packageName.startsWith("@openclaw/") ||
|
||||
BUILTIN_MODULES.has(packageName) ||
|
||||
declared.has(packageName) ||
|
||||
allowedOptional.has(packageName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const files = missing.get(packageName) ?? [];
|
||||
files.push(toPosixPath(filePath));
|
||||
missing.set(packageName, files);
|
||||
}
|
||||
}
|
||||
|
||||
expect(Object.fromEntries(missing)).toEqual({});
|
||||
});
|
||||
|
||||
it(`${extensionDir} does not keep unused direct runtime dependencies`, () => {
|
||||
const manifest = readPackageManifest(manifestPath);
|
||||
const declared = [
|
||||
...Object.keys(manifest.dependencies ?? {}),
|
||||
...Object.keys(manifest.optionalDependencies ?? {}),
|
||||
].toSorted();
|
||||
const allowedIndirect = INDIRECT_RUNTIME_DEPENDENCIES.get(extensionDir) ?? new Set<string>();
|
||||
const runtimeText = listRuntimeFiles(extensionDir)
|
||||
.map((filePath) => fs.readFileSync(filePath, "utf8"))
|
||||
.join("\n");
|
||||
|
||||
const unused = declared.filter(
|
||||
(dependencyName) =>
|
||||
!allowedIndirect.has(dependencyName) && !runtimeText.includes(dependencyName),
|
||||
);
|
||||
|
||||
expect(unused).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user