diff --git a/AGENTS.md b/AGENTS.md index 4a69f95374..aa5ba339b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,8 @@ you are running inside of — NOT the automation backend. - The same test specs and helpers are reused to validate the Docker image via `playwright.mock-llm-docker.config.ts`. Run locally with `npm run test:e2e:mock-llm:docker` (requires Docker daemon and a built image). - **Architecture**: The Docker config replaces the npm path's `bin/agent-canvas.mjs` webServer with a `docker run --network host` command. The mock LLM server still runs on the host. On Linux (including CI), `--network host` lets the container share the host's network stack so all `127.0.0.1` URLs work identically. On macOS/Windows Docker Desktop (bridge networking), set `MOCK_LLM_AGENT_URL=http://host.docker.internal:` so the agent-server inside Docker can reach the host-side mock LLM server. +- **IPv4 URLs in Docker tests**: `playwright.mock-llm-docker.config.ts` uses `127.0.0.1` (not `localhost`) for all URLs — `INGRESS_URL`, `MOCK_LLM_BACKEND_URL`, `MOCK_LLM_PUBLIC_MODE_URL`, and webServer health-check probes. The Docker entrypoint's `static-server.mjs` binds to `0.0.0.0` (IPv4 only), but `localhost` can resolve to `::1` (IPv6) on Ubuntu CI runners, causing `ECONNREFUSED`. The non-Docker config can use `localhost` because its `ingress.mjs` binds to `::` (dual-stack). If you add new URLs or health-check probes to the Docker Playwright config, always use `127.0.0.1`. +- **Entrypoint crash resilience**: `docker/entrypoint.sh` uses a `while kill -0 "$STATIC_PID"; do sleep 10 & wait $!; done` loop instead of `wait -n "${PIDS[@]}"` (any child). If the agent-server or automation backend exits mid-test, the static-server proxy stays up and returns 502s for backend routes — the container doesn't disappear with `ECONNREFUSED`. The container exits only when the static-server (ingress) dies or on SIGTERM/SIGINT. The `sleep & wait $!` pattern ensures `wait` (a bash builtin) is the foreground op, so trapped signals fire immediately. `cleanup()` includes `exit 0` so the script terminates after a signal-triggered trap return. - **URL split**: `mock-llm-helpers.ts` exports two mock LLM URL constants: - `MOCK_LLM_BASE_URL` — always `http://127.0.0.1:`, used by tests for the mock LLM admin API (register/activate/reset trajectories). - `MOCK_LLM_AGENT_URL` — defaults to `MOCK_LLM_BASE_URL`, overridable via `MOCK_LLM_AGENT_URL` env var. Used when configuring the LLM profile (`base_url` field) — this is the URL the agent-server uses for inference calls. The npm path and Docker-with-`--network host` path use the same value; Docker on macOS needs the override. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b641161c68..ca96af041a 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -137,6 +137,7 @@ cleanup() { kill "$pid" 2>/dev/null || true done wait 2>/dev/null || true + exit 0 } trap cleanup EXIT SIGINT SIGTERM @@ -243,7 +244,8 @@ node /opt/agent-canvas/static-server.mjs \ --route "/docs=http://127.0.0.1:${AGENT_SERVER_PORT}" \ --route "/redoc=http://127.0.0.1:${AGENT_SERVER_PORT}" \ --route "/openapi.json=http://127.0.0.1:${AGENT_SERVER_PORT}" & -PIDS+=($!) +STATIC_PID=$! +PIDS+=("$STATIC_PID") # ── 5. (Optional) Public-mode static server ───────────────────────────────── # When PUBLIC_MODE_PORT is set, start a second static-server instance that @@ -272,8 +274,19 @@ fi log "All services started. Unified entry point: http://0.0.0.0:${PORT}/" -# Wait for any child to exit. If one dies, the trap will clean up the rest. -wait -n "${PIDS[@]}" 2>/dev/null -EXIT_CODE=$? -log_error "A service exited with code $EXIT_CODE" -exit "$EXIT_CODE" +# Keep the container alive while the static-server (ingress) is running. +# Backend crashes (agent-server, automation) are tolerated — the proxy +# returns 502 for downed routes, matching the non-Docker path where each +# service is an independent host process. +# +# Pattern: `sleep & wait $!` makes `wait` (a bash builtin) the foreground +# operation. Unlike a bare `sleep`, the builtin `wait` is interrupted +# immediately when a trapped signal (SIGTERM/SIGINT) arrives, so cleanup() +# fires without delay. cleanup() calls `exit 0` to terminate after the +# trap returns. The loop re-checks the static-server PID every 10 s so the +# container exits promptly if the ingress process dies on its own. +while kill -0 "$STATIC_PID" 2>/dev/null; do + sleep 10 & wait $! +done +log_error "Static server (PID $STATIC_PID) exited" +exit 1 diff --git a/playwright.mock-llm-docker.config.ts b/playwright.mock-llm-docker.config.ts index e1fad78a2c..39a8f869ba 100644 --- a/playwright.mock-llm-docker.config.ts +++ b/playwright.mock-llm-docker.config.ts @@ -58,7 +58,14 @@ const sessionApiKey = process.env.MOCK_LLM_SESSION_API_KEY = sessionApiKey; // ── URLs ─────────────────────────────────────────────────────────────── -const INGRESS_URL = `http://localhost:${INGRESS_PORT}/`; +// Use 127.0.0.1 (not localhost) everywhere so Playwright and Chromium +// connect via IPv4 directly. The Docker container's static-server binds +// to 0.0.0.0 (IPv4 only). `localhost` can resolve to ::1 (IPv6) on CI +// runners, causing intermittent ECONNREFUSED when the server doesn't +// listen on IPv6. The non-Docker config uses `localhost` because its +// ingress.mjs binds to :: (dual-stack), but the Docker entrypoint uses +// static-server.mjs which is IPv4-only. +const INGRESS_URL = `http://127.0.0.1:${INGRESS_PORT}/`; const MOCK_LLM_URL = `http://127.0.0.1:${MOCK_LLM_PORT}`; // Python binary for the mock server — defaults to "python3" but CI can @@ -68,9 +75,9 @@ const MOCK_LLM_PYTHON = process.env.MOCK_LLM_PYTHON ?? "python3"; // Export for the test helpers — BACKEND_URL points to the ingress (API // calls are proxied to the agent-server, so no direct backend port needed). -process.env.MOCK_LLM_BACKEND_URL = `http://localhost:${INGRESS_PORT}`; +process.env.MOCK_LLM_BACKEND_URL = `http://127.0.0.1:${INGRESS_PORT}`; process.env.MOCK_LLM_PORT = MOCK_LLM_PORT; -process.env.MOCK_LLM_PUBLIC_MODE_URL = `http://localhost:${PUBLIC_MODE_PORT}`; +process.env.MOCK_LLM_PUBLIC_MODE_URL = `http://127.0.0.1:${PUBLIC_MODE_PORT}`; process.env.VITE_SESSION_API_KEY = sessionApiKey; // MOCK_LLM_AGENT_URL — the URL the agent-server inside Docker uses to @@ -86,9 +93,7 @@ export default defineConfig({ testMatch: /.*\.spec\.ts/, fullyParallel: false, forbidOnly: !!process.env.CI, - // One retry for transient Docker container startup failures (ECONNREFUSED). - // The container health-check (webServer.url) confirms the stack is up, but - // occasional races can still cause the first request to fail. + // One retry for transient Docker container startup failures. retries: process.env.CI ? 1 : 0, workers: 1, timeout: 60_000, @@ -163,7 +168,9 @@ export default defineConfig({ // up before tests start. GET /api/automation/v1 returns 200 (empty // list) without auth — the automation backend does not enforce // session-key auth on the list endpoint. - url: `http://localhost:${INGRESS_PORT}/api/automation/v1`, + // Use 127.0.0.1 (not localhost) to avoid IPv6 resolution — see URL + // comment block above. + url: `http://127.0.0.1:${INGRESS_PORT}/api/automation/v1`, timeout: 180_000, // Docker pull + all services startup reuseExistingServer: !process.env.CI, },