Skip to content

Commit 5468e9b

Browse files
test(plugins): cover required openclaw peer repair
1 parent a47973d commit 5468e9b

1 file changed

Lines changed: 215 additions & 2 deletions

File tree

src/plugins/install.npm-spec.e2e.test.ts

Lines changed: 215 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
import { execFileSync } from "node:child_process";
1+
import { execFile, execFileSync } from "node:child_process";
22
import crypto from "node:crypto";
33
import fs from "node:fs/promises";
44
import http from "node:http";
55
import os from "node:os";
66
import path from "node:path";
7+
import { promisify } from "node:util";
78
import { afterEach, describe, expect, it } from "vitest";
89
import { installPluginFromNpmSpec } from "./install.js";
910

1011
type PackedVersion = {
1112
archive: Buffer;
1213
integrity: string;
14+
peerDependencies?: Record<string, string>;
15+
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
1316
shasum: string;
1417
tarballName: string;
1518
version: string;
@@ -19,6 +22,7 @@ const tempDirs: string[] = [];
1922
const servers: http.Server[] = [];
2023
const envKeys = ["NPM_CONFIG_REGISTRY", "npm_config_registry"] as const;
2124
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
25+
const execFileAsync = promisify(execFile);
2226

2327
afterEach(async () => {
2428
for (const server of servers.splice(0)) {
@@ -43,11 +47,19 @@ async function makeTempDir(label: string): Promise<string> {
4347

4448
async function packPlugin(params: {
4549
packageName: string;
50+
peerDependencies?: Record<string, string>;
51+
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
4652
pluginId: string;
4753
version: string;
4854
rootDir: string;
4955
}): Promise<PackedVersion> {
50-
const packageDir = path.join(params.rootDir, `package-${params.version}`);
56+
const packageDir = path.join(params.rootDir, `package-${params.packageName}-${params.version}`);
57+
const peerDependenciesMeta = params.peerDependencies
58+
? (params.peerDependenciesMeta ??
59+
Object.fromEntries(
60+
Object.keys(params.peerDependencies).map((name) => [name, { optional: true }]),
61+
))
62+
: undefined;
5163
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
5264
await fs.writeFile(
5365
path.join(packageDir, "package.json"),
@@ -57,6 +69,12 @@ async function packPlugin(params: {
5769
version: params.version,
5870
type: "module",
5971
openclaw: { extensions: ["./dist/index.js"] },
72+
...(params.peerDependencies
73+
? {
74+
peerDependencies: params.peerDependencies,
75+
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
76+
}
77+
: {}),
6078
},
6179
null,
6280
2,
@@ -92,12 +110,90 @@ async function packPlugin(params: {
92110
return {
93111
archive,
94112
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
113+
...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}),
114+
...(peerDependenciesMeta ? { peerDependenciesMeta } : {}),
95115
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
96116
tarballName,
97117
version: params.version,
98118
};
99119
}
100120

121+
async function startStaticRegistry(
122+
packages: Array<{
123+
latest: string;
124+
packageName: string;
125+
versions: PackedVersion[];
126+
}>,
127+
): Promise<string> {
128+
const packageEntries = packages.map((pkg) => ({
129+
...pkg,
130+
encodedPackageName: encodeURIComponent(pkg.packageName).replace("%40", "@"),
131+
versionsByVersion: new Map(pkg.versions.map((entry) => [entry.version, entry])),
132+
}));
133+
const server = http.createServer((request, response) => {
134+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
135+
const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
136+
if (request.method !== "GET") {
137+
response.writeHead(405, { "content-type": "text/plain" });
138+
response.end("method not allowed");
139+
return;
140+
}
141+
142+
for (const pkg of packageEntries) {
143+
if (url.pathname === `/${pkg.encodedPackageName}`) {
144+
response.writeHead(200, { "content-type": "application/json" });
145+
response.end(
146+
`${JSON.stringify({
147+
name: pkg.packageName,
148+
"dist-tags": { latest: pkg.latest },
149+
versions: Object.fromEntries(
150+
[...pkg.versionsByVersion.entries()].map(([version, entry]) => [
151+
version,
152+
{
153+
name: pkg.packageName,
154+
version,
155+
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
156+
...(entry.peerDependenciesMeta
157+
? { peerDependenciesMeta: entry.peerDependenciesMeta }
158+
: {}),
159+
dist: {
160+
integrity: entry.integrity,
161+
shasum: entry.shasum,
162+
tarball: `${baseUrl}/${pkg.encodedPackageName}/-/${entry.tarballName}`,
163+
},
164+
},
165+
]),
166+
),
167+
})}\n`,
168+
);
169+
return;
170+
}
171+
172+
const tarballPrefix = `/${pkg.encodedPackageName}/-/`;
173+
if (url.pathname.startsWith(tarballPrefix)) {
174+
const entry = [...pkg.versionsByVersion.values()].find((candidate) =>
175+
url.pathname.endsWith(`/${candidate.tarballName}`),
176+
);
177+
if (entry) {
178+
response.writeHead(200, {
179+
"content-length": String(entry.archive.length),
180+
"content-type": "application/octet-stream",
181+
});
182+
response.end(entry.archive);
183+
return;
184+
}
185+
}
186+
}
187+
188+
response.writeHead(404, { "content-type": "text/plain" });
189+
response.end(`not found: ${url.pathname}`);
190+
});
191+
192+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
193+
servers.push(server);
194+
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
195+
}
196+
101197
async function startMutableRegistry(params: {
102198
packageName: string;
103199
initialLatest: string;
@@ -135,6 +231,10 @@ async function startMutableRegistry(params: {
135231
{
136232
name: params.packageName,
137233
version,
234+
...(entry.peerDependencies ? { peerDependencies: entry.peerDependencies } : {}),
235+
...(entry.peerDependenciesMeta
236+
? { peerDependenciesMeta: entry.peerDependenciesMeta }
237+
: {}),
138238
dist: {
139239
integrity: entry.integrity,
140240
shasum: entry.shasum,
@@ -173,6 +273,119 @@ async function startMutableRegistry(params: {
173273
}
174274

175275
describe("installPluginFromNpmSpec e2e", () => {
276+
it("repairs npm-installed root openclaw for required plugin peers", async () => {
277+
const rootDir = await makeTempDir("npm-plugin-required-peer-e2e");
278+
const npmRoot = path.join(rootDir, "managed-npm");
279+
const packageName = `required-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
280+
const versions = [
281+
await packPlugin({
282+
packageName,
283+
peerDependencies: { openclaw: ">=2026.0.0" },
284+
peerDependenciesMeta: {},
285+
pluginId: packageName,
286+
version: "1.0.0",
287+
rootDir,
288+
}),
289+
];
290+
const openClawVersions = [
291+
await packPlugin({
292+
packageName: "openclaw",
293+
pluginId: "registry-openclaw-copy",
294+
version: "2026.0.0",
295+
rootDir,
296+
}),
297+
];
298+
const registry = await startStaticRegistry([
299+
{ packageName, latest: "1.0.0", versions },
300+
{ packageName: "openclaw", latest: "2026.0.0", versions: openClawVersions },
301+
]);
302+
process.env.NPM_CONFIG_REGISTRY = registry;
303+
process.env.npm_config_registry = registry;
304+
305+
const rawNpmRoot = path.join(rootDir, "raw-managed-npm");
306+
await fs.mkdir(rawNpmRoot, { recursive: true });
307+
await fs.writeFile(
308+
path.join(rawNpmRoot, "package.json"),
309+
`${JSON.stringify(
310+
{
311+
private: true,
312+
dependencies: { [packageName]: "1.0.0" },
313+
},
314+
null,
315+
2,
316+
)}\n`,
317+
"utf8",
318+
);
319+
await execFileAsync(
320+
"npm",
321+
["install", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"],
322+
{
323+
cwd: rawNpmRoot,
324+
encoding: "utf8",
325+
env: {
326+
...process.env,
327+
NPM_CONFIG_REGISTRY: registry,
328+
npm_config_registry: registry,
329+
},
330+
timeout: 120_000,
331+
},
332+
);
333+
const rawManifest = JSON.parse(
334+
await fs.readFile(path.join(rawNpmRoot, "package.json"), "utf8"),
335+
) as {
336+
dependencies?: Record<string, string>;
337+
};
338+
expect(rawManifest.dependencies).toEqual({ [packageName]: "1.0.0" });
339+
const rawLock = JSON.parse(
340+
await fs.readFile(path.join(rawNpmRoot, "package-lock.json"), "utf8"),
341+
) as {
342+
packages?: Record<string, unknown>;
343+
};
344+
expect(rawLock.packages?.["node_modules/openclaw"]).toMatchObject({
345+
peer: true,
346+
version: "2026.0.0",
347+
});
348+
await expect(
349+
fs
350+
.lstat(path.join(rawNpmRoot, "node_modules", "openclaw"))
351+
.then((stat) => stat.isDirectory()),
352+
).resolves.toBe(true);
353+
354+
const result = await installPluginFromNpmSpec({
355+
spec: `${packageName}@1.0.0`,
356+
npmDir: npmRoot,
357+
trustedManagedNpmRoot: true,
358+
logger: { info: () => {}, warn: () => {} },
359+
timeoutMs: 120_000,
360+
});
361+
362+
if (!result.ok) {
363+
throw new Error(result.error);
364+
}
365+
366+
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
367+
dependencies?: Record<string, string>;
368+
};
369+
expect(manifest.dependencies).toEqual({ [packageName]: "1.0.0" });
370+
371+
const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as {
372+
packages?: Record<string, unknown>;
373+
};
374+
expect(lock.packages?.[""] as { dependencies?: Record<string, string> }).toMatchObject({
375+
dependencies: { [packageName]: "1.0.0" },
376+
});
377+
expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined();
378+
379+
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
380+
code: "ENOENT",
381+
});
382+
await expect(
383+
fs
384+
.lstat(path.join(result.targetDir, "node_modules", "openclaw"))
385+
.then((stat) => stat.isSymbolicLink()),
386+
).resolves.toBe(true);
387+
});
388+
176389
it("pins a mutable npm tag to the version resolved before install", async () => {
177390
const rootDir = await makeTempDir("npm-plugin-e2e");
178391
const npmRoot = path.join(rootDir, "managed-npm");

0 commit comments

Comments
 (0)