Ctrl K
ComunidadGuías…

scenarios/lib.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# shellcheck shell=bash
# scenarios/lib.sh — shared helpers for the sparrow v3 e2e scenario suite.
#
# Sourced by every scenarios/NNN-name/run.sh. Provides:
#   scenario_prepare        pre-flight checks, create $SPARROW_TMPROOT, register
#                           EXIT cleanup, and ensure the docker image exists.
#                           Idempotent; called by scenario_start.
#   scenario_start          scenario_prepare + run a fresh container on a random
#                           free 127.0.0.1 port, wait for /healthz, set $SERVER.
#   scenario_requires       declare host-tool requirements (or set
#                           SCENARIO_REQUIRES=(compose swaks) first); a missing
#                           tool prints SKIP and exits 0 — never FAIL.
#   scenario_compose_start  the sidecar variant: everything scenario_prepare does,
#                           then `docker compose -p sparrow-scn-$$ up -d` on the
#                           scenario's own compose.yml. Exports $SERVER and
#                           $ADMIN_TOKEN IDENTICALLY to scenario_start, so every
#                           wrapper below works unchanged.
#
#   CLI wrappers (built CLI at apps/cli/dist/bin.js, isolated XDG_CONFIG_HOME):
#     ac_tok <token> <args...>   run the CLI as a bearer credential (ses_ or
#                                agk_) via SPARROW_TOKEN — the workhorse; no login
#                                needed. Shares one throwaway config dir since
#                                the token, not the store, carries auth.
#     ac_as <profile> <args...>  run the CLI with a per-profile config dir (for
#                                `login` / `enroll` flows that persist a profile).
#     ac <args...>               ac_as default.
#
#   HTTP helpers (raw API, base $SERVER/api/v1):
#     signup <email> <pw> <name>       POST /auth/signup → echoes the ses_ token
#                                      (FIRST signup on an instance bootstraps an
#                                      org and makes that human its owner).
#     api <token> <method> <path> [body]     authed curl, -f (fails on 4xx/5xx)
#     api_raw <token> <method> <path> [body] authed curl, body even on error
#     http_status <token> <method> <path> [body]  → numeric HTTP status only
#     admin_api <method> <path> [body]       curl with X-Admin-Token
#     first_org_id <token>                   → the caller's first org id
#     invite_token_of <url>                  → the ivk_ token from an invite URL
#
#   Assertions / reporting:
#     assert_eq / assert_contains / assert_json / pass / fail
#
# Env overrides:
#   SPARROW_SCENARIO_IMAGE   image tag (default sparrow:scenarios). run-all.sh exports
#                         it after building once so scenarios share one build.
#   SCENARIO_NAME         label in PASS/FAIL output (each run.sh sets it).
#   SCENARIO_DATA_DIR     optional host dir mounted at /data (pre-seed the db).
#   SCENARIO_EXTRA_ENV    optional bash array of extra KEY=VALUE container env
#                         (set before scenario_start; e.g. OPEN_ORG_CREATION=false).
#   SCENARIO_REQUIRES     optional bash array of host tools the scenario needs
#                         (e.g. (compose swaks)); a missing one → SKIP, exit 0.
#   SCENARIO_COMPOSE_PORTS  optional bash array of VARIABLE NAMES that
#                         scenario_compose_start fills with free host ports and
#                         exports into the compose environment (e.g. (SMTP_PORT)).
#   SCENARIO_COMPOSE_READY  optional bash array of `PORT_VAR:/path` readiness
#                         probes polled after `up -d`, alongside core's /healthz.

# Resolve repo root from this file's location (scenarios/ -> repo root).
SPARROW_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SPARROW_REPO_ROOT="$(cd "$SPARROW_LIB_DIR/.." && pwd)"
SPARROW_CLI_BIN="$SPARROW_REPO_ROOT/apps/cli/dist/bin.js"
SPARROW_IMAGE="${SPARROW_SCENARIO_IMAGE:-sparrow:scenarios}"
ADMIN_TOKEN_VALUE="scenario-admin"
SCENARIO_NAME="${SCENARIO_NAME:-scenario}"

# Colors (only when stdout is a terminal).
if [ -t 1 ]; then
  SPARROW_RED=$'\033[31m'; SPARROW_GREEN=$'\033[32m'; SPARROW_YELLOW=$'\033[33m'
  SPARROW_RESET=$'\033[0m'
else
  SPARROW_RED=''; SPARROW_GREEN=''; SPARROW_YELLOW=''; SPARROW_RESET=''
fi

# --- reporting -------------------------------------------------------------

fail() {
  printf '%s\n' "${SPARROW_RED}FAIL ${SCENARIO_NAME}${SPARROW_RESET}: $*" >&2
  exit 1
}

pass() {
  if [ $# -gt 0 ]; then
    printf '%s\n' "${SPARROW_GREEN}PASS ${SCENARIO_NAME}${SPARROW_RESET}: $*"
  else
    printf '%s\n' "${SPARROW_GREEN}PASS ${SCENARIO_NAME}${SPARROW_RESET}"
  fi
  exit 0
}

# skip <why> — the scenario cannot run on this host (a missing host tool, never a
# product problem). Exit 0: a machine without the SMTP toolchain must not turn
# the suite red — run-all.sh reads this line and reports SKIP in its summary.
skip() {
  printf '%s\n' "${SPARROW_YELLOW}SKIP ${SCENARIO_NAME}${SPARROW_RESET}: $*"
  exit 0
}

# --- assertions ------------------------------------------------------------

assert_eq() { # <expected> <actual> [message]
  if [ "$1" != "$2" ]; then
    fail "${3:-assert_eq}: expected [$1] got [$2]"
  fi
}

assert_contains() { # <haystack> <needle> [message]
  case "$1" in
    *"$2"*) : ;;
    *) fail "${3:-assert_contains}: [$1] does not contain [$2]" ;;
  esac
}

assert_not_contains() { # <haystack> <needle> [message]
  case "$1" in
    *"$2"*) fail "${3:-assert_not_contains}: [$1] unexpectedly contains [$2]" ;;
    *) : ;;
  esac
}

# assert_json <json> <jq-filter> <expected> [message]
assert_json() {
  local got
  if ! got="$(printf '%s' "$1" | jq -r "$2" 2>/dev/null)"; then
    fail "${4:-assert_json}: jq filter [$2] failed to evaluate"
  fi
  if [ "$got" != "$3" ]; then
    fail "${4:-assert_json}: filter [$2] expected [$3] got [$got] (json: $1)"
  fi
}

# --- image / container lifecycle ------------------------------------------

_ac_free_port() {
  node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{const p=s.address().port;s.close(()=>process.stdout.write(String(p)+"\n"))})'
}

# Build the scenario image from the repo-root Dockerfile (which owns apps/api's
# build context).
_ac_build_image() {
  echo "[lib] building image $SPARROW_IMAGE ..." >&2
  docker build -t "$SPARROW_IMAGE" "$SPARROW_REPO_ROOT" >&2 || fail "docker build failed"
}

_ac_ensure_image() {
  if docker image inspect "$SPARROW_IMAGE" >/dev/null 2>&1; then
    return 0
  fi
  _ac_build_image
}

# Cleanup handler registered by scenario_prepare. Removes the container (or the
# compose project) and any tmp dirs created for the run.
_ac_cleanup() {
  local code=$?
  if [ -n "${SPARROW_CID:-}" ]; then
    docker rm -fv "$SPARROW_CID" >/dev/null 2>&1 || true
  fi
  # A compose scenario takes its whole project down, volumes included, BEFORE the
  # tmp root is wiped (the project may bind-mount fixtures from it).
  if [ -n "${SPARROW_COMPOSE_PROJECT:-}" ] && [ -n "${SPARROW_COMPOSE_FILE:-}" ]; then
    docker compose -p "$SPARROW_COMPOSE_PROJECT" -f "$SPARROW_COMPOSE_FILE" \
      down -v --remove-orphans --timeout 5 >/dev/null 2>&1 || true
  fi
  # Files under a mounted SCENARIO_DATA_DIR may be root-owned (written by the
  # container); wipe them from inside a container before rm -rf'ing the tmp root.
  if [ -n "${SCENARIO_DATA_DIR:-}" ] && [ -d "${SCENARIO_DATA_DIR:-}" ]; then
    docker run --rm -v "$SCENARIO_DATA_DIR:/wipe" "$SPARROW_IMAGE" \
      node -e 'const fs=require("fs");for(const f of fs.readdirSync("/wipe"))fs.rmSync("/wipe/"+f,{recursive:true,force:true})' \
      >/dev/null 2>&1 || true
  fi
  if [ -n "${SPARROW_TMPROOT:-}" ]; then
    rm -rf "$SPARROW_TMPROOT" 2>/dev/null || true
  fi
  return $code
}

# scenario_requires [tool...] — assert the host tools this scenario needs, from
# the arguments or from the SCENARIO_REQUIRES array. `compose` is special-cased
# (it is a docker plugin, not a binary on PATH). A missing tool SKIPs — the gap
# is reported, never failed, so the suite stays green on a machine that lacks the
# SMTP toolchain.
scenario_requires() {
  local -a tools=("$@")
  if [ ${#tools[@]} -eq 0 ]; then
    tools=(${SCENARIO_REQUIRES[@]+"${SCENARIO_REQUIRES[@]}"})
  fi
  local t
  for t in ${tools[@]+"${tools[@]}"}; do
    case "$t" in
      compose)
        docker compose version >/dev/null 2>&1 \
          || skip "docker compose is not available on this host"
        ;;
      *)
        command -v "$t" >/dev/null 2>&1 || skip "$t not found on PATH"
        ;;
    esac
  done
}

# scenario_prepare — pre-flight checks, tmp root + cleanup trap, docker image.
scenario_prepare() {
  [ -n "${SPARROW_TMPROOT:-}" ] && return 0

  command -v docker >/dev/null 2>&1 || fail "docker not found on PATH"
  command -v jq >/dev/null 2>&1 || fail "jq not found on PATH"
  command -v curl >/dev/null 2>&1 || fail "curl not found on PATH"
  [ -f "$SPARROW_CLI_BIN" ] || fail "CLI not built: $SPARROW_CLI_BIN (run pnpm build)"

  SPARROW_TMPROOT="$(mktemp -d "${TMPDIR:-/tmp}/sparrow-scenario.XXXXXX")"
  trap _ac_cleanup EXIT

  _ac_ensure_image
}

# scenario_start — prepare, run a container, wait for health, set $SERVER.
scenario_start() {
  scenario_prepare

  local -a mount_args=()
  if [ -n "${SCENARIO_DATA_DIR:-}" ]; then
    mount_args=(-v "$SCENARIO_DATA_DIR:/data")
  fi

  local -a extra_env_args=()
  local kv
  for kv in ${SCENARIO_EXTRA_ENV[@]+"${SCENARIO_EXTRA_ENV[@]}"}; do
    extra_env_args+=(-e "$kv")
  done

  local port cid attempt
  for attempt in 1 2 3 4 5; do
    port="$(_ac_free_port)"
    [ -n "$port" ] || continue
    if cid="$(docker run -d \
        -p "127.0.0.1:${port}:8722" \
        -e ADMIN_TOKEN="$ADMIN_TOKEN_VALUE" \
        -e BASE_URL="http://127.0.0.1:${port}" \
        -e PRESENCE_GRACE_SECONDS="${PRESENCE_GRACE_SECONDS:-30}" \
        ${mount_args[@]+"${mount_args[@]}"} \
        ${extra_env_args[@]+"${extra_env_args[@]}"} \
        "$SPARROW_IMAGE" 2>/dev/null)"; then
      sleep 0.3
      if [ "$(docker inspect -f '{{.State.Running}}' "$cid" 2>/dev/null)" = "true" ]; then
        SPARROW_CID="$cid"
        SPARROW_PORT="$port"
        break
      fi
      docker rm -fv "$cid" >/dev/null 2>&1 || true
      cid=""
    fi
  done
  [ -n "${SPARROW_CID:-}" ] || fail "could not start container on a free port after 5 attempts"

  SERVER="http://127.0.0.1:${SPARROW_PORT}"
  ADMIN_TOKEN="$ADMIN_TOKEN_VALUE"
  export SERVER ADMIN_TOKEN SPARROW_SERVER="$SERVER"

  local i
  for i in $(seq 1 30); do
    if curl -fsS "$SERVER/healthz" >/dev/null 2>&1; then
      return 0
    fi
    sleep 1
  done
  echo "--- container logs ---" >&2
  docker logs "$SPARROW_CID" >&2 2>&1 || true
  fail "server did not become healthy within 30s at $SERVER"
}

# --- compose-based scenarios ----------------------------------------------
# Most scenarios stay single-container; `scenario_start` above is unchanged and
# remains the default. A scenario that needs SIDECARS (today only
# 150-email-smtp) ships its own `compose.yml` next to `run.sh` and calls
# `scenario_compose_start "$(dirname "$0")"`.

# _ac_compose — `docker compose` for THIS run's project.
_ac_compose() {
  docker compose -p "$SPARROW_COMPOSE_PROJECT" -f "$SPARROW_COMPOSE_FILE" "$@"
}

# _ac_wait_http <url> <tries> — poll a URL until it answers 2xx.
_ac_wait_http() {
  local url="$1" tries="${2:-60}" i
  for ((i = 0; i < tries; i++)); do
    curl -fsS -o /dev/null "$url" 2>/dev/null && return 0
    sleep 1
  done
  return 1
}

# scenario_compose_start <dir> — everything scenario_prepare does (pre-flight,
# tmp root, EXIT trap, ONE shared image), then brings up <dir>/compose.yml under
# a PER-RUN project name so concurrent or repeated runs never collide.
#
#   * host ports come from the same `_ac_free_port` helper single-container
#     scenarios use: SPARROW_PORT for core, plus every name in
#     SCENARIO_COMPOSE_PORTS (exported, so compose interpolates them);
#   * the compose file pins `image: ${SPARROW_SCENARIO_IMAGE}` for the core
#     service, so run-all.sh's one build is reused and sidecars pin upstream tags;
#   * readiness: core's /healthz plus each `PORT_VAR:/path` in
#     SCENARIO_COMPOSE_READY;
#   * $SERVER / $ADMIN_TOKEN / $SPARROW_SERVER are exported IDENTICALLY to
#     scenario_start, so ac_tok, api, signup, sse_me_watch … work unchanged;
#   * the EXIT trap gains `docker compose … down -v` ahead of the tmp-root wipe.
scenario_compose_start() {
  local dir
  dir="$(cd "$1" && pwd)"
  [ -f "$dir/compose.yml" ] || fail "scenario_compose_start: no compose.yml in $dir"

  scenario_requires compose
  scenario_prepare

  # The core service pins this tag; run-all.sh built it once already.
  export SPARROW_SCENARIO_IMAGE="$SPARROW_IMAGE"
  export ADMIN_TOKEN="$ADMIN_TOKEN_VALUE"
  export SPARROW_TMPROOT

  # File before project: the EXIT trap keys off the project name, and must never
  # see one without the file it belongs to.
  SPARROW_COMPOSE_FILE="$dir/compose.yml"
  SPARROW_COMPOSE_PROJECT="sparrow-scn-$$"

  local attempt name
  for attempt in 1 2 3; do
    # Fresh ports on every attempt: a port that was free a moment ago may not be.
    SPARROW_PORT="$(_ac_free_port)"
    export SPARROW_PORT
    for name in ${SCENARIO_COMPOSE_PORTS[@]+"${SCENARIO_COMPOSE_PORTS[@]}"}; do
      printf -v "$name" '%s' "$(_ac_free_port)"
      export "${name?}"
    done

    # `--build` so a sidecar built from this tree is never stale; layer caching
    # makes that a few seconds when nothing changed. Core is `image:`-pinned and
    # is never built here.
    if _ac_compose up -d --build --remove-orphans >/dev/null 2>&1; then
      SERVER="http://127.0.0.1:${SPARROW_PORT}"
      export SERVER SPARROW_SERVER="$SERVER"

      local ok=1
      _ac_wait_http "$SERVER/healthz" 60 || ok=0
      if [ "$ok" = 1 ]; then
        local probe var path
        for probe in ${SCENARIO_COMPOSE_READY[@]+"${SCENARIO_COMPOSE_READY[@]}"}; do
          var="${probe%%:*}"; path="${probe#*:}"
          _ac_wait_http "http://127.0.0.1:${!var}${path}" 60 || { ok=0; break; }
        done
      fi
      [ "$ok" = 1 ] && return 0

      echo "--- compose logs (attempt $attempt) ---" >&2
      _ac_compose logs --tail 60 >&2 2>&1 || true
      _ac_compose down -v --remove-orphans --timeout 5 >/dev/null 2>&1 || true
      fail "compose stack did not become ready at $SERVER"
    fi
    _ac_compose down -v --remove-orphans --timeout 5 >/dev/null 2>&1 || true
  done
  fail "could not bring the compose stack up on free ports after 3 attempts"
}

# --- CLI wrappers ----------------------------------------------------------

# ac_tok <token> <args...> — run the built CLI authenticated by a bearer token
# (ses_ session or agk_ agent key) via SPARROW_TOKEN. This is the primary wrapper:
# no `login`/profile needed. SPARROW_SERVER points at the running container.
ac_tok() {
  local token="$1"; shift
  XDG_CONFIG_HOME="$SPARROW_TMPROOT/cfg-tok" SPARROW_SERVER="$SERVER" SPARROW_TOKEN="$token" \
    node "$SPARROW_CLI_BIN" "$@"
}

# ac_as <profile> <args...> — run the CLI with a per-profile isolated config dir
# (no SPARROW_TOKEN), for exercising `login` / `login-agent` / `enroll` persistence.
ac_as() {
  local profile="$1"; shift
  XDG_CONFIG_HOME="$SPARROW_TMPROOT/cfg-$profile" SPARROW_SERVER="$SERVER" \
    node "$SPARROW_CLI_BIN" "$@"
}

# ac <args...> — default-profile convenience wrapper.
ac() {
  ac_as default "$@"
}

# --- HTTP helpers ----------------------------------------------------------

# signup <email> <password> <displayName> — POST /auth/signup, echo the ses_
# token. The FIRST signup on a fresh instance auto-creates an org (owner role).
signup() {
  local email="$1" pw="$2" name="$3" resp
  resp="$(curl -fsS -X POST "$SERVER/api/v1/auth/signup" \
    -H 'content-type: application/json' \
    -d "$(jq -cn --arg e "$email" --arg p "$pw" --arg n "$name" \
          '{email:$e,password:$p,displayName:$n}')")" \
    || { echo "signup: POST /auth/signup failed for $email" >&2; return 1; }
  printf '%s' "$resp" | jq -r '.token'
}

# api <token> <method> <path> [json-body] — authed curl against $SERVER/api/v1.
# Uses -f: non-2xx makes curl exit non-zero (use api_raw / http_status for those).
api() {
  local tok="$1" method="$2" path="$3" body="${4:-}"
  if [ -n "$body" ]; then
    curl -fsS -X "$method" "$SERVER/api/v1$path" \
      -H "authorization: Bearer $tok" -H 'content-type: application/json' -d "$body"
  else
    curl -fsS -X "$method" "$SERVER/api/v1$path" -H "authorization: Bearer $tok"
  fi
}

# api_raw <token> <method> <path> [json-body] — like api but returns the body on
# any status (no -f), so error envelopes can be inspected.
api_raw() {
  local tok="$1" method="$2" path="$3" body="${4:-}"
  if [ -n "$body" ]; then
    curl -sS -X "$method" "$SERVER/api/v1$path" \
      -H "authorization: Bearer $tok" -H 'content-type: application/json' -d "$body"
  else
    curl -sS -X "$method" "$SERVER/api/v1$path" -H "authorization: Bearer $tok"
  fi
}

# http_status <token> <method> <path> [json-body] — echo just the numeric status.
# An empty token means "no Authorization header".
http_status() {
  local tok="$1" method="$2" path="$3" body="${4:-}"
  local -a args=(-s -o /dev/null -w '%{http_code}' -X "$method" "$SERVER/api/v1$path")
  [ -n "$tok" ] && args+=(-H "authorization: Bearer $tok")
  if [ -n "$body" ]; then
    args+=(-H 'content-type: application/json' -d "$body")
  fi
  curl "${args[@]}"
}

# admin_api <method> <path> [json-body] — curl with the instance X-Admin-Token.
admin_api() {
  local method="$1" path="$2" body="${3:-}"
  if [ -n "$body" ]; then
    curl -fsS -X "$method" "$SERVER/api/v1$path" \
      -H "x-admin-token: $ADMIN_TOKEN" -H 'content-type: application/json' -d "$body"
  else
    curl -fsS -X "$method" "$SERVER/api/v1$path" -H "x-admin-token: $ADMIN_TOKEN"
  fi
}

# first_org_id <token> — the caller's first org id (GET /me/orgs).
first_org_id() {
  api "$1" GET /me/orgs | jq -r '.items[0].org.id'
}

# invite_token_of <invite-url> — extract the ivk_ token from a …/invite/<token> URL.
invite_token_of() {
  printf '%s' "${1##*/invite/}"
}

# create_agent <ownerToken> <orgId> <name> — POST /me/agents, echo the full
# CreateAgentResponse JSON `{ agent:{id,name,orgId,...}, key:"agk_..." }`. The
# key is delivered exactly once (here); NOTE: a directly-minted agent has NO
# auto-DM (that is an enrollment-only convenience) — `dm` to ensure one.
create_agent() {
  api "$1" POST /me/agents "$(jq -cn --arg o "$2" --arg n "$3" '{orgId:$o,name:$n}')"
}

# enroll_existing_human <approverToken> <orgId> <humanToken> — invite an EXISTING
# human (by their session token) into the org and approve them (default approval
# policy). Idempotent-ish: an already-member enroll (200, no enrollment) is a
# no-op. Leaves the human a `member` of the org.
enroll_existing_human() {
  local at="$1" org="$2" ht="$3" url token eresp eid
  url="$(api "$at" POST "/orgs/$org/invites" '{}' | jq -r '.url')"
  token="$(invite_token_of "$url")"
  eresp="$(api "$ht" POST "/invite/$token/enroll" '{}')"
  eid="$(jq -r '.enrollment.id // empty' <<<"$eresp")"
  if [ -n "$eid" ]; then
    api "$at" POST "/orgs/$org/enrollments/$eid/approve" '{}' >/dev/null
  fi
}

# add_human_to_org <approverToken> <orgId> <email> <pw> <name> — sign up a NEW
# human and enroll+approve them into the org. Echoes the new human's ses_ token.
add_human_to_org() {
  local htok
  htok="$(signup "$3" "$4" "$5")" || return 1
  enroll_existing_human "$1" "$2" "$htok" >/dev/null || return 1
  printf '%s' "$htok"
}

# --- SSE watchers (background) ---------------------------------------------
# vm8's awk is mawk (block-buffers pipes); these write raw JSON lines to a file
# and callers poll with grep -F, never awk.

# sse_room_watch <token> <roomId> <outfile> — tail a room's SSE stream in the
# background (node directly so $! is the node pid). Echoes the pid.
sse_room_watch() {
  XDG_CONFIG_HOME="$SPARROW_TMPROOT/cfg-tok" SPARROW_SERVER="$SERVER" SPARROW_TOKEN="$1" \
    node "$SPARROW_CLI_BIN" watch --room "$2" --json >"$3" 2>/dev/null &
  echo $!
}

# sse_me_watch <token> <outfile> — tail the /me/events fan-in stream. Echoes pid.
sse_me_watch() {
  XDG_CONFIG_HOME="$SPARROW_TMPROOT/cfg-tok" SPARROW_SERVER="$SERVER" SPARROW_TOKEN="$1" \
    node "$SPARROW_CLI_BIN" watch --json >"$2" 2>/dev/null &
  echo $!
}

# wait_for_line <file> <fixed-needle> [tries] — poll a file until it contains the
# needle (grep -F, line-buffer-safe). Returns 0 on match, 1 on timeout.
wait_for_line() {
  local f="$1" needle="$2" tries="${3:-40}" i
  for ((i = 0; i < tries; i++)); do
    grep -qF "$needle" "$f" 2>/dev/null && return 0
    sleep 0.25
  done
  return 1
}