test(plugins): cover required openclaw peer repair

This commit is contained in:
Patrick Erichsen
2026-05-05 22:52:45 -07:00
parent a47973dca0
commit 5468e9ba2e

View File

@@ -1,15 +1,18 @@
import { execFileSync } from "node:child_process";
import { execFile, execFileSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { installPluginFromNpmSpec } from "./install.js";
type PackedVersion = {
archive: Buffer;
integrity: string;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
shasum: string;
tarballName: string;
version: string;
@@ -19,6 +22,7 @@ const tempDirs: string[] = [];
const servers: http.Server[] = [];
const envKeys = ["NPM_CONFIG_REGISTRY", "npm_config_registry"] as const;
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
const execFileAsync = promisify(execFile);
afterEach(async () => {
for (const server of servers.splice(0)) {
@@ -43,11 +47,19 @@ async function makeTempDir(label: string): Promise<string> {
async function packPlugin(params: {
packageName: string;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
pluginId: string;
version: string;
rootDir: string;
}): Promise<PackedVersion> {
const packageDir = path.join(params.rootDir, `package-${params.version}`);
const packageDir = path.join(params.rootDir, `package-${params.packageName}-${params.version}`);
const peerDependenciesMeta = params.peerDependencies
? (params.peerDependenciesMeta ??
Object.fromEntries(
Object.keys(params.peerDependencies).map((name) => [name, { optional: true }]),
))
: undefined;
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
await fs.writeFile(
path.join(packageDir, "package.json"),
@@ -57,6 +69,12 @@ async function packPlugin(params: {
version: params.version,
type: "module",
openclaw: { extensions: ["./dist/index.js"] },
...(params.peerDependencies
? {
peerDependencies: params.peerDependencies,
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
}
: {}),
},
null,
2,
@@ -92,12 +110,90 @@ async function packPlugin(params: {
return {
archive,
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
tarballName,
version: params.version,
};
}
async function startStaticRegistry(
packages: Array<{
latest: string;
packageName: string;
versions: PackedVersion[];
}>,
): Promise<string> {
const packageEntries = packages.map((pkg) => ({
...pkg,
encodedPackageName: encodeURIComponent(pkg.packageName).replace("%40", "@"),
versionsByVersion: new Map(pkg.versions.map((entry) => [entry.version, entry])),
}));
const server = http.createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
if (request.method !== "GET") {
response.writeHead(405, { "content-type": "text/plain" });
response.end("method not allowed");
return;
}
for (const pkg of packageEntries) {
if (url.pathname === `/${pkg.encodedPackageName}`) {
response.writeHead(200, { "content-type": "application/json" });
response.end(
`${JSON.stringify({
name: pkg.packageName,
"dist-tags": { latest: pkg.latest },
versions: Object.fromEntries(
[...pkg.versionsByVersion.entries()].map(([version, entry]) => [
version,
{
name: pkg.packageName,
version,
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
...(entry.peerDependenciesMeta
? { peerDependenciesMeta: entry.peerDependenciesMeta }
: {}),
dist: {
integrity: entry.integrity,
shasum: entry.shasum,
tarball: `${baseUrl}/${pkg.encodedPackageName}/-/${entry.tarballName}`,
},
},
]),
),
})}\n`,
);
return;
}
const tarballPrefix = `/${pkg.encodedPackageName}/-/`;
if (url.pathname.startsWith(tarballPrefix)) {
const entry = [...pkg.versionsByVersion.values()].find((candidate) =>
url.pathname.endsWith(`/${candidate.tarballName}`),
);
if (entry) {
response.writeHead(200, {
"content-length": String(entry.archive.length),
"content-type": "application/octet-stream",
});
response.end(entry.archive);
return;
}
}
}
response.writeHead(404, { "content-type": "text/plain" });
response.end(`not found: ${url.pathname}`);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
servers.push(server);
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
}
async function startMutableRegistry(params: {
packageName: string;
initialLatest: string;
@@ -135,6 +231,10 @@ async function startMutableRegistry(params: {
{
name: params.packageName,
version,
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
...(entry.peerDependenciesMeta
? { peerDependenciesMeta: entry.peerDependenciesMeta }
: {}),
dist: {
integrity: entry.integrity,
shasum: entry.shasum,
@@ -173,6 +273,119 @@ async function startMutableRegistry(params: {
}
describe("installPluginFromNpmSpec e2e", () => {
it("repairs npm-installed root openclaw for required plugin peers", async () => {
const rootDir = await makeTempDir("npm-plugin-required-peer-e2e");
const npmRoot = path.join(rootDir, "managed-npm");
const packageName = `required-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
const versions = [
await packPlugin({
packageName,
peerDependencies: { openclaw: ">=2026.0.0" },
peerDependenciesMeta: {},
pluginId: packageName,
version: "1.0.0",
rootDir,
}),
];
const openClawVersions = [
await packPlugin({
packageName: "openclaw",
pluginId: "registry-openclaw-copy",
version: "2026.0.0",
rootDir,
}),
];
const registry = await startStaticRegistry([
{ packageName, latest: "1.0.0", versions },
{ packageName: "openclaw", latest: "2026.0.0", versions: openClawVersions },
]);
process.env.NPM_CONFIG_REGISTRY = registry;
process.env.npm_config_registry = registry;
const rawNpmRoot = path.join(rootDir, "raw-managed-npm");
await fs.mkdir(rawNpmRoot, { recursive: true });
await fs.writeFile(
path.join(rawNpmRoot, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: { [packageName]: "1.0.0" },
},
null,
2,
)}\n`,
"utf8",
);
await execFileAsync(
"npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"],
{
cwd: rawNpmRoot,
encoding: "utf8",
env: {
...process.env,
NPM_CONFIG_REGISTRY: registry,
npm_config_registry: registry,
},
timeout: 120_000,
},
);
const rawManifest = JSON.parse(
await fs.readFile(path.join(rawNpmRoot, "package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
};
expect(rawManifest.dependencies).toEqual({ [packageName]: "1.0.0" });
const rawLock = JSON.parse(
await fs.readFile(path.join(rawNpmRoot, "package-lock.json"), "utf8"),
) as {
packages?: Record<string, unknown>;
};
expect(rawLock.packages?.["node_modules/openclaw"]).toMatchObject({
peer: true,
version: "2026.0.0",
});
await expect(
fs
.lstat(path.join(rawNpmRoot, "node_modules", "openclaw"))
.then((stat) => stat.isDirectory()),
).resolves.toBe(true);
const result = await installPluginFromNpmSpec({
spec: `${packageName}@1.0.0`,
npmDir: npmRoot,
trustedManagedNpmRoot: true,
logger: { info: () => {}, warn: () => {} },
timeoutMs: 120_000,
});
if (!result.ok) {
throw new Error(result.error);
}
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
expect(manifest.dependencies).toEqual({ [packageName]: "1.0.0" });
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
packages?: Record<string, unknown>;
};
expect(lock.packages?.[""] as { dependencies?: Record<string, string> }).toMatchObject({
dependencies: { [packageName]: "1.0.0" },
});
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(
fs
.lstat(path.join(result.targetDir, "node_modules", "openclaw"))
.then((stat) => stat.isSymbolicLink()),
).resolves.toBe(true);
});
it("pins a mutable npm tag to the version resolved before install", async () => {
const rootDir = await makeTempDir("npm-plugin-e2e");
const npmRoot = path.join(rootDir, "managed-npm");