Files
hermes-agent/web/src/plugins/usePlugins.test.ts
T
kshitij c0c45ab68a fix(plugins): keep loading gate when cached manifests include a /chat override
The sessionStorage seed set loading=false whenever any cache existed, which
defeats App.tsx's load-bearing pluginsLoading gate: with a cached manifest
that declares tab.override === "/chat", the persistent ChatPage host must
NOT mount before plugins resolve, or it spawns a PTY and gets yanked when
the override plugin takes over the route.

Seed loading=false from the cache only when no cached manifest overrides
/chat (canSeedLoadedFromCache); manifests are still seeded either way so
plugin routes register synchronously on refresh. Adds focused tests for
the gate, including the /chat-override case.
2026-08-03 18:48:04 +05:30

145 lines
4.0 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
getCachedManifests,
cacheManifests,
canSeedLoadedFromCache,
MANIFEST_CACHE_KEY,
} from "./usePlugins";
import type { PluginManifest } from "./types";
function makeStorage(): Storage {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.get(key) ?? null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
get length() {
return store.size;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
} as Storage;
}
const exampleManifest: PluginManifest = {
name: "test",
label: "Test",
description: "A test plugin",
icon: "Puzzle",
version: "1.0.0",
tab: { path: "/test" },
entry: "index.js",
has_api: false,
source: "local",
};
describe("plugin manifest cache helpers", () => {
let storage: Storage;
beforeEach(() => {
storage = makeStorage();
vi.stubGlobal("sessionStorage", storage);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("getCachedManifests returns null when nothing is cached", () => {
expect(getCachedManifests()).toBeNull();
});
it("getCachedManifests returns null for invalid JSON", () => {
storage.setItem(MANIFEST_CACHE_KEY, "not-json");
expect(getCachedManifests()).toBeNull();
});
it("getCachedManifests returns null for non-array JSON", () => {
storage.setItem(MANIFEST_CACHE_KEY, JSON.stringify({ foo: "bar" }));
expect(getCachedManifests()).toBeNull();
});
it("getCachedManifests returns null for scalar JSON", () => {
storage.setItem(MANIFEST_CACHE_KEY, JSON.stringify(42));
expect(getCachedManifests()).toBeNull();
});
it("getCachedManifests returns a valid manifest array", () => {
const list: PluginManifest[] = [exampleManifest];
cacheManifests(list);
expect(getCachedManifests()).toEqual(list);
});
it("cacheManifests overwrites a previous cache on refresh", () => {
const first: PluginManifest[] = [exampleManifest];
cacheManifests(first);
expect(getCachedManifests()).toEqual(first);
const second: PluginManifest[] = [
{ ...exampleManifest, name: "updated", label: "Updated" },
];
cacheManifests(second);
expect(getCachedManifests()).toEqual(second);
});
it("cacheManifests swallows storage errors", () => {
const badStorage = makeStorage();
badStorage.setItem = () => {
throw new Error("QuotaExceededError");
};
vi.stubGlobal("sessionStorage", badStorage);
expect(() => cacheManifests([exampleManifest])).not.toThrow();
});
});
describe("canSeedLoadedFromCache (loading seed gate)", () => {
it("returns false when there is no cache (first visit keeps loading=true)", () => {
expect(canSeedLoadedFromCache(null)).toBe(false);
});
it("returns true for an empty cached list", () => {
expect(canSeedLoadedFromCache([])).toBe(true);
});
it("returns true when no cached manifest overrides /chat", () => {
const list: PluginManifest[] = [
exampleManifest,
{
...exampleManifest,
name: "other",
tab: { path: "/other", override: "/skills" },
},
];
expect(canSeedLoadedFromCache(list)).toBe(true);
});
it("returns false when a cached manifest overrides /chat — loading must stay true so App.tsx's pluginsLoading gate keeps the persistent chat host unmounted", () => {
const list: PluginManifest[] = [
exampleManifest,
{
...exampleManifest,
name: "chat-replacer",
tab: { path: "/chat-alt", override: "/chat" },
},
];
expect(canSeedLoadedFromCache(list)).toBe(false);
});
it("tolerates malformed cached entries missing a tab object", () => {
const malformed = [
{ ...exampleManifest, tab: undefined },
] as unknown as PluginManifest[];
expect(canSeedLoadedFromCache(malformed)).toBe(true);
});
});