Files
openclaw/src/cli/plugins-cli.clawhub-install.e2e.test.ts

314 lines
10 KiB
TypeScript

import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js";
const PACKAGE_NAME = "@openclaw/telemetry-demo";
const PACKAGE_VERSION = "1.0.0";
const PLUGIN_ID = "telemetry-demo";
const ENCODED_PACKAGE_NAME = encodeURIComponent(PACKAGE_NAME);
async function readRequestBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
async function spawnOpenClaw(
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
): Promise<{ status: number | null; stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--import", "tsx", "src/entry.ts", ...args], {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (status) => resolve({ status, stdout, stderr }));
});
}
async function buildPluginZip(): Promise<Buffer> {
const zip = new JSZip();
zip.file(
"package/package.json",
JSON.stringify({
name: PACKAGE_NAME,
version: PACKAGE_VERSION,
type: "module",
openclaw: { extensions: ["./dist/index.js"] },
}),
);
zip.file(
"package/openclaw.plugin.json",
JSON.stringify({
id: PLUGIN_ID,
configSchema: { type: "object", properties: {} },
}),
);
zip.file("package/dist/index.js", "export default function register() {}\n");
return await zip.generateAsync({ type: "nodebuffer" });
}
type TestServerOptions = {
artifactSha256?: string;
telemetryStatus?: number;
};
async function startClawHubServer(options: TestServerOptions = {}) {
const archive = await buildPluginZip();
const artifactSha256 =
options.artifactSha256 ?? createHash("sha256").update(archive).digest("hex");
const telemetryBodies: unknown[] = [];
const requestLog: string[] = [];
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
requestLog.push(`${req.method ?? "GET"} ${url.pathname}`);
const packagePath = `/api/v1/packages/${ENCODED_PACKAGE_NAME}`;
if (req.method === "GET" && url.pathname === packagePath) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
package: {
name: PACKAGE_NAME,
displayName: "Telemetry Demo",
family: "code-plugin",
runtimeId: PLUGIN_ID,
channel: "community",
isOfficial: false,
latestVersion: PACKAGE_VERSION,
tags: { latest: PACKAGE_VERSION },
compatibility: {},
},
owner: { handle: "openclaw" },
}),
);
return;
}
if (
req.method === "GET" &&
url.pathname === `${packagePath}/versions/${PACKAGE_VERSION}/artifact`
) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
package: {
name: PACKAGE_NAME,
displayName: "Telemetry Demo",
family: "code-plugin",
},
version: {
version: PACKAGE_VERSION,
createdAt: 1,
changelog: "Initial release",
sha256hash: artifactSha256,
compatibility: {},
},
}),
);
return;
}
if (
req.method === "GET" &&
url.pathname === `${packagePath}/versions/${PACKAGE_VERSION}/security`
) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
package: {
name: PACKAGE_NAME,
displayName: "Telemetry Demo",
family: "code-plugin",
},
release: { version: PACKAGE_VERSION },
trust: {
scanStatus: "clean",
moderationState: null,
blockedFromDownload: false,
reasons: [],
pending: false,
stale: false,
},
}),
);
return;
}
if (req.method === "GET" && url.pathname === `${packagePath}/download`) {
res.writeHead(200, { "Content-Type": "application/zip" });
res.end(archive);
return;
}
if (req.method === "POST" && url.pathname === "/api/cli/telemetry/install") {
telemetryBodies.push(JSON.parse(await readRequestBody(req)) as unknown);
const status = options.telemetryStatus ?? 200;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(status === 200 ? JSON.stringify({ ok: true }) : JSON.stringify({ error: "down" }));
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found");
}
const server = createServer((req, res) => {
void handleRequest(req, res).catch((error: unknown) => {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end(error instanceof Error ? error.message : String(error));
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
return {
registry: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
requestLog,
telemetryBodies,
close: async () => {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
},
};
}
function buildEnv(stateDir: string, registry: string): NodeJS.ProcessEnv {
return {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
OPENCLAW_CLAWHUB_URL: registry,
CLAWHUB_TOKEN: "test-token",
CLAWHUB_DISABLE_TELEMETRY: "",
CLAWDHUB_DISABLE_TELEMETRY: "",
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
};
}
async function readPersistedInstallRecord(stateDir: string) {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json");
try {
const records = await loadInstalledPluginIndexInstallRecords();
return records[PLUGIN_ID];
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
if (previousConfigPath === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
}
}
}
describe("openclaw plugins install ClawHub E2E", () => {
it("reports successful installs and repeat updates after persisting the install record", async () => {
const testServer = await startClawHubServer();
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-telemetry-e2e-"));
try {
const env = buildEnv(stateDir, testServer.registry);
const first = await spawnOpenClaw(
["plugins", "install", `clawhub:${PACKAGE_NAME}@${PACKAGE_VERSION}`],
{ cwd: process.cwd(), env },
);
expect(first.status, first.stderr || first.stdout).toBe(0);
const record = await readPersistedInstallRecord(stateDir);
expect(record).toMatchObject({
source: "clawhub",
clawhubPackage: PACKAGE_NAME,
version: PACKAGE_VERSION,
});
expect(testServer.telemetryBodies).toEqual([
{
event: "plugin_install",
packageName: PACKAGE_NAME,
version: PACKAGE_VERSION,
},
]);
const repeat = await spawnOpenClaw(
["plugins", "install", `clawhub:${PACKAGE_NAME}@${PACKAGE_VERSION}`, "--force"],
{ cwd: process.cwd(), env },
);
expect(repeat.status, repeat.stderr || repeat.stdout).toBe(0);
expect(testServer.telemetryBodies).toHaveLength(2);
expect(testServer.telemetryBodies[1]).toEqual(testServer.telemetryBodies[0]);
} finally {
await testServer.close();
await fs.rm(stateDir, { recursive: true, force: true });
}
}, 60_000);
it("does not report success when plugin installation fails", async () => {
const testServer = await startClawHubServer({ artifactSha256: "0".repeat(64) });
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-telemetry-fail-"));
try {
const result = await spawnOpenClaw(
["plugins", "install", `clawhub:${PACKAGE_NAME}@${PACKAGE_VERSION}`],
{ cwd: process.cwd(), env: buildEnv(stateDir, testServer.registry) },
);
expect(result.status).not.toBe(0);
expect(testServer.telemetryBodies).toEqual([]);
await expect(readPersistedInstallRecord(stateDir)).resolves.toBeUndefined();
} finally {
await testServer.close();
await fs.rm(stateDir, { recursive: true, force: true });
}
}, 30_000);
it("keeps a valid local install successful when telemetry is unavailable", async () => {
const testServer = await startClawHubServer({ telemetryStatus: 503 });
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-telemetry-down-"));
try {
const result = await spawnOpenClaw(
["plugins", "install", `clawhub:${PACKAGE_NAME}@${PACKAGE_VERSION}`],
{ cwd: process.cwd(), env: buildEnv(stateDir, testServer.registry) },
);
expect(result.status, result.stderr || result.stdout).toBe(0);
await expect(readPersistedInstallRecord(stateDir)).resolves.toMatchObject({
source: "clawhub",
clawhubPackage: PACKAGE_NAME,
version: PACKAGE_VERSION,
});
expect(testServer.telemetryBodies).toHaveLength(1);
} finally {
await testServer.close();
await fs.rm(stateDir, { recursive: true, force: true });
}
}, 30_000);
});