chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# One-time bootstrap for the DocsGPT e2e Postgres template DB.
#
# Creates two databases on the native DBngin Postgres instance:
# * docsgpt_e2e_template — schema-only (alembic head), marked as a PG template
# * docsgpt_e2e — the live DB the first `up.sh` run connects to
#
# Idempotent. Safe to re-run after schema changes to refresh the template.
set -euo pipefail
PG_BIN="/Users/Shared/DBngin/postgresql/16.2/bin"
PSQL="${PG_BIN}/psql"
PG_ISREADY="${PG_BIN}/pg_isready"
PG_HOST="127.0.0.1"
PG_PORT="5432"
PG_SUPERUSER="postgres"
TEMPLATE_DB="docsgpt_e2e_template"
E2E_DB="docsgpt_e2e"
OWNER_ROLE="docsgpt"
OWNER_PASSWORD="docsgpt"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
log() {
printf '[bake_template] %s\n' "$*" >&2
}
if [[ ! -x "${PSQL}" ]]; then
log "psql not found at ${PSQL} — is DBngin Postgres 16.2 installed?"
exit 1
fi
log "Checking Postgres is up at ${PG_HOST}:${PG_PORT}..."
if ! "${PG_ISREADY}" -h "${PG_HOST}" -p "${PG_PORT}" -q; then
log "Postgres is not accepting connections at ${PG_HOST}:${PG_PORT}."
log "Start DBngin's Postgres 16.2 instance and try again."
exit 1
fi
log "Dropping and recreating ${TEMPLATE_DB} as superuser ${PG_SUPERUSER}..."
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-v ON_ERROR_STOP=1 -X -q <<SQL
-- Clear the template flag first so DROP DATABASE is allowed.
UPDATE pg_database SET datistemplate = FALSE WHERE datname = '${TEMPLATE_DB}';
UPDATE pg_database SET datallowconn = TRUE WHERE datname = '${TEMPLATE_DB}';
-- Evict any lingering backends on the template DB.
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '${TEMPLATE_DB}'
AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS ${TEMPLATE_DB};
CREATE DATABASE ${TEMPLATE_DB} OWNER ${OWNER_ROLE};
SQL
log "Applying Alembic schema to ${TEMPLATE_DB}..."
if [[ -x "${REPO_ROOT}/.venv/bin/python" ]]; then
PYTHON="${REPO_ROOT}/.venv/bin/python"
else
PYTHON="python3"
fi
(
cd "${REPO_ROOT}"
POSTGRES_URI="postgresql://${OWNER_ROLE}:${OWNER_PASSWORD}@${PG_HOST}:${PG_PORT}/${TEMPLATE_DB}" \
"${PYTHON}" scripts/db/init_postgres.py
)
log "Marking ${TEMPLATE_DB} as a Postgres template (datistemplate=TRUE, datallowconn=FALSE)..."
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-v ON_ERROR_STOP=1 -X -q <<SQL
UPDATE pg_database SET datistemplate = TRUE WHERE datname = '${TEMPLATE_DB}';
UPDATE pg_database SET datallowconn = FALSE WHERE datname = '${TEMPLATE_DB}';
SQL
log "Cloning ${E2E_DB} from ${TEMPLATE_DB}..."
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-v ON_ERROR_STOP=1 -X -q <<SQL
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '${E2E_DB}'
AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS ${E2E_DB};
CREATE DATABASE ${E2E_DB} TEMPLATE ${TEMPLATE_DB} OWNER ${OWNER_ROLE};
SQL
log "Done. Template ${TEMPLATE_DB} is baked; ${E2E_DB} is ready for the first e2e run."
exit 0
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# scripts/e2e/down.sh
#
# Tear down the DocsGPT end-to-end test stack started by up.sh.
# Reads pidfiles from /tmp/docsgpt-e2e/*.pid, sends SIGTERM, waits up to 3s,
# escalates to SIGKILL if still alive, removes each pidfile.
#
# Constraints:
# - Idempotent: exits 0 even when no pidfiles exist.
# - NEVER uses pkill/killall (CLAUDE.md: don't risk killing native
# Postgres/Redis/Mongo processes).
# - NEVER touches shared DB/Redis services.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PIDDIR="/tmp/docsgpt-e2e"
log() {
echo "[down.sh] $*" >&2
}
# Stop a single service given its pidfile. Best-effort; never fatal.
stop_one() {
local pidfile="$1"
local svc
svc="$(basename "$pidfile" .pid)"
if [[ ! -s "$pidfile" ]]; then
log "$svc: pidfile empty or missing — removing"
rm -f "$pidfile"
return 0
fi
local pid
pid="$(cat "$pidfile" 2>/dev/null || true)"
# Guard against garbage in the pidfile (non-numeric or empty).
if ! [[ "$pid" =~ ^[0-9]+$ ]]; then
log "$svc: pidfile contents not numeric ('$pid') — removing"
rm -f "$pidfile"
return 0
fi
if ! kill -0 "$pid" 2>/dev/null; then
log "$svc: pid $pid not running — removing pidfile"
rm -f "$pidfile"
return 0
fi
log "$svc: sending SIGTERM to pid $pid"
kill "$pid" 2>/dev/null || true
# Poll up to 3 seconds for graceful exit.
local waited=0
while (( waited < 3 )); do
if ! kill -0 "$pid" 2>/dev/null; then
break
fi
sleep 1
waited=$(( waited + 1 ))
done
if kill -0 "$pid" 2>/dev/null; then
log "$svc: pid $pid still alive after 3s — SIGKILL"
kill -9 "$pid" 2>/dev/null || true
else
log "$svc: pid $pid exited gracefully"
fi
rm -f "$pidfile"
}
if [[ ! -d "$PIDDIR" ]]; then
log "no pid directory at $PIDDIR — nothing to stop"
exit 0
fi
shopt -s nullglob
pidfiles=( "$PIDDIR"/*.pid )
shopt -u nullglob
if (( ${#pidfiles[@]} == 0 )); then
log "no pidfiles in $PIDDIR — nothing to stop"
exit 0
fi
for pidfile in "${pidfiles[@]}"; do
stop_one "$pidfile"
done
log "teardown complete"
exit 0
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# scripts/e2e/env.sh
#
# Environment variables for the DocsGPT end-to-end test stack.
# This file is intentionally passive: it exports variables and nothing else.
# It is `source`d by scripts/e2e/up.sh (and potentially by developers who want
# to run Flask/Celery manually against the e2e stack).
#
# Mirrors `Appendix A — .env.e2e reference` in e2e-plan.md. If you add/remove
# a variable here, update the plan doc as well.
#
# DO NOT run commands (mkdir, touch, etc.) from this file — keep it pure.
# -----------------------------------------------------------------------------
# Postgres
# -----------------------------------------------------------------------------
export POSTGRES_URI="postgresql://docsgpt:docsgpt@127.0.0.1:5432/docsgpt_e2e"
# -----------------------------------------------------------------------------
# Redis (dev uses DBs 0/1/2; e2e uses 11/12/13 to stay isolated)
# -----------------------------------------------------------------------------
export CELERY_BROKER_URL="redis://127.0.0.1:6379/11"
export CELERY_RESULT_BACKEND="redis://127.0.0.1:6379/12"
export CACHE_REDIS_URL="redis://127.0.0.1:6379/13"
# -----------------------------------------------------------------------------
# Mongo — unused in the e2e stack (app fully cut over on this branch)
# -----------------------------------------------------------------------------
export MONGO_URI=""
# -----------------------------------------------------------------------------
# Vector + storage
# -----------------------------------------------------------------------------
export VECTOR_STORE="faiss"
export EMBEDDINGS_NAME="huggingface_sentence-transformers/all-mpnet-base-v2"
export STORAGE_TYPE="local"
export URL_STRATEGY="backend"
export UPLOAD_FOLDER=".e2e-tmp/inputs"
# -----------------------------------------------------------------------------
# Flask
# -----------------------------------------------------------------------------
export API_URL="http://127.0.0.1:7099"
export FLASK_DEBUG_MODE="false"
# -----------------------------------------------------------------------------
# Auth (specs can override AUTH_TYPE per-launch via process env)
# -----------------------------------------------------------------------------
export AUTH_TYPE="${AUTH_TYPE:-session_jwt}"
export JWT_SECRET_KEY="e2e-fixed-secret-never-use-in-prod"
export ENCRYPTION_SECRET_KEY="e2e-fixed-encryption-key-never-use-in-prod"
# OIDC mode (AUTH_TYPE=oidc) — points at the mock IdP that oidc.spec.ts
# spawns on demand (scripts/e2e/mock_oidc_idp.py, port 7999). Discovery is
# lazy, so Flask boots fine before the IdP is up. Every OIDC_* var is pinned
# here because the app's load_dotenv() walks up and reads the repo .env —
# whatever a developer keeps there must not leak into the e2e stack.
if [[ "${AUTH_TYPE}" == "oidc" ]]; then
export OIDC_ISSUER="${OIDC_ISSUER:-http://127.0.0.1:7999}"
export OIDC_CLIENT_ID="${OIDC_CLIENT_ID:-docsgpt-e2e}"
export OIDC_FRONTEND_URL="${OIDC_FRONTEND_URL:-http://127.0.0.1:5179}"
export OIDC_CLIENT_SECRET="${OIDC_CLIENT_SECRET:-}"
export OIDC_SCOPES="${OIDC_SCOPES:-openid profile email}"
export OIDC_USER_ID_CLAIM="${OIDC_USER_ID_CLAIM:-sub}"
export OIDC_REDIRECT_URI="${OIDC_REDIRECT_URI:-}"
export OIDC_SESSION_LIFETIME_SECONDS="${OIDC_SESSION_LIFETIME_SECONDS:-28800}"
export OIDC_PROVIDER_NAME="${OIDC_PROVIDER_NAME:-}"
export OIDC_ALLOWED_GROUPS="${OIDC_ALLOWED_GROUPS:-}"
export OIDC_GROUPS_CLAIM="${OIDC_GROUPS_CLAIM:-groups}"
export SCIM_ENABLED="${SCIM_ENABLED:-false}"
export SCIM_TOKEN="${SCIM_TOKEN:-}"
fi
# -----------------------------------------------------------------------------
# LLM → mock stub on 127.0.0.1:7899
# -----------------------------------------------------------------------------
export LLM_PROVIDER="openai"
export LLM_NAME="gpt-4o-mini"
export API_KEY="e2e-fake-key"
export OPENAI_API_KEY="e2e-fake-key"
export OPENAI_BASE_URL="http://127.0.0.1:7899/v1"
export EMBEDDINGS_BASE_URL="http://127.0.0.1:7899/v1"
export EMBEDDINGS_KEY="e2e-fake-key"
# -----------------------------------------------------------------------------
# Determinism — disable features that introduce non-determinism in tests
# -----------------------------------------------------------------------------
export ENABLE_CONVERSATION_COMPRESSION="false"
export ENABLE_TOOL_PREFETCH="false"
export PARSE_PDF_AS_IMAGE="false"
export DOCLING_OCR_ENABLED="false"
+509
View File
@@ -0,0 +1,509 @@
"""OpenAI-compatible stub server for the DocsGPT e2e test suite.
Speaks the minimum subset of the OpenAI HTTP API that DocsGPT's ``openai``
Python client needs:
* ``POST /v1/chat/completions`` (streaming + non-streaming, tool calls via fixture)
* ``POST /v1/embeddings`` (deterministic hash-seeded vectors)
* ``GET /healthz`` (liveness probe for ``scripts/e2e/up.sh``)
The server is **deterministic**: the same request always returns the same
response. Requests are fingerprinted by SHA-256 of a canonical JSON encoding
of ``(model, messages, tool_choice)``. If a fixture file matching that hash
exists under ``mock_llm_fixtures/<hash>.json`` it wins; otherwise a generic
"I don't know" fallback is returned and the hash + request is logged to stderr
so a developer can promote it into a fixture later.
Run standalone (does NOT import anything from ``application/``). Python 3.11+.
Flask is the only non-stdlib dependency and is already in
``application/requirements.txt``.
Usage::
python scripts/e2e/mock_llm.py
Defaults to ``127.0.0.1:7899`` to match the ``OPENAI_BASE_URL`` referenced in
``e2e-plan.md`` Appendix A.
"""
from __future__ import annotations
import hashlib
import json
import os
import random
import sys
import time
from pathlib import Path
from typing import Any
from flask import Flask, Response, jsonify, request, stream_with_context
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
HOST = os.environ.get("MOCK_LLM_HOST", "127.0.0.1")
PORT = int(os.environ.get("MOCK_LLM_PORT", "7899"))
FIXTURES_DIR = Path(__file__).parent / "mock_llm_fixtures"
EMBEDDING_DIM = 768
GENERIC_FALLBACK_TEXT = (
"I don't have enough information to answer that from the provided sources."
)
STREAM_CHUNK_COUNT = 5
app = Flask(__name__)
# ---------------------------------------------------------------------------
# CORS — permissive; stub trusts its port
# ---------------------------------------------------------------------------
@app.after_request
def _add_cors_headers(response: Response) -> Response:
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
return response
@app.route("/v1/chat/completions", methods=["OPTIONS"])
@app.route("/v1/embeddings", methods=["OPTIONS"])
def _cors_preflight() -> Response:
return Response(status=204)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _canonical_messages(messages: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""Return a minimal, stable representation of the messages array.
We keep only fields that are semantically meaningful for fingerprinting a
request. Extra keys from the OpenAI client (e.g. ``name``, ``tool_call_id``)
are preserved because they *do* change the intended response.
"""
if not messages:
return []
out: list[dict[str, Any]] = []
for msg in messages:
if not isinstance(msg, dict):
continue
# Content may be a string OR a list of content-part dicts (vision / tool).
# Serialize both forms deterministically.
entry: dict[str, Any] = {
"role": msg.get("role"),
"content": msg.get("content"),
}
for key in ("name", "tool_call_id", "tool_calls"):
if key in msg:
entry[key] = msg[key]
out.append(entry)
return out
def _compute_request_digest(payload: dict[str, Any]) -> str:
"""SHA-256 fingerprint of ``(model, messages, tool_choice)``.
Kept narrow on purpose — temperature / top_p / seed / max_tokens should
NOT influence which canned answer we return; those are knobs the app may
flap on across runs.
"""
canonical = {
"model": payload.get("model"),
"messages": _canonical_messages(payload.get("messages")),
"tool_choice": payload.get("tool_choice"),
}
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def _load_fixture(digest: str) -> dict[str, Any] | None:
"""Return the parsed fixture dict for ``digest``, or ``None`` if missing/bad."""
path = FIXTURES_DIR / f"{digest}.json"
if not path.is_file():
return None
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
sys.stderr.write(f"[mock-llm] failed to load fixture {path}: {exc}\n")
sys.stderr.flush()
return None
return data
def _estimate_tokens(text: str) -> int:
"""Rough 4-chars-per-token estimate (OpenAI's own ballpark)."""
if not text:
return 0
return max(1, len(text) // 4)
def _messages_text(messages: list[dict[str, Any]] | None) -> str:
"""Concatenate message contents for prompt-token estimation."""
if not messages:
return ""
parts: list[str] = []
for msg in messages:
if not isinstance(msg, dict):
continue
content = msg.get("content")
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict):
text = part.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts)
def _split_into_chunks(text: str, count: int) -> list[str]:
"""Split ``text`` into roughly ``count`` pieces by character length.
Guarantees at least one chunk even for the empty string (so streaming
clients still see a delta before ``[DONE]``).
"""
if count <= 0:
return [text]
if not text:
return [""]
n = len(text)
size = max(1, (n + count - 1) // count)
chunks = [text[i : i + size] for i in range(0, n, size)]
if not chunks:
chunks = [""]
return chunks
# ---------------------------------------------------------------------------
# Chat completions
# ---------------------------------------------------------------------------
def _resolve_chat_response(
payload: dict[str, Any], digest: str
) -> tuple[str, list[dict[str, Any]] | None, str, dict[str, int]]:
"""Return ``(content, tool_calls, finish_reason, usage)`` for ``payload``.
Looks up a fixture by digest first; falls back to the generic response if
no fixture is present, and logs the miss so the dev can convert it.
"""
fixture = _load_fixture(digest)
if fixture is None:
sys.stderr.write(f"[mock-llm] unknown fixture hash {digest}\n")
try:
sys.stderr.write(
"[mock-llm] request: "
+ json.dumps(payload, sort_keys=True, ensure_ascii=False)
+ "\n"
)
except (TypeError, ValueError):
sys.stderr.write("[mock-llm] request: <unserializable>\n")
sys.stderr.flush()
content = GENERIC_FALLBACK_TEXT
tool_calls: list[dict[str, Any]] | None = None
finish_reason = "stop"
prompt_tokens = _estimate_tokens(_messages_text(payload.get("messages")))
completion_tokens = _estimate_tokens(content)
usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
return content, tool_calls, finish_reason, usage
response = fixture.get("response") or {}
content = response.get("content") or ""
tool_calls = response.get("tool_calls")
finish_reason = response.get("finish_reason") or "stop"
fixture_usage = response.get("usage") or {}
prompt_tokens = int(
fixture_usage.get(
"prompt_tokens",
_estimate_tokens(_messages_text(payload.get("messages"))),
)
)
completion_tokens = int(
fixture_usage.get("completion_tokens", _estimate_tokens(content))
)
usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
return content, tool_calls, finish_reason, usage
def _chat_completion_envelope(
*,
digest: str,
model: str,
content: str,
tool_calls: list[dict[str, Any]] | None,
finish_reason: str,
usage: dict[str, int],
) -> dict[str, Any]:
message: dict[str, Any] = {"role": "assistant", "content": content}
if tool_calls:
message["tool_calls"] = tool_calls
return {
"id": f"chatcmpl-e2e-{digest[:12]}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": message,
"finish_reason": finish_reason,
}
],
"usage": usage,
}
def _sse(payload: dict[str, Any]) -> str:
return "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n"
def _stream_chat_response(
*,
digest: str,
model: str,
content: str,
tool_calls: list[dict[str, Any]] | None,
finish_reason: str,
chunk_delay_ms: int = 0,
):
"""Generator yielding SSE frames that match the OpenAI streaming protocol.
``chunk_delay_ms`` (controlled by ``X-Mock-LLM-Stream-Chunk-Delay-Ms``
header) sleeps that many milliseconds between successive SSE frames.
Used by durability E2E tests to simulate slow streams that survive a
mid-flight ``kill -9`` against the consumer.
"""
created = int(time.time())
completion_id = f"chatcmpl-e2e-{digest[:12]}"
def _base_chunk(delta: dict[str, Any], final: bool = False) -> dict[str, Any]:
return {
"id": completion_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": finish_reason if final else None,
}
],
}
def _maybe_sleep() -> None:
if chunk_delay_ms > 0:
time.sleep(chunk_delay_ms / 1000.0)
# Opening role delta — matches OpenAI's real behavior.
yield _sse(_base_chunk({"role": "assistant", "content": ""}))
if tool_calls:
# Emit tool calls in one delta; content streaming is skipped when
# tool_calls are present, matching what RAG code paths expect.
_maybe_sleep()
yield _sse(_base_chunk({"tool_calls": tool_calls}))
yield _sse(_base_chunk({}, final=True))
else:
chunks = _split_into_chunks(content, STREAM_CHUNK_COUNT)
last_index = len(chunks) - 1
for i, piece in enumerate(chunks):
_maybe_sleep()
yield _sse(_base_chunk({"content": piece}, final=(i == last_index)))
yield "data: [DONE]\n\n"
def _read_int_header(name: str, default: int = 0, ceiling: int = 600_000) -> int:
"""Parse an integer header with a sane upper bound (10 minutes)."""
raw = request.headers.get(name)
if not raw:
return default
try:
value = int(raw)
except (TypeError, ValueError):
return default
if value < 0:
return default
return min(value, ceiling)
def _read_int_env(name: str, default: int = 0, ceiling: int = 600_000) -> int:
"""Same as ``_read_int_header`` but for env vars — the durability E2E
script sets ``MOCK_LLM_FORCE_*_DELAY_MS`` so it can drive slow streams
through DocsGPT's OpenAI client without injecting per-request
headers."""
raw = os.environ.get(name)
if not raw:
return default
try:
value = int(raw)
except (TypeError, ValueError):
return default
if value < 0:
return default
return min(value, ceiling)
@app.post("/v1/chat/completions")
def chat_completions() -> Response:
payload = request.get_json(silent=True) or {}
model = payload.get("model") or "gpt-4o-mini"
stream = bool(payload.get("stream"))
digest = _compute_request_digest(payload)
content, tool_calls, finish_reason, usage = _resolve_chat_response(payload, digest)
# Durability E2E hooks: per-request OR per-process delays so tests can
# simulate slow providers without touching fixtures or recompiling the
# stub. Headers win over env so a single fixture run can opt in/out.
upfront_delay_ms = _read_int_header("X-Mock-LLM-Total-Delay-Ms") or _read_int_env(
"MOCK_LLM_FORCE_TOTAL_DELAY_MS"
)
chunk_delay_ms = _read_int_header(
"X-Mock-LLM-Stream-Chunk-Delay-Ms"
) or _read_int_env("MOCK_LLM_FORCE_STREAM_CHUNK_DELAY_MS")
if upfront_delay_ms > 0:
time.sleep(upfront_delay_ms / 1000.0)
if stream:
generator = _stream_chat_response(
digest=digest,
model=model,
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
chunk_delay_ms=chunk_delay_ms,
)
response = Response(
stream_with_context(generator),
mimetype="text/event-stream",
)
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
# Flask strips Content-Length on streamed responses; make sure we don't
# accidentally set one. Nothing to do here — just documenting.
return response
envelope = _chat_completion_envelope(
digest=digest,
model=model,
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
)
return jsonify(envelope)
# ---------------------------------------------------------------------------
# Embeddings
# ---------------------------------------------------------------------------
def _deterministic_embedding(text: str) -> list[float]:
"""Hash-seeded 768-dim float vector in [-1, 1).
Never all-zero: seeded RNG on a non-trivial hash of ``text`` plus a small
non-zero offset so degenerate vector-store checks pass even if
``text`` itself is empty.
"""
seed = int(hashlib.sha256(text.encode("utf-8")).hexdigest()[:16], 16) & 0xFFFFFFFF
rng = random.Random(seed)
vec = [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIM)]
# Guarantee non-degeneracy: nudge the first component away from 0 if the
# seeded draw happens to produce a very small value.
if abs(vec[0]) < 1e-6:
vec[0] = 0.1
return vec
@app.post("/v1/embeddings")
@app.post("/v1/v1/embeddings")
def embeddings() -> Response:
payload = request.get_json(silent=True) or {}
model = payload.get("model") or "text-embedding-3-small"
raw_input = payload.get("input", "")
if isinstance(raw_input, str):
inputs: list[str] = [raw_input]
elif isinstance(raw_input, list):
inputs = [str(item) if not isinstance(item, str) else item for item in raw_input]
else:
inputs = [str(raw_input)]
data = [
{
"object": "embedding",
"index": i,
"embedding": _deterministic_embedding(text),
}
for i, text in enumerate(inputs)
]
total_tokens = sum(_estimate_tokens(text) for text in inputs)
return jsonify(
{
"object": "list",
"data": data,
"model": model,
"usage": {
"prompt_tokens": total_tokens,
"total_tokens": total_tokens,
},
}
)
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@app.get("/healthz")
def healthz() -> Response:
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
def main() -> None:
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
sys.stderr.write(
f"[mock-llm] listening on http://{HOST}:{PORT} "
f"(fixtures: {FIXTURES_DIR})\n"
)
sys.stderr.flush()
# threaded=True so that concurrent streaming + embeddings requests from
# the Flask backend + Celery worker don't serialize behind each other.
app.run(host=HOST, port=PORT, debug=False, use_reloader=False, threaded=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,17 @@
{
"_comment": "Tester fixture: 'please run whoami on my remote device' -> run_command tool call (whoami). Used to verify full mode auto-approves a safe non-denylisted command.",
"response": {
"content": "",
"tool_calls": [
{
"id": "call_remote_tester_whoami_1",
"type": "function",
"function": {
"name": "run_command",
"arguments": "{\"command\":\"whoami\",\"working_directory\":\"/tmp\",\"timeout_ms\":5000}"
}
}
],
"finish_reason": "tool_calls"
}
}
+167
View File
@@ -0,0 +1,167 @@
# Mock LLM fixtures
This directory holds **canned OpenAI Chat Completions responses** keyed by a
SHA-256 fingerprint of the request. The stub server at
`scripts/e2e/mock_llm.py` looks up `<hash>.json` here for every
`POST /v1/chat/completions` request; if no fixture matches, the server returns
a generic deterministic fallback and logs the missing hash to stderr so you
can promote it into a fixture later.
Embeddings do not use fixtures — they are generated on the fly from a
hash-seeded RNG. Only chat completions are fixtured.
## Filename format
```
<sha256-hex>.json
```
The hash is a lowercase hex SHA-256 digest (64 characters, no prefix, no
extension beyond `.json`). Example:
```
3f5a7b9c...d12ef0.json
```
## How the hash is computed
The fingerprint covers only the fields that should control which canned answer
is returned:
```python
canonical = {
"model": payload.get("model"),
"messages": [minimal({role, content, name?, tool_call_id?, tool_calls?}) ...],
"tool_choice": payload.get("tool_choice"),
}
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
digest = hashlib.sha256(blob.encode("utf-8")).hexdigest()
```
Notes:
- `temperature`, `top_p`, `seed`, `max_tokens`, `stream`, and any other
sampling/transport knobs **do not influence the hash**. Streaming and
non-streaming variants of the same request resolve to the same fixture.
- Message `content` is kept verbatim — if the app passes a list of content
parts (vision / multi-modal), the list is hashed as-is.
- `tool_calls` on assistant messages and `tool_call_id` / `name` on tool
messages are included because they change what the model is *replying to*.
The canonical source of the hashing logic is `_compute_request_digest` in
`scripts/e2e/mock_llm.py`. If you change that function, regenerate all
fixtures.
## Computing a fixture hash from the command line
The easiest path: run the e2e suite once with your new flow, grep stderr for
`[mock-llm] unknown fixture hash <hash>` along with the request dump on the
following line, and save the canned answer under `<hash>.json`. The up.sh log
tail preserves both lines.
If you need to compute a hash by hand from a request payload:
```python
# scripts/e2e/compute_hash.py (not committed; run ad hoc)
import hashlib, json, sys
payload = json.load(sys.stdin)
canonical = {
"model": payload.get("model"),
"messages": [
{k: v for k, v in msg.items() if k in {"role", "content", "name", "tool_call_id", "tool_calls"}}
for msg in payload.get("messages", [])
],
"tool_choice": payload.get("tool_choice"),
}
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
print(hashlib.sha256(blob.encode("utf-8")).hexdigest())
```
```bash
cat request.json | python scripts/e2e/compute_hash.py
```
## Fixture JSON schema
```json
{
"request_digest": "<hash>",
"description": "Human description of when this is used",
"response": {
"content": "The canned assistant text, may include markdown.",
"tool_calls": null,
"finish_reason": "stop",
"usage": {"prompt_tokens": 12, "completion_tokens": 34}
}
}
```
### Field reference
| Field | Type | Required | Notes |
|---|---|---|---|
| `request_digest` | string | no (documentation only) | Must match the filename. The loader does not re-verify this, it's here for human review. |
| `description` | string | no | Short note on what flow this covers — makes grepping fixtures easier. |
| `response.content` | string | yes (if no `tool_calls`) | The assistant's reply body. Plain text or markdown. Empty string is legal when `tool_calls` is set. |
| `response.tool_calls` | array \| null | no | OpenAI tool-call shape: `[{"id": "call_x", "type": "function", "function": {"name": "...", "arguments": "{...}"}}]`. Arguments must be a JSON **string**, not an object. |
| `response.finish_reason` | string | no (defaults to `"stop"`) | Use `"tool_calls"` when returning tool calls, `"length"` to simulate truncation. |
| `response.usage.prompt_tokens` | number | no | Used verbatim in the non-streaming envelope. Default: estimated from request messages. |
| `response.usage.completion_tokens` | number | no | Default: estimated from `content`. |
`response.usage.total_tokens` is always recomputed as the sum — do not set it.
### Streaming behavior
The stub handles streaming vs non-streaming transparently for both content
and tool-call fixtures:
- **Content fixtures** are split into ~5 SSE deltas by character length. Only
the last delta carries `finish_reason`.
- **Tool-call fixtures** are emitted as a single delta containing the full
`tool_calls` array, followed by a final empty delta carrying `finish_reason`.
No fixture change is needed to toggle between streaming and non-streaming;
the app's `stream=true` flag alone controls it.
## Tool-call example
```json
{
"request_digest": "abc123...",
"description": "Agent calls the weather tool for 'weather in London?'",
"response": {
"content": "",
"tool_calls": [
{
"id": "call_e2e_weather_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"London\"}"
}
}
],
"finish_reason": "tool_calls"
}
}
```
## Workflow for adding a fixture
1. Run the failing e2e spec. Watch `scripts/e2e/up.sh`'s log tail (or
`/tmp/docsgpt-e2e/mock_llm.log` depending on how orchestration pipes it).
2. Find the `[mock-llm] unknown fixture hash <hash>` warning and the request
dump on the following line.
3. Create `mock_llm_fixtures/<hash>.json` with the schema above.
4. Re-run the spec — the warning should disappear and the spec should pass.
5. Commit the fixture. Fixtures are checked into the repo so every developer
and every CI run gets the same canned answers.
## Determinism guarantees
- Same request → same hash → same fixture → same response, always.
- No `time.time()`, no random seeds, no environment dependence in the hash.
- Embeddings are hash-seeded but never all-zero (the stub nudges the first
component away from 0 if the seeded draw is near zero), so the vector store
ingest path never rejects them.
@@ -0,0 +1,17 @@
{
"_comment": "Tester fixture: 'please run mkdir /tmp/rd_v3_test_dir on my remote device' -> run_command tool call (mkdir /tmp/rd_v3_test_dir). Used to verify a write command in ask mode still pauses on the approval bar.",
"response": {
"content": "",
"tool_calls": [
{
"id": "call_remote_tester_mkdir_1",
"type": "function",
"function": {
"name": "run_command",
"arguments": "{\"command\":\"mkdir /tmp/rd_v3_test_dir\",\"working_directory\":\"/tmp\",\"timeout_ms\":5000}"
}
}
],
"finish_reason": "tool_calls"
}
}
@@ -0,0 +1,17 @@
{
"_comment": "Tester fixture: 'please run rm -rf / on my remote device' -> run_command tool call (rm -rf /). Used to verify full mode still pauses on the denylist-forced-prompt circuit-breaker.",
"response": {
"content": "",
"tool_calls": [
{
"id": "call_remote_tester_rm_1",
"type": "function",
"function": {
"name": "run_command",
"arguments": "{\"command\":\"rm -rf /\",\"working_directory\":\"/tmp\",\"timeout_ms\":5000}"
}
}
],
"finish_reason": "tool_calls"
}
}
@@ -0,0 +1,17 @@
{
"_comment": "Tester fixture: 'please run ls -la /tmp on my remote device' -> run_command tool call (ls -la /tmp).",
"response": {
"content": "",
"tool_calls": [
{
"id": "call_remote_tester_1",
"type": "function",
"function": {
"name": "run_command",
"arguments": "{\"command\":\"ls -la /tmp\",\"working_directory\":\"/tmp\",\"timeout_ms\":5000}"
}
}
],
"finish_reason": "tool_calls"
}
}
+307
View File
@@ -0,0 +1,307 @@
"""Mock OpenID Connect provider for the DocsGPT e2e suite and local dev.
Speaks the minimum OIDC surface the backend's ``AUTH_TYPE=oidc`` flow needs:
* ``GET /.well-known/openid-configuration`` (discovery)
* ``GET /authorize`` (auto-approves and redirects back with a code)
* ``POST /token`` (single-use code + PKCE S256 check, RS256 ``id_token``,
single-use rotating ``refresh_token``; also ``grant_type=refresh_token``)
* ``GET /jwks`` (public key for ID-token verification)
* ``GET /userinfo`` (Bearer access token from ``/token``)
* ``GET /end-session`` (honors ``post_logout_redirect_uri``)
* ``POST /trigger-backchannel-logout`` (test hook: signs and delivers a
back-channel logout token to a given URL)
* ``GET /healthz`` (liveness probe)
There is no login form: every ``/authorize`` request is approved as the user
configured via ``MOCK_OIDC_SUB`` / ``MOCK_OIDC_EMAIL`` (overridable per
request with ``?sub=``/``?email=`` for multi-user tests). Group membership
comes from ``MOCK_OIDC_GROUPS`` (comma-separated).
Run standalone (does NOT import anything from ``application/``). Dependencies
(flask, python-jose, cryptography, requests) are all in
``application/requirements.txt``.
Usage::
python scripts/e2e/mock_oidc_idp.py
Defaults to ``127.0.0.1:7999`` to match ``scripts/e2e/env.sh``'s
``OIDC_ISSUER`` default for ``AUTH_TYPE=oidc`` runs.
"""
from __future__ import annotations
import base64
import hashlib
import os
import secrets
import sys
import time
from urllib.parse import urlencode
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from flask import Flask, Response, jsonify, redirect, request
from jose import jwk
from jose import jwt as jose_jwt
HOST = os.environ.get("MOCK_OIDC_HOST", "127.0.0.1")
PORT = int(os.environ.get("MOCK_OIDC_PORT", "7999"))
ISSUER = f"http://{HOST}:{PORT}"
DEFAULT_SUB = os.environ.get("MOCK_OIDC_SUB", "mock-oidc-user")
DEFAULT_EMAIL = os.environ.get("MOCK_OIDC_EMAIL", "mock-oidc-user@example.com")
DEFAULT_NAME = os.environ.get("MOCK_OIDC_NAME", "Mock OIDC User")
DEFAULT_GROUPS = [
group.strip()
for group in os.environ.get("MOCK_OIDC_GROUPS", "docsgpt-users").split(",")
if group.strip()
]
DEFAULT_CLIENT_ID = os.environ.get("MOCK_OIDC_CLIENT_ID", "docsgpt-e2e")
ID_TOKEN_TTL_SECONDS = 300
LOGOUT_TOKEN_TTL_SECONDS = 120
BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"
# Random per process: each restart generates a fresh RSA key, and a fresh
# kid lets relying parties detect the change via their kid-miss refetch
# path instead of failing signature checks against a stale cached JWKS.
KID = f"mock-oidc-key-{secrets.token_hex(4)}"
app = Flask(__name__)
_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
PRIVATE_PEM = _private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("ascii")
PUBLIC_JWK = {
**jwk.construct(PRIVATE_PEM, algorithm="RS256").public_key().to_dict(),
"kid": KID,
"use": "sig",
}
# code -> {client_id, redirect_uri, code_challenge, nonce, sub, email, name, groups}
_codes: dict[str, dict] = {}
# access_token -> {client_id, sub, email, name, groups}
_access_tokens: dict[str, dict] = {}
# refresh_token -> {client_id, sub, email, name, groups} (single-use, rotated)
_refresh_tokens: dict[str, dict] = {}
_last_client_id: str | None = None
def _log(message: str) -> None:
sys.stderr.write(f"[mock-oidc] {message}\n")
sys.stderr.flush()
def _pkce_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
def _user_record(record: dict) -> dict:
"""Identity fields carried from /authorize through tokens and userinfo."""
return {
"client_id": record["client_id"],
"sub": record["sub"],
"email": record["email"],
"name": record["name"],
"groups": list(record.get("groups") or DEFAULT_GROUPS),
}
def _issue_tokens(record: dict, nonce: str | None) -> dict:
"""Mint an id_token (+ tracked access/refresh tokens) for ``record``."""
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": record["client_id"],
"sub": record["sub"],
"email": record["email"],
"name": record["name"],
"groups": list(record.get("groups") or DEFAULT_GROUPS),
"iat": now,
"exp": now + ID_TOKEN_TTL_SECONDS,
}
if nonce:
claims["nonce"] = nonce
id_token = jose_jwt.encode(claims, PRIVATE_PEM, algorithm="RS256", headers={"kid": KID})
access_token = secrets.token_urlsafe(24)
refresh_token = secrets.token_urlsafe(24)
_access_tokens[access_token] = _user_record(record)
_refresh_tokens[refresh_token] = _user_record(record)
return {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": ID_TOKEN_TTL_SECONDS,
"id_token": id_token,
"refresh_token": refresh_token,
}
@app.get("/.well-known/openid-configuration")
def discovery() -> Response:
return jsonify(
{
"issuer": ISSUER,
"authorization_endpoint": f"{ISSUER}/authorize",
"token_endpoint": f"{ISSUER}/token",
"jwks_uri": f"{ISSUER}/jwks",
"userinfo_endpoint": f"{ISSUER}/userinfo",
"end_session_endpoint": f"{ISSUER}/end-session",
"backchannel_logout_supported": True,
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"id_token_signing_alg_values_supported": ["RS256"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["openid", "profile", "email"],
}
)
@app.get("/jwks")
def jwks() -> Response:
return jsonify({"keys": [PUBLIC_JWK]})
@app.get("/authorize")
def authorize() -> Response:
args = request.args
missing = [
name
for name in ("client_id", "redirect_uri", "code_challenge", "state")
if not args.get(name)
]
if missing:
return Response(f"missing params: {', '.join(missing)}", status=400)
if args.get("response_type") != "code":
return Response("unsupported response_type", status=400)
if args.get("code_challenge_method") != "S256":
return Response("unsupported code_challenge_method", status=400)
code = secrets.token_urlsafe(24)
_codes[code] = {
"client_id": args["client_id"],
"redirect_uri": args["redirect_uri"],
"code_challenge": args["code_challenge"],
"nonce": args.get("nonce"),
"sub": args.get("sub") or DEFAULT_SUB,
"email": args.get("email") or DEFAULT_EMAIL,
"name": DEFAULT_NAME,
"groups": list(DEFAULT_GROUPS),
}
_log(f"authorize: auto-approved sub={_codes[code]['sub']}")
separator = "&" if "?" in args["redirect_uri"] else "?"
query = urlencode({"code": code, "state": args["state"]})
return redirect(f"{args['redirect_uri']}{separator}{query}", code=302)
@app.post("/token")
def token() -> Response:
global _last_client_id
form = request.form
grant_type = form.get("grant_type")
if grant_type == "refresh_token":
record = _refresh_tokens.pop(form.get("refresh_token", ""), None) # single-use
if record is None:
_log("token: unknown or reused refresh_token")
return jsonify({"error": "invalid_grant"}), 400
_last_client_id = record["client_id"]
_log(f"token: refreshed tokens for sub={record['sub']}")
return jsonify(_issue_tokens(record, nonce=None))
if grant_type != "authorization_code":
return jsonify({"error": "unsupported_grant_type"}), 400
record = _codes.pop(form.get("code", ""), None) # single-use
if record is None:
_log("token: unknown or replayed code")
return jsonify({"error": "invalid_grant"}), 400
if form.get("redirect_uri") != record["redirect_uri"]:
return jsonify({"error": "invalid_grant", "error_description": "redirect_uri mismatch"}), 400
if form.get("client_id") != record["client_id"]:
return jsonify({"error": "invalid_client"}), 400
verifier = form.get("code_verifier", "")
if not verifier or _pkce_challenge(verifier) != record["code_challenge"]:
_log("token: PKCE verification failed")
return jsonify({"error": "invalid_grant", "error_description": "PKCE failed"}), 400
_last_client_id = record["client_id"]
_log(f"token: issued id_token for sub={record['sub']}")
return jsonify(_issue_tokens(record, nonce=record["nonce"]))
@app.get("/userinfo")
def userinfo() -> Response:
header = request.headers.get("Authorization", "")
access_token = header[len("Bearer "):] if header.startswith("Bearer ") else ""
record = _access_tokens.get(access_token)
if record is None:
_log("userinfo: unknown access token")
return jsonify({"error": "invalid_token"}), 401
return jsonify(
{
"sub": record["sub"],
"email": record["email"],
"name": record["name"],
"groups": record["groups"],
}
)
@app.post("/trigger-backchannel-logout")
def trigger_backchannel_logout() -> Response:
"""Test hook: sign a back-channel logout token and POST it to ``url``."""
body = request.get_json(silent=True) or {}
url = body.get("url")
sub = body.get("sub")
sid = body.get("sid")
if not url or not (sub or sid):
return jsonify({"error": "url and sub (or sid) required"}), 400
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": body.get("client_id") or _last_client_id or DEFAULT_CLIENT_ID,
"iat": now,
"exp": now + LOGOUT_TOKEN_TTL_SECONDS,
"jti": secrets.token_urlsafe(16),
"events": {BACKCHANNEL_LOGOUT_EVENT: {}},
}
if sub:
claims["sub"] = sub
if sid:
claims["sid"] = sid
logout_token = jose_jwt.encode(claims, PRIVATE_PEM, algorithm="RS256", headers={"kid": KID})
try:
downstream = requests.post(url, data={"logout_token": logout_token}, timeout=10)
except requests.RequestException as exc:
_log(f"trigger-backchannel-logout: delivery to {url} failed: {exc}")
return jsonify({"error": "delivery_failed", "detail": str(exc)}), 502
_log(f"trigger-backchannel-logout: {url} responded {downstream.status_code}")
return jsonify({"status": downstream.status_code})
@app.get("/end-session")
def end_session() -> Response:
target = request.args.get("post_logout_redirect_uri")
_log(f"end-session: redirect={target or '<none>'}")
if target:
return redirect(target, code=302)
return Response("Signed out of mock IdP.", status=200, mimetype="text/plain")
@app.get("/healthz")
def healthz() -> Response:
return jsonify({"ok": True})
def main() -> None:
_log(f"listening on {ISSUER} (sub={DEFAULT_SUB}, groups={','.join(DEFAULT_GROUPS)})")
app.run(host=HOST, port=PORT, debug=False, use_reloader=False, threaded=True)
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Reset the DocsGPT e2e DB by cloning it from the baked template.
#
# Called by scripts/e2e/up.sh at the start of each e2e run.
# Fast path: a single psql session does terminate + drop + clone.
set -euo pipefail
PG_BIN="/Users/Shared/DBngin/postgresql/16.2/bin"
PSQL="${PG_BIN}/psql"
PG_ISREADY="${PG_BIN}/pg_isready"
PG_HOST="127.0.0.1"
PG_PORT="5432"
PG_SUPERUSER="postgres"
TEMPLATE_DB="docsgpt_e2e_template"
E2E_DB="docsgpt_e2e"
OWNER_ROLE="docsgpt"
log() {
printf '[reset_db] %s\n' "$*" >&2
}
if [[ ! -x "${PSQL}" ]]; then
log "psql not found at ${PSQL} — is DBngin Postgres 16.2 installed?"
exit 1
fi
if ! "${PG_ISREADY}" -h "${PG_HOST}" -p "${PG_PORT}" -q; then
log "Postgres is not accepting connections at ${PG_HOST}:${PG_PORT}."
exit 1
fi
# Verify the template exists before attempting to clone from it.
template_exists="$(
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-tAX -c "SELECT 1 FROM pg_database WHERE datname = '${TEMPLATE_DB}';"
)"
if [[ "${template_exists}" != "1" ]]; then
log "Template DB '${TEMPLATE_DB}' does not exist."
log "Run scripts/e2e/bake_template.sh once before the first e2e run."
exit 1
fi
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
-v ON_ERROR_STOP=1 -X -q <<SQL
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '${E2E_DB}'
AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS ${E2E_DB};
CREATE DATABASE ${E2E_DB} TEMPLATE ${TEMPLATE_DB} OWNER ${OWNER_ROLE};
SQL
exit 0
+12
View File
@@ -0,0 +1,12 @@
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT tablename
FROM pg_tables
WHERE schemaname='public'
AND tablename <> 'alembic_version'
LOOP
EXECUTE format('TRUNCATE TABLE %I RESTART IDENTITY CASCADE', r.tablename);
END LOOP;
END $$;
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env bash
# scripts/e2e/up.sh
#
# Boot the DocsGPT end-to-end test stack on this machine, natively.
# See e2e-plan.md (Phase 0 / P0-A) for the contract.
#
# Happy path:
# 1. Preflight shared services (Postgres, Redis). Fail loud if down.
# 2. Reset state: Postgres template clone, Redis FLUSHDB 11/12/13, wipe .e2e-tmp.
# 3. Export env.
# 4. Start mock LLM (7899) → Flask (7099) → Celery → Vite (5179), each in
# background, each with its own pidfile + log + readiness probe.
# 5. Exit 0, leaving services running. Playwright (or the user) invokes
# down.sh separately when done.
#
# On error before handoff: tear everything down, non-zero exit.
# We explicitly DO NOT tear down on the happy-path exit — that would defeat
# the purpose of "up".
set -euo pipefail
# -----------------------------------------------------------------------------
# Paths
# -----------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PG_BIN="/Users/Shared/DBngin/postgresql/16.2/bin"
DBNGIN_REDIS_BIN="/Users/Shared/DBngin/redis/7.0.0/bin"
# Resolve redis-cli — PATH first, then DBngin's bundled copy.
if command -v redis-cli >/dev/null 2>&1; then
REDIS_CLI="$(command -v redis-cli)"
elif [[ -x "$DBNGIN_REDIS_BIN/redis-cli" ]]; then
REDIS_CLI="$DBNGIN_REDIS_BIN/redis-cli"
else
REDIS_CLI=""
fi
PIDDIR="/tmp/docsgpt-e2e"
E2E_TMP="$REPO_ROOT/.e2e-tmp"
LOGDIR="$E2E_TMP/logs"
BOOT_LOG="$LOGDIR/up.log"
SVC_LOGDIR="$PIDDIR" # per-service logs live with the pidfiles per the brief
MOCK_LLM_PORT=7899
FLASK_PORT=7099
VITE_PORT=5179
# -----------------------------------------------------------------------------
# Bookkeeping — track which services we successfully started so we can tear
# them down if something later fails.
# -----------------------------------------------------------------------------
HANDOFF_OK=0
STARTED_SERVICES=()
log() {
local msg="[up.sh] $*"
# Goes to stderr so stdout stays clean; also mirrored to the boot log.
echo "$msg" >&2
if [[ -n "${BOOT_LOG:-}" ]] && [[ -d "$(dirname "$BOOT_LOG")" ]]; then
echo "$msg" >> "$BOOT_LOG"
fi
}
die() {
log "ERROR: $*"
exit 1
}
# Trap: if we exit before handoff (failure or Ctrl-C), clean up. The happy
# path sets HANDOFF_OK=1 just before `exit 0`, so the trap becomes a no-op.
cleanup_on_failure() {
local rc=$?
if [[ "$HANDOFF_OK" -eq 1 ]]; then
return 0
fi
log "aborting — tearing down any services that started (rc=$rc)"
if [[ -x "$SCRIPT_DIR/down.sh" ]]; then
"$SCRIPT_DIR/down.sh" || true
fi
}
trap cleanup_on_failure EXIT INT TERM
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
# Wait for a shell predicate to succeed. Args: <label> <timeout-seconds> <cmd...>
wait_for() {
local label="$1"
local timeout="$2"
shift 2
local elapsed=0
while (( elapsed < timeout )); do
if "$@" >/dev/null 2>&1; then
log " -> $label ready after ${elapsed}s"
return 0
fi
sleep 1
elapsed=$(( elapsed + 1 ))
done
return 1
}
# Wait for a substring to appear in a log file.
wait_for_log() {
local label="$1"
local timeout="$2"
local logfile="$3"
local needle="$4"
local elapsed=0
while (( elapsed < timeout )); do
if [[ -f "$logfile" ]] && grep -qF -- "$needle" "$logfile"; then
log " -> $label saw '$needle' after ${elapsed}s"
return 0
fi
sleep 1
elapsed=$(( elapsed + 1 ))
done
return 1
}
# Dump last 50 lines of a log file to stderr (for boot-failure diagnostics).
dump_tail() {
local label="$1"
local logfile="$2"
echo "---- last 50 lines of $label ($logfile) ----" >&2
if [[ -f "$logfile" ]]; then
tail -n 50 "$logfile" >&2 || true
else
echo "(log file does not exist)" >&2
fi
echo "---- end $label ----" >&2
}
# Boot-failure handler: dump the log, then let the trap tear everything down.
boot_fail() {
local svc="$1"
local logfile="$2"
local reason="$3"
log "boot failure: $svc$reason"
dump_tail "$svc" "$logfile"
exit 1
}
# -----------------------------------------------------------------------------
# 1. Preflight
# -----------------------------------------------------------------------------
log "preflight: checking shared native services"
if [[ ! -x "$PG_BIN/pg_isready" ]]; then
die "pg_isready not found at $PG_BIN/pg_isready — is DBngin Postgres 16.2 installed?"
fi
if ! "$PG_BIN/pg_isready" -h 127.0.0.1 -p 5432 -U docsgpt -d postgres >/dev/null 2>&1; then
die "Postgres not reachable at 127.0.0.1:5432 as user 'docsgpt'. Start DBngin Postgres 16.2. (CLAUDE.md: do not kill/start this process from scripts.)"
fi
log " -> postgres OK"
if [[ -z "$REDIS_CLI" ]]; then
die "redis-cli not found on PATH nor at $DBNGIN_REDIS_BIN/redis-cli — install redis or adjust DBNGIN_REDIS_BIN"
fi
if ! "$REDIS_CLI" -h 127.0.0.1 -p 6379 PING 2>/dev/null | grep -q '^PONG$'; then
die "Redis not reachable at 127.0.0.1:6379. Start the native redis-server. (CLAUDE.md: do not kill/start this process from scripts.)"
fi
log " -> redis OK"
# -----------------------------------------------------------------------------
# 2. Reset state
# -----------------------------------------------------------------------------
log "resetting state"
# Wipe & recreate .e2e-tmp first so BOOT_LOG has a home.
rm -rf "$E2E_TMP"
mkdir -p "$E2E_TMP/inputs" "$E2E_TMP/indexes" "$LOGDIR"
: > "$BOOT_LOG"
log " -> .e2e-tmp wiped; logs at $LOGDIR"
mkdir -p "$PIDDIR"
# Leave existing per-service logs alone until we overwrite them at launch time;
# that way a prior failure log isn't immediately erased if someone re-runs up.
# Postgres reset — delegated to reset_db.sh (owned by track P0-B).
RESET_DB_SCRIPT="$SCRIPT_DIR/reset_db.sh"
if [[ ! -x "$RESET_DB_SCRIPT" ]]; then
die "reset_db.sh missing or not executable at $RESET_DB_SCRIPT — has track P0-B landed?"
fi
log " -> invoking reset_db.sh"
if ! "$RESET_DB_SCRIPT" >> "$BOOT_LOG" 2>&1; then
die "reset_db.sh failed — see $BOOT_LOG"
fi
# Redis reset — three dedicated DB indices.
for db in 11 12 13; do
if ! "$REDIS_CLI" -h 127.0.0.1 -p 6379 -n "$db" FLUSHDB >/dev/null 2>&1; then
die "redis-cli FLUSHDB failed on db $db"
fi
done
log " -> redis dbs 11/12/13 flushed"
# -----------------------------------------------------------------------------
# 3. Load env
# -----------------------------------------------------------------------------
log "sourcing env.sh"
# shellcheck source=./env.sh
source "$SCRIPT_DIR/env.sh"
# -----------------------------------------------------------------------------
# 4. Start services
# -----------------------------------------------------------------------------
# Pick Flask / python binaries from the repo venv when present.
if [[ -x "$REPO_ROOT/.venv/bin/flask" ]]; then
FLASK_BIN="$REPO_ROOT/.venv/bin/flask"
else
FLASK_BIN="$(command -v flask || true)"
fi
if [[ -z "$FLASK_BIN" ]]; then
die "flask binary not found (.venv/bin/flask missing and no 'flask' on PATH)"
fi
if [[ -x "$REPO_ROOT/.venv/bin/python" ]]; then
PY_BIN="$REPO_ROOT/.venv/bin/python"
else
PY_BIN="$(command -v python3 || command -v python || true)"
fi
if [[ -z "$PY_BIN" ]]; then
die "python binary not found (.venv/bin/python missing and no 'python3' on PATH)"
fi
log "using flask=$FLASK_BIN python=$PY_BIN"
# ---- 4a. Mock LLM ------------------------------------------------------------
MOCK_LLM_LOG="$SVC_LOGDIR/mock-llm.log"
MOCK_LLM_PID="$PIDDIR/mock-llm.pid"
log "starting mock LLM on 127.0.0.1:$MOCK_LLM_PORT"
(
cd "$REPO_ROOT"
# Port can be read from env by the script; we also export it for clarity.
MOCK_LLM_PORT="$MOCK_LLM_PORT" PYTHONUNBUFFERED=1 nohup "$PY_BIN" scripts/e2e/mock_llm.py \
>"$MOCK_LLM_LOG" 2>&1 &
echo $! > "$MOCK_LLM_PID"
)
STARTED_SERVICES+=("mock-llm")
if ! wait_for "mock-llm /healthz" 10 \
curl -sf "http://127.0.0.1:${MOCK_LLM_PORT}/healthz"; then
boot_fail "mock-llm" "$MOCK_LLM_LOG" "healthz did not respond within 10s"
fi
# ---- 4b. Flask ---------------------------------------------------------------
FLASK_LOG="$SVC_LOGDIR/flask.log"
FLASK_PID="$PIDDIR/flask.pid"
log "starting Flask on 127.0.0.1:$FLASK_PORT"
(
cd "$E2E_TMP"
PYTHONUNBUFFERED=1 nohup "$FLASK_BIN" --app ../application/app.py run \
--host 127.0.0.1 --port "$FLASK_PORT" \
>"$FLASK_LOG" 2>&1 &
echo $! > "$FLASK_PID"
)
STARTED_SERVICES+=("flask")
if ! wait_for "flask /api/config" 30 \
curl -sf "http://127.0.0.1:${FLASK_PORT}/api/config"; then
boot_fail "flask" "$FLASK_LOG" "/api/config did not respond within 30s"
fi
# ---- 4c. Celery --------------------------------------------------------------
CELERY_LOG="$SVC_LOGDIR/celery.log"
CELERY_PID="$PIDDIR/celery.pid"
log "starting Celery worker (solo pool)"
(
cd "$E2E_TMP"
PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
PYTHONUNBUFFERED=1 \
nohup "$PY_BIN" -m celery -A application.app.celery worker \
-l INFO --pool=solo -Q docsgpt,parsing \
--without-gossip --without-mingle --without-heartbeat \
>"$CELERY_LOG" 2>&1 &
echo $! > "$CELERY_PID"
)
STARTED_SERVICES+=("celery")
# Celery's "ready" banner contains both "celery@<host>" and "ready.". Wait for
# both in sequence so we know the worker actually finished bootstrapping.
if ! wait_for_log "celery 'celery@'" 30 "$CELERY_LOG" "celery@"; then
boot_fail "celery" "$CELERY_LOG" "never emitted 'celery@' banner within 30s"
fi
# Ready check via `celery inspect ping`. We can't grep the log for 'ready'
# because application/core/logging_config.py calls dictConfig with the default
# disable_existing_loggers=True, which silences celery.worker's ready banner.
# `inspect ping` queries the worker over the broker — it's the canonical
# responsiveness check and doesn't depend on log output.
CELERY_INSPECT_TIMEOUT=45
elapsed=0
ping_ok=0
while (( elapsed < CELERY_INSPECT_TIMEOUT )); do
if ( cd "$E2E_TMP" && \
PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
PYTHONUNBUFFERED=1 \
"$PY_BIN" -m celery -A application.app.celery inspect ping \
--timeout 2 >/dev/null 2>&1 ); then
ping_ok=1
log " -> celery inspect ping OK after ${elapsed}s"
break
fi
sleep 1
elapsed=$(( elapsed + 1 ))
done
if (( ping_ok == 0 )); then
boot_fail "celery" "$CELERY_LOG" "worker did not respond to 'inspect ping' within ${CELERY_INSPECT_TIMEOUT}s"
fi
# ---- 4d. Vite dev server -----------------------------------------------------
VITE_LOG="$SVC_LOGDIR/vite.log"
VITE_PID="$PIDDIR/vite.pid"
log "starting Vite dev server on 127.0.0.1:$VITE_PORT"
(
cd "$REPO_ROOT/frontend"
VITE_API_HOST="http://127.0.0.1:${FLASK_PORT}" nohup npm run dev -- \
--host 127.0.0.1 --port "$VITE_PORT" --strictPort \
>"$VITE_LOG" 2>&1 &
echo $! > "$VITE_PID"
)
STARTED_SERVICES+=("vite")
# Prefer nc; fall back to lsof. Either succeeding means the port is LISTEN.
vite_ready() {
if command -v nc >/dev/null 2>&1; then
nc -z 127.0.0.1 "$VITE_PORT" >/dev/null 2>&1 && return 0
fi
if command -v lsof >/dev/null 2>&1; then
[[ -n "$(lsof -nP -iTCP:"$VITE_PORT" -sTCP:LISTEN -t 2>/dev/null)" ]] && return 0
fi
return 1
}
if ! wait_for "vite LISTEN on $VITE_PORT" 20 vite_ready; then
boot_fail "vite" "$VITE_LOG" "port $VITE_PORT never entered LISTEN within 20s"
fi
# -----------------------------------------------------------------------------
# 5. Handoff
# -----------------------------------------------------------------------------
log "all services up:"
log " mock-llm pid=$(cat "$MOCK_LLM_PID") log=$MOCK_LLM_LOG"
log " flask pid=$(cat "$FLASK_PID") log=$FLASK_LOG"
log " celery pid=$(cat "$CELERY_PID") log=$CELERY_LOG"
log " vite pid=$(cat "$VITE_PID") log=$VITE_LOG"
log "handoff complete — exiting 0, services remain running. Run scripts/e2e/down.sh to stop."
HANDOFF_OK=1
exit 0