import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; import { createServer as createNetServer, type Server as NetServer, type Socket } from "node:net"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; const tempDirs: string[] = []; const probePath = path.resolve("scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs"); const runtimeSmokePath = path.resolve( "scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs", ); const sweepPath = path.resolve("scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh"); type PluginListEntry = { id: string; origin: string; rootDir: string; }; function makePackageRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-probe-")); tempDirs.push(root); fs.writeFileSync(path.join(root, "package.json"), '{"type":"module"}\n', "utf8"); fs.mkdirSync(path.join(root, "dist"), { recursive: true }); return root; } function writePluginsList(root: string, plugins: PluginListEntry[]): void { fs.writeFileSync( path.join(root, "dist", "index.js"), [ `const plugins = ${JSON.stringify(plugins)};`, "if (process.argv.slice(2).join(' ') !== 'plugins list --json') {", " console.error(`unexpected argv: ${process.argv.slice(2).join(' ')}`);", " process.exit(1);", "}", "console.log(JSON.stringify({ plugins }));", "", ].join("\n"), "utf8", ); } function writePluginManifest(root: string, pluginRoot: string, manifest: Record) { const dir = path.join(root, pluginRoot); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, "openclaw.plugin.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8", ); } function runProbe(root: string, env: Record = {}) { const childEnv = { ...process.env, ...env }; for (const [key, value] of Object.entries(childEnv)) { if (value === undefined) { delete childEnv[key]; } } childEnv.OPENCLAW_ENTRY = path.join(root, "dist", "index.js"); return spawnSync(process.execPath, [probePath, "select"], { cwd: root, encoding: "utf8", env: childEnv as NodeJS.ProcessEnv, }); } function runProbeCommand(root: string, args: string[], env: Record) { const childEnv = { ...process.env, ...env }; for (const [key, value] of Object.entries(childEnv)) { if (value === undefined) { delete childEnv[key]; } } childEnv.OPENCLAW_ENTRY = path.join(root, "dist", "index.js"); return spawnSync(process.execPath, [probePath, ...args], { cwd: root, encoding: "utf8", env: childEnv as NodeJS.ProcessEnv, }); } function runRuntimeSmoke(root: string, args: string[]) { return spawnSync(process.execPath, [runtimeSmokePath, ...args], { cwd: root, encoding: "utf8", env: { ...process.env, OPENCLAW_ENTRY: path.join(root, "dist", "index.js"), }, }); } async function listenOnLoopback(server: HttpServer | NetServer): Promise { return new Promise((resolve, reject) => { const onError = (error: Error) => { server.off("error", onError); reject(error); }; server.once("error", onError); server.listen(0, "127.0.0.1", () => { server.off("error", onError); const address = server.address(); if (!address || typeof address === "string") { reject(new Error("server did not bind to a TCP port")); return; } resolve(address.port); }); }); } async function closeServer(server: HttpServer | NetServer): Promise { await new Promise((resolve, reject) => { server.close((error?: Error) => { if (error) { reject(error); return; } resolve(); }); }); } afterEach(() => { vi.restoreAllMocks(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { force: true, recursive: true }); } }); describe("bundled plugin install/uninstall probe", () => { it("keeps the sweep script compatible with macOS Bash 3", () => { const sweep = fs.readFileSync(sweepPath, "utf8"); expect(sweep).not.toContain("mapfile "); expect(sweep).not.toContain("readarray "); }); it("keeps runtime command output capture bounded", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const first = runtimeSmoke.appendBoundedOutput({ text: "", truncatedChars: 0 }, "abcdef", 5); expect(first).toEqual({ text: "bcdef", truncatedChars: 1 }); const second = runtimeSmoke.appendBoundedOutput(first, "ghij", 5); expect(second).toEqual({ text: "fghij", truncatedChars: 5 }); }); it("keeps runtime log tail reads bounded", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const root = makePackageRoot(); const logPath = path.join(root, "gateway.log"); fs.writeFileSync(logPath, `${"old log line\n".repeat(1000)}[gateway] ready\n`, "utf8"); const fullRead = vi.spyOn(fs, "readFileSync"); const tail = runtimeSmoke.readFileTail(logPath, 64); expect(tail).toContain("[gateway] ready"); expect(Buffer.byteLength(tail)).toBeLessThanOrEqual(64); expect(fullRead).not.toHaveBeenCalled(); }); it("remembers runtime ready logs after they fall outside the tail", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const root = makePackageRoot(); const logPath = path.join(root, "gateway.log"); const readyLogSeen = runtimeSmoke.createReadyLogScanner(logPath); fs.writeFileSync(logPath, `[gateway] ready\n${"x".repeat(300_000)}`, "utf8"); expect(readyLogSeen()).toBe(true); fs.appendFileSync(logPath, "more log output".repeat(30_000), "utf8"); expect(readyLogSeen()).toBe(true); }); it("treats signaled gateway children as already stopped", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const child = { exitCode: null, kill: vi.fn(), signalCode: "SIGTERM", }; expect(runtimeSmoke.hasChildExited(child)).toBe(true); await runtimeSmoke.stopGateway(child); expect(child.kill).not.toHaveBeenCalled(); }); it("does not treat shallow HTTP listen logs as runtime readiness", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const root = makePackageRoot(); const logPath = path.join(root, "gateway.log"); const readyLogSeen = runtimeSmoke.createReadyLogScanner(logPath); fs.writeFileSync(logPath, "[gateway] http server listening\n", "utf8"); expect(readyLogSeen()).toBe(false); }); it("scans only post-ready runtime logs for dependency work", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const root = makePackageRoot(); const logPath = path.join(root, "gateway.log"); fs.writeFileSync( logPath, `pre-ready npm install is allowed here\n${"x".repeat(300_000)}\n[gateway] ready\nruntime ok\n`, "utf8", ); const fullRead = vi.spyOn(fs, "readFileSync"); const readyOffset = runtimeSmoke.findReadyLogOffset(logPath); expect(() => runtimeSmoke.assertNoPostReadyRuntimeDepsWork(logPath, readyOffset)).not.toThrow(); expect(fullRead).not.toHaveBeenCalled(); fs.appendFileSync(logPath, "post-ready pnpm install should fail\n", "utf8"); expect(() => runtimeSmoke.assertNoPostReadyRuntimeDepsWork(logPath, readyOffset)).toThrow( /post-ready runtime dependency work/u, ); }); it("keeps post-ready scans anchored when ready logs fall outside the tail", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const root = makePackageRoot(); const logPath = path.join(root, "gateway.log"); fs.writeFileSync( logPath, `startup\n[gateway] ready\npost-ready yarn install should fail\n${"x".repeat(300_000)}`, "utf8", ); const readyOffset = runtimeSmoke.findReadyLogOffset(logPath); expect(readyOffset).toBe("startup\n".length); expect(() => runtimeSmoke.assertNoPostReadyRuntimeDepsWork(logPath, readyOffset)).toThrow( /post-ready runtime dependency work/u, ); }); it("bounds runtime smoke child commands and preserves captured output", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const startedAt = Date.now(); await expect( runtimeSmoke.runCommand( process.execPath, [ "-e", "process.stdout.write('partial\\n'); process.stderr.write('problem\\n'); setInterval(() => {}, 1000);", ], { timeoutMs: 200 }, ), ).rejects.toThrow(/timed out after 200ms[\s\S]*partial[\s\S]*problem/u); expect(Date.now() - startedAt).toBeLessThan(2_500); }); it("accepts successful runtime HTTP probes", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const server = createHttpServer((_request, response) => { response.writeHead(204); response.end(); }); try { const port = await listenOnLoopback(server); await expect(runtimeSmoke.httpOk(port, "/healthz", { timeoutMs: 1000 })).resolves.toBe(true); } finally { await closeServer(server); } }); it("bounds stalled runtime HTTP probes", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const sockets = new Set(); const server = createNetServer((socket) => { sockets.add(socket); socket.on("close", () => { sockets.delete(socket); }); }); try { const port = await listenOnLoopback(server); const startedAt = Date.now(); await expect(runtimeSmoke.httpOk(port, "/healthz", { timeoutMs: 100 })).resolves.toBe(false); expect(Date.now() - startedAt).toBeLessThan(2_500); } finally { for (const socket of sockets) { socket.destroy(); } await closeServer(server); } }); it("creates runtime smoke state with OPENCLAW_HOME at the test home", async () => { const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href); const env = runtimeSmoke.createIsolatedStateEnv("runtime-env"); tempDirs.push(path.dirname(env.HOME)); expect(env.USERPROFILE).toBe(env.HOME); expect(env.OPENCLAW_HOME).toBe(env.HOME); expect(env.OPENCLAW_STATE_DIR).toBe(path.join(env.HOME, ".openclaw")); expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join(env.OPENCLAW_STATE_DIR, "openclaw.json")); }); it("selects packaged installable bundled sources instead of raw dist extension dirs", () => { const root = makePackageRoot(); fs.mkdirSync(path.join(root, "dist", "extensions", "qa-channel"), { recursive: true }); fs.writeFileSync( path.join(root, "dist", "extensions", "qa-channel", "openclaw.plugin.json"), '{"id":"qa-channel"}\n', "utf8", ); writePluginManifest(root, "dist-runtime/extensions/admin-http-rpc", { id: "admin-http-rpc", configSchema: { required: ["port"] }, }); writePluginsList(root, [ { id: "admin-http-rpc", origin: "bundled", rootDir: path.join(root, "dist-runtime", "extensions", "admin-http-rpc"), }, ]); const result = runProbe(root, { OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: undefined, }); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe( `admin-http-rpc\tadmin-http-rpc\t1\t${path.join(root, "dist-runtime", "extensions", "admin-http-rpc")}`, ); }); it("does not select source-only bundled plugins for package-backed sweeps", () => { const root = makePackageRoot(); writePluginManifest(root, "extensions/qa-channel", { id: "qa-channel", }); writePluginManifest(root, "dist-runtime/extensions/clickclack", { id: "clickclack", }); writePluginsList(root, [ { id: "qa-channel", origin: "bundled", rootDir: path.join(root, "extensions", "qa-channel"), }, { id: "clickclack", origin: "bundled", rootDir: path.join(root, "dist-runtime", "extensions", "clickclack"), }, ]); const result = runProbe(root, { OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: "qa-channel", }); expect(result.status).toBe(1); expect(result.stderr).toContain( "OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS entry is not an installable bundled plugin in this package: qa-channel", ); expect(result.stderr).toContain("Available: clickclack"); }); it("fails explicit ids that are not installable in the packaged runtime", () => { const root = makePackageRoot(); writePluginManifest(root, "dist-runtime/extensions/admin-http-rpc", { id: "admin-http-rpc", }); writePluginsList(root, [ { id: "admin-http-rpc", origin: "bundled", rootDir: path.join(root, "dist-runtime", "extensions", "admin-http-rpc"), }, ]); const result = runProbe(root, { OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: "qa-channel", }); expect(result.status).toBe(1); expect(result.stderr).toContain( "OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS entry is not an installable bundled plugin in this package: qa-channel", ); expect(result.stderr).toContain("Available: admin-http-rpc"); }); it("bounds plugin list selection when the CLI hangs", () => { const root = makePackageRoot(); fs.writeFileSync( path.join(root, "dist", "index.js"), "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);\n", "utf8", ); const startedAt = Date.now(); const result = runProbe(root, { OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS: "100", }); expect(Date.now() - startedAt).toBeLessThan(2_500); expect(result.status).toBe(1); expect(result.stderr).toContain("Timed out listing packaged bundled plugins after 100ms"); }); it("loads runtime smoke manifests from the selected packaged root", () => { const root = makePackageRoot(); writePluginManifest(root, "dist/extensions/runtime-only", { id: "runtime-only", contracts: { speechProviders: ["stale-provider"] }, }); fs.mkdirSync(path.join(root, "dist-runtime", "extensions", "runtime-only"), { recursive: true, }); fs.writeFileSync( path.join(root, "dist-runtime", "extensions", "runtime-only", "openclaw.plugin.json"), '{"id":"runtime-only"}\n', "utf8", ); const result = runRuntimeSmoke(root, [ "tts-global-disable", "runtime-only", "runtime-only", "0", "0", path.join(root, "dist-runtime", "extensions", "runtime-only"), "", ]); expect(result.status).toBe(0); expect(result.stdout).toContain( "Global-disable TTS smoke skipped for runtime-only: no speech provider contract", ); }); it("accepts native Windows bundled source paths when asserting install state", () => { const root = makePackageRoot(); const stateDir = path.join(root, "state"); const windowsSourcePath = "C:\\crabbox\\qa-windows\\dist\\extensions\\nostr"; fs.mkdirSync(path.join(stateDir, "plugins"), { recursive: true }); fs.writeFileSync( path.join(stateDir, "openclaw.json"), JSON.stringify({ plugins: { entries: { nostr: { enabled: true } } } }), "utf8", ); fs.writeFileSync( path.join(stateDir, "plugins", "installs.json"), JSON.stringify({ installRecords: { nostr: { source: "path", sourcePath: windowsSourcePath, installPath: windowsSourcePath, }, }, }), "utf8", ); writePluginsList(root, []); const result = runProbeCommand(root, ["assert-installed", "nostr", "nostr", "0"], { HOME: undefined, OPENCLAW_STATE_DIR: stateDir, }); expect(result.status).toBe(0); }); it("detects native Windows bundled load paths after uninstall", () => { const root = makePackageRoot(); const stateDir = path.join(root, "state"); fs.mkdirSync(path.join(stateDir, "plugins"), { recursive: true }); fs.writeFileSync( path.join(stateDir, "openclaw.json"), JSON.stringify({ plugins: { load: { paths: ["C:\\crabbox\\qa-windows\\dist\\extensions\\nostr"] } }, }), "utf8", ); fs.writeFileSync( path.join(stateDir, "plugins", "installs.json"), JSON.stringify({ installRecords: {} }), "utf8", ); writePluginsList(root, []); const result = runProbeCommand(root, ["assert-uninstalled", "nostr", "nostr"], { HOME: undefined, OPENCLAW_STATE_DIR: stateDir, }); expect(result.status).toBe(1); expect(result.stderr).toContain("load path still present after uninstall for nostr"); }); });