Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
openclaw/test/appcast.test.ts
Normal file
27
openclaw/test/appcast.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
11
openclaw/test/fixtures/child-process-bridge/child.js
vendored
Normal file
11
openclaw/test/fixtures/child-process-bridge/child.js
vendored
Normal 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);
|
||||
82
openclaw/test/fixtures/exec-allowlist-shell-parser-parity.json
vendored
Normal file
82
openclaw/test/fixtures/exec-allowlist-shell-parser-parity.json
vendored
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
39
openclaw/test/fixtures/exec-wrapper-resolution-parity.json
vendored
Normal file
39
openclaw/test/fixtures/exec-wrapper-resolution-parity.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
openclaw/test/fixtures/hooks-install/npm-pack-hooks.tgz
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/npm-pack-hooks.tgz
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/tar-evil-id.tar
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/tar-evil-id.tar
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/tar-hooks.tar
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/tar-hooks.tar
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/tar-reserved-id.tar
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/tar-reserved-id.tar
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/tar-traversal.tar
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/tar-traversal.tar
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/zip-hooks.zip
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/zip-hooks.zip
vendored
Normal file
Binary file not shown.
BIN
openclaw/test/fixtures/hooks-install/zip-traversal.zip
vendored
Normal file
BIN
openclaw/test/fixtures/hooks-install/zip-traversal.zip
vendored
Normal file
Binary file not shown.
115
openclaw/test/fixtures/system-run-approval-binding-contract.json
vendored
Normal file
115
openclaw/test/fixtures/system-run-approval-binding-contract.json
vendored
Normal 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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
67
openclaw/test/fixtures/system-run-approval-mismatch-contract.json
vendored
Normal file
67
openclaw/test/fixtures/system-run-approval-mismatch-contract.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
75
openclaw/test/fixtures/system-run-command-contract.json
vendored
Normal file
75
openclaw/test/fixtures/system-run-command-contract.json
vendored
Normal 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\""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
125
openclaw/test/gateway.multi.e2e.test.ts
Normal file
125
openclaw/test/gateway.multi.e2e.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
48
openclaw/test/git-hooks-pre-commit.test.ts
Normal file
48
openclaw/test/git-hooks-pre-commit.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
6
openclaw/test/global-setup.ts
Normal file
6
openclaw/test/global-setup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { installTestEnv } from "./test-env";
|
||||
|
||||
export default async () => {
|
||||
const { cleanup } = installTestEnv();
|
||||
return () => cleanup();
|
||||
};
|
||||
18
openclaw/test/helpers/dispatch-inbound-capture.ts
Normal file
18
openclaw/test/helpers/dispatch-inbound-capture.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
43
openclaw/test/helpers/envelope-timestamp.ts
Normal file
43
openclaw/test/helpers/envelope-timestamp.ts
Normal 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");
|
||||
}
|
||||
17
openclaw/test/helpers/fast-short-timeouts.ts
Normal file
17
openclaw/test/helpers/fast-short-timeouts.ts
Normal 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();
|
||||
}
|
||||
395
openclaw/test/helpers/gateway-e2e-harness.ts
Normal file
395
openclaw/test/helpers/gateway-e2e-harness.ts
Normal 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})`);
|
||||
}
|
||||
20
openclaw/test/helpers/inbound-contract-capture.ts
Normal file
20
openclaw/test/helpers/inbound-contract-capture.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
9
openclaw/test/helpers/inbound-contract-dispatch-mock.ts
Normal file
9
openclaw/test/helpers/inbound-contract-dispatch-mock.ts
Normal 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);
|
||||
});
|
||||
19
openclaw/test/helpers/inbound-contract.ts
Normal file
19
openclaw/test/helpers/inbound-contract.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
65
openclaw/test/helpers/memory-tool-manager-mock.ts
Normal file
65
openclaw/test/helpers/memory-tool-manager-mock.ts
Normal 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();
|
||||
}
|
||||
27
openclaw/test/helpers/mock-incoming-request.ts
Normal file
27
openclaw/test/helpers/mock-incoming-request.ts
Normal 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;
|
||||
}
|
||||
9
openclaw/test/helpers/normalize-text.ts
Normal file
9
openclaw/test/helpers/normalize-text.ts
Normal 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, "?");
|
||||
}
|
||||
16
openclaw/test/helpers/paths.ts
Normal file
16
openclaw/test/helpers/paths.ts
Normal 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));
|
||||
}
|
||||
25
openclaw/test/helpers/poll.ts
Normal file
25
openclaw/test/helpers/poll.ts
Normal 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;
|
||||
}
|
||||
129
openclaw/test/helpers/temp-home.ts
Normal file
129
openclaw/test/helpers/temp-home.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
17
openclaw/test/helpers/wizard-prompter.ts
Normal file
17
openclaw/test/helpers/wizard-prompter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
76
openclaw/test/mocks/baileys.ts
Normal file
76
openclaw/test/mocks/baileys.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
127
openclaw/test/scripts/check-channel-agnostic-boundaries.test.ts
Normal file
127
openclaw/test/scripts/check-channel-agnostic-boundaries.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
44
openclaw/test/scripts/check-no-random-messaging-tmp.test.ts
Normal file
44
openclaw/test/scripts/check-no-random-messaging-tmp.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
39
openclaw/test/scripts/check-no-raw-window-open.test.ts
Normal file
39
openclaw/test/scripts/check-no-raw-window-open.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
231
openclaw/test/scripts/ios-team-id.test.ts
Normal file
231
openclaw/test/scripts/ios-team-id.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
35
openclaw/test/scripts/ui.test.ts
Normal file
35
openclaw/test/scripts/ui.test.ts
Normal 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
189
openclaw/test/setup.ts
Normal 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
147
openclaw/test/test-env.ts
Normal 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();
|
||||
}
|
||||
17
openclaw/test/ui.presenter-next-run.test.ts
Normal file
17
openclaw/test/ui.presenter-next-run.test.ts
Normal 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(")");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user