diff --git a/package.json b/package.json index 8ddc165996d..92797a51b45 100644 --- a/package.json +++ b/package.json @@ -1622,6 +1622,7 @@ "test:docker:e2e-build": "bash scripts/e2e/build-image.sh", "test:docker:gateway-network": "bash scripts/e2e/gateway-network-docker.sh", "test:docker:kitchen-sink-plugin": "bash scripts/e2e/kitchen-sink-plugin-docker.sh", + "test:docker:kitchen-sink-rpc": "bash scripts/e2e/kitchen-sink-rpc-docker.sh", "test:docker:live-acp-bind": "bash scripts/test-live-acp-bind-docker.sh", "test:docker:live-acp-bind:claude": "OPENCLAW_LIVE_ACP_BIND_AGENT=claude bash scripts/test-live-acp-bind-docker.sh", "test:docker:live-acp-bind:codex": "OPENCLAW_LIVE_ACP_BIND_AGENT=codex bash scripts/test-live-acp-bind-docker.sh", diff --git a/scripts/e2e/kitchen-sink-rpc-docker.sh b/scripts/e2e/kitchen-sink-rpc-docker.sh new file mode 100755 index 00000000000..c7a6df131bd --- /dev/null +++ b/scripts/e2e/kitchen-sink-rpc-docker.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-kitchen-sink-rpc-e2e" OPENCLAW_KITCHEN_SINK_RPC_E2E_IMAGE)" +MAX_MEMORY_MIB="${OPENCLAW_KITCHEN_SINK_MAX_MEMORY_MIB:-2048}" +MAX_CPU_PERCENT="${OPENCLAW_KITCHEN_SINK_MAX_CPU_PERCENT:-1200}" +CONTAINER_NAME="openclaw-kitchen-sink-rpc-e2e-$$" +RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-rpc.XXXXXX")" +STATS_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-rpc-stats.XXXXXX")" + +cleanup() { + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + rm -f "$RUN_LOG" "$STATS_LOG" +} +trap cleanup EXIT + +docker_e2e_build_or_reuse "$IMAGE_NAME" kitchen-sink-rpc + +DOCKER_ENV_ARGS=( + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + -e OPENCLAW_ENTRY=/app/openclaw.mjs +) + +for env_name in \ + OPENCLAW_KITCHEN_SINK_NPM_SPEC \ + OPENCLAW_KITCHEN_SINK_PLUGIN_ID \ + OPENCLAW_KITCHEN_SINK_PERSONALITY \ + OPENCLAW_KITCHEN_SINK_RPC_READY_MS \ + OPENCLAW_KITCHEN_SINK_RPC_COMMAND_MS \ + OPENCLAW_KITCHEN_SINK_RPC_INSTALL_MS \ + OPENCLAW_KITCHEN_SINK_RPC_CALL_MS \ + OPENCLAW_KITCHEN_SINK_MAX_RSS_MIB; do + env_value="${!env_name:-}" + if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then + DOCKER_ENV_ARGS+=(-e "$env_name") + fi +done + +echo "Running kitchen-sink RPC Docker E2E..." +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +docker_e2e_harness_mount_args +docker run --name "$CONTAINER_NAME" "${DOCKER_E2E_HARNESS_ARGS[@]}" "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" \ + node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mjs >"$RUN_LOG" 2>&1 & +docker_pid="$!" + +while kill -0 "$docker_pid" 2>/dev/null; do + if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then + docker stats --no-stream --format '{{json .}}' "$CONTAINER_NAME" >>"$STATS_LOG" 2>/dev/null || true + fi + sleep 2 +done + +set +e +wait "$docker_pid" +run_status="$?" +set -e + +cat "$RUN_LOG" + +node scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" kitchen-sink-rpc + +exit "$run_status" diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index 6674b2985a8..3aac8df6666 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -5,7 +5,6 @@ import path from "node:path"; import process from "node:process"; import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; -import { createPnpmRunnerSpawnSpec } from "../pnpm-runner.mjs"; const PLUGIN_SPEC = process.env.OPENCLAW_KITCHEN_SINK_NPM_SPEC || "npm:@openclaw/kitchen-sink@latest"; @@ -130,7 +129,7 @@ function runCommand(command, args, options = {}) { } async function runOpenClaw(runner, args, env, options = {}) { - const command = resolveOpenClawCommand(runner, args, env, { + const command = await resolveOpenClawCommand(runner, args, env, { stdio: ["ignore", "pipe", "pipe"], }); return runCommand(command.command, command.args, { @@ -140,8 +139,9 @@ async function runOpenClaw(runner, args, env, options = {}) { }); } -function resolveOpenClawCommand(runner, args, env, options = {}) { +async function resolveOpenClawCommand(runner, args, env, options = {}) { if (runner.pnpm) { + const { createPnpmRunnerSpawnSpec } = await import("../pnpm-runner.mjs"); return createPnpmRunnerSpawnSpec({ env, pnpmArgs: [...runner.baseArgs, ...args], @@ -242,12 +242,28 @@ async function rpcCall(method, params, options) { } async function loadCallGatewayModule() { - callGatewayModulePromise ??= import( - pathToFileURL(path.join(process.cwd(), "src/gateway/call.ts")) - ); + callGatewayModulePromise ??= importCallGatewayModule(); return callGatewayModulePromise; } +async function importCallGatewayModule() { + const distDir = path.join(process.cwd(), "dist"); + if (fs.existsSync(distDir)) { + const candidates = fs + .readdirSync(distDir) + .filter((name) => /^call(?:\.runtime)?-[A-Za-z0-9_-]+\.js$/u.test(name)) + .toSorted((left, right) => left.localeCompare(right)); + for (const name of candidates) { + const module = await import(pathToFileURL(path.join(distDir, name)).href); + if (typeof module.callGateway === "function") { + return module; + } + } + throw new Error(`unable to find callGateway export in dist (${candidates.join(", ")})`); + } + return import(pathToFileURL(path.join(process.cwd(), "src/gateway/call.ts")).href); +} + async function retryRpcCall(method, params, options) { const started = Date.now(); let lastError; @@ -347,18 +363,11 @@ function configureKitchenSink(env, port) { writeJson(configPath, config); } -function startGateway(runner, port, env, logPath) { +async function startGateway(runner, port, env, logPath) { const log = fs.openSync(logPath, "w"); - const command = resolveOpenClawCommand( + const command = await resolveOpenClawCommand( runner, - [ - "gateway", - "--port", - String(port), - "--bind", - "loopback", - "--allow-unconfigured", - ], + ["gateway", "--port", String(port), "--bind", "loopback", "--allow-unconfigured"], env, { stdio: ["ignore", log, log], @@ -599,7 +608,7 @@ async function main() { ]; assertIncludesAny(inspectProviders, EXPECTED_PROVIDERS, "plugins inspect providers"); - const child = startGateway(runner, port, env, logPath); + const child = await startGateway(runner, port, env, logPath); try { await waitForGatewayReady(child, port, logPath); const initialSample = await sampleProcess(child.pid); diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 65ed119ccad..fe7e82ef349 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -224,6 +224,19 @@ function liveCodexNpmPluginLane() { ); } +function kitchenSinkRpcLane() { + return serviceLane( + "kitchen-sink-rpc", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:kitchen-sink-rpc", + { + resources: ["npm"], + stateScenario: "empty", + timeoutMs: 15 * 60 * 1000, + weight: 3, + }, + ); +} + export const mainLanes = [ liveLane("live-models", liveDockerScriptCommand("test-live-models-docker.sh"), { providers: ["claude-cli", "google-gemini-cli"], @@ -403,6 +416,7 @@ export const mainLanes = [ stateScenario: "empty", weight: 3, }), + kitchenSinkRpcLane(), ...bundledPluginInstallUninstallLanes, lane( "plugins-offline", @@ -587,6 +601,7 @@ const releasePathPluginRuntimeLanes = [ weight: 3, }, ), + kitchenSinkRpcLane(), serviceLane( "openai-web-search-minimal", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal", @@ -613,6 +628,7 @@ const releasePathPluginRuntimeServiceLanes = [ weight: 3, }, ), + kitchenSinkRpcLane(), serviceLane( "openai-web-search-minimal", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal", diff --git a/scripts/lib/plugin-prerelease-test-plan.mjs b/scripts/lib/plugin-prerelease-test-plan.mjs index 19917eaf467..adedb08c84e 100644 --- a/scripts/lib/plugin-prerelease-test-plan.mjs +++ b/scripts/lib/plugin-prerelease-test-plan.mjs @@ -15,6 +15,7 @@ export const PLUGIN_PRERELEASE_REQUIRED_SURFACES = Object.freeze([ "npm-registry-plugin", "clawhub-registry-plugin", "resource-guardrails", + "plugin-gateway-rpc", "live-ish-availability", ]); @@ -70,6 +71,18 @@ const pluginPrereleaseDockerLanes = Object.freeze([ "resource-guardrails", ], }, + { + lane: "kitchen-sink-rpc", + surfaces: [ + "external-plugins", + "sdk-compatibility", + "gateway-bootstrap", + "status-diagnostics", + "npm-registry-plugin", + "resource-guardrails", + "plugin-gateway-rpc", + ], + }, { lane: "plugin-update", surfaces: ["package-artifact", "update-no-op"], diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 3b52147524c..8dc9dea4024 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -365,6 +365,16 @@ describe("scripts/lib/docker-e2e-plan", () => { stateScenario: "empty", weight: 3, }, + { + command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:kitchen-sink-rpc", + imageKind: "functional", + live: false, + name: "kitchen-sink-rpc", + resources: ["docker", "service", "npm"], + stateScenario: "empty", + timeoutMs: 900_000, + weight: 3, + }, { command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-web-search-minimal", imageKind: "functional", @@ -504,6 +514,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "plugins", ...bundledPluginSweepLanes, "cron-mcp-cleanup", + "kitchen-sink-rpc", "openai-web-search-minimal", "live-plugin-tool", "openwebui", @@ -512,6 +523,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "plugins", ...bundledPluginSweepLanes, "cron-mcp-cleanup", + "kitchen-sink-rpc", "openai-web-search-minimal", "live-plugin-tool", "plugin-update", @@ -766,6 +778,7 @@ describe("scripts/lib/docker-e2e-plan", () => { "plugin-update", "plugins", "kitchen-sink-plugin", + "kitchen-sink-rpc", "bundled-plugin-install-uninstall-0", "commitments-safety", "update-channel-switch", @@ -792,6 +805,7 @@ describe("scripts/lib/docker-e2e-plan", () => { { name: "plugin-update", stateScenario: "empty" }, { name: "plugins", stateScenario: "empty" }, { name: "kitchen-sink-plugin", stateScenario: "empty" }, + { name: "kitchen-sink-rpc", stateScenario: "empty" }, { name: "bundled-plugin-install-uninstall-0", stateScenario: "empty" }, { name: "commitments-safety", stateScenario: "empty" }, { name: "update-channel-switch", stateScenario: "update-stable" }, diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index c68b50f6664..1f0694ff6a1 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -51,6 +51,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { "plugins-offline", "plugins", "kitchen-sink-plugin", + "kitchen-sink-rpc", "plugin-update", "config-reload", "gateway-network", @@ -162,6 +163,33 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { expect(sweepScript).toContain("scan_logs_for_unexpected_errors"); }); + it("keeps kitchen-sink RPC coverage package-backed and resource-guarded", () => { + const lane = getDockerLane("kitchen-sink-rpc"); + const script = readFileSync("scripts/e2e/kitchen-sink-rpc-docker.sh", "utf8"); + const walkScript = readFileSync("scripts/e2e/kitchen-sink-rpc-walk.mjs", "utf8"); + + expect(lane).toEqual({ + command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:kitchen-sink-rpc", + e2eImageKind: "functional", + live: false, + name: "kitchen-sink-rpc", + resources: ["service", "npm"], + retryPatterns: [], + retries: 0, + stateScenario: "empty", + timeoutMs: 900000, + weight: 3, + }); + expect(script).toContain("OPENCLAW_ENTRY=/app/openclaw.mjs"); + expect(script).toContain("docker stats --no-stream"); + expect(script).toContain("scripts/e2e/kitchen-sink-rpc-walk.mjs"); + expect(walkScript).toContain("commands.list"); + expect(walkScript).toContain("tools.invoke"); + expect(walkScript).toContain("tts.providers"); + expect(walkScript).toContain("plugins.uiDescriptors"); + expect(walkScript).toContain("^call(?:\\.runtime)?"); + }); + it("keeps the generic plugin Docker lane as an external install contract canary", () => { const lane = getDockerLane("plugins"); const sweepScript = readFileSync("scripts/e2e/lib/plugins/sweep.sh", "utf8");