import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { openCommandCenter } from "./command-center";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
export const SHOWCASE_PLUGIN_ID = "sidebar-showcase";
export const LEGACY_PLUGIN_ID = "legacy-sidebar";
export const BOTS_PLUGIN_ID = "sidebar-bots";
export const WIDE = { width: 1440, height: 900 };
export const COMPACT = { width: 390, height: 844 };
/** A plugin written against the current API: a screen, header and footer items, a command. */
const SHOWCASE_SOURCE = `import React from "react";
import { Pressable, Text, View } from "react-native";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
function DeploysScreen({ theme }) {
return Deploys screen body;
}
function RefreshButton({ theme, onPress }) {
return (
Refresh
);
}
function DeploysItem({ theme, currentScreen, openScreen }) {
const [refreshes, setRefreshes] = React.useState(0);
return (
openScreen({ screenId: "deploys" })}
trailing={ setRefreshes((count) => count + 1)} />}
/>
);
}
function SyncDetails({ theme, layout, close, openScreen }) {
return (
Sync details
Presentation: {layout.compact ? "compact" : "wide"}
openScreen({ screenId: "deploys" })}>Open deploys
Done
);
}
function SyncItem({ openPopover }) {
return openPopover(SyncDetails)} />;
}
function Broken() {
throw new Error("Sidebar item exploded");
}
export default function contribute(client) {
client.addScreen({ id: "deploys", title: "Deploys", Component: DeploysScreen });
client.addSidebarHeaderItem({ id: "deploys", title: "Deploys", Component: DeploysItem });
client.addSidebarHeaderItem({ id: "broken", title: "Broken header", Component: Broken });
client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem });
client.addSidebarFooterItem({ id: "broken", title: "Broken footer", Component: Broken });
client.addCommandCenterItem({ id: "open-deploys", title: "Open deploys", icon: "Rocket", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "deploys" }) });
return () => {};
}
`;
/**
* One header item that renders a group: a row per bot, a separator and a status row. Each bot
* row opens the bot screen with the bot's id as a param, and its trailing More button opens that
* bot's popover. Commands open a bot with a `serverId` param, and add and remove a footer item
* after setup; that item opens an event observation, releases it in its cleanup, and publishes its
* id on `globalThis.__botAlertsObservation`.
*/
const BOTS_SOURCE = `import React from "react";
import { Pressable, Text, View } from "react-native";
import { usePaseo } from "@getpaseo/plugin/client";
import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui";
const BOTS = [
{ id: "bot-1", name: "Bot 1" },
{ id: "bot-2", name: "Bot 2" },
];
function BotScreen({ theme, params }) {
return (
{"Bot screen: " + params.botId}
{"serverId param: " + (params.serverId ?? "none")}
);
}
function botTitle(params) {
return BOTS.find((bot) => bot.id === params.botId)?.name ?? "Bot";
}
const botMenus = {};
function botMenu(bot) {
botMenus[bot.id] ??= function BotMenu({ theme, close }) {
return (
{bot.name + " options"}
Done
);
};
return botMenus[bot.id];
}
function MoreButton({ theme, bot, onPress }) {
return (
More
);
}
function BotStatus({ theme, close }) {
return (
Bot status details
Done
);
}
function BotsItem({ theme, currentScreen, openScreen, openPopover }) {
return (
<>
{BOTS.map((bot) => (
openScreen({ screenId: "bot", params: { botId: bot.id } })}
trailing={ openPopover(botMenu(bot))} />}
/>
))}
openPopover(BotStatus)} />
>
);
}
function AlertsItem() {
const paseo = usePaseo();
React.useEffect(() => {
const observation = paseo.observeEvents(["project.update"]);
observation.subscribe({ snapshot(value) { globalThis.__botAlertsObservation = value.subscriptionId; }, update() {} });
return () => { void observation.release(); };
}, [paseo]);
return {}} />;
}
export default function contribute(client) {
let removeAlerts = null;
client.addScreen({ id: "bot", title: botTitle, Component: BotScreen });
client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem });
client.addCommandCenterItem({ id: "open-bot-3", title: "Open bot 3 on server x", icon: "Bot", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "bot", params: { botId: "bot-3", serverId: "x" } }) });
client.addCommandCenterItem({ id: "add-alerts", title: "Add bot alerts", icon: "Bell", context: "global", onSelect: () => {
if (!removeAlerts) removeAlerts = client.addSidebarFooterItem({ id: "alerts", title: "Bot alerts", Component: AlertsItem });
} });
client.addCommandCenterItem({ id: "remove-alerts", title: "Remove bot alerts", icon: "BellOff", context: "global", onSelect: () => {
removeAlerts?.();
removeAlerts = null;
} });
return () => {};
}
`;
/** A plugin that still uses only the old descriptor calls. */
const LEGACY_SOURCE = `import React from "react";
import { Text, View } from "react-native";
function Main({ theme }) {
return Legacy screen body;
}
export default function contribute(plugin) {
plugin.addSurface("main", Main);
plugin.addSidebarItem({ id: "entry", title: "Legacy entry", icon: "Server", surface: "main" });
plugin.addSidebarItem({ id: "hidden", title: "Legacy hidden", icon: "Blocks", surface: "main" });
return () => {};
}
`;
export async function installSidebarPlugins() {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const directories: string[] = [];
for (const [id, source] of [
[SHOWCASE_PLUGIN_ID, SHOWCASE_SOURCE],
[LEGACY_PLUGIN_ID, LEGACY_SOURCE],
[BOTS_PLUGIN_ID, BOTS_SOURCE],
] as const) {
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${id}-`));
directories.push(directory);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), source);
}
await client.patchDaemonConfig({ pluginsEnabled: true });
for (const directory of directories) await client.installDirectoryPlugin(directory);
return {
async cleanup() {
await client.removePlugin(SHOWCASE_PLUGIN_ID);
await client.removePlugin(LEGACY_PLUGIN_ID);
await client.removePlugin(BOTS_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await Promise.all(
directories.map((directory) => rm(directory, { recursive: true, force: true })),
);
},
};
}
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
export function visibleTestId(page: Page, testID: string): Locator {
return page.locator(`[data-testid="${testID}"]:visible`).first();
}
export function headerRow(page: Page, pluginId: string, itemId: string): Locator {
return visibleTestId(page, `plugin-sidebar-${pluginId}-${itemId}`);
}
export function footerItem(page: Page, pluginId: string, itemId: string): Locator {
return visibleTestId(page, `plugin-sidebar-footer-${pluginId}-${itemId}`);
}
export async function expectRowActive(row: Locator, active: boolean): Promise {
if (active) await expect(row).toHaveAttribute("aria-selected", "true");
else await expect(row).not.toHaveAttribute("aria-selected", "true");
}
export async function runCommand(page: Page, title: string): Promise {
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();
}
export async function closeScreen(page: Page): Promise {
await page.getByTestId("plugin-surface-close").click();
await expect(page).not.toHaveURL(/\/plugin\//);
}
/** Box of the popover body, located from the text it renders. */
export async function popoverBox(page: Page, text = "Sync details") {
const details = page.getByText(text, { exact: true });
await expect(details).toBeInViewport();
const box = await details.boundingBox();
expect(box).not.toBeNull();
return box!;
}