diff --git a/.changeset/browser-module.md b/.changeset/browser-module.md new file mode 100644 index 000000000..2a87ced7a --- /dev/null +++ b/.changeset/browser-module.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Adds the experimental `ws:browser` host module (`@cloudflare/computer/modules/browser`), which lets isolate JavaScript drive a persistent browser over the Chrome DevTools Protocol. It takes anything with a `name` and a `session()`, such as `Browser` from `agents/browser`, and opens one session per run. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 83243021c..e28fe658f 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -434,6 +434,47 @@ On a backend with `access: "read"`, only tools marked `readOnly` run. Every othe The module's description lists the names code can import. With a function of tools, the function is called again each time the backend describes itself, so the `exec` tool, built after the Workspace, sees the current list. A list is read as given; any other iterable may be single-use, so it is not read for the description, and neither is a function that throws. +### `ws:browser` + +> [!NOTE] +> **Experimental.** + +```js +import { send, attachToTarget, spec, getDebugLog, clearDebugLog } from "ws:browser"; +``` + +`createBrowserModule({ browser, scope?, maxResultBytes? })` from `@cloudflare/computer/modules/browser` lets JavaScript drive a persistent Chrome browser over the Chrome DevTools Protocol. The browser belongs to the host: the module takes anything with the `BrowserSource` shape, a `name` and a `session({ scope, maxResultBytes })` that starts a session for one run. `Browser` from `agents/browser` has that shape, so an agent passes its own browser, and neither package depends on the other: + +```ts +import { Browser, browserRun } from "agents/browser"; +import { createBrowserModule } from "@cloudflare/computer/modules/browser"; + +browser = new Browser({ provider: browserRun(this.env.BROWSER) }); + +new WorkerJavaScriptBackend({ + loader: this.env.LOADER, + modules: { "ws:browser": createBrowserModule({ browser: this.browser }) }, +}); +``` + +```js +import { writeFile } from "node:fs/promises"; +import { Buffer } from "node:buffer"; +import { send } from "ws:browser"; + +export default async function () { + await send({ method: "Page.navigate", params: { url: "https://example.com" }, sessionId: "active" }); + const { data } = await send({ method: "Page.captureScreenshot", sessionId: "active" }); + await writeFile("/workspace/page.png", Buffer.from(data, "base64")); +} +``` + +`send({ method, params?, sessionId?, timeoutMs? })` returns a command's result without the JSON-RPC envelope. `sessionId: "active"` is the tab the scope is working in. The session remembers it between runs, and opens a new blank tab when the scope has none. `attachToTarget({ targetId })` makes another open tab active and returns a `sessionId` handle that keeps working in later runs. A session id from a raw `Target.attachToTarget` only lasts for one run. `Browser.close` and `Browser.crash` are refused, because the host decides when the browser ends. + +A run's first call starts a session, and every call in the run goes to it. When the run ends, the module closes the session, which saves the scope's active tab and closes its connection. The browser stays open. If the session reports that the browser was replaced since the scope last used it, that another scope closed or shares this one's tab, or that the page opened tabs, the module says so on the run's standard error. The CDP rules above (the active tab, attach handles, refused commands, error hints) belong to the session, so `agents/browser`'s direct `createBrowserTool` behaves the same way. The module's description, which the `exec` tool shows, covers the calls and the CDP mistakes models commonly make. + +`scope` keeps conversations that share one browser in their own tabs, while cookies and logins stay shared. A `Page.captureScreenshot` result over `maxResultBytes` (1,000,000 by default in `agents/browser`) fails with a note to capture the viewport or use JPEG. Keep it under the backend's `maxCapabilityBytes`. + ### `ws:container` `createContainerModule()` from `@cloudflare/computer/modules/container` lets JavaScript run shell commands in the Workspace's container backend. With it, JavaScript is the only backend the model sees, and the container is something that JavaScript can call: diff --git a/packages/computer/README.md b/packages/computer/README.md index d5539d43d..dde327016 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -33,6 +33,7 @@ removed in a future release; each one names its replacement. | `LegacyContainerBackend` and `withLegacyWorkspaceContainer` | `@cloudflare/computer/backends/container-legacy` | Deprecated: use `ContainerBackend` | | `WorkerShellBackend` and the shell command groups | `@cloudflare/computer/backends/worker-shell`, `@cloudflare/computer/shell/*` | Experimental | | The `ws:container` host module | `@cloudflare/computer/modules/container` | Experimental | +| The `ws:browser` host module | `@cloudflare/computer/modules/browser` | Experimental | | `workspace.push()`, `workspace.pull()`, and the sync protocol between the durable object and `computerd` | `@cloudflare/computer` | Experimental | | The `computerd` daemon and its container image | `ghcr.io/cloudflare/computer-computerd-linux-x64` | Experimental | | The RPC wire protocol between the durable object and `computerd` (`@cloudflare/computer-rpc`) | Internal | Experimental | @@ -514,6 +515,7 @@ on a computerd instance. | `@cloudflare/computer/modules/git` | `createGitModule()` for `ws:git`: confined Git from isolate JavaScript. | | `@cloudflare/computer/modules/artifacts` | `createArtifactsModule()` for `ws:artifacts`: Artifacts and git tokens from isolate JavaScript. | | `@cloudflare/computer/modules/assets` | `createAssetsModule()` for `ws:assets`: publish a Workspace file from isolate JavaScript. | +| `@cloudflare/computer/modules/browser` | `createBrowserModule()` for `ws:browser`: drive a persistent browser over the Chrome DevTools Protocol from isolate JavaScript. | | `@cloudflare/computer/modules/tools` | `createToolBindings()` for `ws:tools`: an agent's own tools, callable from isolate JavaScript. | | `@cloudflare/computer/tools` | AI SDK tools for agents: `read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and optional `exec` and `publish`. | | `@cloudflare/computer/tools/core` | `defineExec()`: the `exec` tool's description, schema, and executor, with no agent library. | diff --git a/packages/computer/package.json b/packages/computer/package.json index 43e4b509f..9903c057e 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -51,6 +51,10 @@ "types": "./dist/modules/assets.d.ts", "import": "./dist/modules/assets.js" }, + "./modules/browser": { + "types": "./dist/modules/browser.d.ts", + "import": "./dist/modules/browser.js" + }, "./tools": { "types": "./dist/tools/index.d.ts", "import": "./dist/tools/index.js" diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 5c8818b4e..2b31e65e0 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ "modules/artifacts": "src/modules/artifacts.ts", "modules/tools": "src/modules/tools.ts", "modules/assets": "src/modules/assets.ts", + "modules/browser": "src/modules/browser.ts", "backends/container-legacy/index": "src/backends/container-legacy/index.ts", "backends/container/index": "src/backends/container/index.ts", "backends/worker-javascript/index": "src/backends/worker-javascript/index.ts", diff --git a/packages/computer/src/modules/browser.test.ts b/packages/computer/src/modules/browser.test.ts new file mode 100644 index 000000000..c59426cf6 --- /dev/null +++ b/packages/computer/src/modules/browser.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vitest"; + +import type { + WorkspaceModuleCallContext, + WorkspaceModuleHost, + WorkspaceRuntimeValue, +} from "../runtime/types.js"; +import { + createBrowserModule, + type BrowserModuleOptions, + type BrowserSession, + type BrowserSessionOptions, + type BrowserSessionReport, + type BrowserSource, +} from "./browser.js"; + +// SAFETY: The browser module never touches the host's Git, Artifacts, or runtime. +const host = {} as WorkspaceModuleHost; + +interface FakeSession extends BrowserSession { + readonly options: BrowserSessionOptions | undefined; + readonly calls: Array<[string, unknown]>; + closed: number; +} + +// Sessions that record their calls. The CDP behaviour behind a session is +// `agents/browser`'s, tested there; this module only routes calls to the +// run's session and closes it. +class FakeBrowser implements BrowserSource { + readonly name = "work"; + readonly sessions: FakeSession[] = []; + report: BrowserSessionReport | undefined = { + restarted: false, + newTabs: [], + tabClosed: false, + tabShared: false, + }; + + session(options?: BrowserSessionOptions): BrowserSession { + const calls: Array<[string, unknown]> = []; + const record = (name: string) => async (input?: unknown) => { + calls.push([name, input]); + if (name === "send" && (input as { method?: string })?.method === "Browser.close") { + throw new Error("Browser.close is not available"); + } + return { called: name, input: input ?? null }; + }; + const session: FakeSession = { + options, + calls, + closed: 0, + send: record("send"), + attachToTarget: record("attachToTarget"), + spec: record("spec"), + getDebugLog: record("getDebugLog"), + clearDebugLog: record("clearDebugLog"), + close: async () => { + session.closed += 1; + return this.report; + }, + }; + this.sessions.push(session); + return session; + } +} + +function setup(options: Partial = {}) { + const browser = new FakeBrowser(); + const module = createBrowserModule({ browser, ...options }); + const functions = module(host); + let runs = 0; + + function context(executionId: string, logs: string[], deferred: Array<() => unknown>) { + return { + executionId, + signal: new AbortController().signal, + deadline: Date.now() + 60_000, + access: "read-write", + resolvePath: async (path: string) => path, + log: (text: string) => logs.push(text), + defer: (fn: () => unknown) => deferred.push(fn), + } satisfies WorkspaceModuleCallContext; + } + + /** Make `calls` as one run, then run what it deferred, as the backend does. */ + async function run(calls: Array<[string, ...WorkspaceRuntimeValue[]]>) { + const logs: string[] = []; + const deferred: Array<() => unknown> = []; + const ctx = context(`run-${++runs}`, logs, deferred); + const results: unknown[] = []; + let error: unknown; + try { + for (const [name, ...args] of calls) { + results.push(await functions[name](args, ctx)); + } + } catch (caught) { + error = caught; + } + while (deferred.length > 0) await deferred.pop()?.(); + return { results, error, logs }; + } + + return { browser, module, functions, run, context }; +} + +describe("ws:browser", () => { + it("exports the browser calls and describes them for a model", () => { + const { functions, module } = setup(); + expect(Object.keys(functions).sort()).toEqual([ + "attachToTarget", + "clearDebugLog", + "getDebugLog", + "send", + "spec", + ]); + expect(module.description).toContain("send({ method"); + expect(module.description).toContain("standard error"); + expect(module.description).not.toContain("cdp."); + }); + + it("starts one session per run and closes it when the run ends", async () => { + const t = setup(); + const first = await t.run([ + ["send", { method: "Page.navigate", sessionId: "active" }], + ["attachToTarget", { targetId: "target-1" }], + ["spec"], + ["getDebugLog", { limit: 5 }], + ["clearDebugLog"], + ]); + expect(first.error).toBeUndefined(); + expect(first.results[0]).toEqual({ + called: "send", + input: { method: "Page.navigate", sessionId: "active" }, + }); + expect(t.browser.sessions).toHaveLength(1); + expect(t.browser.sessions[0].calls.map(([name]) => name)).toEqual([ + "send", + "attachToTarget", + "spec", + "getDebugLog", + "clearDebugLog", + ]); + expect(t.browser.sessions[0].closed).toBe(1); + + await t.run([["send", { method: "Target.getTargets" }]]); + expect(t.browser.sessions).toHaveLength(2); + expect(t.browser.sessions[1].closed).toBe(1); + }); + + it("shares one session between a run's concurrent calls", async () => { + const t = setup(); + const logs: string[] = []; + const deferred: Array<() => unknown> = []; + const ctx = t.context("parallel", logs, deferred); + await Promise.all([ + t.functions.send([{ method: "Target.getTargets" }], ctx), + t.functions.send([{ method: "Browser.getVersion" }], ctx), + ]); + expect(t.browser.sessions).toHaveLength(1); + expect(deferred).toHaveLength(1); + }); + + it("passes the scope and screenshot limit to the session", async () => { + const t = setup({ scope: "thread-1", maxResultBytes: 500 }); + await t.run([["spec"]]); + expect(t.browser.sessions[0].options).toEqual({ scope: "thread-1", maxResultBytes: 500 }); + + const plain = setup(); + await plain.run([["spec"]]); + expect(plain.browser.sessions[0].options).toEqual({}); + }); + + it("writes what happened to the browser to standard error", async () => { + const t = setup(); + t.browser.report = { + restarted: true, + newTabs: [{ targetId: "target-3", url: "https://popup.example/" }], + tabClosed: true, + tabShared: true, + }; + const { logs } = await t.run([["send", { method: "Target.getTargets" }]]); + expect(logs).toEqual([ + expect.stringContaining("The browser was restarted before this run"), + expect.stringContaining( + "Another conversation using this browser is also working in your tab", + ), + 'New tabs opened by the page: [{"targetId":"target-3","url":"https://popup.example/"}]', + ]); + + t.browser.report = { restarted: false, newTabs: [], tabClosed: true, tabShared: false }; + const closed = await t.run([["send", { method: "Target.getTargets" }]]); + expect(closed.logs).toEqual([ + expect.stringContaining("The tab you were working in was closed"), + ]); + }); + + it("passes the session's errors to the code and still closes it", async () => { + const t = setup(); + const { error } = await t.run([["send", { method: "Browser.close" }]]); + expect(String(error)).toContain("Browser.close is not available"); + expect(t.browser.sessions[0].closed).toBe(1); + }); + + it("refuses more than one argument", async () => { + const t = setup(); + const { error } = await t.run([["send", { method: "Page.navigate" }, "extra"]]); + expect(String(error)).toContain("takes one options object"); + }); + + it("leaves the browser alone for a run that never called it", async () => { + const t = setup(); + const { logs } = await t.run([]); + expect(logs).toEqual([]); + expect(t.browser.sessions).toEqual([]); + }); +}); diff --git a/packages/computer/src/modules/browser.ts b/packages/computer/src/modules/browser.ts new file mode 100644 index 000000000..1ad7f0525 --- /dev/null +++ b/packages/computer/src/modules/browser.ts @@ -0,0 +1,223 @@ +// `ws:browser`: a persistent Chrome browser for isolate JavaScript, over +// the Chrome DevTools Protocol. +// +// import { send, attachToTarget } from "ws:browser"; +// +// The browser belongs to the host. This module opens a session on it for +// each run and closes it when the run ends. It needs only something with a +// `name` and `session()` (see `BrowserSource`), such as `Browser` from +// `agents/browser`, so it doesn't depend on any one browser provider. The +// session does the CDP work: the "active" tab, attach handles that last +// between runs, error hints, and the report of what happened to the browser. + +import type { + WorkspaceModuleCallContext, + WorkspaceModuleFactory, + WorkspaceModuleFunctions, +} from "../runtime/types.js"; + +/** A tab a page opened on its own during a run. */ +export interface BrowserNewTab { + targetId: string; + url?: string; + title?: string; +} + +/** + * What happened to the browser during one run, beyond its calls' results. + * `BrowserSession.close` resolves with it. + */ +export interface BrowserSessionReport { + /** The browser was replaced before this run; earlier page state is gone. */ + restarted: boolean; + /** Tabs the page opened itself (popups, `target=_blank`). */ + newTabs: BrowserNewTab[]; + /** + * The tab this scope was working in was closed by someone else (another + * scope, or a person in Live View), so `"active"` will be a new blank tab. + */ + tabClosed: boolean; + /** Another scope is working in this scope's active tab too. */ + tabShared: boolean; +} + +/** + * One run's session on the browser. `browser.session()` from + * `agents/browser` returns one. Each method takes the single options + * object the isolate passed, which comes from untrusted code, so the + * session must validate it. + */ +export interface BrowserSession { + /** Send a CDP command and resolve with its result. */ + send(input: unknown): Promise; + /** Make an open tab active and resolve with `{ sessionId }`. */ + attachToTarget(input: unknown): Promise; + /** The Chrome DevTools Protocol description the browser serves. */ + spec(): Promise; + /** Recent protocol traffic, newest last. */ + getDebugLog(input?: unknown): Promise; + /** Forget the protocol traffic recorded so far. */ + clearDebugLog(): Promise; + /** + * End the session and resolve with its report, or `undefined` when it + * never connected. The browser stays open. + */ + close(): Promise; +} + +/** Options the module passes to {@link BrowserSource.session}. */ +export interface BrowserSessionOptions { + scope?: string; + maxResultBytes?: number; +} + +/** + * The browser the module drives. `Browser` from `agents/browser` has this + * shape. + */ +export interface BrowserSource { + /** The browser's name. */ + readonly name: string; + /** Start a session for one run. */ + session(options?: BrowserSessionOptions): BrowserSession; +} + +/** Options for {@link createBrowserModule}. */ +export interface BrowserModuleOptions { + /** The browser to drive. */ + readonly browser: BrowserSource; + /** + * Who is driving, when several conversations share one browser: runs with + * the same scope share an active tab, and each scope keeps its own. + * Cookies and logins are shared by every scope. Defaults to the + * browser's own default. + */ + readonly scope?: string; + /** + * The largest `Page.captureScreenshot` result `send` returns, measured as + * JSON. Defaults to the session's own limit (1,000,000 bytes for + * `agents/browser`). Keep it under the backend's `maxCapabilityBytes`. + */ + readonly maxResultBytes?: number; +} + +const RESTARTED_NOTICE = + "The browser was restarted before this run (it expired or was closed): earlier tabs, logins, and page state are gone. Your code ran in a fresh browser, so navigate again before relying on page state."; + +const TAB_CLOSED_NOTICE = + 'The tab you were working in was closed (by another conversation using this browser, or a person). `sessionId: "active"` now opens a blank tab, so navigate again before relying on page state.'; + +const TAB_SHARED_NOTICE = + "Another conversation using this browser is also working in your tab, so its page may change between your runs. Open a tab of your own with Target.createTarget if you need one."; + +/** + * Lines telling the model what happened to its browser during a run: it + * was restarted, its tab was closed by someone else, another scope is + * working in its tab too, or the page opened tabs. + */ +function reportLines(report: BrowserSessionReport | undefined): string[] { + if (!report) return []; + const lines = [ + report.restarted && RESTARTED_NOTICE, + report.tabClosed && !report.restarted && TAB_CLOSED_NOTICE, + report.tabShared && TAB_SHARED_NOTICE, + ].filter((notice) => typeof notice === "string"); + if (report.newTabs.length > 0) { + lines.push(`New tabs opened by the page: ${JSON.stringify(report.newTabs)}`); + } + return lines; +} + +function browserRules(): string[] { + return [ + "The browser persists between runs: tabs, cookies, and logins are still there next time. You never start or close it.", + "If the browser was replaced (idle timeout, crash), standard error says so and earlier tabs and logins are gone. Before an action that matters (submitting a form, a purchase), check you're on the page you expect.", + 'Page-scoped commands (Page.*, Runtime.*, DOM.*, Input.*, Network.*, Emulation.*) need sessionId: "active", your tab, which is remembered between runs. Browser.* and Target.* commands take no sessionId.', + "Other open tabs (Target.getTargets) may belong to other conversations using this browser: don't close, navigate, or attach to a tab you didn't open unless the user asks.", + "Target.createTarget opens a tab and makes it active. To switch to another open tab, call attachToTarget({ targetId }); the sessionId it returns keeps working in later runs, while one from a raw Target.attachToTarget only works in the current run.", + "Tabs a page opens itself (popups, target=_blank links) don't become active; standard error lists them.", + "Runtime.evaluate: pass returnByValue: true, or objects come back as a remote reference with no value, and awaitPromise: true for async expressions. The value is at result.value. A thrown error doesn't reject: check exceptionDetails.", + "Page.navigate returns before the page loads and doesn't throw when it fails: check errorText, then poll document.readyState with Runtime.evaluate until it is 'complete'.", + "Pick out what you need inside Runtime.evaluate and return only that. A single send result over 1 MB (a full-page screenshot, say) fails the call.", + ]; +} + +const DESCRIPTION = [ + "A persistent Chrome browser over the Chrome DevTools Protocol (CDP).", + "- `send({ method, params?, sessionId?, timeoutMs? })` sends a CDP command and returns its result without the JSON-RPC envelope: `Page.navigate` returns `{ frameId, errorText? }`, `Runtime.evaluate` returns `{ result: { value }, exceptionDetails? }`.", + "- `attachToTarget({ targetId })` makes an open tab active and returns `{ sessionId }`.", + '- `spec()` returns the whole protocol (about 650 KB) as `{ domains: [{ name, commands, events, types }] }`. Filter it in code, for example `spec.domains.find((d) => d.name === "Page")`.', + "- `getDebugLog({ limit? })` returns recent protocol traffic, for when a command fails or times out. `clearDebugLog()` clears it.", + '- To look at the page, save a screenshot and open it with the `read` tool: `const { data } = await send({ method: "Page.captureScreenshot", sessionId: "active" }); await writeFile("/workspace/page.png", Buffer.from(data, "base64"));`, with `writeFile` from `node:fs/promises` and `Buffer` from `node:buffer`.', + ...browserRules().map((rule) => `- ${rule}`), +].join("\n"); + +type BrowserFunctionName = "send" | "attachToTarget" | "spec" | "getDebugLog" | "clearDebugLog"; + +const BROWSER_FUNCTION_NAMES: readonly BrowserFunctionName[] = [ + "send", + "attachToTarget", + "spec", + "getDebugLog", + "clearDebugLog", +]; + +/** + * Build the `ws:browser` host module over a persistent browser. + * + * It exports `send`, `attachToTarget`, `spec`, `getDebugLog` and + * `clearDebugLog`. A run's first call starts a session on the browser. + * When the run ends, the module closes the session, which saves the + * scope's active tab, and writes what happened to the browser (a restart, + * tabs the page opened) to standard error. The browser stays open. + * + * @param options - The browser, the scope to drive it in, and the size limit on screenshots. + * @returns The module to pass as `modules["ws:browser"]`. + */ +export function createBrowserModule(options: BrowserModuleOptions): WorkspaceModuleFactory { + const sessionOptions: BrowserSessionOptions = { + ...(options.scope === undefined ? {} : { scope: options.scope }), + ...(options.maxResultBytes === undefined ? {} : { maxResultBytes: options.maxResultBytes }), + }; + const sessions = new Map(); + + const sessionFor = (context: WorkspaceModuleCallContext): BrowserSession => { + const { executionId } = context; + const existing = sessions.get(executionId); + if (existing) return existing; + const session = options.browser.session(sessionOptions); + sessions.set(executionId, session); + context.defer(async () => { + sessions.delete(executionId); + for (const line of reportLines(await session.close())) context.log(line); + }); + return session; + }; + + const call = (session: BrowserSession, name: BrowserFunctionName, input: unknown) => { + switch (name) { + case "send": + return session.send(input); + case "attachToTarget": + return session.attachToTarget(input); + case "spec": + return session.spec(); + case "getDebugLog": + return session.getDebugLog(input); + case "clearDebugLog": + return session.clearDebugLog(); + } + }; + + const functions: Record = {}; + for (const name of BROWSER_FUNCTION_NAMES) { + functions[name] = async (args, context) => { + if (args.length > 1) { + throw new Error(`ws:browser ${name} takes one options object.`); + } + return call(sessionFor(context), name, args[0] ?? undefined); + }; + } + const create = (): WorkspaceModuleFunctions => functions; + return Object.assign(create, { description: DESCRIPTION }); +} diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index e475485e7..2570b15fa 100644 --- a/packages/computer/tests/script-runner-worker.ts +++ b/packages/computer/tests/script-runner-worker.ts @@ -9,6 +9,7 @@ import { createGitClient } from "../src/git/index.js"; import type { WorkspaceRuntimeValue, WorkspaceStub } from "../src/index.js"; import { Workspace } from "../src/index.js"; import { createArtifactsModule } from "../src/modules/artifacts.js"; +import { createBrowserModule, type BrowserSource } from "../src/modules/browser.js"; import { createContainerModule } from "../src/modules/container.js"; import { createGitModule } from "../src/modules/git.js"; @@ -57,6 +58,30 @@ function fakeContainerBackend(): WorkspaceBackend { }; } +// A browser whose sessions answer send with the method they were sent, +// and whose second session reports a replaced browser, so a run sees a +// restart on standard error. +let browserSessions = 0; +const fakeBrowser: BrowserSource = { + name: "fake", + session() { + browserSessions += 1; + const restarted = browserSessions === 2; + return { + async send(input) { + return { result: { value: (input as { method: string }).method } }; + }, + async attachToTarget(input) { + return { sessionId: `target:${(input as { targetId: string }).targetId}` }; + }, + spec: async () => ({ domains: [] }), + getDebugLog: async () => [], + clearDebugLog: async () => null, + close: async () => ({ restarted, newTabs: [], tabClosed: false, tabShared: false }), + }; + }, +}; + // Executions whose deferred functions have finished, so a later run can // check that they ran before the earlier run's result was published. const endedRuns = new Set(); @@ -65,6 +90,7 @@ const trackedRuns = new Set(); // The Worker Loader resolves imports differently under the legacy and // the new module registry, so the same backend runs on both. const NEW_REGISTRY_BACKEND = "worker-javascript-new-registry"; +const BROWSER_BACKEND = "worker-javascript-browser"; function javascriptBackend( env: Env, @@ -172,6 +198,13 @@ export class HostDO extends DurableObject { }, }), fakeContainerBackend(), + // Its own backend, since the restart notice is longer than the + // 64 bytes of standard error the others allow. + new WorkerJavaScriptBackend({ + id: BROWSER_BACKEND, + loader: env.LOADER, + modules: { "ws:browser": createBrowserModule({ browser: fakeBrowser }) }, + }), ], }); } diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index 4f718b0b8..f4123e28b 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -467,6 +467,36 @@ describe("WorkspaceRuntime", () => { expect(result.stderr).toContain("deferred function failed: boom"); }); + it("drives a browser from ws:browser and reports a restart on standard error", async () => { + const source = ` + import { send } from "ws:browser"; + export default () => send({ method: "Runtime.evaluate", sessionId: "active" }); + `; + const first = await runtime({ + source, + cwd: "/workspace", + backend: "worker-javascript-browser", + }); + const firstText = await first.text(); + expect(first.status, firstText).toBe(200); + expect(JSON.parse(firstText).result).toMatchObject({ + status: "completed", + stderr: "", + value: { result: { value: "Runtime.evaluate" } }, + }); + + const second = await runtime({ + source, + cwd: "/workspace", + backend: "worker-javascript-browser", + }); + const secondText = await second.text(); + expect(second.status, secondText).toBe(200); + const { result } = JSON.parse(secondText); + expect(result.status).toBe("completed"); + expect(result.stderr).toContain("The browser was restarted"); + }); + it("fails to link an import the host module does not export", async () => { const response = await runtime({ source: `