# Makefile for Bifrost # Variables HOST ?= localhost PORT ?= 8080 APP_DIR ?= PROMETHEUS_LABELS ?= LOG_STYLE ?= json LOG_LEVEL ?= info TEST_REPORTS_DIR ?= test-reports GOTESTSUM_FORMAT ?= standard-verbose FLOW ?= VERSION ?= dev-build LOCAL ?= DEBUG ?= COMPAT ?= # Colors for output RED=\033[0;31m GREEN=\033[0;32m YELLOW=\033[1;33m BLUE=\033[0;34m CYAN=\033[0;36m NC=\033[0m # No Color ECHO := printf '%b\n' # nvm requires bash-compatible shell semantics; /bin/sh is dash on some Linux distros. SHELL := /usr/bin/env bash # Ensures the Node version pinned in .nvmrc is active before any npm/node call. # nvm is a shell function, so each recipe that needs it must inline this snippet # via `$(USE_NODE); `. USE_NODE = NVM_SH="$${NVM_DIR:-$$HOME/.nvm}/nvm.sh"; \ [ -s "$$NVM_SH" ] || NVM_SH="$$(brew --prefix nvm 2>/dev/null)/nvm.sh"; \ if [ -s "$$NVM_SH" ]; then . "$$NVM_SH" >/dev/null && nvm install >/dev/null 2>&1 && nvm use >/dev/null 2>&1; fi # Loads secrets into the current recipe shell. Reads USE_INFISICAL env var: # USE_INFISICAL=1|y|Y|yes|YES|true|TRUE -> source secrets from Infisical (`infisical export --path

`) # anything else -> source ./.env # Honors INFISICAL_PATH (default /local) when sourcing from Infisical. # After invoking `$(EXPOSE_ENV);`, all subsequent commands inherit the secrets # - no per-command prefix needed. # Use as: `$(EXPOSE_ENV); ` define EXPOSE_ENV case "$$USE_INFISICAL" in \ 1|y|Y|yes|YES|true|TRUE) USE_INFISICAL_RESOLVED=1 ;; \ *) USE_INFISICAL_RESOLVED=0 ;; \ esac; \ if [ "$$USE_INFISICAL_RESOLVED" = "1" ]; then \ if ! which infisical > /dev/null 2>&1; then \ $(ECHO) "$(RED)infisical CLI not found. Install: https://infisical.com/docs/cli/overview$(NC)"; \ exit 1; \ fi; \ INFISICAL_PATH_VAL="$${INFISICAL_PATH:-/local}"; \ $(ECHO) "$(GREEN)Sourcing secrets from Infisical (path=$$INFISICAL_PATH_VAL)$(NC)"; \ if ! infisical export --path "$$INFISICAL_PATH_VAL" --format dotenv > /dev/null 2>&1; then \ $(ECHO) "$(RED)Failed to export secrets from Infisical (path=$$INFISICAL_PATH_VAL)$(NC)"; \ infisical export --path "$$INFISICAL_PATH_VAL" --format dotenv 2>&1 >/dev/null; \ exit 1; \ fi; \ set -a; . <(infisical export --path "$$INFISICAL_PATH_VAL" --format dotenv); set +a; \ else \ if [ -f .env ]; then \ $(ECHO) "$(YELLOW)Loading environment variables from .env...$(NC)"; \ set -a; . ./.env; set +a; \ fi; \ fi endef .PHONY: test-memory all help dev dev-pulse build-ui build build-cli run run-cli install-air install-pulse clean test test-cli install-ui setup-workspace work-init work-clean docs docker-image docker-run cleanup-enterprise mod-tidy test-integrations test-integrations-py test-integrations-ts install-playwright run-e2e run-a11y-audit run-e2e-ui run-e2e-headed e2e-ui-down run-e2e-api run-mcp-codemode-test run-warp-test format ui install-newman run-provider-harness-test smoke-provider-harness-test run-cli-harness-test cli-harness-report test-harness-runner-lib run-video-costing-test list-video-costing-cases test-semantic-cache test-semantic-cache-complete _test-semantic-cache-complete-inner helm-index install-microsocks socks5-proxy install-tinyproxy http-proxy install-squid install-stunnel run-e2e-api-proxy run-mcp-harness-test run-log-store-matrix all: help # Include deployment recipes include recipes/fly.mk include recipes/ecs.mk include recipes/local-k8s.mk # Default target help: ## Show this help message @$(ECHO) "$(BLUE)Bifrost Development - Available Commands:$(NC)" @$(ECHO) "" @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-15s$(NC) %s\n", $$1, $$2}' @$(ECHO) "" @$(ECHO) "$(YELLOW)Environment Variables:$(NC)" @$(ECHO) " HOST Server host (default: localhost)" @$(ECHO) " PORT Server port (default: 8080)" @$(ECHO) " PROMETHEUS_LABELS Labels for Prometheus metrics" @$(ECHO) " LOG_STYLE Logger output format: json|pretty (default: json)" @$(ECHO) " LOG_LEVEL Logger level: debug|info|warn|error (default: info)" @$(ECHO) " APP_DIR App data directory inside container (default: /app/data)" @$(ECHO) " LOCAL Use local go.work for builds (e.g., make build LOCAL=1)" @$(ECHO) " DEBUG Enable delve debugger on port 2345 (e.g., make dev DEBUG=1, make test-core DEBUG=1, make test-governance DEBUG=1)" @$(ECHO) "" @$(ECHO) "$(YELLOW)Test Configuration:$(NC)" @$(ECHO) " TEST_REPORTS_DIR Directory for HTML test reports (default: test-reports)" @$(ECHO) " GOTESTSUM_FORMAT Test output format: testname|dots|pkgname|standard-verbose (default: standard-verbose)" @$(ECHO) " TESTCASE Exact test name to run (e.g., TestVirtualKeyTokenRateLimit)" @$(ECHO) " PATTERN Substring pattern to filter tests (alternative to TESTCASE)" @$(ECHO) " FLOW E2E test flow to run: providers|virtual-keys (default: all)" cleanup-enterprise: ## Clean up enterprise directories if present @$(ECHO) "$(GREEN)Cleaning up enterprise...$(NC)" @if [ -d "ui/app/enterprise" ]; then rm -rf ui/app/enterprise; fi @$(ECHO) "$(GREEN)Enterprise cleaned up$(NC)" install-ui: cleanup-enterprise @$(USE_NODE); \ which node > /dev/null || ($(ECHO) "$(RED)Error: Node.js is not installed. Please install Node.js first.$(NC)" && exit 1); \ which npm > /dev/null || ($(ECHO) "$(RED)Error: npm is not installed. Please install npm first.$(NC)" && exit 1); \ $(ECHO) "$(GREEN)Node.js $$(node -v) and npm $$(npm -v) are installed$(NC)"; \ if [ ! -d "ui/node_modules" ] || [ "ui/package.json" -nt "ui/node_modules/.package-lock.json" ] || [ "ui/package-lock.json" -nt "ui/node_modules/.package-lock.json" ]; then \ $(ECHO) "$(YELLOW)Dependencies changed, running npm ci...$(NC)"; \ cd ui && npm ci; \ else \ $(ECHO) "$(GREEN)UI dependencies up to date, skipping install$(NC)"; \ fi @$(ECHO) "$(GREEN)UI deps are in sync$(NC)" install-air: ## Install air for hot reloading (if not already installed) @which air > /dev/null || ($(ECHO) "$(YELLOW)Installing air for hot reloading...$(NC)" && go install github.com/air-verse/air@latest) @$(ECHO) "$(GREEN)Air is ready$(NC)" install-pulse: ## Install pulse for hot reloading (if not already installed) @which pulse > /dev/null || ($(ECHO) "$(YELLOW)Installing pulse for hot reloading...$(NC)" && go install github.com/Pratham-Mishra04/pulse@latest) @$(ECHO) "$(GREEN)Pulse is ready$(NC)" install-delve: ## Install delve for debugging (if not already installed) @which dlv > /dev/null || ($(ECHO) "$(YELLOW)Installing delve for debugging...$(NC)" && go install github.com/go-delve/delve/cmd/dlv@latest) @$(ECHO) "$(GREEN)Delve is ready$(NC)" install-gotestsum: ## Install gotestsum for test reporting (if not already installed) @which gotestsum > /dev/null || ($(ECHO) "$(YELLOW)Installing gotestsum for test reporting...$(NC)" && go install gotest.tools/gotestsum@latest) @$(ECHO) "$(GREEN)gotestsum is ready$(NC)" install-junit-viewer: ## Install junit-viewer for HTML report generation (if not already installed) @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(GREEN)junit-viewer is already installed$(NC)"; \ else \ $(ECHO) "$(YELLOW)Installing junit-viewer for HTML reports...$(NC)"; \ $(USE_NODE); \ if npm install -g junit-viewer 2>&1; then \ $(ECHO) "$(GREEN)junit-viewer installed successfully$(NC)"; \ else \ $(ECHO) "$(RED)Failed to install junit-viewer. HTML reports will be skipped.$(NC)"; \ $(ECHO) "$(YELLOW)You can install it manually: npm install -g junit-viewer$(NC)"; \ exit 0; \ fi; \ fi; \ else \ $(ECHO) "$(YELLOW)CI environment detected, skipping junit-viewer installation$(NC)"; \ fi install-microsocks: ## Install microsocks SOCKS5 proxy for local testing (if not already installed) @which microsocks > /dev/null || (command -v brew > /dev/null && $(ECHO) "$(YELLOW)Installing microsocks via Homebrew...$(NC)" && brew install microsocks) || ($(ECHO) "$(RED)Error: microsocks not found and Homebrew is unavailable. Install manually: https://github.com/rofl0r/microsocks$(NC)" && exit 1) @$(ECHO) "$(GREEN)microsocks is ready$(NC)" socks5-proxy: install-microsocks ## Run a local SOCKS5 proxy for testing provider proxy_config (Usage: make socks5-proxy [PORT=1080] [HOST=127.0.0.1]) @PROXY_PORT=$${PORT:-1080}; \ PROXY_HOST=$${HOST:-127.0.0.1}; \ $(ECHO) "$(GREEN)Starting SOCKS5 proxy on $$PROXY_HOST:$$PROXY_PORT (no auth, logs each connection, Ctrl+C to stop)...$(NC)"; \ $(ECHO) "$(YELLOW)Point a provider's proxy_config at socks5://$$PROXY_HOST:$$PROXY_PORT to test$(NC)"; \ microsocks -i $$PROXY_HOST -p $$PROXY_PORT install-tinyproxy: ## Install tinyproxy HTTP proxy for local testing (if not already installed) @which tinyproxy > /dev/null || (command -v brew > /dev/null && $(ECHO) "$(YELLOW)Installing tinyproxy via Homebrew...$(NC)" && brew install tinyproxy) || ($(ECHO) "$(RED)Error: tinyproxy not found and Homebrew is unavailable. Install manually: https://github.com/tinyproxy/tinyproxy$(NC)" && exit 1) @$(ECHO) "$(GREEN)tinyproxy is ready$(NC)" http-proxy: install-tinyproxy ## Run a local HTTP proxy for testing provider proxy_config (Usage: make http-proxy [PORT=8888] [HOST=127.0.0.1]) @PROXY_PORT=$${PORT:-8888}; \ PROXY_HOST=$${HOST:-127.0.0.1}; \ CONF=$$(mktemp -t bifrost-tinyproxy); \ trap 'rm -f "$$CONF"' EXIT INT TERM; \ printf 'Port %s\nListen %s\nTimeout 600\nAllow 127.0.0.1\nAllow ::1\nLogLevel Info\n' "$$PROXY_PORT" "$$PROXY_HOST" > "$$CONF"; \ $(ECHO) "$(GREEN)Starting HTTP proxy on $$PROXY_HOST:$$PROXY_PORT (no auth, logs each connection, Ctrl+C to stop)...$(NC)"; \ $(ECHO) "$(YELLOW)Point a provider's proxy_config at http://$$PROXY_HOST:$$PROXY_PORT to test$(NC)"; \ tinyproxy -d -c "$$CONF" install-squid: ## Install squid HTTP proxy for the proxy e2e flow (if not already installed) @command -v squid > /dev/null || [ -x /usr/sbin/squid ] || (command -v brew > /dev/null && $(ECHO) "$(YELLOW)Installing squid via Homebrew...$(NC)" && brew install squid) || ($(ECHO) "$(RED)Error: squid not found and Homebrew is unavailable. Install it with your package manager (apt install squid)$(NC)" && exit 1) @$(ECHO) "$(GREEN)squid is ready$(NC)" install-stunnel: ## Install stunnel (TLS in front of squid for https:// proxies) for the proxy e2e flow (if not already installed) @command -v stunnel > /dev/null || command -v stunnel4 > /dev/null || (command -v brew > /dev/null && $(ECHO) "$(YELLOW)Installing stunnel via Homebrew...$(NC)" && brew install stunnel) || ($(ECHO) "$(RED)Error: stunnel not found and Homebrew is unavailable. Install it with your package manager (apt install stunnel4)$(NC)" && exit 1) @$(ECHO) "$(GREEN)stunnel is ready$(NC)" dev: install-ui install-air setup-workspace $(if $(DEBUG),install-delve) ## Start complete development environment (UI + API with proxy) @$(EXPOSE_ENV); \ set +m; \ ui_pid=""; \ api_pid=""; \ cleanup() { \ $(ECHO) "$(YELLOW)[make dev] cleanup started; ui_pid=$$ui_pid api_pid=$$api_pid$(NC)"; \ trap - EXIT INT TERM HUP; \ for pid in "$$ui_pid" "$$api_pid"; do \ if [ -n "$$pid" ]; then \ children="$$(pgrep -P "$$pid" 2>/dev/null || true)"; \ $(ECHO) "$(YELLOW)[make dev] sending TERM to pid $$pid and children: $${children:-none}$(NC)"; \ kill -TERM $$children "$$pid" 2>/dev/null || true; \ fi; \ done; \ sleep 1; \ for pid in "$$ui_pid" "$$api_pid"; do \ if [ -n "$$pid" ]; then \ children="$$(pgrep -P "$$pid" 2>/dev/null || true)"; \ $(ECHO) "$(YELLOW)[make dev] sending KILL to pid $$pid and remaining children: $${children:-none}$(NC)"; \ kill -KILL $$children "$$pid" 2>/dev/null || true; \ fi; \ done; \ $(ECHO) "$(YELLOW)[make dev] waiting for background jobs to exit...$(NC)"; \ wait 2>/dev/null || true; \ $(ECHO) "$(GREEN)[make dev] cleanup completed.$(NC)"; \ }; \ stop_dev() { \ $(ECHO) "$(YELLOW)[make dev] received shutdown signal; starting cleanup...$(NC)"; \ cleanup; \ exit 130; \ }; \ trap cleanup EXIT; \ trap stop_dev INT TERM HUP; \ $(ECHO) "$(GREEN)Starting Bifrost complete development environment...$(NC)"; \ $(ECHO) "$(YELLOW)This will start:$(NC)"; \ $(ECHO) " 1. UI development server (localhost:3000)"; \ $(ECHO) " 2. API server with UI proxy (localhost:$(PORT))"; \ $(ECHO) "$(CYAN)Access everything at: http://localhost:$(PORT)$(NC)"; \ if [ -n "$(DEBUG)" ]; then \ $(ECHO) "$(CYAN) 3. Debugger (delve) listening on port 2345$(NC)"; \ fi; \ if [ ! -d "transports/bifrost-http/ui" ]; then \ $(ECHO) "$(YELLOW)Creating transports/bifrost-http/ui directory...$(NC)"; \ mkdir -p transports/bifrost-http/ui; \ touch transports/bifrost-http/ui/.tmp; \ fi; \ $(ECHO) ""; \ $(ECHO) "$(YELLOW)Starting UI development server...$(NC)"; \ $(USE_NODE); if [ -n "$(DISABLE_PROFILER)" ]; then \ $(ECHO) "$(CYAN)DevProfiler disabled for testing$(NC)"; \ (cd ui && BIFROST_PORT=$(PORT) BIFROST_DISABLE_PROFILER=1 npm run dev) & \ else \ (cd ui && BIFROST_PORT=$(PORT) npm run dev) & \ fi; \ ui_pid="$$!"; \ $(ECHO) "$(YELLOW)[make dev] UI dev server started with pid $$ui_pid$(NC)"; \ sleep 3; \ $(ECHO) "$(YELLOW)Starting API server with UI proxy...$(NC)"; \ $(MAKE) setup-workspace >/dev/null; \ if [ -n "$(DEBUG)" ]; then \ $(ECHO) "$(CYAN)Starting with air + delve debugger on port 2345...$(NC)"; \ $(ECHO) "$(YELLOW)Attach your debugger to localhost:2345$(NC)"; \ (cd transports/bifrost-http && BIFROST_UI_DEV=true air -c .air.debug.toml -- \ -host "$(HOST)" \ -port "$(PORT)" \ -log-style "$(LOG_STYLE)" \ -log-level "$(LOG_LEVEL)" \ $(if $(PROMETHEUS_LABELS),-prometheus-labels "$(PROMETHEUS_LABELS)") \ $(if $(BASE_PATH),-base-path "$(BASE_PATH)") \ $(if $(APP_DIR),-app-dir "$(abspath $(APP_DIR))")) & \ else \ (cd transports/bifrost-http && BIFROST_UI_DEV=true air -c .air.toml -- \ -host "$(HOST)" \ -port "$(PORT)" \ -log-style "$(LOG_STYLE)" \ -log-level "$(LOG_LEVEL)" \ $(if $(PROMETHEUS_LABELS),-prometheus-labels "$(PROMETHEUS_LABELS)") \ $(if $(BASE_PATH),-base-path "$(BASE_PATH)") \ $(if $(APP_DIR),-app-dir "$(abspath $(APP_DIR))")) & \ fi; \ api_pid="$$!"; \ $(ECHO) "$(YELLOW)[make dev] API dev server started with pid $$api_pid$(NC)"; \ while kill -0 "$$ui_pid" 2>/dev/null && kill -0 "$$api_pid" 2>/dev/null; do sleep 1; done; \ $(ECHO) "$(YELLOW)[make dev] one of the dev processes exited; running cleanup...$(NC)"; \ cleanup; \ exit 1 dev-pulse: install-ui install-pulse setup-workspace $(if $(DEBUG),install-delve) ## Start complete development environment using pulse for hot reloading @$(EXPOSE_ENV); \ set +m; \ ui_pid=""; \ pulse_pid=""; \ cleanup() { \ $(ECHO) "$(YELLOW)[make dev-pulse] cleanup started; ui_pid=$$ui_pid pulse_pid=$$pulse_pid$(NC)"; \ trap - EXIT INT TERM HUP; \ for pid in "$$ui_pid" "$$pulse_pid"; do \ if [ -n "$$pid" ]; then \ children="$$(pgrep -P "$$pid" 2>/dev/null || true)"; \ $(ECHO) "$(YELLOW)[make dev-pulse] sending TERM to pid $$pid and children: $${children:-none}$(NC)"; \ kill -TERM $$children "$$pid" 2>/dev/null || true; \ fi; \ done; \ sleep 1; \ for pid in "$$ui_pid" "$$pulse_pid"; do \ if [ -n "$$pid" ]; then \ children="$$(pgrep -P "$$pid" 2>/dev/null || true)"; \ $(ECHO) "$(YELLOW)[make dev-pulse] sending KILL to pid $$pid and remaining children: $${children:-none}$(NC)"; \ kill -KILL $$children "$$pid" 2>/dev/null || true; \ fi; \ done; \ $(ECHO) "$(YELLOW)[make dev-pulse] waiting for background jobs to exit...$(NC)"; \ wait 2>/dev/null || true; \ $(ECHO) "$(GREEN)[make dev-pulse] cleanup completed.$(NC)"; \ }; \ stop_dev() { \ $(ECHO) "$(YELLOW)[make dev-pulse] received shutdown signal; starting cleanup...$(NC)"; \ cleanup; \ exit 130; \ }; \ trap cleanup EXIT; \ trap stop_dev INT TERM HUP; \ $(ECHO) "$(GREEN)Starting Bifrost complete development environment (pulse)...$(NC)"; \ $(ECHO) "$(YELLOW)This will start:$(NC)"; \ $(ECHO) " 1. UI development server (localhost:3000)"; \ $(ECHO) " 2. API server with UI proxy (localhost:$(PORT))"; \ $(ECHO) "$(CYAN)Access everything at: http://localhost:$(PORT)$(NC)"; \ if [ -n "$(DEBUG)" ]; then \ $(ECHO) "$(CYAN) 3. Debugger (delve) listening on port 2345$(NC)"; \ fi; \ if [ ! -d "transports/bifrost-http/ui" ]; then \ $(ECHO) "$(YELLOW)Creating transports/bifrost-http/ui directory...$(NC)"; \ mkdir -p transports/bifrost-http/ui; \ touch transports/bifrost-http/ui/.tmp; \ fi; \ $(ECHO) ""; \ $(ECHO) "$(YELLOW)Starting UI development server...$(NC)"; \ $(USE_NODE); if [ -n "$(DISABLE_PROFILER)" ]; then \ $(ECHO) "$(CYAN)DevProfiler disabled for testing$(NC)"; \ (cd ui && BIFROST_PORT=$(PORT) BIFROST_DISABLE_PROFILER=1 npm run dev) & \ else \ (cd ui && BIFROST_PORT=$(PORT) npm run dev) & \ fi; \ ui_pid="$$!"; \ $(ECHO) "$(YELLOW)[make dev-pulse] UI dev server started with pid $$ui_pid$(NC)"; \ sleep 3; \ $(ECHO) "$(YELLOW)Starting API server with UI proxy...$(NC)"; \ $(MAKE) setup-workspace >/dev/null; \ if [ -n "$(DEBUG)" ]; then \ $(ECHO) "$(CYAN)Starting with pulse + delve debugger on port 2345...$(NC)"; \ $(ECHO) "$(YELLOW)Attach your debugger to localhost:2345$(NC)"; \ PORT="$(PORT)" HOST="$(HOST)" LOG_STYLE="$(LOG_STYLE)" LOG_LEVEL="$(LOG_LEVEL)" BIFROST_UI_DEV=true \ $(if $(APP_DIR),APP_DIR="$(abspath $(APP_DIR))") pulse & \ else \ PORT="$(PORT)" HOST="$(HOST)" LOG_STYLE="$(LOG_STYLE)" LOG_LEVEL="$(LOG_LEVEL)" BIFROST_UI_DEV=true \ $(if $(APP_DIR),APP_DIR="$(abspath $(APP_DIR))") pulse & \ fi; \ pulse_pid="$$!"; \ $(ECHO) "$(YELLOW)[make dev-pulse] pulse started with pid $$pulse_pid$(NC)"; \ while kill -0 "$$ui_pid" 2>/dev/null && kill -0 "$$pulse_pid" 2>/dev/null; do sleep 1; done; \ $(ECHO) "$(YELLOW)[make dev-pulse] one of the dev processes exited; running cleanup...$(NC)"; \ cleanup; \ exit 1 build-ui: install-ui ## Build ui @$(ECHO) "$(GREEN)Building ui...$(NC)" @rm -rf ui/.next @$(USE_NODE); cd ui && npm run build && npm run copy-build build: build-ui ## Build bifrost-http binary (BUILD_TAGS adds Go build tags; the MCP harness passes harness) @if [ -n "$(LOCAL)" ]; then \ $(ECHO) "$(GREEN)╔═══════════════════════════════════════════════╗$(NC)"; \ $(ECHO) "$(GREEN)║ Building bifrost-http with local go.work... ║$(NC)"; \ $(ECHO) "$(GREEN)╚═══════════════════════════════════════════════╝$(NC)"; \ else \ $(ECHO) "$(GREEN)╔═══════════════════════════════════════╗$(NC)"; \ $(ECHO) "$(GREEN)║ Building bifrost-http... ║$(NC)"; \ $(ECHO) "$(GREEN)╚═══════════════════════════════════════╝$(NC)"; \ fi @if [ -n "$(DYNAMIC)" ]; then \ $(ECHO) "$(YELLOW)Note: This will create a dynamically linked build.$(NC)"; \ else \ $(ECHO) "$(YELLOW)Note: This will create a statically linked build.$(NC)"; \ fi @mkdir -p ./tmp @# set -e: this recipe is ONE shell, and its branches chain with `;`, so a @# failed `go build` fell through to the success echo below and left the @# recipe exiting 0 - make reported a green build for a binary that was @# never produced. Only the caller's own "binary not found" guard caught @# it, several steps later and with a misleading message. @set -e; \ TARGET_OS="$(GOOS)"; \ TARGET_ARCH="$(GOARCH)"; \ ACTUAL_OS=$$(uname -s | tr '[:upper:]' '[:lower:]' | sed 's/darwin/darwin/;s/linux/linux/;s/mingw.*/windows/'); \ ACTUAL_ARCH=$$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/;s/arm64/arm64/'); \ if [ -z "$$TARGET_OS" ]; then \ TARGET_OS=$$ACTUAL_OS; \ fi; \ if [ -z "$$TARGET_ARCH" ]; then \ TARGET_ARCH=$$ACTUAL_ARCH; \ fi; \ HOST_OS=$$ACTUAL_OS; \ HOST_ARCH=$$ACTUAL_ARCH; \ $(ECHO) "$(CYAN)Host: $$HOST_OS/$$HOST_ARCH | Target: $$TARGET_OS/$$TARGET_ARCH$(NC)"; \ if [ "$$TARGET_OS" = "linux" ] && [ "$$HOST_OS" = "linux" ]; then \ if [ -n "$(DYNAMIC)" ]; then \ $(ECHO) "$(CYAN)Building for $$TARGET_OS/$$TARGET_ARCH with dynamic linking...$(NC)"; \ cd transports/bifrost-http && CGO_ENABLED=1 GOOS=$$TARGET_OS GOARCH=$$TARGET_ARCH $(if $(LOCAL),,GOWORK=off) go build \ -ldflags="-w -s -X main.Version=v$(VERSION)" \ -a -trimpath \ $(if $(BUILD_TAGS),-tags "$(BUILD_TAGS)") \ -o ../../tmp/bifrost-http \ .; \ else \ $(ECHO) "$(CYAN)Building for $$TARGET_OS/$$TARGET_ARCH with static linking...$(NC)"; \ cd transports/bifrost-http && CGO_ENABLED=1 GOOS=$$TARGET_OS GOARCH=$$TARGET_ARCH $(if $(LOCAL),,GOWORK=off) go build \ -ldflags="-w -s -extldflags "-static" -X main.Version=v$(VERSION)" \ -a -trimpath \ -tags "sqlite_static $(BUILD_TAGS)" \ -o ../../tmp/bifrost-http \ .; \ fi; \ $(ECHO) "$(GREEN)Built: tmp/bifrost-http (version: v$(VERSION))$(NC)"; \ elif [ "$$TARGET_OS" = "$$HOST_OS" ] && [ "$$TARGET_ARCH" = "$$HOST_ARCH" ]; then \ $(ECHO) "$(CYAN)Building for $$TARGET_OS/$$TARGET_ARCH (native build with CGO)...$(NC)"; \ cd transports/bifrost-http && CGO_ENABLED=1 GOOS=$$TARGET_OS GOARCH=$$TARGET_ARCH $(if $(LOCAL),,GOWORK=off) go build \ -ldflags="-w -s -X main.Version=v$(VERSION)" \ -a -trimpath \ -tags "sqlite_static $(BUILD_TAGS)" \ -o ../../tmp/bifrost-http \ .; \ $(ECHO) "$(GREEN)Built: tmp/bifrost-http (version: v$(VERSION))$(NC)"; \ else \ $(ECHO) "$(YELLOW)Cross-compilation detected: $$HOST_OS/$$HOST_ARCH -> $$TARGET_OS/$$TARGET_ARCH$(NC)"; \ $(ECHO) "$(CYAN)Using Docker for cross-compilation...$(NC)"; \ $(MAKE) _build-with-docker TARGET_OS=$$TARGET_OS TARGET_ARCH=$$TARGET_ARCH $(if $(DYNAMIC),DYNAMIC=$(DYNAMIC)); \ fi build-cli: ## Build bifrost CLI binary @$(ECHO) "$(GREEN)Building bifrost CLI...$(NC)" @mkdir -p ./tmp @cd cli && $(if $(LOCAL),,GOWORK=off) go build -ldflags "-X main.version=v0.1.1-dev" -o ../tmp/bifrost . @$(ECHO) "$(GREEN)Built: tmp/bifrost$(NC)" _build-with-docker: # Internal target for Docker-based cross-compilation @# set -e for the same reason as build-http above: without it a failed @# `docker run` still reaches the "Built:" echo and returns 0. @set -e; \ $(ECHO) "$(CYAN)Using Docker for cross-compilation...$(NC)"; \ if [ "$(TARGET_OS)" = "linux" ]; then \ if [ -n "$(DYNAMIC)" ]; then \ $(ECHO) "$(CYAN)Building for $(TARGET_OS)/$(TARGET_ARCH) in Docker container with dynamic linking...$(NC)"; \ docker run --rm \ --platform linux/$(TARGET_ARCH) \ -v "$(shell pwd):/workspace" \ -w /workspace/transports/bifrost-http \ -e CGO_ENABLED=1 \ -e GOOS=$(TARGET_OS) \ -e GOARCH=$(TARGET_ARCH) \ $(if $(LOCAL),,-e GOWORK=off) \ golang:1.27.3-alpine3.24@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd \ sh -c "apk add --no-cache gcc musl-dev && \ go build \ -ldflags='-w -s -X main.Version=v$(VERSION)' \ -a -trimpath \ $(if $(BUILD_TAGS),-tags \"$(BUILD_TAGS)\") \ -o ../../tmp/bifrost-http \ ."; \ else \ $(ECHO) "$(CYAN)Building for $(TARGET_OS)/$(TARGET_ARCH) in Docker container...$(NC)"; \ docker run --rm \ --platform linux/$(TARGET_ARCH) \ -v "$(shell pwd):/workspace" \ -w /workspace/transports/bifrost-http \ -e CGO_ENABLED=1 \ -e GOOS=$(TARGET_OS) \ -e GOARCH=$(TARGET_ARCH) \ $(if $(LOCAL),,-e GOWORK=off) \ golang:1.27.3-alpine3.24@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd \ sh -c "apk add --no-cache gcc musl-dev && \ go build \ -ldflags='-w -s -extldflags "-static" -X main.Version=v$(VERSION)' \ -a -trimpath \ -tags \"sqlite_static $(BUILD_TAGS)\" \ -o ../../tmp/bifrost-http \ ."; \ fi; \ $(ECHO) "$(GREEN)Built: tmp/bifrost-http ($(TARGET_OS)/$(TARGET_ARCH), version: v$(VERSION))$(NC)"; \ else \ $(ECHO) "$(RED)Error: Docker cross-compilation only supports Linux targets$(NC)"; \ $(ECHO) "$(YELLOW)For $(TARGET_OS), please build on a native $(TARGET_OS) machine$(NC)"; \ exit 1; \ fi docker-image: build-ui ## Build Docker image (LOCAL=1 to use Dockerfile.local) @$(ECHO) "$(GREEN)Building Docker image...$(NC)" $(eval GIT_SHA=$(shell git rev-parse --short HEAD)) $(eval DOCKERFILE=$(if $(LOCAL),transports/Dockerfile.local,transports/Dockerfile)) @docker build -f $(DOCKERFILE) -t bifrost -t bifrost:$(GIT_SHA) -t bifrost:latest . @$(ECHO) "$(GREEN)Docker image built: bifrost, bifrost:$(GIT_SHA), bifrost:latest (using $(DOCKERFILE))$(NC)" docker-run: ## Run Docker container (Usage: make docker-run [CONFIG=path/to/config.json or path/to/dir/]) @$(ECHO) "$(GREEN)Running Docker container...$(NC)" @CONFIG_PATH="$(abspath $(CONFIG))"; \ if [ -n "$(CONFIG)" ]; then \ if [ -d "$$CONFIG_PATH" ]; then \ CONFIG_PATH="$$CONFIG_PATH/config.json"; \ fi; \ CONFIG_MOUNT="-v $$CONFIG_PATH:/app/data/config.json"; \ else \ CONFIG_MOUNT=""; \ fi; \ docker run -e APP_PORT=$(PORT) -e APP_HOST=0.0.0.0 -p $(PORT):$(PORT) -e LOG_LEVEL=$(LOG_LEVEL) -e LOG_STYLE=$(LOG_STYLE) -v $(shell pwd):/app/data $$CONFIG_MOUNT bifrost docs: ## Prepare local docs (bundles OpenAPI spec then starts Mintlify dev server) @$(ECHO) "$(GREEN)Bundling OpenAPI spec...$(NC)" @cd docs/openapi && python3 bundle.py @$(ECHO) "$(GREEN)Preparing local docs...$(NC)" @cd docs && npx --yes mintlify@latest dev run: build ## Build and run bifrost-http (no hot reload) @$(ECHO) "$(GREEN)Running bifrost-http...$(NC)" @./tmp/bifrost-http \ -host "$(HOST)" \ -port "$(PORT)" \ -log-style "$(LOG_STYLE)" \ -log-level "$(LOG_LEVEL)" \ $(if $(PROMETHEUS_LABELS),-prometheus-labels "$(PROMETHEUS_LABELS)") \ $(if $(BASE_PATH),-base-path "$(BASE_PATH)") \ $(if $(APP_DIR),-app-dir "$(abspath $(APP_DIR))") run-cli: build-cli ## Run bifrost CLI (Usage: make run-cli [ARGS="--config ~/.bifrost/config.json"]) @$(ECHO) "$(GREEN)Running bifrost CLI...$(NC)" @./tmp/bifrost $(ARGS) clean: ## Clean build artifacts and temporary files @$(ECHO) "$(YELLOW)Cleaning build artifacts...$(NC)" @rm -rf tmp/ @rm -f transports/bifrost-http/build-errors.log @rm -rf transports/bifrost-http/tmp/ @rm -rf $(TEST_REPORTS_DIR)/ @$(ECHO) "$(GREEN)Clean complete$(NC)" clean-test-reports: ## Clean test reports only @$(ECHO) "$(YELLOW)Cleaning test reports...$(NC)" @rm -rf $(TEST_REPORTS_DIR)/ @$(ECHO) "$(GREEN)Test reports cleaned$(NC)" helm-index: ## Repackage helm chart, regenerate index.yaml digest, then remove the .tgz @if ! which helm > /dev/null 2>&1; then \ $(ECHO) "$(RED)Error: helm not installed$(NC)"; \ exit 1; \ fi @CHART_VERSION=$$(grep '^version:' helm-charts/bifrost/Chart.yaml | awk '{print $$2}'); \ $(ECHO) "$(YELLOW)Packaging helm chart v$$CHART_VERSION...$(NC)"; \ cd helm-charts && \ helm package bifrost && \ $(ECHO) "$(YELLOW)Regenerating index.yaml digest...$(NC)" && \ if [ -f index.yaml ]; then \ helm repo index . --url https://github.com/maximhq/bifrost/releases/download/helm-chart-v$$CHART_VERSION --merge index.yaml; \ else \ helm repo index . --url https://github.com/maximhq/bifrost/releases/download/helm-chart-v$$CHART_VERSION; \ fi && \ $(ECHO) "$(YELLOW)Removing bifrost-$$CHART_VERSION.tgz...$(NC)" && \ rm -f bifrost-$$CHART_VERSION.tgz && \ $(ECHO) "$(GREEN)Helm index updated$(NC)" generate-html-reports: ## Convert existing XML reports to HTML @if ! which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(RED)Error: junit-viewer not installed$(NC)"; \ $(ECHO) "$(YELLOW)Install with: make install-junit-viewer$(NC)"; \ exit 1; \ fi @$(ECHO) "$(GREEN)Converting XML reports to HTML...$(NC)" @if [ ! -d "$(TEST_REPORTS_DIR)" ] || [ -z "$$(ls -A $(TEST_REPORTS_DIR)/*.xml 2>/dev/null)" ]; then \ $(ECHO) "$(YELLOW)No XML reports found in $(TEST_REPORTS_DIR)$(NC)"; \ $(ECHO) "$(YELLOW)Run tests first: make test-all$(NC)"; \ exit 0; \ fi @for xml in $(TEST_REPORTS_DIR)/*.xml; do \ html=$${xml%.xml}.html; \ $(ECHO) " Converting $$(basename $$xml) → $$(basename $$html)"; \ junit-viewer --results=$$xml --save=$$html 2>/dev/null || true; \ done @$(ECHO) "" @$(ECHO) "$(GREEN)✓ HTML reports generated$(NC)" @$(ECHO) "$(CYAN)View reports:$(NC)" @ls -1 $(TEST_REPORTS_DIR)/*.html 2>/dev/null | sed 's|$(TEST_REPORTS_DIR)/| open $(TEST_REPORTS_DIR)/|' || true test: install-gotestsum ## Run tests for bifrost-http @$(ECHO) "$(GREEN)Running bifrost-http tests...$(NC)" @mkdir -p $(TEST_REPORTS_DIR) @cd transports/bifrost-http && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../$(TEST_REPORTS_DIR)/bifrost-http.xml \ -- -v ./... @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ if junit-viewer --results=$(TEST_REPORTS_DIR)/bifrost-http.xml --save=$(TEST_REPORTS_DIR)/bifrost-http.html 2>/dev/null; then \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $(TEST_REPORTS_DIR)/bifrost-http.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $(TEST_REPORTS_DIR)/bifrost-http.html$(NC)"; \ else \ $(ECHO) "$(YELLOW)HTML generation failed. JUnit XML report available.$(NC)"; \ $(ECHO) "$(CYAN)JUnit XML report: $(TEST_REPORTS_DIR)/bifrost-http.xml$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(YELLOW)junit-viewer not installed. Install with: make install-junit-viewer$(NC)"; \ $(ECHO) "$(CYAN)JUnit XML report: $(TEST_REPORTS_DIR)/bifrost-http.xml$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $(TEST_REPORTS_DIR)/bifrost-http.xml$(NC)"; \ fi test-core: install-gotestsum $(if $(DEBUG),install-delve) ## Run core tests (Usage: make test-core PROVIDER=openai TESTCASE=TestName or PATTERN=substring, DEBUG=1 for debugger) @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running core tests...$(NC)"; \ mkdir -p $(TEST_REPORTS_DIR); \ if [ -n "$(PATTERN)" ] && [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(RED)Error: PATTERN and TESTCASE are mutually exclusive$(NC)"; \ $(ECHO) "$(YELLOW)Use PATTERN for substring matching or TESTCASE for exact match$(NC)"; \ exit 1; \ fi; \ TEST_FAILED=0; \ REPORT_FILE=""; \ if [ -n "$(PROVIDER)" ]; then \ $(ECHO) "$(CYAN)Running tests for provider: $(PROVIDER)$(NC)"; \ if [ ! -f "core/providers/$(PROVIDER)/$(PROVIDER)_test.go" ]; then \ $(ECHO) "$(RED)Error: Provider test file '$(PROVIDER)_test.go' not found in core/providers/$(PROVIDER)/$(NC)"; \ $(ECHO) "$(YELLOW)Available providers:$(NC)"; \ find core/providers -name "*_test.go" -type f 2>/dev/null | sed 's|core/providers/\([^/]*\)/.*|\1|' | sort -u | sed 's/^/ - /'; \ exit 1; \ fi; \ fi; \ if [ -n "$(DEBUG)" ]; then \ $(ECHO) "$(CYAN)Debug mode enabled - delve debugger will listen on port 2345$(NC)"; \ $(ECHO) "$(YELLOW)Attach your debugger to localhost:2345$(NC)"; \ fi; \ if [ -n "$(PROVIDER)" ]; then \ PROVIDER_TEST_NAME=$$($(ECHO) "$(PROVIDER)" | awk '{print toupper(substr($$0,1,1)) tolower(substr($$0,2))}' | sed 's/openai/OpenAI/i; s/openrouter/OpenRouter/i; s/sgl/SGL/i; s/xai/XAI/i; s/vllm/VLLM/i; s/githubcopilot/GithubCopilot/i; s/sagemaker/SageMaker/i'); \ if [ -n "$(TESTCASE)" ]; then \ CLEAN_TESTCASE="$(TESTCASE)"; \ CLEAN_TESTCASE=$${CLEAN_TESTCASE#Test$${PROVIDER_TEST_NAME}/}; \ CLEAN_TESTCASE=$${CLEAN_TESTCASE#$${PROVIDER_TEST_NAME}Tests/}; \ CLEAN_TESTCASE=$$($(ECHO) "$$CLEAN_TESTCASE" | sed 's|^Test[A-Z][A-Za-z]*/[A-Z][A-Za-z]*Tests/||'); \ $(ECHO) "$(CYAN)Running Test$${PROVIDER_TEST_NAME}/$${PROVIDER_TEST_NAME}Tests/$$CLEAN_TESTCASE...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/core-$(PROVIDER)-$$(echo $$CLEAN_TESTCASE | sed 's|/|_|g').xml"; \ if [ -n "$(DEBUG)" ]; then \ cd core/providers/$(PROVIDER) && GOWORK=off dlv test --headless --listen=:2345 --api-version=2 -- -test.v -test.run "^Test$${PROVIDER_TEST_NAME}$$/.*Tests/$$CLEAN_TESTCASE$$" || TEST_FAILED=1; \ else \ cd core/providers/$(PROVIDER) && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$$REPORT_FILE \ -- -v -timeout 20m -run "^Test$${PROVIDER_TEST_NAME}$$/.*Tests/$$CLEAN_TESTCASE$$" || TEST_FAILED=1; \ fi; \ cd ../../..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ elif [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running tests matching '$(PATTERN)' for $${PROVIDER_TEST_NAME}...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/core-$(PROVIDER)-$(PATTERN).xml"; \ if [ -n "$(DEBUG)" ]; then \ cd core/providers/$(PROVIDER) && GOWORK=off dlv test --headless --listen=:2345 --api-version=2 -- -test.v -test.run ".*$(PATTERN).*" || TEST_FAILED=1; \ else \ cd core/providers/$(PROVIDER) && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$$REPORT_FILE \ -- -v -timeout 20m -run ".*$(PATTERN).*" || TEST_FAILED=1; \ fi; \ cd ../../..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) "$(CYAN)Running Test$${PROVIDER_TEST_NAME}...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/core-$(PROVIDER).xml"; \ if [ -n "$(DEBUG)" ]; then \ cd core/providers/$(PROVIDER) && GOWORK=off dlv test --headless --listen=:2345 --api-version=2 -- -test.v -test.run "^Test$${PROVIDER_TEST_NAME}$$" || TEST_FAILED=1; \ else \ cd core/providers/$(PROVIDER) && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$$REPORT_FILE \ -- -v -timeout 20m -run "^Test$${PROVIDER_TEST_NAME}$$" || TEST_FAILED=1; \ fi; \ cd ../../..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ fi; \ else \ if [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(RED)Error: TESTCASE requires PROVIDER to be specified$(NC)"; \ $(ECHO) "$(YELLOW)Usage: make test-core PROVIDER=openai TESTCASE=SpeechSynthesisStreamAdvanced/MultipleVoices_Streaming/StreamingVoice_echo$(NC)"; \ exit 1; \ fi; \ if [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running tests matching '$(PATTERN)' across core and all providers...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/core-all-$(PATTERN).xml"; \ if [ -n "$(DEBUG)" ]; then \ cd core && GOWORK=off dlv test --headless --listen=:2345 --api-version=2 . ./providers/... -- -test.v -test.run ".*$(PATTERN).*" || TEST_FAILED=1; \ else \ cd core && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../$$REPORT_FILE \ -- -v -timeout 20m -run ".*$(PATTERN).*" . ./providers/... || TEST_FAILED=1; \ fi; \ else \ REPORT_FILE="$(TEST_REPORTS_DIR)/core-all.xml"; \ if [ -n "$(DEBUG)" ]; then \ cd core && GOWORK=off dlv test --headless --listen=:2345 --api-version=2 ./providers/... -- -test.v || TEST_FAILED=1; \ else \ cd core && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../$$REPORT_FILE \ -- -v ./providers/... || TEST_FAILED=1; \ fi; \ fi; \ cd ..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ fi; \ if [ -f "$$REPORT_FILE" ]; then \ ALL_FAILED=$$(grep -B 1 '/dev/null | \ grep '/dev/null | \ grep ']*name="'"$$ESCAPED"'"[^>]*>.*?//gs' "$(REPORT_FILE).tmp" 2>/dev/null || true; \ fi; \ done; \ if [ -f "$(REPORT_FILE).tmp" ]; then \ mv "$(REPORT_FILE).tmp" "$(REPORT_FILE)"; \ fi; \ fi; \ fi test-plugins: install-gotestsum ## Run plugin tests @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running plugin tests...$(NC)"; \ mkdir -p $(TEST_REPORTS_DIR); \ cd plugins && find . -name "*.go" -path "*/tests/*" -o -name "*_test.go" | head -1 > /dev/null && \ for dir in $$(find . -name "*_test.go" -exec dirname {} \; | sort -u); do \ plugin_name=$$(echo $$dir | sed 's|^\./||' | sed 's|/|-|g'); \ $(ECHO) "Testing $$dir..."; \ cd $$dir && gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../$(TEST_REPORTS_DIR)/plugin-$$plugin_name.xml \ -- -v ./... && cd - > /dev/null; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report for $$plugin_name...$(NC)"; \ junit-viewer --results=../$(TEST_REPORTS_DIR)/plugin-$$plugin_name.xml --save=../$(TEST_REPORTS_DIR)/plugin-$$plugin_name.html 2>/dev/null || true; \ fi; \ fi; \ done || $(ECHO) "No plugin tests found" @$(ECHO) "" @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ $(ECHO) "$(CYAN)HTML reports saved to $(TEST_REPORTS_DIR)/plugin-*.html$(NC)"; \ else \ $(ECHO) "$(CYAN)JUnit XML reports saved to $(TEST_REPORTS_DIR)/plugin-*.xml$(NC)"; \ fi @$(MAKE) --no-print-directory print-test-summary \ SUMMARY_LABEL="Plugin" \ SUMMARY_STRIP="plugin-" \ SUMMARY_FILES="$(TEST_REPORTS_DIR)/plugin-*.xml" test-memory: install-gotestsum ## Run memory-regression tests (allocation scaling + coverage gate) @$(ECHO) "$(GREEN)Running memory regression tests...$(NC)" @$(ECHO) "$(CYAN)Asserts allocation grows with input size, not its square, and that every$(NC)" @$(ECHO) "$(CYAN)loop-bound raw-JSON mutation in core/providers has been classified.$(NC)" @mkdir -p $(TEST_REPORTS_DIR) @rc=0; \ for mod in core transports; do \ pkgs=$$(cd $$mod && grep -rl "_AllocationScaling\|_Retention" --include="*_test.go" . 2>/dev/null | xargs -n1 dirname | sort -u); \ if [ "$$mod" = "core" ]; then pkgs="./internal/memtest $$(echo "$$pkgs" | grep -v '^./internal/memtest$$')"; fi; \ [ -z "$$(echo $$pkgs)" ] && continue; \ $(ECHO) "$(CYAN)$$mod:$(NC)"; \ for p in $$pkgs; do $(ECHO) " $$p"; done; \ ( cd $$mod && gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=$(CURDIR)/$(TEST_REPORTS_DIR)/memory-$$mod.xml \ -- -timeout 15m -count=1 \ -run '_AllocationScaling|_Retention|TestEveryLoopBoundJSONMutationIsReviewed|TestCoveredSitesHaveARealTest' \ $$pkgs ) || rc=1; \ done; \ exit $$rc @$(ECHO) "" @$(ECHO) "$(CYAN)JUnit XML report saved to $(TEST_REPORTS_DIR)/memory-*.xml$(NC)" test-framework: install-gotestsum ## Run framework tests @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running framework tests...$(NC)"; \ mkdir -p $(TEST_REPORTS_DIR); \ rm -f $(TEST_REPORTS_DIR)/.framework-failed; \ cd framework && find . -name "*.go" -path "*/tests/*" -o -name "*_test.go" | head -1 > /dev/null && \ for dir in $$(find . -name "*_test.go" -exec dirname {} \; | sort -u); do \ pkg_name=$$(echo $$dir | sed 's|^\./||' | sed 's|/|-|g'); \ $(ECHO) "Testing $$dir..."; \ ( cd $$dir && gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=$(CURDIR)/$(TEST_REPORTS_DIR)/framework-$$pkg_name.xml \ -- -v -timeout 30m ./... ) || touch $(CURDIR)/$(TEST_REPORTS_DIR)/.framework-failed; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report for $$pkg_name...$(NC)"; \ junit-viewer --results=$(CURDIR)/$(TEST_REPORTS_DIR)/framework-$$pkg_name.xml --save=$(CURDIR)/$(TEST_REPORTS_DIR)/framework-$$pkg_name.html 2>/dev/null || true; \ fi; \ fi; \ done || $(ECHO) "No framework tests found" @$(ECHO) "" @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ $(ECHO) "$(CYAN)HTML reports saved to $(TEST_REPORTS_DIR)/framework-*.html$(NC)"; \ else \ $(ECHO) "$(CYAN)JUnit XML reports saved to $(TEST_REPORTS_DIR)/framework-*.xml$(NC)"; \ fi @$(MAKE) --no-print-directory print-test-summary \ SUMMARY_LABEL="Framework" \ SUMMARY_STRIP="framework-" \ SUMMARY_FILES="$(TEST_REPORTS_DIR)/framework-*.xml" @if [ -f $(TEST_REPORTS_DIR)/.framework-failed ]; then \ rm -f $(TEST_REPORTS_DIR)/.framework-failed; \ exit 1; \ fi # Internal: render a table of test reports + a final pass/fail scenario. # Usage: $(MAKE) print-test-summary SUMMARY_LABEL="Framework" SUMMARY_STRIP="framework-" SUMMARY_FILES="" # Each report becomes one row: tests/failures/errors come from the aggregate, # while skipped is summed from the per- attrs (the aggregate omits skipped). SUMMARY_SEP := -------------------------------------------------- print-test-summary: @$(ECHO) ""; \ $(ECHO) "$(CYAN)============================================================================$(NC)"; \ $(ECHO) "$(CYAN)$(SUMMARY_LABEL) Test Report Summary$(NC)"; \ $(ECHO) "$(CYAN)============================================================================$(NC)"; \ total_tests=0; total_pass=0; total_fail=0; total_err=0; total_skip=0; reports=0; \ printf "%-50s %7s %7s %7s %7s %7s\n" "REPORT" "TESTS" "PASS" "FAIL" "ERR" "SKIP"; \ printf "%-50s %7s %7s %7s %7s %7s\n" "$(SUMMARY_SEP)" "-------" "-------" "-------" "-------" "-------"; \ for xml in $(SUMMARY_FILES); do \ [ -e "$$xml" ] || continue; \ line=$$(grep -o ']*>' "$$xml" | head -1); \ t=$$(printf '%s' "$$line" | sed -n 's/.*[^a-z]tests="\([0-9]*\)".*/\1/p'); \ f=$$(printf '%s' "$$line" | sed -n 's/.*failures="\([0-9]*\)".*/\1/p'); \ e=$$(printf '%s' "$$line" | sed -n 's/.*errors="\([0-9]*\)".*/\1/p'); \ s=$$(grep -o ']*>' "$$xml" | grep -o 'skipped="[0-9]*"' | grep -o '[0-9]*' | awk '{x+=$$1} END{print x+0}'); \ t=$${t:-0}; f=$${f:-0}; e=$${e:-0}; s=$${s:-0}; \ p=$$((t - f - e - s)); \ name=$$(basename "$$xml" .xml | sed 's/^$(SUMMARY_STRIP)//'); \ reports=$$((reports + 1)); \ total_tests=$$((total_tests + t)); total_pass=$$((total_pass + p)); \ total_fail=$$((total_fail + f)); total_err=$$((total_err + e)); total_skip=$$((total_skip + s)); \ if [ $$((f + e)) -gt 0 ]; then color="$(RED)"; else color="$(GREEN)"; fi; \ printf "%b%-50s %7s %7s %7s %7s %7s%b\n" "$$color" "$$name" "$$t" "$$p" "$$f" "$$e" "$$s" "$(NC)"; \ done; \ printf "%-50s %7s %7s %7s %7s %7s\n" "$(SUMMARY_SEP)" "-------" "-------" "-------" "-------" "-------"; \ printf "%-50s %7s %7s %7s %7s %7s\n" "TOTAL ($$reports reports)" "$$total_tests" "$$total_pass" "$$total_fail" "$$total_err" "$$total_skip"; \ $(ECHO) "$(CYAN)============================================================================$(NC)"; \ if [ "$$reports" -eq 0 ]; then \ $(ECHO) "$(YELLOW)No $(SUMMARY_LABEL) test reports found$(NC)"; \ elif [ $$((total_fail + total_err)) -eq 0 ]; then \ $(ECHO) "$(GREEN)✓ ALL $(SUMMARY_LABEL) TESTS PASSED ($$total_pass/$$total_tests passed, $$total_skip skipped)$(NC)"; \ else \ $(ECHO) "$(RED)✗ $(SUMMARY_LABEL) TESTS FAILED ($$total_fail failures, $$total_err errors out of $$total_tests tests)$(NC)"; \ fi test-http-transport: install-gotestsum ## Run HTTP transport tests @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running HTTP transport tests...$(NC)"; \ mkdir -p $(TEST_REPORTS_DIR); \ cd transports/bifrost-http && find . -name "*.go" -path "*/tests/*" -o -name "*_test.go" | head -1 > /dev/null && \ for dir in $$(find . -name "*_test.go" -exec dirname {} \; | sort -u); do \ pkg_name=$$(echo $$dir | sed 's|^\./||' | sed 's|/|-|g'); \ $(ECHO) "Testing $$dir..."; \ cd $$dir && gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$(TEST_REPORTS_DIR)/http-transport-$$pkg_name.xml \ -- -v ./... && cd - > /dev/null; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report for $$pkg_name...$(NC)"; \ junit-viewer --results=../../$(TEST_REPORTS_DIR)/http-transport-$$pkg_name.xml --save=../../$(TEST_REPORTS_DIR)/http-transport-$$pkg_name.html 2>/dev/null || true; \ fi; \ fi; \ done || $(ECHO) "No HTTP transport tests found" @$(ECHO) "" @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ $(ECHO) "$(CYAN)HTML reports saved to $(TEST_REPORTS_DIR)/http-transport-*.html$(NC)"; \ else \ $(ECHO) "$(CYAN)JUnit XML reports saved to $(TEST_REPORTS_DIR)/http-transport-*.xml$(NC)"; \ fi test-governance: install-gotestsum $(if $(DEBUG),install-delve) ## Run governance tests (Usage: make test-governance TESTCASE=TestName or PATTERN=substring, DEBUG=1 for debugger) @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running governance tests...$(NC)"; \ mkdir -p $(TEST_REPORTS_DIR); \ if [ -n "$(PATTERN)" ] && [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(RED)Error: PATTERN and TESTCASE are mutually exclusive$(NC)"; \ $(ECHO) "$(YELLOW)Use PATTERN for substring matching or TESTCASE for exact match$(NC)"; \ exit 1; \ fi; \ if [ ! -d "tests/governance" ]; then \ $(ECHO) "$(RED)Error: Governance tests directory not found$(NC)"; \ exit 1; \ fi; \ TEST_FAILED=0; \ REPORT_FILE=""; \ if [ -n "$(DEBUG)" ]; then \ $(ECHO) "$(CYAN)Debug mode enabled - delve debugger will listen on port 2345$(NC)"; \ $(ECHO) "$(YELLOW)Attach your debugger to localhost:2345$(NC)"; \ fi; \ if [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(CYAN)Running test case: $(TESTCASE)$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/governance-$$(echo $(TESTCASE) | sed 's|/|_|g').xml"; \ if [ -n "$(DEBUG)" ]; then \ cd tests/governance && GOWORK=off dlv test --headless --listen=:2345 --api-version=2 -- -test.v -test.run "^$(TESTCASE)$$" || TEST_FAILED=1; \ else \ cd tests/governance && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../$$REPORT_FILE \ -- -v -run "^$(TESTCASE)$$" || TEST_FAILED=1; \ fi; \ cd ../..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ elif [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running tests matching '$(PATTERN)'...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/governance-$(PATTERN).xml"; \ if [ -n "$(DEBUG)" ]; then \ cd tests/governance && GOWORK=off dlv test --headless --listen=:2345 --api-version=2 -- -test.v -test.run ".*$(PATTERN).*" || TEST_FAILED=1; \ else \ cd tests/governance && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../$$REPORT_FILE \ -- -v -run ".*$(PATTERN).*" || TEST_FAILED=1; \ fi; \ cd ../..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) "$(CYAN)Running all governance tests...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/governance-all.xml"; \ if [ -n "$(DEBUG)" ]; then \ cd tests/governance && GOWORK=off dlv test --headless --listen=:2345 --api-version=2 -- -test.v || TEST_FAILED=1; \ else \ cd tests/governance && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../$$REPORT_FILE \ -- -v || TEST_FAILED=1; \ fi; \ cd ../..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ fi; \ if [ -f "$$REPORT_FILE" ]; then \ ALL_FAILED=$$(grep -B 1 '/dev/null | \ grep ' /dev/null; then \ $(ECHO) "$(GREEN) ✓ $$mcp_name$(NC)"; \ else \ $(ECHO) "$(RED) ✗ $$mcp_name failed$(NC)"; \ FAILED=1; \ cd - > /dev/null 2>&1 || true; \ fi; \ elif [ -f "$$mcp_dir/package.json" ]; then \ $(ECHO) "$(CYAN)Building $$mcp_name (TypeScript)...$(NC)"; \ if cd "$$mcp_dir" && npm install --silent && npm run build && cd - > /dev/null; then \ $(ECHO) "$(GREEN) ✓ $$mcp_name$(NC)"; \ else \ $(ECHO) "$(RED) ✗ $$mcp_name failed$(NC)"; \ FAILED=1; \ cd - > /dev/null 2>&1 || true; \ fi; \ fi; \ fi; \ done; \ if [ $$FAILED -eq 1 ]; then \ $(ECHO) "$(RED)Some MCP test servers failed to build$(NC)"; \ exit 1; \ fi @$(ECHO) "" @$(ECHO) "$(GREEN)✓ All MCP test servers built$(NC)" test-mcp: install-gotestsum setup-mcp-tests ## Run MCP tests (Usage: make test-mcp [TYPE=connection] [TESTCASE=TestName] [PATTERN=substring]) @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running MCP tests...$(NC)"; \ mkdir -p $(TEST_REPORTS_DIR); \ if [ -n "$(PATTERN)" ] && [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(RED)Error: PATTERN and TESTCASE are mutually exclusive$(NC)"; \ $(ECHO) "$(YELLOW)Use PATTERN for substring matching or TESTCASE for exact match$(NC)"; \ exit 1; \ fi; \ if [ ! -d "core/internal/mcptests" ]; then \ $(ECHO) "$(RED)Error: MCP tests directory not found$(NC)"; \ exit 1; \ fi; \ TEST_FAILED=0; \ REPORT_FILE=""; \ if [ -n "$(TYPE)" ]; then \ TYPE_CLEAN=$$(echo $(TYPE) | sed 's/_test\.go$$//'); \ TEST_FILE="core/internal/mcptests/$${TYPE_CLEAN}_test.go"; \ if [ ! -f "$$TEST_FILE" ]; then \ $(ECHO) "$(RED)Error: Test file '$$TEST_FILE' not found$(NC)"; \ $(ECHO) "$(YELLOW)Available test types:$(NC)"; \ ls -1 core/internal/mcptests/*_test.go 2>/dev/null | sed 's|core/internal/mcptests/||' | sed 's|_test\.go$$||' | sed 's/^/ - /'; \ exit 1; \ fi; \ TEST_PATTERN=$$(grep -h "^func Test" $$TEST_FILE 2>/dev/null | sed 's/func \(Test[^(]*\).*/\1/' | paste -sd '|' - || $(ECHO) "^Test"); \ if [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(CYAN)Running $(TYPE) test: $(TESTCASE)...$(NC)"; \ SAFE_TESTCASE=$$($(ECHO) "$(TESTCASE)" | sed 's|/|_|g'); \ REPORT_FILE="$(TEST_REPORTS_DIR)/mcp-$(TYPE)-$$SAFE_TESTCASE.xml"; \ cd core/internal/mcptests && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$$REPORT_FILE \ -- -v -race -run "^$(TESTCASE)$$" . || TEST_FAILED=1; \ elif [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running $(TYPE) tests matching '$(PATTERN)'...$(NC)"; \ SAFE_PATTERN=$$($(ECHO) "$(PATTERN)" | sed 's|/|_|g'); \ REPORT_FILE="$(TEST_REPORTS_DIR)/mcp-$(TYPE)-$$SAFE_PATTERN.xml"; \ cd core/internal/mcptests && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$$REPORT_FILE \ -- -v -race -run ".*$(PATTERN).*" . || TEST_FAILED=1; \ else \ $(ECHO) "$(CYAN)Running all $(TYPE) tests (pattern: $$TEST_PATTERN)...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/mcp-$(TYPE).xml"; \ cd core/internal/mcptests && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$$REPORT_FILE \ -- -v -race -run "$$TEST_PATTERN" . || TEST_FAILED=1; \ fi; \ cd ../../..; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ if [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(CYAN)Running test case: $(TESTCASE) across all MCP tests...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/mcp-all-$(TESTCASE).xml"; \ cd core/internal/mcptests && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$$REPORT_FILE \ -- -v -race -run "^$(TESTCASE)$$" || TEST_FAILED=1; \ elif [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running tests matching '$(PATTERN)' across all MCP tests...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/mcp-all-$(PATTERN).xml"; \ cd core/internal/mcptests && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$$REPORT_FILE \ -- -v -race -run ".*$(PATTERN).*" || TEST_FAILED=1; \ else \ $(ECHO) "$(CYAN)Running all MCP tests...$(NC)"; \ REPORT_FILE="$(TEST_REPORTS_DIR)/mcp-all.xml"; \ cd core/internal/mcptests && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../../$$REPORT_FILE \ -- -v -race || TEST_FAILED=1; \ fi; \ cd ../../..; \ if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ if which junit-viewer > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Generating HTML report...$(NC)"; \ junit-viewer --results=$$REPORT_FILE --save=$${REPORT_FILE%.xml}.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML report: $${REPORT_FILE%.xml}.html$(NC)"; \ $(ECHO) "$(CYAN)Open with: open $${REPORT_FILE%.xml}.html$(NC)"; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ else \ $(ECHO) ""; \ $(ECHO) "$(CYAN)JUnit XML report: $$REPORT_FILE$(NC)"; \ fi; \ fi; \ if [ $$TEST_FAILED -eq 1 ]; then \ exit 1; \ fi test-all: test-core test-framework test-plugins test-http-transport test test-cli ## Run all tests @$(ECHO) "" @$(ECHO) "$(GREEN)═══════════════════════════════════════════════════════════$(NC)" @$(ECHO) "$(GREEN) All Tests Complete - Summary $(NC)" @$(ECHO) "$(GREEN)═══════════════════════════════════════════════════════════$(NC)" @$(ECHO) "" @if [ -z "$$CI" ] && [ -z "$$GITHUB_ACTIONS" ] && [ -z "$$GITLAB_CI" ] && [ -z "$$CIRCLECI" ] && [ -z "$$JENKINS_HOME" ]; then \ $(ECHO) "$(YELLOW)Generating combined HTML report...$(NC)"; \ junit-viewer --results=$(TEST_REPORTS_DIR) --save=$(TEST_REPORTS_DIR)/index.html 2>/dev/null || true; \ $(ECHO) ""; \ $(ECHO) "$(CYAN)HTML reports available in $(TEST_REPORTS_DIR)/:$(NC)"; \ ls -1 $(TEST_REPORTS_DIR)/*.html 2>/dev/null | sed 's/^/ ✓ /' || $(ECHO) " No reports found"; \ $(ECHO) ""; \ $(ECHO) "$(YELLOW)📊 View all test results:$(NC)"; \ $(ECHO) "$(CYAN) open $(TEST_REPORTS_DIR)/index.html$(NC)"; \ $(ECHO) ""; \ $(ECHO) "$(YELLOW)Or view individual reports:$(NC)"; \ ls -1 $(TEST_REPORTS_DIR)/*.html 2>/dev/null | grep -v index.html | sed 's|$(TEST_REPORTS_DIR)/| open $(TEST_REPORTS_DIR)/|' || true; \ $(ECHO) ""; \ else \ $(ECHO) "$(CYAN)JUnit XML reports available in $(TEST_REPORTS_DIR)/:$(NC)"; \ ls -1 $(TEST_REPORTS_DIR)/*.xml 2>/dev/null | sed 's/^/ ✓ /' || $(ECHO) " No reports found"; \ $(ECHO) ""; \ fi test-semantic-cache: ## Run semantic_cache e2e tests (Usage: [CACHE_TYPE=direct|semantic] [RUN_FORCE=0] make test-semantic-cache). RUN_FORCE defaults to 1. Auto-detects trail CLI and wraps the run when present. @cd tests/semanticcache && \ case "$$CACHE_TYPE" in \ direct) \ filter='^(TestPreconditions|TestDirect|TestLifecycle)$$'; \ $(ECHO) "$(CYAN)CACHE_TYPE=direct → running preconditions + direct + lifecycle$(NC)"; \ ;; \ semantic) \ filter='^(TestPreconditions|TestParaphraseFixtures|TestSemantic|TestLifecycle)$$'; \ $(ECHO) "$(CYAN)CACHE_TYPE=semantic → running preconditions + fixtures + semantic + lifecycle$(NC)"; \ ;; \ '') \ filter=''; \ $(ECHO) "$(CYAN)CACHE_TYPE unset → running all phases$(NC)"; \ ;; \ *) \ $(ECHO) "$(RED)CACHE_TYPE=$$CACHE_TYPE invalid; expected 'direct', 'semantic', or unset$(NC)"; \ exit 1; \ ;; \ esac; \ if command -v trail >/dev/null 2>&1; then \ $(ECHO) "$(GREEN)trail detected — wrapping run in 'trail run' (session id will be printed by trail)$(NC)"; \ if [ -n "$$filter" ]; then \ exec trail run -- env RUN_FORCE=$${RUN_FORCE:-1} GOWORK=off go test -v -run "$$filter" ./...; \ else \ exec trail run -- env RUN_FORCE=$${RUN_FORCE:-1} GOWORK=off go test -v ./...; \ fi; \ else \ $(ECHO) "$(YELLOW)trail not on PATH — falling back to direct go test (install 'trail' for capture-based debugging)$(NC)"; \ if [ -n "$$filter" ]; then \ exec env RUN_FORCE=$${RUN_FORCE:-1} GOWORK=off go test -v -run "$$filter" ./...; \ else \ exec env RUN_FORCE=$${RUN_FORCE:-1} GOWORK=off go test -v ./...; \ fi; \ fi test-semantic-cache-complete: ## Run BOTH plugin unit tests + e2e tests for semantic_cache. RUN_FORCE defaults to 1. Wraps everything in trail if available. @if command -v trail >/dev/null 2>&1; then \ $(ECHO) "$(GREEN)trail detected — wrapping unit + e2e tests in a single trail session (id printed by trail)$(NC)"; \ exec trail run -- $(MAKE) _test-semantic-cache-complete-inner; \ else \ $(ECHO) "$(YELLOW)trail not on PATH — running tests directly (install 'trail' for capture-based debugging)$(NC)"; \ $(MAKE) _test-semantic-cache-complete-inner; \ fi _test-semantic-cache-complete-inner: @$(ECHO) "" @$(ECHO) "$(CYAN)═══════════════════════════════════════════════════════════$(NC)" @$(ECHO) "$(CYAN) Running semantic_cache plugin UNIT tests $(NC)" @$(ECHO) "$(CYAN)═══════════════════════════════════════════════════════════$(NC)" @cd plugins/semanticcache && go test -v ./... @$(ECHO) "" @$(ECHO) "$(GREEN)═══════════════════════════════════════════════════════════$(NC)" @$(ECHO) "$(GREEN) Unit tests completed $(NC)" @$(ECHO) "$(GREEN)═══════════════════════════════════════════════════════════$(NC)" @$(ECHO) "" @$(ECHO) "$(CYAN)═══════════════════════════════════════════════════════════$(NC)" @$(ECHO) "$(CYAN) Running semantic_cache E2E tests $(NC)" @$(ECHO) "$(CYAN)═══════════════════════════════════════════════════════════$(NC)" @cd tests/semanticcache && RUN_FORCE=$${RUN_FORCE:-1} GOWORK=off go test -v ./... @$(ECHO) "" @$(ECHO) "$(GREEN)═══════════════════════════════════════════════════════════$(NC)" @$(ECHO) "$(GREEN) E2E tests completed $(NC)" @$(ECHO) "$(GREEN)═══════════════════════════════════════════════════════════$(NC)" test-chatbot: ## Run interactive chatbot integration test (Usage: RUN_CHATBOT_TEST=1 make test-chatbot) @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running interactive chatbot integration test...$(NC)"; \ if [ -z "$(RUN_CHATBOT_TEST)" ]; then \ $(ECHO) "$(YELLOW)⚠️ This is an interactive test. Set RUN_CHATBOT_TEST=1 to run it.$(NC)"; \ $(ECHO) "$(CYAN)Usage: RUN_CHATBOT_TEST=1 make test-chatbot$(NC)"; \ $(ECHO) ""; \ $(ECHO) "$(YELLOW)Required environment variables:$(NC)"; \ $(ECHO) " - OPENAI_API_KEY (required)"; \ $(ECHO) " - ANTHROPIC_API_KEY (optional)"; \ $(ECHO) " - Additional provider keys as needed"; \ exit 0; \ fi; \ cd core && RUN_CHATBOT_TEST=1 go test -v -run TestChatbot test-integrations-py: ## Run Python integration tests (Usage: make test-integrations-py [INTEGRATION=openai] [TESTCASE=test_name] [PATTERN=substring] [VERBOSE=1]) @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running Python integration tests...$(NC)"; \ if [ ! -d "tests/integrations/python" ]; then \ $(ECHO) "$(RED)Error: tests/integrations/python directory not found$(NC)"; \ exit 1; \ fi; \ if [ -n "$(PATTERN)" ] && [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(RED)Error: PATTERN and TESTCASE are mutually exclusive$(NC)"; \ $(ECHO) "$(YELLOW)Use PATTERN for substring matching or TESTCASE for exact match$(NC)"; \ exit 1; \ fi; \ if [ -n "$(TESTCASE)" ] && [ -z "$(INTEGRATION)" ]; then \ $(ECHO) "$(RED)Error: TESTCASE requires INTEGRATION to be specified$(NC)"; \ $(ECHO) "$(YELLOW)Usage: make test-integrations-py INTEGRATION=anthropic TESTCASE=test_05_end2end_tool_calling$(NC)"; \ exit 1; \ fi; \ BIFROST_STARTED=0; \ BIFROST_PID=""; \ TAIL_PID=""; \ TEST_PORT=$${PORT:-8080}; \ TEST_HOST=$${HOST:-localhost}; \ $(ECHO) "$(CYAN)Checking if Bifrost is running on $$TEST_HOST:$$TEST_PORT...$(NC)"; \ if curl -s -o /dev/null -w "%{http_code}" http://$$TEST_HOST:$$TEST_PORT/health 2>/dev/null | grep -q "200\|404"; then \ $(ECHO) "$(GREEN)✓ Bifrost is already running$(NC)"; \ else \ $(ECHO) "$(YELLOW)Bifrost not running, starting it...$(NC)"; \ ./tmp/bifrost-http -host "$$TEST_HOST" -port "$$TEST_PORT" -log-style "$(LOG_STYLE)" -log-level "$(LOG_LEVEL)" -app-dir tests/integrations/python > /tmp/bifrost-test.log 2>&1 & \ BIFROST_PID=$$!; \ BIFROST_STARTED=1; \ $(ECHO) "$(YELLOW)Waiting for Bifrost to be ready...$(NC)"; \ $(ECHO) "$(CYAN)Bifrost logs: /tmp/bifrost-test.log$(NC)"; \ (tail -f /tmp/bifrost-test.log 2>/dev/null | grep -E "error|panic|Error|ERRO|fatal|Fatal|FATAL" --line-buffered &) & \ TAIL_PID=$$!; \ for i in $$(seq 1 30); do \ if curl -s -o /dev/null http://$$TEST_HOST:$$TEST_PORT/health 2>/dev/null; then \ $(ECHO) "$(GREEN)✓ Bifrost is ready (PID: $$BIFROST_PID)$(NC)"; \ break; \ fi; \ if [ $$i -eq 10 ]; then \ $(ECHO) "$(RED)Failed to start Bifrost$(NC)"; \ $(ECHO) "$(YELLOW)Bifrost logs:$(NC)"; \ cat /tmp/bifrost-test.log 2>/dev/null || $(ECHO) "No log file found"; \ [ -n "$$BIFROST_PID" ] && kill $$BIFROST_PID 2>/dev/null; \ [ -n "$$TAIL_PID" ] && kill $$TAIL_PID 2>/dev/null; \ exit 1; \ fi; \ sleep 1; \ done; \ fi; \ TEST_FAILED=0; \ if ! which uv > /dev/null 2>&1; then \ $(ECHO) "$(YELLOW)uv not found, checking for pytest...$(NC)"; \ if ! which pytest > /dev/null 2>&1; then \ $(ECHO) "$(RED)Error: Neither uv nor pytest found$(NC)"; \ $(ECHO) "$(YELLOW)Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh$(NC)"; \ $(ECHO) "$(YELLOW)Or install pytest: pip install pytest$(NC)"; \ [ $$BIFROST_STARTED -eq 1 ] && [ -n "$$BIFROST_PID" ] && kill $$BIFROST_PID 2>/dev/null; \ [ -n "$$TAIL_PID" ] && kill $$TAIL_PID 2>/dev/null; \ exit 1; \ fi; \ $(ECHO) "$(CYAN)Using pytest directly$(NC)"; \ if [ -n "$(INTEGRATION)" ]; then \ if [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(CYAN)Running $(INTEGRATION) integration test: $(TESTCASE)...$(NC)"; \ cd tests/integrations/python && \ if [[ "$(TESTCASE)" == *::* ]]; then \ pytest tests/test_$(INTEGRATION).py::$(TESTCASE) $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ else \ pytest tests/test_$(INTEGRATION).py -k "$(TESTCASE)" $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ fi; \ elif [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running $(INTEGRATION) integration tests matching '$(PATTERN)'...$(NC)"; \ cd tests/integrations/python && pytest tests/test_$(INTEGRATION).py -k "$(PATTERN)" $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ else \ $(ECHO) "$(CYAN)Running $(INTEGRATION) integration tests...$(NC)"; \ cd tests/integrations/python && pytest tests/test_$(INTEGRATION).py $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ fi; \ else \ if [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running all integration tests matching '$(PATTERN)'...$(NC)"; \ cd tests/integrations/python && pytest -k "$(PATTERN)" $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ else \ $(ECHO) "$(CYAN)Running all integration tests...$(NC)"; \ cd tests/integrations/python && pytest $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ fi; \ fi; \ else \ $(ECHO) "$(CYAN)Using uv (fast mode)$(NC)"; \ cd tests/integrations/python && \ if [ -n "$(INTEGRATION)" ]; then \ if [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(CYAN)Running $(INTEGRATION) integration test: $(TESTCASE)...$(NC)"; \ if [[ "$(TESTCASE)" == *::* ]]; then \ uv run --frozen pytest tests/test_$(INTEGRATION).py::$(TESTCASE) $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ else \ uv run --frozen pytest tests/test_$(INTEGRATION).py -k "$(TESTCASE)" $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ fi; \ elif [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running $(INTEGRATION) integration tests matching '$(PATTERN)'...$(NC)"; \ uv run --frozen pytest tests/test_$(INTEGRATION).py -k "$(PATTERN)" $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ else \ $(ECHO) "$(CYAN)Running $(INTEGRATION) integration tests...$(NC)"; \ uv run --frozen pytest tests/test_$(INTEGRATION).py $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ fi; \ else \ if [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running all integration tests matching '$(PATTERN)'...$(NC)"; \ uv run --frozen pytest -k "$(PATTERN)" $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ else \ $(ECHO) "$(CYAN)Running all integration tests...$(NC)"; \ uv run --frozen pytest $(if $(VERBOSE),-v,-q) || TEST_FAILED=1; \ fi; \ fi; \ fi; \ if [ $$BIFROST_STARTED -eq 1 ] && [ -n "$$BIFROST_PID" ]; then \ $(ECHO) "$(YELLOW)Stopping Bifrost (PID: $$BIFROST_PID)...$(NC)"; \ kill $$BIFROST_PID 2>/dev/null || true; \ [ -n "$$TAIL_PID" ] && kill $$TAIL_PID 2>/dev/null || true; \ wait $$BIFROST_PID 2>/dev/null || true; \ $(ECHO) "$(GREEN)✓ Bifrost stopped$(NC)"; \ if [ $$TEST_FAILED -eq 1 ]; then \ $(ECHO) ""; \ $(ECHO) "$(YELLOW)Last 50 lines of Bifrost logs:$(NC)"; \ tail -50 /tmp/bifrost-test.log 2>/dev/null || $(ECHO) "No log file found"; \ fi; \ fi; \ $(ECHO) ""; \ if [ $$TEST_FAILED -eq 1 ]; then \ $(ECHO) "$(RED)✗ Integration tests failed$(NC)"; \ $(ECHO) "$(CYAN)Full Bifrost logs: /tmp/bifrost-test.log$(NC)"; \ exit 1; \ else \ $(ECHO) "$(GREEN)✓ Integration tests complete$(NC)"; \ fi test-integrations-ts: ## Run TypeScript integration tests (Usage: make test-integrations-ts [INTEGRATION=openai] [TESTCASE=test_name] [PATTERN=substring] [VERBOSE=1]) @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running TypeScript integration tests...$(NC)"; \ if [ ! -d "tests/integrations/typescript" ]; then \ $(ECHO) "$(RED)Error: tests/integrations/typescript directory not found$(NC)"; \ exit 1; \ fi; \ if [ -n "$(PATTERN)" ] && [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(RED)Error: PATTERN and TESTCASE are mutually exclusive$(NC)"; \ $(ECHO) "$(YELLOW)Use PATTERN for substring matching or TESTCASE for exact match$(NC)"; \ exit 1; \ fi; \ if [ -n "$(TESTCASE)" ] && [ -z "$(INTEGRATION)" ]; then \ $(ECHO) "$(RED)Error: TESTCASE requires INTEGRATION to be specified$(NC)"; \ $(ECHO) "$(YELLOW)Usage: make test-integrations-ts INTEGRATION=openai TESTCASE=test_simple_chat$(NC)"; \ exit 1; \ fi; \ BIFROST_STARTED=0; \ BIFROST_PID=""; \ TAIL_PID=""; \ TEST_PORT=$${PORT:-8080}; \ TEST_HOST=$${HOST:-localhost}; \ $(ECHO) "$(CYAN)Checking if Bifrost is running on $$TEST_HOST:$$TEST_PORT...$(NC)"; \ if curl -s -o /dev/null -w "%{http_code}" http://$$TEST_HOST:$$TEST_PORT/health 2>/dev/null | grep -q "200\|404"; then \ $(ECHO) "$(GREEN)✓ Bifrost is already running$(NC)"; \ else \ $(ECHO) "$(YELLOW)Bifrost not running, starting it...$(NC)"; \ ./tmp/bifrost-http -host "$$TEST_HOST" -port "$$TEST_PORT" -log-style "$(LOG_STYLE)" -log-level "$(LOG_LEVEL)" -app-dir tests/integrations/typescript > /tmp/bifrost-test.log 2>&1 & \ BIFROST_PID=$$!; \ BIFROST_STARTED=1; \ $(ECHO) "$(YELLOW)Waiting for Bifrost to be ready...$(NC)"; \ $(ECHO) "$(CYAN)Bifrost logs: /tmp/bifrost-test.log$(NC)"; \ (tail -f /tmp/bifrost-test.log 2>/dev/null | grep -E "error|panic|Error|ERRO|fatal|Fatal|FATAL" --line-buffered &) & \ TAIL_PID=$$!; \ for i in $$(seq 1 30); do \ if curl -s -o /dev/null http://$$TEST_HOST:$$TEST_PORT/health 2>/dev/null; then \ $(ECHO) "$(GREEN)✓ Bifrost is ready (PID: $$BIFROST_PID)$(NC)"; \ break; \ fi; \ if [ $$i -eq 10 ]; then \ $(ECHO) "$(RED)Failed to start Bifrost$(NC)"; \ $(ECHO) "$(YELLOW)Bifrost logs:$(NC)"; \ cat /tmp/bifrost-test.log 2>/dev/null || $(ECHO) "No log file found"; \ [ -n "$$BIFROST_PID" ] && kill $$BIFROST_PID 2>/dev/null; \ [ -n "$$TAIL_PID" ] && kill $$TAIL_PID 2>/dev/null; \ exit 1; \ fi; \ sleep 1; \ done; \ fi; \ TEST_FAILED=0; \ $(USE_NODE); \ if ! which npm > /dev/null 2>&1; then \ $(ECHO) "$(RED)Error: npm not found$(NC)"; \ $(ECHO) "$(YELLOW)Install Node.js: https://nodejs.org/$(NC)"; \ [ $$BIFROST_STARTED -eq 1 ] && [ -n "$$BIFROST_PID" ] && kill $$BIFROST_PID 2>/dev/null; \ [ -n "$$TAIL_PID" ] && kill $$TAIL_PID 2>/dev/null; \ exit 1; \ fi; \ $(ECHO) "$(CYAN)Using npm$(NC)"; \ cd tests/integrations/typescript && \ if [ ! -d "node_modules" ]; then \ $(ECHO) "$(YELLOW)Installing dependencies...$(NC)"; \ npm install; \ fi; \ if [ -n "$(INTEGRATION)" ]; then \ if [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(CYAN)Running $(INTEGRATION) integration test: $(TESTCASE)...$(NC)"; \ npm test -- tests/test-$(INTEGRATION).test.ts -t "$(TESTCASE)" $(if $(VERBOSE),--reporter=verbose,) || TEST_FAILED=1; \ elif [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running $(INTEGRATION) integration tests matching '$(PATTERN)'...$(NC)"; \ npm test -- tests/test-$(INTEGRATION).test.ts -t "$(PATTERN)" $(if $(VERBOSE),--reporter=verbose,) || TEST_FAILED=1; \ else \ $(ECHO) "$(CYAN)Running $(INTEGRATION) integration tests...$(NC)"; \ npm test -- tests/test-$(INTEGRATION).test.ts $(if $(VERBOSE),--reporter=verbose,) || TEST_FAILED=1; \ fi; \ else \ if [ -n "$(PATTERN)" ]; then \ $(ECHO) "$(CYAN)Running all integration tests matching '$(PATTERN)'...$(NC)"; \ npm test -- -t "$(PATTERN)" $(if $(VERBOSE),--reporter=verbose,) || TEST_FAILED=1; \ else \ $(ECHO) "$(CYAN)Running all integration tests...$(NC)"; \ npm test $(if $(VERBOSE),-- --reporter=verbose,) || TEST_FAILED=1; \ fi; \ fi; \ if [ $$BIFROST_STARTED -eq 1 ] && [ -n "$$BIFROST_PID" ]; then \ $(ECHO) "$(YELLOW)Stopping Bifrost (PID: $$BIFROST_PID)...$(NC)"; \ kill $$BIFROST_PID 2>/dev/null || true; \ [ -n "$$TAIL_PID" ] && kill $$TAIL_PID 2>/dev/null || true; \ wait $$BIFROST_PID 2>/dev/null || true; \ $(ECHO) "$(GREEN)✓ Bifrost stopped$(NC)"; \ if [ $$TEST_FAILED -eq 1 ]; then \ $(ECHO) ""; \ $(ECHO) "$(YELLOW)Last 50 lines of Bifrost logs:$(NC)"; \ tail -50 /tmp/bifrost-test.log 2>/dev/null || $(ECHO) "No log file found"; \ fi; \ fi; \ $(ECHO) ""; \ if [ $$TEST_FAILED -eq 1 ]; then \ $(ECHO) "$(RED)✗ TypeScript integration tests failed$(NC)"; \ $(ECHO) "$(CYAN)Full Bifrost logs: /tmp/bifrost-test.log$(NC)"; \ exit 1; \ else \ $(ECHO) "$(GREEN)✓ TypeScript integration tests complete$(NC)"; \ fi test-integrations: ## Run Python + TypeScript SDK integration tests in parallel, one process per test file, against one shared gateway (Usage: make test-integrations [JOBS=30] [INTEGRATION=openai] [BUILD=1] [PORT=8080]) @: "Delegates to the CI script rather than reimplementing the fan-out. That script already"; \ : "throttles both suites against one gateway, group-kills descendants on cancellation, and"; \ : "replays each file's buffered log in order - see .github/workflows/scripts/test-integrations.sh."; \ : ""; \ : "Bash check first, before EXPOSE_ENV: an unusable shell should cost milliseconds rather"; \ : "than an Infisical round trip. This recipe shell is the same /usr/bin/env bash the"; \ : "script's shebang resolves, so BASH_VERSINFO here is what the script will see. The script"; \ : "rejects old shells too; this only adds the remedy and names the sequential fallback."; \ if [ "$${BASH_VERSINFO[0]}" -lt 5 ] || { [ "$${BASH_VERSINFO[0]}" -eq 5 ] && [ "$${BASH_VERSINFO[1]}" -lt 1 ]; }; then \ $(ECHO) "$(RED)Error: bash 5.1+ required (the fan-out uses 'wait -f -n -p'), found $$BASH_VERSION$(NC)"; \ $(ECHO) "$(YELLOW)macOS ships bash 3.2. Install a newer one and put it ahead of /bin in PATH:$(NC)"; \ $(ECHO) "$(CYAN) brew install bash$(NC)"; \ $(ECHO) "$(YELLOW)Or run the suites sequentially: make test-integrations-py && make test-integrations-ts$(NC)"; \ exit 1; \ fi; \ SCRIPT=.github/workflows/scripts/test-integrations.sh; \ if [ ! -x "$$SCRIPT" ]; then \ $(ECHO) "$(RED)Error: $$SCRIPT is missing or not executable$(NC)"; \ exit 1; \ fi; \ $(EXPOSE_ENV); \ $(USE_NODE); \ TEST_HOST="$${HOST:-localhost}"; \ TEST_PORT="$${PORT:-8080}"; \ : "Gateway: reuse one that is already serving this port, matching test-integrations-py"; \ : "and -ts. The script cannot work this out for itself - it starts its own gateway"; \ : "unconditionally, and its readiness loop curls /health before checking whether its own"; \ : "child survived, so an existing server would answer for it while the real child died"; \ : "unable to bind. SKIP_GATEWAY_START=1 makes that explicit instead of accidental, and"; \ : "leaves the reused server running at the end - we did not start it."; \ : ""; \ : "Probed after EXPOSE_ENV because .env may set PORT."; \ REUSING=0; \ if curl -sf --connect-timeout 2 --max-time 5 "http://$$TEST_HOST:$$TEST_PORT/health" > /dev/null 2>&1; then \ REUSING=1; \ export SKIP_GATEWAY_START=1; \ $(ECHO) "$(GREEN)Reusing the gateway already serving http://$$TEST_HOST:$$TEST_PORT$(NC)"; \ $(ECHO) "$(YELLOW) Its config is whatever that server was started with. These suites expect$(NC)"; \ $(ECHO) "$(YELLOW) -app-dir tests/integrations/python (35 providers plus the vk-test virtual$(NC)"; \ $(ECHO) "$(YELLOW) key); started elsewhere, expect config-shaped failures.$(NC)"; \ elif command -v nc > /dev/null 2>&1 && nc -z -w 1 "$$TEST_HOST" "$$TEST_PORT" 2>/dev/null; then \ : "Listening but not answering /health - not a gateway, and we cannot bind either."; \ $(ECHO) "$(RED)Error: $$TEST_HOST:$$TEST_PORT is in use but does not answer /health$(NC)"; \ $(ECHO) "$(YELLOW)Something that is not a Bifrost gateway holds the port, and this target$(NC)"; \ $(ECHO) "$(YELLOW)cannot start its own there.$(NC)"; \ $(ECHO) "$(CYAN)Stop it, or: make test-integrations PORT=8081$(NC)"; \ exit 1; \ else \ $(ECHO) "$(CYAN)No gateway on $$TEST_HOST:$$TEST_PORT - starting one (-app-dir tests/integrations/python)$(NC)"; \ fi; \ JOBS_VAL="$${JOBS:-$(JOBS)}"; JOBS_VAL="$${JOBS_VAL:-30}"; \ : "Default 30 against 15 test files total (9 Python + 6 TypeScript) means every file"; \ : "launches at once - the cap is a ceiling for the day the suites grow, not a throttle"; \ : "today. What it stops protecting is provider quota: every test spends a real billed"; \ : "call, and conftest.py marks every test flaky(reruns=3), so one failure costs up to"; \ : "four. Lower it (JOBS=4, what release-pipeline.yml pins) if a run starts drawing 429s."; \ : "The script validates this too; checked here so the message names JOBS, which is what"; \ : "the caller actually typed, rather than INTEGRATION_TEST_MAX_PARALLEL."; \ case "$$JOBS_VAL" in \ ''|*[!0-9]*|0) \ $(ECHO) "$(RED)Error: JOBS must be a positive integer, got '$$JOBS_VAL'$(NC)"; \ exit 1 ;; \ esac; \ : "Build: reuse tmp/bifrost-http when present, BUILD=1 forces the script's own"; \ : "'make LOCAL=1 build'. Not a prerequisite on 'build': that pulls in build-ui and is"; \ : ".PHONY, so every run would rebuild the whole Next.js UI before any test could start,"; \ : "and no SDK integration test touches the UI. The mtime is printed because silently"; \ : "reusing a weeks-old binary is the obvious hazard of defaulting to skip."; \ if [ "$$REUSING" = "1" ]; then \ : "No binary needed: we are attaching to a gateway, not starting one. The script"; \ : "skips its own build under SKIP_GATEWAY_START and never looks for tmp/bifrost-http."; \ $(ECHO) "$(CYAN)Skipping build - attaching to the running gateway$(NC)"; \ elif [ -n "$(BUILD)" ]; then \ $(ECHO) "$(YELLOW)BUILD=1: rebuilding UI and bifrost-http first (slow)$(NC)"; \ elif [ -x tmp/bifrost-http ]; then \ export SKIP_GATEWAY_BUILD=1; \ $(ECHO) "$(GREEN)Reusing tmp/bifrost-http (built $$(date -r tmp/bifrost-http '+%Y-%m-%d %H:%M' 2>/dev/null || echo unknown))$(NC)"; \ $(ECHO) "$(YELLOW)Stale? Rebuild with: make test-integrations BUILD=1$(NC)"; \ else \ $(ECHO) "$(YELLOW)No tmp/bifrost-http yet - building it, UI included (slow)$(NC)"; \ fi; \ : "One process per test FILE, deliberately, not pytest-xdist. tests/test_langchain.py,"; \ : "test_litellm.py and test_pydanticai.py each carry an autouse fixture that overwrites"; \ : "OPENAI_API_KEY and friends in os.environ with dummy values and restores only the base"; \ : "URLs, never the keys. Any xdist mode lets one worker take several files in sequence, so"; \ : "the dummies leak forward - and tests/test_google.py:753 builds a genai.Client that talks"; \ : "to Google directly rather than through the gateway, where a dummy key is a hard auth"; \ : "failure. Process-per-file is a correctness requirement here, which is why pytest-xdist"; \ : "stays unused despite being a declared dependency."; \ if [ -n "$(INTEGRATION)" ]; then \ export INTEGRATION_TEST_FILTER="$(INTEGRATION)"; \ $(ECHO) "$(CYAN)Integration filter: $(INTEGRATION)$(NC)"; \ fi; \ export INTEGRATION_TEST_MAX_PARALLEL="$$JOBS_VAL"; \ : "So the script's markdown failure report lands wherever the Go targets put theirs."; \ export TEST_REPORTS_DIR="$(TEST_REPORTS_DIR)"; \ export PORT="$$TEST_PORT"; \ export HOST="$$TEST_HOST"; \ : "The TS suite runs against the Python app-dir on purpose - one shared gateway, and"; \ : "python/config.json's providers are a superset of typescript/config.json's. CI has run"; \ : "it this way since release-pipeline.yml adopted --parallel-files."; \ $(ECHO) "$(GREEN)Running Python + TypeScript integration tests$(NC)"; \ $(ECHO) "$(CYAN) Gateway: http://$$TEST_HOST:$$TEST_PORT (app-dir tests/integrations/python)$(NC)"; \ $(ECHO) "$(CYAN) Parallel: up to $$JOBS_VAL concurrent test files across both suites$(NC)"; \ "$$SCRIPT" --parallel-files install-playwright: ## Install Playwright test dependencies @$(ECHO) "$(GREEN)Installing Playwright dependencies...$(NC)" @which node > /dev/null || ($(ECHO) "$(RED)Error: Node.js is not installed. Please install Node.js first.$(NC)" && exit 1) @which npm > /dev/null || ($(ECHO) "$(RED)Error: npm is not installed. Please install npm first.$(NC)" && exit 1) @$(USE_NODE); cd tests/e2e && npm ci @cd tests/e2e && if npx playwright install --list 2>/dev/null | grep -q "chromium"; then \ $(ECHO) "$(CYAN)Chromium is already installed, skipping download$(NC)"; \ else \ $(ECHO) "$(CYAN)Installing Chromium...$(NC)"; \ npx playwright install --with-deps chromium; \ fi @$(ECHO) "$(GREEN)Playwright is ready$(NC)" build-test-plugin: ## Build test plugin for E2E tests (copies to tmp/bifrost-test-plugin.so) @$(ECHO) "$(GREEN)Building test plugin for E2E tests...$(NC)" @cd examples/plugins/hello-world && make dev @mkdir -p tmp @cp examples/plugins/hello-world/build/hello-world.so tmp/bifrost-test-plugin.so @$(ECHO) "$(GREEN)✓ Test plugin ready at tmp/bifrost-test-plugin.so$(NC)" run-e2e: run-e2e-ui ## Alias of run-e2e-ui run-a11y-audit: install-playwright ## Scan every UI route with axe-core and report accessibility coverage (Usage: make run-a11y-audit [ROUTES=logs,providers] [MIN_SCORE=80]) @$(ECHO) "$(GREEN)Running accessibility audit...$(NC)" @cd tests/e2e && A11Y_ROUTES="$(ROUTES)" A11Y_MIN_SCORE="$(MIN_SCORE)" npx playwright test --config=playwright.a11y.config.ts @$(ECHO) "$(CYAN)Report: tests/e2e/a11y-report/summary.md$(NC)" @$(ECHO) "$(CYAN)Static JSX checks: cd ui && npm run lint:a11y$(NC)" # Flags for tests/e2e/scripts/run-e2e.mjs, built from the make variables below. E2E_RUNNER_ARGS = $(if $(WORKERS),--workers $(WORKERS)) $(if $(FLOW),--features $(FLOW)) $(if $(RERUN),--rerun $(RERUN)) $(if $(SKIP_BUILD),--skip-build) $(if $(KEEP),--keep) $(if $(INTERACTIVE),--ui) run-e2e-ui: install-playwright ## Run the UI E2E suite on isolated Bifrost workers (Usage: make run-e2e-ui [WORKERS=4] [FLOW=providers,logs] [RERUN=tests/e2e/reports/e2e-results.json] [SKIP_BUILD=1] [KEEP=1] [INTERACTIVE=1]) @$(USE_NODE); $(EXPOSE_ENV); \ . .github/workflows/scripts/setup-go-workspace.sh; \ node tests/e2e/scripts/run-e2e.mjs $(E2E_RUNNER_ARGS) run-e2e-headed: install-playwright ## Run the UI E2E suite with visible browsers (same variables as run-e2e-ui) @$(USE_NODE); $(EXPOSE_ENV); \ . .github/workflows/scripts/setup-go-workspace.sh; \ node tests/e2e/scripts/run-e2e.mjs $(E2E_RUNNER_ARGS) --headed e2e-ui-down: ## Stop servers left running by run-e2e-ui KEEP=1 and remove the e2e Postgres @$(USE_NODE); node tests/e2e/scripts/run-e2e.mjs --down run-e2e-api: install-newman ## Run E2E API management tests (/api/* and /health). PROXY=1 runs the proxy e2e flow instead: real provider calls through squid/stunnel/microsocks for every proxy mode (Usage: make run-e2e-api PROXY=1 [PROXY_CELLS="provider-http env-socks5"] [PORT=8080]) @if [ -n "$(PROXY)" ]; then $(ECHO) "$(GREEN)Running proxy e2e (tests/e2e/api/runners/proxy/run-proxy-matrix.sh)...$(NC)"; else $(ECHO) "$(GREEN)Running E2E API management tests...$(NC)"; fi @BASH4="$${BIFROST_BASH:-}"; \ if [ -z "$$BASH4" ]; then \ for candidate in \ "$$(brew --prefix bash 2>/dev/null)/bin/bash" \ /opt/homebrew/bin/bash \ /usr/local/bin/bash \ "$$(command -v bash)"; do \ if [ -n "$$candidate" ] && [ -x "$$candidate" ] && "$$candidate" -c 'test "$${BASH_VERSINFO[0]}" -ge 4' >/dev/null 2>&1; then \ BASH4="$$candidate"; \ break; \ fi; \ done; \ fi; \ if [ -z "$$BASH4" ]; then \ $(ECHO) "$(RED)Error: run-e2e-api requires Bash 4.0+ for the API runner.$(NC)"; \ $(ECHO) "$(YELLOW)Install a newer Bash with 'brew install bash', or pass BIFROST_BASH=/path/to/bash.$(NC)"; \ exit 1; \ fi; \ if [ -n "$(PROXY)" ]; then \ $(MAKE) --no-print-directory install-squid install-stunnel install-microsocks || exit 1; \ $(EXPOSE_ENV); \ exec "$$BASH4" ./tests/e2e/api/runners/proxy/run-proxy-matrix.sh; \ fi; \ cd tests/e2e/api && "$$BASH4" ./runners/run-newman-api-tests.sh --all-reports run-e2e-api-proxy: ## Proxy e2e flow, same as make run-e2e-api PROXY=1 @$(MAKE) run-e2e-api PROXY=1 run-mcp-codemode-test: install-newman ## Run the hermetic MCP Code Mode E2E suite (no API keys, no paid calls). Builds tmp/bifrost-http from local code unless BINARY is given (Usage: make run-mcp-codemode-test [BINARY=path/to/bifrost-http]) @BINARY="$(BINARY)"; \ if [ -z "$$BINARY" ]; then \ $(MAKE) build LOCAL=1 || exit 1; \ BINARY=tmp/bifrost-http; \ fi; \ if [ ! -x "$$BINARY" ]; then \ $(ECHO) "$(RED)Error: bifrost-http binary not found or not executable: $$BINARY$(NC)"; \ exit 1; \ fi; \ BINARY="$$(cd "$$(dirname "$$BINARY")" && pwd)/$$(basename "$$BINARY")"; \ $(ECHO) "$(GREEN)Running MCP Code Mode E2E tests against $$BINARY...$(NC)"; \ ./tests/e2e/api/runners/individual/run-newman-mcp-codemode-tests.sh --binary "$$BINARY" run-mcp-harness-test: install-newman ## Run the MCP harness (generated by tests/e2e/api/runners/build-mcp-harness.mjs), one folder per test case. Hermetic: boots its own Bifrost and an mcpchaos fleet. Builds tmp/bifrost-http from local code with the harness build tag (OAS-45 needs it) unless BINARY is given (Usage: make run-mcp-harness-test [BINARY=path/to/bifrost-http] [FOLDER="ADM"] [HARNESS_PORT=8093] [CHAOS_PORT=8494] [INPUT_PORT=8496] [APPS_PORT=8497]) @BINARY="$(BINARY)"; \ if [ -z "$$BINARY" ]; then \ $(MAKE) build LOCAL=1 BUILD_TAGS=harness || exit 1; \ BINARY=tmp/bifrost-http; \ fi; \ if [ ! -x "$$BINARY" ]; then \ $(ECHO) "$(RED)Error: bifrost-http binary not found or not executable: $$BINARY$(NC)"; \ exit 1; \ fi; \ BINARY="$$(cd "$$(dirname "$$BINARY")" && pwd)/$$(basename "$$BINARY")"; \ $(ECHO) "$(GREEN)Running MCP harness against $$BINARY...$(NC)"; \ ./tests/e2e/api/runners/individual/run-newman-mcp-harness-tests.sh --binary "$$BINARY" \ $(if $(FOLDER),--folder "$(FOLDER)") $(if $(HARNESS_PORT),--port "$(HARNESS_PORT)") $(if $(CHAOS_PORT),--chaos-port "$(CHAOS_PORT)") $(if $(INPUT_PORT),--input-port "$(INPUT_PORT)") $(if $(APPS_PORT),--apps-port "$(APPS_PORT)") run-log-store-matrix: ## Relay and LLM log permutation matrix on every log store: sqlite, postgres, and hybrid over a local fake S3 (with and without offload exclusions). Hermetic: local upstreams, no API keys, no paid calls. Postgres cells need POSTGRES_URL. Builds tmp/bifrost-http unless BINARY is given (Usage: make run-log-store-matrix [BINARY=path/to/bifrost-http] [CELLS=sqlite,sqlite+s3] [SUITES=pricing,proxy] [PARALLEL=3] [POSTGRES_URL=postgresql://bifrost:bifrost_password@localhost:5432/postgres] [SKIP_CONTENT_MATRIX=1]) @$(USE_NODE); BINARY="$(BINARY)"; \ if [ -z "$$BINARY" ]; then \ $(MAKE) build LOCAL=1 || exit 1; \ BINARY=tmp/bifrost-http; \ fi; \ if [ ! -x "$$BINARY" ]; then \ $(ECHO) "$(RED)Error: bifrost-http binary not found or not executable: $$BINARY$(NC)"; \ exit 1; \ fi; \ if ! npm --prefix tests/e2e/api ls --depth=0 >/dev/null 2>&1; then \ (cd tests/e2e/api && npm install --silent) || exit 1; \ fi; \ BIFROST_E2E_POSTGRES_URL="$(POSTGRES_URL)" node tests/e2e/api/runners/run-log-store-matrix.mjs --binary "$$BINARY" \ $(if $(CELLS),--cells "$(CELLS)") $(if $(SUITES),--suites "$(SUITES)") $(if $(PARALLEL),--parallel "$(PARALLEL)") $(if $(SKIP_CONTENT_MATRIX),--skip-content-matrix) run-log-store-matrix: ## Relay and LLM log permutation matrix on every log store: sqlite, postgres, and hybrid over a local fake S3 (with and without offload exclusions). Hermetic: local upstreams, no API keys, no paid calls. Postgres cells need POSTGRES_URL. Builds tmp/bifrost-http unless BINARY is given (Usage: make run-log-store-matrix [BINARY=path/to/bifrost-http] [CELLS=sqlite,sqlite+s3] [SUITES=pricing,proxy] [PARALLEL=3] [POSTGRES_URL=postgresql://bifrost:bifrost_password@localhost:5432/postgres] [SKIP_CONTENT_MATRIX=1]) @$(USE_NODE); BINARY="$(BINARY)"; \ if [ -z "$$BINARY" ]; then \ $(MAKE) build LOCAL=1 || exit 1; \ BINARY=tmp/bifrost-http; \ fi; \ if [ ! -x "$$BINARY" ]; then \ $(ECHO) "$(RED)Error: bifrost-http binary not found or not executable: $$BINARY$(NC)"; \ exit 1; \ fi; \ if ! npm --prefix tests/e2e/api ls --depth=0 >/dev/null 2>&1; then \ (cd tests/e2e/api && npm install --silent) || exit 1; \ fi; \ BIFROST_E2E_POSTGRES_URL="$(POSTGRES_URL)" node tests/e2e/api/runners/run-log-store-matrix.mjs --binary "$$BINARY" \ $(if $(CELLS),--cells "$(CELLS)") $(if $(SUITES),--suites "$(SUITES)") $(if $(PARALLEL),--parallel "$(PARALLEL)") $(if $(SKIP_CONTENT_MATRIX),--skip-content-matrix) run-warp-test: install-newman ## Run the Warp E2E suite: boots Bifrost on a throwaway Postgres DB, seeds it (tests/cmd/seed/warpseed) and asks Warp questions with a live model. Needs OPENAI_API_KEY (or WARP_UPSTREAM_BIFROST= to use a running Bifrost's key), Postgres and Weaviate (tests/docker-compose.yml). Builds tmp/bifrost-http unless BINARY is given (Usage: make run-warp-test [BINARY=path/to/bifrost-http] [FOLDER="Guardrails"] [WARP_UPSTREAM_BIFROST=http://localhost:8080] [WEAVIATE_HOST=localhost:9000] [POSTGRES_PORT=5432] [USE_INFISICAL=1]) @$(EXPOSE_ENV); \ BINARY="$(BINARY)"; \ if [ -z "$$BINARY" ]; then \ $(MAKE) build LOCAL=1 || exit 1; \ BINARY=tmp/bifrost-http; \ fi; \ if [ ! -x "$$BINARY" ]; then \ $(ECHO) "$(RED)Error: bifrost-http binary not found or not executable: $$BINARY$(NC)"; \ exit 1; \ fi; \ BINARY="$$(cd "$$(dirname "$$BINARY")" && pwd)/$$(basename "$$BINARY")"; \ $(ECHO) "$(GREEN)Running Warp E2E tests against $$BINARY...$(NC)"; \ ./tests/e2e/api/runners/individual/run-newman-warp-tests.sh --binary "$$BINARY" $(if $(FOLDER),--folder "$(FOLDER)",) # Quick start with example config quick-start: ## Quick start with example config and maxim plugin @$(ECHO) "$(GREEN)Quick starting Bifrost with example configuration...$(NC)" @$(MAKE) dev # Linting and formatting lint: ## Run linter for Go code @$(ECHO) "$(GREEN)Running golangci-lint...$(NC)" @golangci-lint run ./... fmt: ## Format Go code @$(ECHO) "$(GREEN)Formatting Go code...$(NC)" @gofmt -s -w . @goimports -w . format: ## Format code (Usage: make format ui) ifeq (ui,$(filter ui,$(MAKECMDGOALS))) @$(ECHO) "$(GREEN)Formatting UI code...$(NC)" @cd ui && $(USE_NODE); npm run format else @$(ECHO) "$(YELLOW)Usage: make format ui$(NC)" endif ui: @: # Workspace helpers setup-workspace: ## Set up Go workspace with all local modules for development @$(ECHO) "$(GREEN)Setting up Go workspace for local development...$(NC)" @$(ECHO) "$(YELLOW)Cleaning existing workspace...$(NC)" @rm -f go.work go.work.sum || true @$(ECHO) "$(YELLOW)Initializing new workspace...$(NC)" @go work init ./cli ./core ./framework ./transports @$(ECHO) "$(YELLOW)Adding plugin modules...$(NC)" @for plugin_dir in ./plugins/*/; do \ if [ -d "$$plugin_dir" ] && [ -f "$$plugin_dir/go.mod" ]; then \ $(ECHO) " Adding plugin: $$(basename $$plugin_dir)"; \ go work use "$$plugin_dir"; \ fi; \ done @$(ECHO) "$(YELLOW)Adding test command modules...$(NC)" @for cmd_dir in ./tests/cmd/*/; do \ if [ -d "$$cmd_dir" ] && [ -f "$$cmd_dir/go.mod" ]; then \ $(ECHO) " Adding test cmd: $$(basename $$cmd_dir)"; \ go work use "$$cmd_dir"; \ fi; \ done @$(ECHO) "$(YELLOW)Syncing workspace...$(NC)" @go work sync @$(ECHO) "$(GREEN)✓ Go workspace ready with all local modules$(NC)" @$(ECHO) "" @$(ECHO) "$(CYAN)Local modules in workspace:$(NC)" @go list -m all | grep "github.com/maximhq/bifrost" | grep -v " v" | sed 's/^/ ✓ /' @$(ECHO) "" @$(ECHO) "$(CYAN)Remote modules (no local version):$(NC)" @go list -m all | grep "github.com/maximhq/bifrost" | grep " v" | sed 's/^/ → /' @$(ECHO) "" @$(ECHO) "$(YELLOW)Note: go.work files are not committed to version control$(NC)" work-init: ## Create local go.work to use local modules for development (legacy) @$(ECHO) "$(YELLOW)⚠️ work-init is deprecated, use 'make setup-workspace' instead$(NC)" @$(MAKE) setup-workspace work-clean: ## Remove local go.work @rm -f go.work go.work.sum || true @$(ECHO) "$(GREEN)Removed local go.work files$(NC)" # Module parameter for mod-tidy (all/core/plugins/framework/transport/tests) MODULE ?= all mod-tidy: ## Run go mod tidy on modules (Usage: make mod-tidy [MODULE=all|cli|core|plugins|framework|transport|tests]) @$(ECHO) "$(GREEN)Running go mod tidy...$(NC)" @if [ "$(MODULE)" = "all" ] || [ "$(MODULE)" = "cli" ]; then \ $(ECHO) "$(CYAN)Tidying cli...$(NC)"; \ cd cli && $(if $(LOCAL),,GOWORK=off) go mod tidy && $(ECHO) "$(GREEN) ✓ cli$(NC)"; \ fi @if [ "$(MODULE)" = "all" ] || [ "$(MODULE)" = "core" ]; then \ $(ECHO) "$(CYAN)Tidying core...$(NC)"; \ cd core && go mod tidy && $(ECHO) "$(GREEN) ✓ core$(NC)"; \ fi @if [ "$(MODULE)" = "all" ] || [ "$(MODULE)" = "framework" ]; then \ $(ECHO) "$(CYAN)Tidying framework...$(NC)"; \ cd framework && go mod tidy && $(ECHO) "$(GREEN) ✓ framework$(NC)"; \ fi @if [ "$(MODULE)" = "all" ] || [ "$(MODULE)" = "transport" ]; then \ $(ECHO) "$(CYAN)Tidying transports...$(NC)"; \ cd transports && go mod tidy && $(ECHO) "$(GREEN) ✓ transports$(NC)"; \ fi @if [ "$(MODULE)" = "all" ] || [ "$(MODULE)" = "plugins" ]; then \ $(ECHO) "$(CYAN)Tidying plugins...$(NC)"; \ for plugin_dir in ./plugins/*/; do \ if [ -d "$$plugin_dir" ] && [ -f "$$plugin_dir/go.mod" ]; then \ plugin_name=$$(basename $$plugin_dir); \ cd $$plugin_dir && go mod tidy && cd ../.. && $(ECHO) "$(GREEN) ✓ plugins/$$plugin_name$(NC)"; \ fi; \ done; \ fi @if [ "$(MODULE)" = "all" ] || [ "$(MODULE)" = "tests" ]; then \ $(ECHO) "$(CYAN)Tidying test command modules...$(NC)"; \ for cmd_dir in ./tests/cmd/*/; do \ if [ -d "$$cmd_dir" ] && [ -f "$$cmd_dir/go.mod" ]; then \ cmd_name=$$(basename $$cmd_dir); \ cd $$cmd_dir && go mod tidy && cd ../../.. && $(ECHO) "$(GREEN) ✓ tests/cmd/$$cmd_name$(NC)"; \ fi; \ done; \ fi @$(ECHO) "" @$(ECHO) "$(GREEN)✓ go mod tidy complete$(NC)" .PHONY: test-cli-integration test-cli-integration: ## Build the CLI and test configuration and management commands (Python 3.11+) @cd cli && python3 scripts/testconfigure.py -v test-cli: install-gotestsum ## Run CLI tests @$(ECHO) "$(GREEN)Running CLI tests...$(NC)" @mkdir -p $(TEST_REPORTS_DIR) @cd cli && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../$(TEST_REPORTS_DIR)/cli.xml \ -- ./... # The CLI harness always runs `go test -v`, because without it `go test` buffers # the whole package's stdout AND stderr and discards it when the package passes - # which would swallow the harness's own per-cell RUNNING/PASS/FAIL lines and its # progress table, leaving a multi-minute run with no output at all. # # -v then emits its own per-subtest scaffolding, which for this matrix is three # lines (=== RUN / PAUSE / CONT) per cell plus a skip line per gated scenario - # far more volume than the results themselves. This strips exactly that, leaving # every diagnostic (t.Fatal bodies, panics, build errors) intact. The two skip # messages matched here are the harness's own strings from clis_test.go, not # go test's, so this cannot drift out from under a Go release. # # awk rather than grep: awk exits 0 even when it emits nothing (grep exits 1, # which `set -o pipefail` would turn into a spurious failure), and fflush keeps # the stream line-buffered so results appear as cells finish rather than in one # dump at the end. VERBOSE=1 bypasses the filter entirely. # The `--- PASS/FAIL/SKIP` summary is emitted once per nesting level and indented # to match, so the pattern allows leading whitespace - anchoring at column 0 # would strip only the outermost line and leave the rest of the tree. # # The package-result line is `FAILpkg0.13s`, matched on the TAB # specifically: the harness's own status column is space-padded ("FAIL" then # spaces), so a `^FAIL[ \t]` pattern would swallow every failed cell's result - # the single most important line in the output. CLI_HARNESS_FILTER = awk '/^=== /||/^[ \t]*--- (PASS|FAIL|SKIP)[: ]/||/^(PASS|FAIL)$$/||/^ok[ \t]/||/^FAIL\t/||/unsupported for /||/not configured in bifrost/{next} {print; fflush()}' test-live: install-gotestsum ## Run GPT Live e2e tests against a running gateway (Usage: make test-live [TESTCASE=TestName|PATTERN=substring] [LIVE_UPSTREAM=fake|real] [BIFROST_BASE_URL=http://localhost:8080]; fake mode: start the gateway with APP_DIR=$$(pwd)/tests/live) @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running GPT Live e2e tests ($${LIVE_UPSTREAM:-fake} upstream)...$(NC)"; \ mkdir -p $(TEST_REPORTS_DIR); \ if [ -n "$(PATTERN)" ] && [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(RED)Error: PATTERN and TESTCASE are mutually exclusive$(NC)"; exit 1; \ fi; \ RUN_FLAG=""; \ if [ -n "$(TESTCASE)" ]; then RUN_FLAG="-run ^$(TESTCASE)$$"; elif [ -n "$(PATTERN)" ]; then RUN_FLAG="-run .*$(PATTERN).*"; \ elif [ "$${LIVE_UPSTREAM:-fake}" = "real" ]; then RUN_FLAG="-run ^Test(Real|SDK)"; fi; \ REPORT_FILE="$(TEST_REPORTS_DIR)/live.xml"; \ cd tests/live && GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../$$REPORT_FILE \ -- -v -timeout 900s $$RUN_FLAG; \ STATUS=$$?; \ cd ../..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ exit $$STATUS test-live-long: install-gotestsum ## Run the long GPT Live sessions: 17 min on the fake, 15 min per transport on the real upstream (Usage: make test-live-long [TESTCASE=TestName] [LIVE_UPSTREAM=fake|real] [BIFROST_BASE_URL=http://localhost:8080]) @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running long GPT Live sessions ($${LIVE_UPSTREAM:-fake} upstream)...$(NC)"; \ mkdir -p $(TEST_REPORTS_DIR); \ RUN_FLAG="-run Long"; \ if [ -n "$(TESTCASE)" ]; then RUN_FLAG="-run ^$(TESTCASE)$$"; fi; \ REPORT_FILE="$(TEST_REPORTS_DIR)/live-long.xml"; \ cd tests/live && LIVE_LONG=1 GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../$$REPORT_FILE \ -- -v -timeout 90m $$RUN_FLAG; \ STATUS=$$?; \ cd ../..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ exit $$STATUS test-realtime: install-gotestsum @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running Realtime API e2e tests...$(NC)"; \ mkdir -p $(TEST_REPORTS_DIR); \ if [ -n "$(PATTERN)" ] && [ -n "$(TESTCASE)" ]; then \ $(ECHO) "$(RED)Error: PATTERN and TESTCASE are mutually exclusive$(NC)"; exit 1; \ fi; \ RUN_FLAG=""; \ if [ -n "$(TESTCASE)" ]; then RUN_FLAG="-run ^$(TESTCASE)$$"; elif [ -n "$(PATTERN)" ]; then RUN_FLAG="-run .*$(PATTERN).*"; fi; \ REPORT_FILE="$(TEST_REPORTS_DIR)/realtime.xml"; \ cd tests/realtime && REALTIME_REQUIRE_GATEWAY=1 GOWORK=off gotestsum \ --format=$(GOTESTSUM_FORMAT) \ --junitfile=../../$$REPORT_FILE \ -- -v -count 1 -timeout 600s $$RUN_FLAG; \ STATUS=$$?; \ cd ../..; \ $(MAKE) cleanup-junit-xml REPORT_FILE=$$REPORT_FILE; \ exit $$STATUS run-cli-harness-test: ## Run the Claude Code + Codex + OpenCode E2E harness (non-interactive, multi-turn JSON streams). Prints one line per cell plus a progress table; MIRROR=1 adds the raw CLI stream, VERBOSE=1 adds go test -v. Usage: make run-cli-harness-test [TESTCASE='TestCLIs/...'] [CLI=claude|codex|opencode] [PROVIDER=openai|anthropic|azure|gemini|bedrock|vertex] [MODEL=] [SCENARIO=simple-chat|conversation-memory|...] [PARALLEL=4] [BASE_URL=http://localhost:8080] [API_KEY=...] [TIMEOUT=60m] [MIRROR=1] [VERBOSE=1] [QUIET=1] @$(EXPOSE_ENV); \ $(ECHO) "$(GREEN)Running CLI harness E2E tests...$(NC)"; \ BASE_URL_VAL="$${BASE_URL:-$(BASE_URL)}"; BASE_URL_VAL="$${BASE_URL_VAL:-http://localhost:8080}"; \ PARALLEL_VAL="$${PARALLEL:-$(PARALLEL)}"; PARALLEL_VAL="$${PARALLEL_VAL:-4}"; \ $(ECHO) "$(CYAN) Bifrost: $$BASE_URL_VAL$(NC)"; \ $(ECHO) "$(CYAN) Parallel: $$PARALLEL_VAL$(NC)"; \ if [ -n "$(PROVIDER)" ]; then \ case "$(PROVIDER)" in openai|anthropic|azure|gemini|bedrock|vertex) ;; \ *) $(ECHO) "$(RED)Error: invalid PROVIDER=$(PROVIDER). Use one of: openai, anthropic, azure, gemini, bedrock, vertex$(NC)"; exit 1 ;; \ esac; \ $(ECHO) "$(CYAN) Provider: $(PROVIDER)$(NC)"; \ fi; \ if ! curl -s -o /dev/null -w "%{http_code}" "$$BASE_URL_VAL/api/providers" | grep -qE '^[2-4]'; then \ $(ECHO) "$(RED)Error: Bifrost not reachable at $$BASE_URL_VAL$(NC)"; \ $(ECHO) "$(YELLOW)Start Bifrost first (e.g. make dev) or pass BASE_URL=...$(NC)"; \ exit 1; \ fi; \ for bin in claude codex opencode; do \ if [ "$(CLI)" = "" ] || [ "$(CLI)" = "$$bin" ] || { [ "$$bin" = opencode ] && [ "$(CLI)" = opencode-responses ]; }; then \ if ! command -v $$bin >/dev/null 2>&1; then \ $(ECHO) "$(YELLOW)Warning: $$bin not on PATH; matrix cells for $$bin will fail.$(NC)"; \ $(ECHO) "$(YELLOW) Install: npm i -g $$( [ $$bin = claude ] && echo @anthropic-ai/claude-code || { [ $$bin = codex ] && echo @openai/codex || echo opencode-ai; } )$(NC)"; \ fi; \ fi; \ done; \ if [ -n "$(TESTCASE)" ]; then \ RUN_PARTS="$(TESTCASE)"; \ else \ RUN_PARTS="TestCLIs"; \ if [ -n "$(CLI)" ]; then RUN_PARTS="$$RUN_PARTS/$(CLI)"; else RUN_PARTS="$$RUN_PARTS/[^/]+"; fi; \ if [ -n "$(PROVIDER)" ]; then RUN_PARTS="$$RUN_PARTS/$(PROVIDER)"; else RUN_PARTS="$$RUN_PARTS/[^/]+"; fi; \ if [ -n "$(MODEL)" ]; then RUN_PARTS="$$RUN_PARTS/[^/]*$(MODEL)[^/]*"; else RUN_PARTS="$$RUN_PARTS/[^/]+"; fi; \ if [ -n "$(SCENARIO)" ]; then RUN_PARTS="$$RUN_PARTS/$(SCENARIO)"; fi; \ fi; \ $(ECHO) "$(CYAN) Filter: $$RUN_PARTS$(NC)"; \ if [ -n "$(MODEL)" ]; then $(ECHO) "$(CYAN) Model: $(MODEL) (substring filter)$(NC)"; fi; \ set -o pipefail; \ cd tests/e2e/clis && \ BIFROST_BASE_URL="$$BASE_URL_VAL" \ BIFROST_API_KEY="$${API_KEY:-$(API_KEY)}" \ BIFROST_E2E_CLIS_QUIET="$(QUIET)" \ BIFROST_E2E_CLIS_MIRROR="$(MIRROR)" \ MODEL="$(MODEL)" \ GOWORK=off go test \ -count=1 \ -timeout=$${TIMEOUT:-$(if $(TIMEOUT),$(TIMEOUT),60m)} \ -parallel="$$PARALLEL_VAL" \ -run "^$$RUN_PARTS$$" \ -v \ ./... $(if $(VERBOSE),,| $(CLI_HARNESS_FILTER)) cli-harness-report: ## Regenerate tests/e2e/clis/reports/index.html from existing reports/*.json, without running any tests (free, instant). Usage: make cli-harness-report @$(ECHO) "$(GREEN)Rendering CLI harness report from existing reports/*.json...$(NC)" @cd tests/e2e/clis && GOWORK=off go test -run "^TestRenderReport$$" -v ./... # The harness runner unit tests are plain node scripts with no framework (the # tests/e2e/api dir configures no test runner), so nothing discovers them # automatically. Without a target they are only ever run by hand, which is how # they rot - and they cover the parsing that produces the status table's numbers. test-harness-runner-lib: ## Run the provider-harness runner unit tests (tests/e2e/api/runners/lib/*.test.mjs). No network, no Bifrost, no credentials. @$(ECHO) "$(GREEN)Running provider-harness runner lib tests...$(NC)" @$(USE_NODE); RC=0; \ for t in tests/e2e/api/runners/lib/*.test.mjs; do \ printf '%s\n' "$(CYAN)-> $$t$(NC)"; \ node "$$t" || RC=1; \ done; \ if [ "$$RC" -ne 0 ]; then $(ECHO) "$(RED)harness runner lib tests failed$(NC)"; else $(ECHO) "$(GREEN)harness runner lib tests passed$(NC)"; fi; \ exit $$RC # Video bills at SETTLEMENT, minutes after the POST returns, so neither newman # (no way to wait for an out-of-band row) nor a Go test (no provider) can check # the figure. This runner does the whole submit -> poll -> settle -> assert loop. run-video-costing-test: ## Verify async video jobs bill correctly against real providers (Usage: make run-video-costing-test ARGS="--group Runware --seed-pricing"). Needs a running Bifrost with video provider keys. @$(ECHO) "$(GREEN)Running video costing checks...$(NC)" @$(USE_NODE); cd tests/e2e/api && node runners/run-video-costing.mjs $(ARGS) list-video-costing-cases: ## List the video costing cases and which checklist line each covers. No network, no Bifrost. @$(USE_NODE); cd tests/e2e/api && node runners/run-video-costing.mjs --list # Named target rather than documentation telling people to type SMOKE=1: a smoke # set nobody can invoke in one word does not get used before a release. smoke-provider-harness-test: ## Run the curated ~100-request provider-harness smoke set (tests/e2e/api/collections/smoke-manifest.json). Same flags as run-provider-harness-test; equivalent to SMOKE=1. @$(MAKE) run-provider-harness-test SMOKE=1 # Versions pinned to match the CI installs in .github/workflows/release-pipeline.yml # (test-core, test-api-integrations, test-docker-image-*). Keep them in sync. NEWMAN_VERSION ?= 6.2.1 NEWMAN_HTMLEXTRA_VERSION ?= 1.23.1 # Every provider fork the harness knows how to run. Also the provider set the # status table lists, including the deferred cache-parity pass, so it lives in # one place rather than being restated per newman invocation. HARNESS_PROVIDERS := openai anthropic bedrock bedrock_mantle gemini vertex azure passthrough openrouter huggingface groq xai mistral cohere deepseek baseten # Second parallelism axis. Each provider fork is sharded again by modality class so the run is not # bound by one provider's whole sequential item list: openai alone is ~1264 requests, and its # largest class is ~268. filter-collection.mjs --class assigns every item to exactly one of these # (first match wins, "other" is the catch-all), so the shards partition the fork rather than # overlapping it. Order here is only the launch order; the priority order lives in CLASS_ORDER. # # Listed SLOWEST-FIRST, measured rather than guessed: in a full sweep the last six shards to finish # were reasoning (x4 providers), tools and chat, with reasoning trailing the median shard by ~20 # minutes. Launch order only matters once HARNESS_JOBS actually binds, which it now does - the # sub-shard axis below pushes the grid past the cap - and when it binds, a slow shard that starts # last sets the wall clock all by itself. HARNESS_CLASSES := reasoning tools chat streaming json vision other audio embeddings image-gen # Third parallelism axis: how many sub-shards each modality class is split into, via # filter-collection.mjs --shard /. The class axis alone cannot flatten the tail, because the # expensive classes are expensive PER REQUEST rather than per row count: a reasoning row costs ~8s # against a chat row's ~1s, so "openai reasoning" is a ~21 minute serial run at 161 rows while # "anthropic tools" clears 226 rows in a fraction of that. Splitting the slow classes cuts their # serial length by n while the fast ones stay single shards, so the grid grows only where it buys # wall clock. Counts are set from that same measured finish order. A class not listed here is 1. # Set SUBSHARDS=0 to collapse this axis and get the previous one-shard-per-class behaviour. # # FALLBACK ONLY as of the shard planner. When a timing table exists (HARNESS_TIMINGS, written at # the end of every run by build-harness-timings.mjs), filter-collection.mjs --plan sizes each # (provider, class) cell from measured cost and this table is not consulted. It still runs a fresh # checkout's first sweep, a run with a corrupt table, and SUBSHARDS=0. # # It is a fallback rather than the mechanism because one number per CLASS cannot fit eight # providers: "reasoning" is ~30 minutes for openai and seconds for embeddings-heavy providers, so # any single count is badly wrong for one of them. Measured: a full sweep spent 77% of its 10.7 # minute wall clock on the last 16% of its requests, dropping from 98 concurrent shards to 1 while # openai--reasoning-s4 finished alone. Sizing from cost puts the same work in 2.8 minutes. HARNESS_SUBSHARDS := reasoning=4 tools=3 chat=3 streaming=3 json=2 vision=2 # Wall clock a single sub-shard aims for, in seconds. The planner splits each cell into # ceil(cellCost / target) shards, capped by its row count. Simulated against a measured sweep at # 120/150/180/240s: 150 is the smallest target that still reaches the floor set by the slowest # single request (2.8 min worst shard) without buying shards that cannot help - 120s costs 21 more # node processes for the same 2.8 min, 180s saves 16 processes for +0.2 min. # # Lower it to trade processes for wall clock, raise it to trade the other way. It cannot push the # sweep below its slowest single request (~170s today), because a request is not divisible. HARNESS_SHARD_TARGET ?= 150 # Where the measured per-request timing table lives. Refreshed at the end of every run from that # run's newman reports, and merged onto whatever was there so a scoped run refines its own rows # instead of erasing the seven providers it did not touch. The committed baseline at # tests/e2e/api/collections/harness-timings.json is consulted when this file is absent, which is # what lets a fresh checkout and CI balance on their first sweep rather than their second. HARNESS_TIMINGS ?= tmp/harness-timings.json # Cap on concurrently running newman shards. The provider x class x sub-shard grid is ~168 cells # now that HARNESS_SUBSHARDS splits the slow classes, so unlike before the cap genuinely binds and # the launcher blocks - which is exactly why HARNESS_CLASSES is ordered slowest-first, so the long # shards hold slots from the start instead of queueing behind a hundred cheap ones. The cap is kept # rather than removed because the grid grows with HARNESS_PROVIDERS, HARNESS_CLASSES and # HARNESS_SUBSHARDS together, and an unbounded loop would fork whatever their product becomes. # Lower it if a provider starts returning 429s - analyze-failures.mjs does file those as # rate_limit rather than as defects, but they still fail the run. HARNESS_JOBS ?= 100 # Echoes are suppressed under CI so run-provider-harness-test, which takes this # as a prerequisite, really does emit nothing but its status table. Install # failures still surface: npm's own stderr is untouched and the recipe still fails. install-newman: ## Install newman + htmlextra reporter if not already installed (pinned via NEWMAN_VERSION / NEWMAN_HTMLEXTRA_VERSION) @$(USE_NODE); which newman > /dev/null 2>&1 || ([ -n "$$CI" ] || $(ECHO) "$(YELLOW)Installing newman@$(NEWMAN_VERSION)...$(NC)"; npm install -g newman@$(NEWMAN_VERSION)) @$(USE_NODE); npm list -g newman-reporter-htmlextra > /dev/null 2>&1 || ([ -n "$$CI" ] || $(ECHO) "$(YELLOW)Installing newman-reporter-htmlextra@$(NEWMAN_HTMLEXTRA_VERSION)...$(NC)"; npm install -g newman-reporter-htmlextra@$(NEWMAN_HTMLEXTRA_VERSION)) @[ -n "$$CI" ] || $(ECHO) "$(GREEN)Newman + htmlextra are ready$(NC)" run-provider-harness-test: $(if $(HELP),,install-newman) ## Run the Bifrost provider-harness Postman collection. HELP=1 prints full parameter docs. Filter via PROVIDER=openai|anthropic|bedrock|gemini|vertex|azure|passthrough|openrouter|huggingface, FEATURE="" or FEATURE="," (AND across substrings; matches request name/URL/body), RERUN_FAILED=1 (re-run only items that failed last run). INCLUDE_PREVIEW=1 to run [PREVIEW]-tagged account/region-scoped cases. SKIP_STREAM_CANCEL=1 skips stream cancellation probes. USE_INFISICAL=1 to source from Infisical (Usage: make run-provider-harness-test [HELP=1] [PROVIDER=anthropic] [FEATURE="web search"] [FEATURE="cross-cut,structured output"] [RERUN_FAILED=1] [INCLUDE_PREVIEW=1] [BASE_URL=...] [FOLDER="..."] [ENV_FILE=...] [VIEWER_PORT=8090] [CI=1]) @if [ -n "$(HELP)" ]; then \ printf '\n%s\n' "$(CYAN)run-provider-harness-test - Bifrost provider harness runner$(NC)"; \ printf '%s\n\n' "Runs the Bifrost provider-harness Postman collection through newman, with optional filtering."; \ printf '%s\n\n' "Includes §8 Criss-Cross: endpoint-shape × model-provider × modality matrix (chat, streaming, embeddings, audio, image gen, tools, vision, JSON, reasoning)."; \ printf '%s\n' "$(YELLOW)PARAMETERS$(NC)"; \ printf ' %-18s %s\n' "HELP=1" "Print this help and exit (no Bifrost or network activity)."; \ printf ' %-18s %s\n' "PROVIDER=" "Filter requests by provider. One of: $(HARNESS_PROVIDERS)."; \ printf ' %-18s %s\n' "" " Matches via PROVIDER_KEYWORDS in tests/e2e/api/runners/filter-collection.mjs (loose name/body substring)."; \ printf ' %-18s %s\n' "FEATURE=\"\"" "Filter by case-insensitive keyword(s) against the full request JSON (name + URL + body + ancestor folder names)."; \ printf ' %-18s %s\n' "" " Single: FEATURE=\"web search\". Multi-keyword AND (comma-separated): FEATURE=\"cross-cut,structured output\"."; \ printf ' %-18s %s\n' "" " \"cross-cut\" is a structural keyword - matches any row routed through unified /v1/chat/completions with a provider/model body, regardless of name."; \ printf ' %-18s %s\n' "RERUN_FAILED=1" "Re-run only requests that failed in the prior run (reads tmp/newman-report.json)."; \ printf ' %-18s %s\n' "" " Composes with PROVIDER and FEATURE (predicates AND together)."; \ printf ' %-18s %s\n' "BASE_URL=" "Bifrost gateway URL (default: http://localhost:8080). Skips auto-start if /health responds."; \ printf ' %-18s %s\n' "APP_DIR=

" "Config dir passed to 'make dev' if Bifrost isn't already running (default: tests/integrations/python)."; \ printf ' %-18s %s\n' "FOLDER=\"\"" "Scope to a single Postman folder (substring match, e.g. \"8. Cross-Model\"). Pre-filters (like PROVIDER/FEATURE) AND is"; \ printf ' %-18s %s\n' "" " passed to newman's own --folder as the final scope. In parallel mode (default), provider forks with zero items"; \ printf ' %-18s %s\n' "" " in that folder are skipped cleanly instead of forked-then-failed - use with PROVIDER= to run a single fork."; \ printf ' %-18s %s\n' "ENV_FILE=" "Postman environment JSON with real keys (kept out of git)."; \ printf ' %-18s %s\n' "VIEWER_PORT=N" "Port for the interactive HTML viewer (default: 8090). Ignored if CI=1."; \ printf ' %-18s %s\n' "CI=1" "CI mode: skip the interactive viewer, emit artifacts only. Prints a one-line heartbeat every"; \ printf ' %-18s %s\n' "" " MONITOR_INTERVAL seconds and the provider status table (provider x total/pass/failed) exactly ONCE,"; \ printf ' %-18s %s\n' "" " at the end - an Actions log is append-only, so reprinting the table left hundreds of stale copies."; \ printf ' %-18s %s\n' "" " Everything the run would"; \ printf ' %-18s %s\n' "" " otherwise have echoed goes to tmp/harness-quiet.log; newman's own output goes to tmp/newman-cli*.log."; \ printf ' %-18s %s\n' "MONITOR_INTERVAL=N" "Seconds between heartbeat lines in CI mode (default 5, clamped to 5..2700)."; \ printf ' %-18s %s\n' "MONITOR_TABLE_REPRINT=1" "Restore the old CI behaviour of reprinting the whole table on every interval. Debugging only."; \ printf ' %-18s %s\n' "INCLUDE_PREVIEW=1" "Run [PREVIEW]-tagged requests (account/region-scoped: vector stores, cached content, MCP servers, preview-model deployments). Off by default."; \ printf ' %-18s %s\n' "INCLUDE_SKIP=1" "Run [SKIP]-tagged criss-cross cells (provider+modality pairs that return NewUnsupportedOperationError by design, e.g., anthropic embeddings, bedrock audio). Off by default."; \ printf ' %-18s %s\n' "SMOKE=1" "Run only the curated smoke set in tests/e2e/api/collections/smoke-manifest.json (~100 requests"; \ printf ' %-18s %s\n' "" " across all $(words $(HARNESS_PROVIDERS)) providers) instead of the full ~1900-request sweep. SMOKE= uses a different manifest."; \ printf ' %-18s %s\n' "" " Rows are matched by (folder, name) against the AUGMENTED collection, which is the only way to reach the"; \ printf ' %-18s %s\n' "" " generated cache-parity rows - they do not exist in provider-harness.json and cannot be tagged in place."; \ printf ' %-18s %s\n' "" " Forces the deferred cache-parity pass ON: a third of the smoke set is cache parity, and the default"; \ printf ' %-18s %s\n' "" " deferral only fires on an unfiltered run. Composes with PROVIDER; RERUN_FAILED wins and skips the defer."; \ printf ' %-18s %s\n' "PARALLEL=0" "Disable per-provider parallelism (default: ON). When ON, forks one newman per provider ($(HARNESS_PROVIDERS)) concurrently; reports merged into tmp/newman-report.json. The htmlextra report is only emitted in sequential mode (PARALLEL=0)."; \ printf ' %-18s %s\n' "CLASS_SHARDS=0" "Collapse the second parallelism axis. By default each provider fork is sharded again by modality"; \ printf ' %-18s %s\n' "" " class (streaming, tools, chat, reasoning, json, vision, other, audio, embeddings, image-gen) so the"; \ printf ' %-18s %s\n' "" " run is not bound by one provider's whole sequential list - openai alone is ~1264 requests and its"; \ printf ' %-18s %s\n' "" " largest class is ~268. filter-collection.mjs --class puts every request in exactly one class, so the"; \ printf ' %-18s %s\n' "" " shards partition the fork instead of overlapping it. Shard artifacts are named --."; \ printf ' %-18s %s\n' "SUBSHARDS=0" "Collapse the third parallelism axis. By default each (provider, class) cell is split again via"; \ printf ' %-18s %s\n' "" " filter-collection.mjs --shard /, because the slow classes are slow PER REQUEST (~8s for a"; \ printf ' %-18s %s\n' "" " reasoning row vs ~1s for chat), so the class axis alone leaves a long tail. How many sub-shards"; \ printf ' %-18s %s\n' "" " and which rows go in each are both MEASURED, from HARNESS_TIMINGS - see the two knobs below."; \ printf ' %-18s %s\n' "" " Sub-shard artifacts are named ---s."; \ printf ' %-18s %s\n' "HARNESS_SHARD_TARGET=N" ""; \ printf ' %-18s %s\n' "" " Wall clock a single sub-shard aims for, in seconds (default 150). Each cell is split into"; \ printf ' %-18s %s\n' "" " ceil(cellCost / target) shards, capped by its row count, and filled by greedy longest-"; \ printf ' %-18s %s\n' "" " processing-time rather than by row position. Lower it to trade node processes for wall clock."; \ printf ' %-18s %s\n' "" " It cannot push a sweep below its slowest single request (~170s today) - a request is not"; \ printf ' %-18s %s\n' "" " divisible. Measured effect: worst shard 9.4min -> 2.8min, using fewer shards than the old table."; \ printf ' %-18s %s\n' "HARNESS_TIMINGS=path" ""; \ printf ' %-18s %s\n' "" " Per-request timing table (default tmp/harness-timings.json), refreshed at the end of every run"; \ printf ' %-18s %s\n' "" " and MERGED onto what was there, so a scoped run refines its own rows without erasing the"; \ printf ' %-18s %s\n' "" " providers it never ran. Falls back to tests/e2e/api/collections/harness-timings.json, which is"; \ printf ' %-18s %s\n' "" " what lets a fresh checkout and CI balance on their FIRST sweep. With no table at all the run"; \ printf ' %-18s %s\n' "" " falls back to the static HARNESS_SUBSHARDS counts and positional slicing - slower, never wrong."; \ printf ' %-18s %s\n' "HARNESS_JOBS=N" "Cap on concurrently running newman shards (default 100). The grid is ~168 live cells with sub-shards"; \ printf ' %-18s %s\n' "" " on, so the cap does block - which is why HARNESS_CLASSES is ordered slowest-first, to keep the long"; \ printf ' %-18s %s\n' "" " shards holding slots from the start. Lower it if a provider starts returning 429s."; \ printf ' %-18s %s\n' "HARNESS_MAX_REQUESTS=N" ""; \ printf ' %-18s %s\n' "" " Approved ceiling on paid requests for this run. Checked with each newman launch's EXACT filtered"; \ printf ' %-18s %s\n' "" " request count (main shards, 429 replays, the deferred cache-parity pass, sequential mode) before it"; \ printf ' %-18s %s\n' "" " starts: a launch that would cross the cap is refused, so the live total never exceeds N. Running"; \ printf ' %-18s %s\n' "" " shards drain and the report merges as usual; the target exits 3. Stream-cancellation probes are"; \ printf ' %-18s %s\n' "" " never sent under a cap (their count is not known up front). Unset = no cap (default)."; \ printf ' %-18s %s\n' "RETRY_429=N" "Max transient-failure retry attempts per shard (default 3; 0 disables). Covers 429 plus the two"; \ printf ' %-18s %s\n' "" " overload codes - 503 (OpenAI 'engine is currently overloaded') and 529 (Anthropic"; \ printf ' %-18s %s\n' "" " overloaded_error, which is its whole equivalent of 503; its error table has no 503). Other 5xx"; \ printf ' %-18s %s\n' "" " are NOT retried: a 500/502 is ambiguous between an upstream blip and a Bifrost defect, and"; \ printf ' %-18s %s\n' "" " finding the second kind is what this sweep is for. After the main pass, any shard that failed"; \ printf ' %-18s %s\n' "" " on one of those replays ONLY its affected rows, after waiting max(retry-after) across them"; \ printf ' %-18s %s\n' "" " or an exponential 5/10/20s when the provider sent no header (capped at 120s). A shard with any"; \ printf ' %-18s %s\n' "" " non-retryable failure stays failed however well the retry went, so a real defect is never masked."; \ printf ' %-18s %s\n' "" " Retry reports merge LAST, so a successful attempt supersedes its own failure in tmp/newman-report.json."; \ printf ' %-18s %s\n' "SHARD_LINES=0" "Drop the per-shard completion lines ( N total/pass/fail) and show only the provider table."; \ printf ' %-18s %s\n' "SKIP_STREAM_CANCEL=1" "Skip the post-Newman stream-abort probes that verify server-side cancellation on client disconnect."; \ printf ' %-18s %s\n' "NONSTREAM_TRIALS=6" "Non-streaming abort trials per provider in those probes; every trial must leave a terminal log row (#6972). Default 6."; \ printf ' %-18s %s\n' "HARNESS_SERVER_CWD" "Server working directory for relative logs_store SQLite paths (default: transports/bifrost-http, matching make dev). BIFROST_LOGS_DB_URL overrides config resolution."; \ printf ' %-18s %s\n' "DB_VERIFY=0" "Disable the dbverify reporter (ON by default). When on, [Costing]/[Accounting] requests assert the logs DB cost matches the getbifrost.ai/datasheet-computed cost (resolves DB from APP_DIR/config.json or BIFROST_LOGS_DB_URL); skips gracefully if no logs DB is reachable."; \ printf ' %-18s %s\n' "USE_INFISICAL=1" "Source secrets from Infisical CLI ('infisical export --path /local --format dotenv') instead of .env."; \ printf ' %-18s %s\n' "VERTEX_GCS_BUCKET" "Env-sourced (.env/Infisical): GCS bucket for Vertex file ops (forwarded to Newman as vertexGcsBucket)."; \ printf ' %-18s %s\n' "VERTEX_GCS_PREFIX" "Env-sourced: GCS object prefix for Vertex file ops (forwarded as vertexGcsPrefix)."; \ printf ' %-18s %s\n' "CHATGPT_ACCESS_TOKEN" "Env-sourced: ChatGPT sign-in access token for the Codex passthrough cases (forwarded as chatgptAccessToken; they skip when unset)."; \ printf '\n%s\n' "$(CYAN)Token Parity Matrix$(NC) ('Cross-Cut Round 33', generated - FOLDER or FEATURE=\"token parity\" to target it)"; \ printf ' %s\n' "Runs the same 3-round conversation directly against each provider AND through Bifrost, asserts usage lands in the same"; \ printf ' %s\n' "range, and writes tmp/harness-token-parity.md. Reuses the SAME env vars tests/integrations/python/config.json already reads"; \ printf ' %s\n' "for Bifrost's own provider config (sourced via .env/Infisical, same as everything else in this target - no separate setup):"; \ printf ' %s\n' " openai/anthropic/gemini: OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY -> {{openaiKey}}/{{anthropicKey}}/{{genaiKey}}."; \ printf ' %s\n' " bedrock: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION (same creds Bifrost's own bedrock provider uses), plus {{bedrockModel}} via ENV_FILE."; \ printf ' %s\n' " vertex: VERTEX_PROJECT_ID / GOOGLE_LOCATION for project/region, plus gcloud CLI on PATH + authenticated ('gcloud auth login') -"; \ printf ' %s\n' " the Makefile mints a fresh OAuth access token per run (VERTEX_CREDENTIALS in config.json is a service-account key,"; \ printf ' %s\n' " not a bearer token Postman can use directly, so this is the one leg that still needs its own auth step)."; \ printf ' %s\n' " ENV_FILE still overrides any of the above if set - these are just sane defaults from what's already injected."; \ printf ' %s\n' " Skipped cells (provider genuinely can't do it, e.g. OpenAI+PDF, Anthropic+audio/video) are listed in the folder description."; \ printf '\n%s\n' "$(CYAN)Cache Parity Matrices$(NC) ('Cross-Cut Rounds 34 + 35', generated - pick 'cache-parity' in the menu or FEATURE=\"cache\")"; \ printf ' %s\n' "Round 34 (Mid-Conversation System Cache-Anchor Parity) runs the same conversation on three legs - direct Bedrock Converse"; \ printf ' %s\n' " over SigV4, Bifrost /anthropic/v1/messages, Bifrost /openai/v1/responses - and diffs the cache read/write counts. The direct"; \ printf ' %s\n' " leg is the baseline: a Bifrost leg reading materially less on identical bytes means a dropped cache breakpoint."; \ printf ' %s\n' "Round 35 (Cross-Provider Prompt-Cache Matrix) sweeps provider x model x explicit/implicit mechanism through Bifrost."; \ printf ' %s\n' "Both write tmp/harness-cache-parity.md. Credentials reuse the same AWS_*/provider keys as the Round 33 token-parity matrix."; \ printf ' %s\n' "Selecting 'cache-parity' in the interactive menu does NOT drop the run to PARALLEL=0: those rows are pulled out of the main"; \ printf ' %s\n' " pass (which keeps its per-provider parallelism) and replayed afterwards in one sequential newman, because they match every"; \ printf ' %s\n' " provider fork and would otherwise run up to six times each. A FEATURE whose comma-separated keywords include"; \ printf ' %s\n' " 'cache-parity' (matched case-insensitively, like FEATURE itself) gets the same treatment, and the whole FEATURE and"; \ printf ' %s\n' " FOLDER predicate is forwarded to that pass so the scope still applies. RERUN_FAILED=1 does not defer, because the"; \ printf ' %s\n' " failed-row selection comes from the main pass it would skip. A keyword that merely contains the key, such as"; \ printf ' %s\n' " FEATURE=\"cache\", is not the key and has no such protection - add PARALLEL=0 yourself in that case."; \ printf '\n%s\n' "$(YELLOW)EXAMPLES$(NC)"; \ printf ' %s\n' "make run-provider-harness-test HELP=1"; \ printf ' %s\n' "make run-provider-harness-test # full provider sweep"; \ printf ' %s\n' "make smoke-provider-harness-test # curated ~100-request smoke set (all providers)"; \ printf ' %s\n' "make run-provider-harness-test SMOKE=1 PROVIDER=bedrock # the smoke set, bedrock rows only"; \ printf ' %s\n' "make run-provider-harness-test PROVIDER=bedrock # bedrock-only"; \ printf ' %s\n' "make run-provider-harness-test FEATURE=\"web search\" # all providers, web-search entries"; \ printf ' %s\n' "make run-provider-harness-test FEATURE=\"cross-cut,structured output\" # AND of substrings"; \ printf ' %s\n' "make run-provider-harness-test RERUN_FAILED=1 # triage iteration loop"; \ printf ' %s\n' "make run-provider-harness-test PROVIDER=anthropic RERUN_FAILED=1 # anthropic failures only"; \ printf ' %s\n' "make run-provider-harness-test PROVIDER=passthrough # passthrough sweep (incl. Bedrock SigV4)"; \ printf ' %s\n' "make run-provider-harness-test CI=1 USE_INFISICAL=1 # CI run with Infisical secrets"; \ printf '\n%s\n' "$(YELLOW)ARTIFACTS$(NC)"; \ printf ' %-30s %s\n' "tmp/newman-report.json" "Machine-readable run report (used by RERUN_FAILED and the analyzer)."; \ printf ' %-30s %s\n' "tmp/newman-cli.log" "Captured newman CLI output (stdout+stderr)."; \ printf ' %-30s %s\n' "tmp/newman-cli-cache-parity.log" "Newman CLI output of the deferred sequential cache-parity pass (also appended to tmp/newman-cli.log)."; \ printf ' %-30s %s\n' "tmp/harness-quiet.log" "Everything the target would have echoed, when CI=1 or a live status table suppressed it."; \ printf ' %-30s %s\n' "tmp/harness-monitor-passes.jsonl" "Pass manifest the single run-wide status table follows (one record per newman invocation)."; \ printf ' %-30s %s\n' "tmp/harness-failures.md" "Categorized failure analyzer output + coverage matrices."; \ printf ' %-30s %s\n' "tmp/bifrost-dev.log" "Bifrost runtime log (only if we auto-started it)."; \ printf ' %-30s %s\n' "tmp/harness-mcp-fixture.log" "MCP test server log (sse_mcp on 3012; only if we started it)."; \ printf ' %-30s %s\n' "tmp/harness-relay-fixture.log" "Relay upstream fixture log (relays-e2e rows, 127.0.0.1:8783; only if we started it)."; \ printf ' %-30s %s\n' "tmp/harness-augmented.json" "Provider harness plus generated streaming/thinking rows."; \ printf ' %-30s %s\n' "tmp/harness-filtered.json" "Filtered collection (only if PROVIDER/FEATURE/RERUN_FAILED set)."; \ printf ' %-30s %s\n' "tmp/harness-timings.json" "Measured median ms per request name, merged across runs. Drives both the shard sizing and the"; \ printf ' %-30s %s\n' "" " slice fill. Safe to delete: the next run falls back to the static table and rewrites it."; \ printf ' %-30s %s\n' "tmp/harness-shard-plan.txt" "The sized grid this run launched: ' ' per cell."; \ printf ' %-30s %s\n' "" " Absent means no timing table was available and the static HARNESS_SUBSHARDS counts were used."; \ printf ' %-30s %s\n' "tmp/newman-report-.json" "Per-shard newman report (parallel mode only). is \"--\", \"---s\" for a sub-sharded class, or plain \"\" under CLASS_SHARDS=0."; \ printf ' %-30s %s\n' "tmp/parallel-exit-" "Exit code of each shard's newman process. Read instead of 'wait ' because the HARNESS_JOBS cap reaps pids as slots free up."; \ printf ' %-30s %s\n' "tmp/newman-cli-

.log" "Per-provider newman stdout/stderr (parallel mode only)."; \ printf ' %-30s %s\n' "tmp/parallel-status" "Per-provider pass/fail summary (parallel mode only)."; \ printf ' %-30s %s\n' "tmp/newman-report.html" "htmlextra report (sequential mode only — PARALLEL=0)."; \ printf ' %-30s %s\n' "tmp/stream-cancel-report.json" "Server-side stream cancellation probe report."; \ printf ' %-30s %s\n' "tmp/harness-token-parity-*.json" "Per-newman-process token parity fragments (one per provider fork in parallel mode)."; \ printf ' %-30s %s\n' "tmp/harness-token-parity.md" "Direct-vs-Bifrost token usage table (prompt/completion/cached/total per backend+modality) - see Token Parity Matrix above."; \ printf ' %-30s %s\n' "" " Same table is also injected into tmp/newman-report.html when present (sequential mode / PARALLEL=0 only)."; \ printf ' %-30s %s\n' "tmp/harness-cache-parity-*.json" "Per-newman-process cache parity fragments (CACHE_ANCHOR_REPORT + CACHE_MATRIX_REPORT blobs)."; \ printf ' %-30s %s\n' "tmp/harness-cache-parity.md" "Round 34 direct-vs-Bifrost cache hit-rate table + Round 35 cross-provider matrix - see Cache Parity Matrices above."; \ printf ' %-30s %s\n' "" " Both parity .md files are DELETED at run start alongside their fragments, because rendering is skipped"; \ printf ' %-30s %s\n' "" " when a run produces no fragments (scope excluded those rows, or the reporter deps failed to install)."; \ printf ' %-30s %s\n' "" " Absent means 'this run measured none'; a leftover from an earlier run reads as current and contradicts"; \ printf ' %-30s %s\n' "" " the freshly written tmp/harness-failures.md, which is rewritten unconditionally on every run."; \ printf ' %-30s %s\n' "tmp/newman-report-cache-parity.json" "Newman report for the deferred sequential cache pass (merged into tmp/newman-report.json)."; \ printf ' %-30s %s\n' "tmp/newman-report-serial.json" "Newman report for the serial pass: [SERIAL] folders run after the parallel sweep, alone against the gateway (merged into tmp/newman-report.json)."; \ printf ' %-30s %s\n' "tmp/newman-cli-serial.log" "Newman CLI output of the serial pass (also appended to tmp/newman-cli.log)."; \ printf ' %-30s %s\n' "tmp/newman-merge.jq" "jq program used to merge per-provider + cache-pass reports into tmp/newman-report.json."; \ printf '\n'; \ exit 0; \ fi @if [ -n "$(HELP)" ]; then exit 0; fi; \ HARNESS_QUIET=0; \ if [ -n "$$CI" ] || [ -n "$(CI)" ]; then HARNESS_QUIET=1; fi; \ HARNESS_MONITORED=0; \ mkdir -p tmp; \ QUIET_LOG="$(CURDIR)/tmp/harness-quiet.log"; \ : > "$$QUIET_LOG"; \ MONITOR_ROSTER="$(or $(PROVIDER),$(HARNESS_PROVIDERS))"; \ MONITOR_PASSES="tmp/harness-monitor-passes.jsonl"; \ MONITOR_LIVE=0; \ : "Per-shard totals, reported as each shard finishes. On by default: with the provider x class"; \ : "grid running up to 80 shards, the provider row says how much is failing but not which slice,"; \ : "and that is otherwise only recoverable from 80 separate tmp/newman-cli-*.log files."; \ : "SHARD_LINES=0 drops back to the plain table."; \ STREAM_FLAG=""; \ if [ "$(SHARD_LINES)" = "0" ]; then STREAM_FLAG="--shard-lines 0"; fi; \ : > "$$MONITOR_PASSES"; \ if [ -f tmp/harness-monitor.pid ]; then \ kill $$(cat tmp/harness-monitor.pid) 2>/dev/null || true; \ rm -f tmp/harness-monitor.pid; \ fi; \ say() { \ if [ "$$HARNESS_QUIET" = "1" ] || [ "$$MONITOR_LIVE" = "1" ]; then \ printf '%b\n' "$$@" >> "$$QUIET_LOG"; \ else printf '%b\n' "$$@"; fi; \ }; \ run_quiet() { \ if [ "$$HARNESS_QUIET" = "1" ] || [ "$$MONITOR_LIVE" = "1" ]; then "$$@" >> "$$QUIET_LOG" 2>&1; \ else "$$@"; fi; \ }; \ : "HARNESS_MAX_REQUESTS is the approved paid-request ceiling for this run. Every newman"; \ : "launch below passes its exact filtered request count here first; a launch that would"; \ : "cross the cap is refused and later launches are refused too, so the live total is"; \ : "bounded by construction rather than estimated from the preflight. Shards already"; \ : "running drain normally and the report still merges; the target then exits 3."; \ BUDGET_USED=0; BUDGET_EXCEEDED=0; \ : "tmp/harness-budget-used mirrors BUDGET_USED so a COMPAT=both parent can hand the"; \ : "remaining budget to its second sub-run instead of letting both spend the full cap."; \ mkdir -p tmp; printf '0' > tmp/harness-budget-used; \ budget_ok() { \ if [ -z "$(HARNESS_MAX_REQUESTS)" ]; then return 0; fi; \ if [ "$$BUDGET_EXCEEDED" = "1" ] || [ $$((BUDGET_USED + $$1)) -gt "$(HARNESS_MAX_REQUESTS)" ]; then \ BUDGET_EXCEEDED=1; \ say "$(RED)[$$2] not launched: $$1 request(s) would push the total to $$((BUDGET_USED + $$1)), past HARNESS_MAX_REQUESTS=$(HARNESS_MAX_REQUESTS) (launched so far: $$BUDGET_USED)$(NC)"; \ return 1; \ fi; \ BUDGET_USED=$$((BUDGET_USED + $$1)); \ printf '%s' "$$BUDGET_USED" > tmp/harness-budget-used; \ return 0; \ }; \ start_monitor() { \ if [ -f tmp/harness-monitor.pid ]; then return 0; fi; \ if [ "$$HARNESS_QUIET" != "1" ] && [ ! -t 1 ]; then return 0; fi; \ $(USE_NODE); \ if [ "$$HARNESS_QUIET" = "1" ]; then \ node tests/e2e/api/runners/harness-monitor.mjs \ --providers "$$MONITOR_ROSTER" --tmp-dir tmp --passes "$$MONITOR_PASSES" \ --ci --ci-interval "$(or $(MONITOR_INTERVAL),5)" \ $$STREAM_FLAG \ $(if $(MONITOR_TABLE_REPRINT),--ci-reprint-table,) < /dev/null & \ else \ node tests/e2e/api/runners/harness-monitor.mjs \ --providers "$$MONITOR_ROSTER" --tmp-dir tmp --passes "$$MONITOR_PASSES" \ $$STREAM_FLAG \ < /dev/null > /dev/tty 2>&1 & \ fi; \ echo $$! > tmp/harness-monitor.pid; \ HARNESS_MONITORED=1; MONITOR_LIVE=1; \ }; \ add_pass() { \ printf '%s\n' "$$1" >> "$$MONITOR_PASSES"; \ start_monitor; \ }; \ end_pass() { \ [ -f tmp/harness-monitor.pid ] || return 0; \ printf '{"t":"pass-end","id":"%s"}\n' "$$1" >> "$$MONITOR_PASSES"; \ }; \ monitor_note() { \ [ -f tmp/harness-monitor.pid ] || return 0; \ printf '{"t":"note","text":"%s"}\n' "$$1" >> "$$MONITOR_PASSES"; \ }; \ stop_monitor() { \ if [ -f tmp/harness-monitor.pid ]; then \ MPID=$$(cat tmp/harness-monitor.pid); \ kill -TERM $$MPID 2>/dev/null; \ wait $$MPID 2>/dev/null || true; \ rm -f tmp/harness-monitor.pid; \ fi; \ MONITOR_LIVE=0; \ }; \ if [ "$(COMPAT)" = "both" ]; then \ mkdir -p tmp; \ say "$(CYAN)COMPAT=both: running harness with compat OFF then ON (sub-runs forced CI=1 to skip the interactive viewer)...$(NC)"; \ : "Both sub-runs share one HARNESS_MAX_REQUESTS: the second gets what the first left."; \ BOTH_REMAINING="$(HARNESS_MAX_REQUESTS)"; BOTH_USED=0; \ for mode in off on; do \ if [ -n "$$BOTH_REMAINING" ] && [ "$$BOTH_REMAINING" -le 0 ]; then \ say "$(RED)compat $$mode not run: HARNESS_MAX_REQUESTS=$(HARNESS_MAX_REQUESTS) already spent by the earlier sub-run ($$BOTH_USED launched)$(NC)"; \ BOTH_RC=3; continue; \ fi; \ say "$(CYAN)=== Harness run: compat $$mode ===$(NC)"; \ $(MAKE) run-provider-harness-test COMPAT=$$mode CI=1 HARNESS_MAX_REQUESTS="$$BOTH_REMAINING"; \ RC=$$?; \ if [ -n "$$BOTH_REMAINING" ]; then \ SUB_USED="$$(cat tmp/harness-budget-used 2>/dev/null || echo 0)"; \ BOTH_USED=$$((BOTH_USED + SUB_USED)); BOTH_REMAINING=$$((BOTH_REMAINING - SUB_USED)); \ fi; \ mv -f tmp/newman-report.json "tmp/newman-report-compat-$$mode.json" 2>/dev/null || true; \ mv -f tmp/newman-report.html "tmp/newman-report-compat-$$mode.html" 2>/dev/null || true; \ mv -f tmp/harness-failures.md "tmp/harness-failures-compat-$$mode.md" 2>/dev/null || true; \ if [ "$$RC" -ne 0 ]; then say "$(RED)compat $$mode run failed (exit $$RC)$(NC)"; BOTH_RC=$$RC; fi; \ done; \ say "$(GREEN)COMPAT=both complete. Reports: tmp/newman-report-compat-{off,on}.{json,html}, tmp/harness-failures-compat-{off,on}.md$(NC)"; \ exit $${BOTH_RC:-0}; \ fi; \ $(EXPOSE_ENV); \ mkdir -p tmp; \ BASE_URL_VAL="$(or $(BASE_URL),http://localhost:8080)"; \ APP_DIR_VAL="$(or $(APP_DIR),tests/integrations/python)"; \ VIEWER_PORT_VAL="$(or $(VIEWER_PORT),8090)"; \ : "Measured per-request cost, used to size the shard grid and to fill each shard (see"; \ : "lib/shard-cost.mjs). tmp/ first because it is refreshed by every run and so tracks the"; \ : "collection as it changes; the committed baseline is the fallback that makes a fresh"; \ : "checkout and CI balanced on their FIRST sweep rather than on their second."; \ TIMINGS_FILE=""; \ for cand in "$(or $(HARNESS_TIMINGS),tmp/harness-timings.json)" tests/e2e/api/collections/harness-timings.json; do \ if [ -s "$$cand" ]; then TIMINGS_FILE="$$cand"; break; fi; \ done; \ DBVERIFY_REPORTER=""; DBVERIFY_ARGS=""; DBVERIFY_READY=0; E2E_DEPS_READY=0; \ if [ -d tests/e2e/api/node_modules ] && npm --prefix tests/e2e/api ls --depth=0 >/dev/null 2>&1; then \ E2E_DEPS_READY=1; \ else \ say "$(YELLOW)Installing e2e reporter deps (dbverify, token-parity)...$(NC)"; \ if (cd tests/e2e/api && run_quiet npm install --silent); then \ E2E_DEPS_READY=1; \ else \ say "$(YELLOW)e2e reporter dep install failed; dbverify cost checks and the token-parity reporter are disabled for this run$(NC)"; \ fi; \ fi; \ if [ "$$E2E_DEPS_READY" = "1" ]; then \ export NODE_PATH="$(CURDIR)/tests/e2e/api/node_modules$${NODE_PATH:+:$$NODE_PATH}"; \ fi; \ if [ "$(DB_VERIFY)" != "0" ] && [ "$$E2E_DEPS_READY" = "1" ]; then \ DBVERIFY_READY=1; \ DBVERIFY_REPORTER=",dbverify"; \ LOGS_DB_VAL="$$(node tests/e2e/api/lib/logs-db-url.js "$$APP_DIR_VAL/config.json" "$(or $(HARNESS_SERVER_CWD),$(CURDIR)/transports/bifrost-http)")"; \ export BIFROST_LOGS_DB_URL="$$LOGS_DB_VAL"; \ DBVERIFY_ARGS="--reporter-dbverify-config $$APP_DIR_VAL/config.json"; \ say "$(CYAN)dbverify reporter enabled (logs DB resolved from configuration or BIFROST_LOGS_DB_URL). Set DB_VERIFY=0 to disable.$(NC)"; \ fi; \ TOKEN_PARITY_REPORTER=""; \ CACHE_PARITY_REPORTER=""; \ if [ "$$E2E_DEPS_READY" = "1" ]; then \ TOKEN_PARITY_REPORTER=",token-parity"; \ CACHE_PARITY_REPORTER=",cache-parity"; \ fi; \ rm -f tmp/harness-token-parity-*.json tmp/harness-cache-parity-*.json tmp/harness-token-parity.md tmp/harness-cache-parity.md; \ cp tests/e2e/api/runners/lib/newman-merge.jq tmp/newman-merge.jq; \ if command -v gcloud > /dev/null 2>&1; then \ VERTEX_ACCESS_TOKEN_VAL="$$(gcloud auth print-access-token 2>/dev/null)"; \ if [ -n "$$VERTEX_ACCESS_TOKEN_VAL" ]; then \ say "$(CYAN)Vertex direct-call access token minted via gcloud (expires in ~1h).$(NC)"; \ else \ say "$(YELLOW)gcloud present but 'gcloud auth print-access-token' failed - Vertex direct-provider parity cells will 401 (run 'gcloud auth login' to fix).$(NC)"; \ fi; \ else \ VERTEX_ACCESS_TOKEN_VAL=""; \ say "$(YELLOW)gcloud not found - Vertex direct-provider parity cells will 401 (install the gcloud CLI and auth to enable them).$(NC)"; \ fi; \ STARTED_BY_US=0; \ cleanup() { \ if [ -f tmp/harness-monitor.pid ]; then \ MPID=$$(cat tmp/harness-monitor.pid); \ kill $$MPID 2>/dev/null; \ rm -f tmp/harness-monitor.pid; \ fi; \ if [ -f tmp/harness-viewer.pid ]; then \ VPID=$$(cat tmp/harness-viewer.pid); \ kill $$VPID 2>/dev/null; \ rm -f tmp/harness-viewer.pid; \ fi; \ if [ "$$STARTED_BY_US" = "1" ] && [ -f tmp/bifrost-dev.pid ]; then \ BPID=$$(cat tmp/bifrost-dev.pid); \ say "$(YELLOW)Stopping Bifrost (pid $$BPID) - we started it...$(NC)"; \ kill $$BPID 2>/dev/null; \ pkill -P $$BPID 2>/dev/null; \ rm -f tmp/bifrost-dev.pid; \ fi; \ if [ -f tmp/harness-mcp-fixture.pid ]; then \ kill $$(cat tmp/harness-mcp-fixture.pid) 2>/dev/null; \ rm -f tmp/harness-mcp-fixture.pid; \ fi; \ if [ -f tmp/harness-relay-fixture.pid ]; then \ kill $$(cat tmp/harness-relay-fixture.pid) 2>/dev/null; \ rm -f tmp/harness-relay-fixture.pid; \ fi; \ }; \ preempt_viewer_port() { \ if [ -f tmp/harness-viewer.pid ]; then \ OLD=$$(cat tmp/harness-viewer.pid); \ if kill -0 $$OLD 2>/dev/null; then \ say "$(YELLOW)Killing orphaned viewer pid $$OLD from a prior run...$(NC)"; \ kill $$OLD 2>/dev/null; sleep 1; \ fi; \ rm -f tmp/harness-viewer.pid; \ fi; \ pkill -f "tests/e2e/api/runners/harness-viewer.mjs" 2>/dev/null || true; \ if command -v lsof > /dev/null 2>&1 && lsof -ti tcp:$$VIEWER_PORT_VAL > /dev/null 2>&1; then \ say "$(YELLOW)Port $$VIEWER_PORT_VAL still in use - freeing it...$(NC)"; \ lsof -ti tcp:$$VIEWER_PORT_VAL | xargs kill 2>/dev/null || true; \ sleep 1; \ fi; \ }; \ trap cleanup EXIT INT TERM HUP; \ PICKED_FEATURES=""; \ if [ -t 0 ] && [ -t 1 ] && [ -z "$$CI" ] && [ -z "$(CI)" ] \ && [ -z "$(PROVIDER)" ] && [ -z "$(FEATURE)" ] && [ -z "$(FOLDER)" ] \ && [ -z "$(RERUN_FAILED)" ] && [ -z "$(SMOKE)" ]; then \ $(USE_NODE); \ PICKED_FEATURES=$$(node tests/e2e/api/runners/pick-features.mjs); \ PICK_RC=$$?; \ case $$PICK_RC in \ 0) ;; \ 1) say "$(YELLOW)Cancelled.$(NC)"; exit 1 ;; \ 2) ;; \ *) exit $$PICK_RC ;; \ esac; \ if [ -n "$$PICKED_FEATURES" ]; then \ say "$(GREEN)Modalities: $$PICKED_FEATURES$(NC)"; \ else \ say "$(GREEN)Modalities: all (no filter)$(NC)"; \ fi; \ fi; \ : "The profile's sse_mcp client (localhost:3012) serves the injected web_search tool."; \ : "Without it injection fails open and every injected-web-search row fails, so start the"; \ : "examples/mcps/remote-test-server fixture before the gateway resolves its MCP clients."; \ MCP_FIXTURE_STARTED=0; \ if nc -z localhost 3012 2>/dev/null; then \ say "$(GREEN)MCP test server already listening on 3012$(NC)"; \ else \ MCP_FIXTURE_BIN="$(CURDIR)/examples/mcps/remote-test-server/bin/remote-test-server"; \ if [ ! -x "$$MCP_FIXTURE_BIN" ] || [ examples/mcps/remote-test-server/main.go -nt "$$MCP_FIXTURE_BIN" ]; then \ say "$(CYAN)Building MCP test server (examples/mcps/remote-test-server)...$(NC)"; \ (cd examples/mcps/remote-test-server && GOWORK=off go build -o bin/remote-test-server .) || { \ say "$(RED)Could not build the MCP test server (examples/mcps/remote-test-server); not running the harness against a stale or missing fixture$(NC)"; \ exit 1; \ }; \ fi; \ if [ -x "$$MCP_FIXTURE_BIN" ]; then \ MCP_HTTP_PORT=3011 MCP_SSE_PORT=3012 "$$MCP_FIXTURE_BIN" > tmp/harness-mcp-fixture.log 2>&1 & \ echo $$! > tmp/harness-mcp-fixture.pid; \ for i in $$(seq 1 40); do nc -z localhost 3012 2>/dev/null && break; sleep 0.25; done; \ if nc -z localhost 3012 2>/dev/null; then \ say "$(GREEN)MCP test server up on 3012 (log: tmp/harness-mcp-fixture.log)$(NC)"; \ MCP_FIXTURE_STARTED=1; \ else \ say "$(RED)MCP test server did not open 3012. See tmp/harness-mcp-fixture.log$(NC)"; \ fi; \ fi; \ fi; \ : "The profile's relays (harness-relay-*) point at a local upstream on 127.0.0.1:8783, so the"; \ : "relays-e2e rows never reach a third party. The gateway dials it per request, not at boot."; \ if nc -z 127.0.0.1 8783 2>/dev/null; then \ if curl -fsS --max-time 2 http://127.0.0.1:8783/__relay/health 2>/dev/null | grep -q '"fixture":"relay-upstream"'; then \ say "$(GREEN)Relay upstream fixture already listening on 8783$(NC)"; \ else \ say "$(RED)Port 8783 is taken by something other than the relay upstream fixture (no /__relay/health match); relays-e2e rows will fail until it is freed.$(NC)"; \ case "$(FEATURE)" in *relay*) exit 1;; esac; \ fi; \ else \ $(USE_NODE); RELAY_UPSTREAM_FIXTURE_PORT=8783 node tests/e2e/api/runners/relay-upstream-fixture.mjs > tmp/harness-relay-fixture.log 2>&1 & \ echo $$! > tmp/harness-relay-fixture.pid; \ for i in $$(seq 1 40); do nc -z 127.0.0.1 8783 2>/dev/null && break; sleep 0.25; done; \ if nc -z 127.0.0.1 8783 2>/dev/null; then \ say "$(GREEN)Relay upstream fixture up on 8783 (log: tmp/harness-relay-fixture.log)$(NC)"; \ else \ say "$(RED)Relay upstream fixture did not open 8783; relays-e2e rows will fail. See tmp/harness-relay-fixture.log$(NC)"; \ case "$(FEATURE)" in *relay*) exit 1;; esac; \ fi; \ fi; \ if curl -fsS --max-time 2 "$$BASE_URL_VAL/health" > /dev/null 2>&1; then \ say "$(GREEN)Bifrost already running at $$BASE_URL_VAL$(NC)"; \ if [ "$$MCP_FIXTURE_STARTED" = "1" ]; then \ say "$(YELLOW)The MCP test server started after this gateway booted, so its sse_mcp client may still be disconnected; restart the gateway if injected-web-search rows fail.$(NC)"; \ fi; \ else \ say "$(YELLOW)Bifrost not running - launching 'make dev' (APP_DIR=$$APP_DIR_VAL) in background...$(NC)"; \ $(MAKE) dev APP_DIR="$$APP_DIR_VAL" > tmp/bifrost-dev.log 2>&1 & \ echo $$! > tmp/bifrost-dev.pid; \ STARTED_BY_US=1; \ say "$(CYAN)Waiting for Bifrost /health to respond (up to 60s)...$(NC)"; \ for i in $$(seq 1 30); do \ if curl -fsS --max-time 2 "$$BASE_URL_VAL/health" > /dev/null 2>&1; then \ say "$(GREEN)Bifrost is up$(NC)"; break; \ fi; \ sleep 2; \ done; \ if ! curl -fsS --max-time 2 "$$BASE_URL_VAL/health" > /dev/null 2>&1; then \ say "$(RED)Bifrost did not become healthy. See tmp/bifrost-dev.log$(NC)"; \ exit 1; \ fi; \ fi; \ : "Rows that assert on extra_fields.raw_request send x-bf-send-back-raw-*; core ignores those"; \ : "headers unless client_config.allow_per_request_raw_override is on. The harness config.json"; \ : "already enables it for a gateway we start ourselves; an already-running one may not have it."; \ say "$(CYAN)Ensuring allow_per_request_raw_override is on (x-bf-send-back-raw-* headers)...$(NC)"; \ BIFROST_BASE_URL="$$BASE_URL_VAL" node tests/e2e/api/runners/set-raw-override-config.mjs enable || { \ say "$(RED)Could not enable allow_per_request_raw_override; raw_request assertions would fail. Set BIFROST_E2E_AUTH_HEADER if auth is on.$(NC)"; \ exit 1; \ }; \ say "$(CYAN)Augmenting provider harness with generated streaming/thinking cases...$(NC)"; \ : "VERTEX_ACCESS_TOKEN_VAL is exported so the token-parity matrix can skip the Vertex"; \ : "direct legs when gcloud could not mint a token, instead of emitting cells that post an"; \ : "unresolved {{vertexAccessToken}} placeholder and 401 en masse."; \ export VERTEX_ACCESS_TOKEN_VAL; \ $(USE_NODE); run_quiet node tests/e2e/api/runners/augment-provider-harness.mjs \ --source tests/e2e/api/collections/provider-harness.json \ --out tmp/harness-augmented.json || { say "$(RED)Harness augmentation failed$(NC)"; exit 1; }; \ COLLECTION_FILE="tmp/harness-augmented.json"; \ : "SMOKE_MANIFEST alone carries smoke mode: it is both the truth test and the flag value."; \ : "Packing --smoke and the path into one scalar would leave field splitting as the only"; \ : "thing separating them, so a manifest path containing a space would arrive truncated."; \ SMOKE_MANIFEST=""; \ if [ -n "$(SMOKE)" ]; then \ case "$(SMOKE)" in \ 1|true|TRUE|yes|YES|y|Y) SMOKE_MANIFEST="tests/e2e/api/collections/smoke-manifest.json" ;; \ *) SMOKE_MANIFEST="$(SMOKE)" ;; \ esac; \ if [ ! -f "$$SMOKE_MANIFEST" ]; then \ say "$(RED)SMOKE manifest not found: $$SMOKE_MANIFEST$(NC)"; exit 1; \ fi; \ say "$(CYAN)Smoke mode: selecting the curated set in $$SMOKE_MANIFEST.$(NC)"; \ fi; \ DEFERRED_KEYS="cache-parity"; \ MAIN_FEATURES=""; CACHE_PASS=0; SKIP_MAIN=0; PICK_SAW_DEFERRED=0; \ for k in $$(printf '%s' "$$PICKED_FEATURES" | tr ',' ' '); do \ IS_DEFERRED=0; \ for d in $$DEFERRED_KEYS; do if [ "$$k" = "$$d" ]; then IS_DEFERRED=1; fi; done; \ if [ "$$IS_DEFERRED" = "1" ]; then PICK_SAW_DEFERRED=1; \ else MAIN_FEATURES="$${MAIN_FEATURES:+$$MAIN_FEATURES,}$$k"; fi; \ done; \ : "Two separate decisions. CACHE_PASS: do the cache-parity rows run at all (in their own"; \ : "sequential newman). CARVE_OUT: are they kept out of the parallel forks. They used to be one"; \ : "flag, and that is how a menu pick without cache-parity ran every cache anchor anyway: the"; \ : "pick is an OR of modality aliases, 'chat' matches any request carrying messages, so the"; \ : "anchors rode in as chat rows - once per fork, and cost-sliced away from their write rounds."; \ : "Those rows are per-process state twice over (the collection-level pre-request script mints"; \ : "{{pcNonce}} once per newman process, and the rounds hand their counters to each other through"; \ : "collectionVariables), so a read round in a different process than its write round starts"; \ : "cold, writes again, and fails on read=0 for a reason that has nothing to do with the cache."; \ if [ -n "$$PICKED_FEATURES" ]; then \ : "The menu is the one place a deselected cache-parity means: do not run them anywhere."; \ CACHE_PASS=$$PICK_SAW_DEFERRED; \ elif [ -z "$(RERUN_FAILED)" ] && [ -z "$(FOLDER)" ] && [ -z "$(FEATURE)" ]; then \ CACHE_PASS=1; \ elif [ "$(or $(PARALLEL),1)" != "0" ]; then \ : "FEATURE/FOLDER/RERUN_FAILED are predicates that may well select cache rows. In parallel"; \ : "mode those rows must not be forked, so the cache pass runs them - ANDed with the same"; \ : "predicate, so it replays only what the scope selected, and skips itself when that is none."; \ : "PARALLEL=0 is one process in collection order, where a cell's rounds already share a"; \ : "nonce, so it keeps the row-level selection in the main pass."; \ CACHE_PASS=1; \ fi; \ if [ "$$CACHE_PASS" = "1" ] && [ -n "$$PICKED_FEATURES" ] && [ -z "$$MAIN_FEATURES" ]; then SKIP_MAIN=1; fi; \ FEATURE_TOKENS="$$(printf '%s' "$(FEATURE)" | tr 'A-Z,' 'a-z ')"; \ for d in $$DEFERRED_KEYS; do \ for t in $$FEATURE_TOKENS; do \ if [ "$$t" = "$$d" ] && [ -z "$(RERUN_FAILED)" ]; then CACHE_PASS=1; SKIP_MAIN=1; fi; \ done; \ done; \ : "SMOKE forces the deferred cache pass on. The default above turns CACHE_PASS on only when nothing"; \ : "is filtering the run, and SMOKE is a filter - so without this line a smoke run would carve the"; \ : "cache-parity rows out of the main pass via EXCLUDE_FLAG and then never replay them, silently"; \ : "dropping the third of the smoke set that exists to catch a dropped cache breakpoint."; \ if [ -n "$${SMOKE_MANIFEST:-}" ] && [ -z "$(RERUN_FAILED)" ]; then CACHE_PASS=1; SKIP_MAIN=0; fi; \ : "Computed last, after SMOKE has had its say: a parallel run always carves the cache rows out"; \ : "of the forks, whether or not a cache pass follows (a deselected menu pick: neither)."; \ CARVE_OUT=$$CACHE_PASS; \ if [ "$(or $(PARALLEL),1)" != "0" ]; then CARVE_OUT=1; fi; \ EXCLUDE_FLAG=""; \ if [ "$$CARVE_OUT" = "1" ]; then \ EXCLUDE_FLAG="--exclude-feature-any cache-parity"; \ if [ "$$CACHE_PASS" = "1" ]; then \ say "$(CYAN)cache-parity deferred to a sequential pass after the main run (its rows are per-process state, so the parallel forks would slice a cell's rounds apart and repeat each request once per fork).$(NC)"; \ else \ say "$(CYAN)cache-parity not selected - its rows are kept out of the parallel forks and do not run.$(NC)"; \ fi; \ fi; \ FEATURE_ANY_FLAG=""; \ if [ -n "$$MAIN_FEATURES" ]; then FEATURE_ANY_FLAG="--feature-any $$MAIN_FEATURES"; fi; \ : "[SERIAL] folders change gateway-wide state (a feature flag, client config) around the request"; \ : "under test. The parallel forks would run other rows against the gateway while one of them has"; \ : "it changed, so a parallel run carves them out of the forks and runs them afterwards in a pass"; \ : "of their own, alone against the gateway. A sequential run already sends one request at a time."; \ SERIAL_PASS=0; SERIAL_EXCLUDE_FLAG=""; SERIAL_RAN=0; MAIN_EMPTY=0; \ if [ "$(or $(PARALLEL),1)" != "0" ] && [ "$$SKIP_MAIN" != "1" ]; then SERIAL_PASS=1; SERIAL_EXCLUDE_FLAG="--exclude-serial"; fi; \ : "The main pass merges its shards over tmp/newman-report.json before the cache pass runs, so"; \ : "a rerun's selection for that pass is read from a snapshot taken now. Dot-prefixed so the"; \ : "tmp/newman-report-*.json merge glob never sweeps it in."; \ rm -f tmp/.newman-report-prior.json; \ if [ -n "$(RERUN_FAILED)" ] && [ -f tmp/newman-report.json ]; then cp -f tmp/newman-report.json tmp/.newman-report-prior.json; fi; \ if [ "$$SKIP_MAIN" != "1" ] && { [ -n "$(PROVIDER)" ] || [ -n "$(FEATURE)" ] || [ -n "$(FOLDER)" ] || [ -n "$(RERUN_FAILED)" ] || [ -n "$$MAIN_FEATURES" ] || [ -n "$$EXCLUDE_FLAG" ] || [ -n "$$SMOKE_MANIFEST" ]; }; then \ say "$(CYAN)Filtering collection (provider=$(PROVIDER), feature=$(FEATURE), folder=$(FOLDER), feature-any=$$MAIN_FEATURES, exclude=$${EXCLUDE_FLAG:+cache-parity}, smoke=$${SMOKE_MANIFEST:--}, rerun-failed=$(RERUN_FAILED))...$(NC)"; \ $(USE_NODE); run_quiet node tests/e2e/api/runners/filter-collection.mjs \ --source "$$COLLECTION_FILE" \ --out tmp/harness-filtered.json \ $(if $(PROVIDER),--provider $(PROVIDER),) \ $(if $(FEATURE),--feature "$(FEATURE)",) \ $(if $(FOLDER),--folder "$(FOLDER)",) \ $$FEATURE_ANY_FLAG \ $$EXCLUDE_FLAG \ $$SERIAL_EXCLUDE_FLAG \ $${SMOKE_MANIFEST:+--smoke "$$SMOKE_MANIFEST"} \ $(if $(RERUN_FAILED),--rerun-failed --report tmp/newman-report.json,) || { say "$(RED)Filter step failed$(NC)"; exit 1; }; \ COLLECTION_FILE="tmp/harness-filtered.json"; \ fi; \ say "$(YELLOW)Running newman against $$BASE_URL_VAL using $$COLLECTION_FILE...$(NC)"; \ set -o pipefail; \ $(USE_NODE); \ PARALLEL_VAL="$(or $(PARALLEL),1)"; \ if [ "$$SKIP_MAIN" = "1" ]; then \ say "$(CYAN)cache-parity was the only selection - skipping the main pass and going straight to the sequential cache pass.$(NC)"; \ printf '%s' '{"collection":{},"environment":{},"run":{"executions":[],"failures":[],"stats":{"iterations":{"total":1,"pending":0,"failed":0},"items":{"total":0},"requests":{"total":0,"failed":0}},"timings":{}}}' > tmp/newman-report.json; \ : > tmp/newman-cli.log; \ NEWMAN_EXIT=0; \ elif [ "$$PARALLEL_VAL" != "0" ] && [ -n "$$PARALLEL_VAL" ]; then \ say "$(CYAN)Parallel mode (default): forking one newman per provider ($(HARNESS_PROVIDERS)) x modality class x sub-shard, slowest class first. Set PARALLEL=0 to disable, SUBSHARDS=0 to drop the sub-shard axis, CLASS_SHARDS=0 for one fork per provider.$(NC)"; \ : "harness-filtered-*.json is cleaned here too, not just the reports. The monitor derives"; \ : "each provider's denominator by summing the leaves of every shard collection it finds in"; \ : "tmp/, so a previous run with a wider FEATURE/FOLDER scope leaves shard files this run"; \ : "never launches: their rows join the total, the sweep reads larger than it is, and the ETA"; \ : "never converges. The 'filesRead < launched' latch cannot catch it - extra files raise"; \ : "filesRead rather than lower it. The unsharded tmp/harness-filtered.json is deliberately"; \ : "NOT matched by this glob (it has no '-' after 'filtered'): it was written above as"; \ : "COLLECTION_FILE and every shard below filters FROM it."; \ rm -f tmp/newman-report-*.json tmp/newman-cli-*.log tmp/parallel-pids tmp/parallel-status tmp/parallel-exit-* tmp/harness-filtered-*.json; \ : > tmp/parallel-pids; \ : > tmp/parallel-status; \ PROVIDERS="$(HARNESS_PROVIDERS)"; \ if [ -n "$(PROVIDER)" ]; then PROVIDERS="$(PROVIDER)"; fi; \ : "CLASS_SHARDS=0 collapses the class axis back to a single fork per provider. The empty"; \ : "string is the sentinel for 'do not pass --class', so the loop body stays one code path."; \ CLASSES="$(HARNESS_CLASSES)"; \ if [ "$(CLASS_SHARDS)" = "0" ]; then CLASSES="-"; fi; \ : "Sub-shard count for a (provider, class) cell. Prefers the measured plan in $$SHARD_PLAN,"; \ : "and falls back to the hand-written HARNESS_SUBSHARDS 'class=n' list; 1 when unlisted."; \ : "Returns 1 for the '-' class too - CLASS_SHARDS=0 collapses both axes, not just the first."; \ : ""; \ : "The plan is per PROVIDER as well as per class, which the static table cannot express and"; \ : "which is where most of the imbalance lived: 'reasoning' is one number, but openai spends"; \ : "~30 minutes there against embeddings' seconds, so any single count is wrong for one of"; \ : "them. An absent or empty plan file means no timings were available, and the static table"; \ : "stands - see filter-collection.mjs --plan, which writes nothing rather than a grid of 1s."; \ subshards_for() { \ SS_P="$$1"; SS_C="$$2"; SS_N=1; \ if [ "$(SUBSHARDS)" != "0" ] && [ "$$SS_C" != "-" ]; then \ SS_N=""; \ : "Braced default so the function is safe under 'set -u' both here (SHARD_PLAN is"; \ : "set just above the launch loop) and in shard-split.test.mjs, which extracts this"; \ : "block and runs it standalone to prove the test cannot pass against stale logic."; \ if [ -s "$${SHARD_PLAN:-}" ]; then \ SS_N="$$(awk -v p="$$SS_P" -v c="$$SS_C" '$$1==p && $$2==c {print $$3; exit}' "$$SHARD_PLAN")"; \ fi; \ if [ -z "$$SS_N" ]; then \ SS_N=1; \ for kv in $(HARNESS_SUBSHARDS); do \ case "$$kv" in "$$SS_C="*) SS_N="$${kv#*=}" ;; esac; \ done; \ fi; \ fi; \ printf '%s' "$$SS_N"; \ }; \ JOBS_CAP="$(or $(HARNESS_JOBS),100)"; \ say "$(CYAN)Shard concurrency cap: $$JOBS_CAP (HARNESS_JOBS).$(NC)"; \ : "Size the provider x class grid from measured per-request cost before launching it."; \ : "One planner invocation for the whole grid rather than one per cell: sizing a cell means"; \ : "running the same predicate stack that selects it, and doing that ~80 times would parse"; \ : "the 20MB collection ~80 times. Writes nothing when no timing table exists, which is the"; \ : "signal subshards_for reads as 'keep the static HARNESS_SUBSHARDS table'."; \ SHARD_PLAN="tmp/harness-shard-plan.txt"; \ rm -f "$$SHARD_PLAN"; \ if [ -n "$$TIMINGS_FILE" ] && [ "$(SUBSHARDS)" != "0" ]; then \ if node tests/e2e/api/runners/filter-collection.mjs \ --source "$$COLLECTION_FILE" --plan \ --providers "$$PROVIDERS" --classes "$$CLASSES" \ --timings "$$TIMINGS_FILE" \ --target "$(or $(HARNESS_SHARD_TARGET),150)" \ $(if $(FEATURE),--feature "$(FEATURE)",) \ $(if $(FOLDER),--folder "$(FOLDER)",) \ > "$$SHARD_PLAN" 2>> "$$QUIET_LOG"; then \ say "$(CYAN)Shard plan sized from $$TIMINGS_FILE -> $$SHARD_PLAN ($$(awk '{s+=$$3} END{print s+0}' "$$SHARD_PLAN") shards across $$(awk 'END{print NR+0}' "$$SHARD_PLAN") cells, $(or $(HARNESS_SHARD_TARGET),150)s target).$(NC)"; \ else \ : "A planner that failed leaves a partial file, and a partial plan is worse than"; \ : "none: the cells it did emit would be measured while the rest silently fell back."; \ rm -f "$$SHARD_PLAN"; \ say "$(YELLOW)Shard planner failed - falling back to the static HARNESS_SUBSHARDS table (see $$QUIET_LOG)$(NC)"; \ fi; \ else \ say "$(YELLOW)No timing table yet - using the static HARNESS_SUBSHARDS table. It is written at the end of this run, so the next sweep balances by measured cost.$(NC)"; \ fi; \ : "One newman invocation, called from two places: the main launch loop and the 429 retry"; \ : "pass. Factored out so the retry cannot drift from the main run - the two must send the"; \ : "same env vars and reporters or the retry would exercise a different configuration than"; \ : "the failure it is meant to clear. Args: ."; \ : "Runs in the foreground and returns the PIPELINE status (pipefail is set above), so the"; \ : "caller can record it; backgrounding stays at the call site so pids remain trackable."; \ : "Running SHARD jobs, excluding every long-lived helper this shell backgrounded: the"; \ : "monitor, the MCP and relay fixtures and a gateway started by 'make dev'. A plain"; \ : "'jobs -pr | wc -l' counts them as shards, and with a low HARNESS_JOBS the count would"; \ : "sit at the cap while 'wait -n' blocks on a process that never exits."; \ shard_jobs() { \ SJ_SKIP="$$(cat tmp/harness-monitor.pid tmp/harness-mcp-fixture.pid tmp/harness-relay-fixture.pid tmp/bifrost-dev.pid 2>/dev/null)"; \ jobs -pr | grep -v -x -F "$${SJ_SKIP:-__none__}" | wc -l | tr -d ' '; \ }; \ newman_shard() { \ NS_SHARD="$$1"; NS_COLLECTION="$$2"; NS_REPORT="$$3"; NS_PROV="$$4"; \ newman run "$$NS_COLLECTION" \ --env-var "baseUrl=$$BASE_URL_VAL" \ $(if $(filter on true 1 yes YES y Y,$(COMPAT)),--env-var "compat=true",) \ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_PREVIEW)),--env-var "include_preview=1",) \ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_SKIP)),--env-var "include_skip=1",) \ $${BEDROCK_GUARDRAIL_IDENTIFIER:+--env-var "bedrockGuardrailIdentifier=$$BEDROCK_GUARDRAIL_IDENTIFIER"} \ $${BEDROCK_GUARDRAIL_VERSION:+--env-var "bedrockGuardrailVersion=$$BEDROCK_GUARDRAIL_VERSION"} \ $${VERTEX_GCS_BUCKET:+--env-var "vertexGcsBucket=$$VERTEX_GCS_BUCKET"} \ $${VERTEX_GCS_PREFIX:+--env-var "vertexGcsPrefix=$$VERTEX_GCS_PREFIX"} \ $${AWS_S3_BUCKET:+--env-var "awsS3Bucket=$$AWS_S3_BUCKET"} \ $${OPENAI_API_KEY:+--env-var "openaiKey=$$OPENAI_API_KEY"} \ $${CHATGPT_ACCESS_TOKEN:+--env-var "chatgptAccessToken=$$CHATGPT_ACCESS_TOKEN"} \ $${ANTHROPIC_API_KEY:+--env-var "anthropicKey=$$ANTHROPIC_API_KEY"} \ $${GEMINI_API_KEY:+--env-var "genaiKey=$$GEMINI_API_KEY"} \ $${AWS_ACCESS_KEY_ID:+--env-var "bedrockDirectAccessKeyId=$$AWS_ACCESS_KEY_ID"} \ $${AWS_SECRET_ACCESS_KEY:+--env-var "bedrockDirectSecretAccessKey=$$AWS_SECRET_ACCESS_KEY"} \ $${AWS_REGION:+--env-var "bedrockDirectRegion=$$AWS_REGION"} \ $${VERTEX_PROJECT_ID:+--env-var "vertexProject=$$VERTEX_PROJECT_ID"} \ $${GOOGLE_LOCATION:+--env-var "vertexLocation=$$GOOGLE_LOCATION"} \ $${VERTEX_ACCESS_TOKEN_VAL:+--env-var "vertexAccessToken=$$VERTEX_ACCESS_TOKEN_VAL"} \ $(if $(ENV_FILE),--environment $(ENV_FILE),) \ $(if $(FOLDER),--folder "$(FOLDER)",) \ --reporters cli,json$$DBVERIFY_REPORTER$$TOKEN_PARITY_REPORTER $$DBVERIFY_ARGS \ $${TOKEN_PARITY_REPORTER:+--reporter-token-parity-out "tmp/harness-token-parity-$$NS_SHARD.json"} \ --reporter-json-export "$$NS_REPORT" 2>&1 | sed "s/^/[$$NS_PROV] /"; \ NS_RC=$$?; \ : "Assertions have already run against the full response, so the stored copy of large bodies"; \ : "(image/audio Buffers, ~4 JSON chars per byte) is dead weight: image-gen shards alone were 1.3GB."; \ : "Slim the report in place with the merge program (idempotent; same trimming the final merge did)."; \ : "Serialized with flock because slimming a big report takes several GB and shards finish together."; \ : "Only reports over 20MB are touched, and a failed slim leaves the original untouched."; \ if command -v jq >/dev/null 2>&1 && command -v flock >/dev/null 2>&1 && [ -f tmp/newman-merge.jq ] && [ "$$(stat -c %s "$$NS_REPORT" 2>/dev/null || echo 0)" -gt 20971520 ]; then \ flock tmp/.slim.lock sh -c 'jq -c -s -f tmp/newman-merge.jq "$$1" > "$$1.slim" && [ -s "$$1.slim" ] && mv -f "$$1.slim" "$$1" || rm -f "$$1.slim"' _ "$$NS_REPORT"; \ fi; \ return $$NS_RC; \ }; \ LAUNCHED=0; \ : "A shard whose filter step fails never reaches tmp/parallel-pids, so the verdict loop"; \ : "below cannot see it and PFAILED alone would report the run green with that slice never"; \ : "run. Counted separately and folded into NEWMAN_EXIT so a skipped shard fails the run."; \ FILTER_FAILED=0; \ for p in $$PROVIDERS; do \ for c in $$CLASSES; do \ SUB_N="$$(subshards_for "$$p" "$$c")"; \ SUB_K=0; \ : "SUB_K is bumped at the TOP of the body, not the bottom: the body below uses 'continue'"; \ : "for a failed filter and for an empty shard, and a bottom increment would be skipped by"; \ : "both - leaving SUB_K stuck and this while loop spinning on the same sub-shard forever."; \ while [ "$$SUB_K" -lt "$$SUB_N" ]; do \ SUB_K=$$((SUB_K+1)); \ if [ "$$c" = "-" ]; then SHARD="$$p"; CLASS_FLAG=""; else SHARD="$$p--$$c"; CLASS_FLAG="--class $$c"; fi; \ : "The -s suffix stays inside the '--' shape every consumer parses:"; \ : "the retry pass takes the provider as $${rs%%--*}, and harness-monitor.mjs matches"; \ : "shard files by the 'harness-filtered---' prefix and detects retry logs by a"; \ : "trailing -retry. A single-sub-shard class keeps its old unsuffixed name, so the"; \ : "common case produces byte-identical filenames to before this axis existed."; \ SHARD_FLAG=""; \ if [ "$$SUB_N" -gt 1 ]; then SHARD="$$SHARD-s$$SUB_K"; SHARD_FLAG="--shard $$SUB_K/$$SUB_N"; fi; \ : "--timings makes the slice balance measured cost instead of row count. Every"; \ : "sub-shard of a cell recomputes the whole assignment from the same table and keeps"; \ : "only its own slice, so they must all be passed the same file or the slices stop"; \ : "being a partition - rows would land in two shards and in none."; \ if ! node tests/e2e/api/runners/filter-collection.mjs \ --source "$$COLLECTION_FILE" \ --out "tmp/harness-filtered-$$SHARD.json" \ --provider "$$p" \ $$CLASS_FLAG \ $$SHARD_FLAG \ $${TIMINGS_FILE:+--timings "$$TIMINGS_FILE"} \ $(if $(FEATURE),--feature "$(FEATURE)",) \ $(if $(FOLDER),--folder "$(FOLDER)",) >> "$$QUIET_LOG" 2>&1; then \ say "$(RED)[$$SHARD] filter step failed - skipping (see $$QUIET_LOG)$(NC)"; \ FILTER_FAILED=$$((FILTER_FAILED+1)); \ continue; \ fi; \ P_ITEM_COUNT=$$(grep -c '"request":' "tmp/harness-filtered-$$SHARD.json" 2>/dev/null); \ P_ITEM_COUNT=$${P_ITEM_COUNT:-0}; \ if [ "$$P_ITEM_COUNT" -eq 0 ]; then \ rm -f "tmp/harness-filtered-$$SHARD.json"; \ continue; \ fi; \ : "A refused shard must not leave its filtered file behind: the status table counts"; \ : "every tmp/harness-filtered-*.json as planned work, so it would show rows that never ran."; \ budget_ok "$$P_ITEM_COUNT" "$$SHARD" || { rm -f "tmp/harness-filtered-$$SHARD.json"; continue; }; \ : "Block until a slot frees. 'wait -n' reaps one arbitrary child, which is why shard"; \ : "exit codes are recorded by the subshell into tmp/parallel-exit- instead of"; \ : "being collected later with 'wait ' - that pid may already have been reaped here."; \ while [ "$$(shard_jobs)" -ge "$$JOBS_CAP" ]; do wait -n 2>/dev/null || true; done; \ ( \ newman_shard "$$SHARD" "tmp/harness-filtered-$$SHARD.json" "tmp/newman-report-$$SHARD.json" "$$p"; \ echo "$$?" > "tmp/parallel-exit-$$SHARD"; \ ) > "tmp/newman-cli-$$SHARD.log" 2>&1 & \ BG_PID=$$!; \ LAUNCHED=$$((LAUNCHED+1)); \ echo "$$BG_PID:$$SHARD" >> tmp/parallel-pids; \ say "$(GREEN)[$$SHARD] launched (pid $$BG_PID, $$P_ITEM_COUNT requests)$(NC)"; \ done; \ done; \ done; \ MAIN_EMPTY=0; \ if [ "$$LAUNCHED" -eq 0 ]; then \ if [ "$$BUDGET_EXCEEDED" = "1" ]; then \ say "$(RED)Aborted before any launch: every shard would exceed HARNESS_MAX_REQUESTS=$(HARNESS_MAX_REQUESTS). Raise the cap or narrow PROVIDER/FEATURE.$(NC)"; \ exit 3; \ fi; \ : "A filter that matches only [SERIAL] rows launches no fork: those rows were carved out"; \ : "for the serial pass below, which decides after it runs whether anything ran at all."; \ if [ "$$SERIAL_PASS" != "1" ]; then \ say "$(RED)No provider runs were launched. Check PROVIDER/FEATURE/FOLDER filters.$(NC)"; \ exit 1; \ fi; \ MAIN_EMPTY=1; \ say "$(CYAN)No parallel rows match the filters; going straight to the serial pass.$(NC)"; \ fi; \ if [ "$$MAIN_EMPTY" = "1" ]; then \ : "Nothing to drain, retry or merge. The serial pass merges its report over this empty one,"; \ : "so the final report is the serial report (the same skeleton a cache-only run starts from)."; \ printf '%s' '{"collection":{},"environment":{},"run":{"executions":[],"failures":[],"stats":{"iterations":{"total":1,"pending":0,"failed":0},"items":{"total":0},"requests":{"total":0,"failed":0}},"timings":{}}}' > tmp/newman-report.json; \ : > tmp/newman-cli.log; \ NEWMAN_EXIT=$$FILTER_FAILED; \ else \ add_pass "$$(printf '{"t":"pass","id":"main","mode":"parallel","statusFile":"tmp/parallel-status","launched":%s}' "$$LAUNCHED")"; \ : "Drain the shards, then read verdicts from the exit files rather than from wait's status:"; \ : "the cap loop above already reaped an unknown subset with 'wait -n', so a status here is"; \ : "not trustworthy - hence '|| true' and the exit files."; \ : ""; \ : "MUST wait on the recorded pids, never a bare 'wait'. start_monitor backgrounds the"; \ : "monitor into THIS shell, so a bare wait blocks on it too - and the monitor only exits"; \ : "once tmp/parallel-status has 'launched' lines, which the loop below writes after this"; \ : "point. That is a deadlock: every shard finishes, the status file stays empty, and the"; \ : "run hangs forever reprinting a frozen heartbeat."; \ while read pidp; do wait "$${pidp%%:*}" 2>/dev/null || true; done < tmp/parallel-pids; \ : "Transient-failure retry pass, covering 429 plus the overload codes 503 and 529 - see"; \ : "RETRYABLE_CODES in lib/rate-limit-retry.mjs for why those three and not the other 5xx."; \ : "Running ~80 shards at once against one set of keys makes rate limiting an"; \ : "expected outcome rather than an exotic one, and analyze-failures.mjs already classifies a"; \ : "429 as 'Backoff/retry; not a harness bug' - which is the tell that the run should do the"; \ : "backoff itself. Only the 429 ITEMS are replayed (--rerun-rate-limited), never the whole"; \ : "shard: replaying an assertion failure would burn quota re-confirming a real defect."; \ : "RETRY_429=0 disables. Reports land as newman-report--retry.json and are merged"; \ : "LAST so the successful attempt supersedes its own 429 (see lib/newman-merge.jq)."; \ RETRY_MAX="$(or $(RETRY_429),3)"; \ RETRY_ATTEMPT=1; \ while [ "$$RETRY_MAX" != "0" ] && [ "$$RETRY_ATTEMPT" -le "$$RETRY_MAX" ]; do \ RETRY_SHARDS=""; RETRY_WAIT=0; \ while read pidp; do \ rs="$${pidp#*:}"; \ [ "$$(cat "tmp/parallel-exit-$$rs" 2>/dev/null || echo 1)" = "0" ] && continue; \ : "Judge from the shard's LATEST attempt, not its original report. The original keeps"; \ : "its 429s forever, so reading it would re-queue a shard whose rows already recovered"; \ : "- a shard that also holds a real defect can never clear its verdict, and would"; \ : "otherwise replay the same recovered rows once per allowed attempt for nothing."; \ RS_LAST="$$(ls -1 tmp/newman-report-$$rs-retry*.json 2>/dev/null | sort -V | tail -1)"; \ RS_SRC="$${RS_LAST:-tmp/newman-report-$$rs.json}"; \ W="$$(node tests/e2e/api/runners/rate-limit-backoff.mjs --report "$$RS_SRC" --attempt "$$RETRY_ATTEMPT" 2>/dev/null || echo 0)"; \ [ "$${W:-0}" -gt 0 ] 2>/dev/null || continue; \ RETRY_SHARDS="$$RETRY_SHARDS $$rs"; \ [ "$$W" -gt "$$RETRY_WAIT" ] && RETRY_WAIT="$$W"; \ done < tmp/parallel-pids; \ [ -z "$$RETRY_SHARDS" ] && break; \ say "$(YELLOW)429 retry $$RETRY_ATTEMPT/$$RETRY_MAX: waiting $${RETRY_WAIT}s, then replaying rate-limited rows in:$$RETRY_SHARDS$(NC)"; \ monitor_note "429 retry $$RETRY_ATTEMPT: sleeping $${RETRY_WAIT}s"; \ sleep "$$RETRY_WAIT"; \ RETRY_PIDS=""; \ for rs in $$RETRY_SHARDS; do \ rp="$${rs%%--*}"; \ RETRY_COLL="tmp/harness-filtered-$$rs-retry$$RETRY_ATTEMPT.json"; \ RS_LAST="$$(ls -1 tmp/newman-report-$$rs-retry*.json 2>/dev/null | sort -V | tail -1)"; \ RS_SRC="$${RS_LAST:-tmp/newman-report-$$rs.json}"; \ : "Selects from the latest attempt too, so attempt N replays only the rows STILL"; \ : "rate-limited rather than every row that was ever throttled."; \ if ! node tests/e2e/api/runners/filter-collection.mjs \ --source "tmp/harness-filtered-$$rs.json" \ --out "$$RETRY_COLL" \ --rerun-rate-limited --report "$$RS_SRC" > /dev/null 2>&1; then \ say "$(YELLOW)[$$rs] retry filter failed - keeping the original verdict$(NC)"; \ continue; \ fi; \ RETRY_COUNT="$$(grep -c '"request":' "$$RETRY_COLL" 2>/dev/null || true)"; RETRY_COUNT="$${RETRY_COUNT:-0}"; \ [ "$$RETRY_COUNT" -eq 0 ] && continue; \ budget_ok "$$RETRY_COUNT" "$$rs-retry$$RETRY_ATTEMPT" || { rm -f "$$RETRY_COLL"; continue; }; \ while [ "$$(shard_jobs)" -ge "$$JOBS_CAP" ]; do wait -n 2>/dev/null || true; done; \ ( \ newman_shard "$$rs-retry$$RETRY_ATTEMPT" "$$RETRY_COLL" "tmp/newman-report-$$rs-retry$$RETRY_ATTEMPT.json" "$$rp"; \ RC=$$?; \ : "Clear the shard only when the retry passed AND every original failure was retryable."; \ : "A shard that also failed an assertion stays failed however well the retry went,"; \ : "otherwise a real defect sitting beside a 429 would be erased by the backoff."; \ if [ "$$RC" = "0" ] && [ "$$(node tests/e2e/api/runners/rate-limit-backoff.mjs --report "tmp/newman-report-$$rs.json" --query only-rate-limited 2>/dev/null || echo 0)" = "1" ]; then \ echo 0 > "tmp/parallel-exit-$$rs"; \ fi; \ ) > "tmp/newman-cli-$$rs-retry$$RETRY_ATTEMPT.log" 2>&1 & \ RETRY_PIDS="$$RETRY_PIDS $$!"; \ done; \ : "Same reason as the main drain: a bare wait would block on the backgrounded monitor."; \ for rpid in $$RETRY_PIDS; do wait "$$rpid" 2>/dev/null || true; done; \ RETRY_ATTEMPT=$$((RETRY_ATTEMPT+1)); \ done; \ PFAILED=0; \ while read pidp; do \ p="$${pidp#*:}"; \ SHARD_RC="$$(cat "tmp/parallel-exit-$$p" 2>/dev/null || echo 1)"; \ if [ "$$SHARD_RC" = "0" ]; then \ echo "$$p:pass" >> tmp/parallel-status; \ PVERDICT="$(GREEN)[$$p] passed$(NC)"; \ else \ echo "$$p:fail" >> tmp/parallel-status; \ PVERDICT="$(RED)[$$p] failed$(NC)"; \ PFAILED=$$((PFAILED+1)); \ fi; \ if [ ! -f tmp/harness-monitor.pid ]; then \ say "$$PVERDICT"; \ tail -n 20 "tmp/newman-cli-$$p.log" 2>/dev/null; \ fi; \ done < tmp/parallel-pids; \ end_pass main; \ monitor_note "Merging per-provider reports"; \ say "$(CYAN)Merging per-provider reports into tmp/newman-report.json...$(NC)"; \ if command -v jq >/dev/null 2>&1 && ls tmp/newman-report-*.json >/dev/null 2>&1; then \ : "Order is load-bearing, so the files are listed explicitly instead of by glob: the merge"; \ : "keeps the LAST occurrence of each item id, and a glob sorts newman-report--retry1.json"; \ : "BEFORE newman-report-.json ('-' is 0x2D, '.' is 0x2E) - the stale 429 would overwrite"; \ : "its own successful retry. Main reports first, then retries in attempt order."; \ MERGE_MAIN="$$(ls tmp/newman-report-*.json 2>/dev/null | grep -v -e '-retry[0-9]*\.json$$' | sort)"; \ MERGE_RETRY="$$(ls tmp/newman-report-*-retry*.json 2>/dev/null | sort -V)"; \ : "Two stages: one jq -s over every shard (~3.6GB) gets OOM-killed, and the > redirect had"; \ : "already truncated newman-report.json to 0 bytes, so every reader died on 'Unexpected end of"; \ : "JSON input'. The merge program is idempotent, so slim each shard alone first (peak = the"; \ : "largest shard) and slurp only the slimmed copies, order preserved. Output lands in a temp"; \ : "file and is moved into place only on success, so a failed merge never leaves an empty report."; \ rm -rf tmp/.merge-slim; mkdir -p tmp/.merge-slim; \ MERGE_SLIM=""; MERGE_OK=1; MERGE_N=0; \ for f in $$MERGE_MAIN $$MERGE_RETRY; do \ MERGE_N=$$((MERGE_N + 1)); \ o="$$(printf 'tmp/.merge-slim/%04d.json' $$MERGE_N)"; \ jq -c -s -f tmp/newman-merge.jq "$$f" > "$$o" || { MERGE_OK=0; say "$(YELLOW)Slimming $$f failed$(NC)"; break; }; \ MERGE_SLIM="$$MERGE_SLIM $$o"; \ done; \ if [ "$$MERGE_OK" = "1" ] && jq -c -s -f tmp/newman-merge.jq $$MERGE_SLIM > tmp/.newman-report-merged.json; then \ mv -f tmp/.newman-report-merged.json tmp/newman-report.json; \ else \ rm -f tmp/.newman-report-merged.json; \ say "$(YELLOW)Report merge failed; per-provider reports remain at tmp/newman-report-*.json$(NC)"; \ fi; \ rm -rf tmp/.merge-slim; \ rm -f tmp/.newman-report.slim.json; \ cat tmp/newman-cli-*.log > tmp/newman-cli.log 2>/dev/null || true; \ else \ say "$(YELLOW)jq not found or no reports produced; skipping merge. See tmp/newman-report-*.json$(NC)"; \ fi; \ if [ "$$HARNESS_MONITORED" != "1" ]; then \ say "$(CYAN)Parallel summary:$(NC)"; \ while read sp; do \ pname="$${sp%%:*}"; \ pstat="$${sp#*:}"; \ if [ "$$pstat" = "pass" ]; then \ say " $(GREEN)✓ $$pname$(NC)"; \ else \ say " $(RED)✗ $$pname$(NC)"; \ fi; \ done < tmp/parallel-status; \ fi; \ if [ "$$FILTER_FAILED" -gt 0 ]; then \ say "$(RED)$$FILTER_FAILED shard(s) skipped - their filter step failed, so those requests never ran. See $$QUIET_LOG$(NC)"; \ fi; \ NEWMAN_EXIT=$$((PFAILED+FILTER_FAILED)); \ fi; \ else \ SEQ_PROVIDERS="$(or $(PROVIDER),$(HARNESS_PROVIDERS))"; \ : > tmp/newman-cli.log; \ SEQ_COUNT="$$(grep -c '"request":' "$$COLLECTION_FILE" 2>/dev/null || true)"; SEQ_COUNT="$${SEQ_COUNT:-0}"; \ if budget_ok "$$SEQ_COUNT" main; then \ add_pass "$$(printf '{"t":"pass","id":"main","mode":"sequential","log":"tmp/newman-cli.log","collection":"%s"}' "$$COLLECTION_FILE")"; \ newman run "$$COLLECTION_FILE" \ --env-var "baseUrl=$$BASE_URL_VAL" \ $(if $(filter on true 1 yes YES y Y,$(COMPAT)),--env-var "compat=true",) \ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_PREVIEW)),--env-var "include_preview=1",) \ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_SKIP)),--env-var "include_skip=1",) \ $${BEDROCK_GUARDRAIL_IDENTIFIER:+--env-var "bedrockGuardrailIdentifier=$$BEDROCK_GUARDRAIL_IDENTIFIER"} \ $${BEDROCK_GUARDRAIL_VERSION:+--env-var "bedrockGuardrailVersion=$$BEDROCK_GUARDRAIL_VERSION"} \ $${VERTEX_GCS_BUCKET:+--env-var "vertexGcsBucket=$$VERTEX_GCS_BUCKET"} \ $${VERTEX_GCS_PREFIX:+--env-var "vertexGcsPrefix=$$VERTEX_GCS_PREFIX"} \ $${AWS_S3_BUCKET:+--env-var "awsS3Bucket=$$AWS_S3_BUCKET"} \ $${OPENAI_API_KEY:+--env-var "openaiKey=$$OPENAI_API_KEY"} \ $${CHATGPT_ACCESS_TOKEN:+--env-var "chatgptAccessToken=$$CHATGPT_ACCESS_TOKEN"} \ $${ANTHROPIC_API_KEY:+--env-var "anthropicKey=$$ANTHROPIC_API_KEY"} \ $${GEMINI_API_KEY:+--env-var "genaiKey=$$GEMINI_API_KEY"} \ $${AWS_ACCESS_KEY_ID:+--env-var "bedrockDirectAccessKeyId=$$AWS_ACCESS_KEY_ID"} \ $${AWS_SECRET_ACCESS_KEY:+--env-var "bedrockDirectSecretAccessKey=$$AWS_SECRET_ACCESS_KEY"} \ $${AWS_REGION:+--env-var "bedrockDirectRegion=$$AWS_REGION"} \ $${VERTEX_PROJECT_ID:+--env-var "vertexProject=$$VERTEX_PROJECT_ID"} \ $${GOOGLE_LOCATION:+--env-var "vertexLocation=$$GOOGLE_LOCATION"} \ $${VERTEX_ACCESS_TOKEN_VAL:+--env-var "vertexAccessToken=$$VERTEX_ACCESS_TOKEN_VAL"} \ $(if $(ENV_FILE),--environment $(ENV_FILE),) \ $(if $(FOLDER),--folder "$(FOLDER)",) \ --reporters cli,json,htmlextra$$DBVERIFY_REPORTER$$TOKEN_PARITY_REPORTER $$DBVERIFY_ARGS \ $${TOKEN_PARITY_REPORTER:+--reporter-token-parity-out "tmp/harness-token-parity-sequential.json"} \ --reporter-json-export tmp/newman-report.json \ --reporter-htmlextra-export tmp/newman-report.html \ --reporter-htmlextra-title "Bifrost Provider Harness" \ --reporter-htmlextra-darkTheme > tmp/newman-cli.log 2>&1; \ NEWMAN_EXIT=$$?; \ end_pass main; \ else NEWMAN_EXIT=0; fi; \ if [ "$$HARNESS_MONITORED" != "1" ] && [ "$$HARNESS_QUIET" != "1" ]; then cat tmp/newman-cli.log; fi; \ if command -v jq >/dev/null 2>&1 && [ -f tmp/newman-report.json ]; then \ say "$(CYAN)Sanitizing tmp/newman-report.json (newman embeds the whole parent folder in every failure)...$(NC)"; \ jq -c -s -f tmp/newman-merge.jq tmp/newman-report.json > tmp/.newman-report-sanitized.json \ && mv -f tmp/.newman-report-sanitized.json tmp/newman-report.json \ && rm -f tmp/.newman-report.slim.json \ || { rm -f tmp/.newman-report-sanitized.json; say "$(YELLOW)Report sanitize failed; tmp/newman-report.json left as-is (may exceed the viewer's 512MB parse limit).$(NC)"; }; \ fi; \ fi; \ if [ "$$CACHE_PASS" = "1" ]; then \ say "$(CYAN)Cache parity pass (sequential, single newman - these rows match every provider fork)...$(NC)"; \ rm -f tmp/harness-cache-filtered.json; \ : "cache-parity is the AND selector, so a FEATURE predicate narrows it: FEATURE is appended,"; \ : "not replaced, and 'cache-parity,gemini' stays narrower than 'cache-parity'. A menu pick"; \ : "is deliberately NOT forwarded - this pass only runs on the menu path when cache-parity"; \ : "itself was picked, and then every cache row is wanted, not the ones that also look like"; \ : "'streaming'."; \ CACHE_FEATURE="cache-parity"; \ if [ -n "$(FEATURE)" ]; then CACHE_FEATURE="cache-parity,$(FEATURE)"; fi; \ $(USE_NODE); run_quiet node tests/e2e/api/runners/filter-collection.mjs \ --source tmp/harness-augmented.json \ --out tmp/harness-cache-filtered.json \ --feature "$$CACHE_FEATURE" \ $(if $(FOLDER),--folder "$(FOLDER)",) \ $${SMOKE_MANIFEST:+--smoke "$$SMOKE_MANIFEST"} \ $(if $(RERUN_FAILED),--rerun-failed --report tmp/.newman-report-prior.json,) \ $(if $(PROVIDER),--provider $(PROVIDER),) || { say "$(RED)Cache parity filter step failed$(NC)"; }; \ CACHE_COUNT="$$(grep -c '"request":' tmp/harness-cache-filtered.json 2>/dev/null || true)"; CACHE_COUNT="$${CACHE_COUNT:-0}"; \ if [ "$$CACHE_COUNT" -eq 0 ]; then \ say "$(CYAN)Cache parity pass: the scope selects no cache-parity row - nothing to replay.$(NC)"; \ elif [ -f tmp/harness-cache-filtered.json ] && budget_ok "$$CACHE_COUNT" cache-parity; then \ CACHE_PROVIDERS="$(or $(PROVIDER),$(HARNESS_PROVIDERS))"; \ : > tmp/newman-cli-cache-parity.log; \ add_pass '{"t":"pass","id":"cache-parity","mode":"sequential","log":"tmp/newman-cli-cache-parity.log","collection":"tmp/harness-cache-filtered.json"}'; \ newman run tmp/harness-cache-filtered.json \ --env-var "baseUrl=$$BASE_URL_VAL" \ $(if $(filter on true 1 yes YES y Y,$(COMPAT)),--env-var "compat=true",) \ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_PREVIEW)),--env-var "include_preview=1",) \ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_SKIP)),--env-var "include_skip=1",) \ $${OPENAI_API_KEY:+--env-var "openaiKey=$$OPENAI_API_KEY"} \ $${CHATGPT_ACCESS_TOKEN:+--env-var "chatgptAccessToken=$$CHATGPT_ACCESS_TOKEN"} \ $${ANTHROPIC_API_KEY:+--env-var "anthropicKey=$$ANTHROPIC_API_KEY"} \ $${GEMINI_API_KEY:+--env-var "genaiKey=$$GEMINI_API_KEY"} \ $${AWS_ACCESS_KEY_ID:+--env-var "bedrockDirectAccessKeyId=$$AWS_ACCESS_KEY_ID"} \ $${AWS_SECRET_ACCESS_KEY:+--env-var "bedrockDirectSecretAccessKey=$$AWS_SECRET_ACCESS_KEY"} \ $${AWS_REGION:+--env-var "bedrockDirectRegion=$$AWS_REGION"} \ $${VERTEX_PROJECT_ID:+--env-var "vertexProject=$$VERTEX_PROJECT_ID"} \ $${GOOGLE_LOCATION:+--env-var "vertexLocation=$$GOOGLE_LOCATION"} \ $${VERTEX_ACCESS_TOKEN_VAL:+--env-var "vertexAccessToken=$$VERTEX_ACCESS_TOKEN_VAL"} \ $(if $(ENV_FILE),--environment $(ENV_FILE),) \ --reporters cli,json$$CACHE_PARITY_REPORTER \ $${CACHE_PARITY_REPORTER:+--reporter-cache-parity-out "tmp/harness-cache-parity-pass.json"} \ --reporter-json-export tmp/newman-report-cache-parity.json > tmp/newman-cli-cache-parity.log 2>&1; \ CACHE_EXIT=$$?; \ end_pass cache-parity; \ : "end_pass above is load-bearing, not tidiness: under PARALLEL=0 the main"; \ : "pass tails tmp/newman-cli.log, and the append below would otherwise be"; \ : "replayed into the counters - every cache row counted a second time."; \ cat tmp/newman-cli-cache-parity.log >> tmp/newman-cli.log 2>/dev/null || true; \ if [ "$$HARNESS_MONITORED" != "1" ] && [ "$$HARNESS_QUIET" != "1" ]; then cat tmp/newman-cli-cache-parity.log; fi; \ if [ "$$CACHE_EXIT" -ne 0 ]; then NEWMAN_EXIT=$$((NEWMAN_EXIT+1)); fi; \ if command -v jq >/dev/null 2>&1 && [ -f tmp/newman-report-cache-parity.json ]; then \ jq -c -s -f tmp/newman-merge.jq tmp/newman-report.json tmp/newman-report-cache-parity.json > tmp/newman-report-combined.json \ && mv tmp/newman-report-combined.json tmp/newman-report.json \ || say "$(YELLOW)Cache pass report merge failed; it remains at tmp/newman-report-cache-parity.json$(NC)"; \ fi; \ fi; \ fi; \ if [ "$$SERIAL_PASS" = "1" ]; then \ say "$(CYAN)Serial pass ([SERIAL] folders, one newman, alone against the gateway)...$(NC)"; \ rm -f tmp/harness-serial-filtered.json; SERIAL_FILTER_FAILED=0; \ $(USE_NODE); run_quiet node tests/e2e/api/runners/filter-collection.mjs \ --source tmp/harness-augmented.json \ --out tmp/harness-serial-filtered.json \ --only-serial \ $(if $(PROVIDER),--provider $(PROVIDER),) \ $(if $(FEATURE),--feature "$(FEATURE)",) \ $(if $(FOLDER),--folder "$(FOLDER)",) \ $$FEATURE_ANY_FLAG \ $$EXCLUDE_FLAG \ $${SMOKE_MANIFEST:+--smoke "$$SMOKE_MANIFEST"} \ $(if $(RERUN_FAILED),--rerun-failed --report tmp/.newman-report-prior.json,) || { say "$(RED)Serial pass filter step failed - the [SERIAL] folders did not run$(NC)"; NEWMAN_EXIT=$$((NEWMAN_EXIT+1)); SERIAL_FILTER_FAILED=1; }; \ SERIAL_COUNT="$$(grep -c '"request":' tmp/harness-serial-filtered.json 2>/dev/null || true)"; SERIAL_COUNT="$${SERIAL_COUNT:-0}"; \ if [ "$$SERIAL_FILTER_FAILED" = "1" ]; then \ :; \ elif [ "$$SERIAL_COUNT" -eq 0 ]; then \ say "$(CYAN)Serial pass: the scope selects no [SERIAL] folder - nothing to run.$(NC)"; \ elif [ -f tmp/harness-serial-filtered.json ] && budget_ok "$$SERIAL_COUNT" serial; then \ : > tmp/newman-cli-serial.log; \ add_pass '{"t":"pass","id":"serial","mode":"sequential","log":"tmp/newman-cli-serial.log","collection":"tmp/harness-serial-filtered.json"}'; \ newman run tmp/harness-serial-filtered.json \ --env-var "baseUrl=$$BASE_URL_VAL" \ $(if $(filter on true 1 yes YES y Y,$(COMPAT)),--env-var "compat=true",) \ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_PREVIEW)),--env-var "include_preview=1",) \ $(if $(filter 1 true TRUE yes YES y Y,$(INCLUDE_SKIP)),--env-var "include_skip=1",) \ $${OPENAI_API_KEY:+--env-var "openaiKey=$$OPENAI_API_KEY"} \ $${CHATGPT_ACCESS_TOKEN:+--env-var "chatgptAccessToken=$$CHATGPT_ACCESS_TOKEN"} \ $${ANTHROPIC_API_KEY:+--env-var "anthropicKey=$$ANTHROPIC_API_KEY"} \ $${GEMINI_API_KEY:+--env-var "genaiKey=$$GEMINI_API_KEY"} \ $(if $(ENV_FILE),--environment $(ENV_FILE),) \ --reporters cli,json \ --reporter-json-export tmp/newman-report-serial.json > tmp/newman-cli-serial.log 2>&1; \ SERIAL_EXIT=$$?; \ SERIAL_RAN=1; \ end_pass serial; \ cat tmp/newman-cli-serial.log >> tmp/newman-cli.log 2>/dev/null || true; \ if [ "$$HARNESS_MONITORED" != "1" ] && [ "$$HARNESS_QUIET" != "1" ]; then cat tmp/newman-cli-serial.log; fi; \ if [ "$$SERIAL_EXIT" -ne 0 ]; then NEWMAN_EXIT=$$((NEWMAN_EXIT+1)); fi; \ if command -v jq >/dev/null 2>&1 && [ -f tmp/newman-report-serial.json ]; then \ jq -c -s -f tmp/newman-merge.jq tmp/newman-report.json tmp/newman-report-serial.json > tmp/newman-report-combined.json \ && mv tmp/newman-report-combined.json tmp/newman-report.json \ || say "$(YELLOW)Serial pass report merge failed; it remains at tmp/newman-report-serial.json$(NC)"; \ fi; \ elif [ "$${MAIN_EMPTY:-0}" = "1" ] && [ "$${BUDGET_EXCEEDED:-0}" = "1" ]; then \ : "budget_ok refused the serial pass, and the main pass had nothing: nothing ran at all."; \ stop_monitor; \ say "$(RED)Aborted before any launch: the serial pass would exceed HARNESS_MAX_REQUESTS=$(HARNESS_MAX_REQUESTS). Raise the cap or narrow PROVIDER/FEATURE.$(NC)"; \ exit 3; \ fi; \ fi; \ if [ "$${MAIN_EMPTY:-0}" = "1" ] && [ "$${SERIAL_RAN:-0}" != "1" ]; then \ stop_monitor; \ say "$(RED)No provider runs were launched. Check PROVIDER/FEATURE/FOLDER filters.$(NC)"; \ exit 1; \ fi; \ stop_monitor; \ : "The single teardown for the whole run: one alt-screen exit, one persistent"; \ : "table snapshot, one \$$GITHUB_STEP_SUMMARY block. Everything after this point"; \ : "prints normally to a restored main screen."; \ say "$(GREEN)Newman finished. Reports: tmp/newman-report.{json,html} + tmp/newman-cli.log$(NC)"; \ STREAM_CANCEL_EXIT=0; \ if [ -n "$(HARNESS_MAX_REQUESTS)" ]; then \ say "$(YELLOW)Skipping stream cancellation probes: their request count is not known before they run, so they are never sent under HARNESS_MAX_REQUESTS.$(NC)"; \ elif [ -z "$(SKIP_STREAM_CANCEL)" ] && [ -z "$(RERUN_FAILED)" ] && [ "$(PROVIDER)" != "passthrough" ] && { [ -z "$(FOLDER)" ] || printf '%s' "$(FOLDER)" | grep -qi 'stream'; }; then \ say "$(CYAN)Running stream cancellation probes...$(NC)"; \ $(USE_NODE); node tests/e2e/api/runners/run-stream-cancellation.mjs \ --base-url "$$BASE_URL_VAL" \ --config "$$APP_DIR_VAL/config.json" \ --server-working-dir "$(or $(HARNESS_SERVER_CWD),$(CURDIR)/transports/bifrost-http)" \ $(if $(PROVIDER),--provider "$(PROVIDER)",) \ $(if $(NONSTREAM_TRIALS),--nonstream-trials "$(NONSTREAM_TRIALS)",) \ --out tmp/stream-cancel-report.json > tmp/stream-cancel-cli.log 2>&1; \ STREAM_CANCEL_EXIT=$$?; \ if [ "$$HARNESS_QUIET" != "1" ]; then cat tmp/stream-cancel-cli.log; fi; \ else \ say "$(YELLOW)Skipping stream cancellation probes (SKIP_STREAM_CANCEL/RERUN_FAILED/FOLDER filter).$(NC)"; \ fi; \ : "Refresh the timing table from this run's reports so the NEXT sweep sizes and fills its"; \ : "shards from what actually happened. Merges onto the existing table rather than replacing"; \ : "it, so a scoped run (PROVIDER=..., FEATURE=...) refines its own rows without erasing the"; \ : "providers it never ran. Never fails the target: a sweep that already produced its results"; \ : "must not go red because a cache file could not be refreshed."; \ $(USE_NODE); run_quiet node tests/e2e/api/runners/build-harness-timings.mjs \ --dir tmp --out "$(or $(HARNESS_TIMINGS),tmp/harness-timings.json)" || true; \ say "$(CYAN)Analyzing failures...$(NC)"; \ $(USE_NODE); run_quiet node tests/e2e/api/runners/analyze-failures.mjs \ --report tmp/newman-report.json \ --bifrost-log tmp/bifrost-dev.log \ --out tmp/harness-failures.md || true; \ say "$(GREEN)Failure breakdown: tmp/harness-failures.md$(NC)"; \ if ls tmp/harness-token-parity-*.json >/dev/null 2>&1; then \ say "$(CYAN)Rendering token parity report...$(NC)"; \ $(USE_NODE); run_quiet node tests/e2e/api/runners/render-token-parity-report.mjs \ --glob "tmp/harness-token-parity-*.json" \ --out tmp/harness-token-parity.md \ --html tmp/newman-report.html || true; \ say "$(GREEN)Token parity report: tmp/harness-token-parity.md (also injected into tmp/newman-report.html when present - sequential mode / PARALLEL=0 only)$(NC)"; \ fi; \ if ls tmp/harness-cache-parity-*.json >/dev/null 2>&1; then \ say "$(CYAN)Rendering cache parity report...$(NC)"; \ $(USE_NODE); run_quiet node tests/e2e/api/runners/render-cache-parity-report.mjs \ --glob "tmp/harness-cache-parity-*.json" \ --out tmp/harness-cache-parity.md \ --html tmp/newman-report.html || true; \ say "$(GREEN)Cache parity report: tmp/harness-cache-parity.md$(NC)"; \ fi; \ if [ -n "$(CI)" ] || [ -n "$$CI" ]; then \ say "$(CYAN)CI mode - skipping interactive viewer. Upload tmp/newman-report.html, tmp/harness-failures.md, and tmp/bifrost-dev.log as workflow artifacts.$(NC)"; \ else \ preempt_viewer_port; \ say "$(CYAN)Launching interactive viewer on http://localhost:$$VIEWER_PORT_VAL (Bifrost stays up for resend)...$(NC)"; \ $(USE_NODE); node tests/e2e/api/runners/harness-viewer.mjs --report tmp/newman-report.json --port $$VIEWER_PORT_VAL & \ VIEWER_PID=$$!; \ echo $$VIEWER_PID > tmp/harness-viewer.pid; \ wait $$VIEWER_PID; \ VIEWER_EXIT=$$?; \ rm -f tmp/harness-viewer.pid; \ if [ $$VIEWER_EXIT -ne 0 ]; then \ say "$(RED)Viewer exited with code $$VIEWER_EXIT (see message above).$(NC)"; \ else \ say "$(GREEN)Viewer closed.$(NC)"; \ fi; \ fi; \ if [ "$$BUDGET_EXCEEDED" = "1" ]; then \ say "$(RED)Aborted: HARNESS_MAX_REQUESTS=$(HARNESS_MAX_REQUESTS) reached; $$BUDGET_USED request(s) were launched. The provider table above is the actual total.$(NC)"; \ exit 3; \ fi; \ if [ "$$NEWMAN_EXIT" -ne 0 ]; then exit $$NEWMAN_EXIT; fi; \ exit $$STREAM_CANCEL_EXIT