From 08d6e60c0d446661a12d7d391db1a623ae75134f Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 29 May 2026 20:38:13 +0000 Subject: [PATCH] fix(browser): start sidecar from open browser action --- packages/api/src/http.ts | 11 +- packages/api/src/services/project-browser.ts | 39 ++++++ packages/api/tests/project-browser.test.ts | 118 ++++++++++++++++++ packages/app/src/web/actions-browser.ts | 21 +++- packages/app/src/web/api.ts | 9 +- .../app/src/web/app-ready-browser-openable.ts | 11 +- .../src/web/app-terminal-session-handlers.ts | 17 ++- packages/app/src/web/panel-browser.tsx | 16 +-- .../tests/docker-git/actions-browser.test.ts | 36 +++--- .../app-ready-browser-openable.test.ts | 16 +-- .../app-terminal-session-handlers.test.ts | 43 ++++++- 11 files changed, 284 insertions(+), 53 deletions(-) create mode 100644 packages/api/tests/project-browser.test.ts diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 460b90df..ce7a9e7f 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -117,7 +117,7 @@ import { writeProjectSkill } from "./services/project-skills.js" import type { ProjectSkillScope } from "./services/project-skills.js" -import { readProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js" +import { readProjectBrowserSession, startProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js" import { parseProjectBrowserProxyPath } from "./services/project-browser-core.js" import { readPanelCloudflareTunnel, @@ -1369,6 +1369,15 @@ export const makeRouter = () => { const browser = yield* _(readProjectBrowserSession(projectId, resolveRequestOrigin(request))) return yield* _(jsonResponse({ browser }, 200)) }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/projects/:projectId/browser/start", + Effect.gen(function*(_) { + const { projectId } = yield* _(projectParams) + const request = yield* _(HttpServerRequest.HttpServerRequest) + const browser = yield* _(startProjectBrowserSession(projectId, resolveRequestOrigin(request))) + return yield* _(jsonResponse({ browser }, 200)) + }).pipe(Effect.catchAll(errorResponse)) ) ) diff --git a/packages/api/src/services/project-browser.ts b/packages/api/src/services/project-browser.ts index 94e45aae..4517c3bf 100644 --- a/packages/api/src/services/project-browser.ts +++ b/packages/api/src/services/project-browser.ts @@ -221,6 +221,33 @@ const inspectBrowserContainerState = ( Effect.catchAll(() => Effect.succeed(missingBrowserContainerState)) ) +const startBrowserContainer = ( + cwd: string, + projectContainerName: string +) => + dockerCapture( + cwd, + [ + "exec", + projectContainerName, + "docker-git-browser-connection", + "start", + "--project", + projectContainerName, + "--network", + `container:${projectContainerName}` + ], + "docker exec docker-git-browser-connection start" + ).pipe( + Effect.asVoid, + Effect.mapError(() => + new ApiConflictError({ + message: + `Failed to start browser runtime for ${projectContainerName}. Make sure the project is running and Playwright MCP is enabled.` + }) + ) + ) + const parseContainerNetworkEntries = (output: string): ReadonlyArray => output .trim() @@ -404,6 +431,18 @@ export const readProjectBrowserSession = ( return browserSessionFromState(projectId, containerName, state, externalOrigin) }) +export const startProjectBrowserSession = ( + projectId: string, + externalOrigin: string +): Effect.Effect => + Effect.gen(function*(_) { + const project = yield* _(getProjectItemById(projectId)) + const containerName = browserContainerName(project.containerName) + yield* _(startBrowserContainer(project.projectDir, project.containerName)) + const state = yield* _(inspectBrowserContainerState(project.projectDir, containerName)) + return browserSessionFromState(projectId, containerName, state, externalOrigin) + }) + const copyProxyRequestHeaders = ( request: HttpServerRequest.HttpServerRequest, target: ProjectBrowserProxyPath, diff --git a/packages/api/tests/project-browser.test.ts b/packages/api/tests/project-browser.test.ts new file mode 100644 index 00000000..d9c8a814 --- /dev/null +++ b/packages/api/tests/project-browser.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "@effect/vitest" +import { NodeContext } from "@effect/platform-node" +import { Effect } from "effect" +import path from "node:path" +import { beforeEach, vi } from "vitest" + +import type { ProjectItem } from "@effect-template/lib" +import { CommandFailedError } from "@effect-template/lib/shell/errors" + +import { ApiConflictError } from "../src/api/errors.js" +import { startProjectBrowserSession } from "../src/services/project-browser.js" + +const getProjectItemByIdMock = vi.hoisted(() => vi.fn()) +const runCommandCaptureMock = vi.hoisted(() => vi.fn()) + +vi.mock("@effect-template/lib/shell/command-runner", () => ({ + runCommandCapture: runCommandCaptureMock +})) + +vi.mock("../src/services/projects.js", () => ({ + getProjectItemById: getProjectItemByIdMock +})) + +const projectId = "/home/dev/.docker-git/projects/repo-issue-353" +const projectDir = "/home/dev/.docker-git/projects/repo-issue-353" +const projectContainerName = "dg-docker-git-issue-353" +const browserContainerName = `${projectContainerName}-browser` + +const projectItem: ProjectItem = { + authorizedKeysExists: true, + authorizedKeysPath: path.join(projectDir, "authorized_keys"), + codexAuthPath: path.join(projectDir, ".orch", "auth", "codex"), + codexHome: "/home/dev/.codex", + containerName: projectContainerName, + displayName: "ProverCoderAI/docker-git", + envGlobalPath: path.join(projectDir, ".orch", "env", "global.env"), + envProjectPath: path.join(projectDir, ".orch", "env", "project.env"), + gpu: "none", + lastKnownStatus: "running", + lastStartAction: "up", + lastStartedAtEpochMs: 1_778_000_000_000, + lastStartedAtIso: "2026-05-29T18:00:00.000Z", + projectDir, + repoRef: "issue-353", + repoUrl: "https://github.com/ProverCoderAI/docker-git.git", + serviceName: "app", + sshCommand: "ssh -p 2222 dev@localhost", + sshKeyPath: null, + sshPort: 2222, + sshUser: "dev", + targetDir: "/home/dev/app" +} + +describe("project browser", () => { + beforeEach(() => { + getProjectItemByIdMock.mockReset() + runCommandCaptureMock.mockReset() + getProjectItemByIdMock.mockImplementation(() => Effect.succeed(projectItem)) + runCommandCaptureMock.mockImplementation((command: { readonly args: ReadonlyArray }) => + command.args[0] === "inspect" + ? Effect.succeed("browser-container-id\ttrue\trunning") + : Effect.succeed("Browser started") + ) + }) + + it.effect("starts or reuses the Rust browser sidecar from the project container", () => + Effect.gen(function*(_) { + const browser = yield* _(startProjectBrowserSession(projectId, "http://127.0.0.1:3334")) + + expect(browser).toMatchObject({ + containerName: browserContainerName, + projectId, + status: "running" + }) + expect(runCommandCaptureMock).toHaveBeenCalledWith( + { + args: [ + "exec", + projectContainerName, + "docker-git-browser-connection", + "start", + "--project", + projectContainerName, + "--network", + `container:${projectContainerName}` + ], + command: "docker", + cwd: projectDir + }, + [0], + expect.any(Function) + ) + expect(runCommandCaptureMock).toHaveBeenLastCalledWith( + { + args: ["inspect", "-f", "{{.Id}}\t{{.State.Running}}\t{{.State.Status}}", browserContainerName], + command: "docker", + cwd: projectDir + }, + [0], + expect.any(Function) + ) + }).pipe(Effect.provide(NodeContext.layer))) + + it.effect("returns a conflict when the project container cannot launch the browser helper", () => + Effect.gen(function*(_) { + runCommandCaptureMock.mockImplementationOnce(() => + Effect.fail(new CommandFailedError({ command: "docker exec docker-git-browser-connection start", exitCode: 127 })) + ) + + const result = yield* _(Effect.either(startProjectBrowserSession(projectId, "http://127.0.0.1:3334"))) + + expect(result._tag).toBe("Left") + if (result._tag === "Left") { + expect(result.left).toBeInstanceOf(ApiConflictError) + expect(result.left.message).toContain("Playwright MCP is enabled") + } + }).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/app/src/web/actions-browser.ts b/packages/app/src/web/actions-browser.ts index 2ce0ed3c..7e942fb4 100644 --- a/packages/app/src/web/actions-browser.ts +++ b/packages/app/src/web/actions-browser.ts @@ -1,6 +1,12 @@ import { type BrowserActionContext, requireSelectedProjectId, withBusy } from "./actions-shared.js" -import { loadProjectBrowser, projectBrowserCdpUrl, projectBrowserNoVncUrl, type ProjectBrowserSession } from "./api.js" -import { openUrl } from "./open-url.js" +import { + loadProjectBrowser, + projectBrowserCdpUrl, + projectBrowserNoVncUrl, + type ProjectBrowserSession, + startProjectBrowser +} from "./api.js" +import { prepareOpenUrl } from "./open-url.js" const browserStatusMessage = (browser: ProjectBrowserSession): string => browser.status === "running" @@ -46,19 +52,24 @@ export const openSelectedProjectBrowser = (context: BrowserActionContext) => { } export const openProjectBrowserById = (projectId: string, context: BrowserActionContext) => { + const preparedUrl = prepareOpenUrl() withBusy({ context, - effect: loadProjectBrowser(projectId), - label: "Opening project browser", + effect: startProjectBrowser(projectId), + label: "Starting project browser", + onFailure: () => { + preparedUrl.close() + }, onSuccess: (browser) => { context.setProjectBrowser(browser) if (browser.status !== "running") { + preparedUrl.close() context.setMessage(`Browser runtime is ${browser.status}. Enable Playwright MCP and start the project first.`) return } const noVncUrl = projectBrowserNoVncUrl(browser) context.setMessage( - openUrl(noVncUrl) + preparedUrl.navigate(noVncUrl) ? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` : `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` ) diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index 8fc60dd0..9154d24b 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -139,9 +139,12 @@ export const loadProjectPortForwards = (projectId: string) => ) export const loadProjectBrowser = (projectId: string) => - requestJson("GET", `/projects/${encodeURIComponent(projectId)}/browser`, ProjectBrowserResponseSchema).pipe( - Effect.map((response) => response.browser) - ) + requestJson("GET", `/projects/${encodeURIComponent(projectId)}/browser`, ProjectBrowserResponseSchema) + .pipe(Effect.map((response) => response.browser)) + +export const startProjectBrowser = (projectId: string) => + requestJson("POST", `/projects/${encodeURIComponent(projectId)}/browser/start`, ProjectBrowserResponseSchema) + .pipe(Effect.map((response) => response.browser)) export const createProjectPortForward = ( projectId: string, diff --git a/packages/app/src/web/app-ready-browser-openable.ts b/packages/app/src/web/app-ready-browser-openable.ts index 85c9e9d7..5e0bd815 100644 --- a/packages/app/src/web/app-ready-browser-openable.ts +++ b/packages/app/src/web/app-ready-browser-openable.ts @@ -1,17 +1,12 @@ import type { ProjectBrowserSession } from "./api.js" import type { BrowserMenuTag } from "./menu.js" -export const browserSidecarUnavailableMessage = - "Browser runtime is not running. Enable Playwright MCP and start the project first." +export const browserSidecarUnavailableMessage = "Select a project before opening the browser." export const canOpenProjectBrowser = ( - projectBrowser: ProjectBrowserSession | null, + _projectBrowser: ProjectBrowserSession | null, projectId: string | null | undefined -): boolean => - projectId !== null && - projectId !== undefined && - projectBrowser?.projectId === projectId && - projectBrowser.status === "running" +): boolean => projectId !== null && projectId !== undefined export const canRunProjectBrowserAction = ( menu: BrowserMenuTag, diff --git a/packages/app/src/web/app-terminal-session-handlers.ts b/packages/app/src/web/app-terminal-session-handlers.ts index 1bf2022a..e2011f34 100644 --- a/packages/app/src/web/app-terminal-session-handlers.ts +++ b/packages/app/src/web/app-terminal-session-handlers.ts @@ -6,13 +6,13 @@ import { applyProject, type ContainerTaskSnapshot, createProjectTerminalSession, - loadProjectBrowser, loadProjectTaskLogs, loadProjectTasks, openSkiller, projectBrowserCdpUrl, projectBrowserNoVncUrl, type ProjectBrowserSession, + startProjectBrowser, stopProjectTask } from "./api.js" import { openUrl, prepareOpenUrl } from "./open-url.js" @@ -47,25 +47,32 @@ const confirmApplyProject = (label: string): boolean => { ) } -const browserStatusMessage = (browser: ProjectBrowserSession): string => { +const browserStatusMessage = (browser: ProjectBrowserSession, opened: boolean): string => { if (browser.status !== "running") { return `Browser runtime is ${browser.status}. Enable Playwright MCP and start the project first.` } const noVncUrl = projectBrowserNoVncUrl(browser) - return openUrl(noVncUrl) + return opened ? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` : `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` } const runOpenBrowser = (projectId: string, setMessage: StateMessageUpdater): void => { + const preparedUrl = prepareOpenUrl() void Effect.runPromise( - loadProjectBrowser(projectId).pipe( + startProjectBrowser(projectId).pipe( Effect.match({ onFailure: (error) => { + preparedUrl.close() setMessage(`Failed to open browser: ${error}`) }, onSuccess: (browser) => { - setMessage(browserStatusMessage(browser)) + if (browser.status !== "running") { + preparedUrl.close() + setMessage(browserStatusMessage(browser, false)) + return + } + setMessage(browserStatusMessage(browser, preparedUrl.navigate(projectBrowserNoVncUrl(browser)))) } }) ) diff --git a/packages/app/src/web/panel-browser.tsx b/packages/app/src/web/panel-browser.tsx index 1fb5af71..92cec7bf 100644 --- a/packages/app/src/web/panel-browser.tsx +++ b/packages/app/src/web/panel-browser.tsx @@ -56,26 +56,27 @@ const BrowserLinks = ({ browser }: { readonly browser: ProjectBrowserSession }): const BrowserStatusDetails = ( { browser, - canOpenBrowser + selectedProjectId }: { readonly browser: ProjectBrowserSession | null - readonly canOpenBrowser: boolean + readonly selectedProjectId: string | null } ): JSX.Element => { - if (browser === null) { + if (browser === null || browser.projectId !== selectedProjectId) { return Browser status is not loaded. } + const browserRunning = browser.status === "running" return ( Container: {browser.containerName} {browser.status} - {canOpenBrowser + {browserRunning ? : ( - Enable Playwright MCP for this project and start it before opening the browser. + Open browser will start the runtime for this project. )} @@ -111,7 +112,8 @@ export const BrowserPanel = ( selectedProjectSummary }: BrowserPanelProps ): JSX.Element => { - const canOpenBrowser = canOpenProjectBrowser(browser, selectedProjectSummary?.id ?? null) + const selectedProjectId = selectedProjectSummary?.id ?? null + const canOpenBrowser = canOpenProjectBrowser(browser, selectedProjectId) return ( Browser @@ -121,7 +123,7 @@ export const BrowserPanel = ( Project: {selectedProjectSummary?.displayName ?? "not selected"} - + vi.fn()) +const startProjectBrowserMock = vi.hoisted(() => vi.fn()) vi.mock("../../src/web/api.js", () => ({ loadProjectBrowser: loadProjectBrowserMock, projectBrowserCdpUrl: (browser: { readonly cdpPath: string }) => browser.cdpPath, - projectBrowserNoVncUrl: (browser: { readonly noVncPath: string }) => browser.noVncPath + projectBrowserNoVncUrl: (browser: { readonly noVncPath: string }) => browser.noVncPath, + startProjectBrowser: startProjectBrowserMock })) const runningBrowser: ProjectBrowserSession = { @@ -35,8 +38,12 @@ const missingBrowser: ProjectBrowserSession = { } describe("web browser actions", () => { + let openedWindow: BrowserOpenMockWindow = makeBrowserOpenMockWindow() + beforeEach(() => { loadProjectBrowserMock.mockReset() + startProjectBrowserMock.mockReset() + openedWindow = makeBrowserOpenMockWindow() }) afterEach(() => { @@ -45,9 +52,10 @@ describe("web browser actions", () => { it.effect("opens a running project browser by id", () => Effect.gen(function*(_) { - const openMock = vi.fn>(() => null) - vi.stubGlobal("open", openMock) - loadProjectBrowserMock.mockImplementation((projectId: string) => Effect.succeed({ ...runningBrowser, projectId })) + const openMock = stubBrowserOpen(openedWindow) + startProjectBrowserMock.mockImplementation((projectId: string) => + Effect.succeed({ ...runningBrowser, projectId }) + ) const { context, setMessage, setProjectBrowser } = makeBrowserActionContext({ selectedProjectName: "octocat/hello-world" @@ -59,17 +67,15 @@ describe("web browser actions", () => { expect(setProjectBrowser).toHaveBeenCalledWith(runningBrowser) })) - expect(openMock).toHaveBeenCalledWith("/api/projects/project-1/browser/novnc", "_blank", "noopener") - expect(setMessage).toHaveBeenLastCalledWith( - "Browser popup was blocked. Open /api/projects/project-1/browser/novnc manually. CDP endpoint: /api/projects/project-1/browser/cdp." - ) + expect(openMock).toHaveBeenCalledWith("about:blank", "_blank", "noopener") + expect(openedWindow.location.href).toBe("/api/projects/project-1/browser/novnc") + expect(setMessage).toHaveBeenLastCalledWith("Browser opened. CDP endpoint: /api/projects/project-1/browser/cdp.") })) - it.effect("reports browser runtime status instead of opening non-running browsers", () => + it.effect("starts the project browser before reporting a non-running status", () => Effect.gen(function*(_) { - const openMock = vi.fn>(() => null) - vi.stubGlobal("open", openMock) - loadProjectBrowserMock.mockImplementation(() => Effect.succeed(missingBrowser)) + const openMock = stubBrowserOpen(openedWindow) + startProjectBrowserMock.mockImplementation(() => Effect.succeed(missingBrowser)) const { context, setMessage, setProjectBrowser } = makeBrowserActionContext({ selectedProjectId: "project-1", @@ -82,7 +88,9 @@ describe("web browser actions", () => { expect(setProjectBrowser).toHaveBeenCalledWith(missingBrowser) })) - expect(openMock).not.toHaveBeenCalled() + expect(startProjectBrowserMock).toHaveBeenCalledWith("project-1") + expect(openMock).toHaveBeenCalledWith("about:blank", "_blank", "noopener") + expect(openedWindow.close).toHaveBeenCalledOnce() expect(setMessage).toHaveBeenLastCalledWith( "Browser runtime is missing. Enable Playwright MCP and start the project first." ) @@ -93,7 +101,7 @@ describe("web browser actions", () => { openSelectedProjectBrowser(context) - expect(loadProjectBrowserMock).not.toHaveBeenCalled() + expect(startProjectBrowserMock).not.toHaveBeenCalled() expect(setMessage).toHaveBeenLastCalledWith("No project selected.") }) }) diff --git a/packages/app/tests/docker-git/app-ready-browser-openable.test.ts b/packages/app/tests/docker-git/app-ready-browser-openable.test.ts index c9b88182..2d609e3a 100644 --- a/packages/app/tests/docker-git/app-ready-browser-openable.test.ts +++ b/packages/app/tests/docker-git/app-ready-browser-openable.test.ts @@ -19,17 +19,19 @@ const browser: ProjectBrowserSession = { } describe("browser open availability", () => { - it("enables browser actions only for the running sidecar on the same project", () => { + it("enables browser actions when a project context exists", () => { expect(canOpenProjectBrowser(browser, "project-1")).toBe(true) - expect(canOpenProjectBrowser({ ...browser, status: "missing" }, "project-1")).toBe(false) - expect(canOpenProjectBrowser(browser, "project-2")).toBe(false) - expect(canOpenProjectBrowser(null, "project-1")).toBe(false) + expect(canOpenProjectBrowser({ ...browser, status: "missing" }, "project-1")).toBe(true) + expect(canOpenProjectBrowser(browser, "project-2")).toBe(true) + expect(canOpenProjectBrowser(null, "project-1")).toBe(true) + expect(canOpenProjectBrowser(browser, null)).toBe(false) }) - it("gates only the browser menu action by sidecar availability", () => { + it("gates only the browser menu action by project availability", () => { expect(canRunProjectBrowserAction("Browser", browser, "project-1")).toBe(true) - expect(canRunProjectBrowserAction("Browser", null, "project-1")).toBe(false) + expect(canRunProjectBrowserAction("Browser", null, "project-1")).toBe(true) + expect(canRunProjectBrowserAction("Browser", null, null)).toBe(false) expect(canRunProjectBrowserAction("Info", null, "project-1")).toBe(true) - expect(browserSidecarUnavailableMessage).toContain("Enable Playwright MCP") + expect(browserSidecarUnavailableMessage).toContain("Select a project") }) }) diff --git a/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts index 0ae83cde..2a1a7399 100644 --- a/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts +++ b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { afterEach, beforeEach, vi } from "vitest" +import type { ProjectBrowserSession } from "../../src/web/api.js" import { newProjectTerminalUrl, type ProjectHandlers, @@ -11,7 +12,10 @@ import { waitForAssertion } from "./browser-action-context-fixture.js" import { type BrowserOpenMockWindow, makeBrowserOpenMockWindow, stubBrowserOpen } from "./browser-open-fixture.js" const terminalApiMocks = vi.hoisted(() => ({ - openSkiller: vi.fn() + openSkiller: vi.fn(), + projectBrowserCdpUrl: vi.fn(), + projectBrowserNoVncUrl: vi.fn(), + startProjectBrowser: vi.fn() })) vi.mock("../../src/web/api.js", () => ({ @@ -21,8 +25,9 @@ vi.mock("../../src/web/api.js", () => ({ loadProjectTaskLogs: vi.fn(), loadProjectTasks: vi.fn(), openSkiller: terminalApiMocks.openSkiller, - projectBrowserCdpUrl: vi.fn(), - projectBrowserNoVncUrl: vi.fn(), + projectBrowserCdpUrl: terminalApiMocks.projectBrowserCdpUrl, + projectBrowserNoVncUrl: terminalApiMocks.projectBrowserNoVncUrl, + startProjectBrowser: terminalApiMocks.startProjectBrowser, stopProjectTask: vi.fn() })) @@ -57,6 +62,17 @@ const skillerLaunch = () => ({ trpcPort: 17_888 }) +const runningBrowser: ProjectBrowserSession = { + cdpPath: "/b/repo-issue-7/cdp/json/version", + cdpUrl: "ws://browser", + containerName: "dg-repo-issue-7-browser", + noVncPath: "/b/repo-issue-7/vnc.html", + noVncUrl: "https://browser/vnc.html", + projectId: "project-1", + projectKey: "repo-issue-7", + status: "running" +} + type ExpectedProjectHandlers = { readonly apply: boolean readonly browser: boolean @@ -88,6 +104,8 @@ describe("useProjectActionHandlers", () => { vi.clearAllMocks() openedWindow = makeBrowserOpenMockWindow() stubBrowserOpen(openedWindow) + terminalApiMocks.projectBrowserCdpUrl.mockImplementation((browser: ProjectBrowserSession) => browser.cdpPath) + terminalApiMocks.projectBrowserNoVncUrl.mockImplementation((browser: ProjectBrowserSession) => browser.noVncPath) }) afterEach(() => { @@ -170,4 +188,23 @@ describe("useProjectActionHandlers", () => { ) })) })) + + it.effect("starts and opens the browser from a terminal action", () => + Effect.gen(function*(_) { + const setMessage = vi.fn() + terminalApiMocks.startProjectBrowser.mockImplementation(() => Effect.succeed(runningBrowser)) + const handlers = buildHandlers({ setMessage }) + + expect(typeof handlers.onOpenBrowser).toBe("function") + handlers.onOpenBrowser?.() + + expect(openedWindow.opener).toBeNull() + yield* _(waitForAssertion(() => { + expect(terminalApiMocks.startProjectBrowser).toHaveBeenCalledWith("project-1") + expect(openedWindow.location.href).toBe("/b/repo-issue-7/vnc.html") + expect(setMessage).toHaveBeenCalledWith( + "Browser opened. CDP endpoint: /b/repo-issue-7/cdp/json/version." + ) + })) + })) })