diff --git a/src/config/sessions/store.pruning.e2e.test.ts b/src/config/sessions/store.pruning.e2e.test.ts new file mode 100644 index 00000000000..dfa73f39c84 --- /dev/null +++ b/src/config/sessions/store.pruning.e2e.test.ts @@ -0,0 +1,260 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "./types.js"; +import { clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } from "./store.js"; + +// Keep integration tests deterministic: never read a real openclaw.json. +vi.mock("../config.js", () => ({ + loadConfig: vi.fn().mockReturnValue({}), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; + +let fixtureRoot = ""; +let fixtureCount = 0; + +function makeEntry(updatedAt: number): SessionEntry { + return { sessionId: crypto.randomUUID(), updatedAt }; +} + +async function createCaseDir(prefix: string): Promise { + const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); + await fs.mkdir(dir, { recursive: true }); + return dir; +} + +describe("Integration: saveSessionStore with pruning", () => { + let testDir: string; + let storePath: string; + let savedCacheTtl: string | undefined; + let mockLoadConfig: ReturnType; + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pruning-integ-")); + }); + + afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + + beforeEach(async () => { + testDir = await createCaseDir("pruning-integ"); + storePath = path.join(testDir, "sessions.json"); + savedCacheTtl = process.env.OPENCLAW_SESSION_CACHE_TTL_MS; + process.env.OPENCLAW_SESSION_CACHE_TTL_MS = "0"; + clearSessionStoreCacheForTest(); + + const configModule = await import("../config.js"); + mockLoadConfig = configModule.loadConfig as ReturnType; + }); + + afterEach(() => { + vi.restoreAllMocks(); + clearSessionStoreCacheForTest(); + if (savedCacheTtl === undefined) { + delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS; + } else { + process.env.OPENCLAW_SESSION_CACHE_TTL_MS = savedCacheTtl; + } + }); + + it("saveSessionStore prunes stale entries on write", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "7d", + maxEntries: 500, + rotateBytes: 10_485_760, + }, + }, + }); + + const now = Date.now(); + const store: Record = { + stale: makeEntry(now - 30 * DAY_MS), + fresh: makeEntry(now), + }; + + await saveSessionStore(storePath, store); + + const loaded = loadSessionStore(storePath); + expect(loaded.stale).toBeUndefined(); + expect(loaded.fresh).toBeDefined(); + }); + + it("saveSessionStore caps entries over limit", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "30d", + maxEntries: 5, + rotateBytes: 10_485_760, + }, + }, + }); + + const now = Date.now(); + const store: Record = {}; + for (let i = 0; i < 10; i++) { + store[`key-${i}`] = makeEntry(now - i * 1000); + } + + await saveSessionStore(storePath, store); + + const loaded = loadSessionStore(storePath); + expect(Object.keys(loaded)).toHaveLength(5); + for (let i = 0; i < 5; i++) { + expect(loaded[`key-${i}`]).toBeDefined(); + } + for (let i = 5; i < 10; i++) { + expect(loaded[`key-${i}`]).toBeUndefined(); + } + }); + + it("saveSessionStore rotates file when over size limit and creates .bak", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "30d", + maxEntries: 500, + rotateBytes: "100b", + }, + }, + }); + + const now = Date.now(); + const largeStore: Record = {}; + for (let i = 0; i < 50; i++) { + largeStore[`agent:main:session-${crypto.randomUUID()}`] = makeEntry(now - i * 1000); + } + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(largeStore, null, 2), "utf-8"); + + const statBefore = await fs.stat(storePath); + expect(statBefore.size).toBeGreaterThan(100); + + const smallStore: Record = { + only: makeEntry(now), + }; + await saveSessionStore(storePath, smallStore); + + const files = await fs.readdir(testDir); + const bakFiles = files.filter((f) => f.startsWith("sessions.json.bak.")); + expect(bakFiles.length).toBeGreaterThanOrEqual(1); + + const loaded = loadSessionStore(storePath); + expect(loaded.only).toBeDefined(); + }); + + it("saveSessionStore applies both pruning and capping together", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "10d", + maxEntries: 3, + rotateBytes: 10_485_760, + }, + }, + }); + + const now = Date.now(); + const store: Record = { + stale1: makeEntry(now - 15 * DAY_MS), + stale2: makeEntry(now - 20 * DAY_MS), + fresh1: makeEntry(now), + fresh2: makeEntry(now - 1 * DAY_MS), + fresh3: makeEntry(now - 2 * DAY_MS), + fresh4: makeEntry(now - 5 * DAY_MS), + }; + + await saveSessionStore(storePath, store); + + const loaded = loadSessionStore(storePath); + expect(loaded.stale1).toBeUndefined(); + expect(loaded.stale2).toBeUndefined(); + expect(Object.keys(loaded).length).toBeLessThanOrEqual(3); + expect(loaded.fresh1).toBeDefined(); + expect(loaded.fresh2).toBeDefined(); + expect(loaded.fresh3).toBeDefined(); + expect(loaded.fresh4).toBeUndefined(); + }); + + it("saveSessionStore skips enforcement when maintenance mode is warn", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "warn", + pruneAfter: "7d", + maxEntries: 1, + rotateBytes: 10_485_760, + }, + }, + }); + + const now = Date.now(); + const store: Record = { + stale: makeEntry(now - 30 * DAY_MS), + fresh: makeEntry(now), + }; + + await saveSessionStore(storePath, store); + + const loaded = loadSessionStore(storePath); + expect(loaded.stale).toBeDefined(); + expect(loaded.fresh).toBeDefined(); + expect(Object.keys(loaded)).toHaveLength(2); + }); + + it("resolveMaintenanceConfig reads from loadConfig().session.maintenance", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { pruneAfter: "7d", maxEntries: 100, rotateBytes: "5mb" }, + }, + }); + + const { resolveMaintenanceConfig } = await import("./store.js"); + const config = resolveMaintenanceConfig(); + + expect(config).toEqual({ + mode: "warn", + pruneAfterMs: 7 * DAY_MS, + maxEntries: 100, + rotateBytes: 5 * 1024 * 1024, + }); + }); + + it("resolveMaintenanceConfig uses defaults for missing fields", async () => { + mockLoadConfig.mockReturnValue({ session: { maintenance: { pruneAfter: "14d" } } }); + + const { resolveMaintenanceConfig } = await import("./store.js"); + const config = resolveMaintenanceConfig(); + + expect(config).toEqual({ + mode: "warn", + pruneAfterMs: 14 * DAY_MS, + maxEntries: 500, + rotateBytes: 10_485_760, + }); + }); + + it("resolveMaintenanceConfig falls back to deprecated pruneDays", async () => { + mockLoadConfig.mockReturnValue({ session: { maintenance: { pruneDays: 2 } } }); + + const { resolveMaintenanceConfig } = await import("./store.js"); + const config = resolveMaintenanceConfig(); + + expect(config).toEqual({ + mode: "warn", + pruneAfterMs: 2 * DAY_MS, + maxEntries: 500, + rotateBytes: 10_485_760, + }); + }); +}); diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index 973763b55c5..2e4a2b78f0a 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -2,23 +2,9 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "./types.js"; -import { - capEntryCount, - clearSessionStoreCacheForTest, - loadSessionStore, - pruneStaleEntries, - rotateSessionFile, - saveSessionStore, -} from "./store.js"; - -// Mock loadConfig so resolveMaintenanceConfig() never reads a real openclaw.json. -// Unit tests always pass explicit overrides so this mock is inert for them. -// Integration tests set return values to control the config. -vi.mock("../config.js", () => ({ - loadConfig: vi.fn().mockReturnValue({}), -})); +import { capEntryCount, pruneStaleEntries, rotateSessionFile } from "./store.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -346,234 +332,3 @@ describe("rotateSessionFile", () => { expect(timestamp).toBeLessThanOrEqual(after); }); }); - -// --------------------------------------------------------------------------- -// Integration tests — exercise saveSessionStore end-to-end. -// The file-level vi.mock("../config.js") stubs loadConfig; per-test -// mockReturnValue controls what resolveMaintenanceConfig() returns. -// --------------------------------------------------------------------------- - -describe("Integration: saveSessionStore with pruning", () => { - let testDir: string; - let storePath: string; - let savedCacheTtl: string | undefined; - let mockLoadConfig: ReturnType; - - beforeEach(async () => { - testDir = await createCaseDir("pruning-integ"); - storePath = path.join(testDir, "sessions.json"); - savedCacheTtl = process.env.OPENCLAW_SESSION_CACHE_TTL_MS; - process.env.OPENCLAW_SESSION_CACHE_TTL_MS = "0"; - clearSessionStoreCacheForTest(); - - const configModule = await import("../config.js"); - mockLoadConfig = configModule.loadConfig as ReturnType; - }); - - afterEach(async () => { - vi.restoreAllMocks(); - clearSessionStoreCacheForTest(); - if (savedCacheTtl === undefined) { - delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS; - } else { - process.env.OPENCLAW_SESSION_CACHE_TTL_MS = savedCacheTtl; - } - }); - - it("saveSessionStore prunes stale entries on write", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "enforce", - pruneAfter: "7d", - maxEntries: 500, - rotateBytes: 10_485_760, - }, - }, - }); - - const now = Date.now(); - const store: Record = { - stale: makeEntry(now - 30 * DAY_MS), - fresh: makeEntry(now), - }; - - await saveSessionStore(storePath, store); - - const loaded = loadSessionStore(storePath); - expect(loaded.stale).toBeUndefined(); - expect(loaded.fresh).toBeDefined(); - }); - - it("saveSessionStore caps entries over limit", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "enforce", - pruneAfter: "30d", - maxEntries: 5, - rotateBytes: 10_485_760, - }, - }, - }); - - const now = Date.now(); - const store: Record = {}; - for (let i = 0; i < 10; i++) { - store[`key-${i}`] = makeEntry(now - i * 1000); - } - - await saveSessionStore(storePath, store); - - const loaded = loadSessionStore(storePath); - expect(Object.keys(loaded)).toHaveLength(5); - for (let i = 0; i < 5; i++) { - expect(loaded[`key-${i}`]).toBeDefined(); - } - for (let i = 5; i < 10; i++) { - expect(loaded[`key-${i}`]).toBeUndefined(); - } - }); - - it("saveSessionStore rotates file when over size limit and creates .bak", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "enforce", - pruneAfter: "30d", - maxEntries: 500, - rotateBytes: "100b", - }, - }, - }); - - const now = Date.now(); - const largeStore: Record = {}; - for (let i = 0; i < 50; i++) { - largeStore[`agent:main:session-${crypto.randomUUID()}`] = makeEntry(now - i * 1000); - } - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(largeStore, null, 2), "utf-8"); - - const statBefore = await fs.stat(storePath); - expect(statBefore.size).toBeGreaterThan(100); - - const smallStore: Record = { - only: makeEntry(now), - }; - await saveSessionStore(storePath, smallStore); - - const files = await fs.readdir(testDir); - const bakFiles = files.filter((f) => f.startsWith("sessions.json.bak.")); - expect(bakFiles.length).toBeGreaterThanOrEqual(1); - - const loaded = loadSessionStore(storePath); - expect(loaded.only).toBeDefined(); - }); - - it("saveSessionStore applies both pruning and capping together", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "enforce", - pruneAfter: "10d", - maxEntries: 3, - rotateBytes: 10_485_760, - }, - }, - }); - - const now = Date.now(); - const store: Record = { - stale1: makeEntry(now - 15 * DAY_MS), - stale2: makeEntry(now - 20 * DAY_MS), - fresh1: makeEntry(now), - fresh2: makeEntry(now - 1 * DAY_MS), - fresh3: makeEntry(now - 2 * DAY_MS), - fresh4: makeEntry(now - 5 * DAY_MS), - }; - - await saveSessionStore(storePath, store); - - const loaded = loadSessionStore(storePath); - expect(loaded.stale1).toBeUndefined(); - expect(loaded.stale2).toBeUndefined(); - expect(Object.keys(loaded).length).toBeLessThanOrEqual(3); - expect(loaded.fresh1).toBeDefined(); - expect(loaded.fresh2).toBeDefined(); - expect(loaded.fresh3).toBeDefined(); - expect(loaded.fresh4).toBeUndefined(); - }); - - it("saveSessionStore skips enforcement when maintenance mode is warn", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { - mode: "warn", - pruneAfter: "7d", - maxEntries: 1, - rotateBytes: 10_485_760, - }, - }, - }); - - const now = Date.now(); - const store: Record = { - stale: makeEntry(now - 30 * DAY_MS), - fresh: makeEntry(now), - }; - - await saveSessionStore(storePath, store); - - const loaded = loadSessionStore(storePath); - expect(loaded.stale).toBeDefined(); - expect(loaded.fresh).toBeDefined(); - expect(Object.keys(loaded)).toHaveLength(2); - }); - - it("resolveMaintenanceConfig reads from loadConfig().session.maintenance", async () => { - mockLoadConfig.mockReturnValue({ - session: { - maintenance: { pruneAfter: "7d", maxEntries: 100, rotateBytes: "5mb" }, - }, - }); - - const { resolveMaintenanceConfig } = await import("./store.js"); - const config = resolveMaintenanceConfig(); - - expect(config).toEqual({ - mode: "warn", - pruneAfterMs: 7 * DAY_MS, - maxEntries: 100, - rotateBytes: 5 * 1024 * 1024, - }); - }); - - it("resolveMaintenanceConfig uses defaults for missing fields", async () => { - mockLoadConfig.mockReturnValue({ session: { maintenance: { pruneAfter: "14d" } } }); - - const { resolveMaintenanceConfig } = await import("./store.js"); - const config = resolveMaintenanceConfig(); - - expect(config).toEqual({ - mode: "warn", - pruneAfterMs: 14 * DAY_MS, - maxEntries: 500, - rotateBytes: 10_485_760, - }); - }); - - it("resolveMaintenanceConfig falls back to deprecated pruneDays", async () => { - mockLoadConfig.mockReturnValue({ session: { maintenance: { pruneDays: 2 } } }); - - const { resolveMaintenanceConfig } = await import("./store.js"); - const config = resolveMaintenanceConfig(); - - expect(config).toEqual({ - mode: "warn", - pruneAfterMs: 2 * DAY_MS, - maxEntries: 500, - rotateBytes: 10_485_760, - }); - }); -});