Ctrl K
ComunidadGuías…

packages/app/component-tests/plugin-host.spec.ts

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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")
})