From 9c2a5634ffdef0f70b326e1de221a52f8f5ee8fc Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 25 Mar 2026 09:37:36 +0000 Subject: [PATCH] fix: feat: end-to-end disinto init smoke test in CI (#668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/smoke-init.sh — an end-to-end smoke test that runs disinto init --bare --yes against a real Forgejo instance (started as a Woodpecker service container). The test validates: - Forgejo API responds after init - Admin and bot users created with tokens - Repo created with labels on Forgejo - Project TOML generated correctly - .env written with FORGE_TOKEN and FORGE_REVIEW_TOKEN - Cron entries installed (dev-poll, review-poll, gardener) Uses mock binaries for docker (routes user creation to Forgejo admin API), claude, tmux, and crontab to run in CI without Docker-in-Docker. Wired into CI via .woodpecker/smoke-init.yml (separate pipeline with Forgejo service, runs on push and pull_request). Co-Authored-By: Claude Opus 4.6 (1M context) --- .woodpecker/smoke-init.yml | 24 +++ tests/smoke-init.sh | 430 +++++++++++++++++++++++++++++++++++++ 2 files changed, 454 insertions(+) create mode 100644 .woodpecker/smoke-init.yml create mode 100644 tests/smoke-init.sh diff --git a/.woodpecker/smoke-init.yml b/.woodpecker/smoke-init.yml new file mode 100644 index 0000000..d863f97 --- /dev/null +++ b/.woodpecker/smoke-init.yml @@ -0,0 +1,24 @@ +# .woodpecker/smoke-init.yml — End-to-end smoke test for disinto init +# +# Starts a real Forgejo instance as a service container, then runs +# disinto init --bare --yes against it and verifies the results. + +when: + event: [push, pull_request] + +services: + - name: forgejo + image: codeberg.org/forgejo/forgejo:11.0 + environment: + FORGEJO__database__DB_TYPE: sqlite3 + FORGEJO__server__ROOT_URL: "http://forgejo:3000/" + FORGEJO__server__HTTP_PORT: "3000" + +steps: + - name: smoke-init + image: debian:bookworm-slim + environment: + SMOKE_FORGE_URL: http://forgejo:3000 + commands: + - apt-get update -qq && apt-get install -y -qq --no-install-recommends bash curl jq python3 git ca-certificates >/dev/null 2>&1 + - bash tests/smoke-init.sh diff --git a/tests/smoke-init.sh b/tests/smoke-init.sh new file mode 100644 index 0000000..61338de --- /dev/null +++ b/tests/smoke-init.sh @@ -0,0 +1,430 @@ +#!/usr/bin/env bash +# tests/smoke-init.sh — End-to-end smoke test for disinto init +# +# Runs against a real Forgejo instance (Woodpecker service container). +# Validates the full init flow: Forgejo API, user/token creation, +# repo setup, labels, TOML generation, and cron installation. +# +# Required env: SMOKE_FORGE_URL (default: http://forgejo:3000) +# Required tools: bash, curl, jq, python3, git + +set -euo pipefail + +FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FORGE_URL="${SMOKE_FORGE_URL:-http://forgejo:3000}" +SETUP_ADMIN="setup-admin" +SETUP_PASS="SetupPass-789xyz" +SETUP_EMAIL="setup-admin@smoke.test" +TEST_SLUG="smoke-org/smoke-repo" +MOCK_BIN="/tmp/smoke-mock-bin" +MOCK_STATE="/tmp/smoke-mock-state" +FAILED=0 + +fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; } +pass() { printf 'PASS: %s\n' "$*"; } + +cleanup() { + rm -rf "$MOCK_BIN" "$MOCK_STATE" /tmp/smoke-test-repo /tmp/forgejo-cookies \ + /tmp/install-page.html "${FACTORY_ROOT}/projects/smoke-repo.toml" \ + "${FACTORY_ROOT}/docker-compose.yml" + # Restore .env only if we created the backup + if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then + mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env" + else + rm -f "${FACTORY_ROOT}/.env" + fi +} +trap cleanup EXIT + +# Back up existing .env if present +if [ -f "${FACTORY_ROOT}/.env" ]; then + cp "${FACTORY_ROOT}/.env" "${FACTORY_ROOT}/.env.smoke-backup" +fi +# Start with a clean .env (setup_forge writes tokens here) +printf '' > "${FACTORY_ROOT}/.env" + +# ── 0. Wait for Forgejo HTTP ──────────────────────────────────────────────── +echo "=== 0/7 Waiting for Forgejo at ${FORGE_URL} ===" +retries=0 +while true; do + if curl -sf --max-time 3 -o /dev/null "${FORGE_URL}/" 2>/dev/null; then + break + fi + retries=$((retries + 1)) + if [ "$retries" -gt 90 ]; then + fail "Forgejo not responsive after 90s" + exit 1 + fi + sleep 1 +done +pass "Forgejo HTTP responsive (${retries}s)" + +# ── 1. Complete Forgejo install ────────────────────────────────────────────── +echo "=== 1/7 Completing Forgejo initial setup ===" + +# GET the install page to obtain CSRF token and session cookie +curl -sf -c /tmp/forgejo-cookies "${FORGE_URL}/install" \ + -o /tmp/install-page.html 2>/dev/null || true + +# Extract CSRF token (hidden input or meta tag) +csrf_token="" +if [ -f /tmp/install-page.html ]; then + csrf_token=$(grep '_csrf' /tmp/install-page.html \ + | grep -oE '(content|value)="[^"]*"' \ + | head -1 | cut -d'"' -f2) || csrf_token="" +fi + +# Build POST data array +post_args=( + --data-urlencode "db_type=SQLite3" + --data-urlencode "db_path=/data/gitea/gitea.db" + --data-urlencode "app_name=Forgejo" + --data-urlencode "repo_root_path=/data/gitea/repositories" + --data-urlencode "lfs_root_path=/data/gitea/lfs" + --data-urlencode "run_user=git" + --data-urlencode "domain=forgejo" + --data-urlencode "ssh_port=22" + --data-urlencode "http_port=3000" + --data-urlencode "app_url=${FORGE_URL}/" + --data-urlencode "log_root_path=/data/gitea/log" + --data-urlencode "admin_name=${SETUP_ADMIN}" + --data-urlencode "admin_passwd=${SETUP_PASS}" + --data-urlencode "admin_confirm_passwd=${SETUP_PASS}" + --data-urlencode "admin_email=${SETUP_EMAIL}" +) +if [ -n "$csrf_token" ]; then + post_args+=(--data-urlencode "_csrf=${csrf_token}") +fi + +install_code=$(curl -s -b /tmp/forgejo-cookies \ + -o /dev/null -w '%{http_code}' \ + -X POST "${FORGE_URL}/install" \ + "${post_args[@]}" 2>/dev/null) || install_code="000" + +echo "Install POST returned HTTP ${install_code}" + +# Wait for Forgejo API to become functional after install +echo -n "Waiting for Forgejo API" +retries=0 +api_version="" +while true; do + api_version=$(curl -sf --max-time 3 "${FORGE_URL}/api/v1/version" 2>/dev/null \ + | jq -r '.version // empty' 2>/dev/null) || api_version="" + if [ -n "$api_version" ]; then + break + fi + retries=$((retries + 1)) + if [ "$retries" -gt 60 ]; then + echo "" + fail "Forgejo API not functional after install (60s)" + exit 1 + fi + echo -n "." + sleep 1 +done +echo " ready (v${api_version})" + +# Verify bootstrap admin user exists +if curl -sf --max-time 5 "${FORGE_URL}/api/v1/users/${SETUP_ADMIN}" >/dev/null 2>&1; then + pass "Bootstrap admin '${SETUP_ADMIN}' created via install endpoint" +else + fail "Bootstrap admin '${SETUP_ADMIN}' not found — install may have failed" + exit 1 +fi + +# ── 2. Set up mock binaries ───────────────────────────────────────────────── +echo "=== 2/7 Setting up mock binaries ===" +mkdir -p "$MOCK_BIN" "$MOCK_STATE" + +# Store bootstrap admin credentials for the docker mock +printf '%s:%s' "${SETUP_ADMIN}" "${SETUP_PASS}" > "$MOCK_STATE/bootstrap_creds" + +# ── Mock: docker ── +# Routes 'docker exec' user-creation calls to the Forgejo admin API, +# using the bootstrap admin's credentials. +cat > "$MOCK_BIN/docker" << 'DOCKERMOCK' +#!/usr/bin/env bash +set -euo pipefail + +FORGE_URL="${SMOKE_FORGE_URL:-http://forgejo:3000}" +MOCK_STATE="/tmp/smoke-mock-state" + +if [ ! -f "$MOCK_STATE/bootstrap_creds" ]; then + echo "mock-docker: bootstrap credentials not found" >&2 + exit 1 +fi +BOOTSTRAP_CREDS="$(cat "$MOCK_STATE/bootstrap_creds")" + +# docker ps — return empty (no containers running) +if [ "${1:-}" = "ps" ]; then + exit 0 +fi + +# docker exec — route to Forgejo API +if [ "${1:-}" = "exec" ]; then + shift # remove 'exec' + + # Skip docker exec flags (-u VALUE, -T, -i, etc.) + while [ $# -gt 0 ] && [ "${1#-}" != "$1" ]; do + case "$1" in + -u|-w|-e) shift 2 ;; + *) shift ;; + esac + done + shift # remove container name (e.g. disinto-forgejo) + + # $@ is now: forgejo admin user list|create [flags] + if [ "${1:-}" = "forgejo" ] && [ "${2:-}" = "admin" ] && [ "${3:-}" = "user" ]; then + subcmd="${4:-}" + + if [ "$subcmd" = "list" ]; then + echo "ID Username Email" + exit 0 + fi + + if [ "$subcmd" = "create" ]; then + shift 4 # skip 'forgejo admin user create' + username="" password="" email="" is_admin="false" + while [ $# -gt 0 ]; do + case "$1" in + --admin) is_admin="true"; shift ;; + --username) username="$2"; shift 2 ;; + --password) password="$2"; shift 2 ;; + --email) email="$2"; shift 2 ;; + --must-change-password*) shift ;; + *) shift ;; + esac + done + + if [ -z "$username" ] || [ -z "$password" ] || [ -z "$email" ]; then + echo "mock-docker: missing required args" >&2 + exit 1 + fi + + # Create user via Forgejo admin API + if ! curl -sf -X POST \ + -u "$BOOTSTRAP_CREDS" \ + -H "Content-Type: application/json" \ + "${FORGE_URL}/api/v1/admin/users" \ + -d "{\"username\":\"${username}\",\"password\":\"${password}\",\"email\":\"${email}\",\"must_change_password\":false,\"login_name\":\"${username}\",\"source_id\":0}" \ + >/dev/null 2>&1; then + echo "mock-docker: failed to create user '${username}'" >&2 + exit 1 + fi + + # Promote to site admin if requested + if [ "$is_admin" = "true" ]; then + curl -sf -X PATCH \ + -u "$BOOTSTRAP_CREDS" \ + -H "Content-Type: application/json" \ + "${FORGE_URL}/api/v1/admin/users/${username}" \ + -d "{\"admin\":true,\"login_name\":\"${username}\",\"source_id\":0}" \ + >/dev/null 2>&1 || true + fi + + echo "New user '${username}' has been successfully created!" + exit 0 + fi + fi + + echo "mock-docker: unhandled exec: $*" >&2 + exit 1 +fi + +echo "mock-docker: unhandled command: $*" >&2 +exit 1 +DOCKERMOCK +chmod +x "$MOCK_BIN/docker" + +# ── Mock: claude ── +cat > "$MOCK_BIN/claude" << 'CLAUDEMOCK' +#!/usr/bin/env bash +case "$*" in + *"auth status"*) printf '{"loggedIn":true}\n' ;; + *"--version"*) printf 'claude 1.0.0 (mock)\n' ;; +esac +exit 0 +CLAUDEMOCK +chmod +x "$MOCK_BIN/claude" + +# ── Mock: tmux ── +printf '#!/usr/bin/env bash\nexit 0\n' > "$MOCK_BIN/tmux" +chmod +x "$MOCK_BIN/tmux" + +# ── Mock: crontab ── +cat > "$MOCK_BIN/crontab" << 'CRONMOCK' +#!/usr/bin/env bash +CRON_FILE="/tmp/smoke-mock-state/crontab-entries" +case "${1:-}" in + -l) cat "$CRON_FILE" 2>/dev/null || true ;; + -) cat > "$CRON_FILE" ;; + *) exit 0 ;; +esac +CRONMOCK +chmod +x "$MOCK_BIN/crontab" + +export PATH="$MOCK_BIN:$PATH" +pass "Mock binaries installed (docker, claude, tmux, crontab)" + +# ── 3. Prepare source repo ────────────────────────────────────────────────── +echo "=== 3/7 Preparing source repo ===" +# Configure git identity for the test (needed for any git operations) +git config --global user.email "smoke@test.local" +git config --global user.name "Smoke Test" + +pass "Git configured" + +# ── 4. Run disinto init ───────────────────────────────────────────────────── +echo "=== 4/7 Running disinto init ===" +rm -f "${FACTORY_ROOT}/projects/smoke-repo.toml" + +export SMOKE_FORGE_URL="$FORGE_URL" +# FORGE_URL is read by lib/env.sh; override so init uses our test Forgejo +export FORGE_URL + +if bash "${FACTORY_ROOT}/bin/disinto" init \ + "${TEST_SLUG}" \ + --bare --yes \ + --forge-url "$FORGE_URL" \ + --repo-root "/tmp/smoke-test-repo"; then + pass "disinto init completed successfully" +else + fail "disinto init exited non-zero" +fi + +# ── 5. Verify Forgejo state ───────────────────────────────────────────────── +echo "=== 5/7 Verifying Forgejo state ===" + +# Admin user exists +if curl -sf --max-time 5 "${FORGE_URL}/api/v1/users/disinto-admin" >/dev/null 2>&1; then + pass "Admin user 'disinto-admin' exists on Forgejo" +else + fail "Admin user 'disinto-admin' not found on Forgejo" +fi + +# Bot users exist +for bot in dev-bot review-bot; do + if curl -sf --max-time 5 "${FORGE_URL}/api/v1/users/${bot}" >/dev/null 2>&1; then + pass "Bot user '${bot}' exists on Forgejo" + else + fail "Bot user '${bot}' not found on Forgejo" + fi +done + +# Repo exists (try org path, then fallback to dev-bot user path) +repo_found=false +for repo_path in "${TEST_SLUG}" "dev-bot/smoke-repo" "disinto-admin/smoke-repo"; do + if curl -sf --max-time 5 "${FORGE_URL}/api/v1/repos/${repo_path}" >/dev/null 2>&1; then + pass "Repo '${repo_path}' exists on Forgejo" + repo_found=true + break + fi +done +if [ "$repo_found" = false ]; then + fail "Repo not found on Forgejo under any expected path" +fi + +# Labels exist on repo — use bootstrap admin to check +setup_token=$(curl -sf -X POST \ + -u "${SETUP_ADMIN}:${SETUP_PASS}" \ + -H "Content-Type: application/json" \ + "${FORGE_URL}/api/v1/users/${SETUP_ADMIN}/tokens" \ + -d '{"name":"smoke-verify","scopes":["all"]}' 2>/dev/null \ + | jq -r '.sha1 // empty') || setup_token="" + +if [ -n "$setup_token" ]; then + label_count=0 + for repo_path in "${TEST_SLUG}" "dev-bot/smoke-repo" "disinto-admin/smoke-repo"; do + label_count=$(curl -sf \ + -H "Authorization: token ${setup_token}" \ + "${FORGE_URL}/api/v1/repos/${repo_path}/labels?limit=50" 2>/dev/null \ + | jq 'length' 2>/dev/null) || label_count=0 + if [ "$label_count" -gt 0 ]; then + break + fi + done + + if [ "$label_count" -ge 5 ]; then + pass "Labels created on repo (${label_count} labels)" + else + fail "Expected >= 5 labels, found ${label_count}" + fi +else + fail "Could not obtain verification token from bootstrap admin" +fi + +# ── 6. Verify local state ─────────────────────────────────────────────────── +echo "=== 6/7 Verifying local state ===" + +# TOML was generated +toml_path="${FACTORY_ROOT}/projects/smoke-repo.toml" +if [ -f "$toml_path" ]; then + toml_name=$(python3 -c " +import tomllib, sys +with open(sys.argv[1], 'rb') as f: + print(tomllib.load(f)['name']) +" "$toml_path" 2>/dev/null) || toml_name="" + + if [ "$toml_name" = "smoke-repo" ]; then + pass "TOML generated with correct project name" + else + fail "TOML name mismatch: expected 'smoke-repo', got '${toml_name}'" + fi +else + fail "TOML not generated at ${toml_path}" +fi + +# .env has tokens +env_file="${FACTORY_ROOT}/.env" +if [ -f "$env_file" ]; then + if grep -q '^FORGE_TOKEN=' "$env_file"; then + pass ".env contains FORGE_TOKEN" + else + fail ".env missing FORGE_TOKEN" + fi + if grep -q '^FORGE_REVIEW_TOKEN=' "$env_file"; then + pass ".env contains FORGE_REVIEW_TOKEN" + else + fail ".env missing FORGE_REVIEW_TOKEN" + fi +else + fail ".env not found" +fi + +# Repo was cloned +if [ -d "/tmp/smoke-test-repo/.git" ]; then + pass "Repo cloned to /tmp/smoke-test-repo" +else + fail "Repo not cloned to /tmp/smoke-test-repo" +fi + +# ── 7. Verify cron setup ──────────────────────────────────────────────────── +echo "=== 7/7 Verifying cron setup ===" +cron_file="$MOCK_STATE/crontab-entries" +if [ -f "$cron_file" ]; then + if grep -q 'dev-poll.sh' "$cron_file"; then + pass "Cron includes dev-poll entry" + else + fail "Cron missing dev-poll entry" + fi + if grep -q 'review-poll.sh' "$cron_file"; then + pass "Cron includes review-poll entry" + else + fail "Cron missing review-poll entry" + fi + if grep -q 'gardener-run.sh' "$cron_file"; then + pass "Cron includes gardener entry" + else + fail "Cron missing gardener entry" + fi +else + fail "No cron entries captured (mock crontab file missing)" +fi + +# ── Summary ────────────────────────────────────────────────────────────────── +echo "" +if [ "$FAILED" -ne 0 ]; then + echo "=== SMOKE-INIT TEST FAILED ===" + exit 1 +fi +echo "=== SMOKE-INIT TEST PASSED ==="