fix(testing): bound openclaw instance logs

This commit is contained in:
Vincent Koc
2026-05-27 22:15:54 +02:00
parent 9ff071f646
commit 1eb27da55d
2 changed files with 96 additions and 20 deletions

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createOpenClawTestInstance } from "./openclaw-test-instance.js";
import { createOpenClawTestInstance, testing } from "./openclaw-test-instance.js";
async function expectPathMissing(targetPath: string): Promise<void> {
try {
@@ -14,6 +14,23 @@ async function expectPathMissing(targetPath: string): Promise<void> {
}
describe("openclaw test instance", () => {
it("keeps only bounded child output tails in helper logs", () => {
const stdout = testing.createBoundedStringLog();
const stderr = testing.createBoundedStringLog();
testing.appendLogChunk(stdout, `old stdout ${"x".repeat(64)}\n`, 32);
testing.appendLogChunk(stdout, "recent stdout\n", 32);
testing.appendLogChunk(stderr, `old stderr ${"y".repeat(64)}\n`, 32);
testing.appendLogChunk(stderr, "recent stderr\n", 32);
const logs = testing.formatLogs(stdout, stderr);
expect(logs).toContain("[output truncated to last");
expect(logs).toContain("recent stdout");
expect(logs).toContain("recent stderr");
expect(logs).not.toContain("old stdout");
expect(logs).not.toContain("old stderr");
});
it("creates isolated config and spawn env without mutating process env", async () => {
const previousHome = process.env.HOME;
const inst = await createOpenClawTestInstance({

View File

@@ -64,8 +64,63 @@ const GATEWAY_START_TIMEOUT_MS = 60_000;
const GATEWAY_STOP_TIMEOUT_MS = 1_500;
const GATEWAY_ENTRYPOINT_PREPARE_TIMEOUT_MS = 120_000;
const COMMAND_TIMEOUT_MS = 30_000;
const LOG_TAIL_MAX_BYTES = 256 * 1024;
const entrypointPromises = new Map<string, Promise<string[]>>();
type BoundedStringLog = string[] & {
byteLength?: number;
truncated?: boolean;
};
function createBoundedStringLog(): string[] {
const log = [] as BoundedStringLog;
log.byteLength = 0;
log.truncated = false;
return log;
}
function appendLogChunk(log: string[], chunk: unknown, maxBytes = LOG_TAIL_MAX_BYTES): void {
const chunks = log as BoundedStringLog;
const limit = Math.max(1, maxBytes);
const text = String(chunk);
const textBytes = Buffer.byteLength(text);
if (textBytes >= limit) {
const buffer = Buffer.from(text);
const tail = buffer.subarray(buffer.length - limit).toString("utf8");
chunks.splice(0, chunks.length, tail);
chunks.byteLength = Buffer.byteLength(tail);
chunks.truncated = true;
return;
}
chunks.push(text);
chunks.byteLength = (chunks.byteLength ?? 0) + textBytes;
while ((chunks.byteLength ?? 0) > limit && chunks.length > 0) {
const first = chunks[0] ?? "";
const firstBytes = Buffer.byteLength(first);
const overflow = (chunks.byteLength ?? 0) - limit;
if (firstBytes <= overflow) {
chunks.shift();
chunks.byteLength = (chunks.byteLength ?? 0) - firstBytes;
chunks.truncated = true;
continue;
}
const buffer = Buffer.from(first);
const tail = buffer.subarray(overflow).toString("utf8");
chunks[0] = tail;
chunks.byteLength = chunks.reduce((total, entry) => total + Buffer.byteLength(entry), 0);
chunks.truncated = true;
}
}
function readLogBuffer(log: string[]): string {
const text = log.join("");
return (log as BoundedStringLog).truncated
? `[output truncated to last ${LOG_TAIL_MAX_BYTES} bytes]\n${text}`
: text;
}
async function resolveBuiltGatewayEntrypoint(cwd: string): Promise<string[] | null> {
const buildStampPath = path.join(cwd, "dist", BUILD_STAMP_FILE);
const runtimePostBuildStampPath = path.join(cwd, "dist", RUNTIME_POSTBUILD_STAMP_FILE);
@@ -90,8 +145,8 @@ async function prepareGatewayEntrypoint(cwd: string): Promise<string[]> {
return builtEntrypoint;
}
const stdout: string[] = [];
const stderr: string[] = [];
const stdout = createBoundedStringLog();
const stderr = createBoundedStringLog();
const child = spawn("node", ["scripts/run-node.mjs", "--help"], {
cwd,
env: { ...process.env, VITEST: "1" },
@@ -99,8 +154,8 @@ async function prepareGatewayEntrypoint(cwd: string): Promise<string[]> {
});
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)));
child.stdout?.on("data", (d) => appendLogChunk(stdout, d));
child.stderr?.on("data", (d) => appendLogChunk(stderr, d));
const completed = await Promise.race([
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
@@ -112,15 +167,13 @@ async function prepareGatewayEntrypoint(cwd: string): Promise<string[]> {
if (completed === null) {
child.kill("SIGKILL");
throw new Error(
`timeout preparing gateway entrypoint\n--- stdout ---\n${stdout.join("")}\n--- stderr ---\n${stderr.join("")}`,
);
throw new Error(`timeout preparing gateway entrypoint\n${formatLogs(stdout, stderr)}`);
}
if (completed.code !== 0) {
throw new Error(
`failed preparing gateway entrypoint (code=${String(completed.code)} signal=${String(
completed.signal,
)})\n--- stdout ---\n${stdout.join("")}\n--- stderr ---\n${stderr.join("")}`,
)})\n${formatLogs(stdout, stderr)}`,
);
}
@@ -224,7 +277,7 @@ function mergeConfig(
}
function formatLogs(stdout: string[], stderr: string[]): string {
return `--- stdout ---\n${stdout.join("")}\n--- stderr ---\n${stderr.join("")}`;
return `--- stdout ---\n${readLogBuffer(stdout)}\n--- stderr ---\n${readLogBuffer(stderr)}`;
}
function createInstanceEnv(params: {
@@ -282,8 +335,8 @@ export async function createOpenClawTestInstance(
),
);
const stdout: string[] = [];
const stderr: string[] = [];
const stdout = createBoundedStringLog();
const stderr = createBoundedStringLog();
const env = createInstanceEnv({
stateEnv: state.env,
extraEnv: options.env ?? {},
@@ -343,8 +396,8 @@ export async function createOpenClawTestInstance(
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)));
child.stdout?.on("data", (d) => appendLogChunk(stdout, d));
child.stderr?.on("data", (d) => appendLogChunk(stderr, d));
try {
await waitForPortOpen(
@@ -410,8 +463,8 @@ async function runCommand(params: {
if (!command) {
throw new Error("missing command");
}
const stdout: string[] = [];
const stderr: string[] = [];
const stdout = createBoundedStringLog();
const stderr = createBoundedStringLog();
const child = spawn(command, args, {
cwd: params.cwd,
env: params.env,
@@ -419,8 +472,8 @@ async function runCommand(params: {
});
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)));
child.stdout?.on("data", (d) => appendLogChunk(stdout, d));
child.stderr?.on("data", (d) => appendLogChunk(stderr, d));
const completed = await Promise.race([
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
@@ -438,7 +491,13 @@ async function runCommand(params: {
}
return {
...completed,
stdout: stdout.join(""),
stderr: stderr.join(""),
stdout: readLogBuffer(stdout),
stderr: readLogBuffer(stderr),
};
}
export const testing = {
appendLogChunk,
createBoundedStringLog,
formatLogs,
};