diff --git a/.changeset/browser-daemon-mode.md b/.changeset/browser-daemon-mode.md new file mode 100644 index 00000000..a994e4c8 --- /dev/null +++ b/.changeset/browser-daemon-mode.md @@ -0,0 +1,5 @@ +--- +"@prover-coder-ai/docker-git": minor +--- + +Add daemon mode for `docker-git browser` via `-d` and `--daemon`. diff --git a/packages/app/src/docker-git/browser-frontend.ts b/packages/app/src/docker-git/browser-frontend.ts index c2f6ced9..0b1e593c 100644 --- a/packages/app/src/docker-git/browser-frontend.ts +++ b/packages/app/src/docker-git/browser-frontend.ts @@ -63,6 +63,23 @@ type BrowserFrontendRuntimeState = { readonly webState: BrowserFrontendStateFile | null } +export interface BrowserFrontendCommandOptions { + readonly daemon: boolean +} + +const browserFrontendForegroundOptions: BrowserFrontendCommandOptions = { daemon: false } + +type BrowserFrontendLaunch = { + readonly env: Readonly> + readonly localUrl: string +} + +type BrowserFrontendRunnerEffect = Effect.Effect< + void, + ControllerBootstrapError | PlatformError, + CommandExecutor.CommandExecutor +> + const browserEnv = (decision: BrowserFrontendStartDecision): Readonly> => ({ ...copyProcessEnv(), DOCKER_GIT_API_URL: decision.apiBaseUrl, @@ -76,12 +93,7 @@ const runStreaming = ( args: ReadonlyArray, env: Readonly> ): Effect.Effect => - runCommandExitCodeStreaming({ - args, - command: "bun", - cwd: process.cwd(), - env - }) + runCommandExitCodeStreaming({ args, command: "bun", cwd: process.cwd(), env }) const parsePids = (output: string): ReadonlyArray => output @@ -89,6 +101,48 @@ const parsePids = (output: string): ReadonlyArray => .map((pid) => pid.trim()) .filter((pid) => /^\d+$/u.test(pid)) +// CHANGE: derive a stable daemon log path beside the browser runtime state file. +// WHY: detached mode must preserve diagnostics after the parent CLI exits. +// QUOTE(ТЗ): "Run browser with support dameon mode, like a flag -d" +// REF: issue-373 +// SOURCE: n/a +// FORMAT THEOREM: suffix(statePath,".json") -> logPath = prefix(statePath,".json") + ".log" +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: every state path maps deterministically to exactly one log path +// COMPLEXITY: O(n)/O(n) where n = |statePath| +const browserFrontendLogPath = (statePath: string): string => + statePath.endsWith(".json") ? `${statePath.slice(0, -".json".length)}.log` : `${statePath}.log` + +const parseDaemonPid = (output: string): Effect.Effect => { + const pid = parsePids(output)[0] + return pid === undefined + ? Effect.fail(browserFrontendError("Browser frontend daemon did not report a pid.")) + : Effect.succeed(pid) +} + +const startDaemon = ( + args: ReadonlyArray, + env: Readonly>, + logPath: string +): Effect.Effect => { + const script = [ + "log_path=\"$1\"", + "shift", + "command -v nohup >/dev/null 2>&1 || exit 127", + "command -v \"$1\" >/dev/null 2>&1 || exit 127", + "mkdir -p \"$(dirname \"$log_path\")\"", + "nohup \"$@\" >>\"$log_path\" 2>&1 < /dev/null &", + String.raw`printf '%s\n' "$!"` + ].join("\n") + + return runCommandCapture( + { args: ["-c", script, "sh", logPath, "bun", ...args], command: "sh", cwd: process.cwd(), env }, + [0], + () => browserFrontendError("Failed to start browser frontend daemon.") + ).pipe(Effect.flatMap((output) => parseDaemonPid(output))) +} + const findWebServerPids = (): Effect.Effect, never, CommandExecutor.CommandExecutor> => { const script = [ "port=\"$1\"", @@ -271,13 +325,19 @@ const ensureSuccess = ( ? Effect.void : Effect.fail(browserFrontendError(`${action} failed with exit code ${exitCode}.`)) -export const runBrowserFrontend = ( +// CHANGE: share the browser frontend build phase between foreground and daemon modes. +// WHY: daemon mode must not drift from foreground mode in revision, environment, or build failure semantics. +// QUOTE(ТЗ): "Run browser with support dameon mode, like a flag -d" +// REF: issue-373 +// SOURCE: n/a +// FORMAT THEOREM: forall mode in {foreground,daemon}: launch(mode) -> built(webRevision) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: launch env is derived exactly once from BrowserFrontendStartDecision +// COMPLEXITY: O(build)/O(env) +const buildBrowserFrontendLaunch = ( decision: BrowserFrontendStartDecision -): Effect.Effect< - void, - ControllerBootstrapError | PlatformError, - CommandExecutor.CommandExecutor -> => +): Effect.Effect => Effect.gen(function*(_) { const env = browserEnv(decision) const localUrl = `http://${decision.host}:${decision.port}/` @@ -285,13 +345,28 @@ export const runBrowserFrontend = ( yield* _(Effect.log(`Building docker-git browser frontend ${decision.webRevision} for API ${decision.apiBaseUrl}.`)) const buildExitCode = yield* _(runStreaming(["run", "--cwd", "packages/app", "build:web"], env)) yield* _(ensureSuccess(buildExitCode, "Browser frontend build")) + return { env, localUrl } + }) - yield* _(Effect.log(`docker-git browser frontend: ${localUrl}`)) +export const runBrowserFrontend = (decision: BrowserFrontendStartDecision): BrowserFrontendRunnerEffect => + Effect.gen(function*(_) { + const launch = yield* _(buildBrowserFrontendLaunch(decision)) + yield* _(Effect.log(`docker-git browser frontend: ${launch.localUrl}`)) yield* _(Effect.log("Press Ctrl+C to stop the browser frontend.")) - const serveExitCode = yield* _(runStreaming(["run", "--cwd", "packages/app", "serve:web"], env)) + const serveExitCode = yield* _(runStreaming(["run", "--cwd", "packages/app", "serve:web"], launch.env)) yield* _(ensureSuccess(serveExitCode, "Browser frontend server")) }) +export const runBrowserFrontendDaemon = (decision: BrowserFrontendStartDecision): BrowserFrontendRunnerEffect => + Effect.gen(function*(_) { + const launch = yield* _(buildBrowserFrontendLaunch(decision)) + const logPath = browserFrontendLogPath(decision.statePath) + + const pid = yield* _(startDaemon(["run", "--cwd", "packages/app", "serve:web"], launch.env, logPath)) + yield* _(Effect.log(`docker-git browser frontend daemon: ${launch.localUrl} (pid ${pid})`)) + yield* _(Effect.log(`docker-git browser frontend daemon log: ${logPath}`)) + }) + // CHANGE: make `docker-git browser` idempotent for local development // WHY: repeated invocations should deploy only changed API or browser code // QUOTE(ТЗ): "Надо перезапускать только те контейнеры у которых изменился код" @@ -302,15 +377,25 @@ export const runBrowserFrontend = ( // EFFECT: Effect // INVARIANT: controller readiness is checked independently from browser runtime reuse // COMPLEXITY: O(total_bytes(web_inputs) + processes + controller_probe) -export const runBrowserFrontendCommand: Effect.Effect< +export const runBrowserFrontendCommandWithOptions = ( + options: BrowserFrontendCommandOptions +): Effect.Effect< void, ControllerBootstrapError | PlatformError, ControllerRuntime -> = pipe( - prepareBrowserStack(), - Effect.flatMap((decision) => - decision.shouldStartWeb - ? runBrowserFrontend(decision) - : Effect.log(`docker-git browser frontend is already running at http://${decision.host}:${decision.port}/`) +> => + pipe( + prepareBrowserStack(), + Effect.flatMap((decision) => { + if (!decision.shouldStartWeb) { + return Effect.log(`docker-git browser frontend is already running at http://${decision.host}:${decision.port}/`) + } + return options.daemon ? runBrowserFrontendDaemon(decision) : runBrowserFrontend(decision) + }) ) -) + +export const runBrowserFrontendCommand: Effect.Effect< + void, + ControllerBootstrapError | PlatformError, + ControllerRuntime +> = runBrowserFrontendCommandWithOptions(browserFrontendForegroundOptions) diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index 509b199f..f3e4df62 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -20,9 +20,22 @@ const isHelpFlag = (token: string): boolean => token === "--help" || token === " const helpCommand: Command = { _tag: "Help", message: usageText } const menuCommand: Command = { _tag: "Menu" } -const browserCommand: Command = { _tag: "Browser" } const statusCommand: Command = { _tag: "Status" } const downAllCommand: Command = { _tag: "DownAll" } +const browserDaemonFlags = new Set(["-d", "--daemon"]) + +// CHANGE: parse browser daemon mode without side effects. +// WHY: CLI intent must be a typed pure value before the shell starts web processes. +// QUOTE(ТЗ): "Run browser with support dameon mode, like a flag -d" +// REF: issue-373 +// SOURCE: n/a +// FORMAT THEOREM: forall args: daemon(parseBrowser(args)) <-> exists a in args: a in {"-d","--daemon"} +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: browser foreground mode is the default when no daemon flag is present +// COMPLEXITY: O(n)/O(1) where n = |args| +const parseBrowser = (args: ReadonlyArray): Either.Either => + Either.right({ _tag: "Browser", daemon: args.some((arg) => browserDaemonFlags.has(arg)) }) // CHANGE: parse --active flag for apply-all command to restrict to running containers // WHY: allow users to apply config only to currently active containers via --active flag @@ -90,8 +103,8 @@ export const parseArgs = (args: ReadonlyArray): Either.Either Either.right(menuCommand)) ) .pipe( - Match.when("browser", () => Either.right(browserCommand)), - Match.when("web", () => Either.right(browserCommand)), + Match.when("browser", () => parseBrowser(rest)), + Match.when("web", () => parseBrowser(rest)), Match.when("apply-all", () => parseApplyAll(rest)), Match.when("update-all", () => parseApplyAll(rest)), Match.when("auth", () => parseAuth(rest)), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index ce55f0fc..1e13b02f 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -1,7 +1,7 @@ export { formatParseError } from "../frontend-lib/core/parse-errors.js" export const usageText = `docker-git menu -docker-git browser +docker-git browser [-d|--daemon] docker-git create [--repo-url ] [options] docker-git clone [options] docker-git open [] [options] @@ -21,7 +21,7 @@ docker-git state [options] Commands: menu Interactive menu (default when no args) - browser Build and serve the browser frontend for the docker-git controller + browser Build and serve the browser frontend for the docker-git controller; use -d to run it as a daemon create, init Generate docker development environment (repo URL optional) clone Create + run container and clone repo open Open an existing docker-git project by selector, URL, or path @@ -79,6 +79,7 @@ Options: --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Rust browser MCP + noVNC/CDP session (default: --no-mcp-playwright) --auto[=claude|codex|gemini|grok] Auto-execute an agent; without value picks by auth, random if multiple are available + -d, --daemon browser: run the browser frontend server in the background after build --active apply-all: apply only to currently running containers (skip stopped ones) --force Overwrite existing files, replace conflicting docker-git projects/containers, and wipe compose volumes --force-env Reset project env defaults only (keep workspace volume/data) diff --git a/packages/app/src/docker-git/frontend-lib/core/domain.ts b/packages/app/src/docker-git/frontend-lib/core/domain.ts index 2398984d..1f7a8b14 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -139,6 +139,7 @@ export interface MenuCommand { export interface BrowserCommand { readonly _tag: "Browser" + readonly daemon: boolean } export interface AttachCommand { diff --git a/packages/app/src/docker-git/frontend-lib/shell/command-runner.ts b/packages/app/src/docker-git/frontend-lib/shell/command-runner.ts index 9c8fcdfa..9470e4b2 100644 --- a/packages/app/src/docker-git/frontend-lib/shell/command-runner.ts +++ b/packages/app/src/docker-git/frontend-lib/shell/command-runner.ts @@ -6,7 +6,7 @@ import { Effect, pipe } from "effect" import * as Chunk from "effect/Chunk" import * as Stream from "effect/Stream" -type RunCommandSpec = { +export type RunCommandSpec = { readonly cwd: string readonly command: string readonly args: ReadonlyArray diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 71b9612c..139433fb 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -21,7 +21,7 @@ import { stopContainerTask, syncState } from "./api-client.js" -import { runBrowserFrontendCommand } from "./browser-frontend.js" +import { runBrowserFrontendCommandWithOptions } from "./browser-frontend.js" import { readCommand } from "./cli/read-command.js" import { usageText } from "./cli/usage.js" import { type ControllerRuntime, ensureControllerReady } from "./controller.js" @@ -209,7 +209,7 @@ const dispatchOperationalCommand = ( ): Effect.Effect => Match.value(command).pipe( Match.when({ _tag: "Menu" }, () => withControllerReady(runMenu)), - Match.when({ _tag: "Browser" }, () => runBrowserFrontendCommand), + Match.when({ _tag: "Browser" }, (command) => runBrowserFrontendCommandWithOptions({ daemon: command.daemon })), Match.when({ _tag: "Create" }, handleCreateCommand), Match.when({ _tag: "Open" }, handleOpenCommand), Match.when({ _tag: "Status" }, handleStatusCommand), diff --git a/packages/app/tests/docker-git/browser-frontend-daemon.test.ts b/packages/app/tests/docker-git/browser-frontend-daemon.test.ts new file mode 100644 index 00000000..fceb4c08 --- /dev/null +++ b/packages/app/tests/docker-git/browser-frontend-daemon.test.ts @@ -0,0 +1,99 @@ +import { NodeContext as BrowserFrontendDaemonTestNodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { beforeEach, type MockInstance, vi } from "vitest" + +import type { BrowserFrontendStartDecision } from "../../src/docker-git/browser-frontend-state.js" +import type { RunCommandSpec } from "../../src/docker-git/frontend-lib/shell/command-runner.js" + +type DaemonNumericCommandMock = MockInstance<(spec: RunCommandSpec) => Effect.Effect> + +const captureDaemonCommandMock = vi.hoisted( + () => vi.fn<(spec: RunCommandSpec) => Effect.Effect>(() => Effect.succeed("456\n")) +) +const exitDaemonCommandMock = vi.hoisted( + () => vi.fn<(spec: RunCommandSpec) => Effect.Effect>(() => Effect.succeed(0)) +) +const streamDaemonCommandMock = vi.hoisted( + () => vi.fn<(spec: RunCommandSpec) => Effect.Effect>(() => Effect.succeed(0)) +) + +vi.mock("../../src/docker-git/frontend-lib/shell/command-runner.js", () => ({ + runCommandCapture: captureDaemonCommandMock, + runCommandExitCode: exitDaemonCommandMock, + runCommandExitCodeStreaming: streamDaemonCommandMock +})) + +const decision: BrowserFrontendStartDecision = { + apiBaseUrl: "http://127.0.0.1:3334", + host: "0.0.0.0", + port: "4174", + shouldStartWeb: true, + statePath: "/home/dev/.docker-git/.orch/state/browser-frontend.json", + webRevision: "revision-1" +} + +const runDaemonUnderTest = Effect.gen(function*(_) { + const { runBrowserFrontendDaemon } = yield* _( + Effect.promise(() => import("../../src/docker-git/browser-frontend.js")) + ) + yield* _(runBrowserFrontendDaemon(decision).pipe(Effect.provide(BrowserFrontendDaemonTestNodeContext.layer))) +}) + +const requireDaemonStartSpec = (): RunCommandSpec => { + const spec = captureDaemonCommandMock.mock.calls[0]?.[0] + if (spec === undefined) { + throw new Error("expected daemon start command") + } + return spec +} + +const resetDaemonCommandMock = (mock: DaemonNumericCommandMock): void => { + mock.mockReset() + mock.mockImplementation(() => Effect.succeed(0)) +} + +const resetDaemonCommandMocks = (): void => { + vi.resetModules() + captureDaemonCommandMock.mockReset() + captureDaemonCommandMock.mockImplementation(() => Effect.succeed("456\n")) + resetDaemonCommandMock(exitDaemonCommandMock) + resetDaemonCommandMock(streamDaemonCommandMock) +} + +describe("browser frontend daemon mode", () => { + beforeEach(resetDaemonCommandMocks) + + it.effect("builds in the foreground and starts serve:web as a daemon", () => + Effect.gen(function*(_) { + yield* _(runDaemonUnderTest) + + const daemonStartSpec = requireDaemonStartSpec() + expect(streamDaemonCommandMock).toHaveBeenCalledTimes(1) + expect(streamDaemonCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + args: ["run", "--cwd", "packages/app", "build:web"], + command: "bun" + }) + ) + expect(daemonStartSpec.command).toBe("sh") + expect(daemonStartSpec.args).toEqual([ + "-c", + expect.stringContaining("nohup \"$@\""), + "sh", + "/home/dev/.docker-git/.orch/state/browser-frontend.log", + "bun", + "run", + "--cwd", + "packages/app", + "serve:web" + ]) + expect(daemonStartSpec.env).toEqual( + expect.objectContaining({ + DOCKER_GIT_API_URL: "http://127.0.0.1:3334", + DOCKER_GIT_WEB_PORT: "4174", + DOCKER_GIT_WEB_STATE_PATH: "/home/dev/.docker-git/.orch/state/browser-frontend.json" + }) + ) + })) +}) diff --git a/packages/app/tests/docker-git/browser-frontend.test.ts b/packages/app/tests/docker-git/browser-frontend.test.ts index 8948e83b..08610ef3 100644 --- a/packages/app/tests/docker-git/browser-frontend.test.ts +++ b/packages/app/tests/docker-git/browser-frontend.test.ts @@ -6,15 +6,9 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { afterEach, beforeEach, vi } from "vitest" +import type { RunCommandSpec } from "../../src/docker-git/frontend-lib/shell/command-runner.js" import type { ControllerBootstrapError } from "../../src/docker-git/host-errors.js" -type CommandSpec = { - readonly args: ReadonlyArray - readonly command: string - readonly cwd: string - readonly env?: Readonly> -} - const ensureControllerReadyMock = vi.hoisted(() => vi.fn<() => Effect.Effect>()) const resolveApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>()) const findReachableApiBaseUrlMock = vi.hoisted( @@ -23,9 +17,11 @@ const findReachableApiBaseUrlMock = vi.hoisted( const resolveConfiguredApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>()) const resolveDefaultLocalApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string | undefined>()) const resolveExplicitApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string | undefined>()) -const runCommandCaptureMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect>()) -const runCommandExitCodeMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect>()) -const runCommandExitCodeStreamingMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect>()) +const runCommandCaptureMock = vi.hoisted(() => vi.fn<(spec: RunCommandSpec) => Effect.Effect>()) +const runCommandExitCodeMock = vi.hoisted(() => vi.fn<(spec: RunCommandSpec) => Effect.Effect>()) +const runCommandExitCodeStreamingMock = vi.hoisted( + () => vi.fn<(spec: RunCommandSpec) => Effect.Effect>() +) vi.mock("../../src/docker-git/controller.js", () => ({ ensureControllerReady: ensureControllerReadyMock, diff --git a/packages/app/tests/docker-git/parser-browser.test.ts b/packages/app/tests/docker-git/parser-browser.test.ts index d08b3649..e6d37cfb 100644 --- a/packages/app/tests/docker-git/parser-browser.test.ts +++ b/packages/app/tests/docker-git/parser-browser.test.ts @@ -6,7 +6,13 @@ import { parseOrThrow } from "./parser-helpers.js" describe("parseArgs browser frontend", () => { it.effect("parses browser aliases", () => Effect.sync(() => { - expect(parseOrThrow(["browser"])._tag).toBe("Browser") - expect(parseOrThrow(["web"])._tag).toBe("Browser") + expect(parseOrThrow(["browser"])).toEqual({ _tag: "Browser", daemon: false }) + expect(parseOrThrow(["web"])).toEqual({ _tag: "Browser", daemon: false }) + })) + + it.effect("parses browser daemon mode", () => + Effect.sync(() => { + expect(parseOrThrow(["browser", "-d"])).toEqual({ _tag: "Browser", daemon: true }) + expect(parseOrThrow(["web", "--daemon"])).toEqual({ _tag: "Browser", daemon: true }) })) }) diff --git a/packages/app/tests/docker-git/program.test.ts b/packages/app/tests/docker-git/program.test.ts index c59920fd..db7f51ea 100644 --- a/packages/app/tests/docker-git/program.test.ts +++ b/packages/app/tests/docker-git/program.test.ts @@ -6,7 +6,9 @@ import { beforeEach, vi } from "vitest" import type { Command } from "../../src/docker-git/frontend-lib/core/domain.js" const ensureControllerReadyMock = vi.hoisted(() => vi.fn(() => Effect.void)) -const runBrowserFrontendCommandMock = vi.hoisted(() => vi.fn(() => Effect.void)) +const runBrowserFrontendCommandMock = vi.hoisted( + () => vi.fn<(options: { readonly daemon: boolean }) => Effect.Effect>(() => Effect.void) +) const runMenuCallMock = vi.hoisted(() => vi.fn(() => {})) const readCommandMock = vi.hoisted(() => vi.fn<() => Command>()) const codexLoginMock = vi.hoisted(() => vi.fn(() => Effect.void)) @@ -16,7 +18,15 @@ const gitlabLoginMock = vi.hoisted(() => vi.fn(() => Effect.succeed({ ok: true } const readStatePullMock = vi.hoisted(() => vi.fn(() => Effect.succeed("State pull completed."))) const menuCommand: Extract = { _tag: "Menu" } -const browserCommand: Extract = { _tag: "Browser" } +const browserCommand: Extract = { _tag: "Browser", daemon: false } +const browserDaemonCommand: Extract = { _tag: "Browser", daemon: true } +const browserRoutingCases: ReadonlyArray<{ + readonly command: Extract + readonly options: { readonly daemon: boolean } +}> = [ + { command: browserCommand, options: { daemon: false } }, + { command: browserDaemonCommand, options: { daemon: true } } +] const codexLoginCommand: Extract = { _tag: "AuthCodexLogin", label: null, @@ -50,7 +60,8 @@ vi.mock("../../src/docker-git/controller.js", () => ({ })) vi.mock("../../src/docker-git/browser-frontend.js", () => ({ - runBrowserFrontendCommand: Effect.flatMap(Effect.sync(() => runBrowserFrontendCommandMock()), (effect) => effect) + runBrowserFrontendCommandWithOptions: (options: { readonly daemon: boolean }) => + Effect.flatMap(Effect.sync(() => runBrowserFrontendCommandMock(options)), (effect) => effect) })) vi.mock("../../src/docker-git/api-client.js", () => ({ @@ -140,15 +151,25 @@ describe("program menu dispatch", () => { expect(process.exitCode ?? 0).toBe(0) })) - it.effect("routes browser frontend through the browser command runner", () => - Effect.gen(function*(_) { - readCommandMock.mockReturnValue(browserCommand) - yield* _(runProgram()) - - expect(ensureControllerReadyMock).not.toHaveBeenCalled() - expect(runBrowserFrontendCommandMock).toHaveBeenCalledTimes(1) - expect(process.exitCode ?? 0).toBe(0) - })) + it.effect("routes browser frontend modes through the browser command runner", () => + Effect.forEach( + browserRoutingCases, + ({ command, options }) => + Effect.gen(function*(_) { + readCommandMock.mockReturnValue(command) + ensureControllerReadyMock.mockClear() + runBrowserFrontendCommandMock.mockClear() + process.exitCode = 0 + + yield* _(runProgram()) + + expect(ensureControllerReadyMock).not.toHaveBeenCalled() + expect(runBrowserFrontendCommandMock).toHaveBeenCalledTimes(1) + expect(runBrowserFrontendCommandMock).toHaveBeenCalledWith(options) + expect(process.exitCode).toBe(0) + }), + { discard: true } + )) it.effect("routes state pull through the controller API", () => Effect.gen(function*(_) {