mirror of https://github.com/openclaw/openclaw.git
fix: isolate live test home from real config
This commit is contained in:
parent
8ea4c4a6ba
commit
914becee52
|
|
@ -151,7 +151,9 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
|||
- Not CI-stable by design (real networks, real provider policies, quotas, outages)
|
||||
- Costs money / uses rate limits
|
||||
- Prefer running narrowed subsets instead of “everything”
|
||||
- Live runs will source `~/.profile` to pick up missing API keys
|
||||
- Live runs source `~/.profile` to pick up missing API keys.
|
||||
- By default, live runs still isolate `HOME` and copy config/auth material into a temp test home so unit fixtures cannot mutate your real `~/.openclaw`.
|
||||
- Set `OPENCLAW_LIVE_USE_REAL_HOME=1` only when you intentionally need live tests to use your real home directory.
|
||||
- `pnpm test:live` now defaults to a quieter mode: it keeps `[live] ...` progress output, but suppresses the extra `~/.profile` notice and mutes gateway bootstrap logs/Bonjour chatter. Set `OPENCLAW_LIVE_TEST_QUIET=0` if you want the full startup logs back.
|
||||
- API key rotation (provider-specific): set `*_API_KEYS` with comma/semicolon format or `*_API_KEY_1`, `*_API_KEY_2` (for example `OPENAI_API_KEYS`, `ANTHROPIC_API_KEYS`, `GEMINI_API_KEYS`) or per-live override via `OPENCLAW_LIVE_*_KEY`; tests retry on rate limit responses.
|
||||
- Progress/heartbeat output:
|
||||
|
|
@ -452,6 +454,7 @@ Live tests discover credentials the same way the CLI does. Practical implication
|
|||
|
||||
- Profile store: `~/.openclaw/credentials/` (preferred; what “profile keys” means in the tests)
|
||||
- Config: `~/.openclaw/openclaw.json` (or `OPENCLAW_CONFIG_PATH`)
|
||||
- Live local runs copy the active config plus auth stores into a temp test home by default; `agents.*.workspace` / `agentDir` path overrides are stripped in that staged copy so probes stay off your real host workspace.
|
||||
|
||||
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { installTestEnv } from "./test-env.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
const tempDirs = new Set<string>();
|
||||
const cleanupFns: Array<() => void> = [];
|
||||
|
||||
function restoreProcessEnv(): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeFile(targetPath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, content, "utf8");
|
||||
}
|
||||
|
||||
function createTempHome(): string {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-env-real-home-"));
|
||||
tempDirs.add(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupFns.length > 0) {
|
||||
cleanupFns.pop()?.();
|
||||
}
|
||||
restoreProcessEnv();
|
||||
for (const tempDir of tempDirs) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
describe("installTestEnv", () => {
|
||||
it("keeps live tests on a temp HOME while copying config and auth state", () => {
|
||||
const realHome = createTempHome();
|
||||
writeFile(path.join(realHome, ".profile"), "export TEST_PROFILE_ONLY=from-profile\n");
|
||||
writeFile(
|
||||
path.join(realHome, "custom-openclaw.json5"),
|
||||
`{
|
||||
// Preserve provider config, strip host-bound paths.
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/Users/peter/Projects",
|
||||
agentDir: "/Users/peter/.openclaw/agents/main/agent",
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "dev",
|
||||
workspace: "/Users/peter/dev-workspace",
|
||||
agentDir: "/Users/peter/.openclaw/agents/dev/agent",
|
||||
},
|
||||
],
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
custom: { baseUrl: "https://example.test/v1" },
|
||||
},
|
||||
},
|
||||
}`,
|
||||
);
|
||||
writeFile(path.join(realHome, ".openclaw", "credentials", "token.txt"), "secret\n");
|
||||
writeFile(
|
||||
path.join(realHome, ".openclaw", "agents", "main", "agent", "auth-profiles.json"),
|
||||
JSON.stringify({ version: 1, profiles: { default: { provider: "openai" } } }, null, 2),
|
||||
);
|
||||
writeFile(path.join(realHome, ".claude", ".credentials.json"), '{"accessToken":"token"}\n');
|
||||
|
||||
process.env.HOME = realHome;
|
||||
process.env.USERPROFILE = realHome;
|
||||
process.env.OPENCLAW_LIVE_TEST = "1";
|
||||
process.env.OPENCLAW_LIVE_TEST_QUIET = "1";
|
||||
process.env.OPENCLAW_CONFIG_PATH = "~/custom-openclaw.json5";
|
||||
|
||||
const testEnv = installTestEnv();
|
||||
cleanupFns.push(testEnv.cleanup);
|
||||
|
||||
expect(testEnv.tempHome).not.toBe(realHome);
|
||||
expect(process.env.HOME).toBe(testEnv.tempHome);
|
||||
expect(process.env.OPENCLAW_TEST_HOME).toBe(testEnv.tempHome);
|
||||
expect(process.env.TEST_PROFILE_ONLY).toBe("from-profile");
|
||||
|
||||
const copiedConfigPath = path.join(testEnv.tempHome, ".openclaw", "openclaw.json");
|
||||
const copiedConfig = JSON.parse(fs.readFileSync(copiedConfigPath, "utf8")) as {
|
||||
agents?: {
|
||||
defaults?: Record<string, unknown>;
|
||||
list?: Array<Record<string, unknown>>;
|
||||
};
|
||||
models?: { providers?: Record<string, unknown> };
|
||||
};
|
||||
expect(copiedConfig.models?.providers?.custom).toEqual({ baseUrl: "https://example.test/v1" });
|
||||
expect(copiedConfig.agents?.defaults?.workspace).toBeUndefined();
|
||||
expect(copiedConfig.agents?.defaults?.agentDir).toBeUndefined();
|
||||
expect(copiedConfig.agents?.list?.[0]?.workspace).toBeUndefined();
|
||||
expect(copiedConfig.agents?.list?.[0]?.agentDir).toBeUndefined();
|
||||
|
||||
expect(
|
||||
fs.existsSync(path.join(testEnv.tempHome, ".openclaw", "credentials", "token.txt")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(testEnv.tempHome, ".openclaw", "agents", "main", "agent", "auth-profiles.json"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(testEnv.tempHome, ".claude", ".credentials.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows explicit live runs against the real HOME", () => {
|
||||
const realHome = createTempHome();
|
||||
writeFile(path.join(realHome, ".profile"), "export TEST_PROFILE_ONLY=from-profile\n");
|
||||
|
||||
process.env.HOME = realHome;
|
||||
process.env.USERPROFILE = realHome;
|
||||
process.env.OPENCLAW_LIVE_TEST = "1";
|
||||
process.env.OPENCLAW_LIVE_USE_REAL_HOME = "1";
|
||||
process.env.OPENCLAW_LIVE_TEST_QUIET = "1";
|
||||
|
||||
const testEnv = installTestEnv();
|
||||
|
||||
expect(testEnv.tempHome).toBe(realHome);
|
||||
expect(process.env.HOME).toBe(realHome);
|
||||
expect(process.env.TEST_PROFILE_ONLY).toBe("from-profile");
|
||||
});
|
||||
});
|
||||
169
test/test-env.ts
169
test/test-env.ts
|
|
@ -2,9 +2,12 @@ import { execFileSync } from "node:child_process";
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
|
||||
type RestoreEntry = { key: string; value: string | undefined };
|
||||
|
||||
const LIVE_EXTERNAL_AUTH_DIRS = [".claude", ".codex", ".minimax"] as const;
|
||||
|
||||
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
|
|
@ -31,8 +34,19 @@ function restoreEnv(entries: RestoreEntry[]): void {
|
|||
}
|
||||
}
|
||||
|
||||
function loadProfileEnv(): void {
|
||||
const profilePath = path.join(os.homedir(), ".profile");
|
||||
function resolveHomeRelativePath(input: string, homeDir: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "~") {
|
||||
return homeDir;
|
||||
}
|
||||
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
||||
return path.join(homeDir, trimmed.slice(2));
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function loadProfileEnv(homeDir = os.homedir()): void {
|
||||
const profilePath = path.join(homeDir, ".profile");
|
||||
if (!fs.existsSync(profilePath)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -67,20 +81,8 @@ function loadProfileEnv(): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
||||
const live =
|
||||
process.env.LIVE === "1" ||
|
||||
process.env.OPENCLAW_LIVE_TEST === "1" ||
|
||||
process.env.OPENCLAW_LIVE_GATEWAY === "1";
|
||||
|
||||
// Live tests must use the real user environment (keys, profiles, config).
|
||||
// The default test env isolates HOME to avoid touching real state.
|
||||
if (live) {
|
||||
loadProfileEnv();
|
||||
return { cleanup: () => {}, tempHome: process.env.HOME ?? "" };
|
||||
}
|
||||
|
||||
const restore: RestoreEntry[] = [
|
||||
function resolveRestoreEntries(): RestoreEntry[] {
|
||||
return [
|
||||
{ key: "OPENCLAW_TEST_FAST", value: process.env.OPENCLAW_TEST_FAST },
|
||||
{ key: "HOME", value: process.env.HOME },
|
||||
{ key: "USERPROFILE", value: process.env.USERPROFILE },
|
||||
|
|
@ -96,6 +98,8 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
|||
{ key: "OPENCLAW_BRIDGE_PORT", value: process.env.OPENCLAW_BRIDGE_PORT },
|
||||
{ key: "OPENCLAW_CANVAS_HOST_PORT", value: process.env.OPENCLAW_CANVAS_HOST_PORT },
|
||||
{ key: "OPENCLAW_TEST_HOME", value: process.env.OPENCLAW_TEST_HOME },
|
||||
{ key: "OPENCLAW_AGENT_DIR", value: process.env.OPENCLAW_AGENT_DIR },
|
||||
{ key: "PI_CODING_AGENT_DIR", value: process.env.PI_CODING_AGENT_DIR },
|
||||
{ key: "TELEGRAM_BOT_TOKEN", value: process.env.TELEGRAM_BOT_TOKEN },
|
||||
{ key: "DISCORD_BOT_TOKEN", value: process.env.DISCORD_BOT_TOKEN },
|
||||
{ key: "SLACK_BOT_TOKEN", value: process.env.SLACK_BOT_TOKEN },
|
||||
|
|
@ -106,7 +110,12 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
|||
{ key: "GITHUB_TOKEN", value: process.env.GITHUB_TOKEN },
|
||||
{ key: "NODE_OPTIONS", value: process.env.NODE_OPTIONS },
|
||||
];
|
||||
}
|
||||
|
||||
function createIsolatedTestHome(restore: RestoreEntry[]): {
|
||||
cleanup: () => void;
|
||||
tempHome: string;
|
||||
} {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-home-"));
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
|
|
@ -118,6 +127,8 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
|||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
// Prefer deriving state dir from HOME so nested tests that change HOME also isolate correctly.
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
// Prefer test-controlled ports over developer overrides (avoid port collisions across tests/workers).
|
||||
delete process.env.OPENCLAW_GATEWAY_PORT;
|
||||
delete process.env.OPENCLAW_BRIDGE_ENABLED;
|
||||
|
|
@ -158,6 +169,132 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
|||
return { cleanup, tempHome };
|
||||
}
|
||||
|
||||
function ensureParentDir(targetPath: string): void {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
}
|
||||
|
||||
function copyDirIfExists(sourcePath: string, targetPath: string): void {
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
fs.cpSync(sourcePath, targetPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
function copyFileIfExists(sourcePath: string, targetPath: string): void {
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return;
|
||||
}
|
||||
ensureParentDir(targetPath);
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
function sanitizeLiveConfig(raw: string): string {
|
||||
try {
|
||||
const parsed = JSON5.parse(raw) as {
|
||||
agents?: {
|
||||
defaults?: Record<string, unknown>;
|
||||
list?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return raw;
|
||||
}
|
||||
if (parsed.agents?.defaults && typeof parsed.agents.defaults === "object") {
|
||||
delete parsed.agents.defaults.workspace;
|
||||
delete parsed.agents.defaults.agentDir;
|
||||
}
|
||||
if (Array.isArray(parsed.agents?.list)) {
|
||||
parsed.agents.list = parsed.agents.list.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return entry;
|
||||
}
|
||||
const nextEntry = { ...entry };
|
||||
delete nextEntry.workspace;
|
||||
delete nextEntry.agentDir;
|
||||
return nextEntry;
|
||||
});
|
||||
}
|
||||
return `${JSON.stringify(parsed, null, 2)}\n`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function copyLiveAuthProfiles(realStateDir: string, tempStateDir: string): void {
|
||||
const agentsDir = path.join(realStateDir, "agents");
|
||||
if (!fs.existsSync(agentsDir)) {
|
||||
return;
|
||||
}
|
||||
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const sourcePath = path.join(agentsDir, entry.name, "agent", "auth-profiles.json");
|
||||
const targetPath = path.join(tempStateDir, "agents", entry.name, "agent", "auth-profiles.json");
|
||||
copyFileIfExists(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
function stageLiveTestState(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
realHome: string;
|
||||
tempHome: string;
|
||||
}): void {
|
||||
const realStateDir = params.env.OPENCLAW_STATE_DIR?.trim()
|
||||
? resolveHomeRelativePath(params.env.OPENCLAW_STATE_DIR, params.realHome)
|
||||
: path.join(params.realHome, ".openclaw");
|
||||
const tempStateDir = path.join(params.tempHome, ".openclaw");
|
||||
fs.mkdirSync(tempStateDir, { recursive: true });
|
||||
|
||||
const realConfigPath = params.env.OPENCLAW_CONFIG_PATH?.trim()
|
||||
? resolveHomeRelativePath(params.env.OPENCLAW_CONFIG_PATH, params.realHome)
|
||||
: path.join(realStateDir, "openclaw.json");
|
||||
if (fs.existsSync(realConfigPath)) {
|
||||
const rawConfig = fs.readFileSync(realConfigPath, "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(tempStateDir, "openclaw.json"),
|
||||
sanitizeLiveConfig(rawConfig),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
copyDirIfExists(path.join(realStateDir, "credentials"), path.join(tempStateDir, "credentials"));
|
||||
copyLiveAuthProfiles(realStateDir, tempStateDir);
|
||||
|
||||
for (const authDir of LIVE_EXTERNAL_AUTH_DIRS) {
|
||||
copyDirIfExists(path.join(params.realHome, authDir), path.join(params.tempHome, authDir));
|
||||
}
|
||||
}
|
||||
|
||||
export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
||||
const live =
|
||||
process.env.LIVE === "1" ||
|
||||
process.env.OPENCLAW_LIVE_TEST === "1" ||
|
||||
process.env.OPENCLAW_LIVE_GATEWAY === "1";
|
||||
const allowRealHome = isTruthyEnvValue(process.env.OPENCLAW_LIVE_USE_REAL_HOME);
|
||||
const realHome = process.env.HOME ?? os.homedir();
|
||||
const liveEnvSnapshot = { ...process.env };
|
||||
|
||||
loadProfileEnv(realHome);
|
||||
|
||||
if (live && allowRealHome) {
|
||||
return { cleanup: () => {}, tempHome: realHome };
|
||||
}
|
||||
|
||||
const restore = resolveRestoreEntries();
|
||||
const testEnv = createIsolatedTestHome(restore);
|
||||
|
||||
if (live) {
|
||||
stageLiveTestState({ env: liveEnvSnapshot, realHome, tempHome: testEnv.tempHome });
|
||||
}
|
||||
|
||||
return testEnv;
|
||||
}
|
||||
|
||||
export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } {
|
||||
return installTestEnv();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue