Files
openclaw/src/transcripts/store.test.ts
cxbAsDev 6ae4bbafb0 fix(transcripts): handle read stream errors gracefully in TranscriptsStore (#100524)
* fix(transcripts): handle read stream errors gracefully in TranscriptsStore

* proof(transcripts): add real behavior proof script for store stream error catch

* fix(transcripts): reject with Error in stream error handler

* proof(transcripts): replace wrapper with real EISDIR stream error proof

* fix(transcripts): reject non-ENOENT stream errors after stream close
2026-07-06 01:07:32 -07:00

141 lines
5.5 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
// Tests TranscriptsStore stream cleanup and transcript reading behavior.
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
const createReadStreamMock = vi.hoisted(() => vi.fn());
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
createReadStream: (...args: Parameters<typeof actual.createReadStream>) =>
createReadStreamMock(...args) ?? actual.createReadStream(...args),
};
});
import { listOpenFileDescriptorsForPath } from "../../src/infra/open-file-descriptors.test-support.js";
import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js";
import { TranscriptsStore } from "./store.js";
const tempRoots: string[] = [];
describe("TranscriptsStore.readUtterancesFromSessionDir", () => {
afterEach(() => {
cleanupTempDirs(tempRoots);
});
it("returns an empty array when transcript.jsonl is missing", () => {
const tmpDir = makeTempDir(tempRoots, "openclaw-transcript-test-");
const store = new TranscriptsStore(tmpDir);
const sessionDir = path.join(tmpDir, "2026-07-01", "missing");
fs.mkdirSync(sessionDir, { recursive: true });
const result = store.readUtterancesFromSessionDir(sessionDir, { maxUtterances: 10 });
return expect(result).resolves.toEqual([]);
});
it("reads utterances from transcript.jsonl", () => {
const tmpDir = makeTempDir(tempRoots, "openclaw-transcript-test-");
const store = new TranscriptsStore(tmpDir);
const sessionDir = path.join(tmpDir, "2026-07-01", "session-1");
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(
path.join(sessionDir, "transcript.jsonl"),
[
JSON.stringify({ text: "hello", sessionId: "session-1" }),
JSON.stringify({ text: "world", sessionId: "session-1" }),
].join("\n") + "\n",
);
const result = store.readUtterancesFromSessionDir(sessionDir, { maxUtterances: 10 });
return expect(result).resolves.toEqual([
expect.objectContaining({ text: "hello" }),
expect.objectContaining({ text: "world" }),
]);
});
it("keeps only the tail when utterances exceed maxUtterances", () => {
const tmpDir = makeTempDir(tempRoots, "openclaw-transcript-test-");
const store = new TranscriptsStore(tmpDir);
const sessionDir = path.join(tmpDir, "2026-07-01", "session-1");
fs.mkdirSync(sessionDir, { recursive: true });
const lines = Array.from({ length: 5 }, (_, i) =>
JSON.stringify({ text: `line-${i}`, sessionId: "session-1" }),
);
fs.writeFileSync(path.join(sessionDir, "transcript.jsonl"), lines.join("\n") + "\n");
const result = store.readUtterancesFromSessionDir(sessionDir, { maxUtterances: 2 });
return expect(result).resolves.toEqual([
expect.objectContaining({ text: "line-3" }),
expect.objectContaining({ text: "line-4" }),
]);
});
it.runIf(process.platform === "linux")(
"does not leak file descriptors when JSON.parse throws",
async () => {
const tmpDir = makeTempDir(tempRoots, "openclaw-transcript-test-");
const store = new TranscriptsStore(tmpDir);
const sessionDir = path.join(tmpDir, "2026-07-01", "session-1");
fs.mkdirSync(sessionDir, { recursive: true });
const transcriptPath = path.join(sessionDir, "transcript.jsonl");
fs.writeFileSync(transcriptPath, "not valid json\n");
const fdsBefore = listOpenFileDescriptorsForPath(sessionDir);
await expect(
store.readUtterancesFromSessionDir(sessionDir, { maxUtterances: 10 }),
).rejects.toThrow();
const fdsAfter = listOpenFileDescriptorsForPath(sessionDir);
const leaked = fdsAfter.filter((p) => !fdsBefore.includes(p));
expect(leaked).toHaveLength(0);
},
);
it.runIf(process.platform === "linux")(
"does not leak file descriptors in the happy path",
async () => {
const tmpDir = makeTempDir(tempRoots, "openclaw-transcript-test-");
const store = new TranscriptsStore(tmpDir);
const sessionDir = path.join(tmpDir, "2026-07-01", "session-1");
fs.mkdirSync(sessionDir, { recursive: true });
const transcriptPath = path.join(sessionDir, "transcript.jsonl");
fs.writeFileSync(
transcriptPath,
JSON.stringify({ text: "hello", sessionId: "session-1" }) + "\n",
);
const fdsBefore = listOpenFileDescriptorsForPath(sessionDir);
await store.readUtterancesFromSessionDir(sessionDir, { maxUtterances: 10 });
const fdsAfter = listOpenFileDescriptorsForPath(sessionDir);
const leaked = fdsAfter.filter((p) => !fdsBefore.includes(p));
expect(leaked).toHaveLength(0);
},
);
it("rejects non-ENOENT read stream errors", async () => {
const tmpDir = makeTempDir(tempRoots, "openclaw-transcript-test-");
const store = new TranscriptsStore(tmpDir);
const sessionDir = path.join(tmpDir, "2026-07-01", "session-1");
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(path.join(sessionDir, "transcript.jsonl"), "");
createReadStreamMock.mockImplementation(() => {
const stream = new PassThrough();
setTimeout(() => {
stream.write(JSON.stringify({ text: "hello", sessionId: "session-1" }) + "\n");
stream.destroy(new Error("read failed"));
}, 10);
return stream;
});
await expect(
store.readUtterancesFromSessionDir(sessionDir, { maxUtterances: 10 }),
).rejects.toThrow("read failed");
});
});