import type { Component, Owner } from "solid-js" import type { PluginInfo } from "@opencode/client/promise" import type { CodeBlockProps, Context, Contract, DialogHandle, Dialogs, Live, SessionRef, SessionScreen, } from "@opencode/plugin/gui" import type { BuiltinPanelTab } from "@opencode/plugin/gui/builtin" import type { Bridge, BridgeMessage, EnableState, Installed } from "@opencode/plugin/gui/host" import type { JSHandle } from "@playwright/test" import type { mountCodeBlocks, mountThemes } from "./plugin-host.fixture" import { expect, sourceURL, story } from "../../stories/playwright/story" const fixture = sourceURL(new URL("./plugin-host.fixture.tsx", import.meta.url)) /** What the code block cases drive from the page. */ type CodeBlocksHost = ReturnType /** What the theme preview case drives from the page. */ type ThemesHost = ReturnType /** What the scoped-registration case keeps from inside its plugin. */ type Scope = { owner?: Owner | null; end: () => void; ctx?: Context } /** The context the session-routing case keeps from inside its plugin. */ type Kept = { ctx?: Context } /** What the asking case keeps from inside its plugin: its dialogs, the end of its scope, and that scope's prompt. */ type Asked = { dialogs?: Dialogs; end?: () => void; prompt?: Promise } /** What the pre-mount case reads from inside its plugin's setup. */ type Reads = { state?: () => string; font?: () => string; prefs?: { value?: { count: number }; ready(): boolean } } story.beforeEach(async ({ mount }) => { // Any story loads the app; the fixture mounts the real host beside it. await mount("ui-line-comment--editor") }) story("a plugin that finishes loading after the host unmounts is never set up", async ({ page }) => { const setups = await page.evaluate(async (fixture) => { const { mountPluginHost } = await import(fixture) const host = mountPluginHost() const state = { setups: 0 } host.unmount() host.load(() => void state.setups++) await new Promise((resolve) => setTimeout(resolve, 100)) return state.setups }, fixture) expect(setups).toBe(0) }) story("async work from synchronous setup cannot register after the host unmounts", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPluginHost, onCleanup } = await import(fixture) const host = mountPluginHost() const started = Promise.withResolvers() const resume = Promise.withResolvers() const done = Promise.withResolvers() const cleaned: string[] = [] const registry = { kind: "registry" as const, id: "fixture-registry" } const resumed = { aborted: false } host.load((ctx: Context) => { ctx.add(registry, "before") onCleanup(() => void cleaned.push("owner")) started.resolve() void resume.promise.then(() => { // A promise callback has no owner; the signal says the work outlived the instance. resumed.aborted = ctx.signal.aborted ctx.add(registry, "after") done.resolve() }) }) await started.promise const before = host.entries(registry.id) host.unmount() resume.resolve() await done.promise return { before, after: host.entries(registry.id), cleaned, aborted: resumed.aborted } }, fixture) expect(result).toEqual({ before: 1, after: 0, cleaned: ["owner"], aborted: true }) }) story("older loads neither set up nor fail over the replacement after reloads", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPluginHost } = await import(fixture) const host = mountPluginHost() const setups: string[] = [] host.reload() host.reload() host.load(() => void setups.push("first"), 0) host.fail(1, new Error("second")) await new Promise((resolve) => setTimeout(resolve, 20)) host.load(() => void setups.push("third"), 2) await new Promise((resolve) => setTimeout(resolve, 100)) const outcome = { setups, status: host.status() } host.unmount() return outcome }, fixture) expect(result).toEqual({ setups: ["third"], status: "active" }) }) story("a dialog service kept from before a reload opens and closes nothing under the replacement", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPluginHost } = await import(fixture) const host = mountPluginHost() const dialogs: Dialogs[] = [] const setup = (ctx: Context) => void dialogs.push(ctx.dialogs) const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value }) const shown = (value: string) => !!document.body.textContent?.includes(value) const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) host.load(setup, 0) await wait(20) host.reload() host.load(setup, 1) await wait(20) const [stale, fresh] = dialogs const kept = stale.open(text("stale dialog")) fresh.open(text("fresh dialog")) await wait(50) kept.close() await wait(300) const outcome = { stale: shown("stale dialog"), fresh: shown("fresh dialog") } host.unmount() return outcome }, fixture) expect(result).toEqual({ stale: false, fresh: true }) }) story("a dialog handle closes its own dialog, and a dialog closes with the scope that opened it", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPluginHost, createKeyed, createSignal } = await import(fixture) const host = mountPluginHost() const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value }) const shown = () => ["below", "middle", "scoped", "cancelled", "brief"].filter((value) => document.body.textContent?.includes(value)) const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const scope = { end: () => {} } const handles: DialogHandle[] = [] host.load((ctx: Context) => { const [on, set] = createSignal(true) scope.end = () => set(false) handles.push(ctx.dialogs.open(text("below")), ctx.dialogs.open(text("middle"))) createKeyed(on, () => void ctx.dialogs.open(text("scoped"))) // Ended in the tick they open, before the deferred opening: neither shows, and the replacing one replaces nothing. ctx.dialogs.open(text("cancelled"), { replace: true }).close() const [brief, end] = createSignal(true) createKeyed(brief, () => void ctx.dialogs.open(text("brief"))) end(false) }, 0) await wait(100) const opened = shown() // The middle dialog is not on top: its handle closes it and nothing else. handles[1]?.close() await wait(300) const closed = shown() scope.end() await wait(300) const ended = shown() host.unmount() return { opened, closed, ended } }, fixture) expect(result).toEqual({ opened: ["below", "middle", "scoped"], closed: ["below", "scoped"], ended: ["below"], }) }) story("Escape closes the top dialog while a dialog below it plays its exit animation", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPluginHost, until } = await import(fixture) const host = mountPluginHost() const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value }) const shown = () => ["below", "middle", "top"].filter((value) => document.body.textContent?.includes(value)) const handles: DialogHandle[] = [] host.load((ctx: Context) => { handles.push(ctx.dialogs.open(text("below")), ctx.dialogs.open(text("middle")), ctx.dialogs.open(text("top"))) }, 0) await until(() => shown().length === 3) handles[1]?.close() // In the same tick, so the middle dialog is still playing its exit animation. document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })) await until(() => shown().length === 1) const outcome = shown() host.unmount() return outcome }, fixture) expect(result).toEqual(["below"]) }) story("a dialog closes itself while the dialog above it exits, and a double close never reaches below", async ({ page, }) => { const result = await page.evaluate(async (fixture) => { const { mountPluginHost, until, useDialog } = await import(fixture) const host = mountPluginHost() const shown = () => ["base", "middle", "top"].filter((value) => document.body.textContent?.includes(value)) const own = { close: () => {} } host.load((ctx: Context) => { ctx.dialogs.open(() => Object.assign(document.createElement("p"), { textContent: "base" })) // A Cancel button inside the middle dialog: a plain close() from its own layer. ctx.dialogs.open(() => { const dialog = useDialog() own.close = () => dialog.close() return Object.assign(document.createElement("p"), { textContent: "middle" }) }) ctx.dialogs.open(() => Object.assign(document.createElement("p"), { textContent: "top" })) }, 0) await until(() => shown().length === 3) document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })) // In the same tick, while the top dialog is still playing its exit animation; twice, as a double click would. own.close() own.close() await until(() => shown().length === 1) // Past every exit animation, so a close that reached the base dialog would have removed it by now. await new Promise((resolve) => setTimeout(resolve, 300)) const outcome = shown() host.unmount() return outcome }, fixture) expect(result).toEqual(["base"]) }) story("a dialog pushed in the same tick as a reload never mounts", async ({ page }) => { const shown = await page.evaluate(async (fixture) => { const { mountPluginHost } = await import(fixture) const host = mountPluginHost() const dialogs: Dialogs[] = [] const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) host.load((ctx: Context) => void dialogs.push(ctx.dialogs), 0) await wait(20) dialogs[0].open(() => Object.assign(document.createElement("p"), { textContent: "same tick dialog" })) host.reload() await wait(300) const outcome = !!document.body.textContent?.includes("same tick dialog") host.unmount() return outcome }, fixture) expect(shown).toBe(false) }) story( "confirm, alert, prompt and select answer once: with the user's pick, else when their dialog or plugin goes away", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPluginHost, createKeyed, createSignal, until } = await import(fixture) const host = mountPluginHost() const kept: Asked = {} const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const button = (label: string) => Array.from(document.querySelectorAll("button")).find((element) => element.textContent?.trim() === label) host.load((ctx: Context) => { const [on, set] = createSignal(true) kept.dialogs = ctx.dialogs kept.end = () => set(false) createKeyed(on, () => void (kept.prompt = ctx.dialogs.prompt({ title: "Scoped prompt" }))) }, 0) await until(() => !!document.body.textContent?.includes("Scoped prompt")) // The scope that asked ends: its prompt closes and answers undefined. kept.end?.() const scoped = await kept.prompt const dialogs = kept.dialogs if (!dialogs) throw new Error("The fixture plugin never set up") const confirmed = dialogs.confirm({ title: "End the meetup?", confirm: "End", danger: true }) await until(() => !!button("End")) button("End")?.click() const alerted = dialogs.alert({ title: "Meetup ended", ok: "Got it" }) await until(() => !!button("Got it")) button("Got it")?.click() const picked = dialogs.select({ title: "Links", options: [ { title: "Alpha", value: "a" }, { title: "Beta", value: "b", description: "second" }, ], }) const beta = () => Array.from(document.querySelectorAll('[role="option"]')).find((element) => element.textContent?.includes("Beta"), ) await until(() => !!beta()) beta()?.click() const dismissed = dialogs.confirm({ title: "Dismiss me" }) await until(() => !!button("Cancel")) document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })) const outcome = { scoped, confirmed: await confirmed, alerted: await alerted, picked: await picked, dismissed: await dismissed, } await wait(300) host.unmount() // Asked of a plugin that went away: nothing opens, and the fallback answers at once. return { ...outcome, late: await dialogs.prompt({ title: "Late" }) } }, fixture) expect(result).toEqual({ scoped: undefined, confirmed: true, alerted: undefined, picked: "b", dismissed: false, late: undefined, }) }, ) story("a plugin's toasts close when it goes away, and a handle closes only its own toast", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountHostApis, until } = await import(fixture) const shown = (title: string) => !!document.body.textContent?.includes(title) const handles: { dismiss: () => void }[] = [] const setups = { count: 0 } const host = mountHostApis({ definitions: [ { id: "fixture", windowEntry: async () => ({ default: (ctx: Context) => { // Only the first instance shows toasts, so the reload's instance shows none of its own. if (setups.count++ > 0) return ctx.toast.show({ title: "kept toast", persistent: true }) handles.push(ctx.toast.show({ title: "dismissed toast", persistent: true })) }, }), }, ], }) await until(() => shown("kept toast") && shown("dismissed toast")) handles[0]?.dismiss() await until(() => !shown("dismissed toast")) const kept = shown("kept toast") host.reload("fixture") await until(() => setups.count === 2 && !shown("kept toast")) host.unmount() return { kept, setups: setups.count } }, fixture) expect(result).toEqual({ kept: true, setups: 2 }) }) story("ctx.ui renders the app's components in a contribution, a host menu and a dialog", async ({ page }) => { await page.evaluate(async (fixture) => { const { mountSessionRegion, until, createComponent, createSignal, Slot } = await import(fixture) // What the components' handlers received, for the test to read. const record = (value: string) => { document.body.dataset.ui = `${document.body.dataset.ui ?? ""}${value};` } const host = mountSessionRegion({ active: "none:main", definitions: [ { id: "fixture", windowEntry: async () => ({ default: (ctx: Context) => { const ui = ctx.ui const [on, setOn] = createSignal(false) const [name, setName] = createSignal("") const [period, setPeriod] = createSignal("day") const dialog = () => ctx.dialogs.open((handle) => createComponent(ui.Dialog, { title: "Fixture dialog", children: "Fixture body", get footer() { return createComponent(ui.Button, { onClick: handle.close, children: "Close fixture" }) }, }), ) ctx.add(Slot, { at: "session.panel.end", render: () => [ createComponent(ui.Icon, { name: "branch" }), createComponent(ui.Button, { icon: "check", onClick: () => record("button"), children: "Apply" }), createComponent(ui.Button, { onClick: dialog, children: "Open dialog" }), createComponent(ui.Tooltip, { value: "Reload tip", keys: ["R"], get children() { return createComponent(ui.IconButton, { icon: "outline-rotate-clockwise", label: "Reload" }) }, }), createComponent(ui.Menu, { label: "Options", items: [ { type: "group", label: "Fixture group", items: [{ label: "Rename", onSelect() {} }] }, { type: "separator" }, { label: "Delete", icon: "trash", keys: ["Del"], onSelect: () => record("menu") }, ], }), createComponent(ui.Loader, { size: "large" }), createComponent(ui.Switch, { label: "Wrap", get checked() { return on() }, onChange: setOn, }), createComponent(ui.TextInput, { label: "Name", get value() { return name() }, onInput: setName, onSubmit: (value: string) => record(`submit ${value}`), }), createComponent(ui.Keybind, { keys: ["Ctrl", "K"] }), createComponent(ui.Markdown, { text: "# Fixture heading\n\nSome *text*." }), createComponent(ui.Code, { content: "const fence = `\n```\n`", language: "ts" }), createComponent(ui.SettingsLayout, { title: "Fixture settings", description: "Fixture page description", get children() { return createComponent(ui.SettingsSection, { title: "Fixture section", footer: "Fixture footer", get children() { return createComponent(ui.SettingsRow, { id: "fixture-period", title: "Period", description: "How far back to count", get children() { return createComponent(ui.Select<"day" | "month">, { label: "Period", options: [ { value: "day", label: "Day" }, { value: "month", label: "Month" }, ], get value() { return period() }, onChange: (value: "day" | "month") => { setPeriod(value) record(`select ${value}`) }, }) }, }) }, }) }, }), ], }) ctx.add(Slot, { at: "session.location.menu", render: () => [ createComponent(ui.MenuSeparator, {}), createComponent(ui.MenuItem, { label: "Usage", icon: "status", keys: ["U"], onSelect: () => record("usage"), }), createComponent(ui.MenuGroup, { label: "Fixture menu group", get children() { return createComponent(ui.MenuSub, { label: "Open in", onOpenChange: (open: boolean) => record(`sub ${open}`), get children() { return createComponent(ui.MenuItem, { label: "Editor", onSelect: () => record("editor") }) }, }) }, }), ], }) }, }), }, ], }) host.container.dataset.testid = "ui-host" await until(() => host.status("fixture") === "active") }, fixture) const root = page.getByTestId("ui-host") await expect(root.locator('svg[data-slot="icon-svg"] > use[href="#opencode-v2-icon-branch"]')).toHaveCount(1) await expect(root.locator('svg[data-component="loader-v2"]')).toHaveAttribute("width", "20") await expect(root.locator('[data-component="keybind-v2"]')).toHaveText("CtrlK") await expect(root.locator('[data-component="markdown"] h1')).toHaveText("Fixture heading") // A line of backticks in the code does not end its block. const code = root.locator('[data-component="markdown"] code.language-ts') await expect(code).toHaveText("const fence = `\n```\n`") await expect(code.locator("span[style]").first()).toBeVisible() const apply = root.getByRole("button", { name: "Apply" }) await expect(apply).toHaveAttribute("data-component", "button-v2") await expect(apply.locator('use[href="#opencode-v2-icon-check"]')).toHaveCount(1) await apply.click() const reload = root.getByRole("button", { name: "Reload" }) await expect(reload).toHaveAttribute("data-component", "icon-button-v2") await reload.hover() await expect(page.locator('[data-component="tooltip-v2"]')).toHaveText("Reload tipR") await root.getByRole("button", { name: "Options" }).click() const menu = page.getByRole("menu") await expect(menu.getByText("Fixture group")).toBeVisible() await expect(menu.getByRole("separator")).toHaveCount(1) await menu.getByRole("menuitem", { name: "Delete" }).click() // The switch's input is visually hidden under its label, which takes the click. await root.getByText("Wrap", { exact: true }).click() await expect(root.getByRole("switch", { name: "Wrap" })).toBeChecked() const field = root.getByRole("textbox", { name: "Name" }) await field.fill("acme") await field.press("Enter") await expect(page.locator("body")).toHaveAttribute("data-ui", "button;menu;submit acme;") await root.getByRole("button", { name: "Open dialog" }).click() const dialog = page.getByRole("dialog", { name: "Fixture dialog" }) await expect(dialog).toContainText("Fixture body") await expect(dialog.locator('[data-slot="dialog-close-button"]')).toHaveCount(1) await dialog.getByRole("button", { name: "Close fixture" }).click() await expect(dialog).toHaveCount(0) // Settings components render the host's settings markup, and search finds the row by its id. await expect(root.locator(".settings-tab-header h2.settings-tab-title")).toHaveText("Fixture settings") const section = root.getByRole("region", { name: "Fixture section" }) await expect(section.locator("h3.settings-section-title")).toHaveText("Fixture section") await expect(section).toContainText("Fixture footer") const row = section.locator('[data-component="settings-list"] > [data-component="settings-row"]') await expect(row).toHaveAttribute("data-action", "fixture-period") await expect(row.locator('[data-slot="settings-row-description"]')).toHaveText("How far back to count") const select = row.locator('[data-slot="settings-row-control"] [data-component="select-v2"]') // The trigger is named by the label and the selected choice. await expect(select).toHaveAccessibleName("Period Day") await select.click() await page.getByRole("option", { name: "Month" }).click() await expect(select).toHaveAccessibleName("Period Month") // Menu rows render inside the host's menu as its own rows: keyboard items, a group, and a submenu. await root.getByRole("button", { name: "Project menu" }).click() const project = page.getByRole("menu") await expect(project.getByRole("separator")).toHaveCount(1) const usage = project.getByRole("menuitem", { name: "Usage" }) await expect(usage).toHaveAttribute("data-component", "menu-v2-item") await expect(usage.locator('use[href="#opencode-v2-icon-status"]')).toHaveCount(1) await expect(usage.locator('[data-component="keybind-v2"]')).toHaveText("U") await usage.click() await expect(project).toHaveCount(0) await root.getByRole("button", { name: "Project menu" }).click() await expect(page.getByRole("menu").getByText("Fixture menu group")).toBeVisible() await page.getByRole("menuitem", { name: "Open in" }).hover() await page.getByRole("menu", { name: "Open in" }).getByRole("menuitem", { name: "Editor" }).click() await expect(page.getByRole("menu")).toHaveCount(0) await expect(page.locator("body")).toHaveAttribute( "data-ui", /^button;menu;submit acme;select month;usage;sub true;editor;/, ) }) story( "routing A, B, then A again: a kept session stays A, the same screen follows the route, and no render remounts", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountSessionRegion, until, Panel, Slot } = await import(fixture) const mounts = { single: 0, grouped: 0, slot: 0 } const inputs: Partial< Record > = {} const kept: Kept = {} const node = () => document.createElement("p") // One render of each kind the host has: a selected tab, a group that stays mounted, and a slot. const render = (kind: keyof typeof mounts) => (input: { readonly session: { readonly id: string }; readonly screen: SessionScreen }) => { mounts[kind]++ inputs[kind] = input return node() } const host = mountSessionRegion({ active: "single:main", definitions: [ { id: "single", windowEntry: async () => ({ default: (ctx: Context) => { const tab = { id: "main", title: "Single" } kept.ctx = ctx ctx.add(Panel, { id: "main", region: "side", list: () => [tab], render: render("single") }) ctx.add(Slot, { at: "session.panel.end", render: render("slot") }) }, }), }, { id: "grouped", windowEntry: async () => ({ default: (ctx: Context) => { const tab = { id: "main", title: "Grouped", group: "group" } ctx.add(Panel, { id: "main", region: "side", list: () => [tab], render: render("grouped") }) }, }), }, ], }) await until(() => mounts.single > 0 && mounts.grouped > 0 && mounts.slot > 0) // Session A's object, as a plugin keeps it, and the screen while it routes A. const session = kept.ctx?.sessions.current() const screen = kept.ctx?.screen.current() const seen = () => ({ mounts: { ...mounts }, sessions: [inputs.single?.session.id, inputs.grouped?.session.id, inputs.slot?.session.id], screens: [inputs.single?.screen === screen, inputs.grouped?.screen === screen, inputs.slot?.screen === screen], kept: session?.id, fresh: kept.ctx?.sessions.current() !== session, screen: { same: kept.ctx?.screen.current() === screen, aligned: !!kept.ctx?.screen.current() === !!kept.ctx?.sessions.current(), }, }) const steps = [seen()] host.route("b") steps.push(seen()) // An action through the screen targets the session routed now. screen?.composer.attach({ type: "file", path: "notes.md" }) host.route("a") steps.push(seen()) host.leave() const home = seen() host.unmount() return { steps, home, attached: host.attached } }, fixture) const step = (id: string, fresh: boolean) => ({ mounts: { single: 1, grouped: 1, slot: 1 }, sessions: [id, id, id], screens: [true, true, true], kept: "a", fresh, screen: { same: true, aligned: true }, }) // Routing A again makes a new object for the new visit; the kept one still names A. expect(result).toEqual({ steps: [step("a", false), step("b", true), step("a", true)], home: { ...step("a", true), screen: { same: false, aligned: true } }, attached: ["b"], }) }, ) story( "before the app interface mounts, host APIs read their defaults and keep writes and dialogs until it mounts", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountHostApis, until, createMemo, Schema } = await import(fixture) const Prefs = Schema.Struct({ count: Schema.Number }) const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value }) const shown = () => ["kept", "cancelled"].filter((value) => document.body.textContent?.includes(value)) const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const fake = { key: "local\nses_fixture", id: "ses_fixture", tab: "tab", pending: false, location: undefined } // SAFETY: the stand-in interface reads only the key it is given; the host APIs pass the session through. // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above const session = fake as unknown as SessionRef const reads: Reads = {} const host = mountHostApis({ definitions: [ { id: "fixture", windowEntry: async () => ({ default: (ctx: Context) => { // Setup runs before the interface mounts, as every built-in's does. ctx.layout.open("fixture:main", session) ctx.layout.settings("fixture") // A dialog waits for the interface too; one its handle closes meanwhile never shows. ctx.dialogs.open(text("kept")) ctx.dialogs.open(text("cancelled")).close() const prefs = ctx.storage.store("prefs", { schema: Prefs, initial: { count: 0 }, scope: { server: "local" }, }) prefs.update((draft) => { draft.count++ }) reads.state = createMemo(() => ctx.layout.state("fixture:main", session)) reads.font = createMemo(() => ctx.appearance.font("mono")) reads.prefs = prefs }, }), }, ], }) const read = () => ({ state: reads.state?.(), font: reads.font?.().startsWith('"JetBrainsMono Nerd Font Mono"') ? "default mono" : reads.font?.(), prefs: reads.prefs?.value?.count, ready: reads.prefs?.ready(), writes: [...host.writes], dialogs: shown(), }) await until(() => host.status("fixture") === "active") // Long enough for a dialog's deferred opening to have shown it. await wait(300) const before = read() host.attach() await until(() => host.writes.length === 2 && shown().length > 0) const after = read() host.unmount() return { before, after } }, fixture) expect(result).toEqual({ before: { state: "closed", font: "default mono", prefs: undefined, ready: false, writes: [], dialogs: [] }, after: { state: "visible", font: "fixture mono", prefs: 1, ready: true, writes: ["open fixture:main", "settings fixture"], dialogs: ["kept"], }, }) }, ) // Selection follows opt-in tier order, not the strip's pinned-first order. Unrelated regular tabs never opt in. const tiers: readonly { name: string; tabs: readonly BuiltinPanelTab[]; active?: string; expected: string }[] = [ { name: "regular tabs, in stored order", tabs: [ { id: "details", title: "Details" }, { id: "review", title: "Review", pinned: true, fallback: true }, { id: "context", title: "Context", first: true, fallback: true }, { id: "file-one", title: "File one", fallback: true }, { id: "file-two", title: "File two", fallback: true }, ], expected: "file-one", }, { name: "first tabs before pinned tabs", tabs: [ { id: "details", title: "Details" }, { id: "review", title: "Review", pinned: true, fallback: true }, { id: "context", title: "Context", first: true, fallback: true }, ], expected: "context", }, { name: "pinned tabs when no other tier opts in", tabs: [ { id: "details", title: "Details" }, { id: "review", title: "Review", pinned: true, fallback: true }, ], expected: "review", }, { name: "no implicit fallback", tabs: [{ id: "details", title: "Details", fallback: false }], expected: "", }, { name: "a stored selection wins without opting in", tabs: [ { id: "details", title: "Details" }, { id: "file", title: "File", fallback: true }, ], active: "details:main", expected: "details", }, ] tiers.forEach((row) => { story(`panel fallback: ${row.name}`, async ({ page }) => { const selected = await page.evaluate( async (input) => { const { mountSessionRegion, Panel, until } = await import(input.fixture) const host = mountSessionRegion({ active: input.row.active ?? "gone:main", definitions: input.row.tabs.map((tab) => ({ id: tab.id, windowEntry: async () => ({ default: (ctx: Context) => { const item = { ...tab, id: "main" } ctx.add(Panel, { id: "main", region: "side", list: () => [item], render: () => ctx.id }) }, }), })), }) await until(() => input.row.tabs.every((tab) => host.status(tab.id) === "active")) const result = host.container.textContent host.unmount() return result }, { fixture, row }, ) expect(selected).toBe(row.expected) }) }) story("a plugin reloaded while disabled starts when it is enabled again", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPluginHost } = await import(fixture) const host = mountPluginHost() const setups: string[] = [] const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) host.load(() => void setups.push("first"), 0) await wait(20) host.disable() await wait(20) host.reload() const afterReload = host.status() // A load the reload started finishes while the plugin is still disabled. if (host.count() > 1) host.load(() => void setups.push("while disabled"), 1) await wait(20) host.enable() await wait(20) host.load(() => void setups.push("enabled"), host.count() - 1) await wait(50) const outcome = { afterReload, setups, status: host.status() } host.unmount() return outcome }, fixture) expect(result).toEqual({ afterReload: "disabled", setups: ["first", "enabled"], status: "active" }) }) story( "a registration is withdrawn with the scope that made it, and at once when that scope already ended", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPlugins, until, createKeyed, createSignal, getOwner, runWithOwner } = await import(fixture) const registry = { kind: "registry" as const, id: "fixture-scoped" } const scope: Scope = { end: () => {} } const host = mountPlugins({ definitions: [ { id: "fixture", windowEntry: async () => ({ default: (ctx: Context) => { const [on, set] = createSignal(true) scope.end = () => set(false) scope.ctx = ctx createKeyed(on, () => { scope.owner = getOwner() ctx.add(registry, "during") }) }, }), }, ], }) await until(() => host.status("fixture") === "active") const during = host.entries(registry.id) scope.end() const ended = host.entries(registry.id) // A captured owner of a generation that ended, as async work resuming late would hold. runWithOwner(scope.owner, () => scope.ctx?.add(registry, "late")) const late = host.entries(registry.id) host.unmount() return { during, ended, late } }, fixture) expect(result).toEqual({ during: 1, ended: 0, late: 0 }) }, ) story("a contribution that throws renders nothing and records the error; the others stay", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPlugins, until, Slot } = await import(fixture) const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value }) const host = mountPlugins({ definitions: [ { id: "fixture", windowEntry: async () => ({ default: (ctx: Context) => { ctx.add(Slot, { at: "window.bottom", render: () => { throw new Error("broken contribution") }, }) ctx.add(Slot, { at: "window.bottom", render: text("kept") }) }, }), }, { id: "other", windowEntry: async () => ({ default: (ctx: Context) => void ctx.add(Slot, { at: "window.bottom", render: text("other") }), }), }, ], }) await until(() => !!host.failure("fixture") && !!host.container.textContent?.includes("other")) const failure = host.failure("fixture") const outcome = { text: host.container.textContent, status: [host.status("fixture"), host.status("other")], failure: { phase: failure?.phase, named: !!failure?.error.includes("broken contribution") }, } host.unmount() return outcome }, fixture) expect(result).toEqual({ text: "keptother", status: ["active", "active"], failure: { phase: "render", named: true }, }) }) story( "plugin GUIs join, hot-reload in place, run their returned cleanup and leave, without holding startup", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPlugins, until } = await import(fixture) const builtinLoad = Promise.withResolvers() const log: string[] = [] // Each setup returns its cleanup, as a server or TUI plugin's setup does. const entry = (name: string) => async () => ({ default: () => { log.push(`setup ${name}`) return () => void log.push(`cleanup ${name}`) }, }) const builtin = { id: "builtin", windowEntry: async () => { await builtinLoad.promise return entry("builtin")() }, } const host = mountPlugins({ definitions: [builtin] }) // A plugin that joins before the built-ins settle, and never loads, must not hold the app's first render. host.define([builtin, { id: "stuck", windowEntry: () => new Promise(() => {}) }]) builtinLoad.resolve() await until(() => host.ready()) const startup = { ready: host.ready(), stuck: host.status("stuck") } host.define([builtin, { id: "acme", windowEntry: entry("acme 1") }]) await until(() => host.status("acme") === "active") // A new revision of the plugin's bundle is a new definition under the same id. host.define([builtin, { id: "acme", windowEntry: entry("acme 2") }]) await until(() => log.includes("setup acme 2")) const reloaded = [...log] host.define([builtin]) await until(() => log.includes("cleanup acme 2")) const outcome = { startup, reloaded, log: [...log], gone: host.status("acme") ?? "none" } host.unmount() return outcome }, fixture) expect(result).toEqual({ startup: { ready: true, stuck: "loading" }, reloaded: ["setup builtin", "setup acme 1", "cleanup acme 1", "setup acme 2"], log: ["setup builtin", "setup acme 1", "cleanup acme 1", "setup acme 2", "cleanup acme 2"], gone: "none", }) }, ) story("a throwing effect or state reader fails only its own plugin; later plugins still activate", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPlugins, until, createMemo, createRoot } = await import(fixture) const later = Promise.withResolvers() const thrown = { count: 0 } const host = mountPlugins({ // Creating the broken plugin's instance throws. plugin: (id: string) => { if (id === "broken") throw new Error("instance broke") return undefined }, definitions: [ { id: "acme", windowEntry: async () => ({ default: () => {} }) }, { id: "broken", windowEntry: async () => ({ default: () => {} }) }, { id: "later", windowEntry: async () => { await later.promise return { default: () => {} } }, }, ], }) // A reader with no error boundary: its throw reaches the write that woke it, here acme becoming active. const dispose = createRoot((dispose: () => void) => { createMemo(() => { if (host.status("acme") === "active" && thrown.count++ === 0) throw new Error("reader broke") }) return dispose }) await until(() => thrown.count > 0 && host.status("broken") === "failed") later.resolve() await until(() => host.status("later") === "active") const outcome = { acme: host.status("acme"), broken: host.failure("broken")?.error.includes("instance broke"), later: host.status("later"), // In the app, an error that reaches the boundary above the host replaces the whole window. escaped: host.escaped(), } dispose() host.unmount() return outcome }, fixture) expect(result).toEqual({ acme: "active", broken: true, later: "active", escaped: [] }) }) story( "a plugin follows another's contract across load, reload and disable, provides only its own, and a failed one stays failed", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPlugins, until, Contract } = await import(fixture) const Tree: Contract<{ version: number }, "provider.tree"> = Contract.define("provider.tree") const providerLoad = Promise.withResolvers() const versions = { value: 0 } const thiefLoads = { value: 0 } const observed = { read: (): Live<{ version: number }> => ({ status: "pending" }) } const host = mountPlugins({ definitions: [ { id: "consumer", windowEntry: async () => ({ default: (ctx: Context) => void (observed.read = ctx.use(Tree)) }), }, { id: "provider", windowEntry: async () => { await providerLoad.promise return { default: (ctx: Context) => void ctx.provide(Tree, { version: ++versions.value }) } }, }, // Another plugin cannot provide the provider's contract: its setup fails, and the contract stays the provider's. { id: "thief", windowEntry: async () => { thiefLoads.value++ return { default: (ctx: Context) => void ctx.provide(Tree, { version: 0 }) } }, }, ], }) await until(() => host.status("consumer") === "active" && host.status("thief") === "failed") const loading = observed.read().status providerLoad.resolve() await until(() => observed.read().status === "active") const first = observed.read() host.reload("provider") await until(() => { const live = observed.read() return live.status === "active" && live.generation === 2 }) const second = observed.read() host.disable(["provider"]) await until(() => observed.read().status === "inactive") const outcome = { loading, first, second, disabled: observed.read(), thief: host.failure("thief")?.error.includes('provides only its own contracts, not "provider.tree"'), // Disabling another plugin does not retry the failed one; only Reload or a new revision does. thiefLoads: thiefLoads.value, } host.unmount() return outcome }, fixture) expect(result).toEqual({ loading: "pending", first: { status: "active", value: { version: 1 }, generation: 1 }, second: { status: "active", value: { version: 2 }, generation: 2 }, disabled: { status: "inactive", reason: "disabled" }, thief: true, thiefLoads: 1, }) }, ) story( "a plugin's own main client waits for main, follows its state, and rejects once main cannot answer", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPlugins, until, Ipc, Schema, createSignal } = await import(fixture) const Awake = Ipc.define({ id: "acme.awake", methods: { get: { output: Schema.Boolean } } }) const Other = Ipc.define({ id: "other.ipc", methods: {} }) const [available, setAvailable] = createSignal(false) const [awake, setAwake] = createSignal(false) const [failed, setFailed] = createSignal(false) const kept: Kept = {} const client = { get: async () => awake(), state: () => ({ awake: awake() }), on: () => () => {}, } const host = mountPlugins({ ipc: (token: { readonly id: string }) => (token.id === Awake.id && available() ? client : undefined), ipcFailed: (token: { readonly id: string }) => token.id === Awake.id && failed(), definitions: [{ id: "acme", windowEntry: async () => ({ default: (ctx: Context) => void (kept.ctx = ctx) }) }], }) await until(() => !!kept.ctx) const ctx = kept.ctx if (!ctx) throw new Error("the plugin never set up") const main = ctx.main(Awake) // A call that waits rejects once main fails to answer the window's subscribe, instead of waiting forever. const stuck = main.get() setFailed(true) const unanswered = await stuck.then( () => false, (cause: unknown) => String(cause).includes("unavailable: failed"), ) setFailed(false) const waiting = main.get() const before = main.state() setAwake(true) setAvailable(true) const answered = await waiting const state = main.state() const foreign = (() => { try { ctx.main(Other) return false } catch (cause) { return String(cause).includes('calls only its own main entry, not Ipc "other.ipc"') } })() host.disable(["acme"]) const rejected = await main.get().then( () => false, (cause: unknown) => String(cause).includes("unavailable"), ) host.unmount() return { unanswered, before, answered, state, foreign, rejected, same: ctx.main(Awake) === main } }, fixture) expect(result).toEqual({ unanswered: true, before: undefined, answered: true, state: { awake: true }, foreign: true, rejected: true, same: true, }) }, ) story("a contract component reads its provider context and preserves reactive props", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { bindPlugin, createComponent, createSignal, Contract, mountPlugins, Slot, until, usePlugin } = await import( fixture ) const View: Contract<{ View: Component<{ readonly label: string }> }, "provider.view"> = Contract.define("provider.view") const observed = { context: "", read: () => "", change: () => {}, mounts: 0 } const host = mountPlugins({ definitions: [ { id: "provider", windowEntry: async () => ({ default: (ctx: Context) => { ctx.provide(View, { View: bindPlugin((props: { readonly label: string }) => { observed.context = usePlugin().id observed.read = () => props.label observed.mounts++ return document.createTextNode(observed.context) }), }) }, }), }, { id: "consumer", windowEntry: async () => ({ default: (ctx: Context) => { const view = ctx.use(View) const [label, setLabel] = createSignal("before") observed.change = () => setLabel("after") ctx.add(Slot, () => { const live = view() if (live.status !== "active") return undefined return { at: "window.bottom", render: () => createComponent(live.value.View, { get label() { return label() }, }), } }) }, }), }, ], }) await until(() => host.container.textContent === "provider") const before = observed.read() observed.change() const outcome = { context: observed.context, mounts: observed.mounts, before, after: observed.read() } host.unmount() return outcome }, fixture) expect(result).toEqual({ context: "provider", mounts: 1, before: "before", after: "after" }) }) story("Extensions › Plugins merges the own server's plugins with the desktop GUIs they run", async ({ page }, info) => { const calls = await page.evaluateHandle(async (fixture) => { const { mountPlugins, until } = await import(fixture) const calls: string[] = [] const installed: Installed[] = [ // A built-in that runs stays hidden; one that failed keeps a row, as nothing else reports it. { id: "review", builtin: true, enabled: true }, { id: "terminal", builtin: true, enabled: true }, { id: "acme", builtin: false, enabled: true, server: true, tui: true }, // A package with only a GUI part has no other part to tag, and here the server list does not name it. { id: "notes", builtin: false, enabled: false, server: false, tui: false }, // A plugin whose `os` leaves out this system keeps a row that says so, and never loads. { id: "windows-only", builtin: false, enabled: true, server: true, unsupported: true }, // A bundle main could not read never reaches the window. { id: "garbled", builtin: false, enabled: true, error: "SyntaxError: garbled bundle" }, { id: "flaky", builtin: false, enabled: true }, // A plugin whose GUI has no window entry runs only in main, so main's error is its only status. { id: "main-only", builtin: false, enabled: true, error: "Error: main broke" }, ] const listed = (id: string, features: PluginInfo["features"]): PluginInfo => ({ id, source: { type: "package", target: id }, features, state: { status: "active" }, }) const bridge: Bridge = { packaged: false, call: async () => undefined, subscribe: async () => ({ available: false }), on: () => () => {}, embed() {}, capture: async () => undefined, runMenubarItem() {}, configure() {}, pluginGuis: async () => ({ plugins: [], reserved: [] }), manager: { initial: Promise.resolve(installed), list: async () => installed, enable: async (id) => void calls.push(`enable ${id}`), disable: async (id) => void calls.push(`disable ${id}`), reload: async (id) => void calls.push(`reload ${id}`), }, } const fails = (message: string) => async () => ({ default: () => { throw new Error(message) }, }) const host = mountPlugins({ bridge, plugins: [ listed("acme", { gui: true, server: true, tui: true }), listed("windows-only", { gui: true, server: true }), listed("flaky", { gui: true }), // A plugin without a GUI lists its name only. listed("lint-rules", { server: true }), // The server's own built-ins are not listed, as on every server. { ...listed("opencode-builtin", { server: true }), source: { type: "builtin" } }, ], definitions: [ { id: "review", windowEntry: async () => ({ default: () => {} }) }, { id: "terminal", windowEntry: fails("terminal broke") }, { id: "acme", windowEntry: async () => ({ default: () => {} }) }, { id: "notes", windowEntry: async () => ({ default: () => {} }) }, { id: "flaky", windowEntry: fails("flaky broke") }, { id: "main-only" }, ], }) await until(() => !!host.container.textContent?.includes("cli.json") && host.status("flaky") === "failed") return calls }, fixture) const plugins = page.getByTestId("extension-plugins") await plugins.screenshot({ path: info.outputPath("extension-plugins.png") }) // One row per plugin id, in the server's order, then what only main reports, then failed built-ins. await expect(plugins.locator(".settings-extension-name")).toHaveText([ "acme", "windows-only", "flaky", "lint-rules", "notes", "main-only", "garbled", "terminal", ]) // Only plugins from the config tag their other parts, and one note says how to turn those off. await expect(plugins.getByText("Server", { exact: true })).toHaveCount(1) await expect(plugins.getByText("TUI", { exact: true })).toHaveCount(1) await expect(plugins.getByText(/Switches turn off only a plugin's desktop UI/)).toHaveCount(1) await expect(plugins.getByText("Not available on this system.", { exact: true })).toBeVisible() // An error shows its first line; the rest, such as a stack trace, is behind a toggle that a one-line error lacks. await expect(plugins.getByText("SyntaxError: garbled bundle", { exact: true })).toBeVisible() await expect(plugins.getByText("Error: flaky broke", { exact: true })).toBeVisible() await expect(plugins.getByText("Error: terminal broke", { exact: true })).toBeVisible() await expect(plugins.getByText("Error: main broke", { exact: true })).toBeVisible() await expect(plugins.getByRole("button", { name: "Show details" })).toHaveCount(2) const flaky = plugins.locator(".settings-extension-row").filter({ hasText: "flaky" }) await flaky.getByRole("button", { name: "Show details" }).press("Enter") await expect(flaky.locator("pre")).toHaveText(/^Error: flaky broke\s+at /) await expect(flaky.getByRole("button", { name: "Hide details" })).toHaveAttribute("aria-expanded", "true") // Every plugin settled: none still says it loads. await expect(plugins.getByText("Failed", { exact: true })).toHaveCount(3) await expect(plugins.getByText("Loading", { exact: true })).toHaveCount(0) await expect(plugins.getByText("Built-in", { exact: true })).toHaveCount(1) // A built-in always runs, so it has no switch; the switches carry on and off without a status label. await expect(plugins.getByRole("switch")).toHaveCount(4) await expect(plugins.getByRole("switch", { name: "terminal" })).toHaveCount(0) await expect(plugins.getByRole("button", { name: "Reload" })).toHaveCount(5) await expect(plugins.getByText("Active", { exact: true })).toHaveCount(0) // The fixture mounts below the story, out of the viewport, so the controls are driven from the keyboard. await plugins.getByRole("switch", { name: "acme" }).press("Space") await plugins.getByRole("button", { name: "Reload" }).nth(1).press("Enter") expect(await calls.jsonValue()).toEqual(["disable acme", "reload flaky"]) }) const invalidSetups = ["promise", "thenable"] invalidSetups.forEach((kind) => { story(`window setup rejects a returned ${kind}`, async ({ page }) => { const result = await page.evaluate( async (input) => { const { mountPlugins, until } = await import(input.fixture) const resume = Promise.withResolvers() const done = Promise.withResolvers() const registry = { kind: "registry" as const, id: "invalid-setup" } const observed = { aborted: false } const host = mountPlugins({ definitions: [ { id: "invalid", windowEntry: async () => ({ default: (ctx: Context) => { ctx.add(registry, "before") if (input.kind === "thenable") return { then(resolve: () => void) { resolve() }, } return resume.promise.then(() => { observed.aborted = ctx.signal.aborted ctx.add(registry, "after") done.resolve() }) }, }), }, ], }) await until(() => host.ready()) const failed = { status: host.status("invalid"), error: host.failure("invalid")?.error.includes("Window setup must be synchronous"), entries: host.entries(registry.id), } resume.resolve() if (input.kind === "promise") await done.promise const after = { entries: host.entries(registry.id), aborted: observed.aborted } host.unmount() return { failed, after } }, { fixture, kind }, ) expect(result).toEqual({ failed: { status: "failed", error: true, entries: 0 }, after: { entries: 0, aborted: kind === "promise" }, }) }) }) story("declared global stores load before setup, so setup reads the stored value", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPlugins, until, Schema, Store } = await import(fixture) const Prefs = Schema.Struct({ open: Schema.Boolean }) const seen: unknown[] = [] const host = mountPlugins({ stored: { "plugin.fixture.prefs": { open: true } }, definitions: [ { id: "fixture", stores: { prefs: Store.global(Prefs, { open: false }) }, windowEntry: async () => ({ default: (ctx: Context & { stores: { prefs: { value: { open: boolean } } } }) => void seen.push(ctx.stores.prefs.value.open), }), }, ], }) await new Promise((resolve) => setTimeout(resolve, 50)) const held = { status: host.status("fixture"), seen: [...seen] } host.release() await until(() => host.status("fixture") === "active") const outcome = { held, seen } host.unmount() return outcome }, fixture) expect(result).toEqual({ held: { status: "loading", seen: [] }, seen: [true] }) }) story("startup enable state does not wait for the manager, and live updates beat older replies", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { Contract, mountPlugins, until } = await import(fixture) const Provider: Contract = Contract.define("disabled.value") const observed = { read: (): Live => ({ status: "pending" }) } const initial = Promise.withResolvers() const reply = Promise.withResolvers() const listeners = new Set<(message: BridgeMessage) => void>() const setups: string[] = [] const list = (disabled: readonly string[]): Installed[] => ["enabled", "disabled"].map((id) => ({ id, builtin: false, enabled: !disabled.includes(id) })) const bridge: Bridge = { packaged: false, call: async () => undefined, subscribe: async () => ({ available: false }), on: (listener) => { listeners.add(listener) return () => void listeners.delete(listener) }, embed() {}, capture: async () => undefined, runMenubarItem() {}, configure() {}, pluginGuis: async () => ({ plugins: [], reserved: [] }), manager: { initial: initial.promise, list: () => reply.promise, enable: async () => {}, disable: async () => {}, reload: async () => {}, }, } const host = mountPlugins({ bridge, // The consumer comes first; the provider's setting arrives asynchronously from the preload. definitions: [ { id: "enabled", windowEntry: async () => ({ default: (ctx: Context) => { setups.push("enabled") observed.read = ctx.use(Provider) }, }), }, { id: "disabled", windowEntry: async () => ({ default: (ctx: Context) => { setups.push("disabled") ctx.provide(Provider, 1) }, }), }, ], }) await new Promise((resolve) => setTimeout(resolve, 50)) const before = [...setups] initial.resolve([{ id: "disabled", enabled: false }]) await until(() => host.ready()) const started = { setups: [...setups], status: host.status("disabled"), live: observed.read(), } const unknown: { ready: boolean; setups: string[]; status?: string }[] = [] // A failed startup reply must not temporarily activate the disabled plugin while list() waits. for (const failure of ["unknown", "rejected"]) { const initial = failure === "unknown" ? Promise.resolve(undefined) : Promise.reject(new Error("snapshot failed")) const reply = Promise.withResolvers() const failed = mountPlugins({ bridge: { ...bridge, manager: { ...bridge.manager, initial, list: () => reply.promise } }, definitions: [ { id: "disabled", windowEntry: async () => ({ default: () => void setups.push("wrongly enabled") }) }, ], }) await new Promise((resolve) => setTimeout(resolve, 50)) const blocked = { ready: failed.ready(), setups: [...setups] } reply.resolve(list(["disabled"])) await until(() => failed.ready()) unknown.push({ ...blocked, status: failed.status("disabled") }) failed.unmount() } listeners.forEach((listener) => listener({ type: "installed", list: list(["enabled"]) })) await until(() => host.status("disabled") === "active" && host.status("enabled") === "disabled") reply.resolve(list(["disabled"])) await new Promise((resolve) => setTimeout(resolve, 50)) const updated = { setups: [...setups], status: [host.status("enabled"), host.status("disabled")], } host.unmount() return { before, started, unknown, updated, listeners: listeners.size } }, fixture) expect(result).toEqual({ before: [], started: { setups: ["enabled"], status: "disabled", live: { status: "inactive", reason: "disabled" }, }, unknown: [ { ready: false, setups: ["enabled"], status: "disabled" }, { ready: false, setups: ["enabled"], status: "disabled" }, ], updated: { setups: ["enabled", "disabled"], status: ["disabled", "active"] }, listeners: 0, }) }) story( "a plugin theme joins the theme picker and leaves it with its plugin, keeping the user's choice", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountThemes, until, Theme } = await import(fixture) const root = document.documentElement const variant = { palette: { neutral: "#101418", ink: "#e6edf3", primary: "#009fff", success: "#3fb950", warning: "#d29922", error: "#f85149", info: "#a371f7", }, } const host = mountThemes({ definitions: [ { id: "acme", windowEntry: async () => ({ default: (ctx: Context) => void ctx.add(Theme, { $schema: "https://opencode.ai/desktop-theme.json", name: "Midnight", id: "midnight", light: variant, dark: variant, }), }), }, ], }) await until(() => host.status("acme") === "active") const names = host.options().map((option: { name: string }) => option.name) const index = names.indexOf("Midnight") host.select("acme:midnight") await until(() => root.dataset.theme === "acme:midnight") host.disable("acme") await until(() => root.dataset.theme === "oc-2") const withdrawn = { listed: host.options().some((option: { id: string }) => option.id === "acme:midnight"), selected: host.selected(), stored: localStorage.getItem("opencode-theme-id"), } host.enable() await until(() => root.dataset.theme === "acme:midnight") const restored = host.selected() host.unmount() return { neighbors: names.slice(index - 1, index + 2), withdrawn, restored } }, fixture) expect(result).toEqual({ // Alphabetical among the app's themes, not appended after them. neighbors: ["Mercury", "Midnight", "Monokai"], withdrawn: { listed: false, selected: "oc-2", stored: "acme:midnight" }, restored: "acme:midnight", }) }, ) story("a theme preview survives a system light/dark flip and a plugin theme arriving, and is never saved", async ({ page, }) => { await page.emulateMedia({ colorScheme: "dark" }) const host: JSHandle = await page.evaluateHandle(async (fixture) => { const { mountThemes, Theme } = await import(fixture) // The frame's own provider saved its scheme; this window follows the system. localStorage.setItem("opencode-color-scheme", "system") localStorage.removeItem("opencode-theme-id") localStorage.removeItem("opencode-theme-css-light") localStorage.removeItem("opencode-theme-css-dark") const variant = { palette: { neutral: "#101418", ink: "#e6edf3", primary: "#009fff", success: "#3fb950", warning: "#d29922", error: "#f85149", info: "#a371f7", }, } const host: ThemesHost = mountThemes({ definitions: [ { id: "acme", windowEntry: async () => ({ default: (ctx: Context) => void ctx.add(Theme, { $schema: "https://opencode.ai/desktop-theme.json", name: "Midnight", id: "midnight", light: variant, dark: variant, }), }), }, ], }) host.previewScheme("light") return host }, fixture) const read = () => host.evaluate((host) => ({ theme: document.documentElement.dataset.theme, shown: document.documentElement.dataset.colorScheme, mode: host.mode(), scheme: localStorage.getItem("opencode-color-scheme"), id: localStorage.getItem("opencode-theme-id"), cached: [localStorage.getItem("opencode-theme-css-light"), localStorage.getItem("opencode-theme-css-dark")].map( (css) => css !== null, ), })) // A media change reaches the provider's listener before one added after it. const flip = async (colorScheme: "light" | "dark") => { const changed = await page.evaluateHandle(() => ({ done: new Promise((resolve) => matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => resolve(), { once: true }), ), })) await page.emulateMedia({ colorScheme }) await changed.evaluate((changed) => changed.done) } // The plugin's theme arrives while the preview is up. await expect.poll(() => host.evaluate((host) => host.status("acme"))).toBe("active") const previewed = await read() await flip("light") await flip("dark") const flipped = await read() await host.evaluate((host) => host.preview("acme:midnight")) const midnight = await read() await host.evaluate((host) => host.cancel()) const cancelled = await read() await host.evaluate((host) => { host.preview("acme:midnight") host.commit() }) const committed = await read() await host.evaluate((host) => host.unmount()) expect({ previewed, flipped, midnight, cancelled, committed }).toEqual({ previewed: { theme: "oc-2", shown: "light", mode: "light", scheme: "system", id: null, cached: [false, false] }, flipped: { theme: "oc-2", shown: "light", mode: "light", scheme: "system", id: null, cached: [false, false] }, midnight: { theme: "acme:midnight", shown: "light", mode: "light", scheme: "system", id: null, cached: [false, false], }, cancelled: { theme: "oc-2", shown: "dark", mode: "dark", scheme: "system", id: null, cached: [false, false] }, committed: { theme: "acme:midnight", shown: "dark", mode: "dark", scheme: "system", id: "acme:midnight", cached: [true, true], }, }) }) story("a selected plugin theme that never returns shows the default theme once plugins settle", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountThemes, until } = await import(fixture) const root = document.documentElement // The last run picked a theme whose plugin is now uninstalled, and cached its colors. localStorage.setItem("opencode-theme-id", "gone:midnight") localStorage.setItem("opencode-theme-css-dark", "--background-base:#010203;") localStorage.setItem("opencode-theme-css-light", "--background-base:#010203;") const host = mountThemes({ definitions: [], pending: true }) // The window is ready to show on the cached colors while the plugin list is still on its way. await until(() => host.applied() > 0) const waiting = host.selected() // The frame's own provider already shows the default theme; mark the root to see this provider apply it. root.dataset.theme = "frame" host.load() await until(() => root.dataset.theme === "oc-2") const settled = { selected: host.selected(), stored: localStorage.getItem("opencode-theme-id"), cached: localStorage.getItem("opencode-theme-css-dark") ?? localStorage.getItem("opencode-theme-css-light"), } host.unmount() return { waiting, settled } }, fixture) expect(result).toEqual({ waiting: "gone:midnight", settled: { selected: "oc-2", stored: "gone:midnight", cached: null }, }) }) story( "a panel with a width of its own sizes the side region and keeps the width the user drags it to", async ({ page }) => { const result = await page.evaluate(async (fixture) => { const { mountPanelWidths, until, Panel } = await import(fixture) const panel = (id: string, width?: "narrow") => ({ id: `${id}-plugin`, windowEntry: async () => ({ default: (ctx: Context) => void ctx.add(Panel, { id, region: "side", width, list: (input: { open: readonly string[] }) => input.open.includes("main") ? [{ id: "main", title: id }] : [], render: () => null, }), }), }) // The row is 1200px wide: the chat panel and the side region share 1192px beside the 8px gap. const host = mountPanelWidths({ definitions: [panel("quota", "narrow"), panel("files")], active: "quota-plugin:main", width: 1200, }) // The chat panel is on screen at this width, and the divider's handle starts a drag from it. const settle = (width: number) => until(() => host.chat() === width && host.handle() === width) // `narrow` leaves the side region its minimum, 320px. await settle(872) host.drag(700) await settle(700) const dragged = { widths: host.widths(), conversation: host.conversation() } // A panel without a width keeps the conversation's, and a drag on it changes only that. host.select("files-plugin:main") await settle(600) host.drag(650) await settle(650) const shared = { widths: host.widths(), conversation: host.conversation() } host.select("quota-plugin:main") await settle(700) host.unmount() return { dragged, shared } }, fixture) expect(result).toEqual({ dragged: { widths: { "quota-plugin:quota": 492 }, conversation: { width: 600, resized: false } }, shared: { widths: { "quota-plugin:quota": 492 }, conversation: { width: 650, resized: true } }, }) }, ) story("composer pills show per session, open their rows, and fail only their own plugin", async ({ page }) => { const statuses = await page.evaluate(async (fixture) => { const { mountComposerPills, until, ComposerPill } = await import(fixture) const host = mountComposerPills({ definitions: [ { id: "acme", windowEntry: async () => ({ default: (ctx: Context) => void ctx.add(ComposerPill, { id: "pulls", show: (input: { readonly session: { readonly directory: string } }) => ({ icon: "github", label: "2 pull requests", tone: "danger", title: "Pull requests", rows: [ { id: "12", label: "#12 Fix the tree", icon: "circle-x", tone: "danger", detail: input.session.directory, run: () => void (document.body.dataset.picked = "12"), }, { id: "13", label: "#13 Draft", icon: "branch" }, ], }), }), }), }, // A pill with nothing to show for this session renders nothing. { id: "quiet", windowEntry: async () => ({ default: (ctx: Context) => void ctx.add(ComposerPill, { id: "none", show: () => undefined }), }), }, { id: "broken", windowEntry: async () => ({ default: (ctx: Context) => void ctx.add(ComposerPill, { id: "boom", show: () => { throw new Error("pill failed") }, }), }), }, ], }) await until(() => !!host.failure("broken") && host.status("acme") === "active") return { acme: host.status("acme"), quiet: host.status("quiet"), broken: host.failure("broken"), } }, fixture) expect(statuses).toEqual({ acme: "active", quiet: "active", broken: { phase: "render", error: expect.stringContaining("pill failed") }, }) const pills = page.getByTestId("composer-pills").locator('[data-slot="session-plugin-pill"]') await expect(pills).toHaveCount(1) await expect(pills).toHaveText("2 pull requests") await expect(pills).toHaveAttribute("aria-label", "Pull requests") await expect(pills.locator("svg").first()).toHaveClass(/text-v2-state-fg-danger/) await pills.click() const menu = page.locator('[data-slot="session-plugin-pill-menu"]') await expect(menu.getByText("Pull requests")).toBeVisible() const rows = menu.locator('[data-slot="session-plugin-pill-row"]') await expect(rows).toHaveCount(2) await expect(rows.nth(0)).toContainText("/repo") // A row without `run` only shows. await expect(rows.nth(1)).toHaveAttribute("data-disabled", "") await rows.nth(0).click() await expect(page.locator("body")).toHaveAttribute("data-picked", "12") }) story("a composer pill with a menu opens the plugin's content under the host's heading", async ({ page }) => { await page.evaluate(async (fixture) => { const { mountComposerPills, until, ComposerPill } = await import(fixture) const host = mountComposerPills({ definitions: [ { id: "acme", windowEntry: async () => ({ default: (ctx: Context) => void ctx.add(ComposerPill, { id: "goal", // `menu` wins over the view's rows. show: () => ({ label: "Goal", title: "Ship the release", rows: [{ id: "unused", label: "Unused" }] }), menu: (input: { readonly session: { readonly directory: string }; close(): void }) => { const button = document.createElement("button") button.dataset.testid = "goal-pause" button.textContent = `Pause ${input.session.directory}` button.onclick = () => input.close() return button }, }), }), }, ], }) await until(() => host.status("acme") === "active") }, fixture) const pill = page.getByTestId("composer-pills").locator('[data-slot="session-plugin-pill"]') await expect(pill).toHaveText("Goal") await pill.click() const popover = page.locator('[data-slot="session-plugin-pill-popover"]') await expect(popover.getByText("Ship the release")).toBeVisible() await expect(popover.locator('[data-slot="session-plugin-pill-row"]')).toHaveCount(0) await expect(popover.getByTestId("goal-pause")).toHaveText("Pause /repo") await popover.getByTestId("goal-pause").click() await expect(popover).toHaveCount(0) // The content renders again on the next open. await pill.click() await expect(page.getByTestId("goal-pause")).toBeVisible() }) story("code block content replaces complete blocks of its languages, and actions sit beside Copy", async ({ page }) => { const host = await page.evaluateHandle(async (fixture) => { const { mountCodeBlocks, until, CodeBlock, CodeBlockAction } = await import(fixture) const host: CodeBlocksHost = mountCodeBlocks({ definitions: [ { id: "acme", windowEntry: async () => ({ default: (ctx: Context) => { ctx.add(CodeBlock, { languages: ["chart"], render: (props: CodeBlockProps) => { const chart = document.createElement("div") chart.dataset.testid = "chart" chart.textContent = `${props.language} ${props.code} in ${props.session?.directory}` return chart }, }) ctx.add(CodeBlock, { languages: ["broken"], render: () => { throw new Error("chart failed") }, }) // Declines, so the block keeps its code. ctx.add(CodeBlock, { languages: ["quiet"], render: () => undefined }) ctx.add(CodeBlockAction, { id: "run", languages: ["sh"], icon: "terminal", title: "Run in terminal", run: (input: CodeBlockProps) => void (document.body.dataset.ran = `${input.code} in ${input.session?.directory}`), }) }, }), }, ], }) await until(() => host.status("acme") === "active") // The closing fence has not arrived yet. host.show("```chart\nA,B", true) return host }, fixture) const root = page.getByTestId("code-blocks") const blocks = root.locator('[data-component="markdown-code"]') await expect(blocks.locator("pre code")).toHaveText("A,B") await expect(root.getByTestId("chart")).toHaveCount(0) // The closing fence arrives while the message still streams. await host.evaluate((host) => host.show("```chart\nA,B\n```\n\nMore", true)) await expect(root.getByTestId("chart")).toHaveText("chart A,B in /repo") await expect(blocks.locator("pre")).toBeHidden() await host.evaluate((host) => host.show( [ "```chart\nA,B\n```", "```broken\nx\n```", "```quiet\ny\n```", "```sh\necho hi\n```", "```ts\nconst a = 1\n```", ].join("\n\n"), ), ) await expect(blocks).toHaveCount(5) await expect(root.getByTestId("chart")).toHaveText("chart A,B in /repo") await expect(blocks.nth(0).locator("pre")).toBeHidden() // A render that throws or declines keeps the code; the failure is the plugin's, which keeps running. for (const index of [1, 2]) { await expect(blocks.nth(index).locator("pre")).toBeVisible() await expect(blocks.nth(index).locator('[data-slot="markdown-code-content"]')).toHaveCount(0) } expect(await host.evaluate((host) => ({ failure: host.failure("acme"), status: host.status("acme") }))).toEqual({ failure: { phase: "render", error: expect.stringContaining("chart failed") }, status: "active", }) // Only the shell block gets the action, before its Copy button; the TypeScript block is untouched. await expect(root.locator('[data-slot="markdown-code-actions"]')).toHaveCount(1) const shell = blocks.nth(3) await expect(shell.locator('[data-slot="markdown-code-actions"] + [data-slot="markdown-copy-button"]')).toHaveCount(1) await shell.hover() await shell.getByRole("button", { name: "Run in terminal" }).click() await expect(page.locator("body")).toHaveAttribute("data-ran", "echo hi in /repo") await expect(blocks.nth(4).locator("pre code")).toHaveText("const a = 1") await expect( blocks.nth(4).locator('[data-slot="markdown-code-actions"], [data-slot="markdown-code-content"]'), ).toHaveCount(0) }) story("code block content follows plugins that join and leave after the message rendered", async ({ page }) => { const host = await page.evaluateHandle(async (fixture) => { const { mountCodeBlocks, until } = await import(fixture) const host: CodeBlocksHost = mountCodeBlocks({ definitions: [] }) host.show("```chart\nA,B\n```") await until(() => !!document.querySelector('[data-testid="code-blocks"] [data-markdown-ready] pre code')) return host }, fixture) const root = page.getByTestId("code-blocks") await expect(root.locator("pre code")).toHaveText("A,B") await host.evaluate(async (host, fixture) => { const { CodeBlock } = await import(fixture) host.define([ { id: "acme", windowEntry: async () => ({ default: (ctx: Context) => void ctx.add(CodeBlock, { languages: ["chart"], render: () => { const chart = document.createElement("div") chart.dataset.testid = "chart" chart.textContent = "drawn" return chart }, }), }), }, ]) }, fixture) await expect(root.getByTestId("chart")).toHaveText("drawn") await expect(root.locator("pre")).toBeHidden() await host.evaluate((host) => host.define([])) await expect(root.getByTestId("chart")).toHaveCount(0) await expect(root.locator("pre code")).toHaveText("A,B") })