#!/usr/bin/env bash # End-to-end browser QA for fund-lab (work order 3c-1 scope and regressions). # Starts: disposable Postgres (port 55434), API (5095), Vite dev server (5175), # then drives the UI with headless Chromium via qa/driver/browser-test.js. # All ports and the container name are non-default and cleaned up on exit. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" QA_DIR="$ROOT/qa" API_PORT=5095 WEB_PORT=5175 PG_PORT="${PG_PORT:-55434}" PG_CONTAINER="${PG_CONTAINER:-fund-lab-qa-pg-3c1}" PG_IMAGE=postgres:16-alpine PG_DB=fund_lab_qa PG_USER=fund_qa AUTH_TOKEN=qa-token TMP_DIR="$(mktemp -d /tmp/opencode/fund-lab-qa-XXXXXX)" ENV_FILE="$TMP_DIR/pg-env" API_LOG="$TMP_DIR/api.log" WEB_LOG="$TMP_DIR/web.log" API_PID="" WEB_PID="" CREATED_CONTAINER_ID="" log() { printf '[qa] %s\n' "$*"; } die() { printf '[qa] FATAL: %s\n' "$*" >&2; exit 1; } cleanup() { local status=$? trap - EXIT INT TERM [ -n "$WEB_PID" ] && kill "$WEB_PID" 2>/dev/null || true [ -n "$API_PID" ] && kill "$API_PID" 2>/dev/null || true [ -n "$WEB_PID" ] && wait "$WEB_PID" 2>/dev/null || true [ -n "$API_PID" ] && wait "$API_PID" 2>/dev/null || true if [ -n "$CREATED_CONTAINER_ID" ]; then owned="$(docker ps -a --filter "id=$CREATED_CONTAINER_ID" --format '{{.Names}}' 2>/dev/null || true)" [ "$owned" = "$PG_CONTAINER" ] && docker rm -f "$CREATED_CONTAINER_ID" >/dev/null 2>&1 || true fi rm -f "$ENV_FILE" log "cleanup done (exit=$status)" log "artifacts preserved in $TMP_DIR" exit "$status" } trap cleanup EXIT INT TERM port_in_use() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && { exec 3>&- 3<&-; return 0; } || return 1 } command -v docker >/dev/null || die "docker is required" command -v openssl >/dev/null || die "openssl is required" docker info >/dev/null 2>&1 || die "docker daemon is not reachable" docker image inspect "$PG_IMAGE" >/dev/null 2>&1 || die "$PG_IMAGE is not available locally; pull it first" docker ps -a --format '{{.Names}}' | grep -qx "$PG_CONTAINER" && die "container $PG_CONTAINER already exists; refusing to touch it" for port in "$API_PORT" "$WEB_PORT" "$PG_PORT"; do port_in_use "$port" && die "port $port is already in use; refusing to continue" done [ -x "$QA_DIR/stub/fund-lab-python" ] || chmod +x "$QA_DIR/stub/fund-lab-python" export PATH="$ROOT/.tools/node-v22.23.2/bin:$PATH" command -v node >/dev/null || die "node not found in .tools" command -v dotnet >/dev/null || die "dotnet not found on PATH" mkdir -p "$TMP_DIR" umask 077 PG_PASSWORD="$(openssl rand -hex 24)" printf 'PG_PASSWORD=%s\n' "$PG_PASSWORD" > "$ENV_FILE" log "starting $PG_IMAGE as $PG_CONTAINER on 127.0.0.1:$PG_PORT" CREATED_CONTAINER_ID="$(docker run -d --name "$PG_CONTAINER" \ -e POSTGRES_DB="$PG_DB" -e POSTGRES_USER="$PG_USER" -e POSTGRES_PASSWORD="$PG_PASSWORD" \ -p "127.0.0.1:$PG_PORT:5432" "$PG_IMAGE")" pg_ready=0 for _ in $(seq 1 60); do if docker exec "$PG_CONTAINER" pg_isready -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1; then pg_ready=1 break fi sleep 0.5 done if [ "$pg_ready" != 1 ]; then docker ps -a --format '{{.Names}} {{.Status}}' | grep "$PG_CONTAINER" >&2 || true docker logs --tail 20 "$PG_CONTAINER" >&2 || true die "postgres did not become ready" fi log "building API" dotnet build "$ROOT/src/FundLab.Api/FundLab.Api.fsproj" -v q --nologo >/dev/null log "checking Release publish (FS3511 regression guard)" bash "$ROOT/scripts/publish-check.sh" log "starting API on 127.0.0.1:$API_PORT (stub interpreter, _synthetic payloads)" # The stub serves NAV through 2026-09-21 and the driver asserts that trade date, so pin the # trading-date clock (same test hook the xUnit fixture uses) instead of crossing the shanghai cutoff. FUND_LAB_TEST_TRADE_DATE="2026-09-21" \ FUND_LAB_DATABASE_URL="Host=127.0.0.1;Port=$PG_PORT;Database=$PG_DB;Username=$PG_USER;Password=$PG_PASSWORD;Pooling=false" \ FUND_LAB_AUTH_TOKEN="$AUTH_TOKEN" \ FUND_LAB_AKSHARE_PYTHON="$QA_DIR/stub/fund-lab-python" \ ASPNETCORE_URLS="http://127.0.0.1:$API_PORT" \ dotnet "$ROOT/src/FundLab.Api/bin/Debug/net8.0/FundLab.Api.dll" > "$API_LOG" 2>&1 & API_PID=$! api_up=0 for _ in $(seq 1 60); do code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$API_PORT/api/instruments?query=x" || true)" [ "$code" != "000" ] && { api_up=1; break; } sleep 0.5 done [ "$api_up" = 1 ] || { cat "$API_LOG" >&2; die "API did not become ready"; } log "compiling F# to JS (fable)" ( cd "$ROOT/src/FundLab.Web" && dotnet fable FundLab.Web.fsproj --outDir dist --noCache ) > "$TMP_DIR/fable.log" 2>&1 log "starting Vite dev server on 127.0.0.1:$WEB_PORT" ( cd "$ROOT/src/FundLab.Web" && FUND_LAB_API_TARGET="http://127.0.0.1:$API_PORT" exec node node_modules/vite/bin/vite.js --host 127.0.0.1 --port "$WEB_PORT" --strictPort ) \ > "$WEB_LOG" 2>&1 & WEB_PID=$! web_up=0 for _ in $(seq 1 60); do code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$WEB_PORT/" || true)" [ "$code" = "200" ] && { web_up=1; break; } sleep 0.5 done [ "$web_up" = 1 ] || { cat "$WEB_LOG" >&2; die "vite dev server did not become ready"; } if [ ! -d "$QA_DIR/driver/node_modules" ]; then log "installing driver dependencies" ( cd "$QA_DIR/driver" && npm install --no-audit --no-fund --loglevel=error >/dev/null ) fi log "running browser scenarios" mkdir -p "$TMP_DIR/screens" QA_BASE_URL="http://127.0.0.1:$WEB_PORT/" \ QA_SHOTS="$TMP_DIR/screens" \ QA_TOKEN="$AUTH_TOKEN" \ node "$QA_DIR/driver/browser-test.js" 2>&1 | tee "$TMP_DIR/driver.log"