diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca11ea73..d9670dda 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,10 +90,28 @@ pnpm open:portal # Open the portal in your browser The first Docker start compiles the Rust AI gateway inside its development container and can take several minutes. During development, the gateway may use all CPUs assigned to Docker or the Podman VM, while its memory remains limited -to 2 GiB. Keep the Compose command running until the `coder-acp-copilot` logs -show that it connected to MongoDB and ensured `queue-coder-acp-copilot`; runs -submitted before that worker is ready remain pending. Later starts reuse the -gateway's named Cargo target volume and are faster. +to 2 GiB. Later starts reuse the gateway's named Cargo target volume and are +faster. + +After Compose starts application services, `scripts/dev-compose.sh` verifies +their published host endpoints before printing them as usable. The Portal root, +API `/ready`, and gateway `/health/ready` must return a successful response from +the host. The Copilot worker is verified separately: its container must be +running and its logs must confirm both `queue-coder-acp-copilot` creation and +MongoDB connection. The command exits nonzero when these bounded checks expire, +so a Portal page alone is not evidence that runs can process. + +Clean image builds do not consume that bounded readiness window. The wrapper +waits for Compose to create the selected application containers, while still +propagating an immediate nonzero build/start failure, and only then starts the +host endpoint and worker readiness attempts. + +The failure message distinguishes an unhealthy/not-running service, a reachable +but non-ready HTTP endpoint, and a healthy container whose published port is +unreachable from the host. The last case usually points to runtime VM networking +or port forwarding. Use the printed `docker compose ps`, `docker compose port`, +and `docker compose logs` commands to inspect it; the startup script never +selects, restarts, or changes the active Docker runtime or context. ### Debug the Docker development API diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 1858dfba..a05ed749 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -83,6 +83,35 @@ flowchart TB ## Data Flow +### Local Compose readiness boundary + +Local Compose startup has two distinct readiness boundaries. Container health +proves that services can respond inside the container network; it does not prove +that a runtime VM published those ports to the developer host. + +For application `up` commands, `scripts/dev-compose.sh` therefore performs +bounded host-side checks against the selected services: + +| Service | Host verification contract | +|---------|----------------------------| +| Portal | Published port serves `/` with HTTP 2xx | +| API | Published port serves `/ready` with HTTP 2xx | +| AI gateway | Published port serves `/health/ready` with HTTP 2xx | +| Copilot worker | Container is running and current logs confirm queue creation plus MongoDB connection | + +Only verified HTTP URLs are printed as usable. Exhausted checks fail the startup +command and distinguish container failure, host transport failure, HTTP +non-readiness, and missing worker startup signals. A healthy-container/failed- +transport result identifies runtime VM networking or port forwarding as the +likely failure class, but the wrapper only prints non-destructive inspection +commands; it never validates or changes the user's active Docker context. + +The bounded readiness budget begins after Compose creates every selected +application container. Image builds remain governed by the Compose process +lifecycle instead of an independent timer: a running build can continue, a +nonzero build/start exit fails immediately, and endpoint retries cannot expire +before host publication is possible. + ### User authentication Configured human callers send the **IdP access token unchanged** on each API call. diff --git a/scripts/dev-compose.sh b/scripts/dev-compose.sh index abf3e53e..fe21c21e 100755 --- a/scripts/dev-compose.sh +++ b/scripts/dev-compose.sh @@ -75,4 +75,163 @@ else set -- "${FILTERED_ARGS[@]}" fi -exec docker compose "${EXTRA_ARGS[@]}" "$@" +COMPOSE_ARGS=("${EXTRA_ARGS[@]}" "$@") + +# Only `up` starts services that can be verified. Preserve direct exec behavior +# for config, build, down, and every other Compose command. +up_index=-1 +for index in "${!COMPOSE_ARGS[@]}"; do + if [ "${COMPOSE_ARGS[$index]}" = "up" ]; then + up_index="$index" + break + fi +done + +if [ "$up_index" -lt 0 ]; then + exec docker compose "${COMPOSE_ARGS[@]}" +fi + +COMPOSE_PREFIX=("${COMPOSE_ARGS[@]:0:$up_index}") +UP_ARGS=("${COMPOSE_ARGS[@]:$((up_index + 1))}") +EXPLICIT_SERVICES=() +skip_next=0 +for arg in "${UP_ARGS[@]}"; do + if [ "$skip_next" -eq 1 ]; then + skip_next=0 + continue + fi + case "$arg" in + --scale|--timeout|--wait-timeout|--pull) + skip_next=1 + ;; + -*) + ;; + *) + EXPLICIT_SERVICES+=("$arg") + ;; + esac +done + +VERIFY_SERVICES="" +if [ "${#EXPLICIT_SERVICES[@]}" -gt 0 ]; then + for service in "${EXPLICIT_SERVICES[@]}"; do + case "$service" in + portal|api|gateway|coder-acp-copilot) + VERIFY_SERVICES+="${VERIFY_SERVICES:+$'\n'}$service" + ;; + esac + done +fi + +# Explicit infra-only starts intentionally skip application readiness checks. +if [ "${#EXPLICIT_SERVICES[@]}" -gt 0 ] && [ -z "$VERIFY_SERVICES" ]; then + exec docker compose "${COMPOSE_ARGS[@]}" +fi + +# Resolve profile-selected application services before startup. This lets the +# wrapper distinguish a legitimate image build (containers do not exist yet) +# from the bounded readiness phase after Compose begins creating containers. +if [ -z "$VERIFY_SERVICES" ]; then + selected_services="$(docker compose "${COMPOSE_PREFIX[@]}" config --services)" + for service in portal api gateway coder-acp-copilot; do + if grep -qx "$service" <<<"$selected_services"; then + VERIFY_SERVICES+="${VERIFY_SERVICES:+$'\n'}$service" + fi + done +fi + +compose_pid="" +verify_pid="" +cleanup_children() { + local signal="$1" + if [ -n "$verify_pid" ] && kill -0 "$verify_pid" 2>/dev/null; then + kill "-$signal" "$verify_pid" 2>/dev/null || true + fi + if [ -n "$compose_pid" ] && kill -0 "$compose_pid" 2>/dev/null; then + kill "-$signal" "$compose_pid" 2>/dev/null || true + fi +} +trap 'cleanup_children INT; exit 130' INT +trap 'cleanup_children TERM; exit 143' TERM + +docker compose "${COMPOSE_ARGS[@]}" & +compose_pid=$! + +# Do not consume the endpoint readiness budget while Compose is still building +# images and none of the expected application containers can exist. This gate +# is tied to the Compose lifecycle rather than an arbitrary clean-build timer: +# it ends when every selected container has been created or Compose exits. +while [ -n "$compose_pid" ]; do + all_containers_created=1 + while IFS= read -r service; do + [ -n "$service" ] || continue + if [ -z "$(docker compose "${COMPOSE_PREFIX[@]}" ps -q "$service" 2>/dev/null | head -n 1)" ]; then + all_containers_created=0 + break + fi + done <<<"$VERIFY_SERVICES" + + if [ "$all_containers_created" -eq 1 ]; then + break + fi + + if ! kill -0 "$compose_pid" 2>/dev/null; then + set +e + wait "$compose_pid" + compose_status=$? + set -e + compose_pid="" + if [ "$compose_status" -ne 0 ]; then + exit "$compose_status" + fi + break + fi + + sleep 0.2 +done + +SCOPE_DEV_VERIFY_SERVICES="$VERIFY_SERVICES" \ + "$(dirname "$0")/verify-dev-stack.sh" "${COMPOSE_PREFIX[@]}" & +verify_pid=$! + +# Fail fast if Compose itself exits unsuccessfully during build/startup. A +# successful detached `up -d` is allowed to exit while verification continues. +compose_status="" +while kill -0 "$verify_pid" 2>/dev/null; do + if [ -n "$compose_pid" ] && ! kill -0 "$compose_pid" 2>/dev/null; then + set +e + wait "$compose_pid" + compose_status=$? + set -e + compose_pid="" + if [ "$compose_status" -ne 0 ]; then + kill "$verify_pid" 2>/dev/null || true + wait "$verify_pid" 2>/dev/null || true + exit "$compose_status" + fi + fi + sleep 0.2 +done + +set +e +wait "$verify_pid" +verify_status=$? +set -e +verify_pid="" + +if [ "$verify_status" -ne 0 ]; then + if [ -n "$compose_pid" ] && kill -0 "$compose_pid" 2>/dev/null; then + kill -TERM "$compose_pid" 2>/dev/null || true + wait "$compose_pid" 2>/dev/null || true + fi + exit "$verify_status" +fi + +if [ -n "$compose_pid" ]; then + set +e + wait "$compose_pid" + compose_status=$? + set -e +fi + +exit "${compose_status:-0}" diff --git a/scripts/verify-dev-stack.sh b/scripts/verify-dev-stack.sh new file mode 100755 index 00000000..52321e42 --- /dev/null +++ b/scripts/verify-dev-stack.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +set -euo pipefail + +ATTEMPTS="${SCOPE_DEV_READY_ATTEMPTS:-300}" +DELAY_SECONDS="${SCOPE_DEV_READY_DELAY_SECONDS:-2}" +CURL_TIMEOUT_SECONDS="${SCOPE_DEV_READY_CURL_TIMEOUT_SECONDS:-2}" +REQUESTED_SERVICES="${SCOPE_DEV_VERIFY_SERVICES:-}" + +COMPOSE=(docker compose "$@") +TARGET_SERVICES=(portal api gateway coder-acp-copilot) + +declare -A SERVICE_PORTS=( + [portal]=80 + [api]=80 + [gateway]=18000 +) +declare -A SERVICE_PATHS=( + [portal]=/ + [api]=/ready + [gateway]=/health/ready +) +declare -A SERVICE_LABELS=( + [portal]=Portal + [api]=API + [gateway]=Gateway +) +declare -A LAST_STATES=() +declare -A LAST_CURL_EXIT=() +declare -A LAST_HTTP_CODE=() +declare -A VERIFIED_URLS=() + +WORKER_QUEUE_READY=0 +WORKER_MONGO_READY=0 + +# Resolve the services selected by profiles unless the wrapper supplied an +# explicit service subset from `docker compose up SERVICE...`. +if [ -z "$REQUESTED_SERVICES" ]; then + REQUESTED_SERVICES="$("${COMPOSE[@]}" config --services)" +fi + +ACTIVE_SERVICES=() +for target in "${TARGET_SERVICES[@]}"; do + if grep -qx "$target" <<<"$REQUESTED_SERVICES"; then + ACTIVE_SERVICES+=("$target") + fi +done + +if [ "${#ACTIVE_SERVICES[@]}" -eq 0 ]; then + exit 0 +fi + +# Return the current container state, preferring Docker health when configured. +service_state() { + local service="$1" + local container_id + container_id="$("${COMPOSE[@]}" ps -q "$service" 2>/dev/null | head -n 1)" + + if [ -z "$container_id" ]; then + printf 'not-created' + return + fi + + docker inspect \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ + "$container_id" 2>/dev/null || printf 'unknown' +} + +# Resolve the published host port without trusting a URL emitted by a service. +published_url() { + local service="$1" + local container_port="$2" + local path="$3" + local mapping + local host_port + + mapping="$("${COMPOSE[@]}" port "$service" "$container_port" 2>/dev/null | head -n 1)" + host_port="${mapping##*:}" + if [[ ! "$host_port" =~ ^[0-9]+$ ]]; then + return 1 + fi + + printf 'http://127.0.0.1:%s%s' "$host_port" "$path" +} + +# Probe once with strict per-attempt bounds. HTTP failures remain distinct from +# transport failures so final diagnostics identify the right failure class. +probe_endpoint() { + local service="$1" + local url="$2" + local result + local curl_exit + + set +e + result="$(curl \ + --silent \ + --output /dev/null \ + --write-out '%{http_code}' \ + --connect-timeout "$CURL_TIMEOUT_SECONDS" \ + --max-time "$CURL_TIMEOUT_SECONDS" \ + "$url" 2>/dev/null)" + curl_exit=$? + set -e + + LAST_CURL_EXIT["$service"]="$curl_exit" + LAST_HTTP_CODE["$service"]="${result:-000}" + + [ "$curl_exit" -eq 0 ] && [[ "$result" =~ ^2[0-9][0-9]$ ]] +} + +# The worker has no HTTP readiness endpoint. Its strongest existing observable +# startup contract is a running container plus both queue and MongoDB startup +# milestones from the current Compose container logs. +probe_worker() { + local state + local logs + + state="$(service_state coder-acp-copilot)" + LAST_STATES[coder-acp-copilot]="$state" + case "$state" in + running|healthy) ;; + *) return 1 ;; + esac + + logs="$("${COMPOSE[@]}" logs --no-color coder-acp-copilot 2>/dev/null || true)" + if grep -Fq '[coder-acp-copilot] Ensured queue exists: queue-coder-acp-copilot' <<<"$logs"; then + WORKER_QUEUE_READY=1 + fi + if grep -Fq '[coder-acp-copilot] Connected to MongoDB' <<<"$logs"; then + WORKER_MONGO_READY=1 + fi + + [ "$WORKER_QUEUE_READY" -eq 1 ] && [ "$WORKER_MONGO_READY" -eq 1 ] +} + +echo "Verifying published development endpoints and worker readiness..." + +all_ready=0 +for ((attempt = 1; attempt <= ATTEMPTS; attempt += 1)); do + all_ready=1 + + for service in "${ACTIVE_SERVICES[@]}"; do + if [ "$service" = "coder-acp-copilot" ]; then + if ! probe_worker; then + all_ready=0 + fi + continue + fi + + state="$(service_state "$service")" + LAST_STATES["$service"]="$state" + case "$state" in + running|healthy) + if url="$(published_url "$service" "${SERVICE_PORTS[$service]}" "${SERVICE_PATHS[$service]}")"; then + if probe_endpoint "$service" "$url"; then + VERIFIED_URLS["$service"]="$url" + else + all_ready=0 + fi + else + LAST_CURL_EXIT["$service"]=1 + LAST_HTTP_CODE["$service"]=000 + all_ready=0 + fi + ;; + *) + all_ready=0 + ;; + esac + done + + if [ "$all_ready" -eq 1 ]; then + break + fi + + if [ "$attempt" -lt "$ATTEMPTS" ]; then + sleep "$DELAY_SECONDS" + fi +done + +if [ "$all_ready" -eq 1 ]; then + for service in portal api gateway; do + if [ -n "${VERIFIED_URLS[$service]:-}" ]; then + safe_url="${VERIFIED_URLS[$service]/127.0.0.1/localhost}" + echo "${SERVICE_LABELS[$service]} verified: $safe_url" + fi + done + if grep -qx 'coder-acp-copilot' <<<"$REQUESTED_SERVICES"; then + echo "Copilot worker verified: container running, queue ensured, and MongoDB connected." + fi + exit 0 +fi + +echo "Development stack readiness verification failed." >&2 +for service in "${ACTIVE_SERVICES[@]}"; do + state="${LAST_STATES[$service]:-not-created}" + + if [ "$service" = "coder-acp-copilot" ]; then + case "$state" in + running|healthy) + missing=() + [ "$WORKER_QUEUE_READY" -eq 0 ] && missing+=("queue creation") + [ "$WORKER_MONGO_READY" -eq 0 ] && missing+=("MongoDB connection") + missing_text="${missing[0]}" + if [ "${#missing[@]}" -gt 1 ]; then + missing_text+=", ${missing[1]}" + fi + printf 'Copilot worker is running but not ready; missing startup signal(s): %s.\n' \ + "$missing_text" >&2 + echo "Inspect with: docker compose logs coder-acp-copilot" >&2 + ;; + *) + echo "Copilot worker is not running and ready (container state: $state)." >&2 + echo "Inspect with: docker compose ps coder-acp-copilot && docker compose logs coder-acp-copilot" >&2 + ;; + esac + continue + fi + + label="${SERVICE_LABELS[$service]}" + if [ -n "${VERIFIED_URLS[$service]:-}" ]; then + continue + fi + case "$state" in + running|healthy) + if [ "${LAST_CURL_EXIT[$service]:-1}" -ne 0 ] || [ "${LAST_HTTP_CODE[$service]:-000}" = "000" ]; then + echo "$label container is $state, but its published host endpoint is unreachable." >&2 + echo "This usually indicates runtime VM networking or host port-forwarding failure." >&2 + echo "Inspect without changing runtimes: docker compose ps $service && docker compose port $service ${SERVICE_PORTS[$service]}" >&2 + else + echo "$label host endpoint is reachable but not ready (HTTP ${LAST_HTTP_CODE[$service]})." >&2 + echo "Inspect with: docker compose logs $service" >&2 + fi + ;; + *) + echo "$label service is unhealthy or not running (container state: $state)." >&2 + echo "Inspect with: docker compose ps $service && docker compose logs $service" >&2 + ;; + esac +done + +exit 1 diff --git a/scripts/verify-dev-stack.test.ts b/scripts/verify-dev-stack.test.ts new file mode 100644 index 00000000..f9dcb137 --- /dev/null +++ b/scripts/verify-dev-stack.test.ts @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const script = resolve("scripts/verify-dev-stack.sh"); +const wrapper = resolve("scripts/dev-compose.sh"); +const directories: string[] = []; + +interface FixtureOptions { + apiCodes?: string; + composeCreateAfter?: number; + composeUpStatus?: number; + gatewayCodes?: string; + portalCodes?: string; + services?: string; + states?: Partial>; + workerReady?: boolean; +} + +function fixture(options: FixtureOptions = {}) { + const directory = mkdtempSync(join(tmpdir(), "scope-verify-stack-")); + directories.push(directory); + const bin = join(directory, "bin"); + const counters = join(directory, "counters"); + mkdirSync(bin); + mkdirSync(counters); + + const docker = join(bin, "docker"); + writeFileSync(docker, `#!/bin/bash +set -eu +if [ "$1" = "inspect" ]; then + container_id="\${@: -1}" + service="\${container_id#container-}" + variable="STATE_\${service//-/_}" + printf '%s\\n' "\${!variable:-running}" + exit 0 +fi +[ "$1" = "compose" ] +shift +arguments=("$@") +command_index=-1 +for index in "\${!arguments[@]}"; do + case "\${arguments[$index]}" in + up|ps|port|logs|config) + command_index="$index" + break + ;; + esac +done +[ "$command_index" -ge 0 ] +command="\${arguments[$command_index]}" +if [ "$command" = "ps" ]; then + last_index=$((\${#arguments[@]} - 1)) + service="\${arguments[$last_index]}" + ps_counter_file="$COUNTERS/PS" + ps_count=0 + [ -f "$ps_counter_file" ] && ps_count="$(cat "$ps_counter_file")" + ps_count=$((ps_count + 1)) + printf '%s' "$ps_count" > "$ps_counter_file" + if [ "$ps_count" -le "\${COMPOSE_CREATE_AFTER:-0}" ]; then + exit 0 + fi + [ -n "\${COMPOSE_CREATED_FILE:-}" ] && : > "$COMPOSE_CREATED_FILE" + printf 'container-%s\\n' "$service" +elif [ "$command" = "port" ]; then + service="\${arguments[$((command_index + 1))]}" + case "$service" in + portal) printf '0.0.0.0:5100\\n' ;; + api) printf '0.0.0.0:3100\\n' ;; + gateway) printf '0.0.0.0:18900\\n' ;; + esac +elif [ "$command" = "logs" ]; then + if [ "\${WORKER_READY:-1}" = "1" ]; then + printf '%s\\n' \ + '[coder-acp-copilot] Ensured queue exists: queue-coder-acp-copilot' \ + '[coder-acp-copilot] Connected to MongoDB' + else + printf '%s\\n' '[coder-acp-copilot] Starting worker...' + fi +elif [ "$command" = "config" ]; then + printf '%s\\n' "\${SCOPE_DEV_VERIFY_SERVICES:-}" +elif [ "$command" = "up" ]; then + if [ "\${COMPOSE_UP_STATUS:-0}" -ne 0 ]; then + /bin/sleep 0.05 + exit "$COMPOSE_UP_STATUS" + fi + if [ "\${COMPOSE_CREATE_AFTER:-0}" -gt 0 ]; then + while [ ! -f "$COMPOSE_CREATED_FILE" ]; do + /bin/sleep 0.02 + done + fi + exit 0 +else + exit 1 +fi +`); + chmodSync(docker, 0o755); + + const curl = join(bin, "curl"); + writeFileSync(curl, `#!/bin/bash +set -eu +url="\${@: -1}" +case "$url" in + *:5100/*) service=PORTAL ;; + *:3100/*) service=API ;; + *:18900/*) service=GATEWAY ;; + *) exit 2 ;; +esac +sequence_variable="\${service}_CODES" +sequence="\${!sequence_variable:-200}" +counter_file="$COUNTERS/$service" +count=0 +[ -f "$counter_file" ] && count="$(cat "$counter_file")" +count=$((count + 1)) +printf '%s' "$count" > "$counter_file" +code="$(printf '%s' "$sequence" | cut -d, -f"$count")" +[ -n "$code" ] || code="$(printf '%s' "$sequence" | awk -F, '{print $NF}')" +printf '%s' "$code" +[ "$code" != "000" ] || exit 7 +`); + chmodSync(curl, 0o755); + + const sleep = join(bin, "sleep"); + writeFileSync(sleep, "#!/bin/sh\nexit 0\n"); + chmodSync(sleep, 0o755); + + const services = options.services ?? "portal\napi\ngateway\ncoder-acp-copilot"; + const states = options.states ?? {}; + const composeCreatedFile = join(directory, "compose-created"); + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + COUNTERS: counters, + SCOPE_DEV_READY_ATTEMPTS: "3", + SCOPE_DEV_READY_DELAY_SECONDS: "0", + SCOPE_DEV_READY_CURL_TIMEOUT_SECONDS: "1", + SCOPE_DEV_VERIFY_SERVICES: services, + PORTAL_CODES: options.portalCodes ?? "200", + API_CODES: options.apiCodes ?? "200", + COMPOSE_CREATED_FILE: composeCreatedFile, + COMPOSE_CREATE_AFTER: String(options.composeCreateAfter ?? 0), + COMPOSE_UP_STATUS: String(options.composeUpStatus ?? 0), + GATEWAY_CODES: options.gatewayCodes ?? "200", + WORKER_READY: options.workerReady === false ? "0" : "1", + STATE_portal: states.portal ?? "running", + STATE_api: states.api ?? "healthy", + STATE_gateway: states.gateway ?? "healthy", + STATE_coder_acp_copilot: states["coder-acp-copilot"] ?? "running", + }; + + return { + counters, + run: (overrides: NodeJS.ProcessEnv = {}) => + spawnSync("bash", [script], { + encoding: "utf8", + env: { ...env, ...overrides }, + }), + runWrapper: (overrides: NodeJS.ProcessEnv = {}) => + spawnSync("bash", [wrapper, "up", "api"], { + encoding: "utf8", + env: { ...env, ...overrides }, + }), + }; +} + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("verify-dev-stack.sh", () => { + it("verifies all published endpoints and the Copilot worker", () => { + const result = fixture().run(); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Portal verified: http://localhost:5100/"); + expect(result.stdout).toContain("API verified: http://localhost:3100/ready"); + expect(result.stdout).toContain("Gateway verified: http://localhost:18900/health/ready"); + expect(result.stdout).toContain("queue ensured, and MongoDB connected"); + }); + + it("allows delayed readiness within the bounded attempts", () => { + const result = fixture({ apiCodes: "503,000,200" }).run(); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("API verified"); + }); + + it("reports a reachable endpoint that remains non-ready", () => { + const result = fixture({ apiCodes: "503" }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("API host endpoint is reachable but not ready (HTTP 503)"); + expect(result.stderr).not.toContain("port-forwarding failure"); + }); + + it("reports connection refusal as host unreachability", () => { + const result = fixture({ apiCodes: "000" }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("API container is healthy, but its published host endpoint is unreachable"); + }); + + it("identifies healthy-container host forwarding failures", () => { + const result = fixture({ gatewayCodes: "000" }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("runtime VM networking or host port-forwarding failure"); + expect(result.stderr).toContain("docker compose port gateway 18000"); + }); + + it("reports unhealthy services separately from host endpoint failures", () => { + const result = fixture({ states: { api: "unhealthy" } }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("API service is unhealthy or not running (container state: unhealthy)"); + expect(result.stderr).not.toContain("API container is unhealthy, but its published host endpoint is unreachable"); + }); + + it("requires the worker queue and MongoDB readiness signals", () => { + const result = fixture({ workerReady: false }).run(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Copilot worker is running but not ready"); + expect(result.stderr).toContain("queue creation, MongoDB connection"); + }); + + it("stops after the configured attempt bound", () => { + const test = fixture({ apiCodes: "000" }); + const result = test.run({ SCOPE_DEV_READY_ATTEMPTS: "2" }); + + expect(result.status).toBe(1); + expect(readFileSync(join(test.counters, "API"), "utf8")).toBe("2"); + }); + + it("does not echo secret-bearing environment values", () => { + const secret = "https://user:super-secret@example.test/path?token=hidden"; + const result = fixture({ apiCodes: "000" }).run({ + GITHUB_TOKEN: "ghp_super_secret", + STORAGE_CONNECTION_STRING: secret, + }); + + expect(`${result.stdout}\n${result.stderr}`).not.toContain("ghp_super_secret"); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("super-secret"); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("token=hidden"); + }); + + it("integrates with the Compose wrapper and preserves a successful startup exit", () => { + const result = fixture({ services: "api" }).runWrapper(); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("API verified: http://localhost:3100/ready"); + }); + + it("makes the Compose startup command fail when verification fails", () => { + const result = fixture({ apiCodes: "000", services: "api" }).runWrapper({ + SCOPE_DEV_READY_ATTEMPTS: "1", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("published host endpoint is unreachable"); + }); + + it("does not consume readiness attempts while Compose is still building", () => { + const test = fixture({ + composeCreateAfter: 6, + services: "api", + }); + const result = test.runWrapper({ + SCOPE_DEV_READY_ATTEMPTS: "1", + }); + + expect(result.status, result.stderr).toBe(0); + expect(Number(readFileSync(join(test.counters, "PS"), "utf8"))).toBeGreaterThan(6); + expect(readFileSync(join(test.counters, "API"), "utf8")).toBe("1"); + expect(result.stdout).toContain("API verified: http://localhost:3100/ready"); + }); + + it("propagates a Compose build failure without waiting for readiness", () => { + const test = fixture({ + composeCreateAfter: 100, + composeUpStatus: 17, + services: "api", + }); + const result = test.runWrapper({ + SCOPE_DEV_READY_ATTEMPTS: "1", + }); + + expect(result.status).toBe(17); + expect(result.stderr).not.toContain("Development stack readiness verification failed"); + expect(() => readFileSync(join(test.counters, "API"), "utf8")).toThrow(); + }); +});