mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 15:18:58 +00:00
test: add shared OpenClaw test-state harness
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
324
scripts/lib/openclaw-test-state.mjs
Normal file
324
scripts/lib/openclaw-test-state.mjs
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
177
src/test-utils/openclaw-test-state.test.ts
Normal file
177
src/test-utils/openclaw-test-state.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
321
src/test-utils/openclaw-test-state.ts
Normal file
321
src/test-utils/openclaw-test-state.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
145
test/scripts/openclaw-test-state.test.ts
Normal file
145
test/scripts/openclaw-test-state.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user