mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-05 21:12:55 +00:00
307 lines
9.3 KiB
TypeScript
307 lines
9.3 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
GATEWAY_READY_OUTPUT_MAX_CHARS,
|
|
MEMORY_SEARCH_RESPONSE_MAX_BYTES,
|
|
classifyMemorySearchInvokeResponse,
|
|
hasChildExited,
|
|
parseArgs,
|
|
readBoundedResponseText,
|
|
readNumber,
|
|
readPositiveNumber,
|
|
stopGatewayWithRuntime,
|
|
updateGatewayReadyOutputState,
|
|
waitForGatewayReady,
|
|
} from "../../scripts/check-memory-fd-repro.mjs";
|
|
|
|
function withEnv<T>(env: Record<string, string | undefined>, callback: () => T): T {
|
|
const previous = new Map<string, string | undefined>();
|
|
for (const [key, value] of Object.entries(env)) {
|
|
previous.set(key, process.env[key]);
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
try {
|
|
return callback();
|
|
} finally {
|
|
for (const [key, value] of previous) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("check-memory-fd-repro", () => {
|
|
it("parses file, fd, and timing limits as strict integers", () => {
|
|
expect(readNumber("0", "limit")).toBe(0);
|
|
expect(readNumber(" 42 ", "limit")).toBe(42);
|
|
expect(readPositiveNumber("1", "limit")).toBe(1);
|
|
|
|
expect(() => readNumber("1.5", "limit")).toThrow("limit must be a non-negative integer");
|
|
expect(() => readNumber("1e3", "limit")).toThrow("limit must be a non-negative integer");
|
|
expect(() => readNumber("10files", "limit")).toThrow("limit must be a non-negative integer");
|
|
expect(() => readPositiveNumber("0", "limit")).toThrow("limit must be greater than 0");
|
|
});
|
|
|
|
it("rejects loose numeric environment limits before generating files", () => {
|
|
expect(
|
|
withEnv(
|
|
{
|
|
OPENCLAW_MEMORY_FD_REPRO_FILES: "17",
|
|
OPENCLAW_MEMORY_FD_REPRO_MAX_WORKSPACE_REG_FDS: "0",
|
|
OPENCLAW_MEMORY_FD_REPRO_SAMPLE_DELAY_MS: "0",
|
|
},
|
|
() => parseArgs([]),
|
|
),
|
|
).toMatchObject({
|
|
fileCount: 17,
|
|
maxWorkspaceRegFds: 0,
|
|
sampleDelayMs: 0,
|
|
});
|
|
|
|
expect(() =>
|
|
withEnv({ OPENCLAW_MEMORY_FD_REPRO_FILES: "17files" }, () => parseArgs([])),
|
|
).toThrow("OPENCLAW_MEMORY_FD_REPRO_FILES must be a non-negative integer");
|
|
expect(() =>
|
|
withEnv({ OPENCLAW_MEMORY_FD_REPRO_TIMEOUT_MS: "1e3" }, () => parseArgs([])),
|
|
).toThrow("OPENCLAW_MEMORY_FD_REPRO_TIMEOUT_MS must be a non-negative integer");
|
|
});
|
|
|
|
it("lets explicit CLI numeric flags override malformed inherited env defaults", () => {
|
|
expect(
|
|
withEnv(
|
|
{
|
|
OPENCLAW_MEMORY_FD_REPRO_FILES: "17files",
|
|
OPENCLAW_MEMORY_FD_REPRO_MAX_WORKSPACE_REG_FDS: "4fds",
|
|
OPENCLAW_MEMORY_FD_REPRO_TIMEOUT_MS: "1e3",
|
|
OPENCLAW_MEMORY_FD_REPRO_SAMPLE_DELAY_MS: "soon",
|
|
OPENCLAW_MEMORY_FD_REPRO_SETTLE_DELAY_MS: "later",
|
|
},
|
|
() =>
|
|
parseArgs([
|
|
"--files",
|
|
"20",
|
|
"--max-workspace-reg-fds",
|
|
"4",
|
|
"--invoke-timeout-ms",
|
|
"1000",
|
|
"--sample-delay-ms",
|
|
"0",
|
|
"--settle-delay-ms",
|
|
"0",
|
|
]),
|
|
),
|
|
).toMatchObject({
|
|
fileCount: 20,
|
|
invokeTimeoutMs: 1000,
|
|
maxWorkspaceRegFds: 4,
|
|
sampleDelayMs: 0,
|
|
settleDelayMs: 0,
|
|
});
|
|
});
|
|
|
|
it("stops parsing options after the argument terminator", () => {
|
|
expect(parseArgs(["--files", "20", "--", "--files", "99"])).toMatchObject({
|
|
fileCount: 20,
|
|
});
|
|
|
|
expect(
|
|
withEnv({ OPENCLAW_MEMORY_FD_REPRO_FILES: "17" }, () => parseArgs(["--", "--unknown"])),
|
|
).toMatchObject({
|
|
fileCount: 17,
|
|
});
|
|
});
|
|
|
|
it("accepts the leading package-manager argument separator", () => {
|
|
expect(parseArgs(["--", "--files", "20", "--allow-non-darwin"])).toMatchObject({
|
|
allowNonDarwin: true,
|
|
fileCount: 20,
|
|
});
|
|
});
|
|
|
|
it("accepts an available memory_search tool payload", () => {
|
|
const result = classifyMemorySearchInvokeResponse({
|
|
httpOk: true,
|
|
status: 200,
|
|
bodyText: JSON.stringify({
|
|
ok: true,
|
|
result: {
|
|
content: [{ type: "text", text: JSON.stringify({ results: [] }) }],
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
gatewayOk: true,
|
|
resultCount: 0,
|
|
});
|
|
});
|
|
|
|
it("rejects disabled memory_search tool payloads", () => {
|
|
const result = classifyMemorySearchInvokeResponse({
|
|
httpOk: true,
|
|
status: 200,
|
|
bodyText: JSON.stringify({
|
|
ok: true,
|
|
result: {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
results: [],
|
|
disabled: true,
|
|
unavailable: true,
|
|
error: 'No API key found for provider "openai".',
|
|
}),
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
gatewayOk: true,
|
|
toolDisabled: true,
|
|
toolUnavailable: true,
|
|
toolError: 'No API key found for provider "openai".',
|
|
});
|
|
});
|
|
|
|
it("rejects gateway success envelopes without memory_search details", () => {
|
|
const result = classifyMemorySearchInvokeResponse({
|
|
httpOk: true,
|
|
status: 200,
|
|
bodyText: JSON.stringify({ ok: true, result: { content: [] } }),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: "memory_search result payload missing or invalid",
|
|
});
|
|
});
|
|
|
|
it("treats signaled gateway children as exited", () => {
|
|
expect(hasChildExited({ exitCode: null, signalCode: "SIGTERM" })).toBe(true);
|
|
expect(hasChildExited({ exitCode: 0, signalCode: null })).toBe(true);
|
|
expect(hasChildExited({ exitCode: null, signalCode: null })).toBe(false);
|
|
});
|
|
|
|
it("fails gateway readiness immediately after signal exits", async () => {
|
|
const child = {
|
|
exitCode: null,
|
|
signalCode: "SIGTERM",
|
|
stderr: new EventEmitter(),
|
|
stdout: new EventEmitter(),
|
|
};
|
|
|
|
await expect(
|
|
waitForGatewayReady({ child, port: 9, logPath: "gateway.log", timeoutMs: 10_000 }),
|
|
).rejects.toThrow("gateway exited before ready");
|
|
});
|
|
|
|
it("does not signal already exited children during gateway cleanup", async () => {
|
|
const child = {
|
|
exitCode: null,
|
|
kill: vi.fn(),
|
|
signalCode: "SIGTERM",
|
|
};
|
|
const findGatewayPidFn = vi.fn(() => null);
|
|
const killProcess = vi.fn();
|
|
|
|
await expect(
|
|
stopGatewayWithRuntime({ child, findGatewayPidFn, killProcess, port: 9 }),
|
|
).resolves.toBeUndefined();
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
expect(findGatewayPidFn).toHaveBeenCalledWith(9);
|
|
expect(killProcess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("bounds gateway readiness output while keeping newest logs", () => {
|
|
const first = updateGatewayReadyOutputState({ tail: "abc", readySeen: false }, "def", 8);
|
|
expect(first).toEqual({ tail: "abcdef", readySeen: false });
|
|
|
|
const second = updateGatewayReadyOutputState(first, "ghijkl", 8);
|
|
expect(second).toEqual({ tail: "efghijkl", readySeen: false });
|
|
expect(second.tail).toHaveLength(8);
|
|
expect(GATEWAY_READY_OUTPUT_MAX_CHARS).toBeGreaterThan(1024);
|
|
});
|
|
|
|
it("keeps readiness after a coalesced noisy chunk truncates the marker", () => {
|
|
const state = updateGatewayReadyOutputState(
|
|
{ tail: "", readySeen: false },
|
|
`[gateway] ready\n${"x".repeat(10_000)}`,
|
|
64,
|
|
);
|
|
|
|
expect(state.readySeen).toBe(true);
|
|
expect(state.tail).toHaveLength(64);
|
|
expect(state.tail).not.toContain("[gateway] ready");
|
|
});
|
|
|
|
it("recognizes readiness split across the existing tail and new chunk", () => {
|
|
const state = updateGatewayReadyOutputState(
|
|
{ tail: "[gateway] rea", readySeen: false },
|
|
"dy\n",
|
|
64,
|
|
);
|
|
|
|
expect(state.readySeen).toBe(true);
|
|
expect(state.tail).toBe("[gateway] ready\n");
|
|
});
|
|
|
|
it("preserves previous readiness once seen", () => {
|
|
const state = updateGatewayReadyOutputState({ tail: "old", readySeen: true }, "new output", 8);
|
|
|
|
expect(state.readySeen).toBe(true);
|
|
expect(state.tail).toBe("w output");
|
|
});
|
|
|
|
it("reads memory_search response bodies under the byte cap", async () => {
|
|
await expect(
|
|
readBoundedResponseText(
|
|
new Response("ok"),
|
|
"memory_search",
|
|
MEMORY_SEARCH_RESPONSE_MAX_BYTES,
|
|
),
|
|
).resolves.toBe("ok");
|
|
});
|
|
|
|
it("rejects oversized memory_search response bodies from content-length", async () => {
|
|
const response = new Response("ignored", {
|
|
headers: { "content-length": String(MEMORY_SEARCH_RESPONSE_MAX_BYTES + 1) },
|
|
});
|
|
|
|
await expect(
|
|
readBoundedResponseText(response, "memory_search", MEMORY_SEARCH_RESPONSE_MAX_BYTES),
|
|
).rejects.toThrow(
|
|
`memory_search response body exceeded ${MEMORY_SEARCH_RESPONSE_MAX_BYTES} bytes`,
|
|
);
|
|
});
|
|
|
|
it("stops reading memory_search response streams after the byte cap", async () => {
|
|
const chunk = new Uint8Array(MEMORY_SEARCH_RESPONSE_MAX_BYTES + 1);
|
|
const response = new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(chunk);
|
|
controller.close();
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
readBoundedResponseText(response, "memory_search", MEMORY_SEARCH_RESPONSE_MAX_BYTES),
|
|
).rejects.toThrow(
|
|
`memory_search response body exceeded ${MEMORY_SEARCH_RESPONSE_MAX_BYTES} bytes`,
|
|
);
|
|
});
|
|
});
|