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