diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index e7d8d050d038..ece3456082df 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1540,6 +1540,13 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs", ["test/scripts/docker-build-helper.test.ts"], ], + [ + "scripts/e2e/lib/clawhub-fixture-server.cjs", + [ + "test/scripts/clawhub-fixture-server.test.ts", + "test/scripts/plugin-prerelease-test-plan.test.ts", + ], + ], ["scripts/e2e/lib/config-reload/assert-log.mjs", ["test/scripts/e2e-mock-config-limits.test.ts"]], [ "scripts/e2e/lib/config-reload/mutate-metadata.mjs", diff --git a/test/scripts/clawhub-fixture-server.test.ts b/test/scripts/clawhub-fixture-server.test.ts new file mode 100644 index 000000000000..cd466dbd9e04 --- /dev/null +++ b/test/scripts/clawhub-fixture-server.test.ts @@ -0,0 +1,125 @@ +// ClawHub Fixture Server tests cover the local package fixture HTTP contract. +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; + +const SCRIPT_PATH = "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"; +const tempDirs: string[] = []; +const servers: ChildProcessWithoutNullStreams[] = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(stopServer)); + cleanupTempDirs(tempDirs); +}); + +function collectStream(stream: NodeJS.ReadableStream) { + let text = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk: string) => { + text += chunk; + }); + return () => text; +} + +async function stopServer(child: ChildProcessWithoutNullStreams) { + 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)]); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await exited; + } +} + +async function startFixtureServer(profile: string) { + const root = makeTempDir(tempDirs, "openclaw-clawhub-fixture-server-"); + const portFile = path.join(root, "port"); + const child = spawn(process.execPath, [SCRIPT_PATH, profile, portFile], { + cwd: process.cwd(), + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + const readStdout = collectStream(child.stdout); + const readStderr = collectStream(child.stderr); + servers.push(child); + + for (let attempt = 0; attempt < 100; 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(25); + } + + 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(); +} + +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 ", + ); + }); +}); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 643efa26fa95..260d175b80e9 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -477,6 +477,13 @@ describe("scripts/test-projects changed-target routing", () => { "scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs", ["test/scripts/docker-build-helper.test.ts"], ], + [ + "scripts/e2e/lib/clawhub-fixture-server.cjs", + [ + "test/scripts/clawhub-fixture-server.test.ts", + "test/scripts/plugin-prerelease-test-plan.test.ts", + ], + ], [ "scripts/e2e/lib/config-reload/assert-log.mjs", ["test/scripts/e2e-mock-config-limits.test.ts"], @@ -938,10 +945,7 @@ describe("scripts/test-projects changed-target routing", () => { }); it("routes unmatched script changes to the tooling suite instead of skipping tests", () => { - const targets = [ - "scripts/check-no-raw-http2-imports.mjs", - "scripts/e2e/lib/clawhub-fixture-server.cjs", - ]; + const targets = ["scripts/check-no-raw-http2-imports.mjs"]; expect(resolveChangedTestTargetPlan(targets)).toEqual({ mode: "targets",