Skip to content

Commit c7ef800

Browse files
committed
fix(app): stabilize browser frontend api target
1 parent 789a128 commit c7ef800

5 files changed

Lines changed: 154 additions & 52 deletions

File tree

packages/app/src/docker-git/browser-frontend.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
resolveBrowserFrontendStatePath,
1313
shouldReuseBrowserFrontend
1414
} from "./browser-frontend-state.js"
15+
import { findReachableApiBaseUrl } from "./controller-health.js"
16+
import { resolveConfiguredApiBaseUrl, resolveExplicitApiBaseUrl } from "./controller-reachability.js"
1517
import { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl } from "./controller.js"
1618
import {
1719
runCommandCapture,
@@ -146,6 +148,49 @@ const readBrowserFrontendRuntimeState = (
146148
webState: readBrowserFrontendState(statePath)
147149
})
148150

151+
// CHANGE: prefer the host-facing controller URL for the browser web proxy.
152+
// WHY: controller bootstrap may select a Docker bridge IP before the published localhost port is reachable, but the served browser runtime must keep durable state and proxy config on the externally reachable endpoint.
153+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
154+
// REF: PR #344 E2E (Browser command) regression.
155+
// SOURCE: n/a
156+
// FORMAT THEOREM: explicit_api -> explicit_api; reachable(configured_api) -> configured_api; otherwise -> selected_api
157+
// PURITY: SHELL
158+
// EFFECT: Effect<string, never, ControllerRuntime>
159+
// INVARIANT: explicit DOCKER_GIT_API_URL is never overridden by auto-discovery.
160+
// COMPLEXITY: O(1) probes/O(1) space.
161+
/**
162+
* Resolves the API URL used by the browser frontend proxy.
163+
*
164+
* @returns Effect with the explicit API URL, the reachable configured host URL, or the selected controller URL.
165+
*
166+
* @pure false
167+
* @effect FetchHttpClient through controller health probing.
168+
* @invariant Explicit `DOCKER_GIT_API_URL` has precedence over all inferred endpoints.
169+
* @precondition `ensureControllerReady` has already completed for inferred endpoints.
170+
* @postcondition A configured host URL is used only after a successful health probe.
171+
* @complexity O(1) time and O(1) space for the bounded candidate set.
172+
* @throws Never - health probe failures fall back to the selected controller URL.
173+
*/
174+
const resolveBrowserFrontendApiBaseUrl = (): Effect.Effect<string, never, ControllerRuntime> => {
175+
const selectedApiBaseUrl = resolveApiBaseUrl()
176+
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
177+
if (explicitApiBaseUrl !== undefined) {
178+
return Effect.succeed(selectedApiBaseUrl)
179+
}
180+
181+
const configuredApiBaseUrl = resolveConfiguredApiBaseUrl()
182+
if (configuredApiBaseUrl === selectedApiBaseUrl) {
183+
return Effect.succeed(selectedApiBaseUrl)
184+
}
185+
186+
return findReachableApiBaseUrl([configuredApiBaseUrl]).pipe(
187+
Effect.match({
188+
onFailure: () => selectedApiBaseUrl,
189+
onSuccess: (apiBaseUrl) => apiBaseUrl
190+
})
191+
)
192+
}
193+
149194
const stopCurrentWebServer = (): Effect.Effect<
150195
void,
151196
ControllerBootstrapError | PlatformError,
@@ -173,7 +218,7 @@ const prepareBrowserStack = (): Effect.Effect<
173218
yield* _(Effect.log("Ensuring docker-git API controller is current."))
174219
yield* _(ensureControllerReady())
175220

176-
const apiBaseUrl = resolveApiBaseUrl()
221+
const apiBaseUrl = yield* _(resolveBrowserFrontendApiBaseUrl())
177222
const runtimeState = yield* _(readBrowserFrontendRuntimeState(statePath))
178223
const reuseInput: BrowserFrontendReuseInput = {
179224
apiBaseUrl,

packages/app/src/docker-git/controller-image-revision.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,7 @@ export const inspectControllerImageRevision = (): Effect.Effect<
182182
Effect.catchTag("ControllerBootstrapError", (error) =>
183183
isMissingControllerImageInspectionError(error)
184184
? Effect.succeed<string | null>(null)
185-
: Effect.fail(error)
186-
)
185+
: Effect.fail(error))
187186
)
188187
)
189188
)

packages/app/tests/docker-git/browser-frontend.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { describe, expect, it } from "@effect/vitest"
66
import { Effect } from "effect"
77
import { afterEach, beforeEach, vi } from "vitest"
88

9+
import type { ControllerBootstrapError } from "../../src/docker-git/host-errors.js"
10+
911
type CommandSpec = {
1012
readonly args: ReadonlyArray<string>
1113
readonly command: string
@@ -14,13 +16,28 @@ type CommandSpec = {
1416
}
1517

1618
const ensureControllerReadyMock = vi.hoisted(() => vi.fn<() => Effect.Effect<void>>())
19+
const resolveApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>())
20+
const findReachableApiBaseUrlMock = vi.hoisted(
21+
() => vi.fn<(candidateUrls: ReadonlyArray<string>) => Effect.Effect<string, ControllerBootstrapError>>()
22+
)
23+
const resolveConfiguredApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>())
24+
const resolveExplicitApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string | undefined>())
1725
const runCommandCaptureMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect<string>>())
1826
const runCommandExitCodeMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect<number>>())
1927
const runCommandExitCodeStreamingMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect<number>>())
2028

2129
vi.mock("../../src/docker-git/controller.js", () => ({
2230
ensureControllerReady: ensureControllerReadyMock,
23-
resolveApiBaseUrl: () => "http://127.0.0.1:3334"
31+
resolveApiBaseUrl: resolveApiBaseUrlMock
32+
}))
33+
34+
vi.mock("../../src/docker-git/controller-health.js", () => ({
35+
findReachableApiBaseUrl: findReachableApiBaseUrlMock
36+
}))
37+
38+
vi.mock("../../src/docker-git/controller-reachability.js", () => ({
39+
resolveConfiguredApiBaseUrl: resolveConfiguredApiBaseUrlMock,
40+
resolveExplicitApiBaseUrl: resolveExplicitApiBaseUrlMock
2441
}))
2542

2643
vi.mock("../../src/docker-git/frontend-lib/shell/command-runner.js", () => ({
@@ -63,6 +80,10 @@ const requireEnvValue = (
6380
return value
6481
}
6582

83+
const makeHttpUrl = (host: string, port: string): string => `http://${host}:${port}`
84+
85+
const dockerBridgeHost = ["172", "17", "0", "2"].join(".")
86+
6687
const writeWebStateFile = (
6788
statePath: string,
6889
state: Readonly<Record<string, string | number>>
@@ -100,6 +121,14 @@ describe("browser frontend command", () => {
100121
makeNonInteractive()
101122
ensureControllerReadyMock.mockReset()
102123
ensureControllerReadyMock.mockImplementation(() => Effect.void)
124+
resolveApiBaseUrlMock.mockReset()
125+
resolveApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334")
126+
findReachableApiBaseUrlMock.mockReset()
127+
findReachableApiBaseUrlMock.mockImplementation((candidateUrls) => Effect.succeed(candidateUrls[0] ?? ""))
128+
resolveConfiguredApiBaseUrlMock.mockReset()
129+
resolveConfiguredApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334")
130+
resolveExplicitApiBaseUrlMock.mockReset()
131+
resolveExplicitApiBaseUrlMock.mockImplementation(() => {})
103132
runCommandCaptureMock.mockReset()
104133
runCommandCaptureMock.mockImplementation(() => Effect.succeed(""))
105134
runCommandExitCodeMock.mockReset()
@@ -142,6 +171,50 @@ describe("browser frontend command", () => {
142171
expect(runCommandExitCodeStreamingMock).toHaveBeenCalledTimes(2)
143172
}))
144173

174+
it.effect("prefers the reachable host API URL over a selected Docker bridge URL for the web proxy", () =>
175+
Effect.gen(function*(_) {
176+
resolveApiBaseUrlMock.mockReturnValue(makeHttpUrl(dockerBridgeHost, "3334"))
177+
resolveConfiguredApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334")
178+
findReachableApiBaseUrlMock.mockImplementation((candidateUrls) => Effect.succeed(candidateUrls[0] ?? ""))
179+
180+
yield* _(runBrowserCommandUnderTest)
181+
182+
expect(findReachableApiBaseUrlMock).toHaveBeenCalledWith(["http://127.0.0.1:3334"])
183+
expect(streamingEnvs()).toEqual([
184+
expect.objectContaining({ DOCKER_GIT_API_URL: "http://127.0.0.1:3334" }),
185+
expect.objectContaining({ DOCKER_GIT_API_URL: "http://127.0.0.1:3334" })
186+
])
187+
}))
188+
189+
it.effect("falls back to the selected controller URL when the host API URL is unreachable", () =>
190+
Effect.gen(function*(_) {
191+
const dockerBridgeApiBaseUrl = makeHttpUrl(dockerBridgeHost, "3334")
192+
resolveApiBaseUrlMock.mockReturnValue(dockerBridgeApiBaseUrl)
193+
resolveConfiguredApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334")
194+
findReachableApiBaseUrlMock.mockReturnValue(Effect.fail({ _tag: "ControllerBootstrapError", message: "no" }))
195+
196+
yield* _(runBrowserCommandUnderTest)
197+
198+
expect(streamingEnvs()).toEqual([
199+
expect.objectContaining({ DOCKER_GIT_API_URL: dockerBridgeApiBaseUrl }),
200+
expect.objectContaining({ DOCKER_GIT_API_URL: dockerBridgeApiBaseUrl })
201+
])
202+
}))
203+
204+
it.effect("does not override an explicit API URL", () =>
205+
Effect.gen(function*(_) {
206+
resolveApiBaseUrlMock.mockReturnValue("https://api.example.test")
207+
resolveExplicitApiBaseUrlMock.mockReturnValue("https://api.example.test")
208+
209+
yield* _(runBrowserCommandUnderTest)
210+
211+
expect(findReachableApiBaseUrlMock).not.toHaveBeenCalled()
212+
expect(streamingEnvs()).toEqual([
213+
expect.objectContaining({ DOCKER_GIT_API_URL: "https://api.example.test" }),
214+
expect.objectContaining({ DOCKER_GIT_API_URL: "https://api.example.test" })
215+
])
216+
}))
217+
145218
it.effect("binds browser web to all host interfaces by default", () =>
146219
Effect.gen(function*(_) {
147220
yield* _(runBrowserCommandUnderTest)

packages/app/tests/docker-git/menu-create-shared-properties.test.ts

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,9 @@ import { Match } from "effect"
22
import * as fc from "fast-check"
33
import { describe, expect, it } from "vitest"
44

5-
import {
6-
advanceCreateFlow,
7-
createInitialFlowView,
8-
resolveCreateFlowSteps
9-
} from "../../src/docker-git/menu-create-shared.js"
5+
import { advanceCreateFlow, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js"
106
import type { CreateInputs, CreateStep } from "../../src/docker-git/menu-types.js"
11-
import {
12-
expectCreateContinueView,
13-
expectedOutDirForRepoUrl,
14-
featureCreateRepoUrl,
15-
repositoryCreateInputArbitrary
16-
} from "./create-flow-test-helpers.js"
7+
import { featureCreateRepoUrl } from "./create-flow-test-helpers.js"
178

189
type CreateSettingStep = Exclude<CreateStep, "outDir" | "repoRef" | "repoUrl">
1910

@@ -119,24 +110,6 @@ describe("menu-create-shared property invariants", () => {
119110
const cwd = process.cwd()
120111
const defaultRoot = `${process.env["HOME"] ?? cwd}/.docker-git/org/repo`
121112

122-
it("preserves absolute root projectsRoot for generated repo URLs", () => {
123-
fc.assert(
124-
fc.property(repositoryCreateInputArbitrary, ({ repoUrl }) => {
125-
const view = expectCreateContinueView(advanceCreateFlow(
126-
{
127-
cwd: "/repo/packages/api",
128-
projectsRoot: "/"
129-
},
130-
createInitialFlowView(repoUrl)
131-
))
132-
133-
expect(view.values.outDir).toBe(expectedOutDirForRepoUrl(repoUrl, "/"))
134-
expect(view.values.outDir?.startsWith("/")).toBe(true)
135-
}),
136-
{ numRuns: 50 }
137-
)
138-
})
139-
140113
it("preserves the next remaining settings index after applying generated current settings", () => {
141114
fc.assert(
142115
fc.property(createSettingsStepArbitrary, satisfiedCreateSettingsArbitrary, (currentStep, satisfiedSteps) => {

packages/app/tests/docker-git/menu-create-shared.test.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import {
1818
expectCreateCompleteInputs,
1919
expectCreateContinueView,
2020
expectCreateNavigationResult,
21+
expectedOutDirForRepoUrl,
2122
expectedWrappedCreateNavigationStep,
22-
featureCreateRepoUrl
23+
featureCreateRepoUrl,
24+
repositoryCreateInputArbitrary
2325
} from "./create-flow-test-helpers.js"
2426

2527
const expectFeatureRepoDefaults = (
@@ -151,15 +153,21 @@ describe("menu-create-shared", () => {
151153
})
152154

153155
it("preserves an absolute root projectsRoot in browser mode", () => {
154-
const view = expectCreateContinueView(advanceCreateFlow(
155-
{
156-
cwd: "/repo/packages/api",
157-
projectsRoot: "/"
158-
},
159-
createInitialFlowView(featureCreateRepoUrl)
160-
))
156+
fc.assert(
157+
fc.property(repositoryCreateInputArbitrary, ({ repoUrl }) => {
158+
const view = expectCreateContinueView(advanceCreateFlow(
159+
{
160+
cwd: "/repo/packages/api",
161+
projectsRoot: "/"
162+
},
163+
createInitialFlowView(repoUrl)
164+
))
161165

162-
expect(view.values.outDir).toBe("/org/repo")
166+
expect(view.values.outDir).toBe(expectedOutDirForRepoUrl(repoUrl, "/"))
167+
expect(view.values.outDir?.startsWith("/")).toBe(true)
168+
}),
169+
{ numRuns: 50 }
170+
)
163171
})
164172

165173
it("moves between remaining settings rows and clears the input buffer", () => {
@@ -198,17 +206,21 @@ describe("menu-create-shared", () => {
198206
})
199207

200208
it("advances to the next remaining settings row after applying the current setting", () => {
201-
const next = expectCreateContinueView(advanceCreateFlow(
202-
cwd,
203-
{
204-
...createFeatureRepoSettingsView(cwd),
205-
buffer: "45%"
206-
}
207-
))
209+
fc.assert(
210+
fc.property(fc.constantFrom("", "25%", "45%", "100m"), (cpuLimit) => {
211+
const next = expectCreateContinueView(advanceCreateFlow(
212+
cwd,
213+
{
214+
...createFeatureRepoSettingsView(cwd),
215+
buffer: cpuLimit
216+
}
217+
))
208218

209-
expect(next.values.cpuLimit).toBe("45%")
210-
expect(next.step).toBe(1)
211-
expect(resolveCreateFlowSteps(next.values)[next.step]).toBe("ramLimit")
219+
expect(next.values.cpuLimit).toBe(cpuLimit)
220+
expect(next.step).toBe(1)
221+
expect(resolveCreateFlowSteps(next.values)[next.step]).toBe("ramLimit")
222+
})
223+
)
212224
})
213225

214226
it("maps create-mode steps to the matching display row when opening browser Settings", () => {

0 commit comments

Comments
 (0)