Files
opencode/packages/plugin-browser/test/machine.test.ts

451 lines
20 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { Session } from "@opencode/schema/session"
import { Schema } from "effect"
import { BrowserMachine } from "../src/machine.js"
import { Browser } from "../src/rpc.js"
const sessionID = Session.ID.make("ses_browser")
const other = Session.ID.make("ses_other")
const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`)
const tab = {
id: tabID,
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 1,
}
const command: Browser.Command = { action: { type: "tabs.list" }, files: [] }
const success = Schema.decodeUnknownSync(Browser.Outcome)({
type: "success",
result: { value: { ok: true }, files: [] },
})
const failure = Schema.decodeUnknownSync(Browser.Outcome)({ type: "failure", code: "navigation", message: "Blocked" })
type Event = BrowserMachine.ConnectionEvent
function run(state: BrowserMachine.ConnectionState, ...events: Event[]) {
return events.reduce(
(result, event) => {
const next = BrowserMachine.transitionConnection(result.state, event)
return { state: next.state, effects: [...result.effects, ...next.effects] }
},
{ state, effects: [] as BrowserMachine.ConnectionEffect[] },
)
}
/** `attach.started` with the attempt the driver reads from `selectNextAttempt` at that moment. */
function attach(state: BrowserMachine.ConnectionState, connectionID: string, id = sessionID): Event {
return { type: "attach.started", sessionID: id, attempt: BrowserMachine.selectNextAttempt(state), connectionID }
}
function entry(state: BrowserMachine.ConnectionState, id = sessionID) {
return state.sessions.get(id)
}
function syncs(effects: readonly BrowserMachine.ConnectionEffect[]) {
return effects.flatMap((effect) => (effect.type === "sync-permissions" ? [effect] : []))
}
function lastSync(effects: readonly BrowserMachine.ConnectionEffect[]) {
const sync = syncs(effects).at(-1)
if (!sync) throw new Error("expected a permission sync")
return sync
}
function synced(attempt: number, id = sessionID): Event {
return { type: "permissions.synced", sessionID: id, attempt }
}
function controls(effects: readonly BrowserMachine.ConnectionEffect[]) {
return effects.flatMap((effect) => (effect.type === "emit-control" ? [effect.control] : []))
}
function settlements(effects: readonly BrowserMachine.ConnectionEffect[]) {
return effects.flatMap((effect) => (effect.type === "settle-request" ? [effect] : []))
}
function message(settlement: BrowserMachine.RequestSettlement) {
return settlement.ok ? undefined : settlement.error.message
}
/** An attached `conn_1`, as the desktop sees it once its grant succeeded. */
function attached(connectionID = "conn_1") {
const initial = BrowserMachine.createConnectionState()
const started = run(initial, attach(initial, connectionID))
return run(started.state, synced(lastSync(started.effects).attempt)).state
}
function started(requestID: string, connectionID = "conn_1"): Event {
return { type: "request.started", sessionID, connectionID, requestID, command }
}
describe("attachment replace", () => {
test("a replaced attachment never revokes its replacement's grant", () => {
const first = attached()
const replace = run(first, attach(first, "conn_2"))
expect(replace.effects).toContainEqual({ type: "close-attachment", attempt: 1, outcome: "replaced" })
expect(entry(replace.state)?.status).toBe("attaching")
// conn_1's RPC scope ends after the replace; that must not touch the session.
const closed = BrowserMachine.transitionConnection(replace.state, {
type: "attach.closed",
sessionID,
attempt: 1,
})
expect(closed.state).toBe(replace.state)
expect(closed.effects).toEqual([])
const done = run(replace.state, synced(lastSync(replace.effects).attempt))
expect(controls(done.effects)).toEqual([{ type: "attached", connectionID: "conn_2", version: 4 }])
expect(syncs([...replace.effects, ...done.effects]).every((sync) => sync.allow)).toBe(true)
expect(entry(done.state)).toMatchObject({ status: "attached", connectionID: "conn_2" })
})
test("a re-attach waits for the revoke in flight, then grants", () => {
const closed = run(attached(), { type: "attach.closed", sessionID, attempt: 1 })
const revoke = lastSync(closed.effects)
expect(revoke).toMatchObject({ allow: false })
const reattach = run(closed.state, attach(closed.state, "conn_2"))
expect(reattach.effects).toEqual([])
expect(entry(reattach.state)).toMatchObject({
status: "attaching",
sync: { attempt: revoke.attempt, allow: false },
})
const revoked = run(reattach.state, synced(revoke.attempt))
const grant = lastSync(revoked.effects)
expect(grant).toMatchObject({ allow: true })
expect(controls(revoked.effects)).toEqual([])
const done = run(revoked.state, synced(grant.attempt))
expect(controls(done.effects)).toEqual([{ type: "attached", connectionID: "conn_2", version: 4 }])
})
test("a replace while attaching reuses the grant in flight and hands `attached` only to the replacement", () => {
const initial = BrowserMachine.createConnectionState()
const first = run(initial, attach(initial, "conn_1"))
const grant = lastSync(first.effects)
const replace = run(first.state, attach(first.state, "conn_2"))
expect(replace.effects).toEqual([{ type: "close-attachment", attempt: 1, outcome: "replaced" }])
const done = run(replace.state, synced(grant.attempt))
expect(controls(done.effects)).toEqual([{ type: "attached", connectionID: "conn_2", version: 4 }])
})
})
describe("handshake", () => {
test("no tool reaches an attachment before its grant succeeds", () => {
const initial = BrowserMachine.createConnectionState()
const first = run(initial, attach(initial, "conn_1"))
expect(controls(first.effects)).toEqual([])
expect(BrowserMachine.selectTarget(first.state, sessionID, { type: "tabs.list" })).toMatchObject({
ok: false,
error: { message: expect.stringContaining("still connecting") },
})
const early = BrowserMachine.transitionConnection(first.state, started("req_1"))
expect(early.state).toBe(first.state)
expect(early.effects).toEqual([
{ type: "settle-request", requestID: "req_1", settlement: { ok: false, error: expect.anything() } },
])
const report = BrowserMachine.transitionConnection(first.state, {
type: "state.updated",
sessionID,
connectionID: "conn_1",
browserState: { tabs: [tab], focusedTabID: tabID },
})
expect(report.state).toBe(first.state)
const done = run(first.state, synced(lastSync(first.effects).attempt))
expect(controls(done.effects)).toEqual([{ type: "attached", connectionID: "conn_1", version: 4 }])
expect(BrowserMachine.selectTarget(done.state, sessionID, { type: "tabs.list" })).toMatchObject({
ok: true,
connectionID: "conn_1",
})
})
test.each(["attach.closed", "session.moved"] as const)(
"%s while attaching never sends `attached` and revokes after the grant settles",
(type) => {
const initial = BrowserMachine.createConnectionState()
const first = run(initial, attach(initial, "conn_1"))
const grant = lastSync(first.effects)
const closed = run(first.state, type === "attach.closed" ? { type, sessionID, attempt: 1 } : { type, sessionID })
expect(closed.effects).toEqual([{ type: "close-attachment", attempt: 1, outcome: "closed" }])
const settled = run(closed.state, synced(grant.attempt))
expect(controls(settled.effects)).toEqual([])
const revoke = lastSync(settled.effects)
expect(revoke).toMatchObject({ allow: false })
const done = run(settled.state, synced(revoke.attempt))
expect(entry(done.state)).toBeUndefined()
expect(done.effects).toEqual([])
},
)
test("a request for an attachment that closed after the tool selected it is never sent", () => {
const first = attached()
expect(BrowserMachine.selectTarget(first, sessionID, { type: "tabs.list" })).toMatchObject({
ok: true,
connectionID: "conn_1",
})
const replaced = run(first, attach(first, "conn_2"))
const synced2 = run(replaced.state, synced(lastSync(replaced.effects).attempt)).state
const late = BrowserMachine.transitionConnection(synced2, started("req_1", "conn_1"))
expect(late.state).toBe(synced2)
expect(late.effects).toHaveLength(1)
expect(message(settlements(late.effects)[0]!.settlement)).toContain("no action ran")
})
})
describe("permission syncs", () => {
test.each([
{ type: "permissions.failed" as const, error: new Error("session gone") },
{ type: "permissions.timed-out" as const },
])("a grant that ends with $type fails the attach and revokes what it may have written", (failed) => {
const initial = BrowserMachine.createConnectionState()
const first = run(initial, attach(initial, "conn_1"))
const grant = lastSync(first.effects)
const next = run(first.state, { ...failed, sessionID, attempt: grant.attempt })
expect(next.effects).toContainEqual({ type: "close-attachment", attempt: 1, outcome: "unavailable" })
expect(next.effects.some((effect) => effect.type === "warn")).toBe(true)
expect(controls(next.effects)).toEqual([])
const revoke = lastSync(next.effects)
expect(revoke).toMatchObject({ allow: false })
const done = run(next.state, { type: "permissions.failed", sessionID, attempt: revoke.attempt, error: undefined })
expect(entry(done.state)).toBeUndefined()
expect(syncs(done.effects)).toEqual([])
})
test("a failed revoke before a re-attach still grants the new attachment", () => {
const closed = run(attached(), { type: "attach.closed", sessionID, attempt: 1 })
const reattach = run(closed.state, attach(closed.state, "conn_2"))
const next = run(reattach.state, {
type: "permissions.timed-out",
sessionID,
attempt: lastSync(closed.effects).attempt,
})
expect(lastSync(next.effects)).toMatchObject({ allow: true })
expect(entry(next.state)).toMatchObject({ status: "attaching", connectionID: "conn_2" })
})
test("stale sync results and stale attach events change nothing", () => {
const initial = BrowserMachine.createConnectionState()
const first = run(initial, attach(initial, "conn_1"))
for (const event of [
synced(99),
{ type: "permissions.failed", sessionID, attempt: 99, error: undefined },
{ type: "permissions.timed-out", sessionID, attempt: 99 },
synced(lastSync(first.effects).attempt, other),
{ type: "attach.closed", sessionID, attempt: 99 },
{ type: "attach.started", sessionID, attempt: 1, connectionID: "conn_9" },
] satisfies Event[]) {
const next = BrowserMachine.transitionConnection(first.state, event)
expect(next.state).toBe(first.state)
expect(next.effects).toEqual([])
}
})
})
describe("reconcileBrowserPermissions", () => {
const user = { action: "bash", resource: "*", effect: "ask" } as const
const allows = BrowserMachine.ACCESS.map((rule) => ({ ...rule, effect: "allow" as const }))
test("granting what is already granted writes nothing", () => {
expect(BrowserMachine.reconcileBrowserPermissions([user, ...allows], true)).toBeUndefined()
expect(BrowserMachine.reconcileBrowserPermissions([user], false)).toBeUndefined()
expect(BrowserMachine.reconcileBrowserPermissions(undefined, false)).toBeUndefined()
})
test("revoking removes every duplicate and keeps the user's rules in order", () => {
expect(BrowserMachine.reconcileBrowserPermissions([...allows, user, ...allows], false)).toEqual([user])
expect(BrowserMachine.reconcileBrowserPermissions([...allows, user, ...allows], true)).toEqual([user, ...allows])
expect(BrowserMachine.reconcileBrowserPermissions(undefined, true)).toEqual(allows)
})
test("browser denies are not browser allows", () => {
const deny = { action: "browser", resource: "*", effect: "deny" } as const
expect(BrowserMachine.reconcileBrowserPermissions([deny], false)).toBeUndefined()
})
})
describe("requests", () => {
const live = () => {
const state = attached()
return run(state, started("req_1"))
}
test("a started request dispatches once and keeps its command for the desktop", () => {
const next = live()
expect(next.effects).toEqual([{ type: "dispatch-command", sessionID, connectionID: "conn_1", requestID: "req_1" }])
expect(BrowserMachine.selectPendingCommand(next.state, sessionID, "conn_1", "req_1")).toBe(command)
expect(BrowserMachine.selectPendingCommand(next.state, sessionID, "conn_2", "req_1")).toBeUndefined()
})
test.each([
{ outcome: success, expected: { ok: true, result: { value: { ok: true }, files: [] } } },
{
outcome: failure,
expected: { ok: false, error: expect.objectContaining({ message: "[browser.navigation] Blocked" }) },
},
])("a result settles the request ($outcome.type)", ({ outcome, expected }) => {
const next = run(live().state, {
type: "request.settled",
sessionID,
connectionID: "conn_1",
requestID: "req_1",
outcome,
})
expect(next.effects).toEqual([{ type: "settle-request", requestID: "req_1", settlement: expected }])
expect(BrowserMachine.selectPendingCommand(next.state, sessionID, "conn_1", "req_1")).toBeUndefined()
})
test("a failed dispatch settles without a cancel", () => {
const next = run(live().state, {
type: "request.dispatch-failed",
sessionID,
connectionID: "conn_1",
requestID: "req_1",
error: new Error("bus closed"),
})
expect(controls(next.effects)).toEqual([])
expect(message(settlements(next.effects)[0]!.settlement)).toContain("Could not dispatch browser.tabs.list")
})
test("a timeout settles and cancels on the desktop", () => {
const next = run(live().state, { type: "request.timed-out", sessionID, connectionID: "conn_1", requestID: "req_1" })
expect(message(settlements(next.effects)[0]!.settlement)).toContain("[browser.timeout]")
expect(controls(next.effects)).toEqual([{ type: "cancel", connectionID: "conn_1", requestID: "req_1" }])
})
test("an interrupted caller cancels on the desktop without a settlement", () => {
const next = run(live().state, { type: "request.cancelled", sessionID, connectionID: "conn_1", requestID: "req_1" })
expect(next.effects).toEqual([
{ type: "emit-control", control: { type: "cancel", connectionID: "conn_1", requestID: "req_1" } },
])
})
test("a close fails pending requests as possibly run, without a cancel", () => {
const next = run(live().state, { type: "attach.closed", sessionID, attempt: 1 })
expect(controls(next.effects)).toEqual([])
expect(message(settlements(next.effects)[0]!.settlement)).toContain("the action may already have run")
})
test("late results after a settlement change nothing", () => {
const settled = run(live().state, {
type: "request.settled",
sessionID,
connectionID: "conn_1",
requestID: "req_1",
outcome: success,
}).state
for (const event of [
{ type: "request.settled", sessionID, connectionID: "conn_1", requestID: "req_1", outcome: success },
{ type: "request.timed-out", sessionID, connectionID: "conn_1", requestID: "req_1" },
{ type: "request.cancelled", sessionID, connectionID: "conn_1", requestID: "req_1" },
{ type: "request.dispatch-failed", sessionID, connectionID: "conn_1", requestID: "req_1", error: undefined },
] satisfies Event[]) {
const next = BrowserMachine.transitionConnection(settled, event)
expect(next.state).toBe(settled)
expect(next.effects).toEqual([])
}
})
})
describe("browser state", () => {
test("the reported inventory resolves tab targets; another connection's report is ignored", () => {
const state = attached()
const browserState = { tabs: [tab], focusedTabID: tabID }
const stale = BrowserMachine.transitionConnection(state, {
type: "state.updated",
sessionID,
connectionID: "conn_2",
browserState,
})
expect(stale.state).toBe(state)
expect(BrowserMachine.selectTarget(state, sessionID, { type: "back", tabID })).toMatchObject({
ok: false,
error: { message: expect.stringContaining("[browser.tab_unavailable]") },
})
const next = run(state, { type: "state.updated", sessionID, connectionID: "conn_1", browserState })
expect(BrowserMachine.selectTarget(next.state, sessionID, { type: "back", tabID })).toMatchObject({ ok: true, tab })
})
})
describe("session lifecycle", () => {
test("deleting an attached session closes it without a revoke", () => {
const next = run(attached(), { type: "session.deleted", sessionID })
expect(next.effects).toEqual([{ type: "close-attachment", attempt: 1, outcome: "closed" }])
expect(entry(next.state)).toBeUndefined()
})
test("deleting an attaching session ends after its grant settles, without a revoke", () => {
const initial = BrowserMachine.createConnectionState()
const first = run(initial, attach(initial, "conn_1"))
const deleted = run(first.state, { type: "session.deleted", sessionID })
expect(entry(deleted.state)).toMatchObject({ status: "revoking", deleted: true })
const done = run(deleted.state, synced(lastSync(first.effects).attempt))
expect(entry(done.state)).toBeUndefined()
expect(done.effects).toEqual([])
})
test("moving an attached session revokes its grant", () => {
const next = run(attached(), { type: "session.moved", sessionID })
expect(next.effects).toContainEqual({ type: "close-attachment", attempt: 1, outcome: "closed" })
expect(lastSync(next.effects)).toMatchObject({ allow: false })
})
})
describe("disposal", () => {
test("an idle plugin drains at once", () => {
const next = run(BrowserMachine.createConnectionState(), { type: "disposed" })
expect(next.effects).toEqual([{ type: "notify-drained" }])
expect(BrowserMachine.isConnectionDisposed(next.state)).toBe(true)
})
test("disposal closes every attachment at once and drains only when the last revoke settles", () => {
const first = attached()
const withOther = run(first, attach(first, "conn_other", other))
const otherGrant = lastSync(withOther.effects)
const disposed = run(withOther.state, { type: "disposed" })
expect(disposed.effects).toContainEqual({ type: "close-attachment", attempt: 1, outcome: "closed" })
expect(disposed.effects).toContainEqual({ type: "close-attachment", attempt: 3, outcome: "closed" })
expect(disposed.effects).not.toContainEqual({ type: "notify-drained" })
expect(BrowserMachine.selectTarget(disposed.state, sessionID, { type: "tabs.list" }).ok).toBe(false)
expect(BrowserMachine.selectAttachment(disposed.state, sessionID, "conn_1")).toBeUndefined()
const revoke = lastSync(disposed.effects)
const firstDone = run(disposed.state, synced(revoke.attempt))
expect(firstDone.effects).toEqual([])
// The other session's grant was still in flight; it is revoked before the plugin drains.
const otherSettled = run(firstDone.state, synced(otherGrant.attempt, other))
const otherRevoke = lastSync(otherSettled.effects)
expect(otherRevoke).toMatchObject({ sessionID: other, allow: false })
expect(otherSettled.effects).not.toContainEqual({ type: "notify-drained" })
const drained = run(otherSettled.state, synced(otherRevoke.attempt, other))
expect(drained.effects).toEqual([{ type: "notify-drained" }])
expect(drained.state.sessions.size).toBe(0)
})
test("a disposed plugin rejects requests and ignores attachments", () => {
const disposed = run(BrowserMachine.createConnectionState(), { type: "disposed" }).state
const request = BrowserMachine.transitionConnection(disposed, started("req_1"))
expect(request.state).toBe(disposed)
expect(message(settlements(request.effects)[0]!.settlement)).toContain("no action ran")
const late = BrowserMachine.transitionConnection(disposed, attach(disposed, "conn_1"))
expect(late.state).toBe(disposed)
expect(late.effects).toEqual([])
})
})