Files
paseo/packages/app/src/plugins/registry-lifecycle.test.ts
T
Mohamed Boudra 42806cdbf9 Let plugins play audio on every client (#5976)
* feat(plugins): play audio files on every client

* fix(audio): preserve voice output and isolate waiting cues

* fix(plugins): normalize audio MIME parameters

* refactor(audio): share PCM conversion and keep plugin playback file-only
2026-10-03 16:56:59 +01:00

148 lines
5.2 KiB
TypeScript

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() {
const setupClients = new Map<string, PaseoApi>();
const plugins = new PluginRegistry({
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,
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([]);
});