test: add shared OpenClaw test-state harness

This commit is contained in:
Peter Steinberger
2026-04-28 10:52:40 +01:00
parent ab3feca0d5
commit 0bc8b9a95a
23 changed files with 1447 additions and 535 deletions

View File

@@ -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 <target>` 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 <name> --scenario <name>` for an in-container shell snippet, or `node scripts/lib/openclaw-test-state.mjs -- create --label <name> --scenario <name> --env-file <path> --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.

View File

@@ -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() {

View File

@@ -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"

View File

@@ -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 <name>] [--scenario <name>] [--env-file <path>] [--json]
node scripts/lib/openclaw-test-state.mjs shell [--label <name>] [--scenario <name>]
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;
});
}

View File

@@ -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",
[

View File

@@ -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();

View File

@@ -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<T>(run: (params: { stateDir: string }) => Promise<T>): Promise<T> {
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<T>(run: (state: OpenClawTestState) => Promise<T>): Promise<T> {
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({

View File

@@ -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<Api>;
const model = {
id: "codex-mini-latest",
provider: "openai-codex",
api: "openai-codex-responses",
} as Model<Api>;
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 () => {

View File

@@ -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<T>(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<OpenClawTestState> {
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);
});
});

View File

@@ -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<typeof captureEnv>;
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");

View File

@@ -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<void>): Promise<void> {
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", () => {

View File

@@ -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<void>): Promise<void> {
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 });

View File

@@ -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<void>): Promise<void> {
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 });

View File

@@ -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<BackupCreateResult> = {}): 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<string[]> {
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 });
},
);
});
});

View File

@@ -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<void>): Promise<void> {
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", () => {

View File

@@ -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<void>,
): Promise<void> {
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", () => {

View File

@@ -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<T>(run: (root: string) => Promise<T>): Promise<T> {
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();
});

View File

@@ -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<T>(run: (root: string) => Promise<T>): Promise<T> {
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();
});

View File

@@ -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<T>(run: () => Promise<T> | T): Promise<T> {
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", () => {

View File

@@ -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",
});
});
});

View File

@@ -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);
});
});

View File

@@ -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<string, string | undefined>;
gateway?: {
port?: number;
token?: string;
};
};
export type OpenClawTestState = {
root: string;
home: string;
stateDir: string;
configPath: string;
workspaceDir: string;
env: NodeJS.ProcessEnv;
envVars: Record<string, string | undefined>;
path: (...parts: string[]) => string;
statePath: (...parts: string[]) => string;
agentDir: (agentId?: string) => string;
sessionsDir: (agentId?: string) => string;
writeConfig: (config: unknown) => Promise<string>;
writeJson: (relativePath: string, value: unknown) => Promise<string>;
writeText: (relativePath: string, value: string) => Promise<string>;
writeAuthProfiles: (store: unknown, agentId?: string) => Promise<string>;
applyEnv: () => void;
restoreEnv: () => void;
cleanup: () => Promise<void>;
};
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<NodeJS.ProcessEnv, "HOMEDRIVE" | "HOMEPATH"> {
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<string, string | undefined> {
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<string, string | undefined>;
extraEnv: Record<string, string | undefined>;
}): Record<string, string | undefined> {
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<string, string | undefined> = {
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<string, string | undefined>): 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<string> {
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<OpenClawTestState> {
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<T>(
options: OpenClawTestStateOptions,
fn: (state: OpenClawTestState) => Promise<T>,
): Promise<T> {
const state = await createOpenClawTestState(options);
try {
return await fn(state);
} finally {
await state.cleanup();
}
}

View File

@@ -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 });
}
});
});