Files
openclaw/extensions/codex/src/app-server/app-inventory-cache.test.ts

142 lines
4.9 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { CodexAppInventoryCache, buildCodexAppInventoryCacheKey } from "./app-inventory-cache.js";
import type { v2 } from "./protocol.js";
describe("Codex app inventory cache", () => {
it("returns missing while scheduling one coalesced app/list refresh", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const request = vi.fn(async (_method: "app/list", params: v2.AppsListParams) => {
return {
data: [app(params.cursor ? "app-2" : "app-1")],
nextCursor: params.cursor ? null : "next",
} satisfies v2.AppsListResponse;
});
const key = buildCodexAppInventoryCacheKey({ codexHome: "/codex", authProfileId: "work" });
const read = cache.read({ key, request, nowMs: 0 });
expect(read.state).toBe("missing");
expect(read.refreshScheduled).toBe(true);
const snapshot = await cache.refreshNow({ key, request, nowMs: 0 });
expect(snapshot.apps.map((item) => item.id)).toEqual(["app-1", "app-2"]);
expect(request).toHaveBeenCalledTimes(2);
const fresh = cache.read({ key, request, nowMs: 50 });
expect(fresh.state).toBe("fresh");
expect(fresh.refreshScheduled).toBe(false);
expect(fresh.snapshot?.apps.map((item) => item.id)).toEqual(["app-1", "app-2"]);
});
it("uses stale inventory for the current read while refreshing asynchronously", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 10 });
const request = vi.fn(async () => {
return {
data: [app(`app-${request.mock.calls.length}`)],
nextCursor: null,
} satisfies v2.AppsListResponse;
});
const key = "runtime";
await cache.refreshNow({ key, request, nowMs: 0 });
const stale = cache.read({ key, request, nowMs: 11 });
expect(stale.state).toBe("stale");
expect(stale.snapshot?.apps.map((item) => item.id)).toEqual(["app-1"]);
expect(stale.refreshScheduled).toBe(true);
const refreshed = await cache.refreshNow({ key, request, nowMs: 11 });
expect(refreshed.apps.map((item) => item.id)).toEqual(["app-2"]);
});
it("records refresh errors without discarding the last successful snapshot", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 1 });
const key = "runtime";
await cache.refreshNow({
key,
nowMs: 0,
request: async () => ({ data: [app("app-1")], nextCursor: null }),
});
await expect(
cache.refreshNow({
key,
nowMs: 2,
request: async () => {
throw new Error("app list failed");
},
}),
).rejects.toThrow("app list failed");
const read = cache.read({
key,
nowMs: 2,
request: async () => ({ data: [app("app-2")], nextCursor: null }),
});
expect(read.snapshot?.apps.map((item) => item.id)).toEqual(["app-1"]);
expect(read.diagnostic?.message).toBe("app list failed");
});
it("forces a post-install refresh past an older in-flight app/list", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 1_000 });
const key = "runtime";
let resolveStale: ((response: v2.AppsListResponse) => void) | undefined;
let resolveFresh: ((response: v2.AppsListResponse) => void) | undefined;
const request = vi.fn(
async (_method: "app/list", params: v2.AppsListParams): Promise<v2.AppsListResponse> => {
expect(params.forceRefetch).toBe(request.mock.calls.length === 2);
return await new Promise((resolve) => {
if (request.mock.calls.length === 1) {
resolveStale = resolve;
} else {
resolveFresh = resolve;
}
});
},
);
const staleRead = cache.read({ key, request, nowMs: 0 });
expect(staleRead.state).toBe("missing");
expect(staleRead.refreshScheduled).toBe(true);
cache.invalidate(key, "plugin installed", 1);
const forcedRead = cache.read({ key, request, nowMs: 1, forceRefetch: true });
expect(forcedRead.state).toBe("missing");
expect(forcedRead.refreshScheduled).toBe(true);
expect(request).toHaveBeenCalledTimes(2);
const forced = cache.refreshNow({ key, request, nowMs: 1 });
resolveFresh?.({ data: [app("fresh-app")], nextCursor: null });
await expect(forced).resolves.toStrictEqual({
key,
apps: [app("fresh-app")],
fetchedAtMs: 1,
expiresAtMs: 1_001,
revision: 2,
});
resolveStale?.({ data: [app("stale-app")], nextCursor: null });
await Promise.resolve();
const freshRead = cache.read({ key, request, nowMs: 2 });
expect(freshRead.state).toBe("fresh");
expect(freshRead.snapshot?.apps.map((item) => item.id)).toEqual(["fresh-app"]);
});
});
function app(id: string): v2.AppInfo {
return {
id,
name: id,
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: true,
isEnabled: true,
pluginDisplayNames: [],
};
}