mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-08 09:37:47 +03:00
2234 lines
82 KiB
TypeScript
2234 lines
82 KiB
TypeScript
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")
|
||
})
|