// ClawHub Fixture Server tests cover the local package fixture HTTP contract. import { execFileSync, spawn, spawnSync, type ChildProcessByStdio } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { Readable } from "node:stream"; import { setTimeout as delay } from "node:timers/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureClawHubPackageTrustAcknowledged } from "../../src/infra/clawhub-install-trust.js"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; const SCRIPT_PATH = path.resolve("scripts/e2e/lib/clawhub-fixture-server.cjs"); const PACKAGE_NAME = "@openclaw/kitchen-sink"; const PACKAGE_PATH = `/api/v1/packages/${encodeURIComponent(PACKAGE_NAME)}`; const KITCHEN_SINK_VERSION = "0.2.5"; type FixtureServerChild = ChildProcessByStdio; const servers: FixtureServerChild[] = []; afterEach(async () => { vi.unstubAllGlobals(); await Promise.all(servers.splice(0).map(stopServer)); }); const tempDirs = useAutoCleanupTempDirTracker(afterEach); function collectStream(stream: NodeJS.ReadableStream) { let text = ""; stream.setEncoding("utf8"); stream.on("data", (chunk: string) => { text += chunk; }); return () => text; } async function stopServer(child: FixtureServerChild) { if (child.exitCode !== null || child.signalCode !== null) { return; } const exited = new Promise((resolve) => { child.once("exit", () => resolve()); }); child.kill("SIGTERM"); await Promise.race([exited, delay(1_000, undefined, { ref: false })]); if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); await exited; } } async function startFixtureServer(profile: string, args: string[] = [], cwd = process.cwd()) { const root = tempDirs.make("openclaw-clawhub-fixture-server-"); const portFile = path.join(root, "port"); const child = spawn(process.execPath, [SCRIPT_PATH, profile, portFile, ...args], { cwd, env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"], }); const readStdout = collectStream(child.stdout); const readStderr = collectStream(child.stderr); servers.push(child); // Preserve the 2.5-second startup budget while detecting the port file sooner. for (let attempt = 0; attempt < 500; attempt += 1) { if (existsSync(portFile)) { const port = Number(readFileSync(portFile, "utf8")); if (Number.isInteger(port) && port > 0) { return { baseUrl: `http://127.0.0.1:${port}` }; } } if (child.exitCode !== null) { throw new Error(`fixture server exited early: stdout=${readStdout()} stderr=${readStderr()}`); } await delay(5); } throw new Error(`fixture server did not write a port: stderr=${readStderr()}`); } async function fetchJson(baseUrl: string, requestPath: string) { const response = await fetch(`${baseUrl}${requestPath}`); expect(response.status).toBe(200); return response.json(); } function runPrepublishAssertion( baseUrl?: string, packageName?: string, version?: string, securityMode?: "required" | "absent", cwd = process.cwd(), ) { return spawnSync( process.execPath, [ SCRIPT_PATH, "assert-prepublish-requests", baseUrl ?? "", packageName ?? "", version ?? "", ...(securityMode ? [securityMode] : []), ], { cwd, encoding: "utf8", env: { ...process.env } }, ); } function runNoRequestsAssertion(baseUrl?: string, cwd = process.cwd()) { return spawnSync(process.execPath, [SCRIPT_PATH, "assert-no-requests", baseUrl ?? ""], { cwd, encoding: "utf8", env: { ...process.env }, }); } describe("ClawHub fixture server", () => { it("serves package metadata and npm-pack artifacts for kitchen-sink fixtures", async () => { const { baseUrl } = await startFixtureServer("kitchen-sink-plugin"); const packageDetail = await fetchJson(baseUrl, PACKAGE_PATH); expect(packageDetail.package.name).toBe(PACKAGE_NAME); expect(packageDetail.package.latestVersion).toBe(KITCHEN_SINK_VERSION); expect(packageDetail.package.artifact.format).toBe("tgz"); const versionDetail = await fetchJson( baseUrl, `${PACKAGE_PATH}/versions/${KITCHEN_SINK_VERSION}/artifact`, ); expect(versionDetail.artifact).toMatchObject({ artifactKind: "npm-pack", packageName: PACKAGE_NAME, source: "clawhub", version: KITCHEN_SINK_VERSION, }); const artifactResponse = await fetch( `${baseUrl}${PACKAGE_PATH}/versions/${KITCHEN_SINK_VERSION}/artifact/download`, ); expect(artifactResponse.status).toBe(200); expect(artifactResponse.headers.get("x-clawhub-artifact-type")).toBe("npm-pack-tarball"); expect(artifactResponse.headers.get("x-clawhub-artifact-sha256")).toMatch(/^[a-f0-9]{64}$/u); expect(Buffer.from(await artifactResponse.arrayBuffer()).length).toBeGreaterThan(100); const missingResponse = await fetch(`${baseUrl}/missing`); expect(missingResponse.status).toBe(404); const methodResponse = await fetch(`${baseUrl}${PACKAGE_PATH}`, { method: "POST" }); expect(methodResponse.status).toBe(405); }); it("rejects missing startup arguments before binding a fixture server", () => { const result = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: process.cwd(), encoding: "utf8", env: { ...process.env }, }); expect(result.status).toBe(1); expect(result.stderr).toContain( "usage: clawhub-fixture-server.cjs [manifest-file]", ); const assertion = runPrepublishAssertion(); expect(assertion.status).toBe(1); expect(assertion.stderr).toContain( "assert-prepublish-requests requires ", ); const emptyAssertion = runNoRequestsAssertion(); expect(emptyAssertion.status).toBe(1); expect(emptyAssertion.stderr).toContain("assert-no-requests requires "); }); it("parks WhatsApp startup config and restores the authored bytes exactly", () => { const root = tempDirs.make("openclaw-clawhub-auth-config-"); const configPath = path.join(root, "openclaw.json"); const snapshotPath = path.join(root, "openclaw.authored.json"); const authoredConfig = `{ "gateway": { "mode": "local", "reload": { "mode": "hybrid" } }, "plugins": { "allow": ["discord", "whatsapp"], "entries": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } } }, "channels": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } } } `; writeFileSync(configPath, authoredConfig); const park = spawnSync( process.execPath, [SCRIPT_PATH, "park-prepublish-auth-config", configPath, snapshotPath], { encoding: "utf8", env: { ...process.env } }, ); expect(park.status, park.stderr).toBe(0); expect(readFileSync(snapshotPath, "utf8")).toBe(authoredConfig); expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({ gateway: { mode: "local", reload: { mode: "off" } }, plugins: { allow: ["discord"], entries: { discord: { enabled: true } }, }, channels: { discord: { enabled: true } }, }); const restore = spawnSync( process.execPath, [SCRIPT_PATH, "restore-prepublish-auth-config", configPath, snapshotPath], { encoding: "utf8", env: { ...process.env } }, ); expect(restore.status, restore.stderr).toBe(0); expect(readFileSync(configPath, "utf8")).toBe(authoredConfig); }); it("rejects malformed probe config without changing authored bytes", () => { const root = tempDirs.make("openclaw-clawhub-invalid-auth-config-"); const configPath = path.join(root, "openclaw.json"); const snapshotPath = path.join(root, "openclaw.authored.json"); const authoredConfig = '{"plugins":{"allow":"whatsapp"}}\n'; writeFileSync(configPath, authoredConfig); const park = spawnSync( process.execPath, [SCRIPT_PATH, "park-prepublish-auth-config", configPath, snapshotPath], { encoding: "utf8", env: { ...process.env } }, ); expect(park.status).toBe(1); expect(park.stderr).toContain("plugins.allow must be an array"); expect(readFileSync(configPath, "utf8")).toBe(authoredConfig); expect(existsSync(snapshotPath)).toBe(false); }); it("serves exact prepublish tarballs through the ClawHub artifact contract", async () => { const root = tempDirs.make("openclaw-clawhub-prepublish-"); const isolatedCwd = tempDirs.make("openclaw-clawhub-isolated-"); const packageDir = path.join(root, "package"); const tarball = "openclaw-whatsapp-2026.8.1-beta.1.tgz"; const tarballPath = path.join(root, tarball); const version = "2026.8.1-beta.1"; mkdirSync(packageDir); writeFileSync( path.join(packageDir, "package.json"), `${JSON.stringify({ name: "@openclaw/whatsapp", version })}\n`, ); writeFileSync( path.join(packageDir, "openclaw.plugin.json"), `${JSON.stringify({ id: "whatsapp", configSchema: { type: "object" } })}\n`, ); execFileSync("tar", ["-czf", tarballPath, "-C", root, "package"]); const archive = readFileSync(tarballPath); const sha256 = createHash("sha256").update(archive).digest("hex"); const npmIntegrity = `sha512-${createHash("sha512").update(archive).digest("base64")}`; const npmShasum = createHash("sha1").update(archive).digest("hex"); const manifestPath = path.join(root, "prepublish-plugin-registry.json"); writeFileSync( manifestPath, `${JSON.stringify({ packages: [{ name: "@openclaw/whatsapp", version, tarball, sha256 }], })}\n`, ); const { baseUrl } = await startFixtureServer( "prepublish-artifacts", [manifestPath], isolatedCwd, ); expect(runNoRequestsAssertion(baseUrl, isolatedCwd).status).toBe(0); const whatsappPath = `/api/v1/packages/${encodeURIComponent("@openclaw/whatsapp")}`; const detail = await fetchJson(baseUrl, whatsappPath); expect(detail.package).toMatchObject({ latestVersion: version, runtimeId: "whatsapp", tags: { beta: version, latest: version }, }); const artifact = await fetchJson(baseUrl, `${whatsappPath}/versions/${version}/artifact`); expect(artifact.artifact).toMatchObject({ artifactKind: "npm-pack", artifactSha256: sha256, }); const securityUrl = `${baseUrl}${whatsappPath}/versions/${version}/security`; const fetchImpl = globalThis.fetch; let security: unknown; vi.stubGlobal("fetch", async (input: string | URL | Request, init?: RequestInit) => { const response = await fetchImpl(input, init); const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (requestUrl === securityUrl) { security = await response.clone().json(); } return response; }); const trust = await ensureClawHubPackageTrustAcknowledged({ subject: { kind: "plugin", packageName: "@openclaw/whatsapp" }, version, baseUrl, mode: "update", }); expect(security).toEqual({ package: { name: "@openclaw/whatsapp", displayName: "@openclaw/whatsapp", family: "code-plugin", }, release: { releaseId: `fixture:@openclaw/whatsapp@${version}`, version, artifactKind: "npm-pack", artifactSha256: sha256, npmIntegrity, npmShasum, npmTarballName: tarball, createdAt: 0, }, trust: { scanStatus: "clean", moderationState: null, blockedFromDownload: false, reasons: [], pending: false, stale: false, }, }); expect(trust).toEqual({ ok: true, trustInstallRecordFields: { clawhubTrustDisposition: "clean", clawhubTrustScanStatus: "clean", clawhubTrustCheckedAt: expect.any(String), }, }); const download = await fetch(`${baseUrl}${whatsappPath}/versions/${version}/artifact/download`); expect(download.headers.get("x-clawhub-artifact-sha256")).toBe(sha256); expect(Buffer.from(await download.arrayBuffer())).toEqual(archive); expect((await fetchJson(baseUrl, "/__fixture__/requests")).requests).toEqual([ `GET ${whatsappPath}`, `GET ${whatsappPath}/versions/${version}/artifact`, `GET ${whatsappPath}/versions/${version}/security`, `GET ${whatsappPath}/versions/${version}/artifact/download`, ]); expect( runPrepublishAssertion(baseUrl, "@openclaw/whatsapp", version, undefined, isolatedCwd).status, ).toBe(0); const unexpectedStartupRequest = runNoRequestsAssertion(baseUrl, isolatedCwd); expect(unexpectedStartupRequest.status).toBe(1); expect(unexpectedStartupRequest.stderr).toContain("unexpected ClawHub fixture requests"); expect((await fetch(`${baseUrl}${whatsappPath}/versions/0.0.0/artifact`)).status).toBe(404); const mismatch = runPrepublishAssertion(baseUrl, "@openclaw/whatsapp", version); expect(mismatch.status).toBe(1); expect(mismatch.stderr).toContain("unexpected ClawHub fixture requests"); }); it("serves separate plugin-family and skill search fixtures", async () => { const { baseUrl } = await startFixtureServer("catalog-search"); const codePlugins = await fetchJson( baseUrl, "/api/v1/packages/search?q=calendar&family=code-plugin&limit=5", ); expect(codePlugins.results).toEqual( expect.arrayContaining([ expect.objectContaining({ score: 4, package: expect.objectContaining({ name: "@acme/calendar", family: "code-plugin", }), }), ]), ); const bundlePlugins = await fetchJson( baseUrl, "/api/v1/packages/search?q=calendar&family=bundle-plugin&limit=5", ); expect(bundlePlugins.results).toEqual( expect.arrayContaining([ expect.objectContaining({ score: 12, package: expect.objectContaining({ name: "@acme/calendar", family: "bundle-plugin", }), }), ]), ); const skills = await fetchJson(baseUrl, "/api/v1/search?q=calendar&limit=5"); expect(skills.results).toEqual([ expect.objectContaining({ score: 99, slug: "calendar-skill", }), ]); const empty = await fetchJson( baseUrl, "/api/v1/packages/search?q=empty&family=code-plugin&limit=5", ); expect(empty).toEqual({ results: [] }); const unavailable = await fetch( `${baseUrl}/api/v1/packages/search?q=unavailable&family=code-plugin&limit=5`, ); expect(unavailable.status).toBe(503); await expect(unavailable.json()).resolves.toEqual({ error: "catalog unavailable" }); }); });