// Plugin state store E2E tests cover persisted plugin state across runtime calls. import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createPluginStateKeyedStore, createPluginStateSyncKeyedStore, resetPluginStateStoreForTests, sweepExpiredPluginStateEntries, } from "./plugin-state-store.js"; import { closePluginStateDatabase } from "./plugin-state-store.sqlite.js"; import { probePluginStateStore, seedPluginStateEntriesForTests, } from "./plugin-state-store.test-helpers.js"; afterEach(() => { vi.useRealTimers(); resetPluginStateStoreForTests(); }); // --------------------------------------------------------------------------- // Runtime smoke // --------------------------------------------------------------------------- describe("runtime smoke", () => { it("writes and reads a value", async () => { await withOpenClawTestState({ label: "e2e-smoke-rw" }, async () => { const store = createPluginStateKeyedStore<{ msg: string }>("fixture-plugin", { namespace: "data", maxEntries: 10, }); await store.register("greeting", { msg: "hello" }); await expect(store.lookup("greeting")).resolves.toEqual({ msg: "hello" }); }); }); it("consumes a value exactly once", async () => { await withOpenClawTestState({ label: "e2e-smoke-consume" }, async () => { const store = createPluginStateKeyedStore<{ token: string }>("fixture-plugin", { namespace: "tokens", maxEntries: 10, }); await store.register("one-shot", { token: "abc123" }); const first = await store.consume("one-shot"); expect(first).toEqual({ token: "abc123" }); const second = await store.consume("one-shot"); expect(second).toBeUndefined(); await expect(store.lookup("one-shot")).resolves.toBeUndefined(); }); }); }); // --------------------------------------------------------------------------- // Persistence // --------------------------------------------------------------------------- describe("persistence", () => { it("survives close and reopen of the store", async () => { await withOpenClawTestState({ label: "e2e-persist" }, async () => { const storeA = createPluginStateKeyedStore<{ persisted: boolean }>("fixture-plugin", { namespace: "durable", maxEntries: 10, }); await storeA.register("key1", { persisted: true }); await storeA.register("key2", { persisted: true }); // Tear down the cached DB handle and option signatures – simulates // a full gateway restart while the on-disk DB survives. resetPluginStateStoreForTests(); const storeB = createPluginStateKeyedStore<{ persisted: boolean }>("fixture-plugin", { namespace: "durable", maxEntries: 10, }); await expect(storeB.lookup("key1")).resolves.toEqual({ persisted: true }); await expect(storeB.lookup("key2")).resolves.toEqual({ persisted: true }); }); }); }); // --------------------------------------------------------------------------- // TTL // --------------------------------------------------------------------------- describe("TTL", () => { it("hides expired values and sweep removes the row", async () => { await withOpenClawTestState({ label: "e2e-ttl" }, async () => { const store = createPluginStateKeyedStore<{ v: number }>("fixture-plugin", { namespace: "ttl-test", maxEntries: 10, }); await store.register("short", { v: 1 }, { ttlMs: 24 * 60 * 60_000 }); await store.register("long", { v: 2 }, { ttlMs: 48 * 60 * 60_000 }); // Before expiry – both visible. await expect(store.lookup("short")).resolves.toEqual({ v: 1 }); await expect(store.lookup("long")).resolves.toEqual({ v: 2 }); seedPluginStateEntriesForTests([ { pluginId: "fixture-plugin", namespace: "ttl-test", key: "short", value: { v: 1 }, expiresAt: Date.now() - 1, }, ]); // Expired value is invisible to reads. await expect(store.lookup("short")).resolves.toBeUndefined(); await expect(store.lookup("long")).resolves.toEqual({ v: 2 }); // Sweep physically removes the expired row. const swept = sweepExpiredPluginStateEntries(); expect(swept).toBe(1); // After sweep the entry list contains only the long-lived record. const remaining = await store.entries(); expect(remaining).toHaveLength(1); expect(expectDefined(remaining[0], "remaining[0] test invariant").key).toBe("long"); }); }); }); // --------------------------------------------------------------------------- // Isolation // --------------------------------------------------------------------------- describe("isolation", () => { it("segregates plugins sharing namespace and key", async () => { await withOpenClawTestState({ label: "e2e-isolation" }, async () => { const pluginA = createPluginStateKeyedStore<{ owner: string }>("plugin-a", { namespace: "x", maxEntries: 10, }); const pluginB = createPluginStateKeyedStore<{ owner: string }>("plugin-b", { namespace: "x", maxEntries: 10, }); await pluginA.register("same", { owner: "a" }); await pluginB.register("same", { owner: "b" }); await expect(pluginA.lookup("same")).resolves.toEqual({ owner: "a" }); await expect(pluginB.lookup("same")).resolves.toEqual({ owner: "b" }); // Clearing one plugin's namespace does not affect the other. await pluginA.clear(); await expect(pluginA.lookup("same")).resolves.toBeUndefined(); await expect(pluginB.lookup("same")).resolves.toEqual({ owner: "b" }); }); }); }); // --------------------------------------------------------------------------- // Limits // --------------------------------------------------------------------------- describe("limits", () => { it.each(["async", "sync"])("enforces the 1 MiB boundary across %s writes", async (mode) => { await withOpenClawTestState({ label: "e2e-limit" }, async () => { const createStore = mode === "async" ? createPluginStateKeyedStore : createPluginStateSyncKeyedStore; const store = createStore("fixture-plugin", { namespace: "size", maxEntries: 10, }); // JSON.stringify wraps a string in quotes (+2 bytes). const boundary = "x".repeat(1_048_574); const oversize = `${boundary}x`; const update = expectDefined(store.update, "keyed store update support"); await store.register("registered", boundary); expect(await store.registerIfAbsent("claimed", boundary)).toBe(true); await store.register("updated", "before"); expect(await update("updated", () => boundary)).toBe(true); for (const write of [ () => store.register("registered", oversize), () => store.registerIfAbsent("rejected", oversize), () => update("updated", () => oversize), ]) { await expect(async () => { await write(); }).rejects.toMatchObject({ code: "PLUGIN_STATE_LIMIT_EXCEEDED", }); } resetPluginStateStoreForTests(); for (const key of ["registered", "claimed", "updated"]) { expect(await store.lookup(key)).toBe(boundary); } expect(await store.lookup("rejected")).toBeUndefined(); }); }); }); // --------------------------------------------------------------------------- // Failure safety // --------------------------------------------------------------------------- describe("failure safety", () => { it("probe returns redacted diagnostics without leaking stored values", async () => { await withOpenClawTestState({ label: "e2e-fail-probe" }, async () => { const result = probePluginStateStore(); expect(result.ok).toBe(true); expect(result.databasePath).toContain("openclaw.sqlite"); expect(result.steps.length).toBeGreaterThanOrEqual(4); const failedSteps = result.steps.filter((step) => !step.ok); expect(failedSteps).toEqual([]); // The probe's temporary stored value must not leak into the result. const serialised = JSON.stringify(result); expect(serialised).not.toContain("probe-value"); }); }); it("close and reopen cycle is clean", async () => { await withOpenClawTestState({ label: "e2e-fail-reopen" }, async () => { const store = createPluginStateKeyedStore<{ v: number }>("fixture-plugin", { namespace: "reopen", maxEntries: 10, }); await store.register("k", { v: 1 }); // First close. closePluginStateDatabase(); await expect(store.lookup("k")).resolves.toEqual({ v: 1 }); // Second close (idempotent). closePluginStateDatabase(); await expect(store.lookup("k")).resolves.toEqual({ v: 1 }); // Write after reopen. await store.register("k", { v: 2 }); await expect(store.lookup("k")).resolves.toEqual({ v: 2 }); }); }); });