openclaw / src /plugins /contracts /extension-runtime-dependencies.contract.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4c76b0d verified
Raw History Blame Contribute Delete
16.5 kB
// Extension runtime dependency contract tests cover runtime dependency placement for extensions.
import fs from "node:fs";
import { builtinModules, createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolvePluginNpmRuntimeBuildPlan } from "../../../scripts/lib/plugin-npm-runtime-build.mts";
import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js";
import {
listGitTrackedFiles,
toRepoPath,
toRepoRelativePath,
} from "../../test-utils/repo-files.js";
const EXTENSION_ROOT = "extensions";
const REPO_ROOT = path.resolve(import.meta.dirname, "../../..");
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/canvas",
// The A2UI bundle probes this optional markdown renderer and falls back when absent.
new Set(["@a2ui/markdown-it"]),
],
[
"extensions/discord",
// @discordjs/voice still probes the native addon in its dependency report path.
new Set(["@discordjs/opus"]),
],
["extensions/qa-lab", new Set(["crabline"])],
]);
const INDIRECT_RUNTIME_DEPENDENCIES = new Map<string, Set<string>>([
[
"extensions/browser",
// The MCP SDK loads zod through its server/zod-compat runtime path.
new Set(["zod"]),
],
[
"extensions/whatsapp",
// Baileys loads this optional peer for audio decoding.
new Set(["audio-decode"]),
],
[
"extensions/memory-lancedb",
// LanceDB imports apache-arrow at runtime through its peer dependency.
new Set(["apache-arrow"]),
],
[
"extensions/memory-core",
// Packaged memory tools run through generated OpenClaw runtime chunks that parse JSON5 config.
new Set(["json5"]),
],
[
"extensions/slack",
// Bolt loads Socket Mode, whose Undici 7 peer must be provided by the plugin package.
new Set(["undici"]),
],
[
"extensions/tlon",
// The Tlon plugin manifest exposes the bundled skill from this package path.
new Set(["@tloncorp/tlon-skill"]),
],
]);
const COMPUTED_RUNTIME_DEPENDENCIES = new Map<string, Set<string>>([
[
"extensions/discord",
// Bundled at build time into the served Discord Activity shell asset rather than
// imported by plugin runtime code; see scripts/build-discord-activity-sdk.mts.
new Set(["@discord/embedded-app-sdk"]),
],
[
"extensions/lobster",
// Keep Lobster external to the plugin bundle; its computed core import is resolved at runtime.
new Set(["@clawdbot/lobster"]),
],
]);
type PackageManifest = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
openclaw?: {
build?: {
staticAssets?: Array<{ source?: string }>;
};
};
};
const trackedFilesByRoot = new Map<string, readonly string[] | null>();
function readPackageManifest(filePath: string): PackageManifest {
return JSON.parse(fs.readFileSync(path.resolve(REPO_ROOT, filePath), "utf8")) as PackageManifest;
}
function listTrackedFiles(root: string): string[] | null {
const relativeRoot = toRepoRelativePath(REPO_ROOT, path.resolve(REPO_ROOT, root));
if (!relativeRoot || relativeRoot.startsWith("..")) {
return null;
}
if (trackedFilesByRoot.has(relativeRoot)) {
const files = trackedFilesByRoot.get(relativeRoot);
return files ? [...files] : null;
}
const trackedFiles = listGitTrackedFiles({ repoRoot: REPO_ROOT, pathspecs: relativeRoot });
if (!trackedFiles) {
trackedFilesByRoot.set(relativeRoot, null);
return null;
}
const files = trackedFiles.toSorted();
trackedFilesByRoot.set(relativeRoot, files);
return [...files];
}
function listPackageManifests(root: string): string[] {
const trackedFiles = listTrackedFiles(root);
if (trackedFiles) {
return trackedFiles
.filter((filePath) => /^extensions\/[^/]+\/package\.json$/u.test(filePath))
.toSorted();
}
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 = toRepoPath(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 manifest = readPackageManifest(path.join(root, "package.json"));
// Static assets execute from the packaged plugin even when a dirty remote sync has not added
// their new source paths to Git's index, so the manifest must remain an authoritative input.
const staticAssetSources = (manifest.openclaw?.build?.staticAssets ?? []).flatMap((entry) => {
const source = entry.source?.trim().replace(/^\.\/+/, "");
if (!source || source.startsWith("../") || source.includes("/../")) {
return [];
}
const filePath = toRepoPath(path.posix.join(root, source));
return EXTENSION_RUNTIME_FILE_EXTENSIONS.has(path.extname(filePath)) &&
!shouldSkipRuntimeFile(filePath) &&
fs.existsSync(path.resolve(REPO_ROOT, filePath))
? [filePath]
: [];
});
const trackedFiles = listTrackedFiles(root);
if (trackedFiles) {
return [
...new Set([
...trackedFiles.filter(
(filePath) =>
EXTENSION_RUNTIME_FILE_EXTENSIONS.has(path.extname(filePath)) &&
!shouldSkipRuntimeFile(filePath),
),
...staticAssetSources,
]),
].toSorted();
}
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 [...new Set([...files, ...staticAssetSources])].toSorted();
}
function readManifestText(root: string): string {
const manifestPath = path.join(root, "openclaw.plugin.json");
const resolvedManifestPath = path.resolve(REPO_ROOT, manifestPath);
return fs.existsSync(resolvedManifestPath) ? fs.readFileSync(resolvedManifestPath, "utf8") : "";
}
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;
}
for (const part of trimmed.slice(1, -1).split(",")) {
const importName = part.trim();
if (importName.length > 0 && !importName.startsWith("type ")) {
return false;
}
}
return true;
}
function collectRuntimeImports(filePath: string): string[] {
const source = fs.readFileSync(path.resolve(REPO_ROOT, 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 collectBundledRuntimeDependencies(root: string, manifest: PackageManifest) {
const dependencies = new Map<string, PackageManifest>();
const entryFiles = new Set<string>();
const declared = runtimeDependencyNames(manifest);
const buildDependencies = new Set(
Object.keys(manifest.devDependencies ?? {}).filter(
(name) => !name.startsWith("@openclaw/") && !declared.has(name),
),
);
const plan = buildDependencies.size
? resolvePluginNpmRuntimeBuildPlan({ repoRoot: REPO_ROOT, packageDir: root })
: null;
if (!plan) {
return { dependencies, entryFiles };
}
const require = createRequire(path.resolve(REPO_ROOT, root, "package.json"));
const staticAssets = (manifest.openclaw?.build?.staticAssets ?? []).flatMap((asset) =>
asset.source ? [path.resolve(REPO_ROOT, root, asset.source)] : [],
);
for (const filePath of Object.values(plan.entry)) {
// Copied assets still need installed dependencies; only emitted entrypoints bundle JS.
if (
staticAssets.some((asset) => filePath === asset || filePath.startsWith(`${asset}${path.sep}`))
) {
continue;
}
entryFiles.add(toRepoPath(path.relative(REPO_ROOT, filePath)));
for (const name of collectRuntimeImports(filePath)) {
if (!buildDependencies.has(name) || dependencies.has(name)) {
continue;
}
const packagePath = require.resolve
.paths(name)
?.map((directory) => path.join(directory, name, "package.json"))
.find((candidate) => fs.existsSync(candidate));
if (!packagePath) {
throw new Error(`${root} cannot resolve bundled dependency ${name}`);
}
dependencies.set(name, readPackageManifest(packagePath));
}
}
return { dependencies, entryFiles };
}
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 === "discord-api-types" ||
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).toStrictEqual([]);
});
for (const manifestPath of listPackageManifests(EXTENSION_ROOT)) {
const extensionDir = toRepoPath(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).toStrictEqual([]);
});
}
});
describe("extension runtime dependency manifests", () => {
it("lists extension dependency inputs from git without walking extension dirs", () => {
expectNoReaddirSyncDuring(() => {
const manifests = listPackageManifests(EXTENSION_ROOT);
const runtimeFiles = listRuntimeFiles("extensions/discord");
expect(manifests.length).toBeGreaterThan(0);
expect(runtimeFiles.length).toBeGreaterThan(0);
});
});
it("keeps json5 in memory-core for packaged runtime config parsing", () => {
const manifest = readPackageManifest("extensions/memory-core/package.json");
expect(manifest.dependencies?.json5).toBeTypeOf("string");
expect(manifest.dependencies?.json5).not.toBe("");
});
for (const [extensionDir, dependencies] of COMPUTED_RUNTIME_DEPENDENCIES) {
it(`${extensionDir} declares every computed runtime dependency`, () => {
const manifest = readPackageManifest(path.join(extensionDir, "package.json"));
const declared = new Set([
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.optionalDependencies ?? {}),
]);
expect([...dependencies].filter((dependencyName) => !declared.has(dependencyName))).toEqual(
[],
);
});
}
for (const manifestPath of listPackageManifests(EXTENSION_ROOT)) {
const extensionDir = toRepoPath(path.dirname(manifestPath));
it(`${extensionDir} declares every runtime package import`, () => {
const manifest = readPackageManifest(manifestPath);
const declared = runtimeDependencyNames(manifest);
const bundled = collectBundledRuntimeDependencies(extensionDir, 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) ||
(bundled.entryFiles.has(filePath) && bundled.dependencies.has(packageName)) ||
allowedOptional.has(packageName)
) {
continue;
}
const files = missing.get(packageName) ?? [];
files.push(toRepoPath(filePath));
missing.set(packageName, files);
}
}
expect(Object.fromEntries(missing)).toStrictEqual({});
});
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 allowedComputed = COMPUTED_RUNTIME_DEPENDENCIES.get(extensionDir) ?? new Set<string>();
const bundled = collectBundledRuntimeDependencies(extensionDir, manifest);
const bundledIndirect = new Map(
[...bundled.dependencies.values()].flatMap((dependency) =>
Object.entries({ ...dependency.dependencies, ...dependency.optionalDependencies }),
),
);
const runtimeText = listRuntimeFiles(extensionDir)
.map((filePath) => fs.readFileSync(path.resolve(REPO_ROOT, filePath), "utf8"))
.concat(readManifestText(extensionDir))
.join("\n");
const unused = declared.filter(
(dependencyName) =>
!allowedIndirect.has(dependencyName) &&
!allowedComputed.has(dependencyName) &&
bundledIndirect.get(dependencyName) !==
(manifest.optionalDependencies?.[dependencyName] ??
manifest.dependencies?.[dependencyName]) &&
!runtimeText.includes(dependencyName),
);
expect(unused).toStrictEqual([]);
});
}
});