Skip to content

Commit 6cdd919

Browse files
committed
Fix Playwright browser runtime generation
1 parent 6066ede commit 6cdd919

6 files changed

Lines changed: 257 additions & 4 deletions

File tree

packages/app/src/lib/core/templates.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ResolvedComposeResourceLimits } from "./resource-limits.js"
44
import { renderEntrypoint } from "./templates-entrypoint.js"
55
import { type ComposeResourceLimits, renderDockerCompose } from "./templates/docker-compose.js"
66
import { renderDockerfile } from "./templates/dockerfile.js"
7+
import { renderPlaywrightBrowserRuntime } from "./templates/playwright-browser-runtime.js"
78
import { renderPlaywrightBrowserDockerfile, renderPlaywrightStartExtra } from "./templates/playwright.js"
89

910
export type FileSpec =
@@ -56,6 +57,12 @@ export const planFiles = (
5657
relativePath: "mcp-playwright-start-extra.sh",
5758
contents: renderPlaywrightStartExtra(),
5859
mode: 0o755
60+
},
61+
{
62+
_tag: "File",
63+
relativePath: "docker-git-browser-runtime.sh",
64+
contents: renderPlaywrightBrowserRuntime(),
65+
mode: 0o755
5966
}
6067
] satisfies ReadonlyArray<FileSpec>)
6168
: ([] satisfies ReadonlyArray<FileSpec>)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/* jscpd:ignore-start */
2+
const playwrightBrowserRuntimeScript = `#!/usr/bin/env bash
3+
set -euo pipefail
4+
5+
declare -a DOCKER_GIT_BROWSER_TEMP_FILES=()
6+
7+
docker_git_browser_log() {
8+
printf '%s\\n' "[docker-git-browser] $*" >&2
9+
}
10+
11+
docker_git_browser_cleanup_temp_files() {
12+
if (( \${#DOCKER_GIT_BROWSER_TEMP_FILES[@]} > 0 )); then
13+
rm -f -- "\${DOCKER_GIT_BROWSER_TEMP_FILES[@]}" || true
14+
fi
15+
}
16+
17+
docker_git_browser_register_temp_file() {
18+
DOCKER_GIT_BROWSER_TEMP_FILES+=("$1")
19+
trap docker_git_browser_cleanup_temp_files EXIT
20+
}
21+
22+
docker_git_browser_has_docker() {
23+
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
24+
}
25+
26+
docker_git_browser_context_dir() {
27+
printf '%s\\n' "\${DOCKER_GIT_BROWSER_CONTEXT_DIR:-/opt/docker-git/browser}"
28+
}
29+
30+
docker_git_stop_playwright_browser() {
31+
local container_name="\${DOCKER_GIT_BROWSER_CONTAINER_NAME:-}"
32+
if [[ -z "$container_name" ]]; then
33+
return 0
34+
fi
35+
if ! docker_git_browser_has_docker; then
36+
return 0
37+
fi
38+
docker rm -f "$container_name" >/dev/null 2>&1 || true
39+
}
40+
41+
docker_git_cleanup_orphaned_playwright_browsers() {
42+
if ! docker_git_browser_has_docker; then
43+
return 0
44+
fi
45+
46+
local browser_id
47+
while IFS= read -r browser_id; do
48+
if [[ -z "$browser_id" ]]; then
49+
continue
50+
fi
51+
52+
local project_container
53+
project_container="$(docker inspect --format '{{ index .Config.Labels "docker-git.project-container" }}' "$browser_id" 2>/dev/null || true)"
54+
55+
local project_running
56+
project_running="false"
57+
if [[ -n "$project_container" && "$project_container" != "<no value>" ]]; then
58+
project_running="$(docker inspect --format '{{ .State.Running }}' "$project_container" 2>/dev/null || true)"
59+
fi
60+
61+
if [[ "$project_running" == "true" ]]; then
62+
continue
63+
fi
64+
65+
local browser_name
66+
browser_name="$(docker inspect --format '{{ .Name }}' "$browser_id" 2>/dev/null | sed 's#^/##' || true)"
67+
docker_git_browser_log "removing orphaned browser container \${browser_name:-$browser_id}"
68+
docker rm -f "$browser_id" >/dev/null 2>&1 || true
69+
done < <(docker ps -a -q --filter "label=docker-git.browser=1" --filter "label=docker-git.project-container")
70+
}
71+
72+
docker_git_start_playwright_browser() {
73+
if [[ "\${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then
74+
docker_git_stop_playwright_browser || true
75+
return 0
76+
fi
77+
78+
local container_name="\${DOCKER_GIT_BROWSER_CONTAINER_NAME:-}"
79+
local image_name="\${DOCKER_GIT_BROWSER_IMAGE_NAME:-}"
80+
local volume_name="\${DOCKER_GIT_BROWSER_VOLUME_NAME:-}"
81+
local main_container="\${DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"
82+
local context_dir
83+
context_dir="$(docker_git_browser_context_dir)"
84+
85+
if [[ -z "$container_name" || -z "$image_name" || -z "$volume_name" || -z "$main_container" ]]; then
86+
docker_git_browser_log "missing browser runtime configuration; skipping nested browser start"
87+
return 0
88+
fi
89+
if ! docker_git_browser_has_docker; then
90+
docker_git_browser_log "Docker API is unavailable; skipping nested browser start"
91+
return 0
92+
fi
93+
if [[ ! -f "$context_dir/Dockerfile.browser" ]]; then
94+
docker_git_browser_log "browser Dockerfile is missing at $context_dir/Dockerfile.browser"
95+
return 0
96+
fi
97+
98+
docker_git_stop_playwright_browser || true
99+
docker_git_cleanup_orphaned_playwright_browsers || true
100+
101+
local build_log
102+
if ! build_log="$(mktemp "\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log" 2>/dev/null)"; then
103+
docker_git_browser_log "failed to create browser build log; skipping nested browser start"
104+
return 0
105+
fi
106+
docker_git_browser_register_temp_file "$build_log"
107+
108+
local build_timeout
109+
build_timeout="\${DOCKER_GIT_BROWSER_BUILD_TIMEOUT_SECONDS:-600}"
110+
111+
docker_git_browser_log "building $image_name"
112+
timeout "$build_timeout" docker build -t "$image_name" -f "$context_dir/Dockerfile.browser" "$context_dir" >"$build_log" 2>&1 || {
113+
docker_git_browser_log "browser image build failed or timed out after \${build_timeout}s; output follows"
114+
cat "$build_log" >&2 || true
115+
docker_git_browser_log "browser image build log path before cleanup: $build_log"
116+
return 0
117+
}
118+
rm -f -- "$build_log"
119+
120+
if ! docker volume create "$volume_name" >/dev/null 2>&1; then
121+
docker_git_browser_log "failed to create browser data volume $volume_name; continuing"
122+
fi
123+
124+
local args=(
125+
run
126+
-d
127+
--name "$container_name"
128+
--label "docker-git.browser=1"
129+
--label "docker-git.project-container=$main_container"
130+
--network "container:$main_container"
131+
--shm-size "2g"
132+
-e "VNC_NOPW=1"
133+
-e "MCP_PLAYWRIGHT_CDP_GUARD=\${MCP_PLAYWRIGHT_CDP_GUARD:-1}"
134+
-e "MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE=\${MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE:-1}"
135+
-v "$volume_name:/data"
136+
)
137+
138+
if [[ -n "\${DOCKER_GIT_BROWSER_CPU_LIMIT:-}" ]]; then
139+
args+=(--cpus "$DOCKER_GIT_BROWSER_CPU_LIMIT")
140+
fi
141+
if [[ -n "\${DOCKER_GIT_BROWSER_RAM_LIMIT:-}" ]]; then
142+
args+=(--memory "$DOCKER_GIT_BROWSER_RAM_LIMIT" --memory-swap "$DOCKER_GIT_BROWSER_RAM_LIMIT")
143+
fi
144+
145+
docker_git_browser_log "starting $container_name inside $main_container network namespace"
146+
docker "\${args[@]}" "$image_name" >/dev/null || {
147+
docker_git_browser_log "failed to start $container_name"
148+
return 0
149+
}
150+
}
151+
`
152+
153+
// CHANGE: manage the Playwright browser as a nested Docker container owned by the project container.
154+
// WHY: issue #306 follow-up requires browser containers to inherit project lifecycle while keeping separate limits.
155+
// QUOTE(ТЗ): "пусть он поднимается внутри dg-issues1 а не где-то из вне"
156+
// REF: issue-306-browser-nested-runtime
157+
// SOURCE: n/a
158+
// FORMAT THEOREM: start(main) -> running(browser) with network(browser) = container:main OR logged_warning
159+
// PURITY: SHELL
160+
// EFFECT: shell commands executed by generated entrypoint
161+
// INVARIANT: browser data volume is preserved; runtime cleanup removes only browser-labeled containers
162+
// COMPLEXITY: O(b + build + docker-run)/O(1), where b = browser-labeled containers
163+
export const renderPlaywrightBrowserRuntime = (): string => playwrightBrowserRuntimeScript
164+
/* jscpd:ignore-end */
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
3+
import { defaultTemplateConfig, type TemplateConfig } from "../../src/lib/core/domain.js"
4+
import { planFiles } from "../../src/lib/core/templates.js"
5+
6+
const makeTemplateConfig = (overrides: Partial<TemplateConfig> = {}): TemplateConfig => ({
7+
...defaultTemplateConfig,
8+
repoUrl: "https://github.com/org/repo.git",
9+
containerName: "dg-test",
10+
serviceName: "dg-test",
11+
sshUser: "dev",
12+
targetDir: "/home/dev/org/repo",
13+
volumeName: "dg-test-home",
14+
dockerGitPath: "/workspace/.docker-git",
15+
authorizedKeysPath: "/workspace/authorized_keys",
16+
envGlobalPath: "/workspace/.orch/env/global.env",
17+
envProjectPath: "/workspace/.orch/env/project.env",
18+
codexAuthPath: "/workspace/.orch/auth/codex",
19+
codexSharedAuthPath: "/workspace/.orch/auth/codex-shared",
20+
codexHome: "/home/dev/.codex",
21+
geminiAuthPath: "/workspace/.orch/auth/gemini",
22+
geminiHome: "/home/dev/.gemini",
23+
grokAuthPath: "/workspace/.orch/auth/grok",
24+
grokHome: "/home/dev/.grok",
25+
gpu: "none",
26+
...overrides
27+
})
28+
29+
describe("app planFiles", () => {
30+
it("includes nested browser runtime artifacts when Playwright is enabled", () => {
31+
const files = planFiles(makeTemplateConfig({ enableMcpPlaywright: true }))
32+
const filePaths = files.flatMap((file) => file._tag === "File" ? [file.relativePath] : [])
33+
const runtime = files.find(
34+
(file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> =>
35+
file._tag === "File" && file.relativePath === "docker-git-browser-runtime.sh"
36+
)
37+
38+
expect(filePaths).toContain("Dockerfile.browser")
39+
expect(filePaths).toContain("mcp-playwright-start-extra.sh")
40+
expect(filePaths).toContain("docker-git-browser-runtime.sh")
41+
expect(runtime).toBeDefined()
42+
expect(runtime?.mode).toBe(0o755)
43+
expect(runtime?.contents).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then')
44+
expect(runtime?.contents).not.toContain('\\${MCP_PLAYWRIGHT_ENABLE:-0}')
45+
})
46+
})

packages/lib/src/core/templates/playwright-browser-runtime.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
const playwrightBrowserRuntimeScript = String.raw`#!/usr/bin/env bash
1+
const playwrightBrowserRuntimeScript = `#!/usr/bin/env bash
22
set -euo pipefail
33
44
declare -a DOCKER_GIT_BROWSER_TEMP_FILES=()
55
66
docker_git_browser_log() {
7-
printf '%s\n' "[docker-git-browser] $*" >&2
7+
printf '%s\\n' "[docker-git-browser] $*" >&2
88
}
99
1010
docker_git_browser_cleanup_temp_files() {
@@ -23,7 +23,7 @@ docker_git_browser_has_docker() {
2323
}
2424
2525
docker_git_browser_context_dir() {
26-
printf '%s\n' "\${DOCKER_GIT_BROWSER_CONTEXT_DIR:-/opt/docker-git/browser}"
26+
printf '%s\\n' "\${DOCKER_GIT_BROWSER_CONTEXT_DIR:-/opt/docker-git/browser}"
2727
}
2828
2929
docker_git_stop_playwright_browser() {

packages/lib/tests/core/templates.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { Effect, pipe } from "effect"
55
import * as fc from "fast-check"
66

77
import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js"
8+
import { planFiles } from "../../src/core/templates.js"
89
import { renderDockerCompose } from "../../src/core/templates/docker-compose.js"
910
import { renderDockerfile } from "../../src/core/templates/dockerfile.js"
11+
import { renderPlaywrightBrowserRuntime } from "../../src/core/templates/playwright-browser-runtime.js"
1012
import { renderEntrypoint } from "../../src/core/templates-entrypoint.js"
1113
import { renderEntrypointDnsRepair } from "../../src/core/templates-entrypoint/dns-repair.js"
1214
import { renderEntrypointGitHooks } from "../../src/core/templates-entrypoint/git.js"
@@ -708,6 +710,35 @@ describe("renderDockerCompose", () => {
708710
expect((compose.match(/\n dns:\n/g) ?? []).length).toBe(1)
709711
})
710712

713+
it("renders live shell expansion in the nested browser runtime script", () => {
714+
const runtime = renderPlaywrightBrowserRuntime()
715+
716+
expect(runtime).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then')
717+
expect(runtime).not.toContain('if [[ "\\${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then')
718+
expect(runtime).toContain('printf \'%s\\n\' "${DOCKER_GIT_BROWSER_CONTEXT_DIR:-/opt/docker-git/browser}"')
719+
expect(runtime).not.toContain('printf \'%s\\n\' "\\${DOCKER_GIT_BROWSER_CONTEXT_DIR:-/opt/docker-git/browser}"')
720+
expect(runtime).toContain('mktemp "${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log"')
721+
expect(runtime).not.toContain('mktemp "\\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log"')
722+
expect(runtime).toContain('docker "${args[@]}" "$image_name" >/dev/null || {')
723+
expect(runtime).not.toContain('docker "\\${args[@]}" "$image_name" >/dev/null || {')
724+
})
725+
726+
it("plans nested browser runtime artifacts when Playwright is enabled", () => {
727+
const files = planFiles(makeTemplateConfig({ enableMcpPlaywright: true }))
728+
const filePaths = files.flatMap((file) => file._tag === "File" ? [file.relativePath] : [])
729+
const runtime = files.find(
730+
(file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> =>
731+
file._tag === "File" && file.relativePath === "docker-git-browser-runtime.sh"
732+
)
733+
734+
expect(filePaths).toContain("Dockerfile.browser")
735+
expect(filePaths).toContain("mcp-playwright-start-extra.sh")
736+
expect(filePaths).toContain("docker-git-browser-runtime.sh")
737+
expect(runtime).toBeDefined()
738+
expect(runtime?.mode).toBe(0o755)
739+
expect(runtime?.contents).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then')
740+
})
741+
711742
it("renders local Docker socket mount only when explicitly enabled", () => {
712743
const compose = renderDockerCompose(
713744
makeTemplateConfig({

packages/lib/tests/usecases/mcp-playwright.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,13 @@ describe("enableMcpPlaywrightProjectFiles", () => {
158158
expect(startExtra).toContain('MCP_PLAYWRIGHT_CDP_GUARD:-1')
159159
expect(startExtra).toContain("docker-git-cdp-guard")
160160
expect(startExtra).toContain("socat TCP-LISTEN:9223")
161+
expect(browserRuntime).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then')
162+
expect(browserRuntime).not.toContain('if [[ "\\${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then')
161163
expect(browserRuntime).toContain('--network "container:$main_container"')
162164
expect(browserRuntime).toContain("docker_git_cleanup_orphaned_playwright_browsers")
163165
expect(browserRuntime).toContain("docker_git_browser_cleanup_temp_files")
164-
expect(browserRuntime).toContain('mktemp "\\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log"')
166+
expect(browserRuntime).toContain('mktemp "${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log"')
167+
expect(browserRuntime).not.toContain('mktemp "\\${TMPDIR:-/tmp}/docker-git-browser-build.XXXXXX.log"')
165168
expect(browserRuntime).toContain('DOCKER_GIT_BROWSER_BUILD_TIMEOUT_SECONDS:-600')
166169
expect(browserRuntime).toContain('timeout "$build_timeout" docker build')
167170
expect(browserRuntime).toContain('cat "$build_log" >&2 || true')
@@ -171,6 +174,8 @@ describe("enableMcpPlaywrightProjectFiles", () => {
171174
expect(browserRuntime).toContain('failed to create browser data volume $volume_name; continuing')
172175
expect(browserRuntime).toContain('args+=(--cpus "$DOCKER_GIT_BROWSER_CPU_LIMIT")')
173176
expect(browserRuntime).toContain('args+=(--memory "$DOCKER_GIT_BROWSER_RAM_LIMIT" --memory-swap "$DOCKER_GIT_BROWSER_RAM_LIMIT")')
177+
expect(browserRuntime).toContain('docker "${args[@]}" "$image_name" >/dev/null || {')
178+
expect(browserRuntime).not.toContain('docker "\\${args[@]}" "$image_name" >/dev/null || {')
174179

175180
const configAfterText = yield* _(fs.readFileString(path.join(outDir, "docker-git.json")))
176181
const configAfter = yield* _(Effect.sync((): unknown => JSON.parse(configAfterText)))

0 commit comments

Comments
 (0)