Files

2234 lines
82 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof mountCodeBlocks>
/** What the theme preview case drives from the page. */
type ThemesHost = ReturnType<typeof mountThemes>
/** 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<string | undefined> }
/** 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<void>()
const resume = Promise.withResolvers<void>()
const done = Promise.withResolvers<void>()
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<HTMLElement>('[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<keyof typeof mounts, { readonly session: { readonly id: string }; readonly screen: SessionScreen }>
> = {}
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<void>()
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<never>(() => {}) }])
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<void>()
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<void>()
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<void>()
const done = Promise.withResolvers<void>()
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<number, "disabled.value"> = Contract.define("disabled.value")
const observed = { read: (): Live<number> => ({ status: "pending" }) }
const initial = Promise.withResolvers<readonly EnableState[]>()
const reply = Promise.withResolvers<readonly Installed[]>()
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<readonly Installed[]>()
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<ThemesHost> = 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<void>((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")
})