opencode/packages/app/test-browser/plugin-primitives.test.ts

575 lines
19 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { createComponent, createRoot, createSignal, onCleanup, type Accessor } from "solid-js"
import { Schema } from "effect"
import {
createKeyed,
createLatest,
createVisitState,
type Context,
type Persisted,
type SessionRef,
} from "@opencode/plugin/gui"
import { liveAccessor, PluginContext } from "@opencode/plugin/gui/host"
import type { Platform } from "@/runtime/platform/platform"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { createLocatedWrites } from "@/runtime/plugin/located"
import { createSessionStore, globalStoreTarget, persistedHandle, whenLoaded } from "@/runtime/plugin/stores"
const pending = { status: "pending" } as const
const restarting = { status: "inactive", reason: "restarting" } as const
const first = { status: "active", value: "first", generation: 1 } as const
const second = { status: "active", value: "second", generation: 2 } as const
const Items = Schema.Struct({ items: Schema.Array(Schema.String) })
const Noted = Schema.Struct({
items: Schema.Array(Schema.String),
note: Schema.optional(Schema.String),
})
/** A web window: storage is the page's localStorage. */
const web: Platform = {
platform: "web",
openExternal: () => undefined,
restart: async () => undefined,
notify: async () => undefined,
openDirectoryPickerDialog: async () => null,
}
/** A session the store and layout code can key and locate; they read nothing else. */
function session(key: Accessor<string>, location: Accessor<{ directory: string } | undefined>) {
const value = {
get key() {
return key()
},
get location() {
return location()
},
}
// SAFETY: the code under test reads only `key` and `location`, which this value provides.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
return value as unknown as SessionRef
}
describe("plugin primitives", () => {
test.each([
{
name: "a Live source runs once per generation and otherwise between them",
start: () => {
const [read, write] = createSignal<Live<string>>(pending)
return {
source: liveAccessor(read),
steps: [first, { ...first }, restarting, second, pending].map((step) => () => write(step)),
}
},
expected: [
"otherwise",
"end otherwise",
"run first",
"end first",
"otherwise",
"end otherwise",
"run second",
"end second",
"otherwise",
],
},
{
name: "a plain accessor runs once per value identity and otherwise while it is empty",
start: () => {
const [read, write] = createSignal<string | undefined>(undefined)
return {
source: read,
steps: ["first", "first", undefined, "second", "third"].map((step) => () => write(step)),
}
},
expected: [
"otherwise",
"end otherwise",
"run first",
"end first",
"otherwise",
"end otherwise",
"run second",
"end second",
"run third",
],
},
])("createKeyed: $name", (row) => {
const log: string[] = []
const scenario = row.start()
const dispose = createRoot((dispose) => {
createKeyed(
scenario.source,
(value) => {
log.push(`run ${value}`)
onCleanup(() => log.push(`end ${value}`))
},
{
otherwise: () => {
log.push("otherwise")
onCleanup(() => log.push("end otherwise"))
},
},
)
return dispose
})
scenario.steps.forEach((step) => step())
expect(log).toEqual(row.expected)
dispose()
})
test("createKeyed with equals keys an object source by the fields it compares", () => {
const [read, write] = createSignal({ directory: "a", loading: true })
const log: string[] = []
const dispose = createRoot((dispose) => {
createKeyed(
() => ({ directory: read().directory, loading: read().loading }),
(value) => {
log.push(`run ${value.directory}`)
onCleanup(() => log.push(`end ${value.directory}`))
},
{ equals: (previous, next) => previous.directory === next.directory },
)
return dispose
})
// The listing loads, which is the same key; then the directory changes.
write({ directory: "a", loading: false })
write({ directory: "b", loading: true })
expect(log).toEqual(["run a", "end a", "run b"])
dispose()
})
test("createLatest clears the error when the next request starts and while the source has no value", async () => {
const [key, setKey] = createSignal<string | undefined>("a")
const requests = new Map<string, PromiseWithResolvers<string>>()
const root = createRoot((dispose) => ({
dispose,
latest: createLatest(key, (value) => {
const reply = Promise.withResolvers<string>()
requests.set(value, reply)
return reply.promise
}),
}))
const error = () => (root.latest.error instanceof Error ? root.latest.error.message : root.latest.error)
const seen: unknown[] = []
requests.get("a")?.reject(new Error("a failed"))
await Bun.sleep(0)
seen.push(error())
setKey("b")
seen.push(error())
requests.get("b")?.reject(new Error("b failed"))
await Bun.sleep(0)
seen.push(error())
setKey(undefined)
seen.push(error())
expect(seen).toEqual(["a failed", undefined, "b failed", undefined])
root.dispose()
})
test("createLatest aborts the previous request and drops its late reply", async () => {
const [key, setKey] = createSignal<string | undefined>("a")
const requests = new Map<string, { signal: AbortSignal; reply: PromiseWithResolvers<string> }>()
const root = createRoot((dispose) => ({
dispose,
latest: createLatest(key, (value, signal) => {
const reply = Promise.withResolvers<string>()
requests.set(value, { signal, reply })
return reply.promise
}),
}))
expect(root.latest.loading).toBe(true)
setKey("b")
requests.get("b")?.reply.resolve("result b")
requests.get("a")?.reply.resolve("result a")
await Bun.sleep(0)
expect({
aborted: [requests.get("a")?.signal.aborted, requests.get("b")?.signal.aborted],
latest: root.latest.latest,
loading: root.latest.loading,
}).toEqual({ aborted: [true, false], latest: "result b", loading: false })
root.dispose()
expect(requests.get("b")?.signal.aborted).toBe(true)
})
test("createVisitState returns to its initial value on every routing visit", () => {
const [visit, setVisit] = createSignal<object>({})
const fake = { sessions: { list: () => [], current: () => ({ visit: visit() }) } }
// SAFETY: `createVisitState` reads only `sessions.current().visit`, which this fake provides.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const context = fake as unknown as Context
const captured: ReturnType<typeof createVisitState<string>>[] = []
const dispose = createRoot((dispose) => {
createComponent(PluginContext.Provider, {
value: context,
get children() {
captured.push(createVisitState("initial"))
return null
},
})
return dispose
})
const [value, set] = captured[0] ?? [() => "missing", () => undefined]
const steps = [
{ action: () => set("chosen"), expected: "chosen" },
{ action: () => setVisit({}), expected: "initial" },
{ action: () => set("again"), expected: "again" },
{ action: () => setVisit({}), expected: "initial" },
]
expect(steps.map((step) => (step.action(), value()))).toEqual(steps.map((step) => step.expected))
dispose()
})
test.each([
{ name: "while the storage read is held", location: { directory: "/repo" } },
{ name: "while the session location is unknown", location: undefined },
{ name: "while held with an invalid returned replacement", location: { directory: "/repo" }, invalid: true },
])("store writes made $name apply in order over the stored value", async (row) => {
const held = Promise.withResolvers<void>()
const key = `plugin-store-${crypto.randomUUID()}`
const platform: Platform = {
platform: "desktop",
windowID: "plugin-store-test",
openExternal: () => undefined,
restart: async () => undefined,
notify: async () => undefined,
openDirectoryPickerDialog: async () => null,
storage: () => ({
getItem: async () => {
await held.promise
return JSON.stringify({ items: ["stored"] })
},
setItem: async () => undefined,
removeItem: async () => undefined,
}),
}
const [location, setLocation] = createSignal<{ directory: string } | undefined>(row.location)
const opened: Persisted<(typeof Items)["Type"]>[] = []
const root = createRoot((dispose) => {
const store = createSessionStore({
open: () => {
const pair = persisted(Persist.global(key), Items, { items: [] }, platform)
const handle = persistedHandle({ store: pair[0], set: pair[1], init: pair[3].promise })
opened.push(handle)
return handle
},
owner: null,
sessions: { list: () => [], current: () => undefined },
})
return {
dispose: () => {
store.dispose()
dispose()
},
handle: store.get(session(() => "server\nses_store", location)),
}
})
if ("invalid" in row) {
// @ts-expect-error JavaScript plugins can return an invalid replacement before hydration too
root.handle.update(() => ({ items: ["wrong"] }))
held.resolve()
await expect(Promise.all(opened.map(whenLoaded))).rejects.toThrow("Use set")
root.dispose()
return
}
root.handle.update((draft) => void draft.items.push("discarded"))
root.handle.set({ items: ["replacement"] })
root.handle.update((draft) => void draft.items.push("first"))
root.handle.update((draft) => void draft.items.push("second"))
const before = { value: root.handle.value, ready: root.handle.ready() }
setLocation({ directory: "/repo" })
held.resolve()
await Promise.all(opened.map(whenLoaded))
expect({ before, after: root.handle.value?.items, ready: root.handle.ready() }).toEqual({
before: { value: undefined, ready: false },
after: ["replacement", "first", "second"],
ready: true,
})
root.dispose()
})
test.each([
{
name: "an edit to the draft",
write: (handle: Persisted<(typeof Noted)["Type"]>) => handle.update((draft) => void draft.items.push("b")),
expected: { items: ["a", "b"], note: "kept" },
},
{
name: "set replaces",
write: (handle: Persisted<(typeof Noted)["Type"]>) => handle.set({ items: ["b"] }),
expected: { items: ["b"] },
},
{
name: "update rejects a returned replacement",
write: (handle: Persisted<(typeof Noted)["Type"]>) =>
expect(() => {
// @ts-expect-error JavaScript plugins can still return a replacement at runtime
handle.update(() => ({ items: ["wrong"] }))
}).toThrow("Use set"),
expected: { items: ["a"], note: "kept" },
},
])("store writes: $name becomes the whole stored value", (row) => {
const key = `plugin-update-${crypto.randomUUID()}`
localStorage.setItem(`opencode.global.dat:${key}`, JSON.stringify({ items: ["a"], note: "kept" }))
const root = createRoot((dispose) => {
const pair = persisted(Persist.global(key), Noted, { items: [] }, web)
return { dispose, handle: persistedHandle({ store: pair[0], set: pair[1], init: pair[3].promise }) }
})
row.write(root.handle)
flushPersisted()
expect({
value: root.handle.value,
stored: JSON.parse(localStorage.getItem(`opencode.global.dat:${key}`) ?? "null"),
}).toEqual({ value: row.expected, stored: row.expected })
root.dispose()
})
test.each([
{
name: "a store whose older key was never imported",
older: "old",
seed: { items: ["old"] },
from: (older: string) => older,
opened: false,
kept: null,
},
{
name: "a store that picked its value from a shared key",
older: "shared",
seed: { prefs: { items: ["old"] }, other: 1 },
from: (older: string) => ({ key: older, pick: (value: { prefs?: unknown } | null) => value?.prefs }),
opened: true,
kept: { prefs: { items: ["old"] }, other: 1 },
},
])("store remove: $name reads its initial value again and never imports its older key", (row) => {
const plugin = `remove-${crypto.randomUUID()}`
const older = `${row.older}-${plugin}`
const from = row.from(older)
localStorage.setItem(older, JSON.stringify(row.seed))
const open = () =>
createRoot((dispose) => {
const value = persisted(globalStoreTarget(plugin, "items", from, true), Items, { items: [] }, web)[0]
const items = [...value.items]
dispose()
return items
})
const imported = row.opened ? open() : undefined
removePersisted(globalStoreTarget(plugin, "items", from, true), web)
expect({ imported, reopened: open(), older: JSON.parse(localStorage.getItem(older) ?? "null") }).toEqual({
imported: row.opened ? ["old"] : undefined,
reopened: [],
older: row.kept,
})
})
test("a session store follows the host's live ref for its key, not the object it first opened with", () => {
// The screen's object keeps reading the controller it was made with; the host's ref follows the live one, here
// after the server re-authenticated and reports the session in another directory.
const first = session(
() => "server\nses_live",
() => ({ directory: "/before" }),
)
const [location, setLocation] = createSignal<{ directory: string } | undefined>({ directory: "/before" })
const listed = session(() => "server\nses_live", location)
const opened: string[] = []
const root = createRoot((dispose) => ({
dispose,
store: createSessionStore({
open: (target) => {
const directory = target.location?.directory ?? ""
opened.push(directory)
return persistedHandle({ store: { directory }, set: () => undefined, init: undefined })
},
owner: null,
sessions: { list: () => [listed], current: () => first },
}),
}))
const handle = root.store.get(first)
setLocation({ directory: "/after" })
expect({ value: handle.value?.directory, opened }).toEqual({ value: "/after", opened: ["/before", "/after"] })
root.store.dispose()
root.dispose()
})
test("a session store opened through each routed session's object reads that session, A to B (pending) to A", () => {
const [routed, setRouted] = createSignal("a")
const [located, setLocated] = createSignal<readonly string[]>(["a"])
// `MountedSession`s: one object per routed session, whose location is that session's, never the route's.
const views = new Map(
["a", "b"].map((id) => [
id,
session(
() => `server\n${id}`,
() => (located().includes(id) ? { directory: `/${id}` } : undefined),
),
]),
)
const view = () =>
views.get(routed()) ??
session(
() => "",
() => undefined,
)
const opened: string[] = []
const root = createRoot((dispose) => ({
dispose,
store: createSessionStore({
open: (target) => {
const directory = target.location?.directory ?? ""
opened.push(directory)
return persistedHandle({ store: { directory }, set: () => undefined, init: undefined })
},
owner: null,
sessions: { list: () => [], current: view },
}),
}))
const a = root.store.get(view())
const read = (handles: readonly Persisted<{ directory: string }>[]) =>
handles.map((handle) => handle.value?.directory)
setRouted("b")
const b = root.store.get(view())
const pending = read([a, b])
setLocated(["a", "b"])
const known = read([a, b])
setRouted("a")
const again = root.store.get(view())
const back = read([again, b])
expect({ pending, known, back, same: again === a, opened }).toEqual({
pending: ["/a", undefined],
known: ["/a", "/b"],
back: ["/a", "/b"],
same: true,
opened: ["/a", "/b"],
})
root.store.dispose()
root.dispose()
})
test("a routed session store survives before its tab is listed, while a closed tab's store is dropped", () => {
const views = ["routed", "closed", "new"].map((id) =>
session(
() => `server\n${id}`,
() => ({ directory: `/${id}` }),
),
)
const [listed, setListed] = createSignal<readonly SessionRef[]>([views[1]])
const [current, setCurrent] = createSignal<SessionRef | undefined>(views[0])
const ended: string[] = []
const root = createRoot((dispose) => ({
dispose,
store: createSessionStore({
open: (target) => {
onCleanup(() => void ended.push(target.key))
return persistedHandle({ store: { key: target.key }, set: () => undefined, init: undefined })
},
owner: null,
sessions: { list: listed, current },
}),
}))
const routed = root.store.get(views[0])
const closed = root.store.get(views[1])
const fresh = root.store.get(views[2])
// A deep link preloads before the titlebar lists its tab. Another tab closes in that window.
setListed([])
expect({ routed: root.store.get(views[0]) === routed, fresh: root.store.get(views[2]) === fresh, ended }).toEqual({
routed: true,
fresh: true,
ended: [views[1].key],
})
expect(root.store.get(views[1])).not.toBe(closed)
// Once listed, the routed store still survives its tab closing until the route leaves it.
setListed([views[0]])
setListed([])
expect(root.store.get(views[0])).toBe(routed)
setCurrent(undefined)
expect(ended).toEqual([views[1].key, views[0].key])
expect(root.store.get(views[0])).not.toBe(routed)
root.store.dispose()
root.dispose()
})
test("a layout write held while the session location is unknown runs once, in order, when it is known", () => {
const [location, setLocation] = createSignal<{ directory: string } | undefined>()
const target = session(() => "server\nses_layout", location)
const log: string[] = []
const root = createRoot((dispose) => ({ dispose, writes: createLocatedWrites() }))
root.writes.hold(target, () => log.push("open"))
root.writes.hold(target, () => log.push("scroll"))
const before = [...log]
setLocation({ directory: "/repo" })
setLocation({ directory: "/repo" })
expect({ before, after: log }).toEqual({ before: [], after: ["open", "scroll"] })
root.dispose()
})
})