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!; }