Files
openclaw/test/scripts/check-memory-fd-repro.test.ts
Peter Steinberger fa03d9b913 refactor: consolidate coercion helpers (#121366)
* refactor: consolidate coercion helpers

* fix: remove duplicate coercion imports

* fix: preserve serialized coercion guard

* chore: ratchet coercion helper carve-outs

* fix(test): keep gauntlet subprocess startup lean

* fix: preserve imported session timestamp semantics

* fix: preserve catalog timestamp string semantics

* chore: align plugin SDK surface ratchet

* fix: preserve trajectory and SDK string contracts

* fix(test): preserve QA record assertion semantics

* fix: complete standalone record guard rename

* refactor(cron): use canonical string coercion

* fix(acpx): preserve Pi timestamp parsing

* test(channels): adapt custody test harnesses

* test(telegram): classify media harness as test support

* test(acpx): split timestamp contract coverage

* test(channels): support generated custody contracts

* chore: ban the full coercion helper name set

Extends the declaration guard to all eleven consolidated helper names and
renames the cron schedule-identity readNumber wrapper to readScheduleInteger
so the banned generic name cannot regrow.

* fix(scripts): repair release-validation guard drift and lint cause

Restores the renamed isJsonRecord guard in assertTrustedWorkflowHarness after
main added isRecord call sites in parallel, and attaches the caught YAML error
as the thrown error cause (preserve-caught-error was red on main).

* fix: preserve Claude timestamp string semantics

* fix: preserve persisted timestamp string semantics

* fix: preserve date-first timestamp contracts

* fix(openai): harden delegation failure formatting

* chore: close coercion helper guard gaps

* test(openai): model non-error delegation rejection

* chore: refresh plugin SDK API contract

* fix(tasks): use canonical string field reader

* fix(ai): use canonical provider error field coercion

* fix(browser): migrate native bootstrap coercion

* docs(plugin-sdk): clarify text record export compatibility

* fix(gateway): normalize approval execution identity

* test(outbound): isolate message action poll harness
2026-08-11 00:02:18 -07:00

428 lines
13 KiB
TypeScript

// Check Memory Fd Repro tests cover check memory fd repro script behavior.
import { EventEmitter } from "node:events";
import fs from "node:fs";
import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it, vi } from "vitest";
import {
GATEWAY_READY_OUTPUT_MAX_CHARS,
MEMORY_SEARCH_PROBE_QUERY,
MEMORY_SEARCH_RESPONSE_MAX_BYTES,
classifyMemorySearchInvokeResponse,
hasChildExited,
invokeMemorySearch,
parseArgs,
parseNonNegativeInteger,
readBoundedResponseText,
readPositiveNumber,
stopGatewayWithRuntime,
updateGatewayReadyOutputState,
waitForGatewayReady,
writeConfig,
} from "../../scripts/check-memory-fd-repro.mts";
import { validateConfigObject } from "../../src/config/validation.js";
import { withEnv } from "../../src/test-utils/env.js";
async function listen(server: Server): Promise<number> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP port");
}
return address.port;
}
describe("check-memory-fd-repro", () => {
it("parses file, fd, and timing limits as strict integers", () => {
expect(parseNonNegativeInteger("0", "limit")).toBe(0);
expect(parseNonNegativeInteger(" 42 ", "limit")).toBe(42);
expect(readPositiveNumber("1", "limit")).toBe(1);
expect(() => parseNonNegativeInteger("1.5", "limit")).toThrow(
"limit must be a non-negative integer",
);
expect(() => parseNonNegativeInteger("1e3", "limit")).toThrow(
"limit must be a non-negative integer",
);
expect(() => parseNonNegativeInteger("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",
OPENCLAW_MEMORY_FD_REPRO_SETTLE_DELAY_MS: String(MAX_TIMER_TIMEOUT_MS + 1),
OPENCLAW_MEMORY_FD_REPRO_TIMEOUT_MS: String(MAX_TIMER_TIMEOUT_MS + 1),
},
() => parseArgs([]),
),
).toMatchObject({
fileCount: 17,
invokeTimeoutMs: MAX_TIMER_TIMEOUT_MS,
maxWorkspaceRegFds: 0,
sampleDelayMs: 0,
settleDelayMs: MAX_TIMER_TIMEOUT_MS,
});
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("rejects missing valued options instead of consuming the next flag", () => {
for (const flag of [
"--files",
"--invoke-timeout-ms",
"--max-workspace-reg-fds",
"--min-leaked-fds",
"--mode",
"--output-dir",
"--sample-delay-ms",
"--settle-delay-ms",
]) {
for (const value of ["--keep", "-h"]) {
expect(() => parseArgs([flag, value])).toThrow(`Missing value for ${flag}`);
}
}
});
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("clamps oversized memory_search invoke timers before scheduling", async () => {
const server = createServer((_request, response) => {
setTimeout(() => {
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
ok: true,
result: {
content: [{ type: "text", text: JSON.stringify({ results: [] }) }],
},
}),
);
}, 25);
});
const port = await listen(server);
try {
await expect(
invokeMemorySearch({
port,
token: "test-token",
timeoutMs: MAX_TIMER_TIMEOUT_MS + 1,
}),
).resolves.toMatchObject({
gatewayOk: true,
ok: true,
resultCount: 0,
});
} finally {
await new Promise<void>((resolve) => {
server.close(() => {
resolve();
});
});
}
});
it("uses a fast matching probe query instead of a no-hit stress query", () => {
expect(MEMORY_SEARCH_PROBE_QUERY).toBe("Top-level memory file");
expect(MEMORY_SEARCH_PROBE_QUERY).not.toContain("nomatch");
});
it("writes an offline FTS-only memory search config for repro indexing", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-memory-fd-config-"));
try {
const homeDir = path.join(root, "home");
const workspaceDir = path.join(root, "workspace");
const configPath = writeConfig({
homeDir,
workspaceDir,
port: 12345,
token: "test-token",
});
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const memorySearch = config.memory.search;
expect(validateConfigObject(config)).toMatchObject({ ok: true });
expect(memorySearch.store).toEqual({ vector: { enabled: false } });
expect(memorySearch).toMatchObject({
provider: "none",
model: "",
});
expect(memorySearch).not.toHaveProperty("sync");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
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("force-kills a gateway child that survives listener cleanup", async () => {
const child = {
exitCode: null,
kill: vi.fn(),
signalCode: null,
};
const findGatewayPidFn = vi.fn().mockReturnValueOnce(1234).mockReturnValue(null);
const killProcess = vi.fn();
await expect(
stopGatewayWithRuntime({
child,
childExitPolls: 0,
findGatewayPidFn,
killProcess,
listenerSettleDelayMs: 0,
port: 9,
}),
).resolves.toBeUndefined();
expect(child.kill).toHaveBeenNthCalledWith(1, "SIGINT");
expect(killProcess).toHaveBeenCalledWith(1234, "SIGTERM");
expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
});
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`,
);
});
});