feat(simulation): expose semantic UI snapshots (#37802)

This commit is contained in:
Kit Langton 2026-07-19 16:04:27 -04:00 committed by GitHub
commit edc93ceff1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 477 additions and 43 deletions

View file

@ -1,6 +1,9 @@
import { expect, test } from "bun:test"
import { BoxRenderable, TextRenderable } from "@opentui/core"
import { Effect } from "effect"
import { execute, type Harness, matches } from "../src/frontend/actions"
import { createHarness, execute, type Harness, matches, snapshot, state } from "../src/frontend/actions"
import { SimulationRenderer } from "../src/frontend/renderer"
import { SimulationSemantics } from "../src/frontend/semantics"
test("matches literal screen text", () => {
const harness = { screen: () => "OpenCode [ready].*" }
@ -39,3 +42,144 @@ test("normalizes named keys for OpenTUI", async () => {
["x", undefined],
])
})
test("clicks a target at relative coordinates through descendant text", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
let clicks = 0
const button = new BoxRenderable(renderer, {
id: "permission.action.once",
width: 12,
height: 1,
onMouseUp: () => clicks++,
})
button.add(new TextRenderable(renderer, { content: "Allow once" }))
renderer.root.add(button)
const harness = createHarness(renderer)
yield* Effect.promise(() => harness.renderOnce())
expect(state(harness).elements).toContainEqual(expect.objectContaining({ id: button.id, clickable: true }))
yield* execute(harness, { type: "ui.click", target: button.num, x: 1, y: 0 })
expect(clicks).toBe(1)
renderer.root.remove(button)
const error = yield* execute(harness, { type: "ui.click", target: button.num, x: 1, y: 0 }).pipe(Effect.flip)
expect(error.message).toContain("click target is stale or unavailable")
}),
),
)
})
test("snapshots lazy semantic hierarchy and interaction state", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
let selected = "once"
const dialog = new BoxRenderable(renderer, { id: "session.permission" })
const actions = new BoxRenderable(renderer, { id: "session.permission.actions" })
const once = new BoxRenderable(renderer, { id: "session.permission.action.once" })
SimulationSemantics.bind(() => ({
instance: "permission-1",
role: "dialog",
label: "Permission required",
expanded: false,
}))(dialog)
SimulationSemantics.bind(() => ({
instance: "permission-1",
role: "listbox",
label: "Permission choices",
}))(actions)
SimulationSemantics.bind(() => ({
instance: "permission-1",
role: "option",
label: "Allow once",
focused: selected === "once",
selected: selected === "once",
disabled: false,
}))(once)
renderer.root.add(dialog)
dialog.add(actions)
actions.add(once)
expect(snapshot(createHarness(renderer))).toEqual({
format: "opencode-ui-snapshot-v1",
nodes: [
{
id: "session.permission",
instance: "permission-1",
role: "dialog",
label: "Permission required",
element: dialog.num,
expanded: false,
},
{
id: "session.permission.actions",
instance: "permission-1",
parent: "session.permission",
role: "listbox",
label: "Permission choices",
element: actions.num,
},
{
id: "session.permission.action.once",
instance: "permission-1",
parent: "session.permission.actions",
role: "option",
label: "Allow once",
element: once.num,
focused: true,
selected: true,
disabled: false,
},
],
})
selected = "reject"
expect(snapshot(createHarness(renderer)).nodes.at(-1)).toMatchObject({
id: "session.permission.action.once",
focused: false,
selected: false,
})
dialog.visible = false
expect(snapshot(createHarness(renderer)).nodes).toEqual([])
}),
),
)
})
test("rejects duplicate semantic identities", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
const first = new BoxRenderable(renderer, { id: "duplicate" })
const second = new BoxRenderable(renderer, { id: "duplicate" })
const definition = () => ({ role: "option" })
SimulationSemantics.bind(definition)(first)
SimulationSemantics.bind(definition)(second)
renderer.root.add(first)
renderer.root.add(second)
expect(() => snapshot(createHarness(renderer))).toThrow("duplicate semantic UI id: duplicate")
}),
),
)
})
test("validates lazy semantic definitions before returning them", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({})
const invalid = new BoxRenderable(renderer, { id: "invalid" })
SimulationSemantics.bind(() => ({ role: "" }))(invalid)
renderer.root.add(invalid)
expect(() => snapshot(createHarness(renderer))).toThrow()
}),
),
)
})

View file

@ -39,7 +39,7 @@ test("scopes the frontend control server and reports malformed JSON", async () =
protocolVersion: 1,
role: "ui",
server: { name: "opencode", version: expect.any(String) },
capabilities: expect.arrayContaining(["ui.state", "ui.capture"]),
capabilities: expect.arrayContaining(["ui.state", "ui.snapshot", "ui.capture"]),
},
})
@ -60,6 +60,13 @@ test("scopes the frontend control server and reports malformed JSON", async () =
},
})
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 3, method: "ui.snapshot" }))
expect(yield* Queue.take(messages)).toEqual({
jsonrpc: "2.0",
id: 3,
result: { format: "opencode-ui-snapshot-v1", nodes: [] },
})
socket.send("{")
expect(yield* Queue.take(messages)).toMatchObject({
id: null,

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { Backend, Frontend, Handshake } from "../src/protocol"
test("decodes ui.matches text params", () => {
@ -21,6 +21,53 @@ test("decodes ui.matches text params", () => {
).toThrow()
})
test("decodes semantic UI snapshots", () => {
expect(
Frontend.decodeRequest({
jsonrpc: "2.0",
id: 1,
method: "ui.snapshot",
}),
).toMatchObject({ method: "ui.snapshot" })
const decode = Schema.decodeUnknownSync(Frontend.SemanticSnapshot)
expect(
decode({
format: "opencode-ui-snapshot-v1",
nodes: [
{
id: "session.permission",
role: "dialog",
label: "Permission required",
element: 1,
expanded: false,
},
],
}),
).toMatchObject({ nodes: [{ role: "dialog", expanded: false }] })
expect(() =>
decode({
format: "opencode-ui-snapshot-v1",
nodes: [{ id: "", role: "dialog", element: 0 }],
}),
).toThrow()
for (const nodes of [
[
{ id: "duplicate", role: "dialog", element: 1 },
{ id: "duplicate", role: "option", element: 2 },
],
[
{ id: "first", role: "dialog", element: 1 },
{ id: "second", role: "option", element: 1 },
],
[{ id: "orphan", parent: "missing", role: "option", element: 1 }],
[
{ id: "first", parent: "second", role: "dialog", element: 1 },
{ id: "second", parent: "first", role: "option", element: 2 },
],
])
expect(() => decode({ format: "opencode-ui-snapshot-v1", nodes })).toThrow()
})
const params: Handshake.Params = {
client: { name: "opencode-drive", version: "test" },
expectedRole: "ui",