mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 13:01:36 +00:00
* perf(logging): flush file transport asynchronously off the request path * fix(ci): record flushLogger surface growth and drain before log reads - +1 public export/callable budget with annotation: logger file-transport flush for graceful shutdown drains; regenerate SDK API baseline - three tests that read the log file right after logging now drain the async file transport first (flushLogger) instead of racing the flusher * test: drain async log transport before reading talk and request-trace logs * test: drain async log transport in CLI logger file tests * test: drop unused withTempDirSync import
507 lines
16 KiB
TypeScript
507 lines
16 KiB
TypeScript
// Console capture tests cover intercepting and restoring console output.
|
|
import fs from "node:fs";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { setVerbose } from "../global-state.js";
|
|
import { logError, logWarn } from "../logger.js";
|
|
import {
|
|
createSubsystemLogger,
|
|
enableConsoleCapture,
|
|
resetLogger,
|
|
routeLogsToStderr,
|
|
setConsoleTimestampPrefix,
|
|
setLoggerOverride,
|
|
} from "../logging.js";
|
|
import { defaultRuntime } from "../runtime.js";
|
|
import { withEnv } from "../test-utils/env.js";
|
|
import { createSuiteLogPathTracker } from "./log-test-helpers.js";
|
|
import { testApi } from "./logger.js";
|
|
import { loggingState } from "./state.js";
|
|
import {
|
|
captureConsoleSnapshot,
|
|
type ConsoleSnapshot,
|
|
restoreConsoleSnapshot,
|
|
} from "./test-helpers/console-snapshot.js";
|
|
|
|
let snapshot: ConsoleSnapshot;
|
|
const logPathTracker = createSuiteLogPathTracker("openclaw-log-");
|
|
|
|
beforeAll(async () => {
|
|
await logPathTracker.setup();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
snapshot = captureConsoleSnapshot();
|
|
loggingState.consolePatched = false;
|
|
loggingState.forceConsoleToStderr = false;
|
|
loggingState.consoleTimestampPrefix = false;
|
|
loggingState.rawConsole = null;
|
|
setVerbose(false);
|
|
resetLogger();
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreConsoleSnapshot(snapshot);
|
|
loggingState.consolePatched = false;
|
|
loggingState.forceConsoleToStderr = false;
|
|
loggingState.consoleTimestampPrefix = false;
|
|
loggingState.rawConsole = null;
|
|
setVerbose(false);
|
|
resetLogger();
|
|
setLoggerOverride(null);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await logPathTracker.cleanup();
|
|
});
|
|
|
|
function firstMockArgAsString(mock: { mock: { calls: readonly unknown[][] } }): string {
|
|
const [call] = mock.mock.calls;
|
|
if (!call) {
|
|
throw new Error("expected mock call");
|
|
}
|
|
return String(call[0]);
|
|
}
|
|
|
|
describe("enableConsoleCapture", () => {
|
|
const secret = "sk-testsecret1234567890abcd";
|
|
|
|
it("swallows EIO from stderr writes", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
vi.spyOn(process.stderr, "write").mockImplementation(() => {
|
|
throw eioError();
|
|
});
|
|
routeLogsToStderr();
|
|
enableConsoleCapture();
|
|
expect(console.log("hello")).toBeUndefined();
|
|
});
|
|
|
|
it("swallows EIO from original console writes", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
console.log = () => {
|
|
throw eioError();
|
|
};
|
|
enableConsoleCapture();
|
|
expect(console.log("hello")).toBeUndefined();
|
|
});
|
|
|
|
it("prefixes console output with timestamps when enabled", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
const now = new Date("2026-01-17T18:01:02.000Z");
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(now);
|
|
const warn = vi.fn();
|
|
console.warn = warn;
|
|
setConsoleTimestampPrefix(true);
|
|
enableConsoleCapture();
|
|
console.warn("[EventQueue] Slow listener detected");
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
const firstArg = firstMockArgAsString(warn);
|
|
// Timestamp uses local time with timezone offset instead of UTC "Z" suffix
|
|
expect(firstArg).toMatch(
|
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2} \[EventQueue\]/,
|
|
);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not double-prefix timestamps", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
const warn = vi.fn();
|
|
console.warn = warn;
|
|
setConsoleTimestampPrefix(true);
|
|
enableConsoleCapture();
|
|
console.warn("12:34:56 [exec] hello");
|
|
expect(warn).toHaveBeenCalledWith("12:34:56 [exec] hello");
|
|
});
|
|
|
|
it("prefixes JSON console output when timestamp prefix is enabled", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
const log = vi.fn();
|
|
console.log = log;
|
|
setConsoleTimestampPrefix(true);
|
|
enableConsoleCapture();
|
|
const payload = JSON.stringify({ ok: true });
|
|
console.log(payload);
|
|
expect(log).toHaveBeenCalledTimes(1);
|
|
const firstArg = firstMockArgAsString(log);
|
|
expect(firstArg).toMatch(/^(?:\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}T)/);
|
|
expect(firstArg.endsWith(` ${payload}`)).toBe(true);
|
|
});
|
|
|
|
it("wraps console passthrough output when console style is JSON", () => {
|
|
setLoggerOverride({ level: "silent", consoleLevel: "info", consoleStyle: "json" });
|
|
const warn = vi.fn();
|
|
console.warn = warn;
|
|
enableConsoleCapture();
|
|
|
|
console.warn("tool failed", { attempt: 1 });
|
|
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(firstMockArgAsString(warn))).toMatchObject({
|
|
level: "warn",
|
|
message: "tool failed { attempt: 1 }",
|
|
});
|
|
});
|
|
|
|
it("does not rewrap structured subsystem output", () => {
|
|
setLoggerOverride({ level: "info", consoleLevel: "warn", consoleStyle: "json" });
|
|
const warn = vi.fn();
|
|
console.warn = warn;
|
|
enableConsoleCapture();
|
|
|
|
createSubsystemLogger("gateway/auth").warn("authentication retry", { attempt: 2 });
|
|
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(firstMockArgAsString(warn))).toMatchObject({
|
|
level: "warn",
|
|
subsystem: "gateway/auth",
|
|
message: "authentication retry",
|
|
attempt: 2,
|
|
});
|
|
});
|
|
|
|
it("keeps console trace output structured at trace level", () => {
|
|
setLoggerOverride({ level: "info", consoleLevel: "trace", consoleStyle: "json" });
|
|
const error = vi.fn();
|
|
console.error = error;
|
|
enableConsoleCapture();
|
|
|
|
console.trace("trace diagnostic\nsecond line");
|
|
|
|
expect(error).toHaveBeenCalledTimes(1);
|
|
const event = JSON.parse(firstMockArgAsString(error)) as Record<string, unknown>;
|
|
expect(event).toMatchObject({ level: "trace" });
|
|
expect(event).toMatchObject({ message: "trace diagnostic\nsecond line" });
|
|
expect(event.stack).toMatch(/^Trace: trace diagnostic\nsecond line\n/u);
|
|
expect(String(event.stack)).not.toContain("forwardedConsoleCall");
|
|
});
|
|
|
|
it("keeps forced-stderr console trace output structured", () => {
|
|
setLoggerOverride({ level: "info", consoleLevel: "trace", consoleStyle: "json" });
|
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
routeLogsToStderr();
|
|
enableConsoleCapture();
|
|
|
|
console.trace("trace diagnostic");
|
|
|
|
expect(stderrWrite).toHaveBeenCalledTimes(1);
|
|
const event = JSON.parse(firstMockArgAsString(stderrWrite)) as Record<string, unknown>;
|
|
expect(event).toMatchObject({ level: "trace" });
|
|
expect(event).toMatchObject({ message: "trace diagnostic" });
|
|
expect(event.stack).toMatch(/^Trace: trace diagnostic\n/u);
|
|
});
|
|
|
|
it("redacts credentials from structured console trace messages and stacks", () => {
|
|
setLoggerOverride({ level: "info", consoleLevel: "trace", consoleStyle: "json" });
|
|
const error = vi.fn();
|
|
console.error = error;
|
|
enableConsoleCapture();
|
|
|
|
console.trace(`Authorization: Bearer ${secret}`);
|
|
|
|
const written = firstMockArgAsString(error);
|
|
const event = JSON.parse(written) as Record<string, unknown>;
|
|
expect(event).toMatchObject({ level: "trace" });
|
|
expect(written).not.toContain(secret);
|
|
expect(String(event.message)).toContain("Authorization: Bearer");
|
|
expect(String(event.stack)).toContain("Authorization: Bearer");
|
|
});
|
|
|
|
it("redacts multiline patterns before JSON escaping in messages and metadata", () => {
|
|
const configPath = `${tempLogPath()}.json`;
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
logging: {
|
|
redactPatterns: [String.raw`/sensitive-one\nsensitive-two/g`],
|
|
file: "${MISSING_LOG_FILE}",
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
setLoggerOverride({
|
|
level: "silent",
|
|
consoleLevel: "warn",
|
|
consoleStyle: "json",
|
|
});
|
|
const warn = vi.fn();
|
|
console.warn = warn;
|
|
|
|
withEnv({ OPENCLAW_CONFIG_PATH: configPath, MISSING_LOG_FILE: undefined }, () => {
|
|
createSubsystemLogger("sensitive-one\nsensitive-two").warn(
|
|
"prefix sensitive-one\nsensitive-two suffix",
|
|
{
|
|
level: "sensitive-one\nsensitive-two",
|
|
nested: { detail: "sensitive-one\nsensitive-two" },
|
|
},
|
|
);
|
|
});
|
|
|
|
const written = firstMockArgAsString(warn);
|
|
const event = JSON.parse(written) as {
|
|
message: string;
|
|
level: string;
|
|
subsystem: string;
|
|
nested: { detail: string };
|
|
};
|
|
expect(written).not.toContain("sensitive-one");
|
|
expect(event.message).not.toContain("sensitive-two");
|
|
expect(event.level).toBe("warn");
|
|
expect(event.subsystem).not.toContain("sensitive-one");
|
|
expect(event.subsystem).not.toContain("sensitive-two");
|
|
expect(event.nested.detail).not.toContain("sensitive-one");
|
|
expect(event.nested.detail).not.toContain("sensitive-two");
|
|
});
|
|
|
|
it("keeps custom trace redaction while the configured log file is unresolved", () => {
|
|
const configPath = `${tempLogPath()}.json`;
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
logging: {
|
|
redactPatterns: ["/custom-only-secret/g"],
|
|
file: "${MISSING_LOG_FILE}",
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
setLoggerOverride({ level: "silent", consoleLevel: "trace", consoleStyle: "json" });
|
|
const error = vi.fn();
|
|
console.error = error;
|
|
|
|
withEnv({ OPENCLAW_CONFIG_PATH: configPath, MISSING_LOG_FILE: undefined }, () => {
|
|
enableConsoleCapture();
|
|
console.trace("custom-only-secret");
|
|
});
|
|
|
|
const written = firstMockArgAsString(error);
|
|
const event = JSON.parse(written) as { message: string; stack: string };
|
|
expect(written).not.toContain("custom-only-secret");
|
|
expect(event.message).not.toBe("custom-only-secret");
|
|
expect(event.stack).not.toContain("custom-only-secret");
|
|
});
|
|
|
|
it("wraps bracket-prefixed root fallback output when console style is JSON", () => {
|
|
setLoggerOverride({
|
|
level: "info",
|
|
file: tempLogPath(),
|
|
consoleLevel: "error",
|
|
consoleStyle: "json",
|
|
});
|
|
const error = vi.fn();
|
|
console.error = error;
|
|
enableConsoleCapture();
|
|
|
|
logError("[tools] exec failed");
|
|
|
|
expect(error).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(firstMockArgAsString(error))).toMatchObject({
|
|
level: "error",
|
|
message: "[tools] exec failed",
|
|
});
|
|
});
|
|
|
|
it.each(["pretty", "compact"] as const)(
|
|
"keeps %s console passthrough output unchanged",
|
|
(consoleStyle) => {
|
|
setLoggerOverride({
|
|
level: "info",
|
|
file: tempLogPath(),
|
|
consoleLevel: "info",
|
|
consoleStyle,
|
|
});
|
|
const warn = vi.fn();
|
|
console.warn = warn;
|
|
enableConsoleCapture();
|
|
|
|
console.warn("tool failed", { attempt: 1 });
|
|
|
|
expect(warn).toHaveBeenCalledWith("tool failed { attempt: 1 }");
|
|
},
|
|
);
|
|
|
|
it.each(["pretty", "compact"] as const)(
|
|
"keeps %s bracket-prefixed root fallback output unchanged",
|
|
(consoleStyle) => {
|
|
setLoggerOverride({
|
|
level: "info",
|
|
file: tempLogPath(),
|
|
consoleLevel: "error",
|
|
consoleStyle,
|
|
});
|
|
const error = vi.fn();
|
|
console.error = error;
|
|
enableConsoleCapture();
|
|
|
|
logError("[tools] exec failed");
|
|
|
|
expect(error).toHaveBeenCalledWith("[tools] exec failed");
|
|
},
|
|
);
|
|
|
|
it("wraps forced stderr passthrough output when console style is JSON", () => {
|
|
setLoggerOverride({ level: "info", consoleLevel: "error", consoleStyle: "json" });
|
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
routeLogsToStderr();
|
|
enableConsoleCapture();
|
|
|
|
console.error(`Authorization: Bearer ${secret}`);
|
|
|
|
expect(stderrWrite).toHaveBeenCalledTimes(1);
|
|
const written = firstMockArgAsString(stderrWrite);
|
|
expect(JSON.parse(written)).toMatchObject({ level: "error" });
|
|
expect(written).not.toContain(secret);
|
|
});
|
|
|
|
it("keeps JSON diagnostics structured while runtime JSON stays raw", () => {
|
|
setLoggerOverride({
|
|
level: "info",
|
|
file: tempLogPath(),
|
|
consoleLevel: "info",
|
|
consoleStyle: "json",
|
|
});
|
|
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
routeLogsToStderr();
|
|
enableConsoleCapture();
|
|
|
|
console.log("diag");
|
|
defaultRuntime.writeJson({ ok: true });
|
|
|
|
expect(JSON.parse(firstMockArgAsString(stderrWrite))).toMatchObject({
|
|
level: "info",
|
|
message: "diag",
|
|
});
|
|
expect(stdoutWrite).toHaveBeenCalledWith('{\n "ok": true\n}\n');
|
|
});
|
|
|
|
it("routes subsystem-prefixed warnings through one file-log sink", async () => {
|
|
const logPath = tempLogPath();
|
|
setLoggerOverride({ level: "info", file: logPath });
|
|
enableConsoleCapture();
|
|
|
|
logWarn("mcp-loopback: conflicting schema definitions");
|
|
await testApi.flushFileLogQueueForTests();
|
|
|
|
const content = fs.readFileSync(logPath, "utf-8");
|
|
expect(countMatchingLines(content, "conflicting schema definitions")).toBe(1);
|
|
});
|
|
|
|
it("redacts credentials before forwarding console output", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
const log = vi.fn();
|
|
console.log = log;
|
|
enableConsoleCapture();
|
|
|
|
console.log("apiKey:", secret);
|
|
|
|
expect(log).toHaveBeenCalledTimes(1);
|
|
const line = firstMockArgAsString(log);
|
|
expect(line).toContain("apiKey:");
|
|
expect(line).not.toContain(secret);
|
|
});
|
|
|
|
it("redacts credentials before writing forced stderr console output", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
routeLogsToStderr();
|
|
enableConsoleCapture();
|
|
|
|
console.error(`Authorization: Bearer ${secret}`);
|
|
|
|
expect(stderrWrite).toHaveBeenCalledTimes(1);
|
|
const line = firstMockArgAsString(stderrWrite);
|
|
expect(line).toContain("Authorization: Bearer");
|
|
expect(line).not.toContain(secret);
|
|
});
|
|
|
|
it("redacts credentials when timestamp prefixing console output", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
const warn = vi.fn();
|
|
console.warn = warn;
|
|
setConsoleTimestampPrefix(true);
|
|
enableConsoleCapture();
|
|
|
|
console.warn(`token=${secret}`);
|
|
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
const line = firstMockArgAsString(warn);
|
|
expect(line).toMatch(/^(?:\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}T)/);
|
|
expect(line).toContain("token=");
|
|
expect(line).not.toContain(secret);
|
|
});
|
|
|
|
it.each([
|
|
{ name: "stdout", stream: process.stdout },
|
|
{ name: "stderr", stream: process.stderr },
|
|
])("exits on async EPIPE on $name", ({ stream }) => {
|
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as typeof process.exit);
|
|
try {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
loggingState.streamErrorHandlersInstalled = false;
|
|
enableConsoleCapture();
|
|
const epipe = new Error("write EPIPE") as NodeJS.ErrnoException;
|
|
epipe.code = "EPIPE";
|
|
stream.emit("error", epipe);
|
|
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("preserves an existing nonzero exit code on async EPIPE", () => {
|
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as typeof process.exit);
|
|
const originalExitCode = process.exitCode;
|
|
try {
|
|
process.exitCode = 2;
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
loggingState.streamErrorHandlersInstalled = false;
|
|
enableConsoleCapture();
|
|
const epipe = new Error("write EPIPE") as NodeJS.ErrnoException;
|
|
epipe.code = "EPIPE";
|
|
process.stderr.emit("error", epipe);
|
|
expect(exitSpy).toHaveBeenCalledWith(2);
|
|
} finally {
|
|
process.exitCode = originalExitCode;
|
|
exitSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("rethrows non-EPIPE errors on stdout", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
enableConsoleCapture();
|
|
const other = new Error("EACCES") as NodeJS.ErrnoException;
|
|
other.code = "EACCES";
|
|
expect(() => process.stdout.emit("error", other)).toThrow("EACCES");
|
|
});
|
|
|
|
it("suppresses libsignal session dumps even in verbose mode", () => {
|
|
setLoggerOverride({ level: "info", file: tempLogPath() });
|
|
const info = vi.fn();
|
|
console.info = info;
|
|
setVerbose(true);
|
|
enableConsoleCapture();
|
|
|
|
console.info("Closing session:", {
|
|
currentRatchet: { rootKey: Buffer.from("root-key") },
|
|
privKey: "private-key",
|
|
});
|
|
|
|
expect(info).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function tempLogPath() {
|
|
return logPathTracker.nextPath();
|
|
}
|
|
|
|
function countMatchingLines(value: string, needle: string): number {
|
|
return value.split(/\r?\n/u).filter((line) => line.includes(needle)).length;
|
|
}
|
|
|
|
function eioError() {
|
|
const err = new Error("EIO") as NodeJS.ErrnoException;
|
|
err.code = "EIO";
|
|
return err;
|
|
}
|