Files
paseo/packages/app/src/plugins/theme.test.ts
T
Mohamed Boudra c689b5626e feat(plugins): stream events and bytes between plugins and the app
Plugins can declare a subscription with defineSubscription, handle it with
server.handle(contract, (input, { push, signal })), and listen from a surface
with useSubscription or client.subscribe. A top-level Uint8Array in an event,
an RPC output, or an RPC input crosses the wire as a binary frame (opcodes
0x20-0x2f) without JSON or base64; a nested one fails validation on the sender.

Each subscriber runs its own handler, owned by the session that subscribed,
so release, disconnect, plugin stop and reload need no plugin code. The
runtime buffers pushes until the subscribe response has gone out, so a client
never sees an event for a subscription it does not know yet.

Gated on server_info.features.pluginSubscriptions; binary outputs reach only
clients that advertise plugin_binary_payloads.
2026-11-02 05:30:01 +01:00

223 lines
7.4 KiB
TypeScript

import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { QueryClient } from "@tanstack/react-query";
import type { PluginThemeContribution } from "@getpaseo/plugin";
import { describe, expect, it } from "vitest";
import { darkTheme, lightTheme } from "@/styles/theme";
import { collectPluginThemes, rememberPluginThemeHost } from "./themes";
import { toPluginTheme } from "./theme";
import type { InstalledPlugin } from "./types";
const MOCHA: PluginThemeContribution = {
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
};
const MOCHA_FORK: PluginThemeContribution = {
...MOCHA,
colors: { ...MOCHA.colors, background: "#11111b", accent: "#f5c2e7" },
};
const LATTE: PluginThemeContribution = {
id: "latte",
name: "Catppuccin Latte",
appearance: "light",
colors: {
background: "#eff1f5",
foreground: "#4c4f69",
raised: "#e6e9ef",
control: "#dce0e8",
border: "#ccd0da",
accent: "#8839ef",
mutedForeground: "#6c6f85",
ring: "#9ca0b0",
},
};
function installed(serverId: string, themes: PluginThemeContribution[]): InstalledPlugin {
return {
id: "catppuccin",
cleanup: () => undefined,
serverId,
clientBundle: serverId,
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
settingsScreens: [],
surfaces: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
attachmentSources: [],
themes,
timelineTransformers: [],
timelineRenderers: [],
};
}
describe("toPluginTheme", () => {
it("maps the app theme into the plugin color tokens", () => {
expect(toPluginTheme(lightTheme)).toEqual({
colors: {
surface0: lightTheme.colors.surface0,
surface1: lightTheme.colors.surface1,
surface2: lightTheme.colors.surface2,
border: lightTheme.colors.border,
foreground: lightTheme.colors.foreground,
foregroundMuted: lightTheme.colors.foregroundMuted,
accent: lightTheme.colors.accent,
accentForeground: lightTheme.colors.accentForeground,
statusSuccess: lightTheme.colors.statusSuccess,
statusWarning: lightTheme.colors.statusWarning,
statusDanger: lightTheme.colors.statusDanger,
},
});
});
});
describe("plugin theme palettes", () => {
it("expands a contributed palette onto the semantic and terminal tokens", () => {
const option = collectPluginThemes([installed("host-a", [MOCHA])], new Set(["host-a"]))[0];
const theme = option.theme;
expect(theme.colorScheme).toBe("dark");
expect(theme.colors).toMatchObject({
surface0: "#1e1e2e",
surface1: "#313244",
surface2: "#45475a",
surfaceSidebar: "#1e1e2e",
foreground: "#cdd6f4",
foregroundMuted: "#a6adc8",
border: "#45475a",
accent: "#cba6f7",
accentBright: "#cba6f7",
accentForeground: "#1e1e2e",
ring: "#6c7086",
terminal: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#cdd6f4",
cursorAccent: "#1e1e2e",
black: "#45475a",
brightBlack: "#6c7086",
},
});
});
it("carries the accent on the foreground when no accent is given", () => {
const { accent: _accent, ...colors } = MOCHA.colors;
const contribution = { ...MOCHA, colors };
const theme = collectPluginThemes([installed("host-a", [contribution])], new Set(["host-a"]))[0]
.theme;
expect(theme.colors.accent).toBe("#cdd6f4");
expect(theme.colors.accentBright).toBe("#cdd6f4");
});
it("keeps the status and syntax tokens the built-in dark themes use", () => {
const theme = collectPluginThemes([installed("host-a", [MOCHA])], new Set(["host-a"]))[0].theme;
expect(theme.colors.statusDanger).toBe(darkTheme.colors.statusDanger);
expect(theme.colors.syntax).toEqual(darkTheme.colors.syntax);
});
it("derives a complete light theme from the same palette contract", () => {
const theme = collectPluginThemes([installed("host-a", [LATTE])], new Set(["host-a"]))[0].theme;
expect(theme.colorScheme).toBe("light");
expect(theme.colors).toMatchObject({
surface0: "#eff1f5",
surface1: "#e6e9ef",
surface2: "#dce0e8",
surfaceSidebar: "#dce0e8",
foreground: "#4c4f69",
border: "#ccd0da",
accent: "#8839ef",
ring: "#9ca0b0",
terminal: {
background: "#eff1f5",
foreground: "#4c4f69",
black: "#4c4f69",
},
});
expect(theme.colors.statusDanger).toBe(lightTheme.colors.statusDanger);
expect(theme.colors.syntax).toEqual(lightTheme.colors.syntax);
});
});
const SUPPORTED = new Set(["host-a", "host-b", "host-z"]);
describe("collectPluginThemes", () => {
it("coalesces the same contribution across hosts", () => {
const options = collectPluginThemes(
[installed("host-a", [MOCHA]), installed("host-b", [MOCHA])],
new Set(["host-a", "host-b"]),
);
expect(options.map((option) => option.id)).toEqual(["catppuccin/theme/mocha"]);
});
// COMPAT(pluginThemes): a daemon without the capability keeps `addTheme` in the server bundle
// it compiles, so its themes are not offered at all.
it("drops themes from a host that predates the pluginThemes capability", () => {
expect(collectPluginThemes([installed("old-host", [MOCHA])], new Set())).toEqual([]);
});
it("keeps themes from the supported host when one peer is too old", () => {
const options = collectPluginThemes(
[installed("new-host", [MOCHA]), installed("old-host", [MOCHA])],
new Set(["new-host"]),
);
expect(options.map((option) => option.id)).toEqual(["catppuccin/theme/mocha"]);
});
// Two hosts, same plugin and theme id, different palettes. The picked host has to survive a peer
// connecting or dropping, or the app repaints with no settings change. The remembered host is
// module state, so this runs the whole sequence in one test rather than leaning on test order.
it("pins the palette to the picked host across peer connect and disconnect", () => {
const bothHosts = () =>
collectPluginThemes(
[installed("host-a", [MOCHA]), installed("host-z", [MOCHA_FORK])],
SUPPORTED,
);
const beforePick = bothHosts();
expect(beforePick).toHaveLength(1);
expect(beforePick[0]?.serverId).toBe("host-a");
expect(beforePick[0]?.theme.colors.surface0).toBe("#1e1e2e");
const onlyHostZ = collectPluginThemes([installed("host-z", [MOCHA_FORK])], SUPPORTED);
expect(onlyHostZ[0]).toBeDefined();
rememberPluginThemeHost(onlyHostZ[0]);
const afterPick = bothHosts();
expect(afterPick).toHaveLength(1);
expect(afterPick[0]?.serverId).toBe("host-z");
expect(afterPick[0]?.theme.colors.surface0).toBe("#11111b");
const withoutHostZ = collectPluginThemes([installed("host-a", [MOCHA])], SUPPORTED);
expect(withoutHostZ[0]?.serverId).toBe("host-a");
expect(withoutHostZ[0]?.theme.colors.surface0).toBe("#1e1e2e");
});
});