Files
paseo/packages/app/src/plugins/registry-lifecycle.test.ts
T
8e7e8cce21 fix(app): retain plugin state across host disconnects (#8105)
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>
2026-10-28 15:55:32 +07:00

231 lines
8.3 KiB
TypeScript

import { filePanelTitle } from "@/file-panels/choice/presentation";
import { i18n } from "@/i18n/i18next";
import { createPluginHosts } from "./hosts";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PaseoApi } from "@getpaseo/client";
import { expect, test } from "vitest";
import { PluginRegistry } from "./registry";
const audio = { play: async () => 0 };
/** A host client that records open event observations by their first event name. */
function observingClient() {
const open = new Set<string>();
const client = {
observeEvents(events: string[]) {
const [name] = events;
open.add(name);
return {
ready: Promise.resolve({ subscriptionId: name }),
subscribe: () => () => undefined,
release: async () => {
open.delete(name);
},
};
},
invokePluginRpc: async () => null,
} as unknown as DaemonClient;
return { client, open };
}
function registry(
localeSource: import("@getpaseo/plugin/client/host").PluginLocaleSource = {
getLocale: () => "en",
subscribe: () => () => {},
},
) {
const setupClients = new Map<string, PaseoApi>();
const plugins = new PluginRegistry({
localeSource,
version: "0.8.0",
createRuntime: (installation) => {
setupClients.set(installation.id, installation.paseo);
return {
hosts: createPluginHosts(
{
getHosts: () => [],
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
},
installation.lifetime.signal,
),
paseo: installation.paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected plugin RPC");
},
openScreen: () => {},
openSurface: () => {},
openSettings: () => {},
playAudio: async () => {},
openPanel: () => {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
};
},
});
return { ...observingClient(), plugins, setupClients };
}
/** A plugin whose setup opens an observation named after it and never releases it. */
function catalog(id: string, body = "return function() {};") {
return {
id,
requirements: { paseo: ">=0.8.0" },
clientBundle: `(function() { return { default: function(plugin) { plugin.paseo.observeEvents(["${id}"]); ${body} } }; })`,
};
}
test("setup and every surface share the installation's one Paseo client", () => {
const h = registry();
h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client, audio });
const [installation] = h.plugins.getSnapshot();
expect(h.setupClients.get("deploys")).toBe(installation.paseo);
expect(h.open).toEqual(new Set(["deploys"]));
});
test("teardown ends every subscription the plugin still holds", async () => {
const h = registry();
h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], {
client: h.client,
audio,
});
expect(h.open).toEqual(new Set(["disabled", "kept"]));
// Disabling one plugin removes it from the catalog.
h.plugins.installCatalog("host", [catalog("kept")], { client: h.client, audio });
await expect.poll(() => [...h.open]).toEqual(["kept"]);
h.plugins.removeHost("host");
await expect.poll(() => h.open.size).toBe(0);
});
test("reloading a plugin ends the old installation's subscriptions", async () => {
const h = registry();
h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client, audio });
const [first] = h.plugins.getSnapshot();
h.plugins.installCatalog("host", [catalog("reloaded")], {
client: h.client,
audio,
replacePluginId: "reloaded",
});
const [second] = h.plugins.getSnapshot();
expect(second).not.toBe(first);
expect(second.paseo).not.toBe(first.paseo);
await expect(first.paseo.dispose()).resolves.toBeUndefined();
expect(() => first.paseo.observeEvents(["project.update"])).toThrow("Paseo API is disposed");
expect(h.open).toEqual(new Set(["reloaded"]));
});
test("failed plugin initialization ends the subscriptions it opened", async () => {
const h = registry();
h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], {
client: h.client,
audio,
});
await expect.poll(() => h.open.size).toBe(0);
expect(h.plugins.getSnapshot()).toEqual([]);
});
test("unloading ends the subscriptions even when plugin cleanup throws, preserving another plugin", async () => {
const h = registry();
const failing = catalog("failing", 'return function() { throw new Error("cleanup failed"); };');
const surviving = catalog("surviving", "return function() {};");
h.plugins.installCatalog("host", [failing, surviving], { client: h.client, audio });
h.plugins.installCatalog("host", [surviving], { client: h.client, audio });
await expect.poll(() => [...h.open]).toEqual(["surviving"]);
expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]);
h.plugins.removeHost("host");
await expect.poll(() => h.open.size).toBe(0);
});
test("an invalid async client entry is disposed and its rejected continuation is observed", async () => {
const h = registry();
h.plugins.installCatalog(
"host",
[catalog("async-entry", 'return Promise.reject(new Error("asynchronous setup failed"));')],
{ client: h.client, audio },
);
await expect.poll(() => h.open.size).toBe(0);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.plugins.getSnapshot()).toEqual([]);
});
test("locale snapshots preserve installation state and observations, then release locale subscriptions", async () => {
let locale = "en";
const listeners = new Set<() => void>();
const h = registry({
getLocale: () => locale,
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
});
const translated = {
...catalog("notes"),
clientBundle: `(function(require) {
const {defineTranslations} = require("@getpaseo/plugin/client");
const messages = defineTranslations({resources:{en:{title:"Notes"},"zh-CN":{title:"笔记"}}});
return {default(plugin) {
plugin.paseo.observeEvents(["notes"]);
plugin.addScreen({id:"notes",title:messages.message("title"),Component:function Notes(){return null;}});
plugin.addFilePanel({id:"notes",title:messages.message("title"),icon:"FileText",files:{extensions:["notes"]},Component:function NotesFile(){return null;}});
return function(){};
}};
})`,
};
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
expect(h.plugins.getEvaluationError("host", "notes")).toBeUndefined();
const [before] = h.plugins.getSnapshot();
function fileTitle(plugin: typeof before) {
const contribution = plugin.filePanels[0];
return filePanelTitle(i18n.t, {
id: "plugin:notes/notes",
builtin: false,
files: contribution.files,
plugin,
contribution,
});
}
expect(fileTitle(before)).toBe("Notes");
expect(before.locale).toBe("en");
expect(
before.i18n.resolve(
before.surfaces[0].title as import("@getpaseo/plugin/client").PluginText,
before.locale,
),
).toBe("Notes");
locale = "zh-CN";
for (const listener of listeners) listener();
const [after] = h.plugins.getSnapshot();
expect(after.locale).toBe("zh-CN");
expect(fileTitle(after)).toBe("笔记");
expect(fileTitle(before)).toBe("Notes");
expect(after.filePanels[0].Component).toBe(before.filePanels[0].Component);
expect(
after.i18n.resolve(
after.surfaces[0].title as import("@getpaseo/plugin/client").PluginText,
after.locale,
),
).toBe("笔记");
expect(after.lifetime).toBe(before.lifetime);
expect(after.queryClient).toBe(before.queryClient);
expect(after.paseo).toBe(before.paseo);
expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component);
expect(h.open).toEqual(new Set(["notes"]));
const translatedSnapshot = h.plugins.getSnapshot();
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
expect(h.plugins.getSnapshot()).toBe(translatedSnapshot);
expect(h.plugins.getSnapshot()[0].locale).toBe("zh-CN");
expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n);
h.plugins.removeHost("host");
expect(listeners.size).toBe(0);
await expect.poll(() => h.open.size).toBe(0);
});