test(plugins): add the plain subscription unit test and the browser spec

This commit is contained in:
Mohamed Boudra
2026-11-02 06:12:33 +01:00
parent 1614903862
commit 11556aaf4c
2 changed files with 154 additions and 190 deletions
@@ -0,0 +1,79 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
/** Installs a plugin example, opens a seeded workspace, and runs `exercise` against them. */
async function withExample(
page: Page,
example: { name: string; id: string },
exercise: (workspace: SeededWorkspace) => Promise<void>,
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const copy = await copyPluginExample(example.name);
const workspace = await seedWorkspace({ repoPrefix: `${example.name}-` });
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(copy.directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await exercise(workspace);
} finally {
try {
await client.removePlugin(example.id);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await copy.cleanup();
}
}
}
test.describe("plugin subscriptions", () => {
test.describe.configure({ timeout: 180_000 });
test("a panel follows events the daemon pushes as files change", async ({ page }) => {
await withExample(page, { name: "diff-watch", id: "diff-watch" }, async (workspace) => {
await runCommand(page, "Open live changes");
await expect(page.getByText("No changes", { exact: true })).toBeVisible();
await writeFile(path.join(workspace.repoPath, "pushed.txt"), "from the test\n");
await expect(page.getByText("1 file changed", { exact: true })).toBeVisible();
await expect(page.getByText("pushed.txt", { exact: true })).toBeVisible();
});
});
test("a panel plays streamed bytes and sends bytes back", async ({ page }) => {
await withExample(page, { name: "media-stream", id: "media-stream" }, async () => {
await runCommand(page, "Open media stream");
await expect(page.getByText("live", { exact: true })).toBeVisible();
await expect(page.getByText(/^[1-9]\d* video frames/)).toBeVisible();
await page.getByRole("button", { name: "Send this frame back", exact: true }).click();
await expect(
page.getByText(/^The daemon received [1-9]\d* bytes, sha256 [0-9a-f]{12}$/),
).toBeVisible();
});
});
});
+75 -190
View File
@@ -1,21 +1,12 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { defineRpc, defineSubscription } from "@getpaseo/plugin";
import { useRpc, useSubscription } from "@getpaseo/plugin/client";
import { PluginTranslationRuntime, subscribePluginRpc } from "@getpaseo/plugin/client/host";
import type { PaseoApi } from "@getpaseo/client";
import { defineSubscription } from "@getpaseo/plugin";
import type { PluginSubscriptionClosed } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SubscriptionObserver } from "@getpaseo/client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { QueryClient } from "@tanstack/react-query";
import React, { type ReactNode } from "react";
import { expect, it } from "vitest";
import { z } from "zod";
import { PluginInstallationProvider } from "./installation-provider";
import { createPluginSubscribe } from "./subscriptions";
import type { InstalledPlugin } from "./types";
const changes = defineSubscription({
name: "diff.watch",
@@ -95,7 +86,8 @@ class HostSubscription {
}
}
function host() {
/** An installation's subscribe on a host the test drives, and what one subscriber observed. */
function installation() {
const subscriptions: HostSubscription[] = [];
const client = {
observePluginRpc: (...[pluginId, method, input, options]: Observed) => {
@@ -105,200 +97,93 @@ function host() {
return subscription.handle() as unknown as ReturnType<DaemonClient["observePluginRpc"]>;
},
};
return { client, subscriptions };
}
function installation(input: {
client: ReturnType<typeof host>["client"];
invoke?: InstalledPlugin["invoke"];
}): InstalledPlugin {
const lifetime = new AbortController();
return {
id: "diffs",
serverId: "host",
clientBundle: "",
lifetime,
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
paseo: { dispose: async () => undefined } as PaseoApi,
invoke: input.invoke ?? (async () => undefined),
subscribe: createPluginSubscribe({
client: input.client,
pluginId: "diffs",
lifetime: lifetime.signal,
}),
queryClient: new QueryClient(),
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
timelineRenderers: [],
};
const subscribe = createPluginSubscribe({ client, pluginId: "diffs", lifetime: lifetime.signal });
function open(ref: string, signal?: AbortSignal) {
const received: string[] = [];
const closed: PluginSubscriptionClosed[] = [];
const handle = subscribePluginRpc(
changes,
subscribe,
{ ref },
(event) =>
received.push(event instanceof Uint8Array ? `bytes:${[...event]}` : `path:${event.path}`),
{ signal, onClosed: (result) => closed.push(result) },
);
return { handle, received, closed, host: subscriptions[subscriptions.length - 1]! };
}
return { open, lifetime };
}
function wrapper(plugin: InstalledPlugin) {
return function Wrapper({ children }: { children: ReactNode }) {
return <PluginInstallationProvider plugin={plugin}>{children}</PluginInstallationProvider>;
};
}
it("delivers JSON and byte events in order and releases the host subscription", async () => {
const { open } = installation();
const { handle, received, closed, host } = open("main");
expect(host.method).toBe("diff.watch");
expect(host.input).toEqual({ ref: "main" });
host.acknowledge();
await handle.ready;
it("useSubscription goes live, delivers JSON and bytes to the latest callback, and releases on unmount", async () => {
const { client, subscriptions } = host();
const plugin = installation({ client });
const received: string[] = [];
const { result, rerender, unmount } = renderHook(
({ label }: { label: string }) =>
useSubscription(changes, { ref: "main" }, (event) => {
received.push(
event instanceof Uint8Array ? `${label}:bytes:${[...event]}` : `${label}:${event.path}`,
);
}),
{ wrapper: wrapper(plugin), initialProps: { label: "first" } },
);
host.event({ path: "a.ts" });
host.bytes(new Uint8Array([1, 2]));
expect(received).toEqual(["path:a.ts", "bytes:1,2"]);
expect(result.current).toEqual({ status: "connecting" });
const [subscription] = subscriptions;
expect(subscription.method).toBe("diff.watch");
expect(subscription.input).toEqual({ ref: "main" });
await act(async () => subscription.acknowledge());
expect(result.current).toEqual({ status: "live" });
act(() => subscription.event({ path: "a.ts" }));
rerender({ label: "second" });
act(() => subscription.bytes(new Uint8Array([1, 2])));
expect(received).toEqual(["first:a.ts", "second:bytes:1,2"]);
// A new callback is not a new subscription.
expect(subscriptions).toHaveLength(1);
unmount();
await subscription.released;
handle();
await host.released;
expect(closed).toEqual([]);
});
it("useSubscription subscribes again when its input changes shallowly", async () => {
const { client, subscriptions } = host();
const plugin = installation({ client });
const { rerender } = renderHook(
({ ref }: { ref: string }) => useSubscription(changes, { ref }, () => undefined),
{ wrapper: wrapper(plugin), initialProps: { ref: "main" } },
);
rerender({ ref: "main" });
expect(subscriptions).toHaveLength(1);
rerender({ ref: "next" });
expect(subscriptions).toHaveLength(2);
await subscriptions[0].released;
expect(subscriptions[1].input).toEqual({ ref: "next" });
expect(subscriptions[1].isReleased).toBe(false);
});
it("reports a refusal, a failure after going live, and a clean close", async () => {
const { open } = installation();
const refused = open("a");
refused.host.refuseWith(new Error("no such ref"));
await expect(refused.handle.ready).rejects.toThrow("no such ref");
expect(refused.closed).toEqual([{ error: new Error("no such ref") }]);
it("useSubscription reports a refusal, a failure after going live, and a clean close", async () => {
const { client, subscriptions } = host();
const plugin = installation({ client });
const refused = renderHook(() => useSubscription(changes, { ref: "a" }, () => undefined), {
wrapper: wrapper(plugin),
});
await act(async () => subscriptions[0].refuseWith(new Error("no such ref")));
expect(refused.result.current).toMatchObject({
status: "error",
error: { message: "no such ref" },
});
const failed = renderHook(() => useSubscription(changes, { ref: "b" }, () => undefined), {
wrapper: wrapper(plugin),
});
await act(async () => subscriptions[1].acknowledge());
act(() => subscriptions[1].closed("Plugin stopped: diffs"));
expect(failed.result.current).toMatchObject({
status: "error",
error: { message: "Plugin stopped: diffs" },
});
await subscriptions[1].released;
const finished = renderHook(() => useSubscription(changes, { ref: "c" }, () => undefined), {
wrapper: wrapper(plugin),
});
await act(async () => subscriptions[2].acknowledge());
act(() => subscriptions[2].closed());
expect(finished.result.current).toEqual({ status: "closed" });
const failed = open("b");
failed.host.acknowledge();
await failed.handle.ready;
failed.host.closed("Plugin stopped: diffs");
expect(failed.closed).toEqual([{ error: new Error("Plugin stopped: diffs") }]);
await failed.host.released;
// A handler that returns at once closes in the same tick the host accepts it.
const immediate = renderHook(() => useSubscription(changes, { ref: "d" }, () => undefined), {
wrapper: wrapper(plugin),
});
await act(async () => {
subscriptions[3].acknowledge();
subscriptions[3].closed();
});
expect(immediate.result.current).toEqual({ status: "closed" });
const finished = open("c");
finished.host.acknowledge();
finished.host.closed();
await expect(finished.handle.ready).resolves.toBeUndefined();
expect(finished.closed).toEqual([{}]);
});
it("an event that fails the contract closes the subscription with an error", async () => {
const { client, subscriptions } = host();
const plugin = installation({ client });
const received: unknown[] = [];
const { result } = renderHook(
() => useSubscription(changes, { ref: "a" }, (event) => received.push(event)),
{ wrapper: wrapper(plugin) },
);
await act(async () => subscriptions[0].acknowledge());
act(() => subscriptions[0].event({ path: 3 }));
it("closes a subscription whose event fails the contract and drops what follows", async () => {
const { open } = installation();
const { handle, received, closed, host } = open("a");
host.acknowledge();
await handle.ready;
host.event({ path: 3 });
host.event({ path: "late.ts" });
expect(received).toEqual([]);
expect(result.current).toMatchObject({
status: "error",
error: { message: "Plugin subscription diff.watch sent an invalid event" },
});
await subscriptions[0].released;
expect(closed).toEqual([
{ error: new Error("Plugin subscription diff.watch sent an invalid event") },
]);
await host.released;
});
it("client.subscribe releases on its signal, on its remover, and when the installation stops", async () => {
const { client, subscriptions } = host();
const plugin = installation({ client });
const subscribe = <Event,>(onEvent: (event: Event) => void, signal?: AbortSignal) =>
subscribePluginRpc(changes, plugin.subscribe, { ref: "main" }, onEvent as never, { signal });
it("releases on its signal, on its remover, and when the installation stops", async () => {
const { open, lifetime } = installation();
const controller = new AbortController();
const bySignal = subscribe(() => undefined, controller.signal);
subscriptions[0].acknowledge();
await bySignal.ready;
const bySignal = open("a", controller.signal);
bySignal.host.acknowledge();
await bySignal.handle.ready;
controller.abort();
await subscriptions[0].released;
await bySignal.host.released;
const byRemover = subscribe(() => undefined);
byRemover();
await subscriptions[1].released;
const byRemover = open("b");
byRemover.handle();
await byRemover.host.released;
const received: unknown[] = [];
subscribe((event) => received.push(event));
subscriptions[2].event({ path: "a.ts" });
expect(received).toEqual([{ path: "a.ts" }]);
plugin.lifetime.abort();
await subscriptions[2].released;
});
it("useRpc resolves a binary output as bytes", async () => {
const thumbnail = defineRpc({
name: "thumbnail.render",
input: z.object({ path: z.string() }),
output: z.instanceof(Uint8Array),
});
const plugin = installation({
client: host().client,
invoke: async (method, input) => {
expect(method).toBe("thumbnail.render");
expect(input).toEqual({ path: "a.png" });
return new Uint8Array([137, 80, 78, 71]);
},
});
const { result } = renderHook(() => useRpc(thumbnail), { wrapper: wrapper(plugin) });
await waitFor(async () => {
const bytes = await result.current({ path: "a.png" });
expect([...bytes]).toEqual([137, 80, 78, 71]);
});
const byLifetime = open("c");
byLifetime.host.event({ path: "a.ts" });
expect(byLifetime.received).toEqual(["path:a.ts"]);
lifetime.abort();
await byLifetime.host.released;
});