diff --git a/docs/reference/test.md b/docs/reference/test.md index 3efba097d27..b7a068ce5c6 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -16,6 +16,7 @@ title: "Tests" - `pnpm check:changed`: runs the smart changed check gate for the diff against `origin/main`. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test ` for test proof. - `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs use fixed shard groups and expand to leaf configs for local parallel execution; the extension group always expands to the per-extension shard configs instead of one giant root-project process. - Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail. +- Shared OpenClaw test state: use `src/test-utils/openclaw-test-state.ts` from Vitest when a test needs an isolated `HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, config fixture, workspace, agent dir, or auth-profile store. Docker/Bash E2E lanes can use `scripts/lib/openclaw-test-state.mjs shell --label --scenario ` for an in-container shell snippet, or `node scripts/lib/openclaw-test-state.mjs -- create --label --scenario --env-file --json` for a sourceable host env file. The `--` before `create` keeps newer Node runtimes from treating `--env-file` as a Node flag. - Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact. - Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes. - Source files with sibling tests map to that sibling before falling back to wider directory globs. Helper edits under `src/channels/plugins/contracts/test-helpers`, `src/plugin-sdk/test-helpers`, and `src/plugins/contracts` use a local import graph to run importing tests instead of broad-running every shard when the dependency path is precise. diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index 12e39ed164b..5ff30850fe8 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -4,14 +4,22 @@ 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-onboard-e2e" OPENCLAW_ONBOARD_E2E_IMAGE)" +OPENCLAW_TEST_STATE_FUNCTION_B64="$( + node "$ROOT_DIR/scripts/lib/openclaw-test-state.mjs" shell-function \ + | base64 \ + | tr -d '\n' +)" docker_e2e_build_or_reuse "$IMAGE_NAME" onboard echo "Running onboarding E2E..." -docker run --rm -t "$IMAGE_NAME" bash -lc ' +docker run --rm -t \ + -e "OPENCLAW_TEST_STATE_FUNCTION_B64=$OPENCLAW_TEST_STATE_FUNCTION_B64" \ + "$IMAGE_NAME" bash -lc ' set -euo pipefail trap "" PIPE export TERM=xterm-256color + eval "$(printf "%s" "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}" | base64 -d)" ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui" # tsdown may emit dist/index.js or dist/index.mjs depending on runtime/bundler. if [ -f dist/index.mjs ]; then @@ -221,12 +229,8 @@ TRASH } set_isolated_openclaw_env() { - local home_dir="$1" - export HOME="$home_dir" - export OPENCLAW_HOME="$home_dir" - export OPENCLAW_STATE_DIR="$home_dir/.openclaw" - export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json" - mkdir -p "$OPENCLAW_STATE_DIR" + local label="$1" + openclaw_test_state_create "$label" empty } assert_file() { diff --git a/scripts/e2e/update-channel-switch-docker.sh b/scripts/e2e/update-channel-switch-docker.sh index 07dec420be4..c003f88b4df 100755 --- a/scripts/e2e/update-channel-switch-docker.sh +++ b/scripts/e2e/update-channel-switch-docker.sh @@ -12,6 +12,13 @@ SKIP_BUILD="${OPENCLAW_UPDATE_CHANNEL_SWITCH_E2E_SKIP_BUILD:-0}" PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz update-channel-switch "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")" # Bare lanes mount the package artifact instead of baking app sources into the image. docker_e2e_package_mount_args "$PACKAGE_TGZ" +OPENCLAW_TEST_STATE_SCRIPT_B64="$( + node "$ROOT_DIR/scripts/lib/openclaw-test-state.mjs" shell \ + --label update-channel-switch \ + --scenario update-stable \ + | base64 \ + | tr -d '\n' +)" docker_e2e_build_or_reuse "$IMAGE_NAME" update-channel-switch "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" @@ -20,6 +27,7 @@ docker run --rm \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SKIP_PROVIDERS=1 \ + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc 'set -euo pipefail @@ -156,17 +164,7 @@ NODE )" export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT -home_dir="$(mktemp -d /tmp/openclaw-update-channel-switch-home.XXXXXX)" -export HOME="$home_dir" -mkdir -p "$HOME/.openclaw" -cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"' -{ - "update": { - "channel": "stable" - }, - "plugins": {} -} -JSON +eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)" export OPENCLAW_GIT_DIR="$git_root" export OPENCLAW_UPDATE_DEV_TARGET_REF="$fixture_sha" diff --git a/scripts/lib/openclaw-test-state.mjs b/scripts/lib/openclaw-test-state.mjs new file mode 100644 index 00000000000..e49343606e2 --- /dev/null +++ b/scripts/lib/openclaw-test-state.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_LABEL = "state"; +const DEFAULT_SCENARIO = "empty"; +const SCENARIOS = new Set([ + "empty", + "minimal", + "update-stable", + "gateway-loopback", + "external-service", +]); + +function usage() { + return `Usage: + node scripts/lib/openclaw-test-state.mjs -- create [--label ] [--scenario ] [--env-file ] [--json] + node scripts/lib/openclaw-test-state.mjs shell [--label ] [--scenario ] + node scripts/lib/openclaw-test-state.mjs shell-function + +Scenarios: ${[...SCENARIOS].join(", ")} +`; +} + +function parseArgs(argv) { + const args = argv[0] === "--" ? argv.slice(1) : argv; + const [command, ...rest] = args; + if (!command || command === "--help" || command === "-h") { + return { command: "help", options: {} }; + } + const options = {}; + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]; + if (arg === "--json") { + options.json = true; + continue; + } + if ( + arg === "--label" || + arg === "--scenario" || + arg === "--env-file" || + arg === "--port" || + arg === "--token" + ) { + const value = rest[index + 1]; + if (!value) { + throw new Error(`missing value for ${arg}`); + } + index += 1; + options[arg.slice(2)] = value; + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + return { command, options }; +} + +function normalizeLabel(value) { + return ( + String(value || DEFAULT_LABEL) + .replace(/[^A-Za-z0-9_.-]+/gu, "-") + .replace(/^-+|-+$/gu, "") || DEFAULT_LABEL + ); +} + +function requireScenario(value) { + const scenario = value || DEFAULT_SCENARIO; + if (!SCENARIOS.has(scenario)) { + throw new Error(`unknown scenario: ${scenario}`); + } + return scenario; +} + +function scenarioConfig(scenario, options = {}) { + if (scenario === "minimal" || scenario === "external-service") { + return {}; + } + if (scenario === "update-stable") { + return { + update: { + channel: "stable", + }, + plugins: {}, + }; + } + if (scenario === "gateway-loopback") { + return { + gateway: { + port: Number(options.port || 18789), + auth: { + mode: "token", + token: options.token || "openclaw-test-token", + }, + controlUi: { + enabled: false, + }, + }, + }; + } + return undefined; +} + +function scenarioEnv(scenario) { + if (scenario === "external-service") { + return { + OPENCLAW_SERVICE_REPAIR_POLICY: "external", + }; + } + return {}; +} + +function shellQuote(value) { + return `'${String(value).replace(/'/gu, `'\\''`)}'`; +} + +function renderExports(env) { + return Object.entries(env) + .map(([key, value]) => `export ${key}=${shellQuote(value)}`) + .join("\n"); +} + +function renderConfigWrite(configPathExpression, config) { + if (config === undefined) { + return ""; + } + const json = JSON.stringify(config, null, 2); + return [ + `cat > ${configPathExpression} <<'OPENCLAW_TEST_STATE_JSON'`, + json, + "OPENCLAW_TEST_STATE_JSON", + ].join("\n"); +} + +function buildCreatePlan(options = {}) { + const label = normalizeLabel(options.label); + const scenario = requireScenario(options.scenario); + if (!options.root) { + throw new Error("buildCreatePlan requires root"); + } + const root = options.root; + const home = path.join(root, "home"); + const stateDir = path.join(home, ".openclaw"); + const configPath = path.join(stateDir, "openclaw.json"); + const workspaceDir = path.join(home, "workspace"); + const config = scenarioConfig(scenario, options); + const env = { + HOME: home, + USERPROFILE: home, + OPENCLAW_HOME: home, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: configPath, + ...scenarioEnv(scenario), + }; + return { + label, + scenario, + root, + home, + stateDir, + configPath, + workspaceDir, + env, + hasConfig: config !== undefined, + config, + }; +} + +export async function createState(options = {}) { + const label = normalizeLabel(options.label); + const root = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`)); + const plan = buildCreatePlan({ ...options, root }); + await fs.mkdir(plan.stateDir, { recursive: true }); + await fs.mkdir(plan.workspaceDir, { recursive: true }); + if (plan.config !== undefined) { + await fs.writeFile(plan.configPath, `${JSON.stringify(plan.config, null, 2)}\n`, "utf8"); + } + return plan; +} + +export function renderEnvFile(plan) { + return `${renderExports(plan.env)}\n`; +} + +export function renderShellSnippet(options = {}) { + const label = normalizeLabel(options.label); + const scenario = requireScenario(options.scenario); + const config = scenarioConfig(scenario, options); + const env = scenarioEnv(scenario); + const template = `/tmp/openclaw-${label}-${scenario}-home.XXXXXX`; + const lines = [ + `OPENCLAW_TEST_STATE_HOME="$(mktemp -d ${shellQuote(template)})"`, + 'export HOME="$OPENCLAW_TEST_STATE_HOME"', + 'export USERPROFILE="$OPENCLAW_TEST_STATE_HOME"', + 'export OPENCLAW_HOME="$OPENCLAW_TEST_STATE_HOME"', + 'export OPENCLAW_STATE_DIR="$OPENCLAW_TEST_STATE_HOME/.openclaw"', + 'export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"', + 'export OPENCLAW_TEST_WORKSPACE_DIR="$OPENCLAW_TEST_STATE_HOME/workspace"', + 'mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR"', + ]; + for (const [key, value] of Object.entries(env)) { + lines.push(`export ${key}=${shellQuote(value)}`); + } + const configWrite = renderConfigWrite('"$OPENCLAW_CONFIG_PATH"', config); + if (configWrite) { + lines.push(configWrite); + } + return `${lines.join("\n")}\n`; +} + +export function renderShellFunction() { + return `openclaw_test_state_create() { + local raw_label="\${1:-state}" + local label="$raw_label" + local scenario="\${2:-empty}" + case "$scenario" in + empty|minimal|update-stable|gateway-loopback|external-service) ;; + *) + echo "unknown OpenClaw test-state scenario: $scenario" >&2 + return 1 + ;; + esac + case "$raw_label" in + /*) + OPENCLAW_TEST_STATE_HOME="$raw_label" + mkdir -p "$OPENCLAW_TEST_STATE_HOME" + ;; + *) + label="$(printf "%s" "$label" | tr -cs "A-Za-z0-9_.-" "-" | sed -e "s/^-*//" -e "s/-*$//")" + [ -n "$label" ] || label="state" + OPENCLAW_TEST_STATE_HOME="$(mktemp -d "/tmp/openclaw-$label-$scenario-home.XXXXXX")" + ;; + esac + export HOME="$OPENCLAW_TEST_STATE_HOME" + export USERPROFILE="$OPENCLAW_TEST_STATE_HOME" + export OPENCLAW_HOME="$OPENCLAW_TEST_STATE_HOME" + export OPENCLAW_STATE_DIR="$OPENCLAW_TEST_STATE_HOME/.openclaw" + export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json" + export OPENCLAW_TEST_WORKSPACE_DIR="$OPENCLAW_TEST_STATE_HOME/workspace" + unset OPENCLAW_AGENT_DIR + unset PI_CODING_AGENT_DIR + unset OPENCLAW_SERVICE_REPAIR_POLICY + mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR" + case "$scenario" in + minimal) + cat > "$OPENCLAW_CONFIG_PATH" <<'OPENCLAW_TEST_STATE_JSON' +{} +OPENCLAW_TEST_STATE_JSON + ;; + update-stable) + cat > "$OPENCLAW_CONFIG_PATH" <<'OPENCLAW_TEST_STATE_JSON' +{ + "update": { + "channel": "stable" + }, + "plugins": {} +} +OPENCLAW_TEST_STATE_JSON + ;; + gateway-loopback) + cat > "$OPENCLAW_CONFIG_PATH" <<'OPENCLAW_TEST_STATE_JSON' +{ + "gateway": { + "port": 18789, + "auth": { + "mode": "token", + "token": "openclaw-test-token" + }, + "controlUi": { + "enabled": false + } + } +} +OPENCLAW_TEST_STATE_JSON + ;; + external-service) + export OPENCLAW_SERVICE_REPAIR_POLICY="external" + cat > "$OPENCLAW_CONFIG_PATH" <<'OPENCLAW_TEST_STATE_JSON' +{} +OPENCLAW_TEST_STATE_JSON + ;; + esac +} +`; +} + +async function main(argv = process.argv.slice(2)) { + const { command, options } = parseArgs(argv); + if (command === "help") { + process.stdout.write(usage()); + return; + } + if (command === "shell") { + process.stdout.write(renderShellSnippet(options)); + return; + } + if (command === "shell-function") { + process.stdout.write(renderShellFunction()); + return; + } + if (command === "create") { + const plan = await createState(options); + if (options["env-file"]) { + await fs.writeFile(options["env-file"], renderEnvFile(plan), "utf8"); + } + if (options.json) { + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + } + return; + } + throw new Error(`unknown command: ${command}`); +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isMain) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.stderr.write(usage()); + process.exitCode = 1; + }); +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 3f6e32f7ebe..e25439c2cfb 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -231,6 +231,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/changed-lanes.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/lib/live-docker-stage.sh", ["test/scripts/live-docker-stage.test.ts"]], + ["scripts/lib/openclaw-test-state.mjs", ["test/scripts/openclaw-test-state.test.ts"]], ["scripts/lib/vitest-local-scheduling.mjs", ["test/scripts/vitest-local-scheduling.test.ts"]], [ "scripts/run-vitest.mjs", @@ -256,6 +257,7 @@ const TOOLING_TEST_TARGETS = new Map([ ["test/scripts/barnacle-auto-response.test.ts", ["test/scripts/barnacle-auto-response.test.ts"]], ["test/scripts/changed-lanes.test.ts", ["test/scripts/changed-lanes.test.ts"]], ["test/scripts/live-docker-stage.test.ts", ["test/scripts/live-docker-stage.test.ts"]], + ["test/scripts/openclaw-test-state.test.ts", ["test/scripts/openclaw-test-state.test.ts"]], ["test/scripts/test-projects.test.ts", ["test/scripts/test-projects.test.ts"]], ["test/scripts/testbox-sync-sanity.test.ts", ["test/scripts/testbox-sync-sanity.test.ts"]], [ @@ -277,6 +279,7 @@ const GROUP_VISIBLE_REPLY_PROMPT_TEST_TARGETS = [ ]; const SOURCE_TEST_TARGETS = new Map([ ...PRECISE_SOURCE_TEST_TARGETS, + ["src/test-utils/openclaw-test-state.ts", ["src/test-utils/openclaw-test-state.test.ts"]], [ "src/plugin-sdk/test-helpers/directory-ids.ts", [ diff --git a/src/agents/auth-profiles.chutes.test.ts b/src/agents/auth-profiles.chutes.test.ts index d7c8e704f59..01a6e7adcfb 100644 --- a/src/agents/auth-profiles.chutes.test.ts +++ b/src/agents/auth-profiles.chutes.test.ts @@ -1,8 +1,6 @@ import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { withEnvAsync } from "../test-utils/env.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import type { AuthProfileStore } from "./auth-profiles.js"; import { CHUTES_TOKEN_ENDPOINT } from "./chutes-oauth.js"; @@ -26,8 +24,6 @@ let resolveApiKeyForProfile: typeof import("./auth-profiles.js").resolveApiKeyFo let resetFileLockStateForTest: typeof import("../infra/file-lock.js").resetFileLockStateForTest; describe("auth-profiles (chutes)", () => { - let tempDir: string | null = null; - beforeAll(async () => { ({ clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, resolveApiKeyForProfile } = await import("./auth-profiles.js")); @@ -43,26 +39,19 @@ describe("auth-profiles (chutes)", () => { vi.unstubAllGlobals(); clearRuntimeAuthProfileStoreSnapshots(); resetFileLockStateForTest(); - if (tempDir) { - await fs.rm(tempDir, { recursive: true, force: true }); - tempDir = null; - } }); it("refreshes expired Chutes OAuth credentials", async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chutes-")); - const agentDir = path.join(tempDir, "agents", "main", "agent"); - await withEnvAsync( + await withOpenClawTestState( { - OPENCLAW_STATE_DIR: tempDir, - OPENCLAW_AGENT_DIR: agentDir, - PI_CODING_AGENT_DIR: agentDir, - CHUTES_CLIENT_ID: undefined, + layout: "state-only", + prefix: "openclaw-chutes-", + agentEnv: "main", + env: { + CHUTES_CLIENT_ID: undefined, + }, }, - async () => { - const authProfilePath = path.join(agentDir, "auth-profiles.json"); - await fs.mkdir(path.dirname(authProfilePath), { recursive: true }); - + async (state) => { const store: AuthProfileStore = { version: 1, profiles: { @@ -76,7 +65,7 @@ describe("auth-profiles (chutes)", () => { }, }, }; - await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`); + const authProfilePath = await state.writeAuthProfiles(store); const fetchSpy = vi.fn(async (input: string | URL) => { const url = typeof input === "string" ? input : input.toString(); diff --git a/src/agents/auth-profiles/session-override.test.ts b/src/agents/auth-profiles/session-override.test.ts index e5e64f0d021..b135d9706c0 100644 --- a/src/agents/auth-profiles/session-override.test.ts +++ b/src/agents/auth-profiles/session-override.test.ts @@ -1,9 +1,11 @@ import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + type OpenClawTestState, + withOpenClawTestState, +} from "../../test-utils/openclaw-test-state.js"; import { resolveSessionAuthProfileOverride } from "./session-override.js"; import type { AuthProfileStore } from "./types.js"; @@ -52,22 +54,14 @@ vi.mock("./usage.js", () => ({ isProfileInCooldown: authStoreMocks.isProfileInCooldown, })); -async function withAuthStateDir(run: (params: { stateDir: string }) => Promise): Promise { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); - const stateDir = path.join(tempRoot, "state"); - process.env.OPENCLAW_STATE_DIR = stateDir; - try { - await fs.mkdir(stateDir, { recursive: true }); - return await run({ stateDir }); - } finally { - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - await fs.rm(tempRoot, { recursive: true, force: true }); - } +async function withAuthState(run: (state: OpenClawTestState) => Promise): Promise { + return await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-auth-", + }, + run, + ); } function createAuthStore(): AuthProfileStore { @@ -103,8 +97,8 @@ describe("resolveSessionAuthProfileOverride", () => { }); it("returns early when no auth sources exist", async () => { - await withAuthStateDir(async ({ stateDir }) => { - const agentDir = path.join(stateDir, "agent"); + await withAuthState(async (state) => { + const agentDir = state.agentDir(); await fs.mkdir(agentDir, { recursive: true }); const sessionEntry: SessionEntry = { @@ -126,15 +120,15 @@ describe("resolveSessionAuthProfileOverride", () => { expect(resolved).toBeUndefined(); expect(authStoreMocks.ensureAuthProfileStore).not.toHaveBeenCalled(); - await expect(fs.access(path.join(agentDir, "auth-profiles.json"))).rejects.toMatchObject({ + await expect(fs.access(`${agentDir}/auth-profiles.json`)).rejects.toMatchObject({ code: "ENOENT", }); }); }); it("keeps user override when provider alias differs", async () => { - await withAuthStateDir(async ({ stateDir }) => { - const agentDir = path.join(stateDir, "agent"); + await withAuthState(async (state) => { + const agentDir = state.agentDir(); await fs.mkdir(agentDir, { recursive: true }); authStoreMocks.state.hasSource = true; authStoreMocks.state.store = createAuthStore(); @@ -164,8 +158,8 @@ describe("resolveSessionAuthProfileOverride", () => { }); it("keeps explicit user override when stored order prefers another profile", async () => { - await withAuthStateDir(async ({ stateDir }) => { - const agentDir = path.join(stateDir, "agent"); + await withAuthState(async (state) => { + const agentDir = state.agentDir(); await fs.mkdir(agentDir, { recursive: true }); authStoreMocks.state.hasSource = true; authStoreMocks.state.store = createAuthStoreWithProfiles({ @@ -212,8 +206,8 @@ describe("resolveSessionAuthProfileOverride", () => { }); it("keeps session override when CLI provider aliases the stored profile provider", async () => { - await withAuthStateDir(async ({ stateDir }) => { - const agentDir = path.join(stateDir, "agent"); + await withAuthState(async (state) => { + const agentDir = state.agentDir(); await fs.mkdir(agentDir, { recursive: true }); authStoreMocks.state.hasSource = true; authStoreMocks.state.store = createAuthStoreWithProfiles({ diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index 5a22d2760eb..23819b70cca 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -5,6 +5,7 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withEnvAsync } from "../test-utils/env.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, @@ -268,110 +269,75 @@ async function resolveDemoLocalApiKey(params: { describe("getApiKeyForModel", () => { it("reads oauth auth-profiles entries from auth-profiles.json via explicit profile", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-")); + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-oauth-", + agentEnv: "main", + }, + async (state) => { + await state.writeAuthProfiles({ + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + ...oauthFixture, + }, + }, + }); - try { - const agentDir = path.join(tempDir, "agent"); - await withEnvAsync( - { - OPENCLAW_STATE_DIR: tempDir, - OPENCLAW_AGENT_DIR: agentDir, - PI_CODING_AGENT_DIR: agentDir, - }, - async () => { - const authProfilesPath = path.join(agentDir, "auth-profiles.json"); - await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); - await fs.writeFile( - authProfilesPath, - `${JSON.stringify( - { - version: 1, - profiles: { - "openai-codex:default": { - type: "oauth", - provider: "openai-codex", - ...oauthFixture, - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); + const model = { + id: "codex-mini-latest", + provider: "openai-codex", + api: "openai-codex-responses", + } as Model; - const model = { - id: "codex-mini-latest", - provider: "openai-codex", - api: "openai-codex-responses", - } as Model; - - const store = ensureAuthProfileStore(process.env.OPENCLAW_AGENT_DIR, { - allowKeychainPrompt: false, - }); - const apiKey = await getApiKeyForModel({ - model, - profileId: "openai-codex:default", - store, - agentDir: process.env.OPENCLAW_AGENT_DIR, - }); - expect(apiKey.apiKey).toBe(oauthFixture.access); - }, - ); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const store = ensureAuthProfileStore(process.env.OPENCLAW_AGENT_DIR, { + allowKeychainPrompt: false, + }); + const apiKey = await getApiKeyForModel({ + model, + profileId: "openai-codex:default", + store, + agentDir: process.env.OPENCLAW_AGENT_DIR, + }); + expect(apiKey.apiKey).toBe(oauthFixture.access); + }, + ); }); it("suggests openai-codex when only Codex OAuth is configured", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); - - try { - const agentDir = path.join(tempDir, "agent"); - await withEnvAsync( - { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-auth-", + agentEnv: "main", + env: { OPENAI_API_KEY: undefined, - OPENCLAW_STATE_DIR: tempDir, - OPENCLAW_AGENT_DIR: agentDir, - PI_CODING_AGENT_DIR: agentDir, }, - async () => { - const authProfilesPath = path.join(tempDir, "agent", "auth-profiles.json"); - await fs.mkdir(path.dirname(authProfilesPath), { - recursive: true, - mode: 0o700, - }); - await fs.writeFile( - authProfilesPath, - `${JSON.stringify( - { - version: 1, - profiles: { - "openai-codex:default": { - type: "oauth", - provider: "openai-codex", - ...oauthFixture, - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); + }, + async (state) => { + await state.writeAuthProfiles({ + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + ...oauthFixture, + }, + }, + }); - let error: unknown = null; - try { - await resolveApiKeyForProvider({ provider: "openai" }); - } catch (err) { - error = err; - } - expect(String(error)).toContain("openai/gpt-5.5"); - }, - ); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + let error: unknown = null; + try { + await resolveApiKeyForProvider({ provider: "openai" }); + } catch (err) { + error = err; + } + expect(String(error)).toContain("openai/gpt-5.5"); + }, + ); }); it("throws when ZAI API key is missing", async () => { diff --git a/src/commands/doctor-auth-flat-profiles.test.ts b/src/commands/doctor-auth-flat-profiles.test.ts index bd28a12dbc0..9f6c533c352 100644 --- a/src/commands/doctor-auth-flat-profiles.test.ts +++ b/src/commands/doctor-auth-flat-profiles.test.ts @@ -1,18 +1,14 @@ import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles/store.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../test-utils/openclaw-test-state.js"; import { maybeRepairLegacyFlatAuthProfileStores } from "./doctor-auth-flat-profiles.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; -const roots: string[] = []; - -function makeTempRoot(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-flat-auth-")); - roots.push(root); - return root; -} +const states: OpenClawTestState[] = []; function makePrompter(shouldRepair: boolean): DoctorPrompter { return { @@ -33,96 +29,75 @@ function makePrompter(shouldRepair: boolean): DoctorPrompter { }; } -function withStateDir(root: string, run: () => T): T { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - process.env.OPENCLAW_STATE_DIR = root; - delete process.env.OPENCLAW_AGENT_DIR; - try { - return run(); - } finally { - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - } +async function makeTestState(): Promise { + const state = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-doctor-flat-auth-", + env: { + OPENCLAW_AGENT_DIR: undefined, + }, + }); + states.push(state); + return state; } -afterEach(() => { +afterEach(async () => { clearRuntimeAuthProfileStoreSnapshots(); - for (const root of roots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); + for (const state of states.splice(0)) { + await state.cleanup(); } }); describe("maybeRepairLegacyFlatAuthProfileStores", () => { it("rewrites legacy flat auth-profiles.json stores with a backup", async () => { - const root = makeTempRoot(); - await withStateDir(root, async () => { - const agentDir = path.join(root, "agents", "main", "agent"); - fs.mkdirSync(agentDir, { recursive: true }); - const authPath = path.join(agentDir, "auth-profiles.json"); - const legacy = { - "ollama-windows": { - apiKey: "ollama-local", - baseUrl: "http://10.0.2.2:11434/v1", - }, - }; - fs.writeFileSync(authPath, `${JSON.stringify(legacy)}\n`, "utf8"); + const state = await makeTestState(); + const legacy = { + "ollama-windows": { + apiKey: "ollama-local", + baseUrl: "http://10.0.2.2:11434/v1", + }, + }; + const authPath = await state.writeAuthProfiles(legacy); - const result = await maybeRepairLegacyFlatAuthProfileStores({ - cfg: {}, - prompter: makePrompter(true), - now: () => 123, - }); - - expect(result.detected).toEqual([authPath]); - expect(result.changes).toHaveLength(1); - expect(result.warnings).toEqual([]); - expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({ - version: 1, - profiles: { - "ollama-windows:default": { - type: "api_key", - provider: "ollama-windows", - key: "ollama-local", - }, - }, - }); - expect(JSON.parse(fs.readFileSync(`${authPath}.legacy-flat.123.bak`, "utf8"))).toEqual( - legacy, - ); + const result = await maybeRepairLegacyFlatAuthProfileStores({ + cfg: {}, + prompter: makePrompter(true), + now: () => 123, }); + + expect(result.detected).toEqual([authPath]); + expect(result.changes).toHaveLength(1); + expect(result.warnings).toEqual([]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({ + version: 1, + profiles: { + "ollama-windows:default": { + type: "api_key", + provider: "ollama-windows", + key: "ollama-local", + }, + }, + }); + expect(JSON.parse(fs.readFileSync(`${authPath}.legacy-flat.123.bak`, "utf8"))).toEqual(legacy); }); it("reports legacy flat stores without rewriting when repair is declined", async () => { - const root = makeTempRoot(); - await withStateDir(root, async () => { - const agentDir = path.join(root, "agents", "main", "agent"); - fs.mkdirSync(agentDir, { recursive: true }); - const authPath = path.join(agentDir, "auth-profiles.json"); - const legacy = { - openai: { - apiKey: "sk-openai", - }, - }; - fs.writeFileSync(authPath, `${JSON.stringify(legacy)}\n`, "utf8"); + const state = await makeTestState(); + const legacy = { + openai: { + apiKey: "sk-openai", + }, + }; + const authPath = await state.writeAuthProfiles(legacy); - const result = await maybeRepairLegacyFlatAuthProfileStores({ - cfg: {}, - prompter: makePrompter(false), - }); - - expect(result.detected).toEqual([authPath]); - expect(result.changes).toEqual([]); - expect(result.warnings).toEqual([]); - expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(legacy); + const result = await maybeRepairLegacyFlatAuthProfileStores({ + cfg: {}, + prompter: makePrompter(false), }); + + expect(result.detected).toEqual([authPath]); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(legacy); }); }); diff --git a/src/commands/doctor-session-locks.test.ts b/src/commands/doctor-session-locks.test.ts index daa5ce0eedc..6e6381e7016 100644 --- a/src/commands/doctor-session-locks.test.ts +++ b/src/commands/doctor-session-locks.test.ts @@ -1,8 +1,10 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { captureEnv } from "../test-utils/env.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../test-utils/openclaw-test-state.js"; const note = vi.hoisted(() => vi.fn()); @@ -13,23 +15,22 @@ vi.mock("../terminal/note.js", () => ({ import { noteSessionLockHealth } from "./doctor-session-locks.js"; describe("noteSessionLockHealth", () => { - let root: string; - let envSnapshot: ReturnType; + let state: OpenClawTestState; beforeEach(async () => { note.mockClear(); - envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); - root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-locks-")); - process.env.OPENCLAW_STATE_DIR = root; + state = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-doctor-locks-", + }); }); afterEach(async () => { - envSnapshot.restore(); - await fs.rm(root, { recursive: true, force: true }); + await state.cleanup(); }); it("reports existing lock files with pid status and age", async () => { - const sessionsDir = path.join(root, "agents", "main", "sessions"); + const sessionsDir = state.sessionsDir(); await fs.mkdir(sessionsDir, { recursive: true }); const lockPath = path.join(sessionsDir, "active.jsonl.lock"); await fs.writeFile( @@ -50,7 +51,7 @@ describe("noteSessionLockHealth", () => { }); it("removes stale locks in repair mode", async () => { - const sessionsDir = path.join(root, "agents", "main", "sessions"); + const sessionsDir = state.sessionsDir(); await fs.mkdir(sessionsDir, { recursive: true }); const staleLock = path.join(sessionsDir, "stale.jsonl.lock"); diff --git a/src/commands/flows.test.ts b/src/commands/flows.test.ts index a077dbd9e7c..0d86c41ea9b 100644 --- a/src/commands/flows.test.ts +++ b/src/commands/flows.test.ts @@ -9,7 +9,7 @@ import { resetTaskRegistryDeliveryRuntimeForTests, resetTaskRegistryForTests, } from "../tasks/task-registry.js"; -import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "./flows.js"; vi.mock("../config/config.js", () => ({ @@ -28,19 +28,24 @@ function createRuntime(): RuntimeEnv { } async function withTaskFlowCommandStateDir(run: (root: string) => Promise): Promise { - await withTempDir({ prefix: "openclaw-flows-command-" }, async (root) => { - process.env.OPENCLAW_STATE_DIR = root; - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - try { - await run(root); - } finally { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-flows-command-", + }, + async (state) => { resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); - } - }); + try { + await run(state.stateDir); + } finally { + resetTaskRegistryDeliveryRuntimeForTests(); + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); + } + }, + ); } describe("flows commands", () => { diff --git a/src/commands/tasks-json.test.ts b/src/commands/tasks-json.test.ts index 9ef9deede1e..43f1befb0c1 100644 --- a/src/commands/tasks-json.test.ts +++ b/src/commands/tasks-json.test.ts @@ -9,11 +9,9 @@ import { resetTaskRegistryDeliveryRuntimeForTests, resetTaskRegistryForTests, } from "../tasks/task-registry.js"; -import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { tasksAuditJsonCommand, tasksListJsonCommand } from "./tasks-json.js"; -const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; - function createRuntime(): RuntimeEnv { return { log: vi.fn(), @@ -27,19 +25,21 @@ function readJsonLog(runtime: RuntimeEnv): unknown { } async function withTaskJsonStateDir(run: () => Promise): Promise { - await withTempDir({ prefix: "openclaw-tasks-json-command-" }, async (root) => { - process.env.OPENCLAW_STATE_DIR = root; - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - try { - await run(); - } finally { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-tasks-json-command-" }, + async () => { resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); - } - }); + try { + await run(); + } finally { + resetTaskRegistryDeliveryRuntimeForTests(); + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); + } + }, + ); } describe("tasks JSON commands", () => { @@ -49,11 +49,6 @@ describe("tasks JSON commands", () => { afterEach(() => { vi.useRealTimers(); - if (ORIGINAL_STATE_DIR === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR; - } resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 68e5d50f8f9..d6fc4f564cb 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -9,11 +9,9 @@ import { resetTaskRegistryDeliveryRuntimeForTests, resetTaskRegistryForTests, } from "../tasks/task-registry.js"; -import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { tasksAuditCommand, tasksMaintenanceCommand } from "./tasks.js"; -const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; - function createRuntime(): RuntimeEnv { return { log: vi.fn(), @@ -23,19 +21,21 @@ function createRuntime(): RuntimeEnv { } async function withTaskCommandStateDir(run: () => Promise): Promise { - await withTempDir({ prefix: "openclaw-tasks-command-" }, async (root) => { - process.env.OPENCLAW_STATE_DIR = root; - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - try { - await run(); - } finally { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-tasks-command-" }, + async () => { resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); - } - }); + try { + await run(); + } finally { + resetTaskRegistryDeliveryRuntimeForTests(); + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); + } + }, + ); } describe("tasks commands", () => { @@ -45,11 +45,6 @@ describe("tasks commands", () => { afterEach(() => { vi.useRealTimers(); - if (ORIGINAL_STATE_DIR === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR; - } resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryForTests({ persist: false }); resetTaskFlowRegistryForTests({ persist: false }); diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 41d5080bcf9..8a8dfd6ccda 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -1,10 +1,10 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import * as tar from "tar"; import { describe, expect, it, vi } from "vitest"; import { backupVerifyCommand } from "../commands/backup-verify.js"; import type { RuntimeEnv } from "../runtime.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { buildExtensionsNodeModulesFilter, createBackupArchive, @@ -27,14 +27,6 @@ function makeResult(overrides: Partial = {}): BackupCreateRe }; } -function restoreEnvValue(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } -} - async function listArchiveEntries(archivePath: string): Promise { const entries: string[] = []; await tar.t({ @@ -140,72 +132,67 @@ describe("buildExtensionsNodeModulesFilter", () => { describe("createBackupArchive", () => { it("omits installed plugin node_modules from the real archive while keeping plugin files", async () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-plugin-deps-")); + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-plugin-deps-", + scenario: "minimal", + }, + async (state) => { + const stateDir = state.stateDir; + const outputDir = state.path("backups"); + await fs.mkdir(path.join(stateDir, "extensions", "demo", "node_modules", "dep"), { + recursive: true, + }); + await fs.mkdir(path.join(stateDir, "extensions", "demo", "src"), { recursive: true }); + await fs.mkdir(path.join(stateDir, "node_modules", "root-dep"), { recursive: true }); + await fs.writeFile( + path.join(stateDir, "extensions", "demo", "openclaw.plugin.json"), + '{"id":"demo"}\n', + "utf8", + ); + await fs.writeFile( + path.join(stateDir, "extensions", "demo", "src", "index.js"), + "export default {}\n", + "utf8", + ); + await fs.writeFile( + path.join(stateDir, "extensions", "demo", "node_modules", "dep", "index.js"), + "module.exports = {}\n", + "utf8", + ); + await fs.writeFile( + path.join(stateDir, "node_modules", "root-dep", "index.js"), + "module.exports = {}\n", + "utf8", + ); + await fs.mkdir(outputDir, { recursive: true }); - try { - const stateDir = path.join(root, "state"); - const outputDir = path.join(root, "backups"); - process.env.OPENCLAW_STATE_DIR = stateDir; - process.env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json"); + const result = await createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 3, 28, 12, 0, 0), + }); + const entries = await listArchiveEntries(result.archivePath); - await fs.mkdir(path.join(stateDir, "extensions", "demo", "node_modules", "dep"), { - recursive: true, - }); - await fs.mkdir(path.join(stateDir, "extensions", "demo", "src"), { recursive: true }); - await fs.mkdir(path.join(stateDir, "node_modules", "root-dep"), { recursive: true }); - await fs.writeFile(process.env.OPENCLAW_CONFIG_PATH, "{}\n", "utf8"); - await fs.writeFile( - path.join(stateDir, "extensions", "demo", "openclaw.plugin.json"), - '{"id":"demo"}\n', - "utf8", - ); - await fs.writeFile( - path.join(stateDir, "extensions", "demo", "src", "index.js"), - "export default {}\n", - "utf8", - ); - await fs.writeFile( - path.join(stateDir, "extensions", "demo", "node_modules", "dep", "index.js"), - "module.exports = {}\n", - "utf8", - ); - await fs.writeFile( - path.join(stateDir, "node_modules", "root-dep", "index.js"), - "module.exports = {}\n", - "utf8", - ); - await fs.mkdir(outputDir, { recursive: true }); + expect( + entries.some((entry) => entry.endsWith("/state/extensions/demo/openclaw.plugin.json")), + ).toBe(true); + expect(entries.some((entry) => entry.endsWith("/state/extensions/demo/src/index.js"))).toBe( + true, + ); + expect( + entries.some((entry) => entry.endsWith("/state/node_modules/root-dep/index.js")), + ).toBe(true); + expect( + entries.some((entry) => entry.includes("/state/extensions/demo/node_modules/")), + ).toBe(false); - const result = await createBackupArchive({ - output: outputDir, - includeWorkspace: false, - nowMs: Date.UTC(2026, 3, 28, 12, 0, 0), - }); - const entries = await listArchiveEntries(result.archivePath); - - expect( - entries.some((entry) => entry.endsWith("/state/extensions/demo/openclaw.plugin.json")), - ).toBe(true); - expect(entries.some((entry) => entry.endsWith("/state/extensions/demo/src/index.js"))).toBe( - true, - ); - expect(entries.some((entry) => entry.endsWith("/state/node_modules/root-dep/index.js"))).toBe( - true, - ); - expect(entries.some((entry) => entry.includes("/state/extensions/demo/node_modules/"))).toBe( - false, - ); - - const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - await expect( - backupVerifyCommand(runtime, { archive: result.archivePath }), - ).resolves.toMatchObject({ ok: true }); - } finally { - restoreEnvValue("OPENCLAW_STATE_DIR", previousStateDir); - restoreEnvValue("OPENCLAW_CONFIG_PATH", previousConfigPath); - await fs.rm(root, { recursive: true, force: true }); - } + const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + await expect( + backupVerifyCommand(runtime, { archive: result.archivePath }), + ).resolves.toMatchObject({ ok: true }); + }, + ); }); }); diff --git a/src/tasks/task-flow-registry.audit.test.ts b/src/tasks/task-flow-registry.audit.test.ts index d9d88ef6ad6..84daacb7bd3 100644 --- a/src/tasks/task-flow-registry.audit.test.ts +++ b/src/tasks/task-flow-registry.audit.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createRunningTaskRun } from "./task-executor.js"; import { listTaskFlowAuditFindings } from "./task-flow-registry.audit.js"; import { @@ -16,19 +16,24 @@ import { const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; async function withTaskFlowAuditStateDir(run: (root: string) => Promise): Promise { - await withTempDir({ prefix: "openclaw-task-flow-audit-" }, async (root) => { - process.env.OPENCLAW_STATE_DIR = root; - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests(); - resetTaskFlowRegistryForTests(); - try { - await run(root); - } finally { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-task-flow-audit-", + }, + async (state) => { resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryForTests(); resetTaskFlowRegistryForTests(); - } - }); + try { + await run(state.stateDir); + } finally { + resetTaskRegistryDeliveryRuntimeForTests(); + resetTaskRegistryForTests(); + resetTaskFlowRegistryForTests(); + } + }, + ); } describe("task-flow-registry audit", () => { diff --git a/src/tasks/task-flow-registry.maintenance.test.ts b/src/tasks/task-flow-registry.maintenance.test.ts index d45fb637eac..158b9f9107f 100644 --- a/src/tasks/task-flow-registry.maintenance.test.ts +++ b/src/tasks/task-flow-registry.maintenance.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createRunningTaskRun } from "./task-executor.js"; import { createManagedTaskFlow, @@ -22,19 +22,24 @@ const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; async function withTaskFlowMaintenanceStateDir( run: (root: string) => Promise, ): Promise { - await withTempDir({ prefix: "openclaw-task-flow-maintenance-" }, async (root) => { - process.env.OPENCLAW_STATE_DIR = root; - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests(); - resetTaskFlowRegistryForTests(); - try { - await run(root); - } finally { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-task-flow-maintenance-", + }, + async (state) => { resetTaskRegistryDeliveryRuntimeForTests(); resetTaskRegistryForTests(); resetTaskFlowRegistryForTests(); - } - }); + try { + await run(state.stateDir); + } finally { + resetTaskRegistryDeliveryRuntimeForTests(); + resetTaskRegistryForTests(); + resetTaskFlowRegistryForTests(); + } + }, + ); } describe("task-flow-registry maintenance", () => { diff --git a/src/tasks/task-flow-registry.store.test.ts b/src/tasks/task-flow-registry.store.test.ts index 734c3179eca..83d0bde4f86 100644 --- a/src/tasks/task-flow-registry.store.test.ts +++ b/src/tasks/task-flow-registry.store.test.ts @@ -1,6 +1,6 @@ import { statSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createManagedTaskFlow, getTaskFlowById, @@ -38,15 +38,32 @@ function createStoredFlow(): TaskFlowRecord { } async function withFlowRegistryTempDir(run: (root: string) => Promise): Promise { - return await withTempDir({ prefix: "openclaw-task-flow-store-" }, async (root) => { - process.env.OPENCLAW_STATE_DIR = root; - resetTaskFlowRegistryForTests(); - try { - return await run(root); - } finally { + return await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-task-flow-store-", + }, + async (state) => { + const root = state.stateDir; + process.env.OPENCLAW_STATE_DIR = root; resetTaskFlowRegistryForTests(); - } - }); + try { + return await run(root); + } finally { + resetTaskFlowRegistryForTests(); + } + }, + ); +} + +const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; + +function restoreOriginalStateDir(): void { + if (ORIGINAL_STATE_DIR === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR; + } } describe("task-flow-registry store runtime", () => { @@ -56,7 +73,7 @@ describe("task-flow-registry store runtime", () => { afterEach(() => { vi.useRealTimers(); - delete process.env.OPENCLAW_STATE_DIR; + restoreOriginalStateDir(); resetTaskFlowRegistryForTests(); }); diff --git a/src/tasks/task-flow-registry.test.ts b/src/tasks/task-flow-registry.test.ts index 792f9d9a6b5..e18fe6a10e2 100644 --- a/src/tasks/task-flow-registry.test.ts +++ b/src/tasks/task-flow-registry.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createFlowRecord, createTaskFlowForTask, @@ -17,18 +17,18 @@ import { } from "./task-flow-registry.js"; import { configureTaskFlowRegistryRuntime } from "./task-flow-registry.store.js"; -const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; - async function withFlowRegistryTempDir(run: (root: string) => Promise): Promise { - return await withTempDir({ prefix: "openclaw-task-flow-registry-" }, async (root) => { - process.env.OPENCLAW_STATE_DIR = root; - resetTaskFlowRegistryForTests(); - try { - return await run(root); - } finally { + return await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-flow-registry-" }, + async (state) => { resetTaskFlowRegistryForTests(); - } - }); + try { + return await run(state.stateDir); + } finally { + resetTaskFlowRegistryForTests(); + } + }, + ); } describe("task-flow-registry", () => { @@ -38,11 +38,6 @@ describe("task-flow-registry", () => { afterEach(() => { vi.useRealTimers(); - if (ORIGINAL_STATE_DIR === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR; - } resetTaskFlowRegistryForTests(); }); diff --git a/src/tasks/task-owner-access.test.ts b/src/tasks/task-owner-access.test.ts index 8db3c08f436..b484ca050f6 100644 --- a/src/tasks/task-owner-access.test.ts +++ b/src/tasks/task-owner-access.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { findLatestTaskForRelatedSessionKeyForOwner, findTaskByRunIdForOwner, @@ -20,21 +20,20 @@ afterEach(() => { }); async function withTaskRegistryTempDir(run: () => Promise | T): Promise { - return await withTempDir({ prefix: "openclaw-task-owner-access-" }, async (root) => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - process.env.OPENCLAW_STATE_DIR = root; - resetTaskRegistryForTests({ persist: false }); - try { - return await run(); - } finally { + return await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-task-owner-access-", + }, + async () => { resetTaskRegistryForTests({ persist: false }); - if (previousStateDir == null) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; + try { + return await run(); + } finally { + resetTaskRegistryForTests({ persist: false }); } - } - }); + }, + ); } describe("task owner access", () => { diff --git a/src/tasks/task-registry.store.test.ts b/src/tasks/task-registry.store.test.ts index 9ca71aaa1c2..607a86465e7 100644 --- a/src/tasks/task-registry.store.test.ts +++ b/src/tasks/task-registry.store.test.ts @@ -1,8 +1,8 @@ -import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; -import os from "node:os"; +import { mkdirSync, statSync } from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { requireNodeSqlite } from "../infra/node-sqlite.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createManagedTaskFlow, resetTaskFlowRegistryForTests } from "./task-flow-registry.js"; import { createTaskRecord, @@ -19,6 +19,8 @@ import { } from "./task-registry.store.js"; import type { TaskRecord } from "./task-registry.types.js"; +const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; + function createStoredTask(): TaskRecord { return { taskId: "task-restored", @@ -40,7 +42,11 @@ function createStoredTask(): TaskRecord { describe("task-registry store runtime", () => { afterEach(() => { - delete process.env.OPENCLAW_STATE_DIR; + if (ORIGINAL_STATE_DIR === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR; + } resetTaskRegistryForTests(); resetTaskFlowRegistryForTests({ persist: false }); }); @@ -273,42 +279,42 @@ describe("task-registry store runtime", () => { }); }); - it("hardens the sqlite task store directory and file modes", () => { + it("hardens the sqlite task store directory and file modes", async () => { if (process.platform === "win32") { return; } - const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-task-store-")); - process.env.OPENCLAW_STATE_DIR = stateDir; + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-store-" }, + async () => { + createTaskRecord({ + runtime: "cron", + ownerKey: "agent:main:main", + scopeKind: "session", + sourceId: "job-456", + runId: "run-perms", + task: "Run secured cron", + status: "running", + deliveryStatus: "not_applicable", + notifyPolicy: "silent", + }); - createTaskRecord({ - runtime: "cron", - ownerKey: "agent:main:main", - scopeKind: "session", - sourceId: "job-456", - runId: "run-perms", - task: "Run secured cron", - status: "running", - deliveryStatus: "not_applicable", - notifyPolicy: "silent", - }); - - const registryDir = resolveTaskRegistryDir(process.env); - const sqlitePath = resolveTaskRegistrySqlitePath(process.env); - expect(statSync(registryDir).mode & 0o777).toBe(0o700); - expect(statSync(sqlitePath).mode & 0o777).toBe(0o600); - - resetTaskRegistryForTests(); - rmSync(stateDir, { recursive: true, force: true }); + const registryDir = resolveTaskRegistryDir(process.env); + const sqlitePath = resolveTaskRegistrySqlitePath(process.env); + expect(statSync(registryDir).mode & 0o777).toBe(0o700); + expect(statSync(sqlitePath).mode & 0o777).toBe(0o600); + }, + ); }); - it("migrates legacy ownerless cron rows to system scope", () => { - const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-task-store-legacy-")); - process.env.OPENCLAW_STATE_DIR = stateDir; - const sqlitePath = resolveTaskRegistrySqlitePath(process.env); - mkdirSync(path.dirname(sqlitePath), { recursive: true }); - const { DatabaseSync } = requireNodeSqlite(); - const db = new DatabaseSync(sqlitePath); - db.exec(` + it("migrates legacy ownerless cron rows to system scope", async () => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-store-legacy-" }, + async () => { + const sqlitePath = resolveTaskRegistrySqlitePath(process.env); + mkdirSync(path.dirname(sqlitePath), { recursive: true }); + const { DatabaseSync } = requireNodeSqlite(); + const db = new DatabaseSync(sqlitePath); + db.exec(` CREATE TABLE task_runs ( task_id TEXT PRIMARY KEY, runtime TEXT NOT NULL, @@ -334,14 +340,14 @@ describe("task-registry store runtime", () => { terminal_outcome TEXT ); `); - db.exec(` + db.exec(` CREATE TABLE task_delivery_state ( task_id TEXT PRIMARY KEY, requester_origin_json TEXT, last_notified_event_at INTEGER ); `); - db.prepare(` + db.prepare(` INSERT INTO task_runs ( task_id, runtime, @@ -357,40 +363,43 @@ describe("task-registry store runtime", () => { last_event_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - "legacy-cron-task", - "cron", - "nightly-digest", - "", - "agent:main:cron:nightly-digest", - "legacy-cron-run", - "Nightly digest", - "running", - "not_applicable", - "silent", - 100, - 100, + "legacy-cron-task", + "cron", + "nightly-digest", + "", + "agent:main:cron:nightly-digest", + "legacy-cron-run", + "Nightly digest", + "running", + "not_applicable", + "silent", + 100, + 100, + ); + db.close(); + + resetTaskRegistryForTests({ persist: false }); + + expect(findTaskByRunId("legacy-cron-run")).toMatchObject({ + taskId: "legacy-cron-task", + ownerKey: "system:cron:nightly-digest", + scopeKind: "system", + deliveryStatus: "not_applicable", + notifyPolicy: "silent", + }); + }, ); - db.close(); - - resetTaskRegistryForTests({ persist: false }); - - expect(findTaskByRunId("legacy-cron-run")).toMatchObject({ - taskId: "legacy-cron-task", - ownerKey: "system:cron:nightly-digest", - scopeKind: "system", - deliveryStatus: "not_applicable", - notifyPolicy: "silent", - }); }); - it("keeps legacy requester_session_key rows writable after restore", () => { - const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-task-store-legacy-write-")); - process.env.OPENCLAW_STATE_DIR = stateDir; - const sqlitePath = resolveTaskRegistrySqlitePath(process.env); - mkdirSync(path.dirname(sqlitePath), { recursive: true }); - const { DatabaseSync } = requireNodeSqlite(); - const db = new DatabaseSync(sqlitePath); - db.exec(` + it("keeps legacy requester_session_key rows writable after restore", async () => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-store-legacy-write-" }, + async () => { + const sqlitePath = resolveTaskRegistrySqlitePath(process.env); + mkdirSync(path.dirname(sqlitePath), { recursive: true }); + const { DatabaseSync } = requireNodeSqlite(); + const db = new DatabaseSync(sqlitePath); + db.exec(` CREATE TABLE task_runs ( task_id TEXT PRIMARY KEY, runtime TEXT NOT NULL, @@ -416,14 +425,14 @@ describe("task-registry store runtime", () => { terminal_outcome TEXT ); `); - db.exec(` + db.exec(` CREATE TABLE task_delivery_state ( task_id TEXT PRIMARY KEY, requester_origin_json TEXT, last_notified_event_at INTEGER ); `); - db.prepare(` + db.prepare(` INSERT INTO task_runs ( task_id, runtime, @@ -437,33 +446,35 @@ describe("task-registry store runtime", () => { last_event_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - "legacy-session-task", - "acp", - "agent:main:main", - "legacy-session-run", - "Legacy session task", - "running", - "pending", - "done_only", - 100, - 100, + "legacy-session-task", + "acp", + "agent:main:main", + "legacy-session-run", + "Legacy session task", + "running", + "pending", + "done_only", + 100, + 100, + ); + db.close(); + + resetTaskRegistryForTests({ persist: false }); + + expect(() => + markTaskLostById({ + taskId: "legacy-session-task", + endedAt: 200, + lastEventAt: 200, + error: "session missing", + }), + ).not.toThrow(); + expect(findTaskByRunId("legacy-session-run")).toMatchObject({ + taskId: "legacy-session-task", + status: "lost", + error: "session missing", + }); + }, ); - db.close(); - - resetTaskRegistryForTests({ persist: false }); - - expect(() => - markTaskLostById({ - taskId: "legacy-session-task", - endedAt: 200, - lastEventAt: 200, - error: "session missing", - }), - ).not.toThrow(); - expect(findTaskByRunId("legacy-session-run")).toMatchObject({ - taskId: "legacy-session-task", - status: "lost", - error: "session missing", - }); }); }); diff --git a/src/test-utils/openclaw-test-state.test.ts b/src/test-utils/openclaw-test-state.test.ts new file mode 100644 index 00000000000..a450467b6c8 --- /dev/null +++ b/src/test-utils/openclaw-test-state.test.ts @@ -0,0 +1,177 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createOpenClawTestState, withOpenClawTestState } from "./openclaw-test-state.js"; + +describe("openclaw test state", () => { + it("creates an isolated home layout with spawn env and restores process env", async () => { + const previousHome = process.env.HOME; + const previousOpenClawHome = process.env.OPENCLAW_HOME; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + + const state = await createOpenClawTestState({ + label: "unit", + scenario: "minimal", + }); + + expect(state.home).toBe(path.join(state.root, "home")); + expect(state.stateDir).toBe(path.join(state.home, ".openclaw")); + expect(state.configPath).toBe(path.join(state.stateDir, "openclaw.json")); + expect(state.workspaceDir).toBe(path.join(state.home, "workspace")); + expect(state.env.HOME).toBe(state.home); + expect(state.env.OPENCLAW_HOME).toBe(state.home); + expect(state.env.OPENCLAW_STATE_DIR).toBe(state.stateDir); + expect(state.env.OPENCLAW_CONFIG_PATH).toBe(state.configPath); + expect(process.env.HOME).toBe(state.home); + expect(process.env.OPENCLAW_HOME).toBe(state.home); + expect(JSON.parse(await fs.readFile(state.configPath, "utf8"))).toEqual({}); + + await state.cleanup(); + + expect(process.env.HOME).toBe(previousHome); + expect(process.env.OPENCLAW_HOME).toBe(previousOpenClawHome); + expect(process.env.OPENCLAW_STATE_DIR).toBe(previousStateDir); + expect(process.env.OPENCLAW_CONFIG_PATH).toBe(previousConfigPath); + await expect(fs.stat(state.root)).rejects.toThrow(); + }); + + it("supports state-only layout without overriding HOME", async () => { + const previousHome = process.env.HOME; + + await withOpenClawTestState( + { + layout: "state-only", + scenario: "empty", + }, + async (state) => { + expect(process.env.HOME).toBe(previousHome); + expect(process.env.OPENCLAW_STATE_DIR).toBe(state.stateDir); + expect(process.env.OPENCLAW_CONFIG_PATH).toBe(state.configPath); + expect(state.env.HOME).toBe(previousHome); + await expect(fs.stat(state.configPath)).rejects.toThrow(); + }, + ); + }); + + it("clears inherited agent-dir overrides by default", async () => { + const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.OPENCLAW_AGENT_DIR = "/tmp/outside-openclaw-agent"; + process.env.PI_CODING_AGENT_DIR = "/tmp/outside-pi-agent"; + + try { + const state = await createOpenClawTestState({ + layout: "state-only", + }); + + try { + expect(process.env.OPENCLAW_AGENT_DIR).toBeUndefined(); + expect(process.env.PI_CODING_AGENT_DIR).toBeUndefined(); + expect(state.env.OPENCLAW_AGENT_DIR).toBeUndefined(); + expect(state.env.PI_CODING_AGENT_DIR).toBeUndefined(); + expect(state.agentDir()).toBe(path.join(state.stateDir, "agents", "main", "agent")); + } finally { + await state.cleanup(); + } + + expect(process.env.OPENCLAW_AGENT_DIR).toBe("/tmp/outside-openclaw-agent"); + expect(process.env.PI_CODING_AGENT_DIR).toBe("/tmp/outside-pi-agent"); + } finally { + if (previousAgentDir === undefined) { + delete process.env.OPENCLAW_AGENT_DIR; + } else { + process.env.OPENCLAW_AGENT_DIR = previousAgentDir; + } + if (previousPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; + } + } + }); + + it("allows explicit agent-dir overrides when a test needs them", async () => { + await withOpenClawTestState( + { + env: { + OPENCLAW_AGENT_DIR: "/tmp/explicit-openclaw-agent", + PI_CODING_AGENT_DIR: "/tmp/explicit-pi-agent", + }, + }, + async (state) => { + expect(process.env.OPENCLAW_AGENT_DIR).toBe("/tmp/explicit-openclaw-agent"); + expect(process.env.PI_CODING_AGENT_DIR).toBe("/tmp/explicit-pi-agent"); + expect(state.env.OPENCLAW_AGENT_DIR).toBe("/tmp/explicit-openclaw-agent"); + expect(state.env.PI_CODING_AGENT_DIR).toBe("/tmp/explicit-pi-agent"); + }, + ); + }); + + it("can route agent-dir env vars to the isolated main agent store", async () => { + await withOpenClawTestState( + { + agentEnv: "main", + }, + async (state) => { + expect(process.env.OPENCLAW_AGENT_DIR).toBe(state.agentDir()); + expect(process.env.PI_CODING_AGENT_DIR).toBe(state.agentDir()); + expect(state.env.OPENCLAW_AGENT_DIR).toBe(state.agentDir()); + expect(state.env.PI_CODING_AGENT_DIR).toBe(state.agentDir()); + }, + ); + }); + + it("writes scenario configs and auth profile stores", async () => { + await withOpenClawTestState( + { + scenario: "update-stable", + }, + async (state) => { + expect(JSON.parse(await fs.readFile(state.configPath, "utf8"))).toEqual({ + update: { + channel: "stable", + }, + plugins: {}, + }); + + const profilePath = await state.writeAuthProfiles({ + version: 1, + profiles: { + "openai:test": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }, + }); + + expect(profilePath).toBe(path.join(state.agentDir(), "auth-profiles.json")); + expect(JSON.parse(await fs.readFile(profilePath, "utf8"))).toMatchObject({ + version: 1, + profiles: { + "openai:test": { + provider: "openai", + }, + }, + }); + }, + ); + }); + + it("keeps external-service env scoped to the fixture", async () => { + const previousPolicy = process.env.OPENCLAW_SERVICE_REPAIR_POLICY; + + await withOpenClawTestState( + { + scenario: "external-service", + }, + async (state) => { + expect(process.env.OPENCLAW_SERVICE_REPAIR_POLICY).toBe("external"); + expect(state.env.OPENCLAW_SERVICE_REPAIR_POLICY).toBe("external"); + }, + ); + + expect(process.env.OPENCLAW_SERVICE_REPAIR_POLICY).toBe(previousPolicy); + }); +}); diff --git a/src/test-utils/openclaw-test-state.ts b/src/test-utils/openclaw-test-state.ts new file mode 100644 index 00000000000..018eebccbdb --- /dev/null +++ b/src/test-utils/openclaw-test-state.ts @@ -0,0 +1,321 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { captureEnv } from "./env.js"; +import { cleanupSessionStateForTest } from "./session-state-cleanup.js"; + +export type OpenClawTestStateLayout = "home" | "state-only" | "split"; + +export type OpenClawTestStateScenario = + | "empty" + | "minimal" + | "update-stable" + | "gateway-loopback" + | "external-service"; + +export type OpenClawTestStateOptions = { + prefix?: string; + label?: string; + layout?: OpenClawTestStateLayout; + scenario?: OpenClawTestStateScenario; + agentEnv?: "clear" | "main"; + applyEnv?: boolean; + env?: Record; + gateway?: { + port?: number; + token?: string; + }; +}; + +export type OpenClawTestState = { + root: string; + home: string; + stateDir: string; + configPath: string; + workspaceDir: string; + env: NodeJS.ProcessEnv; + envVars: Record; + path: (...parts: string[]) => string; + statePath: (...parts: string[]) => string; + agentDir: (agentId?: string) => string; + sessionsDir: (agentId?: string) => string; + writeConfig: (config: unknown) => Promise; + writeJson: (relativePath: string, value: unknown) => Promise; + writeText: (relativePath: string, value: string) => Promise; + writeAuthProfiles: (store: unknown, agentId?: string) => Promise; + applyEnv: () => void; + restoreEnv: () => void; + cleanup: () => Promise; +}; + +const DEFAULT_PREFIX = "openclaw-test-state-"; +const ENV_KEYS = [ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "OPENCLAW_SERVICE_REPAIR_POLICY", +] as const; + +function normalizeLabel(value: string | undefined): string { + return (value ?? "state").replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "") || "state"; +} + +function resolveWindowsHomeEnv(home: string): Pick { + if (process.platform !== "win32") { + return {}; + } + const match = home.match(/^([A-Za-z]:)(.*)$/u); + if (!match) { + return {}; + } + return { + HOMEDRIVE: match[1], + HOMEPATH: match[2] || "\\", + }; +} + +function resolveLayout( + root: string, + layout: OpenClawTestStateLayout, +): { + home: string; + stateDir: string; + configPath: string; + workspaceDir: string; +} { + if (layout === "home") { + const home = path.join(root, "home"); + const stateDir = path.join(home, ".openclaw"); + return { + home, + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + workspaceDir: path.join(home, "workspace"), + }; + } + if (layout === "split") { + const home = path.join(root, "home"); + const stateDir = path.join(root, "state"); + return { + home, + stateDir, + configPath: path.join(root, "config", "openclaw.json"), + workspaceDir: path.join(root, "workspace"), + }; + } + const stateDir = path.join(root, "state"); + return { + home: path.join(root, "home"), + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + workspaceDir: path.join(root, "workspace"), + }; +} + +function scenarioConfig(options: OpenClawTestStateOptions): unknown | undefined { + const scenario = options.scenario ?? "empty"; + if (scenario === "minimal" || scenario === "external-service") { + return {}; + } + if (scenario === "update-stable") { + return { + update: { + channel: "stable", + }, + plugins: {}, + }; + } + if (scenario === "gateway-loopback") { + return { + gateway: { + port: options.gateway?.port ?? 18789, + auth: { + mode: "token", + token: options.gateway?.token ?? "openclaw-test-token", + }, + controlUi: { + enabled: false, + }, + }, + }; + } + return undefined; +} + +function scenarioEnv(options: OpenClawTestStateOptions): Record { + if ((options.scenario ?? "empty") === "external-service") { + return { + OPENCLAW_SERVICE_REPAIR_POLICY: "external", + }; + } + return {}; +} + +function buildEnvVars(params: { + layout: OpenClawTestStateLayout; + home: string; + stateDir: string; + configPath: string; + agentDir: string; + agentEnv: "clear" | "main"; + scenarioEnv: Record; + extraEnv: Record; +}): Record { + const agentDirEnv = + params.agentEnv === "main" + ? { + OPENCLAW_AGENT_DIR: params.agentDir, + PI_CODING_AGENT_DIR: params.agentDir, + } + : { + OPENCLAW_AGENT_DIR: undefined, + PI_CODING_AGENT_DIR: undefined, + }; + const envVars: Record = { + OPENCLAW_STATE_DIR: params.stateDir, + OPENCLAW_CONFIG_PATH: params.configPath, + ...agentDirEnv, + ...params.scenarioEnv, + ...params.extraEnv, + }; + if (params.layout !== "state-only") { + Object.assign(envVars, { + HOME: params.home, + USERPROFILE: params.home, + OPENCLAW_HOME: params.home, + ...resolveWindowsHomeEnv(params.home), + }); + } + return envVars; +} + +function createSpawnEnv(envVars: Record): NodeJS.ProcessEnv { + const nextEnv: NodeJS.ProcessEnv = { ...process.env }; + for (const [key, value] of Object.entries(envVars)) { + if (value === undefined) { + delete nextEnv[key]; + } else { + nextEnv[key] = value; + } + } + return nextEnv; +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + return filePath; +} + +export async function createOpenClawTestState( + options: OpenClawTestStateOptions = {}, +): Promise { + const label = normalizeLabel(options.label ?? options.scenario); + const prefix = options.prefix ?? `${DEFAULT_PREFIX}${label}-`; + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const layout = options.layout ?? "home"; + const paths = resolveLayout(root, layout); + + await fs.mkdir(paths.stateDir, { recursive: true }); + await fs.mkdir(paths.workspaceDir, { recursive: true }); + if (layout !== "state-only") { + await fs.mkdir(paths.home, { recursive: true }); + } + + const config = scenarioConfig(options); + if (config !== undefined) { + await writeJsonFile(paths.configPath, config); + } + + const mainAgentDir = path.join(paths.stateDir, "agents", "main", "agent"); + const envVars = buildEnvVars({ + layout, + home: paths.home, + stateDir: paths.stateDir, + configPath: paths.configPath, + agentDir: mainAgentDir, + agentEnv: options.agentEnv ?? "clear", + scenarioEnv: scenarioEnv(options), + extraEnv: options.env ?? {}, + }); + const env = createSpawnEnv(envVars); + const snapshot = captureEnv([...new Set([...ENV_KEYS, ...Object.keys(envVars)])]); + let envApplied = false; + let cleaned = false; + const agentDir = (agentId = "main") => path.join(paths.stateDir, "agents", agentId, "agent"); + const sessionsDir = (agentId = "main") => + path.join(paths.stateDir, "agents", agentId, "sessions"); + + const state: OpenClawTestState = { + root, + ...paths, + env, + envVars, + path: (...parts) => path.join(root, ...parts), + statePath: (...parts) => path.join(paths.stateDir, ...parts), + agentDir, + sessionsDir, + writeConfig: (value) => writeJsonFile(paths.configPath, value), + writeJson: (relativePath, value) => + writeJsonFile(path.join(paths.stateDir, relativePath), value), + writeText: async (relativePath, value) => { + const filePath = path.join(paths.stateDir, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, value, "utf8"); + return filePath; + }, + writeAuthProfiles: (store, agentId = "main") => { + const filePath = path.join(agentDir(agentId), "auth-profiles.json"); + return writeJsonFile(filePath, store); + }, + applyEnv: () => { + for (const [key, value] of Object.entries(envVars)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + envApplied = true; + }, + restoreEnv: () => { + if (envApplied) { + snapshot.restore(); + envApplied = false; + } + }, + cleanup: async () => { + if (cleaned) { + return; + } + cleaned = true; + await cleanupSessionStateForTest().catch(() => undefined); + state.restoreEnv(); + await fs.rm(root, { recursive: true, force: true }); + }, + }; + + if (options.applyEnv !== false) { + state.applyEnv(); + } + + return state; +} + +export async function withOpenClawTestState( + options: OpenClawTestStateOptions, + fn: (state: OpenClawTestState) => Promise, +): Promise { + const state = await createOpenClawTestState(options); + try { + return await fn(state); + } finally { + await state.cleanup(); + } +} diff --git a/test/scripts/openclaw-test-state.test.ts b/test/scripts/openclaw-test-state.test.ts new file mode 100644 index 00000000000..cef0f03ab30 --- /dev/null +++ b/test/scripts/openclaw-test-state.test.ts @@ -0,0 +1,145 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const scriptPath = path.join(repoRoot, "scripts/lib/openclaw-test-state.mjs"); + +function shellQuote(value: string): string { + return `'${value.replace(/'/gu, `'\\''`)}'`; +} + +describe("scripts/lib/openclaw-test-state", () => { + it("creates a sourceable env file and JSON description", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-state-script-")); + const envFile = path.join(tempRoot, "env.sh"); + try { + const { stdout } = await execFileAsync(process.execPath, [ + scriptPath, + "--", + "create", + "--label", + "script-test", + "--scenario", + "update-stable", + "--env-file", + envFile, + "--json", + ]); + const payload = JSON.parse(stdout); + expect(payload).toMatchObject({ + label: "script-test", + scenario: "update-stable", + home: expect.any(String), + stateDir: expect.any(String), + configPath: expect.any(String), + workspaceDir: expect.any(String), + env: { + HOME: expect.any(String), + OPENCLAW_HOME: expect.any(String), + OPENCLAW_STATE_DIR: expect.any(String), + OPENCLAW_CONFIG_PATH: expect.any(String), + }, + }); + expect(payload.config).toEqual({ + update: { + channel: "stable", + }, + plugins: {}, + }); + + const envFileText = await fs.readFile(envFile, "utf8"); + expect(envFileText).toContain("export HOME="); + expect(envFileText).toContain("export OPENCLAW_HOME="); + expect(envFileText).toContain("export OPENCLAW_STATE_DIR="); + expect(envFileText).toContain("export OPENCLAW_CONFIG_PATH="); + + const probe = await execFileAsync("bash", [ + "-lc", + `source ${shellQuote(envFile)}; node -e 'const fs=require("node:fs"); const config=JSON.parse(fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH,"utf8")); process.stdout.write(JSON.stringify({home:process.env.HOME,stateDir:process.env.OPENCLAW_STATE_DIR,channel:config.update.channel}));'`, + ]); + expect(JSON.parse(probe.stdout)).toEqual({ + home: payload.home, + stateDir: payload.stateDir, + channel: "stable", + }); + await fs.rm(payload.root, { recursive: true, force: true }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("renders a Docker-friendly shell snippet", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-state-shell-")); + const snippetFile = path.join(tempRoot, "state.sh"); + try { + const { stdout } = await execFileAsync(process.execPath, [ + scriptPath, + "shell", + "--label", + "update-channel-switch", + "--scenario", + "update-stable", + ]); + expect(stdout).toContain( + "mktemp -d '/tmp/openclaw-update-channel-switch-update-stable-home.XXXXXX'", + ); + expect(stdout).toContain("OPENCLAW_TEST_STATE_JSON"); + expect(stdout).toContain('"channel": "stable"'); + await fs.writeFile(snippetFile, stdout, "utf8"); + + const probe = await execFileAsync("bash", [ + "-lc", + `source ${shellQuote(snippetFile)}; node -e 'const fs=require("node:fs"); const config=JSON.parse(fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH,"utf8")); process.stdout.write(JSON.stringify({home:process.env.HOME,openclawHome:process.env.OPENCLAW_HOME,workspace:process.env.OPENCLAW_TEST_WORKSPACE_DIR,channel:config.update.channel}));'; rm -rf "$HOME"`, + ]); + + const payload = JSON.parse(probe.stdout); + expect(payload.home).toMatch(/^\/tmp\/openclaw-update-channel-switch-update-stable-home\./u); + expect(payload.openclawHome).toBe(payload.home); + expect(payload.workspace).toBe(`${payload.home}/workspace`); + expect(payload.channel).toBe("stable"); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("renders a reusable Docker shell function", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-state-function-")); + const snippetFile = path.join(tempRoot, "state-function.sh"); + try { + const { stdout } = await execFileAsync(process.execPath, [scriptPath, "shell-function"]); + expect(stdout).toContain("openclaw_test_state_create()"); + expect(stdout).toContain("unset OPENCLAW_AGENT_DIR"); + expect(stdout).toContain("update-stable"); + await fs.writeFile(snippetFile, stdout, "utf8"); + + const probe = await execFileAsync("bash", [ + "-lc", + `source ${shellQuote(snippetFile)}; export OPENCLAW_AGENT_DIR=/tmp/outside-agent; openclaw_test_state_create "onboard case" minimal; node -e 'const fs=require("node:fs"); const config=JSON.parse(fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH,"utf8")); process.stdout.write(JSON.stringify({home:process.env.HOME,agentDir:process.env.OPENCLAW_AGENT_DIR || null,workspace:process.env.OPENCLAW_TEST_WORKSPACE_DIR,config}));'; rm -rf "$HOME"`, + ]); + + const payload = JSON.parse(probe.stdout); + expect(payload.home).toMatch(/^\/tmp\/openclaw-onboard-case-minimal-home\./u); + expect(payload.agentDir).toBeNull(); + expect(payload.workspace).toBe(`${payload.home}/workspace`); + expect(payload.config).toEqual({}); + + const existingHome = path.join(tempRoot, "existing-home"); + const existingProbe = await execFileAsync("bash", [ + "-lc", + `source ${shellQuote(snippetFile)}; openclaw_test_state_create ${shellQuote(existingHome)} minimal; printf '{"kept":true}\\n' > "$OPENCLAW_CONFIG_PATH"; openclaw_test_state_create ${shellQuote(existingHome)} empty; node -e 'const fs=require("node:fs"); const config=JSON.parse(fs.readFileSync(process.env.OPENCLAW_CONFIG_PATH,"utf8")); process.stdout.write(JSON.stringify({home:process.env.HOME,config}));'`, + ]); + + const existingPayload = JSON.parse(existingProbe.stdout); + expect(existingPayload.home).toBe(existingHome); + expect(existingPayload.config).toEqual({ kept: true }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); +});