refactor(browser): colocate the public API plugin

This commit is contained in:
LukeParkerDev 2026-09-01 14:01:45 +10:00
parent 9ff14f593f
commit 7d71bb753f
9 changed files with 308 additions and 335 deletions

View file

@ -106,20 +106,6 @@
"vite-plugin-solid": "2.11.14",
},
},
"packages/browser": {
"name": "@opencode-ai/browser",
"version": "0.0.0",
"devDependencies": {
"@opencode-ai/sdk": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
"peerDependencies": {
"@opencode-ai/plugin": "workspace:*",
"effect": "catalog:",
},
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.4",
@ -2121,8 +2107,6 @@
"@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"],
"@opencode-ai/browser": ["@opencode-ai/browser@workspace:packages/browser"],
"@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],

View file

@ -1,27 +0,0 @@
{
"name": "@opencode-ai/browser",
"version": "0.0.0",
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts",
"./rpc": "./src/rpc.ts"
},
"files": [
"src"
],
"scripts": {
"test": "bun test --timeout 15000",
"typecheck": "tsgo --noEmit"
},
"peerDependencies": {
"@opencode-ai/plugin": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/sdk": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}

View file

@ -1,264 +0,0 @@
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import plugin from "@opencode-ai/browser"
import { Browser } from "@opencode-ai/browser/rpc"
import { Agent, Rpc, Tool } from "@opencode-ai/plugin/effect"
import { AbsolutePath, Location, OpenCode, SessionMessage } from "@opencode-ai/sdk/effect"
import { Effect, Fiber, Queue, Stream } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
const state: Browser.State = {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 7,
}
const fixture = Effect.gen(function* () {
const directory = yield* tmpdirScoped("opencode-browser-")
const config = path.join(directory.path, "config")
yield* Effect.promise(() => mkdir(config))
const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const opencode = yield* OpenCode.create({
database: { path: ":memory:" },
config: {
directory: config,
project: false,
content: JSON.stringify({ permissions: [{ action: "browser", resource: "*", effect: "allow" }] }),
},
models: { fetch: false },
fs: { filewatcher: false, fff: false },
})
const captured = Promise.withResolvers<Tool.Info>()
const permissions: Array<{ action: string; resources: readonly string[] }> = []
yield* opencode.plugin(plugin)
yield* opencode.plugin({
id: "browser-test-observer",
effect: (ctx) =>
Effect.gen(function* () {
// Inspect the real tool through the public draft, without replacing its executor.
yield* ctx.tool.transform((draft) => {
const tool = draft.get("browser")
if (tool && ctx.location.directory === location.directory) captured.resolve(tool)
})
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => permissions.push({ action: event.action, resources: event.resources })),
)
}).pipe(Effect.orDie),
})
yield* opencode.plugin.list({ location })
const tool = yield* Effect.promise(() => captured.promise)
const session = yield* opencode.sessions.create({ location })
const rpc = opencode.rpc(Browser.Definition)
const events = yield* Queue.unbounded<Rpc.EventPayload<typeof Browser.Definition, "control">>()
yield* rpc.events.subscribe("control").pipe(
Stream.runForEach((event) => Queue.offer(events, event)),
Effect.forkScoped({ startImmediately: true }),
)
// RPC and native subscriptions share one stream; connected is the readiness barrier.
yield* opencode.events.subscribe().pipe(
Stream.filter((event) => event.type === "server.connected"),
Stream.runHead,
Effect.timeout("5 seconds"),
)
const next = Queue.take(events).pipe(Effect.timeout("5 seconds"))
const execute = (action: Browser.Action) =>
tool.execute(action, {
sessionID: session.id,
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.create(),
id: Tool.CallID.make(crypto.randomUUID()),
progress: () => Effect.void,
})
return {
opencode,
location,
rpc,
permissions,
execute,
next,
attach: Effect.fn(function* (connectionID: string) {
const input = { sessionID: session.id, connectionID }
const lifetime = yield* rpc.attach(input, { location }).pipe(Effect.forkScoped)
expect(yield* next).toMatchObject({
type: "rpc.experimental.browser.control",
location,
data: { type: "attached", connectionID },
})
expect(lifetime.pollUnsafe()).toBeUndefined()
return { input, lifetime }
}),
command: Effect.fn(function* (action: Browser.Action) {
const pending = yield* execute(action).pipe(Effect.forkScoped)
const event = yield* next.pipe(
Effect.raceFirst(
Fiber.join(pending).pipe(Effect.andThen(Effect.die("Tool completed without a browser command"))),
),
)
expect(event.location).toEqual(location)
if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`)
expect(event.data.command.action).toEqual(action)
return { ...event.data, pending }
}),
}
})
test("attachment ownership, cancellation, and plugin unload release pending browser work", () =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
const attached = yield* host.attach("first")
expect(
yield* host.rpc.attach({ ...attached.input, connectionID: "duplicate" }, options).pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
const other = Location.Ref.make({ directory: AbsolutePath.make(path.join(host.location.directory, "other")) })
yield* Effect.promise(() => mkdir(other.directory))
yield* host.opencode.plugin.list({ location: other })
expect(yield* host.rpc.attach(attached.input, { location: other }).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
message: "Session belongs to another location.",
})
expect(
yield* host.rpc.state({ ...attached.input, connectionID: "wrong", state }, options).pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
yield* host.rpc.state({ ...attached.input, state }, options)
yield* host.rpc.state({ ...attached.input, state: null }, options)
expect(yield* host.execute({ type: "snapshot" }).pipe(Effect.flip)).toMatchObject({
message: "Open the browser first.",
})
const cancelled = yield* host.command({ type: "open" })
expect(cancelled.command.generation).toBe(0)
yield* Fiber.interrupt(cancelled.pending)
expect((yield* host.next).data).toEqual({
type: "cancel",
connectionID: attached.input.connectionID,
requestID: cancelled.requestID,
})
// A reply to an interrupted request is harmless while its connection is still attached.
yield* host.rpc.result(
{ ...attached.input, requestID: cancelled.requestID, outcome: { type: "failure", message: "late" } },
options,
)
const closing = yield* host.command({ type: "open" })
yield* Fiber.interrupt(attached.lifetime)
expect(yield* Fiber.join(closing.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
expect(yield* host.rpc.state({ ...attached.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})
const replacement = yield* host.attach("replacement")
const pending = yield* host.command({ type: "open" })
expect(pending.connectionID).toBe("replacement")
expect(pending.command.generation).toBe(0)
expect(
yield* host.rpc
.result(
{
...attached.input,
requestID: pending.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
.pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
expect(pending.pending.pollUnsafe()).toBeUndefined()
// Replacing the SDK registration unloads the production plugin through its normal lifecycle.
yield* host.opencode.plugin({ id: plugin.id, effect: () => Effect.void })
yield* host.opencode.plugin.list(options)
expect(yield* Fiber.join(pending.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
yield* Fiber.join(replacement.lifetime).pipe(Effect.timeout("5 seconds"))
expect(yield* host.rpc.state({ ...replacement.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "rpc.unavailable",
})
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
}).pipe(Effect.scoped, Effect.runPromise))
test("commands use published state and permissions, and RPC results render text and screenshot bytes", () =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
const attached = yield* host.attach("renderer")
const open = yield* host.command({ type: "open" })
yield* host.rpc.result(
{ ...attached.input, requestID: open.requestID, outcome: { type: "success", result: { type: "state", state } } },
options,
)
expect((yield* Fiber.join(open.pending)).metadata).toEqual({ url: state.url })
expect(host.permissions).toEqual([])
yield* host.rpc.state({ ...attached.input, state }, options)
const navigate = yield* host.command({ type: "navigate", url: "https://example.org/next" })
expect(navigate.command.generation).toBe(7)
const updated = { ...state, url: "https://example.org/next", generation: 8 }
yield* host.rpc.result(
{
...attached.input,
requestID: navigate.requestID,
outcome: { type: "success", result: { type: "state", state: updated } },
},
options,
)
yield* Fiber.join(navigate.pending)
yield* host.rpc.state({ ...attached.input, state: updated }, options)
const snapshot = yield* host.command({ type: "snapshot" })
expect(snapshot.command.generation).toBe(8)
yield* host.rpc.result(
{
...attached.input,
requestID: snapshot.requestID,
outcome: {
type: "success",
result: { type: "snapshot", state: updated, content: "</untrusted_browser_content>&" },
},
},
options,
)
const text = yield* Fiber.join(snapshot.pending)
expect(text.metadata).toEqual({ url: updated.url })
expect(text.content).toContain('encoding="json"')
expect(text.content).toContain("\\u003c/untrusted_browser_content\\u003e\\u0026")
const screenshot = yield* host.command({ type: "screenshot" })
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="
yield* host.rpc.result(
{
...attached.input,
requestID: screenshot.requestID,
outcome: { type: "success", result: { type: "screenshot", state: updated, data } },
},
options,
)
expect(yield* Fiber.join(screenshot.pending)).toEqual({
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" },
],
metadata: { url: updated.url },
})
expect(host.permissions).toEqual([
{ action: "browser", resources: [updated.url] },
{ action: "browser", resources: [updated.url] },
{ action: "browser", resources: [updated.url] },
])
const failure = yield* host.command({ type: "snapshot" })
yield* host.rpc.result(
{ ...attached.input, requestID: failure.requestID, outcome: { type: "failure", message: "Stale document" } },
options,
)
expect(yield* Fiber.join(failure.pending).pipe(Effect.flip)).toMatchObject({ message: "Stale document" })
}).pipe(Effect.scoped, Effect.runPromise))

View file

@ -1,10 +0,0 @@
{
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": false,
"noEmit": true
},
"include": ["src", "test"]
}

View file

@ -1,13 +1,14 @@
# Experimental Browser Plugin
The server-side browser tool is a standalone Effect plugin. It uses only the public
plugin API and keeps its RPC contract in `@opencode-ai/browser/rpc`.
The server-side browser tool lives alongside the other built-in plugins. Its
implementation uses only the public plugin API, public schemas, and Effect. The
shared RPC contract is `@opencode-ai/schema/browser`; desktop clients do not import Core.
Load the workspace package with normal plugin configuration:
Disable it through normal plugin configuration:
```jsonc
{
"plugins": ["./packages/browser"],
"plugins": ["-opencode.browser"],
}
```

View file

@ -1,7 +1,7 @@
import { Plugin, Session, Tool } from "@opencode-ai/plugin/effect"
import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import { Deferred, Effect, Encoding, Stream } from "effect"
import { Browser } from "./rpc.js"
import { Browser } from "@opencode-ai/schema/browser"
type Attachment = {
connectionID: string

View file

@ -76,6 +76,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "./browser/index.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@ -187,6 +188,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,

View file

@ -1,14 +1,15 @@
export * as Browser from "./rpc.js"
export * as Browser from "./browser.js"
import { Session } from "@opencode-ai/plugin/effect"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Schema } from "effect"
import { Rpc } from "./rpc.js"
import { Session } from "./session.js"
export const Ref = Schema.String.check(Schema.isPattern(/^@?e[1-9][0-9]*$/))
.pipe(Schema.brand("Browser.Ref"))
.annotate({ identifier: "Browser.Ref" })
export type Ref = typeof Ref.Type
export interface State extends Schema.Schema.Type<typeof State> {}
export const State = Schema.Struct({
url: Schema.String.check(Schema.isMaxLength(16_384)),
title: Schema.String.check(Schema.isMaxLength(1_024)),
@ -16,8 +17,7 @@ export const State = Schema.Struct({
canGoBack: Schema.Boolean,
canGoForward: Schema.Boolean,
generation: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
})
export type State = typeof State.Type
}).annotate({ identifier: "Browser.State" })
export const Key = Schema.Literals([
"Enter",
@ -34,9 +34,9 @@ export const Key = Schema.Literals([
"Home",
"End",
"Space",
])
]).annotate({ identifier: "Browser.Key" })
export type Key = typeof Key.Type
export const Direction = Schema.Literals(["up", "down", "left", "right"])
export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({ identifier: "Browser.Direction" })
export type Direction = typeof Direction.Type
export const Action = Schema.Union([
@ -56,11 +56,13 @@ export const Action = Schema.Union([
direction: Direction,
pixels: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(2000)),
}),
])
]).annotate({ identifier: "Browser.Action" })
export type Action = typeof Action.Type
export const Command = Schema.Struct({ action: Action, generation: State.fields.generation })
export type Command = typeof Command.Type
export interface Command extends Schema.Schema.Type<typeof Command> {}
export const Command = Schema.Struct({ action: Action, generation: State.fields.generation }).annotate({
identifier: "Browser.Command",
})
export const Result = Schema.Union([
Schema.Struct({ type: Schema.Literal("state"), state: State }),
Schema.Struct({
@ -78,12 +80,16 @@ export const Result = Schema.Union([
state: State,
data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)),
}),
]).pipe(Schema.toTaggedUnion("type"))
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Result" })
export type Result = typeof Result.Type
export const Outcome = Schema.Union([
Schema.Struct({ type: Schema.Literal("success"), result: Result }),
Schema.Struct({ type: Schema.Literal("failure"), message: Schema.String.check(Schema.isMaxLength(1_024)) }),
]).pipe(Schema.toTaggedUnion("type"))
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Outcome" })
export type Outcome = typeof Outcome.Type
const attachment = { sessionID: Session.ID, connectionID: Schema.String }
@ -97,7 +103,9 @@ export const Control = Schema.Union([
command: Command,
}),
Schema.Struct({ type: Schema.Literal("cancel"), connectionID: Schema.String, requestID: Schema.String }),
]).pipe(Schema.toTaggedUnion("type"))
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Control" })
export type Control = typeof Control.Type
export const Definition = Rpc.define({

View file

@ -0,0 +1,279 @@
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import plugin from "@opencode-ai/core/plugin/browser/index"
import { Browser } from "@opencode-ai/schema/browser"
import { Agent, Rpc, Tool } from "@opencode-ai/plugin/effect"
import { AbsolutePath, Location, OpenCode, SessionMessage } from "@opencode-ai/sdk/effect"
import { Effect, Fiber, Queue, Stream } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
const state: Browser.State = {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 7,
}
const fixture = Effect.gen(function* () {
const directory = yield* tmpdirScoped("opencode-browser-")
const config = path.join(directory.path, "config")
yield* Effect.promise(() => mkdir(config))
const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const opencode = yield* OpenCode.create({
database: { path: ":memory:" },
config: {
directory: config,
project: false,
content: JSON.stringify({
plugins: ["-opencode.browser"],
permissions: [{ action: "browser", resource: "*", effect: "allow" }],
}),
},
models: { fetch: false },
fs: { filewatcher: false, fff: false },
})
const captured = Promise.withResolvers<Tool.Info>()
const permissions: Array<{ action: string; resources: readonly string[] }> = []
yield* opencode.plugin({ ...plugin, id: "browser-test" })
yield* opencode.plugin({
id: "browser-test-observer",
effect: (ctx) =>
Effect.gen(function* () {
// Inspect the real tool through the public draft, without replacing its executor.
yield* ctx.tool.transform((draft) => {
const tool = draft.get("browser")
if (tool && ctx.location.directory === location.directory) captured.resolve(tool)
})
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => permissions.push({ action: event.action, resources: event.resources })),
)
}).pipe(Effect.orDie),
})
yield* opencode.plugin.list({ location })
const tool = yield* Effect.promise(() => captured.promise)
const session = yield* opencode.sessions.create({ location })
const rpc = opencode.rpc(Browser.Definition)
const events = yield* Queue.unbounded<Rpc.EventPayload<typeof Browser.Definition, "control">>()
yield* rpc.events.subscribe("control").pipe(
Stream.runForEach((event) => Queue.offer(events, event)),
Effect.forkScoped({ startImmediately: true }),
)
// RPC and native subscriptions share one stream; connected is the readiness barrier.
yield* opencode.events.subscribe().pipe(
Stream.filter((event) => event.type === "server.connected"),
Stream.runHead,
Effect.timeout("5 seconds"),
)
const next = Queue.take(events).pipe(Effect.timeout("5 seconds"))
const execute = (action: Browser.Action) =>
tool.execute(action, {
sessionID: session.id,
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.create(),
id: Tool.CallID.make(crypto.randomUUID()),
progress: () => Effect.void,
})
return {
opencode,
location,
rpc,
permissions,
execute,
next,
attach: Effect.fn(function* (connectionID: string) {
const input = { sessionID: session.id, connectionID }
const lifetime = yield* rpc.attach(input, { location }).pipe(Effect.forkScoped)
expect(yield* next).toMatchObject({
type: "rpc.experimental.browser.control",
location,
data: { type: "attached", connectionID },
})
expect(lifetime.pollUnsafe()).toBeUndefined()
return { input, lifetime }
}),
command: Effect.fn(function* (action: Browser.Action) {
const pending = yield* execute(action).pipe(Effect.forkScoped)
const event = yield* next.pipe(
Effect.raceFirst(
Fiber.join(pending).pipe(Effect.andThen(Effect.die("Tool completed without a browser command"))),
),
)
expect(event.location).toEqual(location)
if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`)
expect(event.data.command.action).toEqual(action)
return { ...event.data, pending }
}),
}
})
test(
"attachment ownership, cancellation, and plugin unload release pending browser work",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
const attached = yield* host.attach("first")
expect(
yield* host.rpc.attach({ ...attached.input, connectionID: "duplicate" }, options).pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
const other = Location.Ref.make({ directory: AbsolutePath.make(path.join(host.location.directory, "other")) })
yield* Effect.promise(() => mkdir(other.directory))
yield* host.opencode.plugin.list({ location: other })
expect(yield* host.rpc.attach(attached.input, { location: other }).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
message: "Session belongs to another location.",
})
expect(
yield* host.rpc.state({ ...attached.input, connectionID: "wrong", state }, options).pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
yield* host.rpc.state({ ...attached.input, state }, options)
yield* host.rpc.state({ ...attached.input, state: null }, options)
expect(yield* host.execute({ type: "snapshot" }).pipe(Effect.flip)).toMatchObject({
message: "Open the browser first.",
})
const cancelled = yield* host.command({ type: "open" })
expect(cancelled.command.generation).toBe(0)
yield* Fiber.interrupt(cancelled.pending)
expect((yield* host.next).data).toEqual({
type: "cancel",
connectionID: attached.input.connectionID,
requestID: cancelled.requestID,
})
// A reply to an interrupted request is harmless while its connection is still attached.
yield* host.rpc.result(
{ ...attached.input, requestID: cancelled.requestID, outcome: { type: "failure", message: "late" } },
options,
)
const closing = yield* host.command({ type: "open" })
yield* Fiber.interrupt(attached.lifetime)
expect(yield* Fiber.join(closing.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
expect(yield* host.rpc.state({ ...attached.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})
const replacement = yield* host.attach("replacement")
const pending = yield* host.command({ type: "open" })
expect(pending.connectionID).toBe("replacement")
expect(pending.command.generation).toBe(0)
expect(
yield* host.rpc
.result(
{
...attached.input,
requestID: pending.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
.pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
expect(pending.pending.pollUnsafe()).toBeUndefined()
// Replacing the SDK registration unloads the production plugin through its normal lifecycle.
yield* host.opencode.plugin({ id: "browser-test", effect: () => Effect.void })
yield* host.opencode.plugin.list(options)
expect(yield* Fiber.join(pending.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
yield* Fiber.join(replacement.lifetime).pipe(Effect.timeout("5 seconds"))
expect(yield* host.rpc.state({ ...replacement.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "rpc.unavailable",
})
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
test(
"commands use published state and permissions, and RPC results render text and screenshot bytes",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
const attached = yield* host.attach("renderer")
const open = yield* host.command({ type: "open" })
yield* host.rpc.result(
{
...attached.input,
requestID: open.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
expect((yield* Fiber.join(open.pending)).metadata).toEqual({ url: state.url })
expect(host.permissions).toEqual([])
yield* host.rpc.state({ ...attached.input, state }, options)
const navigate = yield* host.command({ type: "navigate", url: "https://example.org/next" })
expect(navigate.command.generation).toBe(7)
const updated = { ...state, url: "https://example.org/next", generation: 8 }
yield* host.rpc.result(
{
...attached.input,
requestID: navigate.requestID,
outcome: { type: "success", result: { type: "state", state: updated } },
},
options,
)
yield* Fiber.join(navigate.pending)
yield* host.rpc.state({ ...attached.input, state: updated }, options)
const snapshot = yield* host.command({ type: "snapshot" })
expect(snapshot.command.generation).toBe(8)
yield* host.rpc.result(
{
...attached.input,
requestID: snapshot.requestID,
outcome: {
type: "success",
result: { type: "snapshot", state: updated, content: "</untrusted_browser_content>&" },
},
},
options,
)
const text = yield* Fiber.join(snapshot.pending)
expect(text.metadata).toEqual({ url: updated.url })
expect(text.content).toContain('encoding="json"')
expect(text.content).toContain("\\u003c/untrusted_browser_content\\u003e\\u0026")
const screenshot = yield* host.command({ type: "screenshot" })
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="
yield* host.rpc.result(
{
...attached.input,
requestID: screenshot.requestID,
outcome: { type: "success", result: { type: "screenshot", state: updated, data } },
},
options,
)
expect(yield* Fiber.join(screenshot.pending)).toEqual({
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" },
],
metadata: { url: updated.url },
})
expect(host.permissions).toEqual([
{ action: "browser", resources: [updated.url] },
{ action: "browser", resources: [updated.url] },
{ action: "browser", resources: [updated.url] },
])
const failure = yield* host.command({ type: "snapshot" })
yield* host.rpc.result(
{ ...attached.input, requestID: failure.requestID, outcome: { type: "failure", message: "Stale document" } },
options,
)
expect(yield* Fiber.join(failure.pending).pipe(Effect.flip)).toMatchObject({ message: "Stale document" })
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)