Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const APPCAST_URL = new URL("../appcast.xml", import.meta.url);
function expectedSparkleVersion(shortVersion: string): string {
const [year, month, day] = shortVersion.split(".");
if (!year || !month || !day) {
throw new Error(`unexpected short version: ${shortVersion}`);
}
return `${year}${month.padStart(2, "0")}${day.padStart(2, "0")}0`;
}
describe("appcast.xml", () => {
it("uses the expected Sparkle version for 2026.2.15", () => {
const appcast = readFileSync(APPCAST_URL, "utf8");
const shortVersion = "2026.2.15";
const items = Array.from(appcast.matchAll(/<item>[\s\S]*?<\/item>/g)).map((match) => match[0]);
const matchingItem = items.find((item) =>
item.includes(`<sparkle:shortVersionString>${shortVersion}</sparkle:shortVersionString>`),
);
expect(matchingItem).toBeDefined();
const sparkleMatch = matchingItem?.match(/<sparkle:version>([^<]+)<\/sparkle:version>/);
expect(sparkleMatch?.[1]).toBe(expectedSparkleVersion(shortVersion));
});
});

View File

@@ -0,0 +1,11 @@
process.stdout.write("ready\n");
const keepAlive = setInterval(() => {}, 1000);
const shutdown = () => {
clearInterval(keepAlive);
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

View File

@@ -0,0 +1,82 @@
{
"cases": [
{
"id": "simple-pipeline",
"command": "echo ok | jq .foo",
"ok": true,
"executables": ["echo", "jq"]
},
{
"id": "chained-commands",
"command": "ls && rm -rf /tmp/openclaw-allowlist",
"ok": true,
"executables": ["ls", "rm"]
},
{
"id": "quoted-chain-operators-remain-literal",
"command": "echo \"a && b\"",
"ok": true,
"executables": ["echo"]
},
{
"id": "reject-command-substitution-unquoted",
"command": "echo $(whoami)",
"ok": false,
"executables": []
},
{
"id": "reject-command-substitution-double-quoted",
"command": "echo \"output: $(whoami)\"",
"ok": false,
"executables": []
},
{
"id": "allow-command-substitution-literal-in-single-quotes",
"command": "echo 'output: $(whoami)'",
"ok": true,
"executables": ["echo"]
},
{
"id": "allow-escaped-command-substitution-double-quoted",
"command": "echo \"output: \\$(whoami)\"",
"ok": true,
"executables": ["echo"]
},
{
"id": "reject-backticks-unquoted",
"command": "echo `id`",
"ok": false,
"executables": []
},
{
"id": "reject-backticks-double-quoted",
"command": "echo \"output: `id`\"",
"ok": false,
"executables": []
},
{
"id": "reject-process-substitution-unquoted-input",
"command": "cat <(echo ok)",
"ok": false,
"executables": []
},
{
"id": "reject-process-substitution-unquoted-output",
"command": "echo >(cat)",
"ok": false,
"executables": []
},
{
"id": "allow-process-substitution-literal-double-quoted-input",
"command": "echo \"<(echo ok)\"",
"ok": true,
"executables": ["echo"]
},
{
"id": "allow-process-substitution-literal-double-quoted-output",
"command": "echo \">(cat)\"",
"ok": true,
"executables": ["echo"]
}
]
}

View File

@@ -0,0 +1,39 @@
{
"cases": [
{
"id": "direct-absolute-executable",
"argv": ["/usr/bin/printf", "ok"],
"expectedRawExecutable": "/usr/bin/printf"
},
{
"id": "env-assignment-prefix",
"argv": ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
"expectedRawExecutable": "/usr/bin/env"
},
{
"id": "env-option-with-separate-value",
"argv": ["/usr/bin/env", "-u", "HOME", "/usr/bin/printf", "ok"],
"expectedRawExecutable": "/usr/bin/env"
},
{
"id": "env-option-with-inline-value",
"argv": ["/usr/bin/env", "-uHOME", "/usr/bin/printf", "ok"],
"expectedRawExecutable": "/usr/bin/env"
},
{
"id": "nested-env-wrappers",
"argv": ["/usr/bin/env", "/usr/bin/env", "FOO=bar", "printf", "ok"],
"expectedRawExecutable": "/usr/bin/env"
},
{
"id": "env-shell-wrapper-stops-at-shell",
"argv": ["/usr/bin/env", "bash", "-lc", "echo ok"],
"expectedRawExecutable": "bash"
},
{
"id": "env-missing-effective-command",
"argv": ["/usr/bin/env", "FOO=bar"],
"expectedRawExecutable": "/usr/bin/env"
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,115 @@
{
"cases": [
{
"name": "v1 matches when env key order changes",
"request": {
"host": "node",
"command": "git diff",
"bindingV1": {
"argv": ["git", "diff"],
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "SAFE_A": "1", "SAFE_B": "2" }
}
},
"invoke": {
"argv": ["git", "diff"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "SAFE_B": "2", "SAFE_A": "1" }
}
},
"expected": { "ok": true }
},
{
"name": "v1 rejects env mismatch",
"request": {
"host": "node",
"command": "git diff",
"bindingV1": {
"argv": ["git", "diff"],
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "SAFE": "1" }
}
},
"invoke": {
"argv": ["git", "diff"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "SAFE": "2" }
}
},
"expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" }
},
{
"name": "v1 rejects unbound env overrides",
"request": {
"host": "node",
"command": "git diff",
"bindingV1": {
"argv": ["git", "diff"],
"cwd": null,
"agentId": null,
"sessionKey": null
}
},
"invoke": {
"argv": ["git", "diff"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null,
"env": { "GIT_EXTERNAL_DIFF": "/tmp/pwn.sh" }
}
},
"expected": { "ok": false, "code": "APPROVAL_ENV_BINDING_MISSING" }
},
{
"name": "missing binding rejects requests even with matching argv",
"request": {
"host": "node",
"command": "echo SAFE",
"commandArgv": ["echo", "SAFE"]
},
"invoke": {
"argv": ["echo", "SAFE"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null
}
},
"expected": { "ok": false, "code": "APPROVAL_REQUEST_MISMATCH" }
},
{
"name": "v1 stays authoritative when legacy command text diverges",
"request": {
"host": "node",
"command": "echo STALE",
"commandArgv": ["echo", "STALE"],
"bindingV1": {
"argv": ["echo", "SAFE"],
"cwd": null,
"agentId": null,
"sessionKey": null
}
},
"invoke": {
"argv": ["echo", "SAFE"],
"binding": {
"cwd": null,
"agentId": null,
"sessionKey": null
}
},
"expected": { "ok": true }
}
]
}

View File

@@ -0,0 +1,67 @@
{
"cases": [
{
"name": "request mismatch preserves base details",
"runId": "approval-req-1",
"match": {
"ok": false,
"code": "APPROVAL_REQUEST_MISMATCH",
"message": "approval id does not match request"
},
"expected": {
"ok": false,
"message": "approval id does not match request",
"details": {
"code": "APPROVAL_REQUEST_MISMATCH",
"runId": "approval-req-1"
}
}
},
{
"name": "missing env binding keeps env key details",
"runId": "approval-env-missing",
"match": {
"ok": false,
"code": "APPROVAL_ENV_BINDING_MISSING",
"message": "approval id missing env binding for requested env overrides",
"details": {
"envKeys": ["GIT_EXTERNAL_DIFF"]
}
},
"expected": {
"ok": false,
"message": "approval id missing env binding for requested env overrides",
"details": {
"code": "APPROVAL_ENV_BINDING_MISSING",
"runId": "approval-env-missing",
"envKeys": ["GIT_EXTERNAL_DIFF"]
}
}
},
{
"name": "env mismatch preserves hash diagnostics",
"runId": "approval-env-mismatch",
"match": {
"ok": false,
"code": "APPROVAL_ENV_MISMATCH",
"message": "approval id env binding mismatch",
"details": {
"envKeys": ["SAFE_A"],
"expectedEnvHash": "expected-hash",
"actualEnvHash": "actual-hash"
}
},
"expected": {
"ok": false,
"message": "approval id env binding mismatch",
"details": {
"code": "APPROVAL_ENV_MISMATCH",
"runId": "approval-env-mismatch",
"envKeys": ["SAFE_A"],
"expectedEnvHash": "expected-hash",
"actualEnvHash": "actual-hash"
}
}
}
]
}

View File

@@ -0,0 +1,75 @@
{
"cases": [
{
"name": "direct argv infers display command",
"command": ["echo", "hi there"],
"expected": {
"valid": true,
"displayCommand": "echo \"hi there\""
}
},
{
"name": "direct argv rejects mismatched raw command",
"command": ["uname", "-a"],
"rawCommand": "echo hi",
"expected": {
"valid": false,
"errorContains": "rawCommand does not match command"
}
},
{
"name": "shell wrapper accepts shell payload raw command when no positional argv carriers",
"command": ["/bin/sh", "-lc", "echo hi"],
"rawCommand": "echo hi",
"expected": {
"valid": true,
"displayCommand": "echo hi"
}
},
{
"name": "shell wrapper positional argv carrier requires full argv display binding",
"command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"],
"rawCommand": "$0 \"$1\"",
"expected": {
"valid": false,
"errorContains": "rawCommand does not match command"
}
},
{
"name": "shell wrapper positional argv carrier accepts canonical full argv raw command",
"command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"],
"rawCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker",
"expected": {
"valid": true,
"displayCommand": "/bin/sh -lc \"$0 \\\"$1\\\"\" /usr/bin/touch /tmp/marker"
}
},
{
"name": "env wrapper shell payload accepted when prelude has no env modifiers",
"command": ["/usr/bin/env", "bash", "-lc", "echo hi"],
"rawCommand": "echo hi",
"expected": {
"valid": true,
"displayCommand": "echo hi"
}
},
{
"name": "env assignment prelude requires full argv display binding",
"command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
"rawCommand": "echo hi",
"expected": {
"valid": false,
"errorContains": "rawCommand does not match command"
}
},
{
"name": "env assignment prelude accepts canonical full argv raw command",
"command": ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
"rawCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\"",
"expected": {
"valid": true,
"displayCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\""
}
}
]
}

View File

@@ -0,0 +1,125 @@
import { randomUUID } from "node:crypto";
import { afterAll, describe, expect, it } from "vitest";
import { GatewayClient } from "../src/gateway/client.js";
import { connectGatewayClient } from "../src/gateway/test-helpers.e2e.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
import {
type ChatEventPayload,
type GatewayInstance,
connectNode,
extractFirstTextBlock,
postJson,
spawnGatewayInstance,
stopGatewayInstance,
waitForChatFinalEvent,
waitForNodeStatus,
} from "./helpers/gateway-e2e-harness.js";
const E2E_TIMEOUT_MS = 120_000;
describe("gateway multi-instance e2e", () => {
const instances: GatewayInstance[] = [];
const nodeClients: GatewayClient[] = [];
const chatClients: GatewayClient[] = [];
afterAll(async () => {
for (const client of nodeClients) {
client.stop();
}
for (const client of chatClients) {
client.stop();
}
for (const inst of instances) {
await stopGatewayInstance(inst);
}
});
it(
"spins up two gateways and exercises WS + HTTP + node pairing",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const [gwA, gwB] = await Promise.all([spawnGatewayInstance("a"), spawnGatewayInstance("b")]);
instances.push(gwA, gwB);
const [hookResA, hookResB] = await Promise.all([
postJson(
`http://127.0.0.1:${gwA.port}/hooks/wake`,
{
text: "wake a",
mode: "now",
},
{ "x-openclaw-token": gwA.hookToken },
),
postJson(
`http://127.0.0.1:${gwB.port}/hooks/wake`,
{
text: "wake b",
mode: "now",
},
{ "x-openclaw-token": gwB.hookToken },
),
]);
expect(hookResA.status).toBe(200);
expect((hookResA.json as { ok?: boolean } | undefined)?.ok).toBe(true);
expect(hookResB.status).toBe(200);
expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true);
const [nodeA, nodeB] = await Promise.all([
connectNode(gwA, "node-a"),
connectNode(gwB, "node-b"),
]);
nodeClients.push(nodeA.client, nodeB.client);
await Promise.all([
waitForNodeStatus(gwA, nodeA.nodeId),
waitForNodeStatus(gwB, nodeB.nodeId),
]);
},
);
it(
"delivers final chat event for telegram-shaped session keys",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const gw = await spawnGatewayInstance("chat-telegram-fixture");
instances.push(gw);
const chatEvents: ChatEventPayload[] = [];
const chatClient = await connectGatewayClient({
url: `ws://127.0.0.1:${gw.port}`,
token: gw.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "chat-e2e-cli",
clientVersion: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.CLI,
onEvent: (evt) => {
if (evt.event === "chat" && evt.payload && typeof evt.payload === "object") {
chatEvents.push(evt.payload as ChatEventPayload);
}
},
});
chatClients.push(chatClient);
const sessionKey = "agent:main:telegram:direct:123456";
const idempotencyKey = `idem-${randomUUID()}`;
const sendRes = await chatClient.request<{ runId?: string; status?: string }>("chat.send", {
sessionKey,
message: "/context list",
idempotencyKey,
});
expect(sendRes.status).toBe("started");
const runId = sendRes.runId;
expect(typeof runId).toBe("string");
const finalEvent = await waitForChatFinalEvent({
events: chatEvents,
runId: String(runId),
sessionKey,
});
const finalText = extractFirstTextBlock(finalEvent.message);
expect(typeof finalText).toBe("string");
expect(finalText?.length).toBeGreaterThan(0);
},
);
});

View File

@@ -0,0 +1,48 @@
import { execFileSync } from "node:child_process";
import { chmodSync, copyFileSync } from "node:fs";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
const run = (cwd: string, cmd: string, args: string[] = []) => {
return execFileSync(cmd, args, { cwd, encoding: "utf8" }).trim();
};
describe("git-hooks/pre-commit (integration)", () => {
it("does not treat staged filenames as git-add flags (e.g. --all)", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-pre-commit-"));
run(dir, "git", ["init", "-q"]);
// Copy the hook + helpers so the test exercises real on-disk wiring.
await mkdir(path.join(dir, "git-hooks"), { recursive: true });
await mkdir(path.join(dir, "scripts", "pre-commit"), { recursive: true });
copyFileSync(
path.join(process.cwd(), "git-hooks", "pre-commit"),
path.join(dir, "git-hooks", "pre-commit"),
);
copyFileSync(
path.join(process.cwd(), "scripts", "pre-commit", "run-node-tool.sh"),
path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"),
);
copyFileSync(
path.join(process.cwd(), "scripts", "pre-commit", "filter-staged-files.mjs"),
path.join(dir, "scripts", "pre-commit", "filter-staged-files.mjs"),
);
chmodSync(path.join(dir, "git-hooks", "pre-commit"), 0o755);
chmodSync(path.join(dir, "scripts", "pre-commit", "run-node-tool.sh"), 0o755);
// Create an untracked file that should NOT be staged by the hook.
await writeFile(path.join(dir, "secret.txt"), "do-not-stage\n");
// Stage a maliciously-named file. Older hooks using `xargs git add` could run `git add --all`.
await writeFile(path.join(dir, "--all"), "flag\n");
run(dir, "git", ["add", "--", "--all"]);
// Run the hook directly (same logic as when installed via core.hooksPath).
run(dir, "bash", ["git-hooks/pre-commit"]);
const staged = run(dir, "git", ["diff", "--cached", "--name-only"]).split("\n").filter(Boolean);
expect(staged).toEqual(["--all"]);
});
});

View File

@@ -0,0 +1,6 @@
import { installTestEnv } from "./test-env";
export default async () => {
const { cleanup } = installTestEnv();
return () => cleanup();
};

View File

@@ -0,0 +1,18 @@
import { vi } from "vitest";
export function buildDispatchInboundCaptureMock<T extends Record<string, unknown>>(
actual: T,
setCtx: (ctx: unknown) => void,
) {
const dispatchInboundMessage = vi.fn(async (params: { ctx: unknown }) => {
setCtx(params.ctx);
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
});
return {
...actual,
dispatchInboundMessage,
dispatchInboundMessageWithDispatcher: dispatchInboundMessage,
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage,
};
}

View File

@@ -0,0 +1,43 @@
import {
formatUtcTimestamp,
formatZonedTimestamp,
} from "../../src/infra/format-time/format-datetime.js";
export { escapeRegExp } from "../../src/utils.js";
type EnvelopeTimestampZone = string;
export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string {
const trimmedZone = zone.trim();
const normalized = trimmedZone.toLowerCase();
const weekday = (() => {
try {
if (normalized === "utc" || normalized === "gmt") {
return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", weekday: "short" }).format(date);
}
if (normalized === "local" || normalized === "host") {
return new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(date);
}
return new Intl.DateTimeFormat("en-US", { timeZone: trimmedZone, weekday: "short" }).format(
date,
);
} catch {
return undefined;
}
})();
if (normalized === "utc" || normalized === "gmt") {
const ts = formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
if (normalized === "local" || normalized === "host") {
const ts = formatZonedTimestamp(date) ?? formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
const ts = formatZonedTimestamp(date, { timeZone: trimmedZone }) ?? formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
export function formatLocalEnvelopeTimestamp(date: Date): string {
return formatEnvelopeTimestamp(date, "local");
}

View File

@@ -0,0 +1,17 @@
import { vi } from "vitest";
export function useFastShortTimeouts(maxDelayMs = 2000): () => void {
const realSetTimeout = setTimeout;
const spy = vi.spyOn(global, "setTimeout").mockImplementation(((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
const delay = typeof timeout === "number" ? timeout : 0;
if (delay > 0 && delay <= maxDelayMs) {
return realSetTimeout(handler, 0, ...args);
}
return realSetTimeout(handler, delay, ...args);
}) as typeof setTimeout);
return () => spy.mockRestore();
}

View File

@@ -0,0 +1,395 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { request as httpRequest } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { GatewayClient } from "../../src/gateway/client.js";
import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js";
import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js";
import { sleep } from "../../src/utils.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../src/utils/message-channel.js";
type NodeListPayload = {
nodes?: Array<{ nodeId?: string; connected?: boolean; paired?: boolean }>;
};
export type ChatEventPayload = {
runId?: string;
sessionKey?: string;
state?: string;
message?: unknown;
};
export type GatewayInstance = {
name: string;
port: number;
hookToken: string;
gatewayToken: string;
homeDir: string;
stateDir: string;
configPath: string;
child: ChildProcessWithoutNullStreams;
stdout: string[];
stderr: string[];
};
const GATEWAY_START_TIMEOUT_MS = 60_000;
const GATEWAY_STOP_TIMEOUT_MS = 1_500;
const GATEWAY_CONNECT_STATUS_TIMEOUT_MS = 2_000;
const GATEWAY_NODE_STATUS_TIMEOUT_MS = 4_000;
const GATEWAY_NODE_STATUS_POLL_MS = 20;
const getFreePort = async () => {
const srv = net.createServer();
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close();
throw new Error("failed to bind ephemeral port");
}
await new Promise<void>((resolve) => srv.close(() => resolve()));
return addr.port;
};
async function waitForPortOpen(
proc: ChildProcessWithoutNullStreams,
chunksOut: string[],
chunksErr: string[],
port: number,
timeoutMs: number,
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (proc.exitCode !== null) {
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
);
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect({ host: "127.0.0.1", port });
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("error", (err) => {
socket.destroy();
reject(err);
});
});
return;
} catch {
// keep polling
}
await sleep(10);
}
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`timeout waiting for gateway to listen on port ${port}\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
);
}
export async function spawnGatewayInstance(name: string): Promise<GatewayInstance> {
const port = await getFreePort();
const hookToken = `token-${name}-${randomUUID()}`;
const gatewayToken = `gateway-${name}-${randomUUID()}`;
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-e2e-${name}-`));
const configDir = path.join(homeDir, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
const stateDir = path.join(configDir, "state");
const config = {
gateway: {
port,
auth: { mode: "token", token: gatewayToken },
controlUi: { enabled: false },
},
hooks: { enabled: true, token: hookToken, path: "/hooks" },
};
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
const stdout: string[] = [];
const stderr: string[] = [];
let child: ChildProcessWithoutNullStreams | null = null;
try {
child = spawn(
"node",
[
"dist/index.js",
"gateway",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
],
{
cwd: process.cwd(),
env: {
...process.env,
HOME: homeDir,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_GATEWAY_TOKEN: "",
OPENCLAW_GATEWAY_PASSWORD: "",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_PROVIDERS: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
VITEST: "1",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (d) => stdout.push(String(d)));
child.stderr?.on("data", (d) => stderr.push(String(d)));
await waitForPortOpen(child, stdout, stderr, port, GATEWAY_START_TIMEOUT_MS);
return {
name,
port,
hookToken,
gatewayToken,
homeDir,
stateDir,
configPath,
child,
stdout,
stderr,
};
} catch (err) {
if (child && child.exitCode === null && !child.killed) {
try {
child.kill("SIGKILL");
} catch {
// ignore
}
}
await fs.rm(homeDir, { recursive: true, force: true });
throw err;
}
}
export async function stopGatewayInstance(inst: GatewayInstance) {
if (inst.child.exitCode === null && !inst.child.killed) {
try {
inst.child.kill("SIGTERM");
} catch {
// ignore
}
}
const exited = await Promise.race([
new Promise<boolean>((resolve) => {
if (inst.child.exitCode !== null) {
return resolve(true);
}
inst.child.once("exit", () => resolve(true));
}),
sleep(GATEWAY_STOP_TIMEOUT_MS).then(() => false),
]);
if (!exited && inst.child.exitCode === null && !inst.child.killed) {
try {
inst.child.kill("SIGKILL");
} catch {
// ignore
}
}
await fs.rm(inst.homeDir, { recursive: true, force: true });
}
export async function postJson(
url: string,
body: unknown,
headers?: Record<string, string>,
): Promise<{ status: number; json: unknown }> {
const payload = JSON.stringify(body);
const parsed = new URL(url);
return await new Promise<{ status: number; json: unknown }>((resolve, reject) => {
const req = httpRequest(
{
method: "POST",
hostname: parsed.hostname,
port: Number(parsed.port),
path: `${parsed.pathname}${parsed.search}`,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
...headers,
},
},
(res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
let json: unknown = null;
if (data.trim()) {
try {
json = JSON.parse(data);
} catch {
json = data;
}
}
resolve({ status: res.statusCode ?? 0, json });
});
},
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
export async function connectNode(
inst: GatewayInstance,
label: string,
): Promise<{ client: GatewayClient; nodeId: string }> {
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
const nodeId = deviceIdentity.deviceId;
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: label,
clientVersion: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
caps: ["system"],
commands: ["system.run"],
deviceIdentity,
timeoutMessage: `timeout waiting for ${label} to connect`,
});
return { client, nodeId };
}
async function connectStatusClient(
inst: GatewayInstance,
timeoutMs = GATEWAY_CONNECT_STATUS_TIMEOUT_MS,
): Promise<GatewayClient> {
let settled = false;
let timer: NodeJS.Timeout | null = null;
return await new Promise<GatewayClient>((resolve, reject) => {
const finish = (err?: Error) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
if (err) {
reject(err);
return;
}
resolve(client);
};
const client = new GatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
connectDelayMs: 0,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: `status-${inst.name}`,
clientVersion: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.CLI,
onHelloOk: () => {
finish();
},
onConnectError: (err) => finish(err),
onClose: (code, reason) => {
finish(new Error(`gateway closed (${code}): ${reason}`));
},
});
timer = setTimeout(() => {
finish(new Error("timeout waiting for node.list"));
}, timeoutMs);
client.start();
});
}
export async function waitForNodeStatus(
inst: GatewayInstance,
nodeId: string,
timeoutMs = GATEWAY_NODE_STATUS_TIMEOUT_MS,
) {
const deadline = Date.now() + timeoutMs;
const client = await connectStatusClient(
inst,
Math.min(GATEWAY_CONNECT_STATUS_TIMEOUT_MS, timeoutMs),
);
try {
while (Date.now() < deadline) {
const list = await client.request<NodeListPayload>("node.list", {});
const match = list.nodes?.find((n) => n.nodeId === nodeId);
if (match?.connected && match?.paired) {
return;
}
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
}
} finally {
client.stop();
}
throw new Error(`timeout waiting for node status for ${nodeId}`);
}
export function extractFirstTextBlock(message: unknown): string | undefined {
if (!message || typeof message !== "object") {
return undefined;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) {
return undefined;
}
const first = content[0];
if (!first || typeof first !== "object") {
return undefined;
}
const text = (first as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
}
export async function waitForChatFinalEvent(params: {
events: ChatEventPayload[];
runId: string;
sessionKey: string;
timeoutMs?: number;
}): Promise<ChatEventPayload> {
const deadline = Date.now() + (params.timeoutMs ?? 15_000);
while (Date.now() < deadline) {
const match = params.events.find(
(evt) =>
evt.runId === params.runId && evt.sessionKey === params.sessionKey && evt.state === "final",
);
if (match) {
return match;
}
await sleep(20);
}
throw new Error(`timeout waiting for final chat event (runId=${params.runId})`);
}

View File

@@ -0,0 +1,20 @@
import type { MsgContext } from "../../src/auto-reply/templating.js";
import { buildDispatchInboundCaptureMock } from "./dispatch-inbound-capture.js";
export type InboundContextCapture = {
ctx: MsgContext | undefined;
};
export function createInboundContextCapture(): InboundContextCapture {
return { ctx: undefined };
}
export async function buildDispatchInboundContextCapture(
importOriginal: <T extends Record<string, unknown>>() => Promise<T>,
capture: InboundContextCapture,
) {
const actual = await importOriginal<typeof import("../../src/auto-reply/dispatch.js")>();
return buildDispatchInboundCaptureMock(actual, (ctx) => {
capture.ctx = ctx as MsgContext;
});
}

View File

@@ -0,0 +1,9 @@
import { vi } from "vitest";
import { createInboundContextCapture } from "./inbound-contract-capture.js";
import { buildDispatchInboundContextCapture } from "./inbound-contract-capture.js";
export const inboundCtxCapture = createInboundContextCapture();
vi.mock("../../src/auto-reply/dispatch.js", async (importOriginal) => {
return await buildDispatchInboundContextCapture(importOriginal, inboundCtxCapture);
});

View File

@@ -0,0 +1,19 @@
import { expect } from "vitest";
import type { MsgContext } from "../../src/auto-reply/templating.js";
import { normalizeChatType } from "../../src/channels/chat-type.js";
import { resolveConversationLabel } from "../../src/channels/conversation-label.js";
import { validateSenderIdentity } from "../../src/channels/sender-identity.js";
export function expectInboundContextContract(ctx: MsgContext) {
expect(validateSenderIdentity(ctx)).toEqual([]);
expect(ctx.Body).toBeTypeOf("string");
expect(ctx.BodyForAgent).toBeTypeOf("string");
expect(ctx.BodyForCommands).toBeTypeOf("string");
const chatType = normalizeChatType(ctx.ChatType);
if (chatType && chatType !== "direct") {
const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx);
expect(label).toBeTruthy();
}
}

View File

@@ -0,0 +1,65 @@
import { vi } from "vitest";
export type SearchImpl = () => Promise<unknown[]>;
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
export type MemoryReadResult = { text: string; path: string };
type MemoryBackend = "builtin" | "qmd";
let backend: MemoryBackend = "builtin";
let searchImpl: SearchImpl = async () => [];
let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = async (params) => ({
text: "",
path: params.relPath,
});
const stubManager = {
search: vi.fn(async () => await searchImpl()),
readFile: vi.fn(async (params: MemoryReadParams) => await readFileImpl(params)),
status: () => ({
backend,
files: 1,
chunks: 1,
dirty: false,
workspaceDir: "/workspace",
dbPath: "/workspace/.memory/index.sqlite",
provider: "builtin",
model: "builtin",
requestedProvider: "builtin",
sources: ["memory" as const],
sourceCounts: [{ source: "memory" as const, files: 1, chunks: 1 }],
}),
sync: vi.fn(),
probeVectorAvailability: vi.fn(async () => true),
close: vi.fn(),
};
vi.mock("../../src/memory/index.js", () => ({
getMemorySearchManager: async () => ({ manager: stubManager }),
}));
export function setMemoryBackend(next: MemoryBackend): void {
backend = next;
}
export function setMemorySearchImpl(next: SearchImpl): void {
searchImpl = next;
}
export function setMemoryReadFileImpl(
next: (params: MemoryReadParams) => Promise<MemoryReadResult>,
): void {
readFileImpl = next;
}
export function resetMemoryToolMockState(overrides?: {
backend?: MemoryBackend;
searchImpl?: SearchImpl;
readFileImpl?: (params: MemoryReadParams) => Promise<MemoryReadResult>;
}): void {
backend = overrides?.backend ?? "builtin";
searchImpl = overrides?.searchImpl ?? (async () => []);
readFileImpl =
overrides?.readFileImpl ??
(async (params: MemoryReadParams) => ({ text: "", path: params.relPath }));
vi.clearAllMocks();
}

View File

@@ -0,0 +1,27 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage } from "node:http";
export function createMockIncomingRequest(chunks: string[]): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & {
destroyed?: boolean;
destroy: (error?: Error) => IncomingMessage;
};
req.destroyed = false;
req.headers = {};
req.destroy = () => {
req.destroyed = true;
return req;
};
void Promise.resolve().then(() => {
for (const chunk of chunks) {
req.emit("data", Buffer.from(chunk, "utf-8"));
if (req.destroyed) {
return;
}
}
req.emit("end");
});
return req;
}

View File

@@ -0,0 +1,9 @@
import { stripAnsi } from "../../src/terminal/ansi.js";
export function normalizeTestText(input: string): string {
return stripAnsi(input)
.replaceAll("\r\n", "\n")
.replaceAll("…", "...")
.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?")
.replace(/[\uD800-\uDFFF]/g, "?");
}

View File

@@ -0,0 +1,16 @@
import path from "node:path";
export function isPathWithinBase(base: string, target: string): boolean {
if (process.platform === "win32") {
const normalizedBase = path.win32.normalize(path.win32.resolve(base));
const normalizedTarget = path.win32.normalize(path.win32.resolve(target));
const rel = path.win32.relative(normalizedBase.toLowerCase(), normalizedTarget.toLowerCase());
return rel === "" || (!rel.startsWith("..") && !path.win32.isAbsolute(rel));
}
const normalizedBase = path.resolve(base);
const normalizedTarget = path.resolve(target);
const rel = path.relative(normalizedBase, normalizedTarget);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}

View File

@@ -0,0 +1,25 @@
import { sleep } from "../../src/utils.js";
export type PollOptions = {
timeoutMs?: number;
intervalMs?: number;
};
export async function pollUntil<T>(
fn: () => Promise<T | null | undefined>,
opts: PollOptions = {},
): Promise<T | undefined> {
const timeoutMs = opts.timeoutMs ?? 2000;
const intervalMs = opts.intervalMs ?? 25;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const value = await fn();
if (value !== null && value !== undefined) {
return value;
}
await sleep(intervalMs);
}
return undefined;
}

View File

@@ -0,0 +1,129 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
type EnvValue = string | undefined | ((home: string) => string | undefined);
type EnvSnapshot = {
home: string | undefined;
userProfile: string | undefined;
homeDrive: string | undefined;
homePath: string | undefined;
openclawHome: string | undefined;
stateDir: string | undefined;
};
function snapshotEnv(): EnvSnapshot {
return {
home: process.env.HOME,
userProfile: process.env.USERPROFILE,
homeDrive: process.env.HOMEDRIVE,
homePath: process.env.HOMEPATH,
openclawHome: process.env.OPENCLAW_HOME,
stateDir: process.env.OPENCLAW_STATE_DIR,
};
}
function restoreEnv(snapshot: EnvSnapshot) {
const restoreKey = (key: string, value: string | undefined) => {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreKey("HOME", snapshot.home);
restoreKey("USERPROFILE", snapshot.userProfile);
restoreKey("HOMEDRIVE", snapshot.homeDrive);
restoreKey("HOMEPATH", snapshot.homePath);
restoreKey("OPENCLAW_HOME", snapshot.openclawHome);
restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir);
}
function snapshotExtraEnv(keys: string[]): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of keys) {
snapshot[key] = process.env[key];
}
return snapshot;
}
function restoreExtraEnv(snapshot: Record<string, string | undefined>) {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
function setTempHome(base: string) {
process.env.HOME = base;
process.env.USERPROFILE = base;
// Ensure tests using HOME isolation aren't affected by leaked OPENCLAW_HOME.
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw");
if (process.platform !== "win32") {
return;
}
const match = base.match(/^([A-Za-z]:)(.*)$/);
if (!match) {
return;
}
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
export async function withTempHome<T>(
fn: (home: string) => Promise<T>,
opts: { env?: Record<string, EnvValue>; prefix?: string } = {},
): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), opts.prefix ?? "openclaw-test-home-"));
const snapshot = snapshotEnv();
const envKeys = Object.keys(opts.env ?? {});
for (const key of envKeys) {
if (key === "HOME" || key === "USERPROFILE" || key === "HOMEDRIVE" || key === "HOMEPATH") {
throw new Error(`withTempHome: use built-in home env (got ${key})`);
}
}
const envSnapshot = snapshotExtraEnv(envKeys);
setTempHome(base);
await fs.mkdir(path.join(base, ".openclaw", "agents", "main", "sessions"), { recursive: true });
if (opts.env) {
for (const [key, raw] of Object.entries(opts.env)) {
const value = typeof raw === "function" ? raw(base) : raw;
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
try {
return await fn(base);
} finally {
restoreExtraEnv(envSnapshot);
restoreEnv(snapshot);
try {
if (process.platform === "win32") {
await fs.rm(base, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 50,
});
} else {
await fs.rm(base, {
recursive: true,
force: true,
});
}
} catch {
// ignore cleanup failures in tests
}
}
}

View File

@@ -0,0 +1,17 @@
import { vi } from "vitest";
import type { WizardPrompter } from "../../src/wizard/prompts.js";
export function createWizardPrompter(overrides?: Partial<WizardPrompter>): WizardPrompter {
const select = vi.fn(async () => "quickstart") as unknown as WizardPrompter["select"];
return {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select,
multiselect: vi.fn(async () => []),
text: vi.fn(async () => ""),
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
};
}

View File

@@ -0,0 +1,76 @@
import { EventEmitter } from "node:events";
import { vi } from "vitest";
type BaileysExports = typeof import("@whiskeysockets/baileys");
type FetchLatestBaileysVersionFn = BaileysExports["fetchLatestBaileysVersion"];
type MakeCacheableSignalKeyStoreFn = BaileysExports["makeCacheableSignalKeyStore"];
type MakeWASocketFn = BaileysExports["makeWASocket"];
type UseMultiFileAuthStateFn = BaileysExports["useMultiFileAuthState"];
type DownloadMediaMessageFn = BaileysExports["downloadMediaMessage"];
export type MockBaileysSocket = {
ev: EventEmitter;
ws: { close: ReturnType<typeof vi.fn> };
sendPresenceUpdate: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
readMessages: ReturnType<typeof vi.fn>;
user?: { id?: string };
};
export type MockBaileysModule = {
DisconnectReason: { loggedOut: number };
fetchLatestBaileysVersion: ReturnType<typeof vi.fn<FetchLatestBaileysVersionFn>>;
makeCacheableSignalKeyStore: ReturnType<typeof vi.fn<MakeCacheableSignalKeyStoreFn>>;
makeWASocket: ReturnType<typeof vi.fn<MakeWASocketFn>>;
useMultiFileAuthState: ReturnType<typeof vi.fn<UseMultiFileAuthStateFn>>;
jidToE164?: (jid: string) => string | null;
proto?: unknown;
downloadMediaMessage?: ReturnType<typeof vi.fn<DownloadMediaMessageFn>>;
};
export function createMockBaileys(): {
mod: MockBaileysModule;
lastSocket: () => MockBaileysSocket;
} {
const sockets: MockBaileysSocket[] = [];
const makeWASocket = vi.fn<MakeWASocketFn>((_opts) => {
const ev = new EventEmitter();
const sock: MockBaileysSocket = {
ev,
ws: { close: vi.fn() },
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }),
readMessages: vi.fn().mockResolvedValue(undefined),
user: { id: "123@s.whatsapp.net" },
};
setImmediate(() => ev.emit("connection.update", { connection: "open" }));
sockets.push(sock);
return sock as unknown as ReturnType<MakeWASocketFn>;
});
const mod: MockBaileysModule = {
DisconnectReason: { loggedOut: 401 },
fetchLatestBaileysVersion: vi
.fn<FetchLatestBaileysVersionFn>()
.mockResolvedValue({ version: [1, 2, 3], isLatest: true }),
makeCacheableSignalKeyStore: vi.fn<MakeCacheableSignalKeyStoreFn>((keys) => keys),
makeWASocket,
useMultiFileAuthState: vi.fn<UseMultiFileAuthStateFn>(async () => ({
state: { creds: {}, keys: {} } as Awaited<ReturnType<UseMultiFileAuthStateFn>>["state"],
saveCreds: vi.fn(),
})),
jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"),
downloadMediaMessage: vi.fn<DownloadMediaMessageFn>().mockResolvedValue(Buffer.from("img")),
};
return {
mod,
lastSocket: () => {
const last = sockets.at(-1);
if (!last) {
throw new Error("No Baileys sockets created");
}
return last;
},
};
}

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
findChannelAgnosticBoundaryViolations,
findAcpUserFacingChannelNameViolations,
findChannelCoreReverseDependencyViolations,
findSystemMarkLiteralViolations,
} from "../../scripts/check-channel-agnostic-boundaries.mjs";
describe("check-channel-agnostic-boundaries", () => {
it("flags direct channel module imports", () => {
const source = `
import { getThreadBindingManager } from "../discord/monitor/thread-bindings.js";
const x = 1;
`;
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([
{
line: 2,
reason: 'imports channel module "../discord/monitor/thread-bindings.js"',
},
]);
});
it("flags channel config path access", () => {
const source = `
const x = cfg.channels.discord?.threadBindings?.enabled;
`;
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([
{
line: 2,
reason: 'references config path "channels.discord"',
},
]);
});
it("flags channel-literal comparisons", () => {
const source = `
if (channel === "discord") {
return true;
}
`;
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([
{
line: 2,
reason: 'compares with channel id literal (channel === "discord")',
},
]);
});
it("flags object literals with explicit channel ids", () => {
const source = `
const payload = { channel: "telegram" };
`;
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([
{
line: 2,
reason: 'assigns channel id literal to "channel" ("telegram")',
},
]);
});
it("ignores non-channel literals and unrelated text", () => {
const source = `
const msg = "discord";
const payload = { mode: "persistent" };
const x = cfg.session.threadBindings?.enabled;
`;
expect(findChannelAgnosticBoundaryViolations(source)).toEqual([]);
});
it("reverse-deps mode flags channel module re-exports", () => {
const source = `
export { resolveThreadBindingIntroText } from "../discord/monitor/thread-bindings.messages.js";
`;
expect(findChannelCoreReverseDependencyViolations(source)).toEqual([
{
line: 2,
reason: 're-exports channel module "../discord/monitor/thread-bindings.messages.js"',
},
]);
});
it("reverse-deps mode ignores channel literals when no imports are present", () => {
const source = `
const channel = "discord";
const x = cfg.channels.discord?.threadBindings?.enabled;
`;
expect(findChannelCoreReverseDependencyViolations(source)).toEqual([]);
});
it("user-facing text mode flags channel names in string literals", () => {
const source = `
const message = "Bind a Discord thread first.";
`;
expect(findAcpUserFacingChannelNameViolations(source)).toEqual([
{
line: 2,
reason: 'user-facing text references channel name ("Bind a Discord thread first.")',
},
]);
});
it("user-facing text mode ignores channel names in import specifiers", () => {
const source = `
import { x } from "../discord/monitor/thread-bindings.js";
`;
expect(findAcpUserFacingChannelNameViolations(source)).toEqual([]);
});
it("system-mark guard flags hardcoded gear literals", () => {
const source = `
const line = "⚙️ Thread bindings enabled.";
`;
expect(findSystemMarkLiteralViolations(source)).toEqual([
{
line: 2,
reason: 'hardcoded system mark literal ("⚙️ Thread bindings enabled.")',
},
]);
});
it("system-mark guard ignores module import specifiers", () => {
const source = `
import { x } from "../infra/system-message.js";
`;
expect(findSystemMarkLiteralViolations(source)).toEqual([]);
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { findMessagingTmpdirCallLines } from "../../scripts/check-no-random-messaging-tmp.mjs";
describe("check-no-random-messaging-tmp", () => {
it("finds os.tmpdir calls imported from node:os", () => {
const source = `
import os from "node:os";
const dir = os.tmpdir();
`;
expect(findMessagingTmpdirCallLines(source)).toEqual([3]);
});
it("finds tmpdir named import calls from node:os", () => {
const source = `
import { tmpdir } from "node:os";
const dir = tmpdir();
`;
expect(findMessagingTmpdirCallLines(source)).toEqual([3]);
});
it("finds tmpdir calls imported from os", () => {
const source = `
import os from "os";
const dir = os.tmpdir();
`;
expect(findMessagingTmpdirCallLines(source)).toEqual([3]);
});
it("ignores mentions in comments and strings", () => {
const source = `
// os.tmpdir()
const text = "tmpdir()";
`;
expect(findMessagingTmpdirCallLines(source)).toEqual([]);
});
it("ignores tmpdir symbols that are not imported from node:os", () => {
const source = `
const tmpdir = () => "/tmp";
const dir = tmpdir();
`;
expect(findMessagingTmpdirCallLines(source)).toEqual([]);
});
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { findRawWindowOpenLines } from "../../scripts/check-no-raw-window-open.mjs";
describe("check-no-raw-window-open", () => {
it("finds direct window.open calls", () => {
const source = `
function openDocs() {
window.open("https://docs.openclaw.ai");
}
`;
expect(findRawWindowOpenLines(source)).toEqual([3]);
});
it("finds globalThis.open calls", () => {
const source = `
function openDocs() {
globalThis.open("https://docs.openclaw.ai");
}
`;
expect(findRawWindowOpenLines(source)).toEqual([3]);
});
it("ignores mentions in strings and comments", () => {
const source = `
// window.open("https://example.com")
const text = "window.open('https://example.com')";
`;
expect(findRawWindowOpenLines(source)).toEqual([]);
});
it("handles parenthesized and asserted window references", () => {
const source = `
const openRef = (window as Window).open;
openRef("https://example.com");
(window as Window).open("https://example.com");
`;
expect(findRawWindowOpenLines(source)).toEqual([4]);
});
});

View File

@@ -0,0 +1,231 @@
import { execFileSync } from "node:child_process";
import { chmodSync } from "node:fs";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
const SCRIPT = path.join(process.cwd(), "scripts", "ios-team-id.sh");
async function writeExecutable(filePath: string, body: string): Promise<void> {
await writeFile(filePath, body, "utf8");
chmodSync(filePath, 0o755);
}
function runScript(
homeDir: string,
extraEnv: Record<string, string> = {},
): {
ok: boolean;
stdout: string;
stderr: string;
} {
const binDir = path.join(homeDir, "bin");
const env = {
...process.env,
HOME: homeDir,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
...extraEnv,
};
try {
const stdout = execFileSync("bash", [SCRIPT], {
env,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return { ok: true, stdout: stdout.trim(), stderr: "" };
} catch (error) {
const e = error as {
stdout?: string | Buffer;
stderr?: string | Buffer;
};
const stdout = typeof e.stdout === "string" ? e.stdout : (e.stdout?.toString("utf8") ?? "");
const stderr = typeof e.stderr === "string" ? e.stderr : (e.stderr?.toString("utf8") ?? "");
return { ok: false, stdout: stdout.trim(), stderr: stderr.trim() };
}
}
describe("scripts/ios-team-id.sh", () => {
it("falls back to Xcode-managed provisioning profiles when preference teams are empty", async () => {
const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-"));
const binDir = path.join(homeDir, "bin");
await mkdir(binDir, { recursive: true });
await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true });
await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), {
recursive: true,
});
await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), "");
await writeFile(
path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"),
"stub",
);
await writeExecutable(
path.join(binDir, "plutil"),
`#!/usr/bin/env bash
echo '{}'`,
);
await writeExecutable(
path.join(binDir, "defaults"),
`#!/usr/bin/env bash
if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then
echo '(identifier = "dev@example.com";)'
exit 0
fi
exit 0`,
);
await writeExecutable(
path.join(binDir, "security"),
`#!/usr/bin/env bash
if [[ "$1" == "cms" && "$2" == "-D" ]]; then
cat <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>TeamIdentifier</key>
<array>
<string>ABCDE12345</string>
</array>
</dict>
</plist>
PLIST
exit 0
fi
exit 0`,
);
const result = runScript(homeDir);
expect(result.ok).toBe(true);
expect(result.stdout).toBe("ABCDE12345");
});
it("prints actionable guidance when Xcode account exists but no Team ID is resolvable", async () => {
const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-"));
const binDir = path.join(homeDir, "bin");
await mkdir(binDir, { recursive: true });
await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true });
await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), "");
await writeExecutable(
path.join(binDir, "plutil"),
`#!/usr/bin/env bash
echo '{}'`,
);
await writeExecutable(
path.join(binDir, "defaults"),
`#!/usr/bin/env bash
if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then
echo '(identifier = "dev@example.com";)'
exit 0
fi
echo "Domain/default pair of (com.apple.dt.Xcode, $3) does not exist" >&2
exit 1`,
);
await writeExecutable(
path.join(binDir, "security"),
`#!/usr/bin/env bash
exit 1`,
);
const result = runScript(homeDir);
expect(result.ok).toBe(false);
expect(result.stderr).toContain("An Apple account is signed in to Xcode");
expect(result.stderr).toContain("IOS_DEVELOPMENT_TEAM");
});
it("honors IOS_PREFERRED_TEAM_ID when multiple profile teams are available", async () => {
const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-"));
const binDir = path.join(homeDir, "bin");
await mkdir(binDir, { recursive: true });
await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true });
await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), {
recursive: true,
});
await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), "");
await writeFile(
path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"),
"stub1",
);
await writeFile(
path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "two.mobileprovision"),
"stub2",
);
await writeExecutable(
path.join(binDir, "plutil"),
`#!/usr/bin/env bash
echo '{}'`,
);
await writeExecutable(
path.join(binDir, "defaults"),
`#!/usr/bin/env bash
if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then
echo '(identifier = "dev@example.com";)'
exit 0
fi
exit 0`,
);
await writeExecutable(
path.join(binDir, "security"),
`#!/usr/bin/env bash
if [[ "$1" == "cms" && "$2" == "-D" ]]; then
if [[ "$4" == *"one.mobileprovision" ]]; then
cat <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>TeamIdentifier</key><array><string>AAAAA11111</string></array></dict></plist>
PLIST
exit 0
fi
cat <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>TeamIdentifier</key><array><string>BBBBB22222</string></array></dict></plist>
PLIST
exit 0
fi
exit 0`,
);
const result = runScript(homeDir, { IOS_PREFERRED_TEAM_ID: "BBBBB22222" });
expect(result.ok).toBe(true);
expect(result.stdout).toBe("BBBBB22222");
});
it("matches preferred team IDs even when parser output uses CRLF line endings", async () => {
const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-"));
const binDir = path.join(homeDir, "bin");
await mkdir(binDir, { recursive: true });
await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true });
await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), "");
await writeExecutable(
path.join(binDir, "plutil"),
`#!/usr/bin/env bash
echo '{}'`,
);
await writeExecutable(
path.join(binDir, "defaults"),
`#!/usr/bin/env bash
if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then
echo '(identifier = "dev@example.com";)'
exit 0
fi
exit 0`,
);
await writeExecutable(
path.join(binDir, "fake-python"),
`#!/usr/bin/env bash
printf 'AAAAA11111\\t0\\tAlpha Team\\r\\n'
printf 'BBBBB22222\\t0\\tBeta Team\\r\\n'`,
);
const result = runScript(homeDir, {
IOS_PYTHON_BIN: path.join(binDir, "fake-python"),
IOS_PREFERRED_TEAM_ID: "BBBBB22222",
});
expect(result.ok).toBe(true);
expect(result.stdout).toBe("BBBBB22222");
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { assertSafeWindowsShellArgs, shouldUseShellForCommand } from "../../scripts/ui.js";
describe("scripts/ui windows spawn behavior", () => {
it("enables shell for Windows command launchers that require cmd.exe", () => {
expect(
shouldUseShellForCommand("C:\\Users\\dev\\AppData\\Local\\pnpm\\pnpm.CMD", "win32"),
).toBe(true);
expect(shouldUseShellForCommand("C:\\tools\\pnpm.bat", "win32")).toBe(true);
});
it("does not enable shell for non-shell launchers", () => {
expect(shouldUseShellForCommand("C:\\Program Files\\nodejs\\node.exe", "win32")).toBe(false);
expect(shouldUseShellForCommand("/usr/local/bin/pnpm", "linux")).toBe(false);
});
it("allows safe forwarded args when shell mode is required on Windows", () => {
expect(() =>
assertSafeWindowsShellArgs(["run", "build", "--filter", "@openclaw/ui"], "win32"),
).not.toThrow();
});
it("rejects dangerous forwarded args when shell mode is required on Windows", () => {
expect(() => assertSafeWindowsShellArgs(["run", "build", "evil&calc"], "win32")).toThrow(
/unsafe windows shell argument/i,
);
expect(() => assertSafeWindowsShellArgs(["run", "build", "%PATH%"], "win32")).toThrow(
/unsafe windows shell argument/i,
);
});
it("does not reject args on non-windows platforms", () => {
expect(() => assertSafeWindowsShellArgs(["contains&metacharacters"], "linux")).not.toThrow();
});
});

189
openclaw/test/setup.ts Normal file
View File

@@ -0,0 +1,189 @@
import { afterAll, afterEach, beforeEach, vi } from "vitest";
// Ensure Vitest environment is properly set
process.env.VITEST = "true";
// Config validation walks plugin manifests; keep an aggressive cache in tests to avoid
// repeated filesystem discovery across suites/workers.
process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ??= "60000";
// Vitest vm forks can load transitive lockfile helpers many times per worker.
// Raise listener budget to avoid noisy MaxListeners warnings and warning-stack overhead.
const TEST_PROCESS_MAX_LISTENERS = 128;
if (process.getMaxListeners() > 0 && process.getMaxListeners() < TEST_PROCESS_MAX_LISTENERS) {
process.setMaxListeners(TEST_PROCESS_MAX_LISTENERS);
}
import type {
ChannelId,
ChannelOutboundAdapter,
ChannelPlugin,
} from "../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../src/config/config.js";
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
import { withIsolatedTestHome } from "./test-env.js";
// Set HOME/state isolation before importing any runtime OpenClaw modules.
const testEnv = withIsolatedTestHome();
afterAll(() => testEnv.cleanup());
const [{ installProcessWarningFilter }, { setActivePluginRegistry }, { createTestRegistry }] =
await Promise.all([
import("../src/infra/warning-filter.js"),
import("../src/plugins/runtime.js"),
import("../src/test-utils/channel-plugins.js"),
]);
installProcessWarningFilter();
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
switch (id) {
case "discord":
return deps?.sendDiscord;
case "slack":
return deps?.sendSlack;
case "telegram":
return deps?.sendTelegram;
case "whatsapp":
return deps?.sendWhatsApp;
case "signal":
return deps?.sendSignal;
case "imessage":
return deps?.sendIMessage;
default:
return undefined;
}
};
const createStubOutbound = (
id: ChannelId,
deliveryMode: ChannelOutboundAdapter["deliveryMode"] = "direct",
): ChannelOutboundAdapter => ({
deliveryMode,
sendText: async ({ deps, to, text }) => {
const send = pickSendFn(id, deps);
if (send) {
// oxlint-disable-next-line typescript/no-explicit-any
const result = await send(to, text, { verbose: false } as any);
return { channel: id, ...result };
}
return { channel: id, messageId: "test" };
},
sendMedia: async ({ deps, to, text, mediaUrl }) => {
const send = pickSendFn(id, deps);
if (send) {
// oxlint-disable-next-line typescript/no-explicit-any
const result = await send(to, text, { verbose: false, mediaUrl } as any);
return { channel: id, ...result };
}
return { channel: id, messageId: "test" };
},
});
const createStubPlugin = (params: {
id: ChannelId;
label?: string;
aliases?: string[];
deliveryMode?: ChannelOutboundAdapter["deliveryMode"];
preferSessionLookupForAnnounceTarget?: boolean;
}): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label ?? String(params.id),
selectionLabel: params.label ?? String(params.id),
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
aliases: params.aliases,
preferSessionLookupForAnnounceTarget: params.preferSessionLookupForAnnounceTarget,
},
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: (cfg: OpenClawConfig) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[params.id];
if (!entry || typeof entry !== "object") {
return [];
}
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
const ids = accounts ? Object.keys(accounts).filter(Boolean) : [];
return ids.length > 0 ? ids : ["default"];
},
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[params.id];
if (!entry || typeof entry !== "object") {
return {};
}
const accounts = (entry as { accounts?: Record<string, unknown> }).accounts;
const match = accountId ? accounts?.[accountId] : undefined;
return (match && typeof match === "object") || typeof match === "string" ? match : entry;
},
isConfigured: async (_account, cfg: OpenClawConfig) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
return Boolean(channels?.[params.id]);
},
},
outbound: createStubOutbound(params.id, params.deliveryMode),
});
const createDefaultRegistry = () =>
createTestRegistry([
{
pluginId: "discord",
plugin: createStubPlugin({ id: "discord", label: "Discord" }),
source: "test",
},
{
pluginId: "slack",
plugin: createStubPlugin({ id: "slack", label: "Slack" }),
source: "test",
},
{
pluginId: "telegram",
plugin: {
...createStubPlugin({ id: "telegram", label: "Telegram" }),
status: {
buildChannelSummary: async () => ({
configured: false,
tokenSource: process.env.TELEGRAM_BOT_TOKEN ? "env" : "none",
}),
},
},
source: "test",
},
{
pluginId: "whatsapp",
plugin: createStubPlugin({
id: "whatsapp",
label: "WhatsApp",
deliveryMode: "gateway",
preferSessionLookupForAnnounceTarget: true,
}),
source: "test",
},
{
pluginId: "signal",
plugin: createStubPlugin({ id: "signal", label: "Signal" }),
source: "test",
},
{
pluginId: "imessage",
plugin: createStubPlugin({ id: "imessage", label: "iMessage", aliases: ["imsg"] }),
source: "test",
},
]);
// Creating a fresh registry before every single test was measurable overhead.
// The registry is treated as immutable by production code; tests that need a
// custom registry set it explicitly.
const DEFAULT_PLUGIN_REGISTRY = createDefaultRegistry();
beforeEach(() => {
setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY);
});
afterEach(() => {
// Guard against leaked fake timers across test files/workers.
if (vi.isFakeTimers()) {
vi.useRealTimers();
}
});

147
openclaw/test/test-env.ts Normal file
View File

@@ -0,0 +1,147 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
type RestoreEntry = { key: string; value: string | undefined };
function restoreEnv(entries: RestoreEntry[]): void {
for (const { key, value } of entries) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
function loadProfileEnv(): void {
const profilePath = path.join(os.homedir(), ".profile");
if (!fs.existsSync(profilePath)) {
return;
}
try {
const output = execFileSync(
"/bin/bash",
["-lc", `set -a; source "${profilePath}" >/dev/null 2>&1; env -0`],
{ encoding: "utf8" },
);
const entries = output.split("\0");
let applied = 0;
for (const entry of entries) {
if (!entry) {
continue;
}
const idx = entry.indexOf("=");
if (idx <= 0) {
continue;
}
const key = entry.slice(0, idx);
if (!key || (process.env[key] ?? "") !== "") {
continue;
}
process.env[key] = entry.slice(idx + 1);
applied += 1;
}
if (applied > 0) {
console.log(`[live] loaded ${applied} env vars from ~/.profile`);
}
} catch {
// ignore profile load failures
}
}
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[] = [
{ key: "OPENCLAW_TEST_FAST", value: process.env.OPENCLAW_TEST_FAST },
{ key: "HOME", value: process.env.HOME },
{ key: "USERPROFILE", value: process.env.USERPROFILE },
{ key: "XDG_CONFIG_HOME", value: process.env.XDG_CONFIG_HOME },
{ key: "XDG_DATA_HOME", value: process.env.XDG_DATA_HOME },
{ key: "XDG_STATE_HOME", value: process.env.XDG_STATE_HOME },
{ key: "XDG_CACHE_HOME", value: process.env.XDG_CACHE_HOME },
{ key: "OPENCLAW_STATE_DIR", value: process.env.OPENCLAW_STATE_DIR },
{ key: "OPENCLAW_CONFIG_PATH", value: process.env.OPENCLAW_CONFIG_PATH },
{ key: "OPENCLAW_GATEWAY_PORT", value: process.env.OPENCLAW_GATEWAY_PORT },
{ key: "OPENCLAW_BRIDGE_ENABLED", value: process.env.OPENCLAW_BRIDGE_ENABLED },
{ key: "OPENCLAW_BRIDGE_HOST", value: process.env.OPENCLAW_BRIDGE_HOST },
{ 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: "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 },
{ key: "SLACK_APP_TOKEN", value: process.env.SLACK_APP_TOKEN },
{ key: "SLACK_USER_TOKEN", value: process.env.SLACK_USER_TOKEN },
{ key: "COPILOT_GITHUB_TOKEN", value: process.env.COPILOT_GITHUB_TOKEN },
{ key: "GH_TOKEN", value: process.env.GH_TOKEN },
{ key: "GITHUB_TOKEN", value: process.env.GITHUB_TOKEN },
{ key: "NODE_OPTIONS", value: process.env.NODE_OPTIONS },
];
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-home-"));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.OPENCLAW_TEST_HOME = tempHome;
process.env.OPENCLAW_TEST_FAST = "1";
// Ensure test runs never touch the developer's real config/state, even if they have overrides set.
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;
// 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;
delete process.env.OPENCLAW_BRIDGE_HOST;
delete process.env.OPENCLAW_BRIDGE_PORT;
delete process.env.OPENCLAW_CANVAS_HOST_PORT;
// Avoid leaking real GitHub/Copilot tokens into non-live test runs.
delete process.env.TELEGRAM_BOT_TOKEN;
delete process.env.DISCORD_BOT_TOKEN;
delete process.env.SLACK_BOT_TOKEN;
delete process.env.SLACK_APP_TOKEN;
delete process.env.SLACK_USER_TOKEN;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
// Avoid leaking local dev tooling flags into tests (e.g. --inspect).
delete process.env.NODE_OPTIONS;
// Windows: prefer the default state dir so auth/profile tests match real paths.
if (process.platform === "win32") {
process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw");
}
process.env.XDG_CONFIG_HOME = path.join(tempHome, ".config");
process.env.XDG_DATA_HOME = path.join(tempHome, ".local", "share");
process.env.XDG_STATE_HOME = path.join(tempHome, ".local", "state");
process.env.XDG_CACHE_HOME = path.join(tempHome, ".cache");
const cleanup = () => {
restoreEnv(restore);
try {
fs.rmSync(tempHome, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
};
return { cleanup, tempHome };
}
export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } {
return installTestEnv();
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { formatNextRun } from "../ui/src/ui/presenter.ts";
describe("formatNextRun", () => {
it("returns n/a for nullish values", () => {
expect(formatNextRun(null)).toBe("n/a");
expect(formatNextRun(undefined)).toBe("n/a");
});
it("includes weekday and relative time", () => {
const ts = Date.UTC(2026, 1, 23, 15, 0, 0);
const out = formatNextRun(ts);
expect(out).toMatch(/^[A-Za-z]{3}, /);
expect(out).toContain("(");
expect(out).toContain(")");
});
});