diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 319d22b1735..48095c34593 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -192,9 +192,15 @@ jobs: - name: Setup Node environment for local pack smoke uses: ./.github/actions/setup-node-env with: - install-bun: "false" + install-bun: "true" install-deps: "true" + - name: Run Bun global install image-provider smoke + env: + OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE: openclaw-dockerfile-smoke:local + OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD: "0" + run: bash scripts/e2e/bun-global-install-smoke.sh + - name: Run installer docker tests env: OPENCLAW_INSTALL_URL: https://openclaw.ai/install.sh diff --git a/docs/help/testing.md b/docs/help/testing.md index d5676fc9fb7..0a0171eda92 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -975,6 +975,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`) - Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`) - Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies enabling the plugin installs its runtime deps on demand, runs doctor, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. +- Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`. - Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`) - OpenAI Responses web_search minimal reasoning regression: `pnpm test:docker:openai-web-search-minimal` (script: `scripts/e2e/openai-web-search-minimal-docker.sh`) runs a mocked OpenAI server through Gateway, verifies `web_search` raises `reasoning.effort` from `minimal` to `low`, then forces the provider schema reject and checks the raw detail appears in Gateway logs. - MCP channel bridge (seeded Gateway + stdio bridge + raw Claude notification-frame smoke): `pnpm test:docker:mcp-channels` (script: `scripts/e2e/mcp-channels-docker.sh`) diff --git a/scripts/e2e/bun-global-install-smoke.sh b/scripts/e2e/bun-global-install-smoke.sh new file mode 100755 index 00000000000..8638ecc6f2a --- /dev/null +++ b/scripts/e2e/bun-global-install-smoke.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUN_BIN="${BUN_BIN:-bun}" +HOST_BUILD="${OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD:-1}" +DIST_IMAGE="${OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE:-}" +PACKAGE_TGZ="${OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ:-}" +COMMAND_TIMEOUT_MS="${OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_MS:-180000}" +SMOKE_DIR="" +PACK_DIR="" + +cleanup() { + if [ -n "${SMOKE_DIR:-}" ]; then + rm -rf "$SMOKE_DIR" + fi + if [ -n "${PACK_DIR:-}" ]; then + rm -rf "$PACK_DIR" + fi +} + +trap cleanup EXIT + +run_with_timeout() { + local timeout_ms="$1" + shift + node - "$timeout_ms" "$@" <<'NODE' +const { spawnSync } = require("node:child_process"); + +const timeout = Number(process.argv[2]); +const command = process.argv[3]; +const args = process.argv.slice(4); +const result = spawnSync(command, args, { + encoding: "utf8", + env: process.env, + timeout, +}); + +if (result.stdout) { + process.stdout.write(result.stdout); +} +if (result.stderr) { + process.stderr.write(result.stderr); +} +if (result.error) { + console.error(`command failed: ${command}: ${result.error.message}`); + process.exit(1); +} +if (result.signal) { + console.error(`command terminated: ${command}: ${result.signal}`); + process.exit(1); +} +process.exit(result.status ?? 0); +NODE +} + +restore_dist_from_image() { + local image="$1" + local container_id + + echo "==> Reuse dist/ from Docker image: $image" + container_id="$(docker create "$image")" + rm -rf "$ROOT_DIR/dist" + if ! docker cp "${container_id}:/app/dist" "$ROOT_DIR/dist"; then + docker rm -f "$container_id" >/dev/null 2>&1 || true + return 1 + fi + docker rm -f "$container_id" >/dev/null +} + +resolve_package_tgz() { + if [ -n "$PACKAGE_TGZ" ]; then + if [ ! -f "$PACKAGE_TGZ" ]; then + echo "OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ does not exist: $PACKAGE_TGZ" >&2 + exit 1 + fi + PACKAGE_TGZ="$(cd "$(dirname "$PACKAGE_TGZ")" && pwd)/$(basename "$PACKAGE_TGZ")" + return 0 + fi + + if [ -n "$DIST_IMAGE" ]; then + restore_dist_from_image "$DIST_IMAGE" + elif [ "$HOST_BUILD" != "0" ]; then + echo "==> Build host package artifacts" + pnpm build + else + echo "==> Skipping host build (OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0)" + fi + + if [ ! -d "$ROOT_DIR/dist" ]; then + echo "dist/ is missing; run pnpm build or set OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE" >&2 + exit 1 + fi + + echo "==> Write package inventory" + node --import tsx scripts/write-package-dist-inventory.ts + + local pack_json_file + PACK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-bun-pack.XXXXXX")" + pack_json_file="$PACK_DIR/pack.json" + + echo "==> Pack OpenClaw tarball" + npm pack --ignore-scripts --json --pack-destination "$PACK_DIR" >"$pack_json_file" + PACKAGE_TGZ="$( + node -e ' +const raw = require("node:fs").readFileSync(process.argv[1], "utf8") || "[]"; +const parsed = JSON.parse(raw); +const last = Array.isArray(parsed) ? parsed.at(-1) : null; +if (!last || typeof last.filename !== "string" || last.filename.length === 0) { + process.exit(1); +} +process.stdout.write(require("node:path").resolve(process.argv[2], last.filename)); +' "$pack_json_file" "$PACK_DIR" + )" + if [ -z "$PACKAGE_TGZ" ] || [ ! -f "$PACKAGE_TGZ" ]; then + echo "missing packed OpenClaw tarball" >&2 + exit 1 + fi +} + +main() { + cd "$ROOT_DIR" + + if ! command -v "$BUN_BIN" >/dev/null 2>&1; then + echo "Bun is required for bun global install smoke; set BUN_BIN or install bun." >&2 + exit 1 + fi + + resolve_package_tgz + + local bun_path + local openclaw_bin + bun_path="$(command -v "$BUN_BIN")" + SMOKE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-bun-global.XXXXXX")" + + export HOME="$SMOKE_DIR/home" + export BUN_INSTALL="$HOME/.bun" + export XDG_CACHE_HOME="$SMOKE_DIR/cache" + export OPENCLAW_NO_ONBOARD=1 + export OPENCLAW_DISABLE_UPDATE_CHECK=1 + export NO_COLOR=1 + mkdir -p "$HOME" "$BUN_INSTALL/bin" "$XDG_CACHE_HOME" + export PATH="$BUN_INSTALL/bin:$(dirname "$(command -v node)"):$PATH" + + echo "==> Bun version" + "$bun_path" --version + + echo "==> Bun global install packed OpenClaw" + "$bun_path" install -g "$PACKAGE_TGZ" --no-progress + + openclaw_bin="$BUN_INSTALL/bin/openclaw" + if [ ! -x "$openclaw_bin" ]; then + openclaw_bin="$(command -v openclaw || true)" + fi + if [ -z "$openclaw_bin" ] || [ ! -x "$openclaw_bin" ]; then + echo "Bun global install did not create an executable openclaw binary" >&2 + exit 1 + fi + + echo "==> OpenClaw version through Bun global install" + run_with_timeout "$COMMAND_TIMEOUT_MS" "$openclaw_bin" --version + + echo "==> OpenClaw image providers through Bun global install" + local providers_json + providers_json="$(run_with_timeout "$COMMAND_TIMEOUT_MS" "$openclaw_bin" infer image providers --json)" + OPENCLAW_IMAGE_PROVIDERS_JSON="$providers_json" node - <<'NODE' +const raw = process.env.OPENCLAW_IMAGE_PROVIDERS_JSON ?? ""; +let parsed; +try { + parsed = JSON.parse(raw); +} catch (error) { + console.error(raw); + throw new Error(`image providers output is not JSON: ${error.message}`); +} +if (!Array.isArray(parsed)) { + throw new Error("image providers output must be a JSON array"); +} +if (parsed.length === 0) { + throw new Error("image providers output is empty"); +} +const ids = new Set(parsed.map((entry) => entry && typeof entry.id === "string" ? entry.id : "")); +for (const expected of ["google", "openai", "xai"]) { + if (!ids.has(expected)) { + throw new Error(`image providers output is missing bundled provider '${expected}'`); + } +} +console.log(`bun-global-install-smoke: image providers OK (${parsed.length} providers)`); +NODE +} + +main "$@" diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index df62ec446cc..ed28b2e220d 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; const SCRIPT_PATH = "scripts/test-install-sh-docker.sh"; const SMOKE_RUNNER_PATH = "scripts/docker/install-sh-smoke/run.sh"; +const BUN_GLOBAL_SMOKE_PATH = "scripts/e2e/bun-global-install-smoke.sh"; +const INSTALL_SMOKE_WORKFLOW_PATH = ".github/workflows/install-smoke.yml"; describe("test-install-sh-docker", () => { it("defaults local Apple Silicon smoke runs to native arm64 while keeping CI on amd64", () => { @@ -95,3 +97,26 @@ describe("install-sh smoke runner", () => { expect(runner).toContain("==> Direct npm global update candidate"); }); }); + +describe("bun global install smoke", () => { + it("packs the current tree and verifies image-provider discovery through Bun", () => { + const script = readFileSync(BUN_GLOBAL_SMOKE_PATH, "utf8"); + + expect(script).toContain("npm pack --ignore-scripts --json --pack-destination"); + expect(script).toContain('"$bun_path" install -g "$PACKAGE_TGZ" --no-progress'); + expect(script).toContain("infer image providers --json"); + expect(script).toContain("image providers output is missing bundled provider"); + expect(script).toContain("OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE"); + }); + + it("runs from the install-smoke workflow with Bun enabled", () => { + const workflow = readFileSync(INSTALL_SMOKE_WORKFLOW_PATH, "utf8"); + + expect(workflow).toContain('install-bun: "true"'); + expect(workflow).toContain("Run Bun global install image-provider smoke"); + expect(workflow).toContain("bash scripts/e2e/bun-global-install-smoke.sh"); + expect(workflow).toContain( + "OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE: openclaw-dockerfile-smoke:local", + ); + }); +});