mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* test(agents): deslop s035 tests * test(scripts): deslop s030 tests * test(browser): deslop s039 tests * test(gateway): deslop s034 tests * test(copilot): deslop s040 tests * test(auto-reply): deslop s042 tests * test(agents): deslop s041 tests * test(scripts): deslop s022 tests * test(sessions): deslop s036 tests * test(cli): deslop s037 tests * test: adapt d011 fixtures to current owners * test: preserve current native i18n guidance * test: preserve d011 authorization coverage and diagnostic assertions * test: retain fixture imports used by newer main tests * test: fix lint in d011 test reductions * test(copilot): remove obsolete max-lines suppression
1431 lines
51 KiB
TypeScript
1431 lines
51 KiB
TypeScript
// Dev Tooling Safety tests cover dev tooling safety script behavior.
|
|
import { spawn } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import http from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { testing as promptProbeTesting } from "../../scripts/anthropic-prompt-probe.ts";
|
|
import { testing as claudeUsageTesting } from "../../scripts/debug-claude-usage.ts";
|
|
import { testing as discordSmokeTesting } from "../../scripts/dev/discord-acp-plain-language-smoke.ts";
|
|
import { testing as realtimeSmokeTesting } from "../../scripts/dev/realtime-talk-live-smoke.ts";
|
|
import { testing as tuiPtyWatchTesting } from "../../scripts/dev/tui-pty-test-watch.ts";
|
|
import {
|
|
maskIdentifier,
|
|
parseBooleanEnv,
|
|
parseStrictIntegerOption,
|
|
previewForDevToolLog,
|
|
redactHomePath,
|
|
redactJsonValueForDevToolLog,
|
|
} from "../../scripts/lib/dev-tooling-safety.ts";
|
|
import { createVitestResourceOwner } from "../../scripts/lib/vitest-resource-ownership.mts";
|
|
import { scriptProcessEntrypoints } from "../../scripts/script-process-runtime.test-support.js";
|
|
import {
|
|
resolveRuntimeWorkerArgv,
|
|
resolveRuntimeWorkerUrl,
|
|
} from "../../src/infra/runtime-worker-url.js";
|
|
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
|
|
import { killPidIfAlive } from "../../src/test-utils/process-tree.js";
|
|
import { isProcessAlive, waitForChildClose, waitForDead } from "../helpers/process-wait.js";
|
|
import { runQaGatewayFixture } from "../helpers/qa-gateway-cleanup.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
const testNodeExecPath = resolveTestNodeExecPath();
|
|
const promptProbeUrl = resolveRuntimeWorkerUrl(scriptProcessEntrypoints.anthropicPromptProbe);
|
|
|
|
async function waitForCondition(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
if (predicate()) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 10);
|
|
});
|
|
}
|
|
throw new Error("timed out waiting for condition");
|
|
}
|
|
|
|
// writeFileSync is not atomic for concurrent readers: the pid file can exist
|
|
// before its payload is flushed, so wait for non-empty content or the parse
|
|
// races into NaN under parallel-suite load.
|
|
async function waitForPidFile(pidPath: string, timeoutMs = 15_000): Promise<number> {
|
|
let content = "";
|
|
await waitForCondition(() => {
|
|
try {
|
|
content = readFileSync(pidPath, "utf8").trim();
|
|
} catch {
|
|
return false;
|
|
}
|
|
return content.length > 0;
|
|
}, timeoutMs);
|
|
return Number.parseInt(content, 10);
|
|
}
|
|
|
|
function quotePosixShellArg(value: string): string {
|
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
}
|
|
|
|
async function writeFakePromptCli(
|
|
root: string,
|
|
descendantPidPath: string,
|
|
mode: "blocking" | "leader-exit" | "escaped-output" = "blocking",
|
|
): Promise<string> {
|
|
const descendantPath = path.join(root, "fake-prompt-descendant.mjs");
|
|
await fs.writeFile(
|
|
descendantPath,
|
|
[
|
|
'import fs from "node:fs";',
|
|
'process.on("SIGINT", () => {});',
|
|
'process.on("SIGTERM", () => {});',
|
|
"fs.writeFileSync(process.argv[2], String(process.pid));",
|
|
"setInterval(() => {}, 1000);",
|
|
...(mode === "escaped-output" ? ['fs.writeFileSync(process.argv[3], "ready");'] : []),
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
const fakeCli = path.join(root, "fake-prompt-cli.sh");
|
|
if (mode === "escaped-output") {
|
|
const leaderPath = path.join(root, "fake-prompt-leader.mjs");
|
|
await fs.writeFile(
|
|
leaderPath,
|
|
[
|
|
'import { spawn } from "node:child_process";',
|
|
'import fs from "node:fs";',
|
|
`const child = spawn(${JSON.stringify(testNodeExecPath)}, ${JSON.stringify([
|
|
descendantPath,
|
|
descendantPidPath,
|
|
path.join(root, "escaped.ready"),
|
|
])}, { detached: true, stdio: ["ignore", "inherit", "inherit"] });`,
|
|
`fs.appendFileSync(${JSON.stringify(path.join(root, "launches.jsonl"))}, JSON.stringify({ pid: child.pid, args: process.argv.slice(2) }) + "\\n");`,
|
|
`fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(child.pid));`,
|
|
"child.unref();",
|
|
"setInterval(() => {}, 1000);",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
await fs.writeFile(
|
|
fakeCli,
|
|
[
|
|
"#!/bin/sh",
|
|
`exec ${quotePosixShellArg(testNodeExecPath)} ${quotePosixShellArg(leaderPath)} "$@"`,
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
return fakeCli;
|
|
}
|
|
await fs.writeFile(
|
|
fakeCli,
|
|
[
|
|
"#!/bin/sh",
|
|
`${quotePosixShellArg(testNodeExecPath)} ${quotePosixShellArg(descendantPath)} ${quotePosixShellArg(descendantPidPath)} >/dev/null 2>&1 &`,
|
|
`while [ ! -s ${quotePosixShellArg(descendantPidPath)} ]; do sleep 0.01; done`,
|
|
mode === "leader-exit" ? "exit 0" : "while :; do sleep 1; done",
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
return fakeCli;
|
|
}
|
|
|
|
async function writeBlockingPromptCli(root: string): Promise<string> {
|
|
const fakeCli = path.join(root, "blocking-prompt-cli.sh");
|
|
await fs.writeFile(fakeCli, ["#!/bin/sh", "while :; do sleep 1; done", ""].join("\n"), {
|
|
mode: 0o755,
|
|
});
|
|
return fakeCli;
|
|
}
|
|
|
|
async function waitForChildExit(
|
|
child: ReturnType<typeof spawn>,
|
|
timeoutMs = 8_000,
|
|
): Promise<{ status: number | null; signal: NodeJS.Signals | null }> {
|
|
return await Promise.race([
|
|
new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("exit", (status, signal) => resolve({ status, signal }));
|
|
}),
|
|
new Promise<never>((_, reject) => {
|
|
const timer = setTimeout(
|
|
() => reject(new Error("timed out waiting for child exit")),
|
|
timeoutMs,
|
|
);
|
|
timer.unref();
|
|
}),
|
|
]);
|
|
}
|
|
|
|
type CliResult = {
|
|
status: number | null;
|
|
stderr: string;
|
|
stdout: string;
|
|
};
|
|
|
|
function runCli(scriptPath: string, args: string[]): Promise<CliResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(testNodeExecPath, ["--import", "tsx", scriptPath, ...args], {
|
|
cwd: process.cwd(),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += String(chunk);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += String(chunk);
|
|
});
|
|
child.once("error", reject);
|
|
child.once("close", (status) => resolve({ status, stderr, stdout }));
|
|
});
|
|
}
|
|
|
|
function createCliPair(
|
|
scriptPath: string,
|
|
firstArgs: string[],
|
|
secondArgs: string[],
|
|
): () => Promise<[CliResult, CliResult]> {
|
|
let result: Promise<[CliResult, CliResult]> | undefined;
|
|
return () => {
|
|
// Pair only sibling entrypoint probes: process proof stays real while peak
|
|
// child concurrency stays capped at two.
|
|
result ??= Promise.all([runCli(scriptPath, firstArgs), runCli(scriptPath, secondArgs)]);
|
|
return result;
|
|
};
|
|
}
|
|
|
|
const getDiscordCliResults = createCliPair(
|
|
"scripts/dev/discord-acp-plain-language-smoke.ts",
|
|
["--wat"],
|
|
["--help"],
|
|
);
|
|
const getTuiPtyCliResults = createCliPair(
|
|
"scripts/dev/tui-pty-test-watch.ts",
|
|
["--help"],
|
|
["--wat"],
|
|
);
|
|
const getClaudeUsageCliResults = createCliPair(
|
|
"scripts/debug-claude-usage.ts",
|
|
["--help"],
|
|
["--agent"],
|
|
);
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
for (const dir of tempDirs.splice(0)) {
|
|
await fs.rm(dir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
describe("dev tooling safety helpers", () => {
|
|
it("redacts secrets before truncating script log previews", () => {
|
|
const token = "sk-test1234567890abcdefghijklmnop"; // pragma: allowlist secret
|
|
const preview = previewForDevToolLog(`prefix OPENAI_API_KEY=${token} suffix`, 80);
|
|
|
|
expect(preview).not.toContain(token);
|
|
expect(preview).toContain("OPENAI_API_KEY=");
|
|
});
|
|
|
|
it("recursively redacts JSON-ish detail values before printing smoke results", () => {
|
|
const token = "sk-test1234567890abcdefghijklmnop"; // pragma: allowlist secret
|
|
const redacted = redactJsonValueForDevToolLog({
|
|
nested: [{ message: `Authorization: Bearer ${token}` }],
|
|
}) as { nested: Array<{ message: string }> };
|
|
|
|
expect(redacted.nested[0]?.message).not.toContain(token);
|
|
expect(redacted.nested[0]?.message).toContain("Authorization");
|
|
});
|
|
|
|
it("parses boolean env values explicitly", () => {
|
|
expect(parseBooleanEnv({ fallback: false, name: "FLAG", raw: "yes" })).toBe(true);
|
|
expect(parseBooleanEnv({ fallback: true, name: "FLAG", raw: "0" })).toBe(false);
|
|
expect(() => parseBooleanEnv({ fallback: false, name: "FLAG", raw: "maybe" })).toThrow(
|
|
/FLAG must be one of/u,
|
|
);
|
|
});
|
|
|
|
it("rejects partial numeric option parses", () => {
|
|
expect(parseStrictIntegerOption({ fallback: 3, label: "--runs", min: 1, raw: undefined })).toBe(
|
|
3,
|
|
);
|
|
expect(() =>
|
|
parseStrictIntegerOption({ fallback: 3, label: "--runs", min: 1, raw: "2abc" }),
|
|
).toThrow(/--runs must be an integer/u);
|
|
});
|
|
|
|
it("redacts home paths and masks opaque ids", () => {
|
|
expect(redactHomePath("/home/alice/.openclaw/state.json", "/home/alice")).toBe(
|
|
"~/.openclaw/state.json",
|
|
);
|
|
expect(maskIdentifier("session-key-abcdef123456")).toBe("sessio...3456");
|
|
});
|
|
|
|
it("does not split boundary emoji in script log previews", () => {
|
|
expect(previewForDevToolLog("123456😀tail", 10)).toBe("123456...");
|
|
});
|
|
});
|
|
|
|
describe("script-specific dev tooling hardening", () => {
|
|
it("rejects unknown Discord smoke drivers instead of silently using token mode", () => {
|
|
expect(discordSmokeTesting.parseDriverMode("webhook")).toBe("webhook");
|
|
expect(() => discordSmokeTesting.parseDriverMode("curl")).toThrow(/Invalid --driver/u);
|
|
});
|
|
|
|
it("rejects unknown Discord smoke args before live Discord/OpenClaw work", async () => {
|
|
expect(() => discordSmokeTesting.parseArgs(["--wat"])).toThrow("Unknown argument: --wat");
|
|
const [result] = await getDiscordCliResults();
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr.trim()).toBe("Unknown argument: --wat");
|
|
});
|
|
|
|
it("prints Discord smoke usage without starting live validation", async () => {
|
|
const [, result] = await getDiscordCliResults();
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("Usage: bun scripts/dev/discord-acp-plain-language-smoke.ts");
|
|
expect(result.stderr).toBe("");
|
|
});
|
|
|
|
it("rejects missing Discord smoke option values before env fallbacks", () => {
|
|
expect(() => discordSmokeTesting.parseArgs(["--channel"])).toThrow(
|
|
"--channel requires a value",
|
|
);
|
|
expect(() => discordSmokeTesting.parseArgs(["--channel="])).toThrow(
|
|
"--channel requires a value",
|
|
);
|
|
expect(() => discordSmokeTesting.parseArgs(["--channel", "--json"])).toThrow(
|
|
"--channel requires a value",
|
|
);
|
|
for (const flag of ["--channel", "--token", "--timeout-ms", "--state-dir"]) {
|
|
expect(() => discordSmokeTesting.parseArgs([flag, "-h"])).toThrow(`${flag} requires a value`);
|
|
}
|
|
});
|
|
|
|
it("redacts Discord webhook tokens from API paths", () => {
|
|
const token = "webhook-secret-token-abcdef123456"; // pragma: allowlist secret
|
|
const apiPath = `/webhooks/123/${token}?wait=true`;
|
|
|
|
expect(discordSmokeTesting.redactDiscordApiPath(apiPath)).not.toContain(token);
|
|
expect(discordSmokeTesting.redactDiscordApiPath(apiPath)).toContain("/webhooks/123/");
|
|
});
|
|
|
|
it("computes the remaining Discord smoke timeout budget", () => {
|
|
expect(discordSmokeTesting.remainingTimeoutMs(1_500, undefined, 1_000)).toBe(500);
|
|
expect(() => discordSmokeTesting.remainingTimeoutMs(1_000, undefined, 1_000)).toThrow(
|
|
/exceeded total timeout/u,
|
|
);
|
|
expect(() =>
|
|
discordSmokeTesting.remainingTimeoutMs(
|
|
1_000,
|
|
() => new Error("request-specific timeout"),
|
|
1_000,
|
|
),
|
|
).toThrow("request-specific timeout");
|
|
});
|
|
|
|
it("aborts stalled Discord smoke fetches at the request timeout", async () => {
|
|
let signal: AbortSignal | undefined;
|
|
const request = discordSmokeTesting.requestDiscordJson({
|
|
method: "GET",
|
|
path: "/users/@me",
|
|
headers: {},
|
|
retries: 0,
|
|
timeoutMs: 5,
|
|
errorPrefix: "Discord API",
|
|
fetchImpl: ((_url, init) => {
|
|
signal = init?.signal ?? undefined;
|
|
return new Promise(() => {});
|
|
}) as typeof fetch,
|
|
});
|
|
|
|
await expect(request).rejects.toThrow(/Discord API GET \/users\/@me exceeded timeout/u);
|
|
expect(signal?.aborted).toBe(true);
|
|
});
|
|
|
|
it("times out stalled Discord smoke response body reads", async () => {
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
|
const response = new Response(
|
|
new ReadableStream({
|
|
start() {},
|
|
}),
|
|
{ status: 200, statusText: "OK" },
|
|
);
|
|
const request = discordSmokeTesting.requestDiscordJson({
|
|
method: "GET",
|
|
path: "/channels/123/messages",
|
|
headers: {},
|
|
retries: 0,
|
|
timeoutMs: 5,
|
|
errorPrefix: "Discord API",
|
|
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
|
|
});
|
|
|
|
const rejection = expect(request).rejects.toThrow(
|
|
/Discord API GET \/channels\/123\/messages exceeded timeout/u,
|
|
);
|
|
await vi.advanceTimersByTimeAsync(5);
|
|
await rejection;
|
|
});
|
|
|
|
it("bounds Discord smoke response bodies by content-length", async () => {
|
|
const response = new Response("{}", {
|
|
headers: { "content-length": "6" },
|
|
});
|
|
const request = discordSmokeTesting.requestDiscordJson({
|
|
method: "GET",
|
|
path: "/channels/123/messages",
|
|
headers: {},
|
|
retries: 0,
|
|
timeoutMs: 50,
|
|
responseBodyMaxBytes: 5,
|
|
errorPrefix: "Discord API",
|
|
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
|
|
});
|
|
|
|
await expect(request).rejects.toThrow(
|
|
"Discord API GET /channels/123/messages response body exceeded 5 bytes",
|
|
);
|
|
});
|
|
|
|
it("bounds Discord smoke response bodies by streamed bytes", async () => {
|
|
const response = new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(6));
|
|
controller.close();
|
|
},
|
|
}),
|
|
);
|
|
const request = discordSmokeTesting.requestDiscordJson({
|
|
method: "GET",
|
|
path: "/channels/123/messages",
|
|
headers: {},
|
|
retries: 0,
|
|
timeoutMs: 50,
|
|
responseBodyMaxBytes: 5,
|
|
errorPrefix: "Discord API",
|
|
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
|
|
});
|
|
|
|
await expect(request).rejects.toThrow(
|
|
"Discord API GET /channels/123/messages response body exceeded 5 bytes",
|
|
);
|
|
});
|
|
|
|
it("does not launch another Discord smoke retry after the timeout budget expires", async () => {
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
|
let calls = 0;
|
|
const response = {
|
|
ok: false,
|
|
status: 429,
|
|
statusText: "Too Many Requests",
|
|
json: async () => ({ retry_after: 1 }),
|
|
} as Response;
|
|
|
|
await expect(
|
|
discordSmokeTesting.requestDiscordJson({
|
|
method: "GET",
|
|
path: "/channels/123/messages",
|
|
headers: {},
|
|
retries: 1,
|
|
timeoutMs: 5,
|
|
errorPrefix: "Discord API",
|
|
fetchImpl: (() => {
|
|
calls += 1;
|
|
return Promise.resolve(response);
|
|
}) as typeof fetch,
|
|
}),
|
|
).rejects.toThrow(/exceeded total timeout/u);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("prints TUI PTY watch usage without launching the watcher", async () => {
|
|
const [result] = await getTuiPtyCliResults();
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("Usage: node --import tsx scripts/dev/tui-pty-test-watch.ts");
|
|
expect(result.stderr).toBe("");
|
|
});
|
|
|
|
it("rejects unknown TUI PTY watch args before launching the watcher", async () => {
|
|
expect(() => tuiPtyWatchTesting.parseOptions(["--wat"])).toThrow("Unknown argument: --wat");
|
|
const [, result] = await getTuiPtyCliResults();
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr.trim()).toBe("Unknown argument: --wat");
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
|
|
it("rejects short flags as TUI PTY watch option values", () => {
|
|
for (const flag of ["--mode", "--mirror-path"]) {
|
|
expect(() => tuiPtyWatchTesting.parseOptions([flag, "-h"])).toThrow(
|
|
`${flag} requires a value`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("keeps TUI PTY watch vitest args behind the separator", () => {
|
|
expect(tuiPtyWatchTesting.parseOptions(["--mode", "all", "--", "--help"])).toMatchObject({
|
|
mode: "all",
|
|
vitestArgs: ["--help"],
|
|
});
|
|
});
|
|
|
|
it("escalates stalled TUI PTY watch children after interrupt cleanup", async () => {
|
|
vi.useFakeTimers();
|
|
const signals: NodeJS.Signals[] = [];
|
|
const stopper = tuiPtyWatchTesting.createChildStopper(
|
|
{ kill: () => true },
|
|
(_child, signal: NodeJS.Signals): void => {
|
|
signals.push(signal);
|
|
},
|
|
);
|
|
|
|
stopper.stop();
|
|
expect(signals).toEqual(["SIGINT"]);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(signals).toEqual(["SIGINT", "SIGTERM"]);
|
|
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
expect(signals).toEqual(["SIGINT", "SIGTERM", "SIGKILL"]);
|
|
});
|
|
|
|
it("restarts TUI PTY mirror reads when the mirror file is truncated", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-watch-test-"));
|
|
tempDirs.push(tempRoot);
|
|
const mirrorPath = path.join(tempRoot, "mirror.ansi");
|
|
await fs.writeFile(mirrorPath, "fresh", "utf8");
|
|
|
|
const result = await tuiPtyWatchTesting.readNewMirrorData(mirrorPath, 10, 1024);
|
|
|
|
expect(result.chunk.toString("utf8")).toBe("fresh");
|
|
expect(result.offset).toBe(5);
|
|
});
|
|
|
|
it("drains all pending TUI PTY mirror chunks after the child exits", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-watch-test-"));
|
|
tempDirs.push(tempRoot);
|
|
const mirrorPath = path.join(tempRoot, "mirror.ansi");
|
|
await fs.writeFile(mirrorPath, "first-second-third", "utf8");
|
|
const chunks: string[] = [];
|
|
|
|
const offset = await tuiPtyWatchTesting.drainNewMirrorData(
|
|
mirrorPath,
|
|
0,
|
|
(chunk: Buffer) => chunks.push(chunk.toString("utf8")),
|
|
6,
|
|
);
|
|
|
|
expect(chunks).toEqual(["first-", "second", "-third"]);
|
|
expect(offset).toBe("first-second-third".length);
|
|
});
|
|
|
|
it("keeps only diagnostic tails from noisy TUI PTY child output", () => {
|
|
const retained = tuiPtyWatchTesting.appendBufferTail(
|
|
Buffer.from("0123456789", "utf8"),
|
|
Buffer.from("abcdef", "utf8"),
|
|
8,
|
|
);
|
|
|
|
expect(retained.toString("utf8")).toBe("89abcdef");
|
|
});
|
|
|
|
it("aborts stalled OpenAI realtime smoke fetches at the request timeout", async () => {
|
|
let signal: AbortSignal | undefined;
|
|
const request = realtimeSmokeTesting.createOpenAIClientSecret("test-key", {
|
|
timeoutMs: 5,
|
|
fetchImpl: ((_url, init) => {
|
|
signal = init?.signal ?? undefined;
|
|
return new Promise(() => {});
|
|
}) as typeof fetch,
|
|
});
|
|
|
|
await expect(request).rejects.toThrow(
|
|
/OpenAI Realtime client secret request exceeded timeout/u,
|
|
);
|
|
expect(signal?.aborted).toBe(true);
|
|
});
|
|
|
|
it("times out stalled OpenAI realtime smoke response body reads", async () => {
|
|
const response = new Response(
|
|
new ReadableStream({
|
|
start() {},
|
|
}),
|
|
);
|
|
const request = realtimeSmokeTesting.createOpenAIClientSecret("test-key", {
|
|
timeoutMs: 5,
|
|
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
|
|
});
|
|
|
|
await expect(request).rejects.toThrow(
|
|
/OpenAI Realtime client secret request exceeded timeout/u,
|
|
);
|
|
});
|
|
|
|
it("rejects invalid OpenAI realtime smoke timeout values", () => {
|
|
expect(realtimeSmokeTesting.resolveOpenAIHttpTimeoutMs("42")).toBe(42);
|
|
expect(() => realtimeSmokeTesting.resolveOpenAIHttpTimeoutMs("2s")).toThrow(
|
|
/OPENCLAW_REALTIME_OPENAI_HTTP_TIMEOUT_MS must be an integer/u,
|
|
);
|
|
});
|
|
|
|
it("formats OpenAI realtime smoke help without launching live checks", () => {
|
|
expect(realtimeSmokeTesting.parseRealtimeSmokeArgs(["--help"])).toEqual({
|
|
help: true,
|
|
openAIAudioCycles: 1,
|
|
openAIOnly: false,
|
|
});
|
|
expect(realtimeSmokeTesting.parseRealtimeSmokeArgs(["--openai-only"])).toEqual({
|
|
help: false,
|
|
openAIAudioCycles: 1,
|
|
openAIOnly: true,
|
|
});
|
|
expect(realtimeSmokeTesting.parseRealtimeSmokeArgs(["--openai-audio-cycles", "3"])).toEqual({
|
|
help: false,
|
|
openAIAudioCycles: 3,
|
|
openAIOnly: false,
|
|
});
|
|
expect(realtimeSmokeTesting.usage()).toContain(
|
|
"Usage: node --import tsx scripts/dev/realtime-talk-live-smoke.ts",
|
|
);
|
|
expect(realtimeSmokeTesting.usage()).toContain("--openai-only");
|
|
expect(realtimeSmokeTesting.usage()).toContain("--openai-audio-cycles");
|
|
});
|
|
|
|
it("rejects unknown OpenAI realtime smoke args before runtime setup", () => {
|
|
expect(() => realtimeSmokeTesting.parseRealtimeSmokeArgs(["--wat"])).toThrow(
|
|
"Unknown argument: --wat",
|
|
);
|
|
expect(() => realtimeSmokeTesting.parseRealtimeSmokeArgs(["--openai-audio-cycles"])).toThrow(
|
|
"--openai-audio-cycles requires a value",
|
|
);
|
|
expect(() =>
|
|
realtimeSmokeTesting.parseRealtimeSmokeArgs(["--openai-audio-cycles", "0"]),
|
|
).toThrow("--openai-audio-cycles must be an integer >= 1");
|
|
expect(() =>
|
|
realtimeSmokeTesting.parseRealtimeSmokeArgs(["--openai-audio-cycles", "11"]),
|
|
).toThrow("--openai-audio-cycles must be <= 10");
|
|
});
|
|
|
|
it("chunks PCM input without copying or dropping bytes", async () => {
|
|
const sent: Buffer[] = [];
|
|
const chunkCount = await realtimeSmokeTesting.sendPcmAudioInChunks(
|
|
{
|
|
sendAudio: (audio: Buffer) => sent.push(audio),
|
|
} as never,
|
|
Buffer.from([1, 2, 3, 4, 5]),
|
|
{ chunkBytes: 2, delayMs: 0 },
|
|
);
|
|
|
|
expect(chunkCount).toBe(3);
|
|
expect(Buffer.concat(sent)).toEqual(Buffer.from([1, 2, 3, 4, 5]));
|
|
expect(sent.map((chunk) => chunk.byteLength)).toEqual([2, 2, 1]);
|
|
await expect(
|
|
realtimeSmokeTesting.sendPcmAudioInChunks({ sendAudio: vi.fn() } as never, Buffer.alloc(1), {
|
|
chunkBytes: 0,
|
|
delayMs: 0,
|
|
}),
|
|
).rejects.toThrow("PCM audio chunk size must be a positive integer");
|
|
});
|
|
|
|
it("matches normalized live audio transcript markers", () => {
|
|
expect(realtimeSmokeTesting.transcriptIncludesMarker(["Glacier."], "glacier")).toBe(true);
|
|
expect(
|
|
realtimeSmokeTesting.transcriptIncludesMarker(
|
|
["Please reply with the single-word GLACIER!"],
|
|
"single word glacier",
|
|
),
|
|
).toBe(true);
|
|
expect(realtimeSmokeTesting.transcriptIncludesMarker(["ocean"], "glacier")).toBe(false);
|
|
});
|
|
|
|
it("resolves the realtime relay smoke to an existing Control UI module", () => {
|
|
const modulePath = realtimeSmokeTesting.resolveGatewayRelayModulePath(process.cwd());
|
|
|
|
expect(modulePath.endsWith("/ui/src/pages/chat/talk/gateway-relay.ts")).toBe(true);
|
|
expect(existsSync(modulePath.slice("/@fs/".length))).toBe(true);
|
|
});
|
|
|
|
it("rejects unsafe OpenAI realtime SDP answer content-length values before reading", async () => {
|
|
const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES;
|
|
const body = {
|
|
cancel: vi.fn(() => Promise.resolve()),
|
|
getReader: vi.fn(() => {
|
|
throw new Error("reader should not be acquired");
|
|
}),
|
|
};
|
|
const response = {
|
|
headers: new Headers({ "content-length": "9007199254740993" }),
|
|
body,
|
|
} as unknown as Response;
|
|
|
|
await expect(
|
|
realtimeSmokeTesting.readOpenAIRealtimeBrowserResponseText(
|
|
response,
|
|
"OpenAI Realtime SDP answer",
|
|
maxBytes,
|
|
),
|
|
).rejects.toThrow(`OpenAI Realtime SDP answer response body exceeded ${maxBytes} bytes`);
|
|
expect(body.getReader).not.toHaveBeenCalled();
|
|
expect(body.cancel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("bounds OpenAI realtime smoke response body reads by streamed bytes", async () => {
|
|
const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES;
|
|
const response = new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(maxBytes + 1));
|
|
controller.close();
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
realtimeSmokeTesting.readBoundedText(response, "OpenAI Realtime test", maxBytes),
|
|
).rejects.toThrow(`OpenAI Realtime test response body exceeded ${maxBytes} bytes`);
|
|
});
|
|
|
|
it("rejects absolute-form URLs in the Anthropic capture proxy", () => {
|
|
expect(
|
|
promptProbeTesting.resolveAnthropicUpstreamUrl(
|
|
"/v1/messages?anthropic-version=2023-06-01",
|
|
"https://api.anthropic.com",
|
|
),
|
|
).toBe("https://api.anthropic.com/v1/messages?anthropic-version=2023-06-01");
|
|
expect(() =>
|
|
promptProbeTesting.resolveAnthropicUpstreamUrl(
|
|
"http://169.254.169.254/latest/meta-data",
|
|
"https://api.anthropic.com",
|
|
),
|
|
).toThrow(/refusing non-origin proxy request URL/u);
|
|
});
|
|
|
|
it("bounds Anthropic capture proxy request bodies", async () => {
|
|
const request = Readable.from([Buffer.alloc(8), Buffer.alloc(8)]) as never;
|
|
const destroy = vi.spyOn(request, "destroy");
|
|
|
|
await expect(promptProbeTesting.readRequestBody(request, 12)).rejects.toThrow(
|
|
"Anthropic capture proxy request body exceeded 12 bytes",
|
|
);
|
|
expect(destroy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("reads only the bounded Anthropic prompt probe gateway log tail", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-prompt-probe-log-"));
|
|
tempDirs.push(tempRoot);
|
|
const logPath = path.join(tempRoot, "gateway.log");
|
|
const token = "sk-test1234567890abcdefghijklmnop"; // pragma: allowlist secret
|
|
await fs.writeFile(
|
|
logPath,
|
|
[
|
|
`DO_NOT_PRINT_OLD_GATEWAY_LOG OPENAI_API_KEY=${token}`,
|
|
"x".repeat(256),
|
|
`recent gateway tail Authorization: Bearer ${token}`,
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
const tail = await promptProbeTesting.readLogTail(logPath, 128);
|
|
|
|
expect(tail).toContain("recent gateway tail");
|
|
expect(tail).not.toContain("DO_NOT_PRINT_OLD_GATEWAY_LOG");
|
|
expect(tail).not.toContain(token);
|
|
});
|
|
|
|
it("drops partial Anthropic prompt probe log lines before redaction", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-prompt-probe-log-"));
|
|
tempDirs.push(tempRoot);
|
|
const logPath = path.join(tempRoot, "gateway.log");
|
|
const token = `sk-test${"a".repeat(80)}`; // pragma: allowlist secret
|
|
await fs.writeFile(logPath, `Authorization: Bearer ${token}\nrecent gateway tail`, "utf8");
|
|
|
|
const tail = await promptProbeTesting.readLogTail(logPath, "recent gateway tail".length + 24);
|
|
|
|
expect(tail).toBe("recent gateway tail");
|
|
expect(tail).not.toContain(token.slice(-16));
|
|
});
|
|
|
|
it("cleans Anthropic prompt probe temp dirs unless explicitly kept", async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-prompt-probe-test-"));
|
|
const keepRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-prompt-probe-test-"));
|
|
|
|
expect(promptProbeTesting.promptProbeTmpResult(tempRoot, false)).toEqual({});
|
|
expect(promptProbeTesting.promptProbeTmpResult(keepRoot, true)).toEqual({ tmpDir: keepRoot });
|
|
|
|
await promptProbeTesting.cleanupPromptProbeTmpDir(tempRoot, false);
|
|
await promptProbeTesting.cleanupPromptProbeTmpDir(keepRoot, true);
|
|
|
|
await expect(fs.stat(tempRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(fs.stat(keepRoot)).resolves.toBeTruthy();
|
|
await fs.rm(keepRoot, { force: true, recursive: true });
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects Anthropic direct prompt success while its descendant remains active",
|
|
async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-normal-exit-"));
|
|
tempDirs.push(tempRoot);
|
|
const descendantPidPath = path.join(tempRoot, "descendant.pid");
|
|
const fakeClaudeBin = await writeFakePromptCli(tempRoot, descendantPidPath, "leader-exit");
|
|
let descendantPid = 0;
|
|
const result = promptProbeTesting.runDirectPrompt("normal exit cleanup proof", {
|
|
claudeBin: fakeClaudeBin,
|
|
timeoutMs: 5_000,
|
|
});
|
|
void result.catch(() => undefined);
|
|
|
|
await runQaGatewayFixture(
|
|
async () => {
|
|
descendantPid = await waitForPidFile(descendantPidPath);
|
|
await expect(result).rejects.toMatchObject({
|
|
code: "EPROCESSGROUP_CLEANUP_FAILED",
|
|
processTreeState: "terminated",
|
|
});
|
|
expect(isProcessAlive(descendantPid)).toBe(false);
|
|
},
|
|
async () => {
|
|
if (descendantPid && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
await result.catch(() => undefined);
|
|
if (descendantPid) {
|
|
await waitForDead(descendantPid, 5_000);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"reports Anthropic direct cleanup uncertainty on parent signal",
|
|
async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-unjoined-"));
|
|
const owner = createVitestResourceOwner(tempRoot);
|
|
const descendantPidPath = path.join(tempRoot, "descendant.pid");
|
|
const fakeClaudeBin = await writeFakePromptCli(tempRoot, descendantPidPath, "escaped-output");
|
|
const probe = spawn(process.execPath, resolveRuntimeWorkerArgv(promptProbeUrl), {
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_BIN: fakeClaudeBin,
|
|
OPENCLAW_PROMPT_TRANSPORT: "direct",
|
|
OPENCLAW_PROMPT_CAPTURE: "0",
|
|
OPENCLAW_PROMPT_KEEP_TMP: "0",
|
|
OPENCLAW_PROMPT_TIMEOUT_MS: "10000",
|
|
OPENCLAW_PROMPT_LIST_JSON: JSON.stringify(["cleanup uncertainty", "must not run"]),
|
|
TMPDIR: tempRoot,
|
|
TMP: tempRoot,
|
|
TEMP: tempRoot,
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let descendantPid = 0;
|
|
probe.stdout.on("data", (chunk) => (stdout += String(chunk)));
|
|
probe.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
|
const closed = waitForChildClose(probe, 10_000);
|
|
void closed.catch(() => undefined);
|
|
|
|
await runQaGatewayFixture(
|
|
async () => {
|
|
descendantPid = await waitForPidFile(descendantPidPath);
|
|
await waitForCondition(() => {
|
|
const readyPath = path.join(tempRoot, "escaped.ready");
|
|
return existsSync(readyPath) && readFileSync(readyPath, "utf8") === "ready";
|
|
});
|
|
expect(isProcessAlive(descendantPid)).toBe(true);
|
|
probe.kill("SIGTERM");
|
|
const exit = await closed;
|
|
|
|
expect(isProcessAlive(descendantPid)).toBe(true);
|
|
expect(() => owner.assertReleased()).toThrow("Unreleased Vitest resource claim");
|
|
expect(exit).toEqual({ code: 1, signal: null });
|
|
expect(stderr).toContain(
|
|
"Managed command cleanup could not verify child, process group, and output closure",
|
|
);
|
|
expect(stdout).toBe("");
|
|
const launches = (await fs.readFile(path.join(tempRoot, "launches.jsonl"), "utf8"))
|
|
.trim()
|
|
.split("\n");
|
|
expect(launches).toHaveLength(1);
|
|
},
|
|
async () => {
|
|
const failures: unknown[] = [];
|
|
const cleanupClosed =
|
|
probe.stdout.closed &&
|
|
probe.stderr.closed &&
|
|
(probe.exitCode !== null || probe.signalCode !== null)
|
|
? Promise.resolve()
|
|
: waitForChildClose(probe);
|
|
if (probe.exitCode === null && probe.signalCode === null) {
|
|
probe.kill("SIGTERM");
|
|
}
|
|
try {
|
|
await cleanupClosed;
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
// Rescue extra launches too, even when the one-prompt assertion fails.
|
|
const ownedPids = new Set(descendantPid > 1 ? [descendantPid] : []);
|
|
try {
|
|
const journal = await fs.readFile(path.join(tempRoot, "launches.jsonl"), "utf8");
|
|
for (const line of journal.trim().split("\n").filter(Boolean)) {
|
|
const entry: unknown = JSON.parse(line);
|
|
if (
|
|
!entry ||
|
|
typeof entry !== "object" ||
|
|
!("pid" in entry) ||
|
|
typeof entry.pid !== "number" ||
|
|
!Number.isSafeInteger(entry.pid) ||
|
|
entry.pid <= 1
|
|
) {
|
|
throw new Error("Invalid escaped-child PID in the owned launch journal");
|
|
}
|
|
ownedPids.add(entry.pid);
|
|
}
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
for (const pid of ownedPids) {
|
|
try {
|
|
killPidIfAlive(pid);
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
}
|
|
const cleanup = await Promise.allSettled(
|
|
[...ownedPids].map((pid) => waitForDead(pid, 5_000)),
|
|
);
|
|
failures.push(
|
|
...cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])),
|
|
);
|
|
if (failures.length > 0) {
|
|
throw new AggregateError(failures, `Fixture cleanup failed; retained ${tempRoot}`);
|
|
}
|
|
tempDirs.push(tempRoot);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"returns a terminal result after an Anthropic direct prompt timeout",
|
|
async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-prompt-tree-"));
|
|
tempDirs.push(tempRoot);
|
|
const fakeClaudeBin = await writeBlockingPromptCli(tempRoot);
|
|
|
|
await expect(
|
|
promptProbeTesting.runDirectPrompt("timeout cleanup proof", {
|
|
claudeBin: fakeClaudeBin,
|
|
timeoutMs: 500,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
exitCode: null,
|
|
ok: false,
|
|
signal: "SIGKILL",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"cleans Anthropic direct prompt descendants on parent signal",
|
|
async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-parent-signal-"));
|
|
tempDirs.push(tempRoot);
|
|
const descendantPidPath = path.join(tempRoot, "descendant.pid");
|
|
let descendantPid = 0;
|
|
const fakeClaudeBin = await writeFakePromptCli(tempRoot, descendantPidPath);
|
|
const probe = spawn(process.execPath, resolveRuntimeWorkerArgv(promptProbeUrl), {
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_BIN: fakeClaudeBin,
|
|
OPENCLAW_PROMPT_TEXT: "parent signal cleanup proof",
|
|
OPENCLAW_PROMPT_TIMEOUT_MS: "10000",
|
|
OPENCLAW_PROMPT_TRANSPORT: "direct",
|
|
},
|
|
stdio: "ignore",
|
|
});
|
|
|
|
try {
|
|
descendantPid = await waitForPidFile(descendantPidPath);
|
|
expect(Number.isInteger(descendantPid)).toBe(true);
|
|
expect(isProcessAlive(descendantPid)).toBe(true);
|
|
|
|
const probeExit = waitForChildExit(probe);
|
|
process.kill(probe.pid!, "SIGTERM");
|
|
await expect(probeExit).resolves.toEqual({ status: 143, signal: null });
|
|
await waitForCondition(() => !isProcessAlive(descendantPid));
|
|
} finally {
|
|
if (probe.pid && isProcessAlive(probe.pid)) {
|
|
process.kill(probe.pid, "SIGKILL");
|
|
}
|
|
if (descendantPid && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it("waits for the Anthropic prompt gateway child after SIGKILL cleanup", async () => {
|
|
const events = new EventEmitter();
|
|
const signals: NodeJS.Signals[] = [];
|
|
let closeCalls = 0;
|
|
const child = {
|
|
exitCode: null as number | null,
|
|
signalCode: null as NodeJS.Signals | null,
|
|
kill(signal: NodeJS.Signals) {
|
|
signals.push(signal);
|
|
if (signal === "SIGKILL") {
|
|
setTimeout(() => {
|
|
child.signalCode = "SIGKILL";
|
|
events.emit("exit");
|
|
}, 1);
|
|
}
|
|
return true;
|
|
},
|
|
once(event: "exit", listener: () => void) {
|
|
events.once(event, listener);
|
|
},
|
|
};
|
|
|
|
const stopped = await promptProbeTesting.stopGatewayPromptChild(
|
|
child,
|
|
{
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
},
|
|
1,
|
|
50,
|
|
);
|
|
|
|
expect(stopped).toBe(true);
|
|
expect(signals).toEqual(["SIGINT", "SIGKILL"]);
|
|
expect(closeCalls).toBe(1);
|
|
});
|
|
|
|
it("bounds Anthropic prompt gateway cleanup when the child never exits", async () => {
|
|
const signals: NodeJS.Signals[] = [];
|
|
let closeCalls = 0;
|
|
const child = {
|
|
exitCode: null as number | null,
|
|
signalCode: null as NodeJS.Signals | null,
|
|
kill(signal: NodeJS.Signals) {
|
|
signals.push(signal);
|
|
return false;
|
|
},
|
|
once(_event: "exit", _listener: () => void) {},
|
|
};
|
|
|
|
const stopped = await promptProbeTesting.stopGatewayPromptChild(
|
|
child,
|
|
{
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
},
|
|
1,
|
|
1,
|
|
);
|
|
|
|
expect(stopped).toBe(false);
|
|
expect(signals).toEqual(["SIGINT", "SIGKILL"]);
|
|
expect(closeCalls).toBe(1);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"cleans Anthropic prompt gateway descendants after leader exit",
|
|
async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-prompt-gateway-tree-"));
|
|
tempDirs.push(tempRoot);
|
|
const descendantPidPath = path.join(tempRoot, "descendant.pid");
|
|
let descendantPid = 0;
|
|
const descendantScript = [
|
|
"process.on('SIGINT', () => {});",
|
|
"process.on('SIGTERM', () => {});",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("");
|
|
const leaderScript = [
|
|
"import childProcess from 'node:child_process';",
|
|
"import fs from 'node:fs';",
|
|
"const descendant = childProcess.spawn(process.execPath, [",
|
|
" '--input-type=module',",
|
|
` '--eval', ${JSON.stringify(descendantScript)},`,
|
|
"], { stdio: 'ignore' });",
|
|
`fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(descendant.pid));`,
|
|
"process.on('SIGINT', () => process.exit(0));",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
const child = spawn(process.execPath, ["--input-type=module", "--eval", leaderScript], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
let closeCalls = 0;
|
|
|
|
try {
|
|
await waitForCondition(() => isProcessAlive(child.pid!));
|
|
descendantPid = await waitForPidFile(descendantPidPath);
|
|
expect(Number.isInteger(descendantPid)).toBe(true);
|
|
expect(isProcessAlive(descendantPid)).toBe(true);
|
|
|
|
const stopped = await promptProbeTesting.stopGatewayPromptChild(
|
|
child as Parameters<typeof promptProbeTesting.stopGatewayPromptChild>[0],
|
|
{
|
|
close: async () => {
|
|
closeCalls += 1;
|
|
},
|
|
},
|
|
50,
|
|
100,
|
|
);
|
|
|
|
expect(stopped).toBe(true);
|
|
expect(closeCalls).toBe(1);
|
|
await waitForCondition(() => !isProcessAlive(descendantPid));
|
|
} finally {
|
|
if (child.pid && isProcessAlive(child.pid)) {
|
|
process.kill(-child.pid, "SIGKILL");
|
|
}
|
|
if (descendantPid && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"cleans Anthropic prompt gateway descendants when the child attaches after parent signal",
|
|
async () => {
|
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-prompt-parent-signal-"));
|
|
tempDirs.push(tempRoot);
|
|
const descendantPidPath = path.join(tempRoot, "descendant.pid");
|
|
const readyPath = path.join(tempRoot, "ready");
|
|
const runnerPath = path.join(tempRoot, "parent-signal-runner.mjs");
|
|
let descendantPid = 0;
|
|
const descendantScript = [
|
|
"process.on('SIGINT', () => {});",
|
|
"process.on('SIGTERM', () => {});",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("");
|
|
const leaderScript = [
|
|
"import childProcess from 'node:child_process';",
|
|
"import fs from 'node:fs';",
|
|
"const descendant = childProcess.spawn(process.execPath, [",
|
|
" '--input-type=module',",
|
|
` '--eval', ${JSON.stringify(descendantScript)},`,
|
|
"], { stdio: 'ignore' });",
|
|
`fs.writeFileSync(${JSON.stringify(descendantPidPath)}, String(descendant.pid));`,
|
|
"process.on('SIGINT', () => process.exit(0));",
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n");
|
|
await fs.writeFile(
|
|
runnerPath,
|
|
[
|
|
"import childProcess from 'node:child_process';",
|
|
"import fs from 'node:fs';",
|
|
`const { testing } = await import(${JSON.stringify(promptProbeUrl.href)});`,
|
|
"const signalController = testing.createPromptProbeParentSignalController();",
|
|
`const child = childProcess.spawn(process.execPath, ['--input-type=module', '--eval', ${JSON.stringify(leaderScript)}], { detached: true, stdio: 'ignore' });`,
|
|
"let stopPromise;",
|
|
"const stopGateway = () => {",
|
|
" stopPromise ??= testing.stopGatewayPromptChild(child, { close: async () => {} }, 50, 100);",
|
|
" return stopPromise;",
|
|
"};",
|
|
"process.on('SIGTERM', () => {",
|
|
" signalController.attach({",
|
|
" stop: stopGateway,",
|
|
" forceKill: () => {",
|
|
" try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }",
|
|
" },",
|
|
" });",
|
|
"});",
|
|
`fs.writeFileSync(${JSON.stringify(readyPath)}, String(process.pid));`,
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
const runner = spawn(
|
|
process.execPath,
|
|
[...resolveRuntimeWorkerArgv(promptProbeUrl).slice(0, -1), runnerPath],
|
|
{
|
|
stdio: "ignore",
|
|
},
|
|
);
|
|
|
|
try {
|
|
await waitForCondition(() => existsSync(readyPath));
|
|
descendantPid = await waitForPidFile(descendantPidPath);
|
|
expect(Number.isInteger(descendantPid)).toBe(true);
|
|
expect(isProcessAlive(descendantPid)).toBe(true);
|
|
|
|
const runnerExit = waitForChildExit(runner);
|
|
process.kill(runner.pid!, "SIGTERM");
|
|
await expect(runnerExit).resolves.toEqual({ status: 143, signal: null });
|
|
await waitForCondition(() => !isProcessAlive(descendantPid));
|
|
} finally {
|
|
if (runner.pid && isProcessAlive(runner.pid)) {
|
|
process.kill(runner.pid, "SIGKILL");
|
|
}
|
|
if (descendantPid && isProcessAlive(descendantPid)) {
|
|
process.kill(descendantPid, "SIGKILL");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it("waits for Anthropic prompt gateway log writes before closing the log file", async () => {
|
|
let resolveWrite: (() => void) | undefined;
|
|
const order: string[] = [];
|
|
const pendingWrite = new Promise<void>((resolve) => {
|
|
resolveWrite = () => {
|
|
order.push("write");
|
|
resolve();
|
|
};
|
|
});
|
|
const stop = promptProbeTesting.stopGatewayPromptChild(
|
|
{
|
|
exitCode: 0,
|
|
signalCode: null,
|
|
kill: () => true,
|
|
once(_event: "exit", _listener: () => void) {},
|
|
},
|
|
{
|
|
close: async () => {
|
|
order.push("close");
|
|
},
|
|
},
|
|
1,
|
|
1,
|
|
[pendingWrite],
|
|
);
|
|
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 10);
|
|
});
|
|
expect(order).toEqual([]);
|
|
|
|
resolveWrite?.();
|
|
await expect(stop).resolves.toBe(true);
|
|
expect(order).toEqual(["write", "close"]);
|
|
});
|
|
|
|
it("uses exact Claude cookie host matchers instead of broad substring matches", () => {
|
|
expect(claudeUsageTesting.CLAUDE_COOKIE_HOST_SQL).toContain("host_key = 'claude.ai'");
|
|
expect(claudeUsageTesting.CLAUDE_COOKIE_HOST_SQL).toContain("LIKE '%.claude.ai'");
|
|
expect(claudeUsageTesting.CLAUDE_COOKIE_HOST_SQL).not.toContain("%claude.ai%");
|
|
});
|
|
|
|
it("rejects malformed Claude usage args before reading auth or browser state", () => {
|
|
expect(claudeUsageTesting.parseArgs(["--agent", "work", "--session-key=abc"])).toEqual({
|
|
agentId: "work",
|
|
help: false,
|
|
reveal: false,
|
|
sessionKey: "abc",
|
|
});
|
|
expect(claudeUsageTesting.parseArgs(["--help"])).toEqual({
|
|
agentId: "main",
|
|
help: true,
|
|
reveal: false,
|
|
sessionKey: undefined,
|
|
});
|
|
expect(() => claudeUsageTesting.parseArgs(["--wat"])).toThrow("Unknown argument: --wat");
|
|
expect(() => claudeUsageTesting.parseArgs(["--agent"])).toThrow("--agent requires a value");
|
|
expect(() => claudeUsageTesting.parseArgs(["--agent="])).toThrow("--agent requires a value");
|
|
expect(() => claudeUsageTesting.parseArgs(["--session-key", "--reveal"])).toThrow(
|
|
"--session-key requires a value",
|
|
);
|
|
expect(() => claudeUsageTesting.parseArgs(["--session-key= "])).toThrow(
|
|
"--session-key requires a value",
|
|
);
|
|
});
|
|
|
|
it("prints Claude usage help without opening auth stores", async () => {
|
|
const [result] = await getClaudeUsageCliResults();
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("Usage: node --import tsx scripts/debug-claude-usage.ts");
|
|
expect(result.stderr).toBe("");
|
|
});
|
|
|
|
it("fails missing Claude usage option values before defaulting to main auth", async () => {
|
|
const [, result] = await getClaudeUsageCliResults();
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("--agent requires a value");
|
|
});
|
|
|
|
it("aborts stalled Claude usage fetches at the request timeout", async () => {
|
|
let signal: AbortSignal | undefined;
|
|
const request = claudeUsageTesting.fetchAnthropicOAuthUsage("test-token", {
|
|
timeoutMs: 5,
|
|
fetchImpl: ((_url, init) => {
|
|
signal = init?.signal ?? undefined;
|
|
return new Promise(() => {});
|
|
}) as typeof fetch,
|
|
});
|
|
|
|
await expect(request).rejects.toThrow(/Anthropic OAuth usage request exceeded timeout/u);
|
|
expect(signal?.aborted).toBe(true);
|
|
});
|
|
|
|
it("times out stalled Claude usage response body reads", async () => {
|
|
const response = new Response(
|
|
new ReadableStream({
|
|
start() {},
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
);
|
|
const request = claudeUsageTesting.fetchAnthropicOAuthUsage("test-token", {
|
|
timeoutMs: 5,
|
|
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
|
|
});
|
|
|
|
await expect(request).rejects.toThrow(/Anthropic OAuth usage request exceeded timeout/u);
|
|
});
|
|
|
|
it("rejects invalid Claude usage timeout values", () => {
|
|
expect(claudeUsageTesting.resolveFetchTimeoutMs("123")).toBe(123);
|
|
expect(() => claudeUsageTesting.resolveFetchTimeoutMs("1.5")).toThrow(
|
|
/OPENCLAW_DEBUG_CLAUDE_USAGE_FETCH_TIMEOUT_MS must be an integer/u,
|
|
);
|
|
});
|
|
|
|
it("bounds Claude usage response body reads by content-length", async () => {
|
|
const maxBytes = 256 * 1024;
|
|
const response = new Response("{}", {
|
|
headers: { "content-length": String(maxBytes + 1) },
|
|
});
|
|
|
|
await expect(
|
|
claudeUsageTesting.fetchAnthropicOAuthUsage("test-token", {
|
|
fetchImpl: async () => response,
|
|
timeoutMs: 1_000,
|
|
}),
|
|
).rejects.toThrow(`Anthropic OAuth usage request response body exceeded ${maxBytes} bytes`);
|
|
});
|
|
|
|
it("bounds Claude usage response body reads by streamed bytes", async () => {
|
|
const maxBytes = 256 * 1024;
|
|
const response = new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new Uint8Array(maxBytes + 1));
|
|
controller.close();
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
claudeUsageTesting.fetchAnthropicOAuthUsage("test-token", {
|
|
fetchImpl: async () => response,
|
|
timeoutMs: 1_000,
|
|
}),
|
|
).rejects.toThrow(`Anthropic OAuth usage request response body exceeded ${maxBytes} bytes`);
|
|
});
|
|
|
|
it.each([
|
|
{ name: "before response headers", sendsHeaders: false },
|
|
{ name: "during the response body", sendsHeaders: true },
|
|
])("bounds an Anthropic upstream stall $name", async ({ sendsHeaders }) => {
|
|
const nativeFetch = globalThis.fetch;
|
|
const upstream = http.createServer((_request, response) => {
|
|
if (sendsHeaders) {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.flushHeaders();
|
|
}
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
upstream.once("error", reject);
|
|
upstream.listen(0, "127.0.0.1", () => {
|
|
upstream.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
const upstreamAddress = upstream.address();
|
|
if (!upstreamAddress || typeof upstreamAddress === "string") {
|
|
throw new Error("upstream did not bind to a TCP port");
|
|
}
|
|
const upstreamUrl = `http://127.0.0.1:${upstreamAddress.port}/v1/messages`;
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockImplementation((_input, init) => nativeFetch(upstreamUrl, init));
|
|
let proxy: Awaited<ReturnType<typeof promptProbeTesting.startAnthropicProxy>> | undefined;
|
|
|
|
try {
|
|
proxy = await promptProbeTesting.startAnthropicProxy({
|
|
port: 0,
|
|
upstreamBaseUrl: "https://api.anthropic.com",
|
|
timeoutMs: 100,
|
|
});
|
|
const startedAt = Date.now();
|
|
const proxyUrl = `http://127.0.0.1:${proxy.port}/v1/messages`;
|
|
const request = sendsHeaders
|
|
? new Promise<{ status: number; body: string }>((resolve, reject) => {
|
|
const downstream = spawn(
|
|
testNodeExecPath,
|
|
[
|
|
"-e",
|
|
`fetch(process.argv[1], { method: "POST", body: "{}" })
|
|
.then(async response => ({ status: response.status, body: await response.text() }))
|
|
.then(result => process.stdout.write(JSON.stringify(result)))
|
|
.catch(error => { process.stderr.write(String(error)); process.exitCode = 1; });`,
|
|
proxyUrl,
|
|
],
|
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
downstream.stdout.on("data", (chunk) => (stdout += chunk));
|
|
downstream.stderr.on("data", (chunk) => (stderr += chunk));
|
|
downstream.once("error", reject);
|
|
downstream.once("close", (code) => {
|
|
if (code === 0) {
|
|
resolve(JSON.parse(stdout));
|
|
} else {
|
|
reject(new Error(stderr || `proxy client exited ${String(code)}`));
|
|
}
|
|
});
|
|
})
|
|
: nativeFetch(proxyUrl, { method: "POST", body: "{}" }).then(async (response) => ({
|
|
status: response.status,
|
|
body: await response.text(),
|
|
}));
|
|
|
|
if (sendsHeaders) {
|
|
await expect(request).rejects.toThrow();
|
|
} else {
|
|
await expect(request).resolves.toMatchObject({
|
|
status: 502,
|
|
body: expect.stringMatching(/Anthropic upstream timed out after 100ms/u),
|
|
});
|
|
}
|
|
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
"https://api.anthropic.com/v1/messages",
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
} finally {
|
|
fetchSpy.mockRestore();
|
|
await proxy?.stop();
|
|
upstream.closeAllConnections();
|
|
await new Promise<void>((resolve, reject) => {
|
|
upstream.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
});
|