307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { BrowserHost } from "@opencode-ai/core/browser-host"
|
|
import { Browser } from "@opencode-ai/schema/browser"
|
|
import { BrowserControl } from "@opencode-ai/schema/browser-control"
|
|
import { Session } from "@opencode-ai/schema/session"
|
|
import { Cause, Deferred, Effect, Fiber, Layer, Option, Queue, Scope, Stream } from "effect"
|
|
import { TestClock } from "effect/testing"
|
|
import { testEffect } from "./lib/effect"
|
|
|
|
const it = testEffect(
|
|
Layer.effect(
|
|
BrowserHost.Service,
|
|
BrowserHost.make(() => Effect.succeed(true)),
|
|
),
|
|
)
|
|
const denied = testEffect(
|
|
Layer.effect(
|
|
BrowserHost.Service,
|
|
BrowserHost.make(() => Effect.succeed(false)),
|
|
),
|
|
)
|
|
const sessionID = Session.ID.make("ses_browser_host")
|
|
const state: Browser.State = {
|
|
url: "https://example.com/",
|
|
title: "Example",
|
|
loading: false,
|
|
canGoBack: false,
|
|
canGoForward: false,
|
|
generation: 3,
|
|
}
|
|
|
|
const makePeer = Effect.gen(function* () {
|
|
const inbound = yield* Queue.unbounded<BrowserControl.FromDesktop, BrowserHost.ConnectionError>()
|
|
const outbound = yield* Queue.unbounded<BrowserControl.FromServer>()
|
|
const closed = yield* Deferred.make<{ close: BrowserHost.CloseReason; message: string }>()
|
|
return {
|
|
peer: {
|
|
messages: Stream.fromQueue(inbound),
|
|
send: (message) => Queue.offer(outbound, message).pipe(Effect.asVoid),
|
|
close: (close, message) => Deferred.succeed(closed, { close, message }).pipe(Effect.asVoid),
|
|
} satisfies BrowserHost.Peer,
|
|
inbound,
|
|
outbound,
|
|
closed,
|
|
}
|
|
})
|
|
|
|
const attach = (peer: Effect.Success<typeof makePeer>, leaseID: Browser.LeaseID, revision = 1) =>
|
|
Queue.offer(peer.inbound, {
|
|
type: "browser.control.sync" as const,
|
|
revision,
|
|
attachments: [{ sessionID, leaseID, state }],
|
|
}).pipe(Effect.asVoid)
|
|
|
|
const awaitSynced = Effect.fn("BrowserHostTest.awaitSynced")(function* (peer: Effect.Success<typeof makePeer>) {
|
|
const message = yield* Queue.take(peer.outbound)
|
|
if (message.type !== "browser.control.synced") throw new Error("expected sync acknowledgement")
|
|
return message
|
|
})
|
|
|
|
const awaitLease = Effect.fn("BrowserHostTest.awaitLease")(function* (host: BrowserHost.Interface) {
|
|
while (true) {
|
|
const lease = yield* host.lease(sessionID)
|
|
if (Option.isSome(lease)) return lease.value
|
|
yield* Effect.yieldNow
|
|
}
|
|
})
|
|
|
|
describe("BrowserHost", () => {
|
|
it.effect("correlates requests with the exact synced lease", () =>
|
|
Effect.gen(function* () {
|
|
const host = yield* BrowserHost.Service
|
|
const connection = yield* host.claim
|
|
const transport = yield* makePeer
|
|
yield* Effect.forkChild(connection.run(transport.peer))
|
|
const leaseID = Browser.LeaseID.make("brl_first")
|
|
yield* attach(transport, leaseID)
|
|
expect(yield* awaitSynced(transport)).toEqual({ type: "browser.control.synced", revision: 1 })
|
|
const lease = yield* awaitLease(host)
|
|
|
|
const result = yield* Effect.forkChild(lease.request({ type: "snapshot", generation: state.generation }))
|
|
const request = yield* Queue.take(transport.outbound)
|
|
expect(request).toMatchObject({
|
|
type: "browser.control.request",
|
|
sessionID,
|
|
leaseID,
|
|
command: { type: "snapshot", generation: state.generation },
|
|
})
|
|
if (request.type !== "browser.control.request") throw new Error("expected request")
|
|
yield* Queue.offer(transport.inbound, {
|
|
type: "browser.control.response",
|
|
requestID: request.requestID,
|
|
leaseID,
|
|
outcome: {
|
|
type: "success",
|
|
result: { type: "snapshot", state, format: "opencode.semantic.v1", content: "page" },
|
|
},
|
|
})
|
|
|
|
expect(yield* Fiber.join(result)).toEqual({
|
|
type: "snapshot",
|
|
state,
|
|
format: "opencode.semantic.v1",
|
|
content: "page",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("revokes captured leases instead of redirecting them", () =>
|
|
Effect.gen(function* () {
|
|
const host = yield* BrowserHost.Service
|
|
const connection = yield* host.claim
|
|
const transport = yield* makePeer
|
|
yield* Effect.forkChild(connection.run(transport.peer))
|
|
yield* attach(transport, Browser.LeaseID.make("brl_first"))
|
|
yield* awaitSynced(transport)
|
|
const first = yield* awaitLease(host)
|
|
|
|
yield* attach(transport, Browser.LeaseID.make("brl_second"), 2)
|
|
yield* awaitSynced(transport)
|
|
yield* first.revoked
|
|
const stale = yield* first.request({ type: "snapshot", generation: state.generation }).pipe(Effect.result)
|
|
expect(stale).toMatchObject({
|
|
_tag: "Failure",
|
|
failure: { _tag: "BrowserHost.RequestError", code: "not_attached" },
|
|
})
|
|
expect((yield* awaitLease(host)).id).toBe(Browser.LeaseID.make("brl_second"))
|
|
}),
|
|
)
|
|
|
|
it.effect("sends cancellation when request execution is interrupted", () =>
|
|
Effect.gen(function* () {
|
|
const host = yield* BrowserHost.Service
|
|
const connection = yield* host.claim
|
|
const transport = yield* makePeer
|
|
yield* Effect.forkChild(connection.run(transport.peer))
|
|
const leaseID = Browser.LeaseID.make("brl_cancel")
|
|
yield* attach(transport, leaseID)
|
|
yield* awaitSynced(transport)
|
|
const lease = yield* awaitLease(host)
|
|
|
|
const fiber = yield* Effect.forkChild(
|
|
lease.request({ type: "click", ref: Browser.Ref.make("e1"), generation: state.generation }),
|
|
)
|
|
const request = yield* Queue.take(transport.outbound)
|
|
if (request.type !== "browser.control.request") throw new Error("expected request")
|
|
yield* Fiber.interrupt(fiber)
|
|
|
|
expect(yield* Queue.take(transport.outbound)).toEqual({
|
|
type: "browser.control.cancel",
|
|
requestID: request.requestID,
|
|
leaseID,
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("settles the maximum legal response burst without overflowing transport assumptions", () =>
|
|
Effect.gen(function* () {
|
|
const host = yield* BrowserHost.Service
|
|
const connection = yield* host.claim
|
|
const transport = yield* makePeer
|
|
yield* Effect.forkChild(connection.run(transport.peer))
|
|
const leaseID = Browser.LeaseID.make("brl_burst")
|
|
yield* attach(transport, leaseID)
|
|
yield* awaitSynced(transport)
|
|
const lease = yield* awaitLease(host)
|
|
const scope = yield* Scope.Scope
|
|
|
|
const fibers = yield* Effect.forEach(
|
|
Array.from({ length: 32 }),
|
|
() =>
|
|
Effect.forkIn(lease.request({ type: "snapshot", generation: state.generation }), scope, {
|
|
startImmediately: true,
|
|
}),
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
while ((yield* Queue.size(transport.outbound)) < 32) yield* Effect.yieldNow
|
|
const requests = yield* Queue.takeAll(transport.outbound)
|
|
expect(requests.length).toBe(32)
|
|
yield* Effect.forEach(
|
|
requests,
|
|
(request) => {
|
|
if (request.type !== "browser.control.request") return Effect.die("expected request")
|
|
return Queue.offer(transport.inbound, {
|
|
type: "browser.control.response" as const,
|
|
requestID: request.requestID,
|
|
leaseID,
|
|
outcome: {
|
|
type: "success" as const,
|
|
result: {
|
|
type: "snapshot" as const,
|
|
state,
|
|
format: "opencode.semantic.v1" as const,
|
|
content: "page",
|
|
},
|
|
},
|
|
})
|
|
},
|
|
{ concurrency: "unbounded", discard: true },
|
|
)
|
|
expect((yield* Fiber.joinAll(fibers)).length).toBe(32)
|
|
}),
|
|
)
|
|
|
|
it.effect("rejects a second process-local browser owner", () =>
|
|
Effect.gen(function* () {
|
|
const host = yield* BrowserHost.Service
|
|
yield* host.claim
|
|
expect(yield* host.claim.pipe(Effect.result)).toMatchObject({
|
|
_tag: "Failure",
|
|
failure: { _tag: "BrowserHost.OwnerExistsError" },
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("does not admit another owner after shutdown", () =>
|
|
Effect.gen(function* () {
|
|
const host = yield* BrowserHost.Service
|
|
yield* host.claim
|
|
yield* host.shutdown
|
|
yield* host.shutdown
|
|
|
|
expect(yield* host.claim.pipe(Effect.result)).toMatchObject({
|
|
_tag: "Failure",
|
|
failure: { _tag: "BrowserHost.OwnerExistsError", message: expect.stringContaining("shutting down") },
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("requires an initial attachment snapshot", () =>
|
|
Effect.gen(function* () {
|
|
const host = yield* BrowserHost.Service
|
|
const connection = yield* host.claim
|
|
const transport = yield* makePeer
|
|
const running = yield* Effect.forkChild(connection.run(transport.peer))
|
|
yield* TestClock.adjust("5 seconds")
|
|
expect(yield* Fiber.join(running).pipe(Effect.result)).toMatchObject({
|
|
_tag: "Failure",
|
|
failure: { _tag: "BrowserHost.ProtocolError" },
|
|
})
|
|
expect(yield* host.claim.pipe(Effect.as(true))).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.effect("revokes leases when their Session is deleted", () =>
|
|
Effect.gen(function* () {
|
|
const available = { value: true }
|
|
const host = yield* BrowserHost.make(() => Effect.succeed(available.value))
|
|
const connection = yield* host.claim
|
|
const transport = yield* makePeer
|
|
yield* Effect.forkChild(connection.run(transport.peer))
|
|
yield* attach(transport, Browser.LeaseID.make("brl_deleted"))
|
|
yield* awaitSynced(transport)
|
|
const lease = Option.getOrThrow(yield* host.lease(sessionID))
|
|
|
|
available.value = false
|
|
expect(Option.isNone(yield* host.lease(sessionID))).toBe(true)
|
|
yield* lease.revoked
|
|
expect(
|
|
yield* lease.request({ type: "snapshot", generation: state.generation }).pipe(Effect.result),
|
|
).toMatchObject({
|
|
_tag: "Failure",
|
|
failure: { _tag: "BrowserHost.RequestError", code: "not_attached" },
|
|
})
|
|
}),
|
|
)
|
|
|
|
denied.effect("rejects attachment snapshots for unknown Sessions", () =>
|
|
Effect.gen(function* () {
|
|
const host = yield* BrowserHost.Service
|
|
const connection = yield* host.claim
|
|
const transport = yield* makePeer
|
|
const running = yield* Effect.forkChild(connection.run(transport.peer))
|
|
yield* attach(transport, Browser.LeaseID.make("brl_unknown"))
|
|
expect(yield* Fiber.join(running).pipe(Effect.result)).toMatchObject({
|
|
_tag: "Failure",
|
|
failure: { _tag: "BrowserHost.ProtocolError" },
|
|
})
|
|
expect(Option.isNone(yield* host.lease(sessionID))).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.effect("clears attachments and pending requests when the connection fails", () =>
|
|
Effect.gen(function* () {
|
|
const host = yield* BrowserHost.Service
|
|
const connection = yield* host.claim
|
|
const transport = yield* makePeer
|
|
yield* Effect.forkChild(connection.run(transport.peer))
|
|
yield* attach(transport, Browser.LeaseID.make("brl_disconnect"))
|
|
yield* awaitSynced(transport)
|
|
const lease = yield* awaitLease(host)
|
|
const request = yield* Effect.forkChild(lease.request({ type: "snapshot", generation: state.generation }))
|
|
yield* Queue.take(transport.outbound)
|
|
|
|
Queue.failCauseUnsafe(
|
|
transport.inbound,
|
|
Cause.fail(new BrowserHost.ConnectionError({ kind: "closed", message: "disconnected" })),
|
|
)
|
|
|
|
expect(yield* request.pipe(Fiber.join, Effect.result)).toMatchObject({
|
|
_tag: "Failure",
|
|
failure: { _tag: "BrowserHost.RequestError", code: "not_attached" },
|
|
})
|
|
expect(Option.isNone(yield* host.lease(sessionID))).toBe(true)
|
|
}),
|
|
)
|
|
})
|