mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
Keep client plugin installations and local UI state alive through transient host disconnects. Reconcile changed bundles and removals on reconnect and refresh settings changed while offline. Connection switching still replaces the client and reinstalls plugins. Preserves the implementation from #6731, adds a failing-before/passing-after real browser regression, and corrects the English and Chinese lifecycle documentation. Verified with focused tests, browser reconnect journeys, Android debug offline interaction, root checks and green current-head CI. Co-authored-by: luke <luopeihuan@gmail.com> Co-authored-by: RoundRedDot <roundreddot@gmail.com>
257 lines
9.9 KiB
TypeScript
257 lines
9.9 KiB
TypeScript
import appPackage from "../../package.json";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import { pluginRegistry as registry } from "./registry";
|
|
|
|
const audio = { play: async () => 0 };
|
|
|
|
vi.mock("./navigation", () => ({
|
|
createPluginNavigation: () => ({}),
|
|
}));
|
|
vi.mock("./client-runtime", () => ({
|
|
createPluginClientRuntime: () => ({
|
|
paseo: { dispose: async () => {} },
|
|
rpc: async () => undefined,
|
|
openSurface: () => undefined,
|
|
openPanel: () => undefined,
|
|
addComposerPill: () => ({ update() {}, remove() {} }),
|
|
addHeaderButton: () => ({ update() {}, remove() {} }),
|
|
}),
|
|
}));
|
|
|
|
const daemonClient = {} as DaemonClient;
|
|
const pluginRegistry = {
|
|
getSnapshot: registry.getSnapshot,
|
|
subscribe: registry.subscribe,
|
|
removeHost: registry.removeHost.bind(registry),
|
|
installCatalog(
|
|
serverId: string,
|
|
catalog: Parameters<typeof registry.installCatalog>[1],
|
|
options: { replacePluginId?: string; client?: DaemonClient } = {},
|
|
) {
|
|
return registry.installCatalog(
|
|
serverId,
|
|
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
|
|
{ client: daemonClient, ...options, audio },
|
|
);
|
|
},
|
|
};
|
|
|
|
function bundle(marker: string): string {
|
|
return `(function() {
|
|
const module = { exports: {} };
|
|
module.exports.default = function(plugin) {
|
|
function Surface() { return ${JSON.stringify(marker)}; }
|
|
plugin.addSurface("main", Surface);
|
|
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" });
|
|
plugin.addWorkspacePanel({ id: "review", title: "Review", icon: "Blocks", context: "workspace", Component: Surface });
|
|
plugin.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Blocks", context: "workspace", onSelect() {} });
|
|
return function() { globalThis.__pluginCleanups = (globalThis.__pluginCleanups || 0) + 1; };
|
|
};
|
|
return module.exports;
|
|
})`;
|
|
}
|
|
|
|
function timelineBundle(marker: string): string {
|
|
return `(function() {
|
|
return { default: function(plugin) {
|
|
plugin.addTimelineTransformer({
|
|
id: "report",
|
|
query: { itemType: "tool_call" },
|
|
transform() { return ${JSON.stringify(marker)} ? { items: [] } : undefined; },
|
|
});
|
|
return function() {};
|
|
} };
|
|
})`;
|
|
}
|
|
|
|
function installedPluginIds(): string[] {
|
|
return pluginRegistry.getSnapshot().map(({ id }) => id);
|
|
}
|
|
|
|
afterEach(() => {
|
|
pluginRegistry.removeHost("host-a");
|
|
pluginRegistry.removeHost("host-b");
|
|
Reflect.deleteProperty(globalThis, "__pluginCleanups");
|
|
});
|
|
|
|
describe("PluginRegistry", () => {
|
|
it("publishes synchronous setup once after storing the installation", () => {
|
|
const snapshots: string[][] = [];
|
|
const unsubscribe = pluginRegistry.subscribe(() => {
|
|
snapshots.push(installedPluginIds());
|
|
});
|
|
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
|
unsubscribe();
|
|
|
|
expect(snapshots).toEqual([["example"]]);
|
|
});
|
|
|
|
it("reports timeline contribution changes", () => {
|
|
const first = timelineBundle("one");
|
|
expect(pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle: first }])).toBe(
|
|
true,
|
|
);
|
|
expect(pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle: first }])).toBe(
|
|
false,
|
|
);
|
|
expect(
|
|
pluginRegistry.installCatalog("host-a", [
|
|
{ id: "reports", clientBundle: timelineBundle("two") },
|
|
]),
|
|
).toBe(true);
|
|
expect(pluginRegistry.installCatalog("host-a", [])).toBe(true);
|
|
});
|
|
|
|
it("preserves a plugin query cache when the same bundle reconnects", () => {
|
|
const clientBundle = bundle("one");
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
|
const first = pluginRegistry.getSnapshot()[0];
|
|
first?.queryClient.setQueryData(["counter"], 3);
|
|
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
|
|
|
const second = pluginRegistry.getSnapshot()[0];
|
|
expect(second).toBe(first);
|
|
expect(second?.queryClient).toBe(first?.queryClient);
|
|
expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
|
|
});
|
|
|
|
it("keeps the snapshot when the same catalog is installed again", () => {
|
|
const clientBundle = timelineBundle("one");
|
|
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
|
const snapshot = pluginRegistry.getSnapshot();
|
|
const transformers = snapshot[0]?.timelineTransformers;
|
|
const listener = vi.fn();
|
|
const unsubscribe = pluginRegistry.subscribe(listener);
|
|
|
|
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
|
unsubscribe();
|
|
|
|
expect(pluginRegistry.getSnapshot()).toBe(snapshot);
|
|
expect(pluginRegistry.getSnapshot()[0]?.timelineTransformers).toBe(transformers);
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reinstalls an unchanged bundle for a new host client", () => {
|
|
const clientBundle = bundle("one");
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
|
const first = pluginRegistry.getSnapshot()[0];
|
|
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], {
|
|
client: {} as DaemonClient,
|
|
});
|
|
|
|
const second = pluginRegistry.getSnapshot()[0];
|
|
expect(second).not.toBe(first);
|
|
expect(first?.lifetime.signal.aborted).toBe(true);
|
|
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
|
});
|
|
|
|
it("replaces the query cache when the plugin bundle changes", () => {
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
|
const first = pluginRegistry.getSnapshot()[0];
|
|
first?.queryClient.setQueryData(["counter"], 3);
|
|
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("two") }]);
|
|
|
|
const second = pluginRegistry.getSnapshot()[0];
|
|
expect(second?.queryClient).not.toBe(first?.queryClient);
|
|
expect(second?.queryClient.getQueryData(["counter"])).toBeUndefined();
|
|
expect(first?.queryClient.getQueryData(["counter"])).toBeUndefined();
|
|
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
|
});
|
|
|
|
it("isolates query caches between hosts", () => {
|
|
const clientBundle = bundle("same");
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
|
pluginRegistry.installCatalog("host-b", [{ id: "example", clientBundle }]);
|
|
const [hostA, hostB] = pluginRegistry.getSnapshot();
|
|
hostA?.queryClient.setQueryData(["counter"], 3);
|
|
|
|
expect(hostB?.queryClient.getQueryData(["counter"])).toBeUndefined();
|
|
});
|
|
|
|
it("cleans up the old installation before evaluating its replacement", () => {
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
|
const replacement = `(function() { return { default: function(plugin) {
|
|
globalThis.__cleanupSeenByReplacement = globalThis.__pluginCleanups || 0;
|
|
plugin.addSurface("main", function() { return null; });
|
|
return function() {};
|
|
} }; })`;
|
|
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: replacement }]);
|
|
|
|
expect(Reflect.get(globalThis, "__cleanupSeenByReplacement")).toBe(1);
|
|
Reflect.deleteProperty(globalThis, "__cleanupSeenByReplacement");
|
|
});
|
|
|
|
it("replaces an unchanged bundle when explicit reload names the plugin", () => {
|
|
const clientBundle = bundle("same");
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
|
const first = pluginRegistry.getSnapshot()[0];
|
|
first?.queryClient.setQueryData(["attachment-search"], "cached");
|
|
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], {
|
|
replacePluginId: "example",
|
|
});
|
|
|
|
const second = pluginRegistry.getSnapshot()[0];
|
|
expect(second).not.toBe(first);
|
|
expect(second?.workspacePanels.map((panel) => panel.id)).toEqual(["review"]);
|
|
expect(second?.commandCenterItems.map((item) => item.id)).toEqual(["open-review"]);
|
|
expect(first?.queryClient.getQueryData(["attachment-search"])).toBeUndefined();
|
|
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
|
});
|
|
|
|
it.each(["disable", "remove"])("cleans a catalog entry exactly once on %s", () => {
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
|
|
|
pluginRegistry.installCatalog("host-a", []);
|
|
pluginRegistry.installCatalog("host-a", []);
|
|
|
|
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
|
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
|
});
|
|
|
|
it("cancels an in-flight attachment query during teardown", async () => {
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
|
const plugin = pluginRegistry.getSnapshot()[0];
|
|
if (!plugin) throw new Error("Expected installed plugin");
|
|
let observeAbort: () => void = () => undefined;
|
|
const aborted = new Promise<void>((resolve) => {
|
|
observeAbort = resolve;
|
|
});
|
|
void plugin.queryClient
|
|
.fetchQuery({
|
|
queryKey: ["plugin-attachment-search", "example"],
|
|
queryFn: ({ signal }) =>
|
|
new Promise<never>((_resolve, reject) => {
|
|
signal.addEventListener("abort", () => {
|
|
observeAbort();
|
|
reject(new Error("aborted"));
|
|
});
|
|
}),
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
pluginRegistry.installCatalog("host-a", []);
|
|
|
|
await aborted;
|
|
expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]);
|
|
});
|
|
|
|
it("continues host teardown when cleanup throws", () => {
|
|
const throwing = `(function() { return { default: function(plugin) {
|
|
plugin.addSurface("main", function() { return null; });
|
|
return function() { throw new Error("cleanup exploded"); };
|
|
} }; })`;
|
|
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: throwing }]);
|
|
pluginRegistry.getSnapshot()[0]?.queryClient.setQueryData(["counter"], 3);
|
|
|
|
expect(() => pluginRegistry.removeHost("host-a")).not.toThrow();
|
|
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
|
});
|
|
});
|