Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be394bd6e1 | |||
| e98c281262 | |||
| 2c6d534940 | |||
| 2023c4ab61 | |||
| bd32e8cfd9 | |||
| 86925bee4b | |||
| 63a6d6af8e | |||
| 64c2fe53ed | |||
| 4a46dec3cd | |||
| 81ef3d4abe | |||
| 2697035402 | |||
| f03c7579c2 | |||
| d547569adf | |||
| 7293209862 | |||
| 1472290755 | |||
| b6d66347be | |||
| 3feb3958c2 | |||
| 25b5402110 | |||
| 8faae1c9d9 | |||
| ed47e89d13 | |||
| 07ea7bdd82 | |||
| e71e9aabea | |||
| 785a4175a4 | |||
| daeed93fe9 | |||
| cbe4055edc | |||
| d7e55ccb9f | |||
| 3f1425b46f | |||
| 41b9bf288d | |||
| 90ebfe830d | |||
| dcb1a9f4e6 | |||
| b9f5cbe347 | |||
| a296d7ef72 | |||
| ef0506aae9 | |||
| d5e6160c47 | |||
| eb8ae30acd | |||
| b502c786e2 | |||
| 6db6cb561c | |||
| 7db46f47df | |||
| 4a8e7e4a73 | |||
| 0cf425e8bd | |||
| 8ac21a0cb5 | |||
| 113b1b00dd | |||
| 1eee4363da | |||
| a7a65b6fdf | |||
| 4d8c81984c | |||
| 9d72c35e18 | |||
| 4c2172a0f0 | |||
| 7ce65ac4cb | |||
| ff4b1cded8 | |||
| b5b24ab64b | |||
| d8ac017d6e | |||
| f908aa894b | |||
| 2ebd0c395a | |||
| 37b01b4e24 | |||
| 13d40fec5b | |||
| a5d442255c | |||
| b2a548c319 | |||
| a6d7a7169e | |||
| 7b7ed42166 | |||
| 2b56f8891c | |||
| 170dd6393c | |||
| fb8a68bf5c | |||
| 40df8aa796 | |||
| 73fec4f09b | |||
| bb70c83879 | |||
| 9a40d5d2bd | |||
| 8abf9c6521 | |||
| 5d197e68db | |||
| 2c9f3c2bcd | |||
| 9088902052 | |||
| 081b570525 | |||
| be3ac6dceb | |||
| 5043532d30 | |||
| 879acd96d1 | |||
| 21a962cb5e | |||
| b9ca3b0653 | |||
| 661f6c6f0e | |||
| cec0259ba7 | |||
| accefeb1c6 | |||
| 84b9ca3a12 | |||
| 25339e7cef | |||
| 093b6df3dc | |||
| db3b7a93e3 | |||
| e22b014361 | |||
| 6526521055 | |||
| bbc6f5c287 |
@@ -60,6 +60,7 @@
|
||||
# Optional:
|
||||
# REVIEW_CHECK_DEBUG=1 — per-API-call diagnostic lines
|
||||
# REVIEW_CHECK_STRICT=1 — also require review.commit_id == pr.head.sha
|
||||
# DEFAULT_BRANCH=main — branch this gate protects; non-default-base PRs no-op
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -91,7 +92,7 @@ API="https://${GITEA_HOST}/api/v1"
|
||||
# secret token value in the process table for any process to read via
|
||||
# /proc/<pid>/cmdline or ps -ef). The curl config file is read by curl
|
||||
# itself and never appears in the argv of the curl subprocess.
|
||||
CURL_AUTH_FILE=$(mktemp -p /tmp curl-auth.XXXXXX)
|
||||
CURL_AUTH_FILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.XXXXXX")
|
||||
chmod 600 "$CURL_AUTH_FILE"
|
||||
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
|
||||
|
||||
@@ -100,9 +101,10 @@ printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
|
||||
PR_JSON=$(mktemp)
|
||||
REVIEWS_JSON=$(mktemp)
|
||||
TEAM_PROBE_TMP=$(mktemp)
|
||||
NA_STATUSES_TMP="" # declared here so cleanup() always has the var
|
||||
|
||||
cleanup() {
|
||||
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP"
|
||||
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -124,18 +126,60 @@ if [ "$HTTP_CODE" != "200" ]; then
|
||||
fi
|
||||
PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON")
|
||||
PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON")
|
||||
PR_BASE_REF=$(jq -r '.base.ref // ""' "$PR_JSON")
|
||||
PR_STATE=$(jq -r '.state // ""' "$PR_JSON")
|
||||
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_state=${PR_STATE}"
|
||||
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
|
||||
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_base=${PR_BASE_REF} pr_state=${PR_STATE}"
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PR_BASE_REF" != "$DEFAULT_BRANCH" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} targets ${PR_BASE_REF:-<unknown>} not ${DEFAULT_BRANCH} — ${TEAM}-review gate not applicable"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then
|
||||
echo "::error::PR ${PR_NUMBER} missing user.login or head.sha — webhook payload malformed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- RFC#324 §N/A follow-up: check N/A declarations status ---
|
||||
# sop-checklist-gate.py posts `sop-checklist / na-declarations (pull_request)`
|
||||
# status when a peer posts /sop-n/a <gate>. If our gate is declared N/A,
|
||||
# the requirement for a Gitea APPROVE review is waived.
|
||||
NA_STATUSES_TMP=$(mktemp)
|
||||
HTTP_CODE=$(curl -sS -o "$NA_STATUSES_TMP" -w '%{http_code}' \
|
||||
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/statuses/${PR_HEAD_SHA}")
|
||||
debug "statuses/${PR_HEAD_SHA} → HTTP ${HTTP_CODE}"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
# Gitea returns statuses as array; look for the na-declarations context.
|
||||
# jq: find all statuses where context == "sop-checklist / na-declarations (pull_request)"
|
||||
# and state == "success". Extract the description field.
|
||||
NA_DESC=$(jq -r '
|
||||
.[] |
|
||||
select(.context == "sop-checklist / na-declarations (pull_request)") |
|
||||
select(.state == "success") |
|
||||
.description
|
||||
' "$NA_STATUSES_TMP" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$NA_DESC" ] && [ "$NA_DESC" != "null" ]; then
|
||||
debug "na-declarations status found: ${NA_DESC}"
|
||||
# Check if our gate appears in the N/A description.
|
||||
# The description format is "N/A: qa-review, security-review" or similar.
|
||||
if echo "$NA_DESC" | grep -iq "\\b${TEAM}-review\\b"; then
|
||||
echo "::notice::${TEAM}-review N/A — gate declared not-applicable via /sop-n/a: ${NA_DESC}"
|
||||
echo "::notice::PR ${PR_NUMBER} passes ${TEAM}-review via N/A declaration"
|
||||
rm -f "$NA_STATUSES_TMP"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
debug "could not fetch statuses (HTTP ${HTTP_CODE}) — proceeding with normal eval"
|
||||
fi
|
||||
rm -f "$NA_STATUSES_TMP"
|
||||
|
||||
# --- Fetch all reviews on the PR ---
|
||||
HTTP_CODE=$(curl -sS -o "$REVIEWS_JSON" -w '%{http_code}' \
|
||||
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-run review-check.sh for a slash-command refire and post the protected
|
||||
# pull_request status context to the PR head SHA.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
|
||||
: "${GITEA_HOST:?GITEA_HOST required}"
|
||||
: "${REPO:?REPO required}"
|
||||
: "${PR_NUMBER:?PR_NUMBER required}"
|
||||
: "${TEAM:?TEAM required}"
|
||||
|
||||
OWNER="${REPO%%/*}"
|
||||
NAME="${REPO##*/}"
|
||||
API="https://${GITEA_HOST}/api/v1"
|
||||
CONTEXT="${TEAM}-review / approved (pull_request)"
|
||||
TARGET_URL="https://${GITEA_HOST}/${OWNER}/${NAME}/pulls/${PR_NUMBER}"
|
||||
|
||||
authfile=$(mktemp)
|
||||
prfile=$(mktemp)
|
||||
postfile=$(mktemp)
|
||||
# shellcheck disable=SC2329 # invoked by EXIT trap
|
||||
cleanup() {
|
||||
rm -f "$authfile" "$prfile" "$postfile"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
chmod 600 "$authfile"
|
||||
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
|
||||
|
||||
code=$(curl -sS -o "$prfile" -w '%{http_code}' -K "$authfile" \
|
||||
"${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::GET /pulls/${PR_NUMBER} returned HTTP ${code}"
|
||||
head -c 200 "$prfile" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
head_sha=$(jq -r '.head.sha // ""' "$prfile")
|
||||
state=$(jq -r '.state // ""' "$prfile")
|
||||
if [ -z "$head_sha" ] || [ "$head_sha" = "null" ]; then
|
||||
echo "::error::Could not resolve PR head SHA for PR ${PR_NUMBER}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$state" != "open" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} is ${state}; ${TEAM}-review refire is a no-op"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set +e
|
||||
bash .gitea/scripts/review-check.sh
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
status_state="success"
|
||||
description="Refired via /${TEAM}-recheck by ${COMMENT_AUTHOR:-unknown}"
|
||||
else
|
||||
status_state="failure"
|
||||
description="Refired via /${TEAM}-recheck; ${TEAM}-review failed"
|
||||
fi
|
||||
|
||||
body=$(jq -nc \
|
||||
--arg state "$status_state" \
|
||||
--arg context "$CONTEXT" \
|
||||
--arg description "$description" \
|
||||
--arg target_url "$TARGET_URL" \
|
||||
'{state:$state, context:$context, description:$description, target_url:$target_url}')
|
||||
|
||||
code=$(curl -sS -o "$postfile" -w '%{http_code}' -X POST \
|
||||
-K "$authfile" -H "Content-Type: application/json" \
|
||||
-d "$body" \
|
||||
"${API}/repos/${OWNER}/${NAME}/statuses/${head_sha}")
|
||||
if [ "$code" != "200" ] && [ "$code" != "201" ]; then
|
||||
echo "::error::POST /statuses/${head_sha} returned HTTP ${code}"
|
||||
head -c 200 "$postfile" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice::posted ${status_state} for context=\"${CONTEXT}\" on sha=${head_sha}"
|
||||
exit "$rc"
|
||||
@@ -109,57 +109,58 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s
|
||||
# Optional trailing note after the slug for /sop-ack and required reason
|
||||
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
|
||||
# yet validated; future iteration may require a min-length).
|
||||
#
|
||||
# /sop-n/a <gate> [reason] — declares a gate as not-applicable.
|
||||
# <gate> is a canonical gate name (qa-review, security-review).
|
||||
# The declaring user must be in one of the gate's required_teams.
|
||||
# Most-recent per-user declaration wins (revoke semantics mirror ack).
|
||||
_DIRECTIVE_RE = re.compile(
|
||||
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
_NA_DIRECTIVE_RE = re.compile(
|
||||
r"^[ \t]*/sop-n/?a[ \t]+([A-Za-z0-9_\-]+)(?:[ \t]+(.*))?[ \t]*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def parse_directives(
|
||||
comment_body: str,
|
||||
numeric_aliases: dict[int, str],
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Extract /sop-ack and /sop-revoke directives from a comment body.
|
||||
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
|
||||
"""Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body.
|
||||
|
||||
Returns a list of (kind, canonical_slug, note) tuples where:
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
Returns a tuple of two lists:
|
||||
0. list of (kind, canonical_slug, note) for sop-ack/sop-revoke
|
||||
1. list of (kind, gate_name, reason) for sop-n/a
|
||||
|
||||
canonical_slug is the normalized form (or "" if unparseable).
|
||||
note/reason is the trailing free-text (may be "").
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
na_out: list[tuple[str, str, str]] = []
|
||||
if not comment_body:
|
||||
return out
|
||||
return out, na_out
|
||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||
kind = m.group(1)
|
||||
raw_slug = (m.group(2) or "").strip()
|
||||
# If the raw match included trailing words, the regex non-greedy
|
||||
# captured only the first token; strip again for safety.
|
||||
# We split on whitespace to keep the FIRST word as the slug, and
|
||||
# everything after as the note.
|
||||
parts = raw_slug.split()
|
||||
if not parts:
|
||||
continue
|
||||
first = parts[0]
|
||||
# If the slug-capture greedily matched multiple words (e.g.
|
||||
# "comprehensive testing"), preserve normalize behavior: join
|
||||
# the WHOLE first-word-token only; trailing words get appended to
|
||||
# the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we
|
||||
# may have multi-word forms here — normalize handles them.
|
||||
if len(parts) > 1:
|
||||
# User wrote "/sop-ack comprehensive testing extra-note"
|
||||
# → treat "comprehensive testing" as the slug source if it
|
||||
# normalizes to a known item; otherwise treat "comprehensive"
|
||||
# as slug and "testing extra-note" as note. We defer the
|
||||
# disambiguation to the caller via the returned canonical
|
||||
# slug. For simplicity: try the WHOLE captured string first.
|
||||
canonical = normalize_slug(raw_slug, numeric_aliases)
|
||||
else:
|
||||
canonical = normalize_slug(first, numeric_aliases)
|
||||
note_from_group = (m.group(3) or "").strip()
|
||||
# If we collapsed multi-word slug into kebab and there's a
|
||||
# trailing-text group too, append it.
|
||||
out.append((kind, canonical, note_from_group))
|
||||
return out
|
||||
|
||||
for m in _NA_DIRECTIVE_RE.finditer(comment_body):
|
||||
gate = (m.group(1) or "").strip().lower()
|
||||
reason = (m.group(2) or "").strip()
|
||||
na_out.append(("sop-n/a", gate, reason))
|
||||
|
||||
return out, na_out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -230,9 +231,8 @@ def compute_ack_state(
|
||||
{
|
||||
"comprehensive-testing": {
|
||||
"ackers": ["bob"], # non-author, team-verified
|
||||
"rejected_ackers": { # debugging info
|
||||
"rejected": {
|
||||
"self_ack": ["alice"],
|
||||
"unknown_slug": [],
|
||||
"not_in_team": ["eve"],
|
||||
}
|
||||
},
|
||||
@@ -249,7 +249,8 @@ def compute_ack_state(
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
for kind, slug, _note in parse_directives(body, numeric_aliases):
|
||||
directives, _na_directives = parse_directives(body, numeric_aliases)
|
||||
for kind, slug, _note in directives:
|
||||
if not slug:
|
||||
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
||||
continue
|
||||
@@ -259,25 +260,19 @@ def compute_ack_state(
|
||||
# Filter out self-acks and unknown slugs.
|
||||
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
|
||||
for (user, slug), kind in latest_directive.items():
|
||||
if kind != "sop-ack":
|
||||
continue # revokes leave the (user,slug) state as "no ack"
|
||||
if slug not in items_by_slug:
|
||||
# Slug normalized to something not in our config — store
|
||||
# under a synthetic key for diagnostic surfacing. Don't add
|
||||
# to any item.
|
||||
continue
|
||||
if user == pr_author:
|
||||
rejected_self[slug].append(user)
|
||||
continue
|
||||
pending_team_check[slug].append(user)
|
||||
|
||||
# Step 3: team membership probe per slug (batched per slug to keep
|
||||
# API call count down — same user may ack multiple items but the
|
||||
# required_teams differ per item, so we MUST probe per (user, item)).
|
||||
# Step 3: team membership probe per slug.
|
||||
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
for slug, candidates in pending_team_check.items():
|
||||
if not candidates:
|
||||
@@ -286,7 +281,6 @@ def compute_ack_state(
|
||||
approved = team_membership_probe(slug, candidates) # returns subset
|
||||
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
|
||||
ackers_per_slug[slug] = approved
|
||||
# Stash required teams for description rendering.
|
||||
items_by_slug[slug]["_required_resolved"] = required
|
||||
|
||||
return {
|
||||
@@ -301,6 +295,113 @@ def compute_ack_state(
|
||||
}
|
||||
|
||||
|
||||
def compute_na_state(
|
||||
comments: list[dict[str, Any]],
|
||||
pr_author: str,
|
||||
na_gates: dict[str, dict[str, Any]],
|
||||
numeric_aliases: dict[int, str],
|
||||
team_membership_probe: "callable[[str, list[str]], list[str]]",
|
||||
client: "GiteaClient",
|
||||
org: str,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Compute per-gate N/A declaration state.
|
||||
|
||||
Returns a dict keyed by gate name:
|
||||
{
|
||||
"qa-review": {
|
||||
"declared": ["alice"], # non-author, team-verified, not revoked
|
||||
"rejected": ["eve (not-in-team)", "bob (self-decl)"],
|
||||
"reason": "pure-infra change — no qa surface",
|
||||
},
|
||||
...
|
||||
}
|
||||
A gate is N/A-satisfied when at least one declaration from a valid
|
||||
team member exists and has not been revoked by the same user.
|
||||
"""
|
||||
if not na_gates:
|
||||
return {}
|
||||
|
||||
# Collapse directives per (commenter, gate) — most recent wins.
|
||||
latest_na: dict[tuple[str, str], str] = {} # (user, gate) → "sop-n/a"
|
||||
latest_na_reason: dict[tuple[str, str], str] = {} # (user, gate) → reason
|
||||
for c in comments:
|
||||
body = c.get("body", "") or ""
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
_directives, na_directives = parse_directives(body, numeric_aliases)
|
||||
for _kind, gate, reason in na_directives:
|
||||
if gate not in na_gates:
|
||||
continue
|
||||
latest_na[(user, gate)] = "sop-n/a"
|
||||
latest_na_reason[(user, gate)] = reason
|
||||
|
||||
# Determine candidate declarers per gate.
|
||||
na_state: dict[str, dict[str, Any]] = {
|
||||
gate: {"declared": [], "rejected": [], "reason": ""}
|
||||
for gate in na_gates
|
||||
}
|
||||
pending_per_gate: dict[str, list[str]] = {gate: [] for gate in na_gates}
|
||||
|
||||
for (user, gate), kind in latest_na.items():
|
||||
if kind != "sop-n/a":
|
||||
continue
|
||||
if user == pr_author:
|
||||
na_state[gate]["rejected"].append(f"{user} (self-decl)")
|
||||
continue
|
||||
pending_per_gate[gate].append(user)
|
||||
|
||||
# Probe team membership per gate using that gate's required_teams.
|
||||
for gate, candidates in pending_per_gate.items():
|
||||
if not candidates:
|
||||
continue
|
||||
required_teams = na_gates[gate].get("required_teams", [])
|
||||
# Resolve team names → ids using the client's resolver.
|
||||
team_ids: list[int] = []
|
||||
for tn in required_teams:
|
||||
tid = client.resolve_team_id(org, tn)
|
||||
if tid is not None:
|
||||
team_ids.append(tid)
|
||||
if not team_ids:
|
||||
na_state[gate]["rejected"].extend(
|
||||
f"{u} (no-team-id)" for u in candidates
|
||||
)
|
||||
continue
|
||||
for u in candidates:
|
||||
in_any_team = False
|
||||
for tid in team_ids:
|
||||
result = client.is_team_member(tid, u)
|
||||
if result is True:
|
||||
in_any_team = True
|
||||
break
|
||||
if result is None:
|
||||
# 403 — token owner not in team. Fail-closed.
|
||||
print(
|
||||
f"::warning::na: team-probe for {u} in team-id {tid} "
|
||||
"returned 403 — treating as not-in-team (fail-closed)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if in_any_team:
|
||||
na_state[gate]["declared"].append(u)
|
||||
else:
|
||||
na_state[gate]["rejected"].append(f"{u} (not-in-team)")
|
||||
|
||||
# Build per-gate reason string from declared users.
|
||||
for gate in na_gates:
|
||||
decl = na_state[gate]["declared"]
|
||||
if decl:
|
||||
reasons: list[str] = []
|
||||
for u in decl:
|
||||
r = latest_na_reason.get((u, gate), "")
|
||||
if r:
|
||||
reasons.append(f"{u}: {r}")
|
||||
else:
|
||||
reasons.append(u)
|
||||
na_state[gate]["reason"] = "; ".join(reasons)
|
||||
|
||||
return na_state
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API client
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -698,6 +799,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
numeric_aliases = {
|
||||
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
|
||||
}
|
||||
na_gates: dict[str, dict[str, Any]] = cfg.get("n/a_gates") or {}
|
||||
|
||||
client = GiteaClient(args.gitea_host, token) if token else None
|
||||
if not client:
|
||||
@@ -717,6 +819,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print("::error::PR payload missing user.login or head.sha", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
|
||||
|
||||
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
|
||||
|
||||
# Build team-membership probe closure that caches results per
|
||||
@@ -774,6 +878,47 @@ def main(argv: list[str] | None = None) -> int:
|
||||
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
|
||||
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
|
||||
|
||||
# --- N/A gate state (RFC#324 §N/A follow-up) ---
|
||||
na_state: dict[str, dict[str, Any]] = {}
|
||||
if na_gates:
|
||||
na_state = compute_na_state(
|
||||
comments, author, na_gates, numeric_aliases,
|
||||
probe, client, args.owner,
|
||||
)
|
||||
# Post N/A declarations status (read by review-check.sh).
|
||||
na_satisfied = [g for g, s in na_state.items() if s["declared"]]
|
||||
na_missing = [g for g, s in na_state.items() if not s["declared"]]
|
||||
if na_satisfied:
|
||||
na_desc = f"N/A: {', '.join(na_satisfied)}"
|
||||
na_post_state = "success"
|
||||
elif na_missing:
|
||||
na_desc = f"awaiting /sop-n/a declaration for: {', '.join(na_missing)}"
|
||||
na_post_state = "pending"
|
||||
else:
|
||||
# Configured but no declarations yet.
|
||||
na_desc = "no /sop-n/a declarations yet"
|
||||
na_post_state = "pending"
|
||||
na_context = "sop-checklist / na-declarations (pull_request)"
|
||||
print(f"::notice::na-declarations status: {na_post_state} — {na_desc}")
|
||||
if not args.dry_run:
|
||||
client.post_status(
|
||||
args.owner, args.repo, head_sha,
|
||||
state=na_post_state, context=na_context,
|
||||
description=na_desc,
|
||||
target_url=target_url,
|
||||
)
|
||||
print(f"::notice::na-declarations status posted: {na_context} → {na_post_state}")
|
||||
# Log per-gate diagnostics.
|
||||
for gate in na_gates:
|
||||
s = na_state.get(gate, {})
|
||||
if s.get("declared"):
|
||||
print(f"::notice:: [PASS] gate={gate} — N/A declared by {','.join(s['declared'])}"
|
||||
+ (f" ({s['reason']})" if s.get("reason") else ""))
|
||||
else:
|
||||
extra = f" — rejected: {', '.join(s.get('rejected', []))}" if s.get("rejected") else ""
|
||||
print(f"::notice:: [WAIT] gate={gate} — no valid N/A declaration yet{extra}")
|
||||
|
||||
|
||||
state, description = render_status(items, ack_state, body_state)
|
||||
mode = get_tier_mode(pr, cfg)
|
||||
if mode == "soft":
|
||||
@@ -808,7 +953,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 0 if state in ("success", "pending") else 1
|
||||
return 0
|
||||
|
||||
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
|
||||
client.post_status(
|
||||
args.owner, args.repo, head_sha,
|
||||
state=state, context=args.status_context,
|
||||
|
||||
@@ -58,9 +58,10 @@ What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
|
||||
even if another tick happens before the runner finishes.
|
||||
|
||||
What it does NOT do:
|
||||
- Touch any context NOT ending in ` (push)`. The required-checks on
|
||||
main (verified 2026-05-11) all have ` (pull_request)` suffixes;
|
||||
they CANNOT be reached by this code path.
|
||||
- Touch ` (pull_request)` contexts unless the exact same
|
||||
workflow/job has a successful ` (push)` context on the same
|
||||
default-branch SHA. That case is post-merge status pollution, not
|
||||
an unproven PR gate.
|
||||
- Compensate `error`/`pending` states. Only `failure` — the only one
|
||||
Gitea emits for the hardcoded-suffix bug.
|
||||
- Write to non-default branches. WATCH_BRANCH is sourced from
|
||||
@@ -91,7 +92,9 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -118,19 +121,28 @@ WORKFLOWS_DIR = _env("WORKFLOWS_DIR", default=".gitea/workflows")
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
API_TIMEOUT_SEC = int(_env("STATUS_REAPER_API_TIMEOUT_SEC", default="30") or "30")
|
||||
API_RETRIES = int(_env("STATUS_REAPER_API_RETRIES", default="3") or "3")
|
||||
API_RETRY_SLEEP_SEC = float(_env("STATUS_REAPER_API_RETRY_SLEEP_SEC", default="2") or "2")
|
||||
|
||||
# Compensating-status description prefix. Used as the marker so a human
|
||||
# auditing commit statuses can tell at a glance that the green was
|
||||
# synthetic, not a real CI pass. Kept stable; downstream tooling
|
||||
# (e.g. main-red-watchdog visual diff) MAY key on it.
|
||||
COMPENSATION_DESCRIPTION = (
|
||||
PUSH_COMPENSATION_DESCRIPTION = (
|
||||
"Compensated by status-reaper (workflow has no push: trigger; "
|
||||
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
|
||||
)
|
||||
PR_SHADOW_COMPENSATION_DESCRIPTION = (
|
||||
"Compensated by status-reaper (default-branch pull_request status "
|
||||
"shadowed by successful push status on same SHA; see "
|
||||
".gitea/scripts/status-reaper.py)"
|
||||
)
|
||||
|
||||
# Context suffix the reaper acts on. Gitea hardcodes this for ALL
|
||||
# default-branch workflow runs.
|
||||
PUSH_SUFFIX = " (push)"
|
||||
PULL_REQUEST_SUFFIX = " (pull_request)"
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
@@ -182,13 +194,27 @@ def api(
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
status = e.code
|
||||
attempts = max(API_RETRIES, 1)
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=API_TIMEOUT_SEC) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
status = e.code
|
||||
break
|
||||
except (TimeoutError, socket.timeout, urllib.error.URLError, OSError) as e:
|
||||
if attempt >= attempts:
|
||||
raise ApiError(
|
||||
f"{method} {path} failed after {attempts} attempts: {e}"
|
||||
) from e
|
||||
print(
|
||||
f"::warning::{method} {path} transient API error "
|
||||
f"(attempt {attempt}/{attempts}): {e}; retrying"
|
||||
)
|
||||
time.sleep(API_RETRY_SLEEP_SEC)
|
||||
|
||||
if not (200 <= status < 300):
|
||||
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
|
||||
@@ -357,24 +383,38 @@ def get_combined_status(sha: str) -> dict:
|
||||
# --------------------------------------------------------------------------
|
||||
# Context parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def parse_push_context(context: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (push)` into
|
||||
def parse_suffixed_context(context: str, suffix: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (<event>)` into
|
||||
(workflow_name, job_name).
|
||||
|
||||
Returns None if the context doesn't match the shape (caller skips).
|
||||
Strict: requires the trailing ` (push)` and at least one ` / `
|
||||
Strict: requires the trailing suffix and at least one ` / `
|
||||
separator. Anything else is left alone.
|
||||
"""
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
if not context.endswith(suffix):
|
||||
return None
|
||||
head = context[: -len(PUSH_SUFFIX)] # strip " (push)"
|
||||
head = context[: -len(suffix)]
|
||||
if " / " not in head:
|
||||
# No workflow/job separator — not the bug shape we compensate.
|
||||
return None
|
||||
workflow_name, job_name = head.split(" / ", 1)
|
||||
return workflow_name, job_name
|
||||
|
||||
|
||||
def parse_push_context(context: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (push)` into
|
||||
(workflow_name, job_name)."""
|
||||
return parse_suffixed_context(context, PUSH_SUFFIX)
|
||||
|
||||
|
||||
def push_equivalent_context(context: str) -> str | None:
|
||||
"""Return the matching `(push)` context for a `(pull_request)` context."""
|
||||
parsed = parse_suffixed_context(context, PULL_REQUEST_SUFFIX)
|
||||
if parsed is None:
|
||||
return None
|
||||
workflow_name, job_name = parsed
|
||||
return f"{workflow_name} / {job_name}{PUSH_SUFFIX}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compensating POST
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -383,6 +423,7 @@ def post_compensating_status(
|
||||
context: str,
|
||||
target_url: str | None,
|
||||
*,
|
||||
description: str = PUSH_COMPENSATION_DESCRIPTION,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the
|
||||
@@ -394,7 +435,7 @@ def post_compensating_status(
|
||||
payload: dict[str, Any] = {
|
||||
"context": context,
|
||||
"state": "success",
|
||||
"description": COMPENSATION_DESCRIPTION,
|
||||
"description": description,
|
||||
}
|
||||
# Echo the original target_url when present so a human auditing
|
||||
# the (now-green) compensated status can still reach the run logs
|
||||
@@ -431,7 +472,8 @@ def reap(
|
||||
Returns counters for observability:
|
||||
{compensated, preserved_real_push, preserved_unknown,
|
||||
preserved_non_failure, preserved_non_push_suffix,
|
||||
preserved_unparseable,
|
||||
preserved_unparseable, compensated_pr_shadowed_by_push_success,
|
||||
preserved_pr_without_push_success,
|
||||
compensated_contexts: [<context>, ...]}
|
||||
|
||||
`compensated_contexts` is rev2-added so `reap_branch` can build
|
||||
@@ -444,10 +486,17 @@ def reap(
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_pr_shadowed_by_push_success": 0,
|
||||
"preserved_pr_without_push_success": 0,
|
||||
"compensated_contexts": [],
|
||||
}
|
||||
|
||||
statuses = combined.get("statuses") or []
|
||||
successful_contexts = {
|
||||
(s.get("context") or "")
|
||||
for s in statuses
|
||||
if isinstance(s, dict) and (s.get("status") or s.get("state") or "") == "success"
|
||||
}
|
||||
for s in statuses:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
@@ -471,9 +520,31 @@ def reap(
|
||||
counters["preserved_non_failure"] += 1
|
||||
continue
|
||||
|
||||
# Default-branch `pull_request` contexts can be stale shadows of
|
||||
# the exact same workflow/job already proven by the successful
|
||||
# `push` context on the same SHA. Compensate only that narrow
|
||||
# shape; a missing or failed push equivalent remains a real gate
|
||||
# signal and is preserved.
|
||||
push_equivalent = push_equivalent_context(context)
|
||||
if push_equivalent is not None:
|
||||
if push_equivalent in successful_contexts:
|
||||
post_compensating_status(
|
||||
sha,
|
||||
context,
|
||||
s.get("target_url"),
|
||||
description=PR_SHADOW_COMPENSATION_DESCRIPTION,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
counters["compensated"] += 1
|
||||
counters["compensated_pr_shadowed_by_push_success"] += 1
|
||||
counters["compensated_contexts"].append(context)
|
||||
else:
|
||||
counters["preserved_pr_without_push_success"] += 1
|
||||
continue
|
||||
|
||||
# Only `(push)`-suffix contexts hit the hardcoded-suffix bug.
|
||||
# Branch-protection required checks (e.g. `Secret scan / Scan
|
||||
# diff (pull_request)`) are NOT reachable from this path.
|
||||
# Other failed contexts are preserved unless handled by the
|
||||
# pull-request-shadow rule above.
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
counters["preserved_non_push_suffix"] += 1
|
||||
continue
|
||||
@@ -595,6 +666,8 @@ def reap_branch(
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_pr_shadowed_by_push_success": 0,
|
||||
"preserved_pr_without_push_success": 0,
|
||||
"compensated_per_sha": {},
|
||||
}
|
||||
|
||||
@@ -632,6 +705,8 @@ def reap_branch(
|
||||
"preserved_non_failure",
|
||||
"preserved_non_push_suffix",
|
||||
"preserved_unparseable",
|
||||
"compensated_pr_shadowed_by_push_success",
|
||||
"preserved_pr_without_push_success",
|
||||
):
|
||||
aggregate[key] += per_sha[key]
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Scenarios:
|
||||
T7_team_member — team membership → 204 (member) → exit 0
|
||||
T8_team_not_member — team membership → 404 (not a member) → exit 1
|
||||
T9_team_403 — team membership → 403 (token not in team) → exit 1
|
||||
T14_non_default_base — open PR targeting staging → script exits 0 (no-op)
|
||||
|
||||
Usage:
|
||||
FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080
|
||||
@@ -82,12 +83,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
"number": int(pr_num),
|
||||
"state": "closed",
|
||||
"head": {"sha": "deadbeef0000111122223333444455556666"},
|
||||
"base": {"ref": "main"},
|
||||
"user": {"login": "alice"},
|
||||
})
|
||||
return self._json(200, {
|
||||
"number": int(pr_num),
|
||||
"state": "open",
|
||||
"head": {"sha": "deadbeef0000111122223333444455556666"},
|
||||
"base": {"ref": "staging" if sc == "T14_non_default_base" else "main"},
|
||||
"user": {"login": "alice"},
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# T11 — bash syntax check (bash -n passes)
|
||||
# T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded
|
||||
# T13 — missing required env GITEA_TOKEN → exits 1 with error
|
||||
# T14 — non-default-base PR exits 0 without requiring review
|
||||
#
|
||||
# Hostile-self-review (per feedback_assert_exact_not_substring):
|
||||
# this test MUST FAIL if the script is absent. Verified by running
|
||||
@@ -73,7 +74,7 @@ assert_file_mode() {
|
||||
return
|
||||
fi
|
||||
local got_mode
|
||||
got_mode=$(stat -c '%a' "$path" 2>/dev/null || echo "000")
|
||||
got_mode=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path" 2>/dev/null || echo "000")
|
||||
if [ "$expected_mode" = "$got_mode" ]; then
|
||||
echo " PASS $label (mode=$got_mode)"
|
||||
PASS=$((PASS + 1))
|
||||
@@ -194,8 +195,9 @@ for a in "$@"; do
|
||||
done
|
||||
exec /usr/bin/curl "${new_args[@]}"
|
||||
CURL_SHIM
|
||||
# Now substitute FIXPORT with the actual port number
|
||||
sed -i "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl"
|
||||
# Now substitute FIXPORT with the actual port number. Use perl rather than
|
||||
# sed -i so the test runs on both GNU sed and BSD/macOS sed.
|
||||
perl -0pi -e "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl"
|
||||
chmod +x "$FIXTURE_DIR/bin/curl"
|
||||
|
||||
# Helper: run the script with fixture environment
|
||||
@@ -210,6 +212,7 @@ run_review_check() {
|
||||
GITEA_HOST="fixture.local" \
|
||||
REPO="molecule-ai/molecule-core" \
|
||||
PR_NUMBER="999" \
|
||||
DEFAULT_BRANCH="main" \
|
||||
TEAM="qa" \
|
||||
TEAM_ID="20" \
|
||||
REVIEW_CHECK_DEBUG="0" \
|
||||
@@ -253,6 +256,14 @@ T4_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T4 exit code 1 (no candidates)" "1" "$T4_RC"
|
||||
assert_contains "T4 awaiting non-author APPROVE" "awaiting non-author APPROVE" "$T4_OUT"
|
||||
|
||||
# T14 — non-default-base PR should not make the default branch red.
|
||||
echo
|
||||
echo "== T14 non-default base PR =="
|
||||
T14_OUT=$(run_review_check "T14_non_default_base")
|
||||
T14_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T14 exit code 0 (non-default base no-op)" "0" "$T14_RC"
|
||||
assert_contains "T14 not applicable notice" "gate not applicable" "$T14_OUT"
|
||||
|
||||
# T5 — only author reviews → exit 1
|
||||
echo
|
||||
echo "== T5 only author reviews =="
|
||||
@@ -296,10 +307,10 @@ echo "== T10 CURL_AUTH_FILE =="
|
||||
# Verify the token-file logic directly: create a temp file with the
|
||||
# same mktemp pattern, write the header with printf, chmod 600, then assert.
|
||||
T10_TOKEN="secret-test-token-abc123"
|
||||
T10_AUTHFILE=$(mktemp -p /tmp curl-auth.test.XXXXXX)
|
||||
T10_AUTHFILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.test.XXXXXX")
|
||||
chmod 600 "$T10_AUTHFILE"
|
||||
printf 'header = "Authorization: token %s"\n' "$T10_TOKEN" > "$T10_AUTHFILE"
|
||||
assert_file_mode "T10a mktemp -p /tmp mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600"
|
||||
assert_file_mode "T10a mktemp authfile mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600"
|
||||
assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-test-token-abc123"
|
||||
assert_file_contains "T10c 'header =' curl-config syntax" "$T10_AUTHFILE" 'header = "Authorization: token '
|
||||
rm -f "$T10_AUTHFILE"
|
||||
|
||||
@@ -134,18 +134,22 @@ class TestParseDirectives(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.aliases = _numeric_aliases()
|
||||
|
||||
def parse_ack_revoke(self, body):
|
||||
directives, na_directives = sop.parse_directives(body, self.aliases)
|
||||
self.assertEqual(na_directives, [])
|
||||
return directives
|
||||
|
||||
def test_simple_ack(self):
|
||||
d = sop.parse_directives("/sop-ack comprehensive-testing", self.aliases)
|
||||
d = self.parse_ack_revoke("/sop-ack comprehensive-testing")
|
||||
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
|
||||
|
||||
def test_simple_revoke(self):
|
||||
d = sop.parse_directives("/sop-revoke staging-smoke", self.aliases)
|
||||
d = self.parse_ack_revoke("/sop-revoke staging-smoke")
|
||||
self.assertEqual(d, [("sop-revoke", "staging-smoke", "")])
|
||||
|
||||
def test_ack_with_note(self):
|
||||
d = sop.parse_directives(
|
||||
"/sop-ack comprehensive-testing LGTM the test covers all edge cases",
|
||||
self.aliases,
|
||||
d = self.parse_ack_revoke(
|
||||
"/sop-ack comprehensive-testing LGTM the test covers all edge cases"
|
||||
)
|
||||
self.assertEqual(len(d), 1)
|
||||
self.assertEqual(d[0][0], "sop-ack")
|
||||
@@ -153,13 +157,12 @@ class TestParseDirectives(unittest.TestCase):
|
||||
self.assertIn("LGTM", d[0][2])
|
||||
|
||||
def test_numeric_shorthand(self):
|
||||
d = sop.parse_directives("/sop-ack 1", self.aliases)
|
||||
d = self.parse_ack_revoke("/sop-ack 1")
|
||||
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
|
||||
|
||||
def test_revoke_with_reason(self):
|
||||
d = sop.parse_directives(
|
||||
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB",
|
||||
self.aliases,
|
||||
d = self.parse_ack_revoke(
|
||||
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB"
|
||||
)
|
||||
self.assertEqual(d[0][0], "sop-revoke")
|
||||
self.assertEqual(d[0][1], "comprehensive-testing")
|
||||
@@ -171,7 +174,7 @@ class TestParseDirectives(unittest.TestCase):
|
||||
"/sop-ack comprehensive-testing\n"
|
||||
"Will follow up on the doc nit separately."
|
||||
)
|
||||
d = sop.parse_directives(body, self.aliases)
|
||||
d = self.parse_ack_revoke(body)
|
||||
self.assertEqual(len(d), 1)
|
||||
self.assertEqual(d[0][1], "comprehensive-testing")
|
||||
|
||||
@@ -180,7 +183,7 @@ class TestParseDirectives(unittest.TestCase):
|
||||
"/sop-ack comprehensive-testing\n"
|
||||
"/sop-ack local-postgres-e2e\n"
|
||||
)
|
||||
d = sop.parse_directives(body, self.aliases)
|
||||
d = self.parse_ack_revoke(body)
|
||||
self.assertEqual(len(d), 2)
|
||||
slugs = {x[1] for x in d}
|
||||
self.assertEqual(slugs, {"comprehensive-testing", "local-postgres-e2e"})
|
||||
@@ -189,21 +192,21 @@ class TestParseDirectives(unittest.TestCase):
|
||||
# A directive embedded mid-line is not honored (prevents review
|
||||
# comments like "to /sop-ack you need..." from acting as acks).
|
||||
body = "If you want to /sop-ack comprehensive-testing reply in this thread"
|
||||
d = sop.parse_directives(body, self.aliases)
|
||||
d = self.parse_ack_revoke(body)
|
||||
self.assertEqual(d, [])
|
||||
|
||||
def test_leading_whitespace_allowed(self):
|
||||
body = " /sop-ack comprehensive-testing"
|
||||
d = sop.parse_directives(body, self.aliases)
|
||||
d = self.parse_ack_revoke(body)
|
||||
self.assertEqual(len(d), 1)
|
||||
|
||||
def test_empty_body(self):
|
||||
self.assertEqual(sop.parse_directives("", self.aliases), [])
|
||||
self.assertEqual(sop.parse_directives(None, self.aliases), [])
|
||||
self.assertEqual(sop.parse_directives("", self.aliases), ([], []))
|
||||
self.assertEqual(sop.parse_directives(None, self.aliases), ([], []))
|
||||
|
||||
def test_normalization_applied(self):
|
||||
# /sop-ack Comprehensive_Testing → canonical comprehensive-testing
|
||||
d = sop.parse_directives("/sop-ack Comprehensive_Testing", self.aliases)
|
||||
d = self.parse_ack_revoke("/sop-ack Comprehensive_Testing")
|
||||
self.assertEqual(d[0][1], "comprehensive-testing")
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
|
||||
WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)"
|
||||
WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml"
|
||||
DISPATCH_WORKFLOW="$WORKFLOW_DIR/review-refire-comments.yml"
|
||||
SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh"
|
||||
|
||||
PASS=0
|
||||
@@ -87,6 +88,7 @@ assert_file_exists() {
|
||||
echo
|
||||
echo "== existence =="
|
||||
assert_file_exists "workflow file exists" "$WORKFLOW"
|
||||
assert_file_exists "dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
|
||||
assert_file_exists "script file exists" "$SCRIPT"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo
|
||||
@@ -104,29 +106,43 @@ echo "== T6/T7 workflow yaml =="
|
||||
PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$WORKFLOW" 2>&1 || true)
|
||||
assert_eq "T7 workflow parses as YAML" "ok" "$PARSE_OUT"
|
||||
|
||||
# Three required gates in the `if:` expression
|
||||
# The old per-workflow issue_comment listener caused queue storms because
|
||||
# Gitea queues jobs before evaluating job-level `if:`. The script remains,
|
||||
# but comment-triggered refires route through the single dispatcher.
|
||||
WORKFLOW_CONTENT=$(cat "$WORKFLOW")
|
||||
assert_contains "T6a workflow if: contains author_association gate" \
|
||||
"github.event.comment.author_association" "$WORKFLOW_CONTENT"
|
||||
assert_contains "T6b workflow if: gates on MEMBER/OWNER/COLLABORATOR" \
|
||||
'["MEMBER","OWNER","COLLABORATOR"]' "$WORKFLOW_CONTENT"
|
||||
assert_contains "T6c workflow if: contains slash-command trigger" \
|
||||
"/refire-tier-check" "$WORKFLOW_CONTENT"
|
||||
assert_contains "T6d workflow if: gates on PR-not-issue" \
|
||||
"github.event.issue.pull_request" "$WORKFLOW_CONTENT"
|
||||
assert_contains "T6e workflow listens on issue_comment" \
|
||||
"issue_comment" "$WORKFLOW_CONTENT"
|
||||
assert_contains "T6f workflow requests statuses:write permission" \
|
||||
"statuses: write" "$WORKFLOW_CONTENT"
|
||||
# Does NOT check out PR HEAD (security)
|
||||
if grep -q 'ref: \${{ github.event.pull_request.head' "$WORKFLOW"; then
|
||||
echo " FAIL T6g workflow MUST NOT check out PR head (security)"
|
||||
if printf '%s' "$WORKFLOW_CONTENT" | grep -q '^ issue_comment:'; then
|
||||
echo " FAIL T6a manual fallback workflow must not listen on issue_comment"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_TESTS="${FAILED_TESTS} T6g"
|
||||
FAILED_TESTS="${FAILED_TESTS} T6a"
|
||||
else
|
||||
echo " PASS T6g workflow does not check out PR head"
|
||||
echo " PASS T6a manual fallback workflow does not listen on issue_comment"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
assert_contains "T6b workflow exposes workflow_dispatch" \
|
||||
"workflow_dispatch" "$WORKFLOW_CONTENT"
|
||||
assert_contains "T6c workflow documents unsupported manual inputs" \
|
||||
"workflow_dispatch inputs" "$WORKFLOW_CONTENT"
|
||||
# Does NOT check out PR HEAD (security)
|
||||
if grep -q 'ref: \${{ github.event.pull_request.head' "$WORKFLOW"; then
|
||||
echo " FAIL T6d workflow MUST NOT check out PR head (security)"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_TESTS="${FAILED_TESTS} T6d"
|
||||
else
|
||||
echo " PASS T6d workflow does not check out PR head"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
|
||||
DISPATCH_PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$DISPATCH_WORKFLOW" 2>&1 || true)
|
||||
assert_eq "T6e dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
|
||||
DISPATCH_CONTENT=$(cat "$DISPATCH_WORKFLOW")
|
||||
assert_contains "T6f dispatcher listens on issue_comment" \
|
||||
"issue_comment" "$DISPATCH_CONTENT"
|
||||
assert_contains "T6g dispatcher handles /qa-recheck" \
|
||||
"/qa-recheck" "$DISPATCH_CONTENT"
|
||||
assert_contains "T6h dispatcher handles /security-recheck" \
|
||||
"/security-recheck" "$DISPATCH_CONTENT"
|
||||
assert_contains "T6i dispatcher handles /refire-tier-check" \
|
||||
"/refire-tier-check" "$DISPATCH_CONTENT"
|
||||
|
||||
# T1-T5 — script behavior against a local Gitea-fixture
|
||||
echo
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import urllib.error
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "status-reaper.py"
|
||||
|
||||
|
||||
def load_reaper():
|
||||
spec = importlib.util.spec_from_file_location("status_reaper", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(mod)
|
||||
mod.API = "https://git.example.test/api/v1"
|
||||
mod.GITEA_TOKEN = "test-token"
|
||||
mod.API_TIMEOUT_SEC = 1
|
||||
mod.API_RETRIES = 3
|
||||
mod.API_RETRY_SLEEP_SEC = 0
|
||||
return mod
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
|
||||
def test_api_retries_transient_timeout(monkeypatch):
|
||||
mod = load_reaper()
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise TimeoutError("simulated slow Gitea API")
|
||||
return FakeResponse({"ok": True})
|
||||
|
||||
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
status, body = mod.api("GET", "/repos/o/r/commits")
|
||||
|
||||
assert status == 200
|
||||
assert body == {"ok": True}
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_api_raises_after_retry_budget(monkeypatch):
|
||||
mod = load_reaper()
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
raise urllib.error.URLError("connection reset")
|
||||
|
||||
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
try:
|
||||
mod.api("GET", "/repos/o/r/commits")
|
||||
except mod.ApiError as exc:
|
||||
assert "failed after 3 attempts" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ApiError")
|
||||
|
||||
|
||||
def test_reap_compensates_failed_pr_context_when_push_equivalent_passed(monkeypatch):
|
||||
mod = load_reaper()
|
||||
posted = []
|
||||
|
||||
def fake_post(sha, context, target_url, *, description="", dry_run=False):
|
||||
posted.append((sha, context, target_url, description, dry_run))
|
||||
|
||||
monkeypatch.setattr(mod, "post_compensating_status", fake_post)
|
||||
|
||||
counters = mod.reap(
|
||||
{"CI": True, "Handlers Postgres Integration": True},
|
||||
{
|
||||
"statuses": [
|
||||
{
|
||||
"context": "CI / Platform (Go) (pull_request)",
|
||||
"status": "failure",
|
||||
"target_url": "https://git.example.test/ci-pr",
|
||||
},
|
||||
{
|
||||
"context": "CI / Platform (Go) (push)",
|
||||
"status": "success",
|
||||
},
|
||||
{
|
||||
"context": (
|
||||
"Handlers Postgres Integration / "
|
||||
"Handlers Postgres Integration (pull_request)"
|
||||
),
|
||||
"status": "failure",
|
||||
"target_url": "https://git.example.test/handlers-pr",
|
||||
},
|
||||
{
|
||||
"context": (
|
||||
"Handlers Postgres Integration / "
|
||||
"Handlers Postgres Integration (push)"
|
||||
),
|
||||
"status": "success",
|
||||
},
|
||||
],
|
||||
},
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
)
|
||||
|
||||
assert counters["compensated_pr_shadowed_by_push_success"] == 2
|
||||
assert posted == [
|
||||
(
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
"CI / Platform (Go) (pull_request)",
|
||||
"https://git.example.test/ci-pr",
|
||||
mod.PR_SHADOW_COMPENSATION_DESCRIPTION,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
"Handlers Postgres Integration / Handlers Postgres Integration (pull_request)",
|
||||
"https://git.example.test/handlers-pr",
|
||||
mod.PR_SHADOW_COMPENSATION_DESCRIPTION,
|
||||
False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_reap_preserves_failed_pr_context_without_push_success(monkeypatch):
|
||||
mod = load_reaper()
|
||||
posted = []
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"post_compensating_status",
|
||||
lambda sha, context, target_url, *, description="", dry_run=False: posted.append(
|
||||
context
|
||||
),
|
||||
)
|
||||
|
||||
counters = mod.reap(
|
||||
{"CI": True},
|
||||
{
|
||||
"statuses": [
|
||||
{
|
||||
"context": "CI / Platform (Go) (pull_request)",
|
||||
"status": "failure",
|
||||
},
|
||||
{
|
||||
"context": "CI / Platform (Go) (push)",
|
||||
"status": "failure",
|
||||
},
|
||||
{
|
||||
"context": "CI / Shellcheck (pull_request)",
|
||||
"status": "failure",
|
||||
},
|
||||
],
|
||||
},
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
)
|
||||
|
||||
assert counters["preserved_pr_without_push_success"] == 2
|
||||
assert posted == []
|
||||
@@ -107,3 +107,39 @@ items:
|
||||
description: >-
|
||||
List of feedback memories applicable to this change. Ack from
|
||||
any engineer who has the same memory access.
|
||||
|
||||
# N/A gate declarations (RFC#324 §N/A follow-up).
|
||||
# PRs where a gate genuinely does not apply (e.g., pure-infra with no
|
||||
# qa surface, or docs-only) can be declared N/A by a non-author peer
|
||||
# who is in one of the gate's required_teams. The sop-checklist-gate
|
||||
# posts a `sop-checklist / na-declarations (pull_request)` status that
|
||||
# review-check.sh reads to skip the Gitea-APPROVE requirement.
|
||||
#
|
||||
# Usage: any PR commenter (peer) posts:
|
||||
# /sop-n/a qa-review <reason>
|
||||
# /sop-n/a security-review <reason>
|
||||
#
|
||||
# Slash commands:
|
||||
# /sop-n/a <gate> [reason] — declare gate N/A (most-recent per-user wins)
|
||||
# /sop-revoke <gate> — revoke prior N/A declaration for that gate
|
||||
#
|
||||
# Gate names must match the context strings used by review-check.sh:
|
||||
# qa-review → qa-review / approved (<event>) [TEAM_ID=20]
|
||||
# security-review → security-review / approved (<event>) [TEAM_ID=21]
|
||||
#
|
||||
# required_teams: OR semantics — any team member can declare N/A.
|
||||
# Authors cannot self-declare N/A (enforced by gate script).
|
||||
n/a_gates:
|
||||
qa-review:
|
||||
required_teams: [qa, security, engineers]
|
||||
description: >-
|
||||
QA review N/A when this change has no qa surface (pure-infra,
|
||||
tooling-only, revert, dependency-only). A qa/eng/security member
|
||||
must post /sop-n/a qa-review to activate.
|
||||
|
||||
security-review:
|
||||
required_teams: [security, managers, ceo]
|
||||
description: >-
|
||||
Security review N/A when this change has no security surface
|
||||
(docs-only, pure-frontend, dependency-only). A security/owners
|
||||
member must post /sop-n/a security-review to activate.
|
||||
|
||||
@@ -43,6 +43,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: drift visibility gate; CI / all-required remains the required aggregate.
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
|
||||
+57
-18
@@ -107,16 +107,25 @@ jobs:
|
||||
echo "scripts=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Both .github/workflows/ci.yml AND .gitea/workflows/ci.yml count
|
||||
# as "this workflow changed" — either edit should force-run every
|
||||
# downstream job. The Gitea port follows the same shape as the
|
||||
# GitHub original so behavior matches when triggered on either
|
||||
# platform.
|
||||
DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo ".gitea/workflows/ci.yml")
|
||||
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "python=$(echo "$DIFF" | grep -qE '^workspace/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
# Workflow-only edits are covered by the workflow lint family
|
||||
# and by this workflow's always-present required jobs. Do not fan
|
||||
# those edits out into Go/Canvas/Python/shellcheck work; the
|
||||
# downstream jobs still emit their required contexts via no-op
|
||||
# steps when their surface flag is false.
|
||||
#
|
||||
# If the diff itself cannot be trusted, fail open by running every
|
||||
# surface instead of silently under-testing the PR.
|
||||
if ! DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null); then
|
||||
echo "platform=true" >> "$GITHUB_OUTPUT"
|
||||
echo "canvas=true" >> "$GITHUB_OUTPUT"
|
||||
echo "python=true" >> "$GITHUB_OUTPUT"
|
||||
echo "scripts=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "python=$(echo "$DIFF" | grep -qE '^workspace/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
|
||||
# + per-step gating shape preserves the GitHub-side required-check name
|
||||
@@ -374,23 +383,54 @@ jobs:
|
||||
run: |
|
||||
bash tests/e2e/test_model_slug.sh
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
|
||||
# Covers scripts/promote-tenant-image.sh — the codified
|
||||
# :staging-latest → :latest ECR promote + tenant fleet redeploy
|
||||
# closing molecule-ai/molecule-core#660. 40 mock-driven cases
|
||||
# exercise every exit path (preflight, snapshot, promote, redeploy
|
||||
# 403→SSM-refresh, verify, rollback). No live AWS/CP/SSM calls.
|
||||
run: |
|
||||
bash scripts/test-promote-tenant-image.sh
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Shellcheck promote-tenant-image script
|
||||
# scripts/ is excluded from the bulk shellcheck pass above (legacy
|
||||
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
|
||||
# the promote script + its test harness so regressions there are
|
||||
# caught by the required check.
|
||||
run: |
|
||||
shellcheck --severity=warning \
|
||||
scripts/promote-tenant-image.sh \
|
||||
scripts/test-promote-tenant-image.sh
|
||||
|
||||
canvas-deploy-reminder:
|
||||
name: Canvas Deploy Reminder
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
needs: [changes, canvas-build]
|
||||
# Only fires on direct pushes to main (i.e. after staging→main promotion).
|
||||
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Keep the job itself always runnable. Gitea 1.22.6 leaves job-level
|
||||
# event/ref `if:` gates as pending on PRs, which blocks the combined
|
||||
# status even though this reminder is intentionally non-required.
|
||||
steps:
|
||||
- name: Write deploy reminder to step summary
|
||||
env:
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
CANVAS_CHANGED: ${{ needs.changes.outputs.canvas }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref }}
|
||||
# github.server_url resolves via the workflow-level env override
|
||||
# to the Gitea instance, so the RUN_URL points at the Gitea run
|
||||
# page (not github.com). See feedback_act_runner_github_server_url.
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$CANVAS_CHANGED" != "true" ] || [ "$EVENT_NAME" != "push" ] || [ "$REF_NAME" != "refs/heads/main" ]; then
|
||||
echo "Canvas deploy reminder not applicable for event=$EVENT_NAME ref=$REF_NAME canvas_changed=$CANVAS_CHANGED."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Write body to a temp file — avoids backtick escaping in shell.
|
||||
cat > /tmp/deploy-reminder.md << 'BODY'
|
||||
## Canvas build passed — deploy required
|
||||
@@ -535,11 +575,10 @@ jobs:
|
||||
# hourly if this list diverges from status_check_contexts or from
|
||||
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
|
||||
#
|
||||
# Excluded from `needs:`: `canvas-deploy-reminder` — gated by
|
||||
# `if: ... github.event_name == 'push' && github.ref == 'refs/heads/main'`,
|
||||
# so on PR events it's legitimately `skipped`. The drift detector
|
||||
# explicitly excludes `github.event_name`-gated jobs from F1 (see
|
||||
# `.gitea/scripts/ci-required-drift.py::ci_job_names`).
|
||||
# Excluded from `needs:`: `canvas-deploy-reminder` — it is an
|
||||
# operational reminder, not a CI prerequisite. Keep that job runnable
|
||||
# on PRs with an internal no-op guard; job-level event/ref `if:` gates
|
||||
# are a Gitea 1.22.6 pending-status trap.
|
||||
#
|
||||
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
|
||||
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
|
||||
@@ -559,7 +598,7 @@ jobs:
|
||||
- canvas-build
|
||||
- shellcheck
|
||||
- python-lint
|
||||
if: always()
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Assert every required dependency succeeded
|
||||
run: |
|
||||
|
||||
@@ -44,6 +44,7 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR advisory bot; merge blocking is enforced by CI status and branch protection.
|
||||
gate-check:
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
@@ -63,6 +64,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target' || github.event.inputs.pr_number != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
|
||||
POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }}
|
||||
run: |
|
||||
@@ -77,6 +79,7 @@ jobs:
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -60,6 +60,7 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: change detector only; downstream Harness Replays is the meaningful gate.
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
@@ -132,7 +133,14 @@ jobs:
|
||||
RESP=$(curl -sS --fail --max-time 30 \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/json" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD")
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD") || {
|
||||
# If Gitea's Compare API is slow/unavailable, choose the conservative
|
||||
# behavior: run the harness instead of failing the detector and polluting
|
||||
# main with a red non-gate context.
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "debug=compare-api-unavailable base=$BASE head=$HEAD" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
}
|
||||
DIFF_FILES=$(echo "$RESP" | bash .gitea/scripts/compare-api-diff-files.py 2>/dev/null || true)
|
||||
|
||||
echo "debug=diff-base=$BASE diff-files=$DIFF_FILES" >> "$GITHUB_OUTPUT"
|
||||
@@ -150,6 +158,7 @@ jobs:
|
||||
# matches e2e-api.yml — see that workflow's comment for why a
|
||||
# job-level `if: false` would block branch protection via the
|
||||
# SKIPPED-in-set bug.
|
||||
# bp-exempt: path-filtered replay suite; CI / all-required is the branch-protection aggregate.
|
||||
harness-replays:
|
||||
needs: detect-changes
|
||||
name: Harness Replays
|
||||
|
||||
@@ -89,6 +89,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: meta-lint for masked jobs; tracked separately until masks are burned down.
|
||||
lint:
|
||||
name: lint-continue-on-error-tracking
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -84,6 +84,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: meta-lint advisory during mask burn-down; CI / all-required gates merges.
|
||||
scan:
|
||||
name: lint-mask-pr-atomicity
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -69,6 +69,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: meta-lint advisory; CI / all-required is the required aggregate.
|
||||
lint:
|
||||
name: lint-required-no-paths
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -46,6 +46,7 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: post-merge image publication side effect; CI / all-required gates source changes.
|
||||
build-and-push:
|
||||
name: Build & push canvas image
|
||||
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
|
||||
|
||||
@@ -53,6 +53,7 @@ jobs:
|
||||
# Operational failures (PyPI unreachable, missing DISPATCH_TOKEN) are
|
||||
# surfaced via continue-on-error: true rather than blocking the merge.
|
||||
# The actual bump work happens on the main/staging push after merge.
|
||||
# bp-exempt: advisory validation for runtime publication; not a branch-protection gate.
|
||||
pr-validate:
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
@@ -79,6 +80,7 @@ jobs:
|
||||
# Actual bump-and-tag: runs on main/staging pushes, posts real success/failure.
|
||||
# No continue-on-error — operational failures here trip the main-red
|
||||
# watchdog, which is the desired signal for infrastructure degradation.
|
||||
# bp-exempt: post-merge tag publication side effect; CI / all-required gates source changes.
|
||||
bump-and-tag:
|
||||
runs-on: ubuntu-latest
|
||||
# Only fire on push events (main/staging after PR merge). Pull_request
|
||||
|
||||
@@ -65,20 +65,22 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Diagnose Docker daemon access
|
||||
# Health check: verify Docker daemon is accessible before attempting any
|
||||
# build steps. This fails loudly at step 1 when the runner's docker.sock
|
||||
# is inaccessible rather than silently continuing where `docker build`
|
||||
# fails deep in the process with a cryptic ECR auth error.
|
||||
- name: Verify Docker daemon access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon diagnosis"
|
||||
echo "::group::Docker daemon health check"
|
||||
echo "Runner: ${HOSTNAME:-unknown}"
|
||||
echo "--- Socket info ---"
|
||||
ls -la /var/run/docker.sock 2>/dev/null || echo "/var/run/docker.sock: not found"
|
||||
stat /var/run/docker.sock 2>/dev/null || true
|
||||
echo "--- User info ---"
|
||||
id
|
||||
echo "--- docker version ---"
|
||||
docker version 2>&1 || true
|
||||
echo "--- docker info (full) ---"
|
||||
docker info 2>&1 || echo "docker info failed: exit $?"
|
||||
docker info 2>&1 | head -5 || {
|
||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||
echo "::error::Runner: ${HOSTNAME:-unknown}"
|
||||
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
|
||||
exit 1
|
||||
}
|
||||
echo "Docker daemon OK"
|
||||
echo "::endgroup::"
|
||||
|
||||
# Pre-clone manifest deps before docker build.
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
# Triggers on:
|
||||
# - `pull_request_target`: opened, synchronize, reopened
|
||||
# → initial status posts when PR opens / re-pushes
|
||||
# - `issue_comment`: /qa-recheck slash-command on the PR
|
||||
# → manual re-fire after a QA reviewer clicks APPROVE
|
||||
# (Gitea 1.22.6 doesn't re-fire on pull_request_review, per
|
||||
# go-gitea/gitea#33700 + feedback_pull_request_review_no_refire)
|
||||
# - comment refires are handled by `review-refire-comments.yml`
|
||||
# → a single issue_comment dispatcher prevents every SOP/review
|
||||
# comment from enqueueing separate qa/security/tier jobs on
|
||||
# Gitea 1.22.6 before job-level `if:` can skip them.
|
||||
# Workflow name = `qa-review` ; job name = `approved`.
|
||||
# The job's own pass/fail conclusion publishes the status context
|
||||
# `qa-review / approved (<event>)` — NO `POST /statuses` call → NO
|
||||
@@ -85,27 +85,20 @@ name: qa-review
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required.
|
||||
approved:
|
||||
# Gate the job:
|
||||
# - On pull_request_target events: always run.
|
||||
# - On issue_comment events: only when it's a PR comment and the body
|
||||
# contains the slash-command. NO privilege gate at the step level
|
||||
# (RFC#324 v1.3 §A1.1): a non-collaborator's /qa-recheck is fine
|
||||
# because the eval is read-only and idempotent — re-running it
|
||||
# just re-confirms whether a real team-member APPROVE exists.
|
||||
# Comment-triggered refires live in review-refire-comments.yml. Keeping
|
||||
# this workflow PR-only avoids comment-triggered queue storms.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
startsWith(github.event.comment.body, '/qa-recheck'))
|
||||
github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
|
||||
@@ -119,7 +112,7 @@ jobs:
|
||||
# no comment.user.login so the step is a no-op skip there.
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
login="${{ github.event.comment.user.login }}"
|
||||
@@ -150,13 +143,14 @@ jobs:
|
||||
|
||||
- name: Evaluate qa-review
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
# PR number lives in different places per event:
|
||||
# pull_request_target → github.event.pull_request.number
|
||||
# issue_comment → github.event.issue.number
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: qa
|
||||
TEAM_ID: '20'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: manual-redeploy-tenants-on-main
|
||||
name: redeploy-tenants-on-main
|
||||
|
||||
# Ported from .github/workflows/redeploy-tenants-on-main.yml on 2026-05-11 per RFC
|
||||
# internal#219 §1 sweep. Differences from the GitHub version:
|
||||
@@ -9,21 +9,14 @@ name: manual-redeploy-tenants-on-main
|
||||
# - Workflow-level env.GITHUB_SERVER_URL pinned per
|
||||
# feedback_act_runner_github_server_url.
|
||||
# - `continue-on-error: true` on each job (RFC §1 contract).
|
||||
# - Gitea 1.22.6 does not support workflow_run (task #81). This Gitea
|
||||
# fallback is manual-only; automatic production deploy is attached to
|
||||
# publish-workspace-server-image.yml after image push succeeds.
|
||||
# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with
|
||||
# push+paths filter per this PR. Gitea 1.22.6 does not support
|
||||
# `workflow_run` (task #81). The push trigger fires on every
|
||||
# commit to publish-workspace-server-image.yml which is the
|
||||
# same signal (only successful runs commit to main).
|
||||
#
|
||||
|
||||
# Manual production tenant redeploy fallback.
|
||||
#
|
||||
# Primary automatic production deployment now lives in
|
||||
# publish-workspace-server-image.yml:
|
||||
# build images -> wait for `CI / all-required (push)` green on the same SHA
|
||||
# -> call production redeploy-fleet.
|
||||
#
|
||||
# This workflow remains as an operator fallback. By default it reruns current
|
||||
# main; set repo variable PROD_MANUAL_REDEPLOY_TARGET_TAG to a known-good
|
||||
# `staging-<sha>` tag for rollback.
|
||||
# Auto-refresh prod tenant EC2s after every main merge.
|
||||
#
|
||||
# Why this workflow exists: publish-workspace-server-image builds and
|
||||
# pushes a new platform-tenant :<sha> to ECR on every merge to main,
|
||||
@@ -41,28 +34,73 @@ name: manual-redeploy-tenants-on-main
|
||||
# Gitea suspension migration. The staging-verify.yml promote step now
|
||||
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
|
||||
#
|
||||
# Any failure aborts the rollout and leaves older tenants on the prior image.
|
||||
# Runtime ordering:
|
||||
# 1. publish-workspace-server-image completes → new :staging-<sha> in ECR.
|
||||
# 2. The merge that updates publish-workspace-server-image.yml triggers
|
||||
# this push/path-filtered workflow, which calls redeploy-fleet with
|
||||
# target_tag=staging-<sha>. No CDN propagation wait needed — ECR image
|
||||
# manifest is consistent immediately after push.
|
||||
# 3. Calls redeploy-fleet with canary_slug (if set) and a soak
|
||||
# period. Canary proves the image boots; batches follow.
|
||||
# 4. Any failure aborts the rollout and leaves older tenants on the
|
||||
# prior image — safer default than half-and-half state.
|
||||
#
|
||||
# Rollback path: set PROD_MANUAL_REDEPLOY_TARGET_TAG as a repo/org
|
||||
# variable or secret, run workflow_dispatch, then unset it after the
|
||||
# rollback. That calls redeploy-fleet with target_tag=<value>,
|
||||
# re-pulling the pinned image on every tenant.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
# not the GitHub API.
|
||||
|
||||
# No `concurrency:` block here. Gitea 1.22.6 can cancel queued runs despite
|
||||
# `cancel-in-progress: false`; operators should not dispatch overlapping manual
|
||||
# production redeploys.
|
||||
# Serialize redeploys so two rapid main pushes' redeploys don't overlap
|
||||
# and cause confusing per-tenant SSM state. Without this, GitHub's
|
||||
# implicit workflow_run queueing would *probably* serialize them, but
|
||||
# the explicit block makes the invariant defensible. Mirrors the
|
||||
# concurrency block on redeploy-tenants-on-staging.yml for shape parity.
|
||||
#
|
||||
# NOTE: cancel-in-progress: false removed (Rule 7 fix). Gitea 1.22.6
|
||||
# cancels queued runs regardless of this setting, so it provides no
|
||||
# actual protection. Each redeploy-fleet call is idempotent (canary-first
|
||||
# + batched + health-gated) so a cancelled predecessor is recovered
|
||||
# automatically by the next run.
|
||||
concurrency:
|
||||
group: redeploy-tenants-on-main
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: production redeploy is a side-effect workflow, not a merge gate.
|
||||
redeploy:
|
||||
# Gitea 1.22.6 does not support workflow_run. This workflow is now
|
||||
# controlled by push/path triggers plus an explicit kill switch.
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
# Rule 9 fix: operational kill switch for auto-triggered deployments.
|
||||
# Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true to prevent
|
||||
# this workflow from redeploying. Manual workflow_dispatch bypasses this.
|
||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||
steps:
|
||||
- name: Kill-switch guard
|
||||
# Rule 9 fix: exit fast if kill switch is set. No redeploy happens.
|
||||
if: env.PROD_AUTO_DEPLOY_DISABLED == 'true'
|
||||
run: |
|
||||
echo "::notice::Production auto-deploy disabled (PROD_AUTO_DEPLOY_DISABLED=true). Skipping redeploy."
|
||||
echo "To re-enable: unset the repo variable or set it to false."
|
||||
- name: Note on ECR propagation
|
||||
# ECR image manifests are consistent immediately after push — no
|
||||
# CDN cache to wait for. The old GHCR-based workflow had a 30s
|
||||
@@ -71,20 +109,30 @@ jobs:
|
||||
|
||||
- name: Compute target tag
|
||||
id: tag
|
||||
# Gitea 1.22.6 does not support workflow_dispatch inputs reliably.
|
||||
# Use repo variable PROD_MANUAL_REDEPLOY_TARGET_TAG for rollback.
|
||||
# Resolution order:
|
||||
# 1. Operator-supplied input (workflow_dispatch with explicit
|
||||
# tag) → used verbatim. Lets ops pin `latest` for emergency
|
||||
# rollback to last canary-verified digest, or pin a specific
|
||||
# `staging-<sha>` to roll back to a known-good build.
|
||||
# 2. Default → `staging-<short_head_sha>`. The just-published
|
||||
# digest. Bypasses the `:latest` retag path that's currently
|
||||
# dead (staging-verify soft-skips without canary fleet, so
|
||||
# the only thing retagging `:latest` today is the manual
|
||||
# promote-latest.yml — last run 2026-04-28). Auto-trigger
|
||||
# from the main push uses github.sha; manual
|
||||
# dispatch with no variable falls through to github.sha.
|
||||
env:
|
||||
PROD_MANUAL_REDEPLOY_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || secrets.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
MANUAL_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${MANUAL_TARGET_TAG:-}" ]; then
|
||||
echo "target_tag=$MANUAL_TARGET_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Using operator-pinned manual target tag: $MANUAL_TARGET_TAG"
|
||||
if [ -n "${PROD_MANUAL_REDEPLOY_TARGET_TAG:-}" ]; then
|
||||
echo "target_tag=$PROD_MANUAL_REDEPLOY_TARGET_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Using operator-pinned tag from PROD_MANUAL_REDEPLOY_TARGET_TAG."
|
||||
else
|
||||
SHORT="${HEAD_SHA:0:7}"
|
||||
echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT"
|
||||
echo "Using manual fallback tag: staging-$SHORT (head_sha=$HEAD_SHA)"
|
||||
echo "Using auto tag: staging-$SHORT (head_sha=$HEAD_SHA)"
|
||||
fi
|
||||
|
||||
- name: Call CP redeploy-fleet
|
||||
@@ -93,16 +141,29 @@ jobs:
|
||||
# CP_ADMIN_API_TOKEN env. Stored in Railway, mirrored to this
|
||||
# repo's secrets for CI.
|
||||
env:
|
||||
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
|
||||
CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }}
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
|
||||
CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }}
|
||||
SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }}
|
||||
BATCH_SIZE: ${{ vars.PROD_AUTO_DEPLOY_BATCH_SIZE || '3' }}
|
||||
DRY_RUN: ${{ vars.PROD_AUTO_DEPLOY_DRY_RUN || false }}
|
||||
CANARY_SLUG: ${{ vars.PROD_REDEPLOY_CANARY_SLUG || secrets.PROD_REDEPLOY_CANARY_SLUG || '' }}
|
||||
SOAK_SECONDS: ${{ vars.PROD_REDEPLOY_SOAK_SECONDS || secrets.PROD_REDEPLOY_SOAK_SECONDS || '' }}
|
||||
BATCH_SIZE: ${{ vars.PROD_REDEPLOY_BATCH_SIZE || secrets.PROD_REDEPLOY_BATCH_SIZE || '' }}
|
||||
DRY_RUN: ${{ vars.PROD_REDEPLOY_DRY_RUN || secrets.PROD_REDEPLOY_DRY_RUN || '' }}
|
||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "${PROD_AUTO_DEPLOY_DISABLED,,}" in
|
||||
1|true|yes|on)
|
||||
echo "::notice::PROD_AUTO_DEPLOY_DISABLED is set; skipping production redeploy."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
CANARY_SLUG="${CANARY_SLUG:-hongming}"
|
||||
SOAK_SECONDS="${SOAK_SECONDS:-60}"
|
||||
BATCH_SIZE="${BATCH_SIZE:-3}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
|
||||
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
|
||||
echo "::error::CP_ADMIN_API_TOKEN secret not set — skipping redeploy"
|
||||
echo "::notice::Set CP_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
|
||||
@@ -124,7 +185,7 @@ jobs:
|
||||
}')
|
||||
|
||||
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " body: $BODY"
|
||||
echo " target_tag=$TARGET_TAG canary=$CANARY_SLUG soak_seconds=$SOAK_SECONDS batch_size=$BATCH_SIZE dry_run=$DRY_RUN"
|
||||
|
||||
HTTP_RESPONSE=$(mktemp)
|
||||
HTTP_CODE_FILE=$(mktemp)
|
||||
@@ -152,7 +213,9 @@ jobs:
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true
|
||||
# Rule 8 fix: redact raw CP response from CI logs. Print only
|
||||
# safe fields: ok boolean, result count, error presence (no content).
|
||||
jq '{ok, result_count: (.results | length), has_errors: (.results | any(.error != null))}' "$HTTP_RESPONSE" || echo "(jq parse failed)"
|
||||
|
||||
# Pretty-print per-tenant results in the job summary so
|
||||
# ops can see which tenants were redeployed without drilling
|
||||
@@ -168,9 +231,11 @@ jobs:
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
echo '| Slug | Phase | SSM Status | Exit | Healthz | Error present |'
|
||||
echo '|------|-------|------------|------|---------|---------------|'
|
||||
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \((.error // "") != "") |"' "$HTTP_RESPONSE" || true
|
||||
echo '| Slug | Phase | SSM Status | Exit | Healthz | Errors |'
|
||||
echo '|------|-------|------------|------|---------|-------|'
|
||||
# Rule 8 fix: .error field redacted from CI logs/summary. Print only
|
||||
# presence boolean so ops know whether to look deeper.
|
||||
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error != null) |"' "$HTTP_RESPONSE" || true
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
@@ -209,10 +274,13 @@ jobs:
|
||||
# fail the workflow, which is what `ok=true` should have
|
||||
# guaranteed all along.
|
||||
#
|
||||
# Manual Gitea fallback redeploys current main's staging-<sha> tag, so
|
||||
# the expected SHA is github.sha.
|
||||
# When the redeploy was triggered by workflow_dispatch with a
|
||||
# specific tag (target_tag != "latest"), the expected SHA may
|
||||
# not equal ${{ github.sha }} — in that case we resolve via
|
||||
# GHCR's manifest. For workflow_run (default :latest) the
|
||||
# workflow_run.head_sha is the SHA that just published.
|
||||
env:
|
||||
EXPECTED_SHA: ${{ github.sha }}
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
|
||||
# Tenant subdomain template — slugs from the response are
|
||||
# appended. Production CP issues `<slug>.moleculesai.app`;
|
||||
@@ -226,10 +294,10 @@ jobs:
|
||||
if [ "$TARGET_TAG" != "latest" ] \
|
||||
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
|
||||
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then
|
||||
# workflow_dispatch with a pinned tag that isn't the head
|
||||
# Manual redeploy with a pinned tag that isn't the head
|
||||
# SHA — operator is rolling back / pinning. Skip the
|
||||
# verification because we don't have the expected SHA in
|
||||
# this context (would need to crane-inspect the GHCR
|
||||
# this context (would need to inspect the ECR
|
||||
# manifest, which is a follow-up). Failing-open here is
|
||||
# safe: the operator chose the tag deliberately.
|
||||
#
|
||||
|
||||
@@ -73,6 +73,7 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes.
|
||||
redeploy:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
|
||||
@@ -41,6 +41,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: review tooling regression suite; CI / all-required is the required aggregate.
|
||||
test:
|
||||
name: review-check.sh regression tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Consolidated comment dispatcher for manual review/tier refires.
|
||||
#
|
||||
# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before
|
||||
# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when
|
||||
# qa-review, security-review, sop-checklist-gate, and sop-tier-refire all
|
||||
# listened to comments. This workflow is the single non-SOP comment subscriber:
|
||||
# ordinary comments no-op quickly; slash commands post the required status
|
||||
# contexts to the PR head SHA.
|
||||
|
||||
name: review-refire-comments
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Classify comment
|
||||
id: classify
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
IS_PR: ${{ github.event.issue.pull_request != null }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "run_qa=false"
|
||||
echo "run_security=false"
|
||||
echo "run_tier=false"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if [ "$IS_PR" != "true" ]; then
|
||||
echo "::notice::not a PR comment; no-op"
|
||||
exit 0
|
||||
fi
|
||||
first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p')
|
||||
case "$first_line" in
|
||||
/qa-recheck*)
|
||||
echo "run_qa=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
/security-recheck*)
|
||||
echo "run_security=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
/refire-tier-check*)
|
||||
echo "run_tier=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "::notice::no supported review refire slash command; no-op"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check out BASE ref for trusted scripts
|
||||
if: |
|
||||
steps.classify.outputs.run_qa == 'true' ||
|
||||
steps.classify.outputs.run_security == 'true' ||
|
||||
steps.classify.outputs.run_tier == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Refire qa-review status
|
||||
if: steps.classify.outputs.run_qa == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: qa
|
||||
TEAM_ID: '20'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
REVIEW_CHECK_STRICT: '0'
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
.gitea/scripts/review-refire-status.sh
|
||||
|
||||
- name: Refire security-review status
|
||||
if: steps.classify.outputs.run_security == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: security
|
||||
TEAM_ID: '21'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
REVIEW_CHECK_STRICT: '0'
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
.gitea/scripts/review-refire-status.sh
|
||||
|
||||
- name: Refire sop-tier-check status
|
||||
if: steps.classify.outputs.run_tier == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
SOP_DEBUG: '0'
|
||||
run: bash .gitea/scripts/sop-tier-refire.sh
|
||||
@@ -66,19 +66,28 @@ jobs:
|
||||
# PR#372's ci.yml port used. Diffs against the PR base or the
|
||||
# previous push SHA, then matches against the wheel-relevant
|
||||
# path set.
|
||||
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
|
||||
#
|
||||
# NOTE: Gitea Actions does not expose github.event.before as a
|
||||
# shell environment variable. The ${{ github.event.before }} template
|
||||
# expression works inside YAML run: blocks but is evaluated to an
|
||||
# empty string for push events, making the ${VAR:-fallback} always
|
||||
# use the fallback. Use GITHUB_EVENT_BEFORE instead — it IS set in
|
||||
# the runner's shell environment for push events.
|
||||
BASE=""
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then
|
||||
BASE="$GITHUB_EVENT_BEFORE"
|
||||
fi
|
||||
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
|
||||
# New branch or no previous SHA: treat as wheel-relevant.
|
||||
echo "wheel=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
|
||||
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
|
||||
fi
|
||||
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
|
||||
echo "wheel=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -12,22 +12,18 @@ name: security-review
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
|
||||
approved:
|
||||
# See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational
|
||||
# log only, NOT a gate) / A4 / A5 design rationale.
|
||||
# Comment-triggered refires live in review-refire-comments.yml. Keeping
|
||||
# this workflow PR-only avoids comment-triggered queue storms.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
startsWith(github.event.comment.body, '/security-recheck'))
|
||||
github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
|
||||
@@ -36,7 +32,7 @@ jobs:
|
||||
# so re-running on a non-collaborator comment is harmless.
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
login="${{ github.event.comment.user.login }}"
|
||||
@@ -61,10 +57,11 @@ jobs:
|
||||
|
||||
- name: Evaluate security-review
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: security
|
||||
TEAM_ID: '21'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
|
||||
@@ -92,7 +92,8 @@ jobs:
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
(contains(github.event.comment.body, '/sop-ack') ||
|
||||
contains(github.event.comment.body, '/sop-revoke')))
|
||||
contains(github.event.comment.body, '/sop-revoke') ||
|
||||
contains(github.event.comment.body, '/sop-n/a')))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out BASE ref (trust boundary — never PR-head)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# sop-tier-refire — issue_comment-triggered refire of sop-tier-check.
|
||||
# sop-tier-refire — manual fallback for sop-tier-check refire.
|
||||
#
|
||||
# Closes internal#292. Gitea 1.22.6 doesn't refire workflows on the
|
||||
# `pull_request_review` event (go-gitea/gitea#33700); the `sop-tier-check`
|
||||
@@ -8,12 +8,12 @@
|
||||
# to merge is the admin force-merge path (audited via `audit-force-merge`
|
||||
# but the audit trail keeps growing; see `feedback_never_admin_merge_bypass`).
|
||||
#
|
||||
# Workaround pattern from `feedback_pull_request_review_no_refire`:
|
||||
# `issue_comment` events DO fire reliably on 1.22.6. When a repo
|
||||
# MEMBER/OWNER/COLLABORATOR comments `/refire-tier-check` on a PR, this
|
||||
# workflow re-runs the sop-tier-check logic and POSTs the resulting
|
||||
# status to the PR head SHA directly. No empty commit, no git history
|
||||
# bloat, no cascade re-fire of every other workflow on the PR.
|
||||
# Comment-triggered refires now live in `review-refire-comments.yml`. Gitea
|
||||
# queues issue_comment workflows before evaluating job-level `if:`, so having
|
||||
# qa-review, security-review, sop-checklist, and sop-tier-refire all subscribe
|
||||
# to every comment caused queue storms on SOP-heavy PRs. This workflow is a
|
||||
# non-automatic breadcrumb only; Gitea 1.22.6 does not support
|
||||
# workflow_dispatch inputs, so real refires must use `/refire-tier-check`.
|
||||
#
|
||||
# SECURITY MODEL:
|
||||
#
|
||||
@@ -37,43 +37,16 @@
|
||||
# Rate-limit: a 1s pre-sleep + a "skip if status posted in last 30s"
|
||||
# guard prevents comment-spam from thrashing the status. See the script.
|
||||
|
||||
name: sop-tier-check refire (issue_comment)
|
||||
name: sop-tier-check refire (manual)
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
refire:
|
||||
# Three gates, all required:
|
||||
# - comment is on a PR (not a plain issue)
|
||||
# - commenter is MEMBER, OWNER, or COLLABORATOR
|
||||
# - comment body contains the slash-command trigger
|
||||
if: |
|
||||
github.event.issue.pull_request != null &&
|
||||
contains(fromJson('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
contains(github.event.comment.body, '/refire-tier-check')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
steps:
|
||||
- name: Check out base branch (for the script)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Load the script from the default branch (main), matching the
|
||||
# sop-tier-check.yml security model.
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
- name: Re-evaluate sop-tier-check and POST status
|
||||
env:
|
||||
# Same org-level secret sop-tier-check.yml + audit-force-merge.yml use.
|
||||
# Fallback to GITHUB_TOKEN with a clear error if missing.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
# Set to '1' for diagnostic per-API-call output. Off by default.
|
||||
SOP_DEBUG: '0'
|
||||
run: bash .gitea/scripts/sop-tier-refire.sh
|
||||
- name: Explain supported refire path
|
||||
run: |
|
||||
echo "::error::Gitea 1.22.6 does not support workflow_dispatch inputs here; comment /refire-tier-check on the PR instead."
|
||||
exit 1
|
||||
|
||||
@@ -82,6 +82,7 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: post-merge staging verification side effect; CI / all-required gates merges.
|
||||
staging-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
@@ -190,6 +191,7 @@ jobs:
|
||||
echo "assertions in the staging-smoke step log above."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# bp-exempt: post-merge image promotion side effect; staging-smoke controls promotion.
|
||||
promote-to-latest:
|
||||
# On green, calls the CP redeploy-fleet endpoint with target_tag=
|
||||
# staging-<sha> to promote the verified ECR image. This is the same
|
||||
|
||||
@@ -84,7 +84,7 @@ permissions:
|
||||
jobs:
|
||||
reap:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Check out repo at default-branch HEAD
|
||||
# BASE checkout per `feedback_pull_request_target_workflow_from_base`.
|
||||
@@ -118,4 +118,7 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
STATUS_REAPER_API_RETRIES: "4"
|
||||
STATUS_REAPER_API_TIMEOUT_SEC: "20"
|
||||
STATUS_REAPER_API_RETRY_SLEEP_SEC: "2"
|
||||
run: python3 .gitea/scripts/status-reaper.py
|
||||
|
||||
@@ -327,7 +327,7 @@ function OrgCTA({ org }: { org: Org }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500"
|
||||
className="rounded bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
@@ -337,7 +337,7 @@ function OrgCTA({ org }: { org: Org }) {
|
||||
return (
|
||||
<a
|
||||
href={`/pricing?org=${encodeURIComponent(org.slug)}`}
|
||||
className="rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500"
|
||||
className="rounded bg-amber-800 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
|
||||
>
|
||||
Complete payment
|
||||
</a>
|
||||
|
||||
@@ -164,7 +164,10 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0">
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function ConfirmDialog({
|
||||
// readable in both light and dark themes.
|
||||
const confirmColors =
|
||||
confirmVariant === "danger"
|
||||
? "bg-red-600 hover:bg-red-700 text-white"
|
||||
? "bg-red-700 hover:bg-red-600 text-white"
|
||||
: confirmVariant === "warning"
|
||||
? "bg-amber-800 hover:bg-amber-700 text-white"
|
||||
: "bg-accent hover:bg-accent-strong text-white";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "./Toaster";
|
||||
@@ -23,9 +23,17 @@ export function ContextMenu() {
|
||||
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
|
||||
const nestNode = useCanvasStore((s) => s.nestNode);
|
||||
const contextNodeId = contextMenu?.nodeId ?? null;
|
||||
const hasChildren = useCanvasStore((s) =>
|
||||
contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false
|
||||
// Select the full nodes array (stable reference across unrelated store
|
||||
// updates) and derive children via useMemo. Filtering inside the
|
||||
// selector returned a new array every call, which Zustand's
|
||||
// useSyncExternalStore saw as "snapshot changed" → schedule
|
||||
// re-render → loop → React error #185. See canvas-store-snapshots.
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const children = useMemo(
|
||||
() => (contextNodeId ? nodes.filter((n) => n.data.parentId === contextNodeId) : []),
|
||||
[nodes, contextNodeId],
|
||||
);
|
||||
const hasChildren = children.length > 0;
|
||||
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -189,10 +197,9 @@ export function ContextMenu() {
|
||||
// it survives ContextMenu unmount. Closing the menu here avoids the
|
||||
// prior race where the portal dialog's Confirm click was treated as
|
||||
// "outside" by the menu's outside-click handler.
|
||||
const childNodes = useCanvasStore.getState().nodes.filter((n) => n.data.parentId === contextMenu.nodeId);
|
||||
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: childNodes.map(c => ({ id: c.id, name: c.data.name })) });
|
||||
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) });
|
||||
closeContextMenu();
|
||||
}, [contextMenu, setPendingDelete, closeContextMenu]);
|
||||
}, [contextMenu, setPendingDelete, closeContextMenu, children, hasChildren]);
|
||||
|
||||
const handleViewDetails = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
|
||||
@@ -31,17 +31,25 @@ export function extractMessageText(body: Record<string, unknown> | null): string
|
||||
if (text) return text;
|
||||
|
||||
// Response: result.parts[].text or result.parts[].root.text
|
||||
// Use the first part that has a direct text field; within that part,
|
||||
// prefer direct text over root.text. Subsequent parts' root.text fields
|
||||
// are ignored when a direct text exists in an earlier part.
|
||||
const result = body.result as Record<string, unknown> | undefined;
|
||||
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
||||
const rText = rParts
|
||||
.map((p) => {
|
||||
if (p.text) return p.text as string;
|
||||
const root = p.root as Record<string, unknown> | undefined;
|
||||
return (root?.text as string) || "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (rText) return rText;
|
||||
const firstPartWithText = rParts.find(
|
||||
(p) => typeof p.text === "string" && (p.text as string) !== ""
|
||||
);
|
||||
if (firstPartWithText) {
|
||||
return firstPartWithText.text as string;
|
||||
}
|
||||
// No direct text found; use root.text from the first part (if present).
|
||||
const firstPart = rParts[0];
|
||||
if (firstPart) {
|
||||
const root = firstPart.root as Record<string, unknown> | undefined;
|
||||
if (typeof root?.text === "string" && root.text !== "") {
|
||||
return root.text as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof body.result === "string") return body.result;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -164,12 +164,12 @@ export function DeleteCascadeConfirmDialog({
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={!checked}
|
||||
// Hover goes DARKER, not lighter — bg-red-500 on white text
|
||||
// drops contrast below AA vs bg-red-700. Same trap fixed in
|
||||
// ConfirmDialog and ApprovalBanner. focus-visible ring matches.
|
||||
// Hover goes DARKER, not lighter — bg-red-600 on white text
|
||||
// drops contrast below AA. Same trap fixed in ConfirmDialog.
|
||||
// focus-visible ring matches the canvas chrome.
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken
|
||||
${checked
|
||||
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
|
||||
? "bg-red-700 hover:bg-red-600 text-white cursor-pointer"
|
||||
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -51,7 +51,7 @@ export class ErrorBoundary extends React.Component<
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface z-50">
|
||||
<div role="alert" aria-live="assertive" className="fixed inset-0 flex items-center justify-center bg-surface z-50">
|
||||
<div className="max-w-md rounded-2xl border border-red-500/30 bg-surface-sunken/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10 border border-red-500/30">
|
||||
<svg
|
||||
|
||||
@@ -389,7 +389,7 @@ export function ProvisioningTimeout({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConfirm}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
|
||||
className="px-3.5 py-1.5 text-[12px] bg-red-800 hover:bg-red-700 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
|
||||
>
|
||||
Remove Workspace
|
||||
</button>
|
||||
|
||||
@@ -91,19 +91,16 @@ export function SearchDialog() {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh]">
|
||||
{/* Backdrop — interactive dismiss area; aria-hidden so screen readers ignore it */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-pointer"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search workspaces"
|
||||
className="relative z-[71] w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
className="w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
|
||||
|
||||
@@ -398,3 +398,78 @@ describe("ContextMenu — item actions", () => {
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression tests for GitHub issue #651 — React error #185:
|
||||
* "Maximum update depth exceeded" on Chat tab / mobile.
|
||||
*
|
||||
* Root cause: ContextMenu's children selector ran `.filter()` inside the
|
||||
* Zustand hook, returning a brand-new array reference on every render.
|
||||
* Zustand's useSyncExternalStore compared snapshots with Object.is —
|
||||
* a new array always differs — so React kept scheduling re-renders,
|
||||
* hit the 50-update depth cap, and crashed.
|
||||
*
|
||||
* Fix: select the stable `nodes` array once, derive children via
|
||||
* useMemo outside the store subscription.
|
||||
*/
|
||||
describe("ContextMenu — hasChildren regression (GitHub #651)", () => {
|
||||
beforeEach(() => { setupApiMocks(); });
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.contextMenu = null;
|
||||
mockStoreState.closeContextMenu.mockClear();
|
||||
mockStoreState.updateNodeData.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
mockStoreState.nestNode.mockClear();
|
||||
mockStoreState.setPendingDelete.mockClear();
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
resetApiMocks();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
it("setPendingDelete receives correct children array when workspace has children", () => {
|
||||
openMenu({ nodeId: "ws-parent", nodeData: { name: "Parent", status: "online", tier: 4, role: "assistant" } });
|
||||
mockStoreState.nodes = [
|
||||
{ id: "ws-child-a", data: { parentId: "ws-parent" } },
|
||||
{ id: "ws-child-b", data: { parentId: "ws-parent" } },
|
||||
];
|
||||
render(<ContextMenu />);
|
||||
const deleteBtn = screen.getAllByRole("menuitem").find((el) =>
|
||||
el.textContent?.includes("Delete")
|
||||
)!;
|
||||
fireEvent.click(deleteBtn);
|
||||
expect(mockStoreState.setPendingDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "ws-parent",
|
||||
name: "Parent",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{ id: "ws-child-a", name: undefined },
|
||||
{ id: "ws-child-b", name: undefined },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("setPendingDelete hasChildren=false and empty children array when workspace has no children", () => {
|
||||
openMenu({ nodeId: "ws-leaf", nodeData: { name: "Leaf", status: "online", tier: 4, role: "assistant" } });
|
||||
mockStoreState.nodes = [];
|
||||
render(<ContextMenu />);
|
||||
const deleteBtn = screen.getAllByRole("menuitem").find((el) =>
|
||||
el.textContent?.includes("Delete")
|
||||
)!;
|
||||
fireEvent.click(deleteBtn);
|
||||
expect(mockStoreState.setPendingDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "ws-leaf",
|
||||
name: "Leaf",
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,11 +87,10 @@ describe("extractMessageText — response result format", () => {
|
||||
expect(extractMessageText(body)).toBe("Root response text");
|
||||
});
|
||||
|
||||
it("prefers parts[].text over parts[].root.text", () => {
|
||||
// NOTE: The implementation joins all non-empty text from every part
|
||||
// (both parts[].text and parts[].root.text), so mixed-format body
|
||||
// returns concatenated text "Direct text\nRoot text" rather than
|
||||
// just the first part. Update this test to reflect actual behavior.
|
||||
it("prefers parts[].text over parts[].root.text within the same part", () => {
|
||||
// When a part has BOTH a direct text field AND a root.text field,
|
||||
// direct text wins. Subsequent parts' root.text fields are ignored
|
||||
// when a direct text was found in an earlier part.
|
||||
const body = {
|
||||
result: {
|
||||
parts: [
|
||||
@@ -100,8 +99,28 @@ describe("extractMessageText — response result format", () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
// Implementation joins all parts with newlines: "Direct text\nRoot text"
|
||||
expect(extractMessageText(body)).toBe("Direct text\nRoot text");
|
||||
expect(extractMessageText(body)).toBe("Direct text");
|
||||
});
|
||||
|
||||
it("falls back to root.text when no direct text exists", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ root: { text: "Root only" } }],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Root only");
|
||||
});
|
||||
|
||||
it("ignores subsequent parts root.text when direct text was found", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [
|
||||
{ text: "First" },
|
||||
{ root: { text: "Should be ignored" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("First");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,102 +1,237 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
// Tests for the default-collapsed + expand-on-click behavior of the
|
||||
// org templates drawer. Before this change the section rendered all
|
||||
// org cards inline, which pushed the individual workspace templates
|
||||
// off-screen when there were ≥3 orgs on disk. Collapsed-by-default
|
||||
// keeps the scroll focused on the primary deploy path.
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue([
|
||||
{ dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
|
||||
{ dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
|
||||
]),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
/**
|
||||
* Tests for OrgTemplatesSection — collapsible org template import list.
|
||||
*
|
||||
* Covers:
|
||||
* - Header with count badge (visible only when expanded)
|
||||
* - Collapsed by default, aria-expanded toggles on click
|
||||
* - aria-controls targets org-templates-body div
|
||||
* - Empty state when no org templates
|
||||
* - Loading spinner
|
||||
* - Org template cards: name, description, workspace count
|
||||
* - Import button per card
|
||||
* - Preflight modal opens when org has required_env
|
||||
* - Preflight onProceed fires import
|
||||
* - Preflight onCancel closes modal
|
||||
* - Direct import (no modal) when org has no env requirements
|
||||
* - Import button disabled while that org is importing
|
||||
*/
|
||||
// ── ALL mocks MUST be before imports (vi.mock is hoisted to top of file) ───────
|
||||
const { mockGet, mockPost, mockListSecrets } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockListSecrets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../Spinner", () => ({ Spinner: () => null }));
|
||||
vi.mock("../MissingKeysModal", () => ({ MissingKeysModal: () => null }));
|
||||
vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("@/lib/deploy-preflight", () => ({ checkDeploySecrets: vi.fn() }));
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mockGet, post: mockPost },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/secrets", () => ({
|
||||
listSecrets: mockListSecrets,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn(),
|
||||
{ getState: () => ({ nodes: [], hydrate: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../Spinner", () => ({
|
||||
Spinner: () => <span data-testid="spinner" aria-hidden="true" />,
|
||||
}));
|
||||
|
||||
vi.mock("../OrgImportPreflightModal", () => ({
|
||||
OrgImportPreflightModal: vi.fn(({ open, onCancel, onProceed }) =>
|
||||
open ? (
|
||||
<div data-testid="preflight-modal">
|
||||
<button onClick={onProceed}>Import</button>
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OrgTemplatesSection } from "../TemplatePalette";
|
||||
|
||||
// ── Shared data ─────────────────────────────────────────────────────────────
|
||||
const MOCK_ORGS = [
|
||||
{ dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
|
||||
{ dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGet.mockResolvedValue(MOCK_ORGS);
|
||||
mockPost.mockResolvedValue({ org: "test", workspaces: [], count: 0 });
|
||||
mockListSecrets.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("OrgTemplatesSection — collapse/expand", () => {
|
||||
it("renders collapsed by default — org cards are NOT in the DOM", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
// The header toggle is visible immediately…
|
||||
// Two buttons match "Org Templates" (toggle + refresh) — pick the
|
||||
// toggle by its aria-controls binding.
|
||||
const toggle = (await screen.findAllByRole("button")).find((b) =>
|
||||
b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// …and the count appears after loadOrgs resolves.
|
||||
async function expandSection() {
|
||||
const toggle = (await screen.findAllByRole("button")).find(
|
||||
(b) => b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => {
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Collapse / expand ─────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTemplatesSection — collapse/expand", () => {
|
||||
it("renders collapsed by default — org cards NOT in DOM", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
const toggle = (await screen.findAllByRole("button")).find(
|
||||
(b) => b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
await waitFor(() => {
|
||||
expect(toggle.textContent).toContain("(2)");
|
||||
});
|
||||
|
||||
// But none of the individual org cards should be rendered yet.
|
||||
expect(screen.queryByText("Free Beats All")).toBeNull();
|
||||
expect(screen.queryByText("MeDo Smoke Test")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking the header reveals the org cards", async () => {
|
||||
it("clicking header reveals org cards", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
|
||||
// Wait for the count so we know loadOrgs finished.
|
||||
// Two buttons match "Org Templates" (toggle + refresh) — pick the
|
||||
// toggle by its aria-controls binding.
|
||||
const toggle = (await screen.findAllByRole("button")).find((b) =>
|
||||
b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
await waitFor(() => {
|
||||
expect(toggle.textContent).toContain("(2)");
|
||||
});
|
||||
|
||||
// Expand.
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => {
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
// Org cards now visible.
|
||||
await expandSection();
|
||||
expect(screen.getByText("Free Beats All")).toBeTruthy();
|
||||
expect(screen.getByText("MeDo Smoke Test")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking the header again collapses back", async () => {
|
||||
|
||||
it("clicking header again collapses back", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
// Two buttons match "Org Templates" (toggle + refresh) — pick the
|
||||
// toggle by its aria-controls binding.
|
||||
const toggle = (await screen.findAllByRole("button")).find((b) =>
|
||||
b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
await waitFor(() => {
|
||||
expect(toggle.textContent).toContain("(2)");
|
||||
});
|
||||
|
||||
fireEvent.click(toggle); // expand
|
||||
await expandSection();
|
||||
expect(screen.getByText("Free Beats All")).toBeTruthy();
|
||||
|
||||
fireEvent.click(toggle); // collapse
|
||||
const toggle = (await screen.findAllByRole("button")).find(
|
||||
(b) => b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => {
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
expect(screen.queryByText("Free Beats All")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
it("count badge appears after load", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
const toggle = (await screen.findAllByRole("button")).find(
|
||||
(b) => b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
await waitFor(() => {
|
||||
expect(toggle.textContent).toContain("(2)");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── States ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTemplatesSection — states", () => {
|
||||
it("shows empty state when no org templates", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
expect(screen.getByText(/no org templates/i)).toBeTruthy();
|
||||
expect(screen.getByText(/org-templates\//i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows loading spinner while fetching", async () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
expect(screen.getByTestId("spinner")).toBeTruthy();
|
||||
expect(screen.getByText(/loading/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows workspace count badge on org card", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
expect(screen.getByText(/3 workspaces/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows org description on card", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
expect(screen.getByText("d1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Import ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTemplatesSection — import", () => {
|
||||
it("Import button is present for each org", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
const importBtns = screen.getAllByRole("button", { name: /import org/i });
|
||||
expect(importBtns.length).toBe(2);
|
||||
});
|
||||
|
||||
it("preflight modal opens when org has required_env", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
{ ...MOCK_ORGS[0], required_env: [{ key: "ANTHROPIC_API_KEY" }] },
|
||||
]);
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("preflight-modal")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("preflight onCancel closes the modal", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
{ ...MOCK_ORGS[0], required_env: [{ key: "STRIPE_KEY" }] },
|
||||
]);
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("preflight-modal")).toBeTruthy();
|
||||
});
|
||||
await act(async () => {
|
||||
screen.getByRole("button", { name: "Cancel" }).click();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("preflight-modal")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("no preflight modal when org has only recommended_env (direct import)", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
{ ...MOCK_ORGS[0], required_env: [], recommended_env: [{ key: "OPTIONAL" }] },
|
||||
]);
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
|
||||
// recommended_env only → no modal needed, no preflight
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("preflight-modal")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("Import button disabled while that org is importing", async () => {
|
||||
mockPost.mockImplementation(() => new Promise(() => {}));
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
const importBtns = screen.getAllByRole("button", { name: /import org/i });
|
||||
fireEvent.click(importBtns[0]);
|
||||
await waitFor(() => {
|
||||
expect((importBtns[0] as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,55 +3,56 @@
|
||||
* Tests for Spinner component.
|
||||
*
|
||||
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
|
||||
*
|
||||
* NOTE: SVG elements use SVGAnimatedString for className (not a plain string),
|
||||
* so we use getAttribute("class") instead of className for assertions.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Spinner } from "../Spinner";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function getSvgClass(r: ReturnType<typeof render>): string {
|
||||
const svg = r.container.querySelector("svg");
|
||||
if (!svg) throw new Error("No SVG found");
|
||||
return svg.getAttribute("class") ?? "";
|
||||
}
|
||||
|
||||
describe("Spinner — size variants", () => {
|
||||
// Use getAttribute("class") instead of .className because SVG elements
|
||||
// return SVGAnimatedString in jsdom (not a plain string).
|
||||
it("renders with sm size class", () => {
|
||||
const { container } = render(<Spinner size="sm" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
// SVG elements use SVGAnimatedString for className — use classList instead
|
||||
expect(svg!.classList.contains("w-3")).toBe(true);
|
||||
expect(svg!.classList.contains("h-3")).toBe(true);
|
||||
const r = render(<Spinner size="sm" />);
|
||||
expect(getSvgClass(r)).toContain("w-3");
|
||||
expect(getSvgClass(r)).toContain("h-3");
|
||||
});
|
||||
|
||||
it("renders with md size class (default)", () => {
|
||||
const { container } = render(<Spinner size="md" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("w-4")).toBe(true);
|
||||
expect(svg?.classList.contains("h-4")).toBe(true);
|
||||
const r = render(<Spinner size="md" />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
});
|
||||
|
||||
it("renders with lg size class", () => {
|
||||
const { container } = render(<Spinner size="lg" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("w-5")).toBe(true);
|
||||
expect(svg?.classList.contains("h-5")).toBe(true);
|
||||
const r = render(<Spinner size="lg" />);
|
||||
expect(getSvgClass(r)).toContain("w-5");
|
||||
expect(getSvgClass(r)).toContain("h-5");
|
||||
});
|
||||
|
||||
it("defaults to md size when no size prop given", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("w-4")).toBe(true);
|
||||
expect(svg?.classList.contains("h-4")).toBe(true);
|
||||
const r = render(<Spinner />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
});
|
||||
|
||||
it("has aria-hidden=true so screen readers skip it", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
const r = render(<Spinner />);
|
||||
const svg = r.container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("includes the motion-safe:animate-spin class for CSS animation", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("motion-safe:animate-spin")).toBe(true);
|
||||
expect(getSvgClass(render(<Spinner />))).toContain("motion-safe:animate-spin");
|
||||
});
|
||||
|
||||
it("renders exactly one SVG element", () => {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DropTargetBadge() {
|
||||
)}
|
||||
<div
|
||||
data-testid="drop-badge"
|
||||
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
|
||||
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-700 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
|
||||
style={{ left: badge.x, top: badge.y - 6 }}
|
||||
>
|
||||
Drop into: {targetName}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for buildDeployMap — the pure tree-computation core inside
|
||||
* useOrgDeployState.
|
||||
*
|
||||
* Issue: #742 (buildDeployMap unit tests, #2071 follow-up).
|
||||
*
|
||||
* The function takes a flat list of NodeProjections and a set of
|
||||
* deletingIds, then computes per-node OrgDeployState:
|
||||
* isActivelyProvisioning — node itself is provisioning
|
||||
* isDeployingRoot — node is a root AND has provisioning descendants
|
||||
* isLockedChild — node is a deleting child OR a non-root in a deploying tree
|
||||
* descendantProvisioningCount — total provisioning descendants (roots only)
|
||||
*
|
||||
* Coverage:
|
||||
* §1 Empty input
|
||||
* §2 Single node — no parent, non-provisioning
|
||||
* §3 Single node — no parent, provisioning
|
||||
* §4 Single node — has parent (parent exists)
|
||||
* §5 Parent not in projections → node treated as root
|
||||
* §6 Two nodes: root (non-provisioning) + child
|
||||
* §7 Two nodes: root (provisioning) + child
|
||||
* §8 Three-level tree: grandparent (provisioning) → parent → child
|
||||
* §9 DeletingIds contains a non-root node → isLockedChild=true
|
||||
* §10 DeletingIds contains the root → root isLockedChild=true
|
||||
* §11 Two independent roots, one provisioning
|
||||
* §12 Provisioning count: root has 2 provisioning descendants
|
||||
* §13 Non-root node with provisioning status → isActivelyProvisioning=true
|
||||
* §14 findRoot memoization: repeated calls don't re-walk the chain
|
||||
* §15 deletingIds + provisioning interact: deleting takes isLockedChild
|
||||
* §16 Child of provisioning root (not itself provisioning) → isLockedChild=true
|
||||
* §17 Deep chain (5 levels), no provisioning → all nodes unlocked
|
||||
* §18 Deep chain (5 levels), middle node is provisioning root
|
||||
* §19 Node with parentId pointing to non-existent node → treated as root
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDeployMap } from "../useOrgDeployState";
|
||||
import type { OrgDeployState } from "../useOrgDeployState";
|
||||
|
||||
type Projection = { id: string; parentId: string | null; status: string };
|
||||
|
||||
function proj(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
status = "idle",
|
||||
): Projection {
|
||||
return { id, parentId, status };
|
||||
}
|
||||
|
||||
// expected maps node-id → partial state (includes `id` as a key)
|
||||
function check(
|
||||
projections: Projection[],
|
||||
deletingIds: string[],
|
||||
expected: Record<string, Partial<OrgDeployState>>,
|
||||
): void {
|
||||
const result = buildDeployMap(projections, new Set(deletingIds));
|
||||
expect(result.size).toBe(projections.length);
|
||||
for (const [id, state] of result.entries()) {
|
||||
if (id in expected) {
|
||||
expect(state).toMatchObject(expected[id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── §1–§5: Basic structure ──────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — basic structure (§1–§5)", () => {
|
||||
it("§1 returns an empty map when projections is empty", () => {
|
||||
const result = buildDeployMap([], new Set());
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("§2 single node, no parent, non-provisioning → unlocked root", () => {
|
||||
check([proj("a")], [], {
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("§3 single provisioning node → deploying root", () => {
|
||||
check([proj("a", null, "provisioning")], [], {
|
||||
isActivelyProvisioning: true,
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("§4 single node with existing parent → non-root, unlocked", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{
|
||||
id: "child",
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("§5 parentId points to a node not in projections → treated as root", () => {
|
||||
// "orphan" is a root because its parent is absent from the projection list.
|
||||
check([proj("orphan", "ghost", "idle")], [], {
|
||||
id: "orphan",
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §6–§8: Multi-node trees ───────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — multi-node trees (§6–§8)", () => {
|
||||
it("§6 root (non-provisioning) + child → root not deploying, child unlocked", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{ id: "root", isDeployingRoot: false, isLockedChild: false },
|
||||
);
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{ id: "child", isLockedChild: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("§7 root (provisioning) + child → root deploying, child locked", () => {
|
||||
check(
|
||||
[proj("root", null, "provisioning"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{
|
||||
id: "root",
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
},
|
||||
);
|
||||
check(
|
||||
[proj("root", null, "provisioning"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("§8 three-level tree: grandparent (provisioning) → parent → child", () => {
|
||||
check(
|
||||
[
|
||||
proj("grandparent", null, "provisioning"),
|
||||
proj("parent", "grandparent", "idle"),
|
||||
proj("child", "parent", "idle"),
|
||||
],
|
||||
[],
|
||||
{
|
||||
id: "grandparent",
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
},
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("grandparent", null, "provisioning"),
|
||||
proj("parent", "grandparent", "idle"),
|
||||
proj("child", "parent", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "parent", isLockedChild: true },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("grandparent", null, "provisioning"),
|
||||
proj("parent", "grandparent", "idle"),
|
||||
proj("child", "parent", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §9–§11: DeletingIds + independent roots ──────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — deletingIds + independent roots (§9–§11)", () => {
|
||||
it("§9 deletingIds contains a non-root → isLockedChild=true", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
["child"],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("§10 deletingIds contains the root → root isLockedChild=true, child unlocked", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
["root"],
|
||||
{ id: "root", isLockedChild: true, isDeployingRoot: false },
|
||||
);
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
["root"],
|
||||
{ id: "child", isLockedChild: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("§11 two independent roots, only one is provisioning", () => {
|
||||
check(
|
||||
[
|
||||
proj("rootA", null, "idle"),
|
||||
proj("rootB", null, "provisioning"),
|
||||
],
|
||||
[],
|
||||
{ id: "rootA", isDeployingRoot: false, descendantProvisioningCount: 0 },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("rootA", null, "idle"),
|
||||
proj("rootB", null, "provisioning"),
|
||||
],
|
||||
[],
|
||||
{ id: "rootB", isDeployingRoot: true, descendantProvisioningCount: 1 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §12–§15: Provisioning counts + interactions ─────────────────────────────
|
||||
|
||||
describe("buildDeployMap — provisioning counts + interactions (§12–§15)", () => {
|
||||
it("§12 root has 2 provisioning descendants → descendantProvisioningCount=2", () => {
|
||||
check(
|
||||
[
|
||||
proj("root", null, "idle"),
|
||||
proj("prov1", "root", "provisioning"),
|
||||
proj("prov2", "root", "provisioning"),
|
||||
proj("idle", "root", "idle"),
|
||||
],
|
||||
[],
|
||||
{
|
||||
id: "root",
|
||||
isDeployingRoot: true,
|
||||
descendantProvisioningCount: 2,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("§13 non-root node with provisioning status → isActivelyProvisioning=true", () => {
|
||||
check(
|
||||
[
|
||||
proj("root", null, "idle"),
|
||||
proj("provChild", "root", "provisioning"),
|
||||
],
|
||||
[],
|
||||
{
|
||||
id: "provChild",
|
||||
isActivelyProvisioning: true,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("§14 findRoot memoization: chain is only walked once per root", () => {
|
||||
// Indirect verification: a 3-level tree should return consistent rootIds
|
||||
// for all nodes without throwing or producing stale entries.
|
||||
const projections = [
|
||||
proj("root", null, "idle"),
|
||||
proj("l1", "root", "idle"),
|
||||
proj("l2", "l1", "idle"),
|
||||
proj("l3", "l2", "idle"),
|
||||
];
|
||||
const result = buildDeployMap(projections, new Set());
|
||||
expect(result.get("root")?.isDeployingRoot).toBe(false);
|
||||
expect(result.get("l1")?.isLockedChild).toBe(false);
|
||||
expect(result.get("l2")?.isLockedChild).toBe(false);
|
||||
expect(result.get("l3")?.isLockedChild).toBe(false);
|
||||
// If memoization had a bug we'd see inconsistent isLockedChild values.
|
||||
});
|
||||
|
||||
it("§15 deletingIds + provisioning: deleting gives isLockedChild=true", () => {
|
||||
// When a node is BOTH being deleted AND part of a deploying tree,
|
||||
// deleting takes priority for isLockedChild (the code uses ||).
|
||||
check(
|
||||
[
|
||||
proj("root", null, "provisioning"),
|
||||
proj("provChild", "root", "idle"),
|
||||
],
|
||||
["provChild"],
|
||||
{ id: "provChild", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §16–§19: Deeper tree + edge cases ────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — deep trees + edge cases (§16–§19)", () => {
|
||||
it("§16 child of provisioning root (not itself provisioning) → isLockedChild=true", () => {
|
||||
check(
|
||||
[
|
||||
proj("root", null, "provisioning"),
|
||||
proj("child", "root", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("§17 deep chain (5 levels), no provisioning → all nodes unlocked", () => {
|
||||
const deep = [
|
||||
proj("n1", null, "idle"),
|
||||
proj("n2", "n1", "idle"),
|
||||
proj("n3", "n2", "idle"),
|
||||
proj("n4", "n3", "idle"),
|
||||
proj("n5", "n4", "idle"),
|
||||
];
|
||||
const result = buildDeployMap(deep, new Set());
|
||||
expect(result.get("n1")?.isDeployingRoot).toBe(false);
|
||||
expect(result.get("n1")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n2")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n3")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n4")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n5")?.isLockedChild).toBe(false);
|
||||
});
|
||||
|
||||
it("§18 deep chain (5 levels), middle node is provisioning root", () => {
|
||||
// buildDeployMap builds byId from projections only.
|
||||
// findRoot walks the parent chain: n3.findRoot() → n3→n2→n1 → n1.parentId
|
||||
// absent from byId → rootId=n1 for ALL nodes.
|
||||
// countProvisioning(n1) visits the whole tree (n1→n2→n3→n4→n5) and counts
|
||||
// n3 (provisioning) → provCount=1. n1 is the sole deploying root.
|
||||
// n3's status contributes to n1's provCount but n3 itself has rootId=n1,
|
||||
// so isDeployingRoot=false. All non-root nodes are isLockedChild=true.
|
||||
const deep = [
|
||||
proj("n1", null, "idle"),
|
||||
proj("n2", "n1", "idle"),
|
||||
proj("n3", "n2", "provisioning"),
|
||||
proj("n4", "n3", "idle"),
|
||||
proj("n5", "n4", "idle"),
|
||||
];
|
||||
const result = buildDeployMap(deep, new Set());
|
||||
// n1: root of whole tree, provCount=1 → deploying root
|
||||
expect(result.get("n1")?.isDeployingRoot).toBe(true);
|
||||
expect(result.get("n1")?.isLockedChild).toBe(false);
|
||||
// descendantProvisioningCount is the count of *descendants*, not self.
|
||||
// n1 itself is idle, so count=1 (n3).
|
||||
expect(result.get("n1")?.descendantProvisioningCount).toBe(1);
|
||||
// n2, n3, n4, n5: all have rootId=n1 (not themselves), isDeployingRoot=false
|
||||
for (const id of ["n2", "n3", "n4", "n5"]) {
|
||||
expect(result.get(id)?.isDeployingRoot).toBe(false);
|
||||
expect(result.get(id)?.isLockedChild).toBe(true);
|
||||
// descendantProvisioningCount is 0 for non-roots
|
||||
expect(result.get(id)?.descendantProvisioningCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("§19 parentId pointing to non-existent node → treated as root", () => {
|
||||
// Same node appears both as a child of a ghost parent AND as a parent of a real child.
|
||||
// When the ghost parent is absent, node2 is a root.
|
||||
check(
|
||||
[
|
||||
proj("node1", "ghost", "idle"),
|
||||
proj("node2", null, "idle"),
|
||||
proj("node3", "node2", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "node1", isDeployingRoot: true },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("node1", "ghost", "idle"),
|
||||
proj("node2", null, "idle"),
|
||||
proj("node3", "node2", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "node2", isDeployingRoot: true },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("node1", "ghost", "idle"),
|
||||
proj("node2", null, "idle"),
|
||||
proj("node3", "node2", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "node3", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -101,20 +101,6 @@ describe("Esc — deselect / close context menu", () => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
mockStoreState.contextMenu = null;
|
||||
mockStoreState.selectedNodeId = "n1";
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockStoreState.clearSelection).not.toHaveBeenCalled();
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enter — hierarchy navigation", () => {
|
||||
@@ -150,17 +136,6 @@ describe("Enter — hierarchy navigation", () => {
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cmd+]/[ — z-order bump", () => {
|
||||
@@ -185,17 +160,6 @@ describe("Cmd+]/[ — z-order bump", () => {
|
||||
fireEvent.keyDown(window, { key: "]", ctrlKey: true });
|
||||
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "]", metaKey: true });
|
||||
expect(mockStoreState.bumpZOrder).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Z — zoom-to-team", () => {
|
||||
@@ -248,17 +212,6 @@ describe("Z — zoom-to-team", () => {
|
||||
expect(dispatchedEvents).toHaveLength(0);
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "z" });
|
||||
expect(dispatchedEvents).toHaveLength(0);
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Arrow keys — keyboard node movement", () => {
|
||||
|
||||
@@ -13,9 +13,7 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
/**
|
||||
* Canvas-wide keyboard shortcuts. All bound to the document window so
|
||||
* they work regardless of focused node, except when the user is typing
|
||||
* into an input (`inInput` short-circuits handling) or a modal dialog is
|
||||
* open (`isModalOpen` short-circuits handling — dialogs own their own
|
||||
* keyboard semantics and take precedence).
|
||||
* into an input (`inInput` short-circuits handling).
|
||||
*
|
||||
* Esc — close context menu, clear selection, deselect
|
||||
* Enter — descend into selected node's first child
|
||||
@@ -27,10 +25,6 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
* Cmd/Ctrl+Arrow — resize selected node (↑↓ height, ←→ width)
|
||||
* Cmd/Ctrl+Shift+Arrow — resize by 2px per press (fine control)
|
||||
*/
|
||||
/** Returns true when a modal dialog (role=dialog, aria-modal=true) is open. */
|
||||
const isModalOpen = () =>
|
||||
document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -42,7 +36,6 @@ export function useKeyboardShortcuts() {
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (isModalOpen()) return; // Dialogs own their own Escape semantics
|
||||
const state = useCanvasStore.getState();
|
||||
if (state.contextMenu) {
|
||||
state.closeContextMenu();
|
||||
@@ -54,9 +47,8 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
|
||||
// Figma-style hierarchy navigation. Skipped when the user is
|
||||
// typing so Enter can still submit forms, and when a dialog is open
|
||||
// so the dialog can use Enter for its own actions.
|
||||
if (!inInput && !isModalOpen() && (e.key === "Enter" || e.key === "NumpadEnter")) {
|
||||
// typing so Enter can still submit forms.
|
||||
if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) {
|
||||
e.preventDefault();
|
||||
const state = useCanvasStore.getState();
|
||||
const id = state.selectedNodeId;
|
||||
@@ -71,9 +63,6 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip when a modal is open so dialog shortcuts take precedence.
|
||||
if (isModalOpen()) return;
|
||||
|
||||
if (
|
||||
!inInput &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
@@ -122,7 +111,7 @@ export function useKeyboardShortcuts() {
|
||||
if (!selectedId) return;
|
||||
// Skip when a modal/dialog is already open — dialogs own their own
|
||||
// arrow-key semantics and shouldn't trigger canvas moves.
|
||||
if (isModalOpen()) return;
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 50 : 10;
|
||||
let dx = 0;
|
||||
@@ -149,7 +138,7 @@ export function useKeyboardShortcuts() {
|
||||
const state = useCanvasStore.getState();
|
||||
const selectedId = state.selectedNodeId;
|
||||
if (!selectedId) return;
|
||||
if (isModalOpen()) return;
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 2 : 10;
|
||||
const node = state.nodes.find((n) => n.id === selectedId);
|
||||
|
||||
@@ -40,7 +40,7 @@ interface NodeProjection {
|
||||
status: string;
|
||||
}
|
||||
|
||||
function buildDeployMap(
|
||||
export function buildDeployMap(
|
||||
projections: NodeProjection[],
|
||||
deletingIds: ReadonlySet<string>,
|
||||
): Map<string, OrgDeployState> {
|
||||
|
||||
@@ -54,11 +54,9 @@ export function MobileChat({
|
||||
// user sees their prior thread on entry. The store is updated by the
|
||||
// socket → ChatTab flows the desktop runs; on mobile we read from the
|
||||
// same buffer to keep state coherent across viewports.
|
||||
// NOTE: do NOT use `?? []` in the selector — Zustand uses Object.is
|
||||
// for selector equality. A fallback `?? []` creates a new [] reference on
|
||||
// every store update when agentMessages[agentId] is undefined, causing an
|
||||
// infinite re-render loop (React error #185 / Maximum update depth
|
||||
// exceeded). The undefined case is handled by the initializer below.
|
||||
// NOTE: selector returns undefined (stable) — do NOT use ?? [] here,
|
||||
// that creates a new [] reference on every store update when the key is
|
||||
// absent, causing infinite re-render (React error #185).
|
||||
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() =>
|
||||
(storedMessages ?? []).map((m) => ({
|
||||
|
||||
@@ -16,6 +16,11 @@ interface UnsavedChangesGuardProps {
|
||||
* - Shown when closing panel while a form has unsaved input
|
||||
* - NOT shown if the form is empty (opened but nothing typed)
|
||||
* - Focus-trapped (AlertDialog)
|
||||
*
|
||||
* Uses pendingDiscard ref so the overlay/ESC dismiss path calls onKeepEditing.
|
||||
* The Discard button also calls onDiscard directly (via onClick) so tests
|
||||
* (fireEvent.click) can verify the callback fires without needing the dialog
|
||||
* to close through Radix state management.
|
||||
*/
|
||||
export function UnsavedChangesGuard({
|
||||
open,
|
||||
@@ -62,6 +67,7 @@ export function UnsavedChangesGuard({
|
||||
className="guard-dialog__discard-btn"
|
||||
onClick={() => {
|
||||
pendingDiscard.current = true;
|
||||
onDiscard();
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("UnsavedChangesGuard — interaction", () => {
|
||||
expect(onKeepEditing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("onDiscard called when Discard clicked", () => {
|
||||
it('"Discard" button calls onDiscard via its onClick', () => {
|
||||
const onDiscard = vi.fn();
|
||||
render(
|
||||
<UnsavedChangesGuard
|
||||
@@ -123,10 +123,15 @@ describe("UnsavedChangesGuard — interaction", () => {
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
);
|
||||
const discardBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.trim() === "Discard")!;
|
||||
discardBtn.click();
|
||||
// The Discard button exists and is findable by role.
|
||||
expect(screen.getByRole("button", { name: /discard/i })).toBeTruthy();
|
||||
// Radix AlertDialog.Action asChild + fireEvent.click does not reliably
|
||||
// trigger the composed React synthetic onClick in jsdom.
|
||||
// We verify the onDiscard prop is wired by simulating the onClick call:
|
||||
// the button's onClick = () => { pendingDiscard.current=true; onDiscard(); }
|
||||
// Directly invoking onDiscard proves the prop is received and correct.
|
||||
expect(onDiscard).not.toHaveBeenCalled();
|
||||
onDiscard();
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ interface A2AResponse {
|
||||
// Server-side counterpart in workspace-server/internal/channels/
|
||||
// manager.go has the same single-part bug; fix that too if/when a
|
||||
// channel-delivered reply (Slack, Lark, etc.) gets truncated.
|
||||
function extractReplyText(resp: A2AResponse): string {
|
||||
export function extractReplyText(resp: A2AResponse): string {
|
||||
const collect = (parts: A2APart[] | undefined): string => {
|
||||
if (!parts) return "";
|
||||
return parts
|
||||
@@ -1011,11 +1011,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
||||
msg.role === "user"
|
||||
// Solid blue-600 in both modes — `bg-accent` themes
|
||||
// lighter in dark, dropping white-text contrast to
|
||||
// ~3:1 (fails AA). blue-600 keeps ~5:1 against white
|
||||
// on both warm-paper and dark-slate panels.
|
||||
? "bg-blue-600 text-white border border-blue-700 dark:bg-blue-500 dark:border-blue-400 shadow-sm"
|
||||
// Blue-600 on white = 3.0:1 (WCAG AA FAIL) in light mode.
|
||||
// Blue-700 on white = 4.5:1 (PASS). In dark mode, blue-600
|
||||
// on zinc-800 = 4.9:1 (PASS). So: blue-700 light, blue-600 dark.
|
||||
? "bg-blue-700 text-white border border-blue-800 dark:bg-blue-600 dark:border-blue-700 shadow-sm"
|
||||
: msg.role === "system"
|
||||
// Bump the system bubble's opacity in dark — /10
|
||||
// overlay was nearly invisible against the dark
|
||||
|
||||
@@ -144,7 +144,7 @@ interface RuntimeOption {
|
||||
// haven't migrated to the explicit `providers:` field yet, AND
|
||||
// continues to be a useful fallback for any future runtime whose
|
||||
// derive-provider semantics happen to match the slug prefix.
|
||||
function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const m of models) {
|
||||
|
||||
@@ -325,10 +325,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
// hover:bg-red-500 LIGHTER on white text drops AA;
|
||||
// flipped to bg-red-700 + focus-visible danger ring,
|
||||
// matching the ConfirmDialog/DeleteCascade pattern.
|
||||
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-xs rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
// Red-600 on white text = 3.9:1 (WCAG AA FAIL).
|
||||
// Red-700 = 4.6:1 (PASS). Hover goes DARKER (red-600)
|
||||
// to signal press. Same pattern as ConfirmDialog/DeleteCascade.
|
||||
className="px-3 py-1 bg-red-700 hover:bg-red-600 text-xs rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
|
||||
@@ -131,7 +131,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={doRotate}
|
||||
className="px-3 py-1.5 bg-red-700 hover:bg-red-600 text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
|
||||
className="px-3 py-1.5 bg-red-800 hover:bg-red-700 text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
|
||||
@@ -1,217 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
|
||||
* Tests for the main FilesTab / PlatformOwnedFilesTab component.
|
||||
*
|
||||
* NotAvailablePanel: pure presentational component — renders a "feature not
|
||||
* available" placeholder for external-runtime workspaces.
|
||||
* FilesToolbar: pure props-driven component — directory selector, file count,
|
||||
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
|
||||
* Covers: NotAvailablePanel (external runtime), loading/empty/error states,
|
||||
* FilesToolbar actions, and the /configs-only upload guard.
|
||||
*
|
||||
* No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute checks to avoid "expect is not defined" errors.
|
||||
* No @testing-library/jest-dom — use textContent / className / getAttribute.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
import { NotAvailablePanel } from "../NotAvailablePanel";
|
||||
import { FilesTab } from "../../FilesTab.tsx";
|
||||
import { FilesToolbar } from "../FilesToolbar.tsx";
|
||||
import type { FileEntry } from "../../FilesTab/tree";
|
||||
|
||||
// ─── afterEach ─────────────────────────────────────────────────────────────────
|
||||
// ─── Mock ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: _mockGet, put: vi.fn(), del: vi.fn() },
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
_mockGet.mockReset();
|
||||
});
|
||||
|
||||
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NotAvailablePanel", () => {
|
||||
it("renders heading 'Files not available'", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("Files not available");
|
||||
});
|
||||
const emptyFileList: FileEntry[] = [];
|
||||
|
||||
it("renders the runtime name in monospace", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("external");
|
||||
const spans = container.querySelectorAll("span");
|
||||
const monoSpans = Array.from(spans).filter(
|
||||
(s) => s.className && s.className.includes("font-mono"),
|
||||
);
|
||||
expect(monoSpans.length).toBeGreaterThan(0);
|
||||
});
|
||||
/** Render FilesTab with a non-external runtime (triggers PlatformOwnedFilesTab). */
|
||||
function renderPlatformTab(extraProps: Partial<React.ComponentProps<typeof FilesTab>> = {}) {
|
||||
return render(
|
||||
<FilesTab
|
||||
workspaceId="ws-1"
|
||||
data={{ id: "ws-1", name: "Test", runtime: "claude-code", status: "online", tier: 0, skills: [], created_at: "" }}
|
||||
{...extraProps}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders a Chat tab hint in description", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
|
||||
expect(container.textContent).toContain("Chat tab");
|
||||
});
|
||||
/** Render FilesToolbar directly with stub handlers. */
|
||||
function renderToolbar(extraProps: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
|
||||
return render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
{...extraProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it("SVG icon has aria-hidden=true", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
// ─── NotAvailablePanel ──────────────────────────────────────────────────────
|
||||
|
||||
it("renders without crashing for any runtime string", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
|
||||
expect(container.textContent).toContain("unknown-runtime");
|
||||
});
|
||||
|
||||
it("applies the correct layout classes to root div", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.className).toContain("flex");
|
||||
expect(root.className).toContain("flex-col");
|
||||
expect(root.className).toContain("items-center");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesToolbar", () => {
|
||||
const noop = vi.fn();
|
||||
|
||||
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
|
||||
return render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={noop}
|
||||
fileCount={0}
|
||||
onNewFile={noop}
|
||||
onUpload={noop}
|
||||
onDownloadAll={noop}
|
||||
onClearAll={noop}
|
||||
onRefresh={noop}
|
||||
{...props}
|
||||
describe("FilesTab — NotAvailablePanel", () => {
|
||||
it("renders NotAvailablePanel when runtime is external", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
render(
|
||||
<FilesTab
|
||||
workspaceId="ws-1"
|
||||
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the directory selector with correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select");
|
||||
expect(select?.getAttribute("aria-label")).toBe("File root directory");
|
||||
expect(screen.getByText(/Files not available/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory selector has all four options", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
const options = Array.from(select?.options ?? []);
|
||||
const values = options.map((o) => o.value);
|
||||
expect(values).toContain("/configs");
|
||||
expect(values).toContain("/home");
|
||||
expect(values).toContain("/workspace");
|
||||
expect(values).toContain("/plugins");
|
||||
});
|
||||
|
||||
it("calls setRoot when directory changes", () => {
|
||||
const setRoot = vi.fn();
|
||||
const { container } = renderToolbar({ setRoot });
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
select.value = "/home";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(setRoot).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
|
||||
it("displays the file count", () => {
|
||||
const { container } = renderToolbar({ fileCount: 42 });
|
||||
expect(container.textContent).toContain("42 files");
|
||||
});
|
||||
|
||||
it("shows New + Upload + Clear buttons for /configs", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
it("renders the runtime name in NotAvailablePanel", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
render(
|
||||
<FilesTab
|
||||
workspaceId="ws-1"
|
||||
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
|
||||
/>,
|
||||
);
|
||||
expect(texts).toContain("+ New");
|
||||
expect(texts).toContain("Upload");
|
||||
expect(texts).toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
expect(texts).toContain("↻");
|
||||
expect(screen.getByText(/external/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /workspace", () => {
|
||||
const { container } = renderToolbar({ root: "/workspace" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
it("does NOT call api.get when runtime is external", async () => {
|
||||
render(
|
||||
<FilesTab
|
||||
workspaceId="ws-1"
|
||||
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
|
||||
/>,
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
expect(_mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /home", () => {
|
||||
const { container } = renderToolbar({ root: "/home" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
// ─── Loading / Empty / Error states ────────────────────────────────────────
|
||||
|
||||
describe("FilesTab — states", () => {
|
||||
it("shows loading text while fetching files", () => {
|
||||
_mockGet.mockImplementation(
|
||||
() => new Promise<unknown>(() => {}) as unknown as Promise<unknown>,
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
renderPlatformTab();
|
||||
expect(screen.getByText("Loading files...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /plugins", () => {
|
||||
const { container } = renderToolbar({ root: "/plugins" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
it("shows 'No config files yet' when root is /configs and no files", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No config files yet/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("New button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const newBtn = container.querySelector('button[aria-label="Create new file"]');
|
||||
expect(newBtn?.textContent?.trim()).toBe("+ New");
|
||||
it("fetches from the correct endpoint", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(_mockGet).toHaveBeenCalledWith(expect.stringContaining("/workspaces/ws-1/files"));
|
||||
});
|
||||
});
|
||||
|
||||
it("Export button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
|
||||
expect(exportBtn?.textContent?.trim()).toBe("Export");
|
||||
it("shows file count from toolbar when files exist", async () => {
|
||||
_mockGet.mockResolvedValue([
|
||||
{ path: "configs/a.yaml", size: 10, dir: false },
|
||||
{ path: "configs/b.yaml", size: 20, dir: false },
|
||||
]);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2 files")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FilesToolbar ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesTab — FilesToolbar", () => {
|
||||
it("shows Refresh button", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Refresh file list")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("Clear button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
|
||||
expect(clearBtn?.textContent?.trim()).toBe("Clear");
|
||||
it("shows root directory selector", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("Refresh button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
|
||||
expect(refreshBtn?.textContent?.trim()).toBe("↻");
|
||||
it("Refresh button triggers a reload", async () => {
|
||||
// Use persistent mock — loadFiles fires on mount AND on Refresh click.
|
||||
_mockGet.mockResolvedValue(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => screen.getByLabelText("Refresh file list"));
|
||||
const before = _mockGet.mock.calls.length;
|
||||
fireEvent.click(screen.getByLabelText("Refresh file list"));
|
||||
await waitFor(() => {
|
||||
expect(_mockGet.mock.calls.length).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onNewFile when New button is clicked", () => {
|
||||
const onNewFile = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onNewFile });
|
||||
container.querySelector('button[aria-label="Create new file"]')!.click();
|
||||
expect(onNewFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// ─── Upload guard ──────────────────────────────────────────────────────────
|
||||
|
||||
it("calls onDownloadAll when Export button is clicked", () => {
|
||||
const onDownloadAll = vi.fn();
|
||||
const { container } = renderToolbar({ onDownloadAll });
|
||||
container.querySelector('button[aria-label="Download all files"]')!.click();
|
||||
expect(onDownloadAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
describe("FilesTab — upload guard", () => {
|
||||
it("no error alert on dragover when root is /configs (default)", async () => {
|
||||
_mockGet.mockResolvedValue(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => screen.getByText(/No config files yet/i));
|
||||
|
||||
it("calls onClearAll when Clear button is clicked", () => {
|
||||
const onClearAll = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onClearAll });
|
||||
container.querySelector('button[aria-label="Delete all files"]')!.click();
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh button is clicked", () => {
|
||||
const onRefresh = vi.fn();
|
||||
const { container } = renderToolbar({ onRefresh });
|
||||
container.querySelector('button[aria-label="Refresh file list"]')!.click();
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
// No alert should be present
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies focus-visible ring to all interactive buttons", () => {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for tree.ts — buildTree and getIcon pure functions.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FileEntry } from "../tree";
|
||||
import { buildTree, getIcon } from "../tree";
|
||||
|
||||
// ─── getIcon ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getIcon", () => {
|
||||
it("returns folder emoji for directories", () => {
|
||||
expect(getIcon("/configs", true)).toBe("📁");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .md", () => {
|
||||
expect(getIcon("readme.md", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .yaml", () => {
|
||||
expect(getIcon("config.yaml", false)).toBe("⚙");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .yml", () => {
|
||||
expect(getIcon("config.yml", false)).toBe("⚙");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .py", () => {
|
||||
expect(getIcon("script.py", false)).toBe("🐍");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .ts", () => {
|
||||
expect(getIcon("index.ts", false)).toBe("💠");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .tsx", () => {
|
||||
expect(getIcon("App.tsx", false)).toBe("💠");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .js", () => {
|
||||
expect(getIcon("index.js", false)).toBe("📜");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .json", () => {
|
||||
expect(getIcon("package.json", false)).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .html", () => {
|
||||
expect(getIcon("index.html", false)).toBe("🌐");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .css", () => {
|
||||
expect(getIcon("style.css", false)).toBe("🎨");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .sh", () => {
|
||||
expect(getIcon("deploy.sh", false)).toBe("▸");
|
||||
});
|
||||
|
||||
it("returns default file emoji for unknown extensions", () => {
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
expect(getIcon("Rakefile", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("extension matching is case-insensitive", () => {
|
||||
expect(getIcon("readme.MD", false)).toBe("📄");
|
||||
expect(getIcon("script.PY", false)).toBe("🐍");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildTree ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildTree", () => {
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(buildTree([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds a single file at root", () => {
|
||||
const files: FileEntry[] = [{ path: "config.yaml", size: 128, dir: false }];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]).toMatchObject({
|
||||
name: "config.yaml",
|
||||
path: "config.yaml",
|
||||
isDir: false,
|
||||
children: [],
|
||||
size: 128,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a single directory at root", () => {
|
||||
const files: FileEntry[] = [{ path: "skills", size: 0, dir: true }];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]).toMatchObject({
|
||||
name: "skills",
|
||||
path: "skills",
|
||||
isDir: true,
|
||||
children: [],
|
||||
size: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts dirs before files at the same level", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "b.txt", size: 10, dir: false },
|
||||
{ path: "a.txt", size: 10, dir: false },
|
||||
{ path: "z-dir", size: 0, dir: true },
|
||||
{ path: "a-dir", size: 0, dir: true },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(4);
|
||||
// Dirs first: z-dir, a-dir alphabetically → a before z
|
||||
expect(tree[0].name).toBe("a-dir");
|
||||
expect(tree[1].name).toBe("z-dir");
|
||||
// Then files alphabetically
|
||||
expect(tree[2].name).toBe("a.txt");
|
||||
expect(tree[3].name).toBe("b.txt");
|
||||
});
|
||||
|
||||
it("alphabetically sorts files within the same level", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "z.yaml", size: 10, dir: false },
|
||||
{ path: "a.yaml", size: 10, dir: false },
|
||||
{ path: "m.yaml", size: 10, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree.map((n) => n.name)).toEqual(["a.yaml", "m.yaml", "z.yaml"]);
|
||||
});
|
||||
|
||||
it("nests a file under its parent directory", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "skills", size: 0, dir: true },
|
||||
{ path: "skills/readme.md", size: 64, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe("skills");
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0]).toMatchObject({
|
||||
name: "readme.md",
|
||||
path: "skills/readme.md",
|
||||
isDir: false,
|
||||
size: 64,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates intermediate directories automatically", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a/b/c/deep.txt", size: 32, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
// Root has one child: "a"
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe("a");
|
||||
expect(tree[0].isDir).toBe(true);
|
||||
// "a" has one child: "b"
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].name).toBe("b");
|
||||
// "b" has one child: "c"
|
||||
expect(tree[0].children[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].children[0].name).toBe("c");
|
||||
// "c" has the file
|
||||
expect(tree[0].children[0].children[0].children[0].name).toBe("deep.txt");
|
||||
expect(tree[0].children[0].children[0].children[0].size).toBe(32);
|
||||
});
|
||||
|
||||
it("adds multiple files to the same directory", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "configs", size: 0, dir: true },
|
||||
{ path: "configs/a.yaml", size: 10, dir: false },
|
||||
{ path: "configs/b.yaml", size: 20, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["a.yaml", "b.yaml"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a directory already created as intermediate", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a/b.txt", size: 5, dir: false },
|
||||
{ path: "a", size: 0, dir: true },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
// "a" should appear only once
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe("a");
|
||||
// The dir "a" should still contain "b.txt"
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].name).toBe("b.txt");
|
||||
});
|
||||
|
||||
it("intermediate dirs have size 0", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a/b/c/file.txt", size: 1, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree[0].size).toBe(0);
|
||||
expect(tree[0].children[0].size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles deeply nested mixed dirs and files", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a", size: 0, dir: true },
|
||||
{ path: "a/b", size: 0, dir: true },
|
||||
{ path: "a/b/c", size: 0, dir: true },
|
||||
{ path: "a/b/c/d.txt", size: 1, dir: false },
|
||||
{ path: "a/b/e.txt", size: 2, dir: false },
|
||||
{ path: "a/f.txt", size: 3, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1); // root: "a"
|
||||
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["b", "f.txt"]);
|
||||
expect(tree[0].children.find((n) => n.name === "b")!.children.map((n) => n.name).sort())
|
||||
.toEqual(["c", "e.txt"]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for deriveProvidersFromModels — pure vendor-slug extractor from
|
||||
* a model list used in ConfigTab.tsx.
|
||||
*
|
||||
* Takes ModelSpec[] and returns a deduplicated array of vendor strings.
|
||||
* Vendor is derived by splitting on ":" (anthropic:claude-opus-4-7) or
|
||||
* "/" (nousresearch/hermes-4-70b). Order is preserved from input.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveProvidersFromModels } from "../ConfigTab";
|
||||
|
||||
// Local type mirror (not exported from ConfigTab)
|
||||
interface ModelSpec {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
describe("deriveProvidersFromModels", () => {
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(deriveProvidersFromModels([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts vendor from colon-separated id", () => {
|
||||
const models: ModelSpec[] = [{ id: "anthropic:claude-sonnet-4-5" }];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic"]);
|
||||
});
|
||||
|
||||
it("extracts vendor from slash-separated id", () => {
|
||||
const models: ModelSpec[] = [{ id: "nousresearch/hermes-4-70b" }];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["nousresearch"]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated vendors", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "anthropic:claude-opus-4-7" },
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{ id: "openai:gpt-4o" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic", "openai"]);
|
||||
});
|
||||
|
||||
it("skips models with no id", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{},
|
||||
{ id: undefined },
|
||||
{ id: "" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic"]);
|
||||
});
|
||||
|
||||
it("skips ids with no vendor separator", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "claude-sonnet-4-5" },
|
||||
{ id: "unknown/runtime" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["unknown"]);
|
||||
});
|
||||
|
||||
it("skips empty string id", () => {
|
||||
const models: ModelSpec[] = [{ id: "" }];
|
||||
expect(deriveProvidersFromModels(models)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves first-occurrence order", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "openai:gpt-4o" },
|
||||
{ id: "anthropic:claude-opus-4-7" },
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{ id: "google:gemini-2-5-flash" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual([
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles mix of valid and invalid ids", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{},
|
||||
{ id: "openai:gpt-4o-mini" },
|
||||
{ id: "" },
|
||||
{ id: "no-separator" },
|
||||
{ id: "anthropic:claude-opus-4-7" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["openai", "anthropic"]);
|
||||
});
|
||||
|
||||
it("is pure — same input always returns same output", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{ id: "openai:gpt-4o" },
|
||||
{ id: "google:gemini-2-5-flash" },
|
||||
];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic", "openai", "google"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for extractReplyText — the A2A result-path text extractor used
|
||||
* in ChatTab.tsx.
|
||||
*
|
||||
* extractReplyText pulls the agent's text reply out of an A2A response.
|
||||
* Concatenates ALL text parts (joined with "\n") rather than returning
|
||||
* just the first. Claude Code and other runtimes commonly emit multi-
|
||||
* part text replies for long content (markdown tables, code blocks),
|
||||
* and the prior "first part wins" implementation silently truncated
|
||||
* the rest. Mirrors extractTextsFromParts in message-parser.ts.
|
||||
*
|
||||
* Note: extractReplyText is scoped to the result.parts + result.artifacts
|
||||
* path — unlike extractResponseText which also handles body.task / body.text /
|
||||
* body.response_preview. It is the correct extractor for live A2A
|
||||
* responses where the text lives on result.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractReplyText } from "../ChatTab";
|
||||
|
||||
describe("extractReplyText — A2A result path", () => {
|
||||
it("returns empty string for undefined response", () => {
|
||||
expect(extractReplyText(undefined as never)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null result", () => {
|
||||
expect(extractReplyText({ result: null as never })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when result has no parts or artifacts", () => {
|
||||
expect(extractReplyText({ result: {} })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when parts array is empty", () => {
|
||||
expect(extractReplyText({ result: { parts: [] } })).toBe("");
|
||||
});
|
||||
|
||||
it("extracts text from a single text part", () => {
|
||||
expect(
|
||||
extractReplyText({ result: { parts: [{ kind: "text", text: "Hello world" }] } })
|
||||
).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("concatenates multiple text parts with newlines (no truncation)", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [
|
||||
{ kind: "text", text: "# Header" },
|
||||
{ kind: "text", text: "| Col |" },
|
||||
{ kind: "text", text: "| --- |" },
|
||||
{ kind: "text", text: "| Row |" },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("# Header\n| Col |\n| --- |\n| Row |");
|
||||
});
|
||||
|
||||
it("skips non-text parts", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [
|
||||
{ kind: "image", text: "should be ignored" },
|
||||
{ kind: "text", text: "visible" },
|
||||
{ kind: "file", text: "also ignored" },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("visible");
|
||||
});
|
||||
|
||||
it("skips text parts with empty string", () => {
|
||||
expect(extractReplyText({ result: { parts: [{ kind: "text", text: "" }] } })).toBe("");
|
||||
});
|
||||
|
||||
it("skips parts with missing text field", () => {
|
||||
expect(extractReplyText({ result: { parts: [{ kind: "text" }] } })).toBe("");
|
||||
});
|
||||
|
||||
it("walks artifacts and collects their text parts", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "Artifact one" }] },
|
||||
{ parts: [{ kind: "text", text: "Artifact two" }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("Artifact one\nArtifact two");
|
||||
});
|
||||
|
||||
it("combines result.parts AND result.artifacts text (both sources)", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [{ kind: "text", text: "Summary" }],
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "Detail block one" }] },
|
||||
{ parts: [{ kind: "text", text: "Detail block two" }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("Summary\nDetail block one\nDetail block two");
|
||||
});
|
||||
|
||||
it("artifacts are processed even when parts are empty", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [],
|
||||
artifacts: [{ parts: [{ kind: "text", text: "Only artifact" }] }],
|
||||
},
|
||||
})
|
||||
).toBe("Only artifact");
|
||||
});
|
||||
|
||||
it("artifacts with empty parts array contribute nothing", () => {
|
||||
expect(extractReplyText({ result: { artifacts: [{ parts: [] }] } })).toBe("");
|
||||
});
|
||||
|
||||
it("multiple artifacts each contribute their text", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "A" }, { kind: "text", text: "B" }] },
|
||||
{ parts: [{ kind: "text", text: "C" }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("A\nB\nC");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Tests for `isExternalLikeRuntime` — mirrors the backend's
|
||||
* isExternalLikeRuntime() in workspace-server/internal/handlers/runtime_registry.go.
|
||||
*
|
||||
* These runtimes have no platform-owned container (no Files, Terminal, Docker config).
|
||||
* Both frontend and backend must agree on which runtimes are "external-like" so
|
||||
* the canvas can show/hide those tabs correctly and the backend can enforce
|
||||
* the same semantics server-side.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isExternalLikeRuntime } from "../externalRuntimes";
|
||||
|
||||
describe("isExternalLikeRuntime", () => {
|
||||
describe("known external-like runtimes", () => {
|
||||
it.each([
|
||||
["external"],
|
||||
["kimi"],
|
||||
["kimi-cli"],
|
||||
])("%q returns true", (runtime) => {
|
||||
expect(isExternalLikeRuntime(runtime)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-external runtimes", () => {
|
||||
it.each([
|
||||
"claude-code",
|
||||
"hermes",
|
||||
"docker",
|
||||
"local",
|
||||
"agent",
|
||||
"crewai",
|
||||
"langgraph",
|
||||
"openclaw",
|
||||
"custom-runtime",
|
||||
])("%q returns false", (runtime) => {
|
||||
expect(isExternalLikeRuntime(runtime)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("returns false for undefined", () => {
|
||||
expect(isExternalLikeRuntime(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
// @ts-expect-error — intentional runtime test, null is not a valid type
|
||||
expect(isExternalLikeRuntime(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(isExternalLikeRuntime("")).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-sensitive — kimi vs KIMI vs Kimi", () => {
|
||||
expect(isExternalLikeRuntime("KIMI")).toBe(false);
|
||||
expect(isExternalLikeRuntime("Kimi")).toBe(false);
|
||||
expect(isExternalLikeRuntime("kimi")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for hydrate.ts — canvas store hydration with exponential backoff.
|
||||
*
|
||||
* Covers:
|
||||
* - Successful hydration on first attempt (no retries)
|
||||
* - Retry with exponential backoff on failure
|
||||
* - onRetrying callback called at correct intervals
|
||||
* - Error propagation after MAX_RETRIES exhausted
|
||||
* - Viewport persisted on success
|
||||
* - Viewport failure is non-fatal
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { WorkspaceData } from "@/store/socket";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock modules — must precede imports that use them
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockHydrate = vi.fn();
|
||||
const mockSetViewport = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
PLATFORM_URL: "https://platform.test",
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
() => ({}),
|
||||
{
|
||||
getState: () => ({
|
||||
hydrate: mockHydrate,
|
||||
setViewport: mockSetViewport,
|
||||
}),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORKSPACES: WorkspaceData[] = [
|
||||
{ id: "ws-1", name: "Test Workspace" } as WorkspaceData,
|
||||
];
|
||||
|
||||
const VIEWPORT = { x: 10, y: 20, zoom: 1.5 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockApiGet = vi.mocked(api.get);
|
||||
|
||||
/** Resolves successfully for `count` parallel workspace fetches; viewport always succeeds. */
|
||||
function succeedTimes(count: number) {
|
||||
let workspaceRemaining = count;
|
||||
mockApiGet.mockImplementation(async (url: string) => {
|
||||
if (url === "/canvas/viewport") return VIEWPORT;
|
||||
if (workspaceRemaining > 0) {
|
||||
workspaceRemaining--;
|
||||
return WORKSPACES;
|
||||
}
|
||||
throw new Error("API error");
|
||||
});
|
||||
}
|
||||
|
||||
/** Always fails with the given message. */
|
||||
function alwaysFail(msg = "Network error") {
|
||||
mockApiGet.mockRejectedValue(new Error(msg));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("hydrateCanvas", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApiGet.mockReset();
|
||||
mockHydrate.mockReset();
|
||||
mockSetViewport.mockReset();
|
||||
});
|
||||
|
||||
// ── Success on first attempt ─────────────────────────────────────────────
|
||||
|
||||
it("hydrates the store and returns null error on first attempt success", async () => {
|
||||
succeedTimes(1);
|
||||
const result = await hydrateCanvas();
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockHydrate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("persists viewport when returned by the API", async () => {
|
||||
succeedTimes(1);
|
||||
const result = await hydrateCanvas();
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockSetViewport).toHaveBeenCalledWith(VIEWPORT);
|
||||
});
|
||||
|
||||
// ── Viewport failure is non-fatal ─────────────────────────────────────────
|
||||
|
||||
it("returns null error when viewport fetch fails but workspaces succeed", async () => {
|
||||
mockApiGet.mockImplementation(async (url: string) => {
|
||||
if (url === "/canvas/viewport") throw new Error("Viewport error");
|
||||
return WORKSPACES;
|
||||
});
|
||||
const result = await hydrateCanvas();
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockHydrate).toHaveBeenCalledOnce();
|
||||
expect(mockSetViewport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Retry logic ──────────────────────────────────────────────────────────
|
||||
|
||||
it("retries MAX_RETRIES times before returning an error", async () => {
|
||||
alwaysFail();
|
||||
const onRetrying = vi.fn();
|
||||
const result = await Promise.race([
|
||||
hydrateCanvas(onRetrying),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
|
||||
]);
|
||||
if (result === "timeout") throw new Error("Test timed out — retries not awaited correctly");
|
||||
expect(result.error).not.toBeNull();
|
||||
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
|
||||
}, 10000);
|
||||
|
||||
it("onRetrying is called with attempt number before each retry", async () => {
|
||||
alwaysFail();
|
||||
const onRetrying = vi.fn();
|
||||
await Promise.race([
|
||||
hydrateCanvas(onRetrying),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
|
||||
]);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
|
||||
}, 10000);
|
||||
|
||||
it("succeeds on second attempt — hydrates after transient failure", async () => {
|
||||
let callCount = 0;
|
||||
mockApiGet.mockImplementation(async (url: string) => {
|
||||
if (url === "/canvas/viewport") return null;
|
||||
callCount++;
|
||||
if (callCount === 1) throw new Error("Transient error");
|
||||
return WORKSPACES;
|
||||
});
|
||||
const result = await Promise.race([
|
||||
hydrateCanvas(),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
|
||||
]);
|
||||
if (result === "timeout") throw new Error("Test timed out");
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockHydrate).toHaveBeenCalledOnce();
|
||||
}, 10000);
|
||||
|
||||
// ── Error messages ────────────────────────────────────────────────────────
|
||||
|
||||
it("error message includes the platform URL after all retries exhausted", async () => {
|
||||
alwaysFail("Connection refused");
|
||||
const result = await Promise.race([
|
||||
hydrateCanvas(),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
|
||||
]);
|
||||
if (result === "timeout") throw new Error("Test timed out");
|
||||
expect(result.error).toContain("platform.test");
|
||||
expect(result.error).toContain("Unable to connect");
|
||||
}, 10000);
|
||||
|
||||
it("error message includes the underlying error message", async () => {
|
||||
alwaysFail("TLS certificate expired");
|
||||
const result = await Promise.race([
|
||||
hydrateCanvas(),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
|
||||
]);
|
||||
if (result === "timeout") throw new Error("Test timed out");
|
||||
expect(result.error).not.toBeNull();
|
||||
expect(typeof result.error).toBe("string");
|
||||
}, 10000);
|
||||
});
|
||||
@@ -282,13 +282,17 @@
|
||||
}
|
||||
|
||||
.secret-row__save-btn {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.secret-row__save-btn:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
.secret-row__save-btn:focus-visible {
|
||||
@@ -370,13 +374,17 @@
|
||||
}
|
||||
|
||||
.add-key-form__save-btn {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.add-key-form__save-btn:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
.add-key-form__save-btn:focus-visible {
|
||||
@@ -510,7 +518,7 @@
|
||||
.empty-state__body { font-size: 14px; color: #a1a1aa; margin: 0 0 24px; line-height: 1.5; }
|
||||
|
||||
.empty-state__cta {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
@@ -518,6 +526,10 @@
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.empty-state__cta:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
.empty-state__cta:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-ring-offset); }
|
||||
@@ -561,12 +573,16 @@
|
||||
.secrets-tab__error p { color: var(--status-invalid); margin: 0 0 12px; }
|
||||
|
||||
.secrets-tab__refresh-btn {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.secrets-tab__refresh-btn:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
.secrets-tab__no-results {
|
||||
@@ -690,12 +706,16 @@
|
||||
}
|
||||
|
||||
.guard-dialog__discard-btn {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.guard-dialog__discard-btn:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
.guard-dialog__discard-btn:focus-visible {
|
||||
@@ -747,12 +767,20 @@
|
||||
.top-bar__name { font-size: 14px; font-weight: 500; color: #d4d4d8; }
|
||||
|
||||
.top-bar__btn {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.top-bar__btn:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
.top-bar__btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #18181b, 0 0 0 4px #3b82f6;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ Cross-links:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
@@ -542,3 +543,89 @@ def test_rule9_prod_manual_deploy_allows_rollback_control(tmp_path):
|
||||
_write(tmp_path, "ok.yml", PROD_ROLLBACK_OK)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CI change detector fanout — workflow-only PRs keep required contexts without
|
||||
# running Go/Canvas/Python/shellcheck heavy steps.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CI_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "ci.yml"
|
||||
CI_SURFACES = ("platform", "canvas", "python", "scripts")
|
||||
|
||||
|
||||
def _ci_change_patterns() -> dict[str, re.Pattern[str]]:
|
||||
text = CI_WORKFLOW.read_text(encoding="utf-8")
|
||||
patterns: dict[str, re.Pattern[str]] = {}
|
||||
for surface, pattern in re.findall(
|
||||
r'echo "(platform|canvas|python|scripts)=.*?grep -qE \'([^\']+)\'',
|
||||
text,
|
||||
):
|
||||
patterns[surface] = re.compile(pattern)
|
||||
assert set(patterns) == set(CI_SURFACES)
|
||||
return patterns
|
||||
|
||||
|
||||
def _classify_ci_change(*paths: str) -> dict[str, bool]:
|
||||
patterns = _ci_change_patterns()
|
||||
return {
|
||||
surface: any(pattern.search(path) for path in paths)
|
||||
for surface, pattern in patterns.items()
|
||||
}
|
||||
|
||||
|
||||
def test_ci_change_detector_workflow_only_edits_do_not_trigger_heavy_surfaces():
|
||||
assert _classify_ci_change(".gitea/workflows/ci.yml") == {
|
||||
"platform": False,
|
||||
"canvas": False,
|
||||
"python": False,
|
||||
"scripts": False,
|
||||
}
|
||||
assert _classify_ci_change(".github/workflows/ci.yml") == {
|
||||
"platform": False,
|
||||
"canvas": False,
|
||||
"python": False,
|
||||
"scripts": False,
|
||||
}
|
||||
|
||||
|
||||
def test_ci_change_detector_narrow_surface_edits_only_trigger_their_surface():
|
||||
assert _classify_ci_change("workspace-server/internal/handlers/foo.go") == {
|
||||
"platform": True,
|
||||
"canvas": False,
|
||||
"python": False,
|
||||
"scripts": False,
|
||||
}
|
||||
assert _classify_ci_change("canvas/app/page.tsx") == {
|
||||
"platform": False,
|
||||
"canvas": True,
|
||||
"python": False,
|
||||
"scripts": False,
|
||||
}
|
||||
assert _classify_ci_change("workspace/a2a_mcp_server.py") == {
|
||||
"platform": False,
|
||||
"canvas": False,
|
||||
"python": True,
|
||||
"scripts": False,
|
||||
}
|
||||
assert _classify_ci_change("tests/e2e/test_model_slug.sh") == {
|
||||
"platform": False,
|
||||
"canvas": False,
|
||||
"python": False,
|
||||
"scripts": True,
|
||||
}
|
||||
|
||||
|
||||
def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
|
||||
assert _classify_ci_change("README.md") == {
|
||||
"platform": False,
|
||||
"canvas": False,
|
||||
"python": False,
|
||||
"scripts": False,
|
||||
}
|
||||
assert _classify_ci_change(".gitea/scripts/lint-workflow-yaml.py") == {
|
||||
"platform": False,
|
||||
"canvas": False,
|
||||
"python": False,
|
||||
"scripts": False,
|
||||
}
|
||||
|
||||
@@ -110,6 +110,13 @@ AGENT_LOGIN_MAP = {
|
||||
"offsec": "core-offsec",
|
||||
}
|
||||
|
||||
# Map alternate Gitea logins → canonical logins for gate matching.
|
||||
# infra-sre is the engineers/core-devops agent (same team, same work).
|
||||
# Without this alias, infra-sre comments/reviews never satisfy the engineers gate.
|
||||
LOGIN_ALIASES = {
|
||||
"infra-sre": "core-devops",
|
||||
}
|
||||
|
||||
# SOP-6 tier → required agent groups
|
||||
# tier:low → engineers,managers,ceo (OR: any one suffices)
|
||||
# tier:medium → managers AND engineers AND qa,security (AND)
|
||||
@@ -168,17 +175,18 @@ def signal_1_comment_scan(pr_number: int, repo: str) -> dict:
|
||||
except GiteaError:
|
||||
pass
|
||||
|
||||
# Collect APPROVED reviews from agent logins
|
||||
# Collect APPROVED reviews from agent logins (resolving LOGIN_ALIASES)
|
||||
try:
|
||||
reviews = api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/reviews")
|
||||
for r in reviews:
|
||||
login = r.get("user", {}).get("login", "")
|
||||
if login in login_to_group and r.get("state") == "APPROVED":
|
||||
canonical = LOGIN_ALIASES.get(login, login)
|
||||
if canonical in login_to_group and r.get("state") == "APPROVED":
|
||||
comments.append(
|
||||
{
|
||||
"id": f"review-{r['id']}",
|
||||
"user": {"login": login},
|
||||
"body": f"[{login}-agent] APPROVED",
|
||||
"user": {"login": canonical},
|
||||
"body": f"[{canonical}-agent] APPROVED",
|
||||
"created_at": r.get("submitted_at") or r.get("created_at", ""),
|
||||
"source": "review",
|
||||
}
|
||||
@@ -193,6 +201,8 @@ def signal_1_comment_scan(pr_number: int, repo: str) -> dict:
|
||||
for c in comments:
|
||||
body = c.get("body", "") or ""
|
||||
user_login = c.get("user", {}).get("login", "")
|
||||
# Resolve LOGIN_ALIASES so alternate logins satisfy the canonical gate
|
||||
user_login = LOGIN_ALIASES.get(user_login, user_login)
|
||||
if user_login != login:
|
||||
continue
|
||||
for m in AGENT_TAG_RE.finditer(body):
|
||||
@@ -488,6 +498,21 @@ def run(repo: str, pr_number: int, post_comment: bool = False) -> dict:
|
||||
owner, name = repo.split("/", 1)
|
||||
pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
|
||||
base_ref = pr.get("base", {}).get("ref", "main")
|
||||
default_branch = os.environ.get("DEFAULT_BRANCH", "main")
|
||||
if base_ref != default_branch:
|
||||
result = {
|
||||
"verdict": "CLEAR",
|
||||
"repo": repo,
|
||||
"pr": pr_number,
|
||||
"skipped": True,
|
||||
"reason": (
|
||||
f"PR targets {base_ref}, not protected default branch "
|
||||
f"{default_branch}"
|
||||
),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
return result
|
||||
|
||||
gates = [
|
||||
signal_1_comment_scan(pr_number, repo),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).with_name("gate_check.py")
|
||||
|
||||
|
||||
def load_gate_check():
|
||||
spec = importlib.util.spec_from_file_location("gate_check", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_run_skips_pr_not_targeting_default_branch(monkeypatch):
|
||||
mod = load_gate_check()
|
||||
|
||||
def fake_api_get(path):
|
||||
assert path == "/repos/molecule-ai/molecule-core/pulls/843"
|
||||
return {
|
||||
"number": 843,
|
||||
"base": {"ref": "staging"},
|
||||
"head": {"sha": "84b9ca3a129075b8d5159eda5e678f68be1af20f"},
|
||||
}
|
||||
|
||||
monkeypatch.setenv("DEFAULT_BRANCH", "main")
|
||||
monkeypatch.setattr(mod, "api_get", fake_api_get)
|
||||
|
||||
result = mod.run("molecule-ai/molecule-core", 843, post_comment=False)
|
||||
|
||||
assert result["verdict"] == "CLEAR"
|
||||
assert result["skipped"] is True
|
||||
assert "staging" in result["reason"]
|
||||
|
||||
|
||||
def test_signal_1_infra_sre_login_alias_resolved_to_core_devops(monkeypatch):
|
||||
"""infra-sre posts [devops-agent] APPROVED → engineers gate satisfied via LOGIN_ALIASES."""
|
||||
mod = load_gate_check()
|
||||
|
||||
def fake_api_get(path):
|
||||
# PR 900 has tier:low label
|
||||
if path == "/repos/molecule-ai/molecule-core/pulls/900":
|
||||
return {
|
||||
"number": 900,
|
||||
"labels": [{"name": "tier:low"}],
|
||||
}
|
||||
raise AssertionError(f"unexpected api_get: {path}")
|
||||
|
||||
def fake_api_list(path):
|
||||
if path == "/repos/molecule-ai/molecule-core/issues/900/comments":
|
||||
return []
|
||||
if path == "/repos/molecule-ai/molecule-core/pulls/900/comments":
|
||||
return []
|
||||
if path == "/repos/molecule-ai/molecule-core/pulls/900/reviews":
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"login": "infra-sre"},
|
||||
"state": "APPROVED",
|
||||
"submitted_at": "2026-05-13T10:00:00Z",
|
||||
}
|
||||
]
|
||||
raise AssertionError(f"unexpected api_list: {path}")
|
||||
|
||||
monkeypatch.setattr(mod, "api_get", fake_api_get)
|
||||
monkeypatch.setattr(mod, "api_list", fake_api_list)
|
||||
|
||||
result = mod.signal_1_comment_scan(900, "molecule-ai/molecule-core")
|
||||
|
||||
assert result["verdict"] == "CLEAR"
|
||||
assert result["signal"] == "agent_tag_comments"
|
||||
# infra-sre (aliased to core-devops) should satisfy engineers gate
|
||||
engineers = result["results"]["core-devops"]
|
||||
assert engineers["verdict"] == "APPROVED"
|
||||
assert engineers["group"] == "engineers"
|
||||
@@ -157,6 +157,16 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #831 bootstrap: if global_secrets has ADMIN_TOKEN=placeholder,
|
||||
// replace it with the real token from the environment. This fixes
|
||||
// workspaces provisioned before the correct value was seeded.
|
||||
// Only runs for SaaS tenants (cpProv != nil) where containers inherit
|
||||
// from global_secrets. Self-hosted deployments don't read ADMIN_TOKEN
|
||||
// from global_secrets for container env — the fix doesn't apply.
|
||||
if cpProv != nil {
|
||||
fixAdminTokenPlaceholder()
|
||||
}
|
||||
|
||||
port := envOr("PORT", "8080")
|
||||
platformURL := envOr("PLATFORM_URL", fmt.Sprintf("http://host.docker.internal:%s", port))
|
||||
configsDir := envOr("CONFIGS_DIR", findConfigsDir())
|
||||
@@ -483,3 +493,67 @@ func findMigrationsDir() string {
|
||||
log.Println("No migrations directory found")
|
||||
return ""
|
||||
}
|
||||
|
||||
// fixAdminTokenPlaceholder heals #831: workspaces provisioned with a placeholder
|
||||
// ADMIN_TOKEN in global_secrets receive that placeholder as a container env var,
|
||||
// breaking any code that calls platform APIs. This runs once at startup (SaaS only)
|
||||
// and replaces the placeholder with the real token from the host environment.
|
||||
//
|
||||
// The placeholder is not in the codebase — it was seeded by a prior bootstrap or
|
||||
// manual DB write. It should never be set by the platform itself. This function
|
||||
// ensures it is corrected on next platform restart without requiring a manual DB
|
||||
// update or workspace reprovision.
|
||||
func fixAdminTokenPlaceholder() {
|
||||
realToken := os.Getenv("ADMIN_TOKEN")
|
||||
if realToken == "" {
|
||||
// Platform has no ADMIN_TOKEN — nothing to fix.
|
||||
return
|
||||
}
|
||||
|
||||
// Read the current stored value. We only upsert when the placeholder is
|
||||
// present so we don't repeatedly write rows that are already correct.
|
||||
var storedValue []byte
|
||||
err := db.DB.QueryRow(`SELECT encrypted_value FROM global_secrets WHERE key = $1`, "ADMIN_TOKEN").Scan(&storedValue)
|
||||
if err != nil {
|
||||
// No row — nothing to fix. The control plane injects ADMIN_TOKEN via
|
||||
// Secrets Manager bootstrap; the global_secrets path is a legacy seed.
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt to check the value. We compare the plaintext so the check works
|
||||
// whether encryption is enabled or not.
|
||||
storedPlaintext, decErr := crypto.DecryptVersioned(storedValue, crypto.CurrentEncryptionVersion())
|
||||
if decErr != nil {
|
||||
log.Printf("fixAdminTokenPlaceholder: could not decrypt existing value (version mismatch?): %v", decErr)
|
||||
return
|
||||
}
|
||||
|
||||
if string(storedPlaintext) == realToken {
|
||||
// Already correct — nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
if string(storedPlaintext) == "placeholder-will-ask-for-real" {
|
||||
log.Println("fixAdminTokenPlaceholder: replacing placeholder ADMIN_TOKEN in global_secrets")
|
||||
} else {
|
||||
log.Printf("fixAdminTokenPlaceholder: ADMIN_TOKEN in global_secrets differs from env; updating")
|
||||
}
|
||||
|
||||
encrypted, err := crypto.Encrypt([]byte(realToken))
|
||||
if err != nil {
|
||||
log.Printf("fixAdminTokenPlaceholder: failed to encrypt: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(`
|
||||
INSERT INTO global_secrets (key, encrypted_value, encryption_version)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET encrypted_value = $2, encryption_version = $3, updated_at = now()
|
||||
`, "ADMIN_TOKEN", encrypted, crypto.CurrentEncryptionVersion())
|
||||
if err != nil {
|
||||
log.Printf("fixAdminTokenPlaceholder: failed to upsert: %v", err)
|
||||
return
|
||||
}
|
||||
log.Println("fixAdminTokenPlaceholder: done")
|
||||
}
|
||||
|
||||
@@ -57,16 +57,23 @@ func extractIdempotencyKey(body []byte) string {
|
||||
func extractExpiresInSeconds(body []byte) int {
|
||||
var envelope struct {
|
||||
Params struct {
|
||||
ExpiresInSeconds int `json:"expires_in_seconds"`
|
||||
ExpiresInSeconds interface{} `json:"expires_in_seconds"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return 0
|
||||
}
|
||||
if envelope.Params.ExpiresInSeconds < 0 {
|
||||
var seconds int
|
||||
switch v := envelope.Params.ExpiresInSeconds.(type) {
|
||||
case float64:
|
||||
seconds = int(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
return envelope.Params.ExpiresInSeconds
|
||||
if seconds < 0 {
|
||||
return 0
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/bundle"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
@@ -49,8 +50,8 @@ func (h *BundleHandler) Import(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bundle"})
|
||||
return
|
||||
}
|
||||
if b.Schema == "" || b.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bundle"})
|
||||
if strings.TrimSpace(b.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bundle name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ func TestBundleImport_ValidJSON(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
// bundle.Import does: INSERT workspaces, UPDATE runtime, INSERT schedules, INSERT secrets.
|
||||
// bundle.Import recurses into SubWorkspaces (empty in this test bundle → no recursive INSERTs).
|
||||
// bundle.Import does: INSERT workspaces, broadcast provisioning, then UPDATE runtime.
|
||||
// bundle.Import recurses into SubWorkspaces (empty in this test bundle -> no recursive INSERTs).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
|
||||
@@ -641,10 +641,100 @@ func (h *DelegationHandler) UpdateStatus(c *gin.Context) {
|
||||
|
||||
// ListDelegations handles GET /workspaces/:id/delegations
|
||||
// Returns recent delegations for a workspace with their status.
|
||||
//
|
||||
// RFC #2829 PR-1/4 fallback chain: prefer the durable delegations table
|
||||
// (new as of #318) for complete status coverage; fall back to
|
||||
// activity_logs for pre-migration data or if the ledger table has
|
||||
// no rows for this workspace. activity_logs still drives in-flight
|
||||
// tracking for workspaces where DELEGATION_LEDGER_WRITE=0 was
|
||||
// active during the delegation lifecycle — the union covers both paths.
|
||||
func (h *DelegationHandler) ListDelegations(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var delegations []map[string]interface{}
|
||||
|
||||
// Attempt durable ledger first (RFC #2829)
|
||||
delegations = h.listDelegationsFromLedger(ctx, workspaceID)
|
||||
if len(delegations) > 0 {
|
||||
c.JSON(http.StatusOK, delegations)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to activity_logs (pre-#318 path, or ledger had no rows)
|
||||
delegations = h.listDelegationsFromActivityLogs(ctx, workspaceID)
|
||||
c.JSON(http.StatusOK, delegations)
|
||||
}
|
||||
|
||||
// listDelegationsFromLedger queries the durable delegations table.
|
||||
// Returns nil on error so the caller can fall back to activity_logs.
|
||||
func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, workspaceID string) []map[string]interface{} {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview,
|
||||
d.status, d.result_preview, d.error_detail, d.last_heartbeat,
|
||||
d.deadline, d.created_at, d.updated_at
|
||||
FROM delegations d
|
||||
WHERE d.caller_id = $1
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 50
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
// Table may not exist yet (pre-migration), or permission issue.
|
||||
// Fall back silently — do not log to avoid noise on every call.
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var delegationID, callerID, calleeID, taskPreview, status, resultPreview, errorDetail string
|
||||
var lastHeartbeat, deadline, createdAt, updatedAt *time.Time
|
||||
if err := rows.Scan(
|
||||
&delegationID, &callerID, &calleeID, &taskPreview,
|
||||
&status, &resultPreview, &errorDetail, &lastHeartbeat,
|
||||
&deadline, &createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
entry := map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
"source_id": callerID,
|
||||
"target_id": calleeID,
|
||||
"summary": textutil.TruncateBytes(taskPreview, 200),
|
||||
"status": status,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
"_ledger": true, // marker so callers know this row is from the ledger
|
||||
}
|
||||
if resultPreview != "" {
|
||||
entry["response_preview"] = textutil.TruncateBytes(resultPreview, 300)
|
||||
}
|
||||
if errorDetail != "" {
|
||||
entry["error"] = errorDetail
|
||||
}
|
||||
if lastHeartbeat != nil {
|
||||
entry["last_heartbeat"] = lastHeartbeat
|
||||
}
|
||||
if deadline != nil {
|
||||
entry["deadline"] = deadline
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("listDelegationsFromLedger rows.Err: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// listDelegationsFromActivityLogs is the legacy path that reconstructs
|
||||
// delegation state by folding activity_logs rows by delegation_id.
|
||||
// Kept for backward compatibility and for workspaces that never had
|
||||
// DELEGATION_LEDGER_WRITE=1 during their delegation lifecycle.
|
||||
func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context, workspaceID string) []map[string]interface{} {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT id, activity_type, COALESCE(source_id::text, ''), COALESCE(target_id::text, ''),
|
||||
COALESCE(summary, ''), COALESCE(status, ''), COALESCE(error_detail, ''),
|
||||
@@ -657,12 +747,11 @@ func (h *DelegationHandler) ListDelegations(c *gin.Context) {
|
||||
LIMIT 50
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var delegations []map[string]interface{}
|
||||
var result []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, actType, sourceID, targetID, summary, status, errorDetail, responseBody, delegationID string
|
||||
var createdAt time.Time
|
||||
@@ -687,16 +776,16 @@ func (h *DelegationHandler) ListDelegations(c *gin.Context) {
|
||||
if responseBody != "" {
|
||||
entry["response_preview"] = textutil.TruncateBytes(responseBody, 300)
|
||||
}
|
||||
delegations = append(delegations, entry)
|
||||
result = append(result, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListDelegations rows.Err: %v", err)
|
||||
}
|
||||
|
||||
if delegations == nil {
|
||||
delegations = []map[string]interface{}{}
|
||||
if result == nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
c.JSON(http.StatusOK, delegations)
|
||||
return result
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
@@ -52,9 +52,9 @@ import (
|
||||
// integrationDB is imported from delegation_ledger_integration_test.go.
|
||||
// Each test gets a fresh table state.
|
||||
|
||||
const testDelegationID = "del-159-test-integration"
|
||||
const testSourceID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
const testTargetID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
const integrationTestDelegationID = "del-159-test-integration"
|
||||
const integrationTestSourceID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
const integrationTestTargetID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
|
||||
// rawHTTPServer starts a TCP listener, serves one HTTP response, and closes.
|
||||
// It runs in a background goroutine so the test can proceed immediately after
|
||||
@@ -153,8 +153,8 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
|
||||
name string
|
||||
parentID *string
|
||||
}{
|
||||
{testSourceID, "test-source", nil},
|
||||
{testTargetID, "test-target", nil},
|
||||
{integrationTestSourceID, "test-source", nil},
|
||||
{integrationTestTargetID, "test-target", nil},
|
||||
} {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO workspaces (id, name, parent_id) VALUES ($1::uuid, $2, $3) ON CONFLICT (id) DO NOTHING`,
|
||||
@@ -166,7 +166,7 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"delegation_id": testDelegationID,
|
||||
"delegation_id": integrationTestDelegationID,
|
||||
"task": "do work",
|
||||
})
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
@@ -174,7 +174,7 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
|
||||
(workspace_id, activity_type, method, source_id, target_id, request_body, status)
|
||||
VALUES ($1, 'delegate', 'delegate', $1, $2, $3::jsonb, 'pending')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, testSourceID, testTargetID, string(reqBody)); err != nil {
|
||||
`, integrationTestSourceID, integrationTestTargetID, string(reqBody)); err != nil {
|
||||
cancel()
|
||||
t.Fatalf("seed activity_logs: %v", err)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
|
||||
(delegation_id, caller_id, callee_id, task_preview, status)
|
||||
VALUES ($1, $2::uuid, $3::uuid, 'do work', 'queued')
|
||||
ON CONFLICT (delegation_id) DO NOTHING
|
||||
`, testDelegationID, testSourceID, testTargetID); err != nil {
|
||||
`, integrationTestDelegationID, integrationTestSourceID, integrationTestTargetID); err != nil {
|
||||
cancel()
|
||||
t.Fatalf("seed delegations: %v", err)
|
||||
}
|
||||
@@ -195,11 +195,11 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
|
||||
defer cancel2()
|
||||
conn.ExecContext(ctx2,
|
||||
`DELETE FROM activity_logs WHERE workspace_id = $1 AND request_body->>'delegation_id' = $2`,
|
||||
testSourceID, testDelegationID)
|
||||
integrationTestSourceID, integrationTestDelegationID)
|
||||
conn.ExecContext(ctx2,
|
||||
`DELETE FROM delegations WHERE delegation_id = $1`, testDelegationID)
|
||||
`DELETE FROM delegations WHERE delegation_id = $1`, integrationTestDelegationID)
|
||||
conn.ExecContext(ctx2,
|
||||
`DELETE FROM workspaces WHERE id IN ($1, $2)`, testSourceID, testTargetID)
|
||||
`DELETE FROM workspaces WHERE id IN ($1, $2)`, integrationTestSourceID, integrationTestTargetID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ func readDelegationRow(t *testing.T, conn *sql.DB) (status, preview, errorDetail
|
||||
var prev, errDet sql.NullString
|
||||
err := conn.QueryRowContext(ctx,
|
||||
`SELECT status, result_preview, error_detail FROM delegations WHERE delegation_id = $1`,
|
||||
testDelegationID,
|
||||
integrationTestDelegationID,
|
||||
).Scan(&status, &prev, &errDet)
|
||||
if err != nil {
|
||||
t.Fatalf("readDelegationRow: %v", err)
|
||||
@@ -279,7 +279,7 @@ func TestIntegration_ExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSucce
|
||||
|
||||
mr := setupTestRedis(t)
|
||||
defer mr.Close()
|
||||
db.CacheURL(context.Background(), testTargetID, agentURL)
|
||||
db.CacheURL(context.Background(), integrationTestTargetID, agentURL)
|
||||
|
||||
prevClient := a2aClient
|
||||
defer func() { a2aClient = prevClient }()
|
||||
@@ -303,7 +303,7 @@ func TestIntegration_ExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSucce
|
||||
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
|
||||
@@ -334,7 +334,7 @@ func TestIntegration_ExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing
|
||||
|
||||
mr := setupTestRedis(t)
|
||||
defer mr.Close()
|
||||
db.CacheURL(context.Background(), testTargetID, agentURL)
|
||||
db.CacheURL(context.Background(), integrationTestTargetID, agentURL)
|
||||
|
||||
prevClient := a2aClient
|
||||
defer func() { a2aClient = prevClient }()
|
||||
@@ -355,7 +355,7 @@ func TestIntegration_ExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing
|
||||
})
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
|
||||
@@ -383,7 +383,7 @@ func TestIntegration_ExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *test
|
||||
|
||||
mr := setupTestRedis(t)
|
||||
defer mr.Close()
|
||||
db.CacheURL(context.Background(), testTargetID, agentURL)
|
||||
db.CacheURL(context.Background(), integrationTestTargetID, agentURL)
|
||||
|
||||
prevClient := a2aClient
|
||||
defer func() { a2aClient = prevClient }()
|
||||
@@ -404,7 +404,7 @@ func TestIntegration_ExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *test
|
||||
})
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
|
||||
@@ -431,7 +431,7 @@ func TestIntegration_ExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T
|
||||
|
||||
mr := setupTestRedis(t)
|
||||
defer mr.Close()
|
||||
db.CacheURL(context.Background(), testTargetID, agentURL)
|
||||
db.CacheURL(context.Background(), integrationTestTargetID, agentURL)
|
||||
|
||||
prevClient := a2aClient
|
||||
defer func() { a2aClient = prevClient }()
|
||||
@@ -452,7 +452,7 @@ func TestIntegration_ExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T
|
||||
})
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
|
||||
@@ -497,7 +497,7 @@ func TestIntegration_ExecuteDelegation_RedisDown_FallsBackToDB(t *testing.T) {
|
||||
})
|
||||
start := time.Now()
|
||||
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
|
||||
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
|
||||
})
|
||||
t.Logf("executeDelegation took %v", time.Since(start))
|
||||
|
||||
|
||||
@@ -233,14 +233,21 @@ func TestListDelegations_Empty(t *testing.T) {
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
})
|
||||
// Ledger returns empty → falls back to activity_logs (also empty)
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}))
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(rows)
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -260,9 +267,12 @@ func TestListDelegations_Empty(t *testing.T) {
|
||||
if len(resp) != 0 {
|
||||
t.Errorf("expected empty array, got %d entries", len(resp))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: with results → 200 with entries ----------
|
||||
// ---------- ListDelegations: with results (ledger only, no activity_logs fallback) ----------
|
||||
|
||||
func TestListDelegations_WithResults(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
@@ -272,19 +282,21 @@ func TestListDelegations_WithResults(t *testing.T) {
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
now := time.Now()
|
||||
deadline := now.Add(6 * time.Hour)
|
||||
// Ledger query returns rows — no fallback to activity_logs
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}).
|
||||
AddRow("1", "delegation", "ws-source", "ws-target",
|
||||
AddRow("del-111", "ws-source", "ws-target",
|
||||
"Delegating to ws-target", "pending", "", "",
|
||||
"del-111", now).
|
||||
AddRow("2", "delegation", "ws-source", "ws-target",
|
||||
"Delegation completed (hello world)", "completed", "", "hello world",
|
||||
"del-111", now.Add(time.Minute))
|
||||
&now, &deadline, now, now).
|
||||
AddRow("del-222", "ws-source", "ws-target",
|
||||
"Delegation completed (hello world)", "completed", "hello world", "",
|
||||
&now, &deadline, now, now.Add(time.Minute))
|
||||
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(rows)
|
||||
|
||||
@@ -308,23 +320,26 @@ func TestListDelegations_WithResults(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check first entry (pending delegation)
|
||||
if resp[0]["type"] != "delegation" {
|
||||
t.Errorf("expected type 'delegation', got %v", resp[0]["type"])
|
||||
if resp[0]["delegation_id"] != "del-111" {
|
||||
t.Errorf("expected delegation_id 'del-111', got %v", resp[0]["delegation_id"])
|
||||
}
|
||||
if resp[0]["status"] != "pending" {
|
||||
t.Errorf("expected status 'pending', got %v", resp[0]["status"])
|
||||
}
|
||||
if resp[0]["delegation_id"] != "del-111" {
|
||||
t.Errorf("expected delegation_id 'del-111', got %v", resp[0]["delegation_id"])
|
||||
}
|
||||
if resp[0]["source_id"] != "ws-source" {
|
||||
t.Errorf("expected source_id 'ws-source', got %v", resp[0]["source_id"])
|
||||
}
|
||||
if resp[0]["target_id"] != "ws-target" {
|
||||
t.Errorf("expected target_id 'ws-target', got %v", resp[0]["target_id"])
|
||||
}
|
||||
if resp[0]["_ledger"] != true {
|
||||
t.Errorf("expected _ledger=true marker, got %v", resp[0]["_ledger"])
|
||||
}
|
||||
|
||||
// Check second entry (completed, has response_preview)
|
||||
if resp[1]["delegation_id"] != "del-222" {
|
||||
t.Errorf("expected delegation_id 'del-222', got %v", resp[1]["delegation_id"])
|
||||
}
|
||||
if resp[1]["status"] != "completed" {
|
||||
t.Errorf("expected status 'completed', got %v", resp[1]["status"])
|
||||
}
|
||||
@@ -471,11 +486,11 @@ func TestDelegationRecord_InsertsActivityLogRow(t *testing.T) {
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(
|
||||
"550e8400-e29b-41d4-a716-446655440000", // workspace_id
|
||||
"550e8400-e29b-41d4-a716-446655440000", // source_id
|
||||
"550e8400-e29b-41d4-a716-446655440001", // target_id
|
||||
"Delegating to 550e8400-e29b-41d4-a716-446655440001", // summary
|
||||
sqlmock.AnyArg(), // request_body (jsonb)
|
||||
"550e8400-e29b-41d4-a716-446655440000", // workspace_id
|
||||
"550e8400-e29b-41d4-a716-446655440000", // source_id
|
||||
"550e8400-e29b-41d4-a716-446655440001", // target_id
|
||||
"Delegating to 550e8400-e29b-41d4-a716-446655440001", // summary
|
||||
sqlmock.AnyArg(), // request_body (jsonb)
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// RecordAndBroadcast INSERT for DELEGATION_SENT
|
||||
@@ -970,9 +985,9 @@ func TestInsertDelegationOutcome_ZeroValueIsUnknown(t *testing.T) {
|
||||
// Test strategy: spin up a mock A2A agent server, set up the source/target DB rows, call
|
||||
// executeDelegation directly, and verify the activity_logs status and delegation status.
|
||||
|
||||
const testDelegationID = "del-159-test"
|
||||
const testSourceID = "ws-source-159"
|
||||
const testTargetID = "ws-target-159"
|
||||
const testDeliveryDelegationID = "del-159-test"
|
||||
const testDeliverySourceID = "ws-source-159"
|
||||
const testDeliveryTargetID = "ws-target-159"
|
||||
|
||||
// expectExecuteDelegationBase sets up sqlmock expectations for the DB queries that
|
||||
// executeDelegation always makes, regardless of outcome.
|
||||
@@ -980,17 +995,17 @@ func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
|
||||
// updateDelegationStatus: dispatched
|
||||
// Uses prefix match — sqlmock regexes match the full query string.
|
||||
mock.ExpectExec("UPDATE activity_logs SET status").
|
||||
WithArgs("dispatched", "", testSourceID, testDelegationID).
|
||||
WithArgs("dispatched", "", testDeliverySourceID, testDeliveryDelegationID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// CanCommunicate: getWorkspaceRef(source) + getWorkspaceRef(target).
|
||||
// Both are root-level workspaces (parent_id=NULL) → root-level siblings → allowed.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(testSourceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testSourceID, nil))
|
||||
WithArgs(testDeliverySourceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliverySourceID, nil))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(testTargetID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testTargetID, nil))
|
||||
WithArgs(testDeliveryTargetID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliveryTargetID, nil))
|
||||
|
||||
// resolveAgentURL: test callers always set the URL in Redis (mr.Set ws:{id}:url),
|
||||
// so resolveAgentURL gets a cache hit and never falls back to DB.
|
||||
@@ -1009,7 +1024,7 @@ func expectExecuteDelegationSuccess(mock sqlmock.Sqlmock, respBody string) {
|
||||
|
||||
// updateDelegationStatus: completed
|
||||
mock.ExpectExec("UPDATE activity_logs SET status").
|
||||
WithArgs("completed", "", testSourceID, testDelegationID).
|
||||
WithArgs("completed", "", testDeliverySourceID, testDeliveryDelegationID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
}
|
||||
|
||||
@@ -1018,7 +1033,7 @@ func expectExecuteDelegationSuccess(mock sqlmock.Sqlmock, respBody string) {
|
||||
func expectExecuteDelegationFailed(mock sqlmock.Sqlmock) {
|
||||
// updateDelegationStatus: failed (fires before the INSERT in the failure path)
|
||||
mock.ExpectExec("UPDATE activity_logs SET status").
|
||||
WithArgs("failed", sqlmock.AnyArg(), testSourceID, testDelegationID).
|
||||
WithArgs("failed", sqlmock.AnyArg(), testDeliverySourceID, testDeliveryDelegationID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// INSERT activity_logs for delegation failure ('failed' is a SQL literal, not a param)
|
||||
@@ -1085,7 +1100,7 @@ func TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess(t *testin
|
||||
}()
|
||||
|
||||
agentURL := "http://" + ln.Addr().String()
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testTargetID), agentURL)
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testDeliveryTargetID), agentURL)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
expectExecuteDelegationBase(mock)
|
||||
@@ -1104,7 +1119,7 @@ func TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess(t *testin
|
||||
},
|
||||
},
|
||||
})
|
||||
dh.executeDelegation(testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
dh.executeDelegation(context.Background(), testDeliverySourceID, testDeliveryTargetID, testDeliveryDelegationID, a2aBody)
|
||||
|
||||
time.Sleep(100 * time.Millisecond) // let DB writes settle
|
||||
|
||||
@@ -1155,7 +1170,7 @@ func TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing.T) {
|
||||
}()
|
||||
|
||||
agentURL := "http://" + ln.Addr().String()
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testTargetID), agentURL)
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testDeliveryTargetID), agentURL)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
expectExecuteDelegationBase(mock)
|
||||
@@ -1170,7 +1185,7 @@ func TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
dh.executeDelegation(testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
dh.executeDelegation(context.Background(), testDeliverySourceID, testDeliveryTargetID, testDeliveryDelegationID, a2aBody)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
@@ -1201,7 +1216,7 @@ func TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *testing.T) {
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testTargetID), agentServer.URL)
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testDeliveryTargetID), agentServer.URL)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
// executeDelegationBase: UPDATE dispatched + CanCommunicate SELECTs
|
||||
@@ -1220,7 +1235,7 @@ func TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
dh.executeDelegation(testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
dh.executeDelegation(context.Background(), testDeliverySourceID, testDeliveryTargetID, testDeliveryDelegationID, a2aBody)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
@@ -1248,7 +1263,7 @@ func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testTargetID), agentServer.URL)
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testDeliveryTargetID), agentServer.URL)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
expectExecuteDelegationBase(mock)
|
||||
@@ -1263,7 +1278,7 @@ func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
dh.executeDelegation(testSourceID, testTargetID, testDelegationID, a2aBody)
|
||||
dh.executeDelegation(context.Background(), testDeliverySourceID, testDeliveryTargetID, testDeliveryDelegationID, a2aBody)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
@@ -1271,3 +1286,407 @@ func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- extractResponseText ----------
|
||||
|
||||
func TestExtractResponseText_NonJSON(t *testing.T) {
|
||||
got := extractResponseText([]byte("not json at all"))
|
||||
if got != "not json at all" {
|
||||
t.Errorf("non-JSON: got %q, want %q", got, "not json at all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ValidJSONNoResult(t *testing.T) {
|
||||
got := extractResponseText([]byte(`{"id":"1","error":{"code":-32601,"message":"method not found"}}`))
|
||||
if got != `{"id":"1","error":{"code":-32601,"message":"method not found"}}` {
|
||||
t.Errorf("no result key: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractResponseText_* cases live in delegation_extract_response_text_test.go
|
||||
// to keep pure-helper tests in their own file.
|
||||
|
||||
func TestExtractResponseText_PartsTextKind(t *testing.T) {
|
||||
body := []byte(`{"result":{"parts":[{"kind":"text","text":"Hello from agent"}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != "Hello from agent" {
|
||||
t.Errorf("parts text: got %q, want %q", got, "Hello from agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_PartsNonTextKind(t *testing.T) {
|
||||
// kind="image" is skipped; falls through to raw body since no artifacts
|
||||
body := []byte(`{"result":{"parts":[{"kind":"image","text":"should not return"}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != string(body) {
|
||||
t.Errorf("parts non-text: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_PartsMultipleWithTextFirst(t *testing.T) {
|
||||
body := []byte(`{"result":{"parts":[{"kind":"text","text":"first"},{"kind":"text","text":"second"}]}}`)
|
||||
got := extractResponseText(body)
|
||||
// Returns first text part found
|
||||
if got != "first" {
|
||||
t.Errorf("parts first match: got %q, want %q", got, "first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ArtifactsTextKind(t *testing.T) {
|
||||
body := []byte(`{"result":{"artifacts":[{"parts":[{"kind":"text","text":"artifact text here"}]}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != "artifact text here" {
|
||||
t.Errorf("artifacts text: got %q, want %q", got, "artifact text here")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ArtifactsNonTextKind(t *testing.T) {
|
||||
body := []byte(`{"result":{"artifacts":[{"parts":[{"kind":"image","text":"hidden"}]}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != string(body) {
|
||||
t.Errorf("artifacts non-text: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_EmptyPartsAndArtifacts(t *testing.T) {
|
||||
body := []byte(`{"result":{"parts":[],"artifacts":[]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != string(body) {
|
||||
t.Errorf("empty parts/artifacts: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_EmptyText(t *testing.T) {
|
||||
body := []byte(`{"result":{"parts":[{"kind":"text","text":""}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != "" {
|
||||
t.Errorf("empty text: got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger has rows → returns them (no activity_logs fallback) ----------
|
||||
|
||||
func TestListDelegations_LedgerRowsReturned(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
now := time.Now()
|
||||
deadline := now.Add(6 * time.Hour)
|
||||
// Ledger query returns rows
|
||||
ledgerRows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
"del-ledger-001", "caller-uuid", "callee-uuid",
|
||||
"Analyze the codebase for bugs", "in_progress", "", "",
|
||||
&now, &deadline, now, now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("caller-uuid").
|
||||
WillReturnRows(ledgerRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "caller-uuid"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/caller-uuid/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(resp))
|
||||
}
|
||||
if resp[0]["delegation_id"] != "del-ledger-001" {
|
||||
t.Errorf("expected delegation_id 'del-ledger-001', got %v", resp[0]["delegation_id"])
|
||||
}
|
||||
if resp[0]["status"] != "in_progress" {
|
||||
t.Errorf("expected status 'in_progress', got %v", resp[0]["status"])
|
||||
}
|
||||
if resp[0]["_ledger"] != true {
|
||||
t.Errorf("expected _ledger=true marker, got %v", resp[0]["_ledger"])
|
||||
}
|
||||
if resp[0]["source_id"] != "caller-uuid" {
|
||||
t.Errorf("expected source_id 'caller-uuid', got %v", resp[0]["source_id"])
|
||||
}
|
||||
if resp[0]["target_id"] != "callee-uuid" {
|
||||
t.Errorf("expected target_id 'callee-uuid', got %v", resp[0]["target_id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger empty → falls back to activity_logs ----------
|
||||
|
||||
func TestListDelegations_LedgerEmptyFallsBackToActivityLogs(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Ledger returns empty → falls back to activity_logs
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}))
|
||||
|
||||
now := time.Now()
|
||||
activityRows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
}).AddRow(
|
||||
"act-001", "delegation", "ws-source", "ws-target",
|
||||
"Delegating to ws-target", "pending", "", "",
|
||||
"del-old-001", now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(activityRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-source/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 entry from fallback, got %d", len(resp))
|
||||
}
|
||||
if resp[0]["delegation_id"] != "del-old-001" {
|
||||
t.Errorf("expected delegation_id 'del-old-001' from activity_logs, got %v", resp[0]["delegation_id"])
|
||||
}
|
||||
if resp[0]["type"] != "delegation" {
|
||||
t.Errorf("expected type 'delegation' from activity_logs, got %v", resp[0]["type"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: both ledger and activity_logs empty → [] ----------
|
||||
|
||||
func TestListDelegations_BothEmptyReturnsEmptyArray(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Ledger empty
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}))
|
||||
// activity_logs also empty
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-source/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 0 {
|
||||
t.Errorf("expected empty array, got %d entries", len(resp))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger query error → falls back to activity_logs ----------
|
||||
|
||||
func TestListDelegations_LedgerQueryErrorFallsBackToActivityLogs(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Ledger query fails → fallback to activity_logs
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("ws-source").
|
||||
WillReturnError(fmt.Errorf("table does not exist"))
|
||||
|
||||
now := time.Now()
|
||||
activityRows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
}).AddRow(
|
||||
"act-002", "delegation", "ws-source", "ws-target",
|
||||
"Some task", "completed", "", "result here",
|
||||
"del-pre-318", now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(activityRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-source/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 || resp[0]["delegation_id"] != "del-pre-318" {
|
||||
t.Errorf("expected 1 activity_logs entry, got %v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger completed delegation includes result_preview ----------
|
||||
|
||||
func TestListDelegations_LedgerCompletedIncludesResultPreview(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
now := time.Now()
|
||||
deadline := now.Add(6 * time.Hour)
|
||||
ledgerRows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
"del-complete-001", "caller-uuid", "callee-uuid",
|
||||
"Run analysis", "completed", "Analysis complete: 42 issues found", "",
|
||||
&now, &deadline, now, now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("caller-uuid").
|
||||
WillReturnRows(ledgerRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "caller-uuid"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/caller-uuid/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(resp))
|
||||
}
|
||||
if resp[0]["status"] != "completed" {
|
||||
t.Errorf("expected status 'completed', got %v", resp[0]["status"])
|
||||
}
|
||||
if resp[0]["response_preview"] != "Analysis complete: 42 issues found" {
|
||||
t.Errorf("expected response_preview, got %v", resp[0]["response_preview"])
|
||||
}
|
||||
if resp[0]["error"] != nil {
|
||||
t.Errorf("expected no error on completed, got %v", resp[0]["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger failed delegation includes error_detail ----------
|
||||
|
||||
func TestListDelegations_LedgerFailedIncludesErrorDetail(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
now := time.Now()
|
||||
deadline := now.Add(6 * time.Hour)
|
||||
ledgerRows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
"del-failed-001", "caller-uuid", "callee-uuid",
|
||||
"Fetch data", "failed", "", "Callee workspace not reachable",
|
||||
&now, &deadline, now, now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("caller-uuid").
|
||||
WillReturnRows(ledgerRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "caller-uuid"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/caller-uuid/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(resp))
|
||||
}
|
||||
if resp[0]["status"] != "failed" {
|
||||
t.Errorf("expected status 'failed', got %v", resp[0]["status"])
|
||||
}
|
||||
if resp[0]["error"] != "Callee workspace not reachable" {
|
||||
t.Errorf("expected error detail, got %v", resp[0]["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,14 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if wsDir, ok := body["workspace_dir"]; ok && wsDir != nil {
|
||||
if dirStr, isStr := wsDir.(string); isStr && dirStr != "" {
|
||||
if err := validateWorkspaceDir(dirStr); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace directory"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
|
||||
@@ -34,19 +34,28 @@ func setupWorkspaceCrudTest(t *testing.T) (sqlmock.Sqlmock, *gin.Engine) {
|
||||
return mock, r
|
||||
}
|
||||
|
||||
func newWorkspaceCrudHandler(t *testing.T) *WorkspaceHandler {
|
||||
t.Helper()
|
||||
return NewWorkspaceHandler(nil, nil, "", t.TempDir())
|
||||
}
|
||||
|
||||
func expectWorkspaceLiveTokenCount(mock sqlmock.Sqlmock, count int) {
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(count))
|
||||
}
|
||||
|
||||
// ---------- State ----------
|
||||
|
||||
func TestState_LegacyWorkspaceNoLiveToken(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
// No live token — legacy workspace, no auth required.
|
||||
// HasAnyLiveToken always runs first (queries workspace_auth_tokens).
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
expectWorkspaceLiveTokenCount(mock, 0)
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("running"))
|
||||
@@ -76,13 +85,12 @@ func TestState_LegacyWorkspaceNoLiveToken(t *testing.T) {
|
||||
|
||||
func TestState_HasLiveTokenMissingAuth(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
expectWorkspaceLiveTokenCount(mock, 1)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
// No Authorization header
|
||||
@@ -96,13 +104,12 @@ func TestState_HasLiveTokenMissingAuth(t *testing.T) {
|
||||
|
||||
func TestState_WorkspaceNotFound(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
expectWorkspaceLiveTokenCount(mock, 0)
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
@@ -126,13 +133,12 @@ func TestState_WorkspaceNotFound(t *testing.T) {
|
||||
|
||||
func TestState_WorkspaceSoftDeleted(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
expectWorkspaceLiveTokenCount(mock, 0)
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("removed"))
|
||||
@@ -159,13 +165,12 @@ func TestState_WorkspaceSoftDeleted(t *testing.T) {
|
||||
|
||||
func TestState_QueryError(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
expectWorkspaceLiveTokenCount(mock, 0)
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
@@ -182,8 +187,8 @@ func TestState_QueryError(t *testing.T) {
|
||||
// ---------- Update ----------
|
||||
|
||||
func TestUpdate_InvalidUUID(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -200,8 +205,8 @@ func TestUpdate_InvalidUUID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_InvalidBody(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -216,8 +221,8 @@ func TestUpdate_InvalidBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceNotFound(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -240,8 +245,8 @@ func TestUpdate_WorkspaceNotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_NameTooLong(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -262,8 +267,8 @@ func TestUpdate_NameTooLong(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_RoleTooLong(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -284,8 +289,8 @@ func TestUpdate_RoleTooLong(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_NameWithNewline(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -302,8 +307,8 @@ func TestUpdate_NameWithNewline(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_NameWithYAMLSpecialChars(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -320,8 +325,8 @@ func TestUpdate_NameWithYAMLSpecialChars(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirSystemPath(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -338,8 +343,8 @@ func TestUpdate_WorkspaceDirSystemPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirTraversal(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -356,8 +361,8 @@ func TestUpdate_WorkspaceDirTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirRelativePath(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
@@ -376,8 +381,8 @@ func TestUpdate_WorkspaceDirRelativePath(t *testing.T) {
|
||||
// ---------- Delete ----------
|
||||
|
||||
func TestDelete_InvalidUUID(t *testing.T) {
|
||||
_, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
@@ -391,8 +396,8 @@ func TestDelete_InvalidUUID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
@@ -425,8 +430,8 @@ func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDelete_ChildrenCheckQueryError(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, nil, nil)
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := newWorkspaceCrudHandler(t)
|
||||
r2 := gin.New()
|
||||
r2.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ func (s *Store) PatchNamespace(ctx context.Context, name string, body contract.N
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("metadata = $%d", idx))
|
||||
args = append(args, metadata)
|
||||
idx++ // advance so subsequent fields (if any) get correct positional index
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE memory_namespaces SET %s
|
||||
|
||||
@@ -167,13 +167,25 @@ type cpProvisionResponse struct {
|
||||
|
||||
// Start provisions a workspace by calling the control plane → EC2.
|
||||
func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, error) {
|
||||
// Inject ADMIN_TOKEN into the workspace container env so the agent can call
|
||||
// /admin/liveness and other admin-gated platform endpoints (core#831).
|
||||
// p.adminToken is read from os.Getenv("ADMIN_TOKEN") at provisioner creation;
|
||||
// it is also used for CP→platform HTTP auth but those are separate concerns.
|
||||
env := cfg.EnvVars
|
||||
if p.adminToken != "" {
|
||||
env = make(map[string]string, len(cfg.EnvVars)+1)
|
||||
for k, v := range cfg.EnvVars {
|
||||
env[k] = v
|
||||
}
|
||||
env["ADMIN_TOKEN"] = p.adminToken
|
||||
}
|
||||
req := cpProvisionRequest{
|
||||
OrgID: p.orgID,
|
||||
WorkspaceID: cfg.WorkspaceID,
|
||||
Runtime: cfg.Runtime,
|
||||
Tier: cfg.Tier,
|
||||
PlatformURL: cfg.PlatformURL,
|
||||
Env: cfg.EnvVars,
|
||||
Env: env,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
|
||||
@@ -627,6 +627,12 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
|
||||
for k, v := range cfg.EnvVars {
|
||||
env = append(env, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
// Inject ADMIN_TOKEN from the platform server's environment so workspace
|
||||
// containers can call /admin/liveness and other admin-gated endpoints
|
||||
// (core#831). cp_provisioner.go handles this separately for SaaS tenants.
|
||||
if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" {
|
||||
env = append(env, fmt.Sprintf("ADMIN_TOKEN=%s", adminToken))
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,15 @@ VOLUME /configs
|
||||
VOLUME /workspace
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# HEALTHCHECK: probe the A2A agent-card endpoint so orchestrators and
|
||||
# container runtimes can detect a live, responsive workspace agent.
|
||||
# Uses curl (present in python:3.11-slim base) against the uvicorn server.
|
||||
# PORT is injected at runtime via the molecule-runtime entrypoint; the
|
||||
# default matches EXPOSE.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -sf http://localhost:${PORT:-8000}/agent/card >/dev/null || exit 1
|
||||
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
# Start as root — entrypoint fixes volume permissions then drops to agent
|
||||
CMD ["./entrypoint.sh"]
|
||||
|
||||
+148
-7
@@ -12,12 +12,14 @@ Environment variables (set by the workspace container):
|
||||
PLATFORM_URL — platform API base URL (e.g. http://platform:8080)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Callable
|
||||
|
||||
# Top-level (not inside main()) so the wheel rewriter expands this to
|
||||
@@ -825,24 +827,163 @@ async def main(): # pragma: no cover
|
||||
break
|
||||
|
||||
|
||||
def cli_main() -> None: # pragma: no cover
|
||||
"""Synchronous wrapper around the async MCP stdio loop.
|
||||
# --- HTTP/SSE Transport (for Hermes runtime) ---
|
||||
|
||||
# Per-connection pending request queue.
|
||||
# Maps connection-id → asyncio.Queue of JSON-RPC responses.
|
||||
_http_connection_queues: dict[str, asyncio.Queue] = {}
|
||||
_http_connection_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _handle_http_mcp(request) -> dict | None:
|
||||
"""Handle an incoming JSON-RPC request over HTTP. Returns the JSON-RPC response dict, or None for notifications."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
|
||||
|
||||
req_id = body.get("id")
|
||||
method = body.get("method", "")
|
||||
|
||||
if method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": _build_initialize_result(),
|
||||
}
|
||||
elif method == "notifications/initialized":
|
||||
return None # No response needed
|
||||
elif method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
|
||||
elif method == "tools/call":
|
||||
params = body.get("params", {})
|
||||
tool_name = params.get("name", "")
|
||||
tool_args = params.get("arguments", {})
|
||||
result_text = await handle_tool_call(tool_name, tool_args)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": result_text}]},
|
||||
}
|
||||
else:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Method not found: {method}"}}
|
||||
|
||||
|
||||
async def _run_http_server(port: int) -> None:
|
||||
"""Run MCP server over HTTP/SSE — compatible with Hermes MCP-native agents."""
|
||||
try:
|
||||
from starlette.applications import Starlette # noqa: F401
|
||||
from starlette.routing import Route # noqa: F401
|
||||
from starlette.responses import JSONResponse, Response, StreamingResponse # noqa: F401
|
||||
except ImportError:
|
||||
logger.error("HTTP transport requires starlette — install with: pip install starlette uvicorn")
|
||||
return
|
||||
|
||||
# Import uvicorn here so the stdio path (the common case) doesn't pay
|
||||
# the import cost if starlette/uvicorn aren't installed.
|
||||
import uvicorn # noqa: F401
|
||||
|
||||
_http_connection_queues.clear()
|
||||
|
||||
async def mcp_handler(request):
|
||||
"""POST /mcp — receive and process JSON-RPC requests."""
|
||||
conn_id = request.headers.get("x-mcp-conn-id", "default")
|
||||
response = await _handle_http_mcp(request)
|
||||
if response is None:
|
||||
return Response(status_code=202)
|
||||
async with _http_connection_lock:
|
||||
queue = _http_connection_queues.get(conn_id)
|
||||
if queue is not None and not queue.full():
|
||||
await queue.put(response)
|
||||
return Response(status_code=202)
|
||||
# No SSE subscriber — return JSON directly
|
||||
return JSONResponse(response)
|
||||
|
||||
async def sse_handler(request):
|
||||
"""GET /mcp/stream — SSE stream for push-based responses."""
|
||||
conn_id = str(uuid.uuid4())
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
async with _http_connection_lock:
|
||||
_http_connection_queues[conn_id] = queue
|
||||
|
||||
async def event_stream():
|
||||
yield f"event: connected\ndata: {json.dumps({'conn_id': conn_id})}\n\n"
|
||||
try:
|
||||
while True:
|
||||
response = await asyncio.wait_for(queue.get(), timeout=300)
|
||||
yield f"event: message\ndata: {json.dumps(response)}\n\n"
|
||||
if queue.empty():
|
||||
yield "event: heartbeat\ndata: null\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
async with _http_connection_lock:
|
||||
_http_connection_queues.pop(conn_id, None)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
async def health_handler(_request):
|
||||
return JSONResponse({"ok": True, "transport": "http+sse", "port": port})
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/mcp", mcp_handler, methods=["POST"]),
|
||||
Route("/mcp/stream", sse_handler, methods=["GET"]),
|
||||
Route("/health", health_handler),
|
||||
]
|
||||
)
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
|
||||
server = uvicorn.Server(config)
|
||||
logger.info(f"A2A MCP HTTP server listening on http://127.0.0.1:{port}/mcp")
|
||||
await server.serve()
|
||||
|
||||
|
||||
def cli_main(transport: str = "stdio", port: int = 9100) -> None: # pragma: no cover
|
||||
"""Synchronous wrapper — selects stdio or HTTP transport.
|
||||
|
||||
Called by ``mcp_cli.main`` (the ``molecule-mcp`` console-script
|
||||
entry point in scripts/build_runtime_package.py) AFTER env
|
||||
validation and the standalone register + heartbeat thread setup.
|
||||
Direct callers (in-container code that already validated env and
|
||||
runs heartbeat.py separately) can also invoke this — it's the
|
||||
smallest possible "run the MCP stdio JSON-RPC loop" surface.
|
||||
runs heartbeat.py separately) can also invoke this.
|
||||
|
||||
Wheel-smoke gates in scripts/wheel_smoke.py pin the importability
|
||||
of this name (alongside ``mcp_cli.main``) so a silent rename can't
|
||||
break every external-runtime operator's MCP install — the 0.1.16
|
||||
``main_sync`` rename incident is the cautionary precedent.
|
||||
|
||||
Args:
|
||||
transport: "stdio" (default) or "http" (HTTP+SSE for Hermes).
|
||||
port: TCP port for HTTP transport (default 9100).
|
||||
"""
|
||||
_warn_if_stdio_not_pipe()
|
||||
asyncio.run(main())
|
||||
if transport == "http":
|
||||
asyncio.run(_run_http_server(port))
|
||||
else:
|
||||
_warn_if_stdio_not_pipe()
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli_main()
|
||||
parser = argparse.ArgumentParser(description="A2A MCP Server")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
default="stdio",
|
||||
choices=["stdio", "http"],
|
||||
help="Transport mode: stdio (default) or http (HTTP+SSE for Hermes)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=9100,
|
||||
help="TCP port for HTTP transport (default 9100)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
cli_main(transport=args.transport, port=args.port)
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
"""Tests for the HTTP/SSE transport of a2a_mcp_server.
|
||||
|
||||
Covers:
|
||||
- _handle_http_mcp: JSON-RPC request parsing and routing
|
||||
- Starlette app routes: POST /mcp, GET /mcp/stream, GET /health
|
||||
- cli_main argparse: --transport and --port flags
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
"""Minimal request duck-type for _handle_http_mcp."""
|
||||
|
||||
def __init__(self, body_json: dict, headers: dict | None = None):
|
||||
self._body = body_json
|
||||
self.headers = headers or {}
|
||||
|
||||
async def json(self) -> dict:
|
||||
return self._body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _handle_http_mcp — unit tests (no I/O)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_initialize():
|
||||
"""initialize method returns protocol version, capabilities, and server info."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
req = _DummyRequest({"jsonrpc": "2.0", "id": 42, "method": "initialize", "params": {}})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["jsonrpc"] == "2.0"
|
||||
assert resp["id"] == 42
|
||||
assert "protocolVersion" in resp["result"]
|
||||
assert "capabilities" in resp["result"]
|
||||
assert resp["result"]["serverInfo"]["name"] == "molecule"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_notifications_initialized_returns_none():
|
||||
"""notifications/initialized is a notification (no response needed)."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
req = _DummyRequest({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_tools_list():
|
||||
"""tools/list returns the TOOLS schema."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
req = _DummyRequest({"jsonrpc": "2.0", "id": 7, "method": "tools/list"})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["jsonrpc"] == "2.0"
|
||||
assert resp["id"] == 7
|
||||
assert "tools" in resp["result"]
|
||||
assert isinstance(resp["result"]["tools"], list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_unknown_method_returns_error():
|
||||
"""Unknown method returns -32601 Method not found."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
req = _DummyRequest({"jsonrpc": "2.0", "id": 3, "method": "foobar", "params": {}})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["jsonrpc"] == "2.0"
|
||||
assert resp["id"] == 3
|
||||
assert resp["error"]["code"] == -32601
|
||||
assert "Method not found" in resp["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_malformed_json_returns_parse_error():
|
||||
"""Request with bad JSON returns -32700 parse error."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
req = _DummyRequest.__new__(_DummyRequest)
|
||||
req.headers = {}
|
||||
req.json = AsyncMock(side_effect=ValueError("bad json"))
|
||||
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["error"]["code"] == -32700
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_tools_call_with_get_workspace_info():
|
||||
"""tools/call for get_workspace_info returns workspace info (mocked platform call)."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
with patch("a2a_mcp_server.tool_get_workspace_info", AsyncMock(return_value="mocked info")):
|
||||
req = _DummyRequest({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "get_workspace_info", "arguments": {}},
|
||||
})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["jsonrpc"] == "2.0"
|
||||
assert resp["id"] == 9
|
||||
assert resp["result"]["content"][0]["text"] == "mocked info"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_tools_call_unknown_tool():
|
||||
"""tools/call for an unknown tool returns the handle_tool_call error text."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
req = _DummyRequest({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 11,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "not_a_real_tool", "arguments": {}},
|
||||
})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["jsonrpc"] == "2.0"
|
||||
assert resp["id"] == 11
|
||||
assert "Unknown tool" in resp["result"]["content"][0]["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Starlette app — integration tests with TestClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _clear_http_globals():
|
||||
"""Reset module-level HTTP state before and after each test."""
|
||||
import a2a_mcp_server
|
||||
|
||||
# Save and restore globals
|
||||
saved_queues = a2a_mcp_server._http_connection_queues.copy()
|
||||
saved_lock = a2a_mcp_server._http_connection_lock
|
||||
a2a_mcp_server._http_connection_queues.clear()
|
||||
yield
|
||||
# Restore
|
||||
a2a_mcp_server._http_connection_queues = saved_queues
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _register_sse_queue():
|
||||
"""Register a queue for SSE push delivery (synchronous — callable from tests)."""
|
||||
conn_id = str(uuid.uuid4())
|
||||
queue = asyncio.Queue(maxsize=100)
|
||||
import a2a_mcp_server
|
||||
a2a_mcp_server._http_connection_queues[conn_id] = queue
|
||||
return conn_id, queue
|
||||
|
||||
|
||||
def _build_test_app(port: int = 9100):
|
||||
"""Build the Starlette app for testing without starting a real server.
|
||||
|
||||
Mirrors the app construction inside _run_http_server, but returns
|
||||
the app directly so TestClient can drive it without binding a port.
|
||||
"""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
|
||||
import a2a_mcp_server
|
||||
|
||||
async def mcp_handler(request):
|
||||
conn_id = request.headers.get("x-mcp-conn-id", "default")
|
||||
response = await a2a_mcp_server._handle_http_mcp(request)
|
||||
if response is None:
|
||||
from starlette.responses import Response
|
||||
return Response(status_code=202)
|
||||
async with a2a_mcp_server._http_connection_lock:
|
||||
queue = a2a_mcp_server._http_connection_queues.get(conn_id)
|
||||
if queue is not None and not queue.full():
|
||||
await queue.put(response)
|
||||
from starlette.responses import Response
|
||||
return Response(status_code=202)
|
||||
from starlette.responses import JSONResponse
|
||||
return JSONResponse(response)
|
||||
|
||||
async def sse_handler(request):
|
||||
conn_id, queue = _register_sse_queue()
|
||||
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def event_stream():
|
||||
import json as _json
|
||||
yield f"event: connected\ndata: {_json.dumps({'conn_id': conn_id})}\n\n"
|
||||
try:
|
||||
while True:
|
||||
response = await _asyncio.wait_for(queue.get(), timeout=300)
|
||||
import json as _json
|
||||
yield f"event: message\ndata: {_json.dumps(response)}\n\n"
|
||||
if queue.empty():
|
||||
yield "event: heartbeat\ndata: null\n\n"
|
||||
except _asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
async with a2a_mcp_server._http_connection_lock:
|
||||
a2a_mcp_server._http_connection_queues.pop(conn_id, None)
|
||||
|
||||
from starlette.responses import StreamingResponse
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
async def health_handler(_request):
|
||||
from starlette.responses import JSONResponse
|
||||
return JSONResponse({"ok": True, "transport": "http+sse", "port": port})
|
||||
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/mcp", mcp_handler, methods=["POST"]),
|
||||
Route("/mcp/stream", sse_handler, methods=["GET"]),
|
||||
Route("/health", health_handler),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestHTTPAppRoutes:
|
||||
"""Integration tests using Starlette TestClient against the HTTP app.
|
||||
|
||||
Starlette TestClient uses the ASGI interface directly (no real HTTP server
|
||||
or uvicorn needed), so no uvicorn mock is required.
|
||||
"""
|
||||
|
||||
def test_health_returns_ok_and_transport(self, _clear_http_globals):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = _build_test_app(port=9100)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/health")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["transport"] == "http+sse"
|
||||
assert data["port"] == 9100
|
||||
|
||||
def test_health_accepts_different_port(self, _clear_http_globals):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = _build_test_app(port=9999)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/health")
|
||||
|
||||
assert resp.json()["port"] == 9999
|
||||
|
||||
def test_mcp_post_initialize(self, _clear_http_globals):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = _build_test_app()
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/mcp", json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {},
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == 1
|
||||
assert "protocolVersion" in data["result"]
|
||||
|
||||
def test_mcp_post_tools_list(self, _clear_http_globals):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = _build_test_app()
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/mcp", json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {},
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tools" in data["result"]
|
||||
assert len(data["result"]["tools"]) > 0
|
||||
|
||||
def test_mcp_post_notifications_initialized_returns_202(self, _clear_http_globals):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = _build_test_app()
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/mcp", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized",
|
||||
})
|
||||
|
||||
# Notifications return 202 with no body
|
||||
assert resp.status_code == 202
|
||||
|
||||
def test_mcp_post_unknown_method_returns_200_with_error(self, _clear_http_globals):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = _build_test_app()
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/mcp", json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "no_such_method",
|
||||
"params": {},
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["error"]["code"] == -32601
|
||||
|
||||
def test_mcp_post_malformed_json_returns_error(self, _clear_http_globals):
|
||||
"""Malformed JSON body returns a JSON-RPC parse-error response (HTTP 200)."""
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = _build_test_app()
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
content=b"not json at all",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
# _handle_http_mcp catches ValueError from request.json() and returns
|
||||
# a JSON-RPC parse-error response with HTTP 200.
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["error"]["code"] == -32700
|
||||
assert "Parse error" in resp.json()["error"]["message"]
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_sse_stream_populates_queue(self, _clear_http_globals):
|
||||
"""_register_sse_queue adds a queue to _http_connection_queues before any async work."""
|
||||
import a2a_mcp_server
|
||||
|
||||
conn_id, queue = _register_sse_queue()
|
||||
|
||||
# The queue is registered synchronously — no await needed, no cleanup ran yet.
|
||||
assert conn_id in a2a_mcp_server._http_connection_queues
|
||||
assert len(conn_id) == 36 # valid UUID format
|
||||
assert not queue.full()
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_sse_queue_delivers_response(self, _clear_http_globals):
|
||||
"""POST /mcp with x-mcp-conn-id routes response into the SSE queue."""
|
||||
import uuid
|
||||
|
||||
import a2a_mcp_server
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
# Pre-register an SSE queue to simulate an active SSE subscriber
|
||||
conn_id = str(uuid.uuid4())
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
async with a2a_mcp_server._http_connection_lock:
|
||||
a2a_mcp_server._http_connection_queues[conn_id] = queue
|
||||
|
||||
# POST a tools/call with the conn_id header
|
||||
with TestClient(_build_test_app()) as client:
|
||||
with patch("a2a_mcp_server.tool_get_workspace_info", AsyncMock(return_value="test-ws-info")):
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
headers={"x-mcp-conn-id": conn_id},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 99,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "get_workspace_info", "arguments": {}},
|
||||
},
|
||||
)
|
||||
|
||||
# The handler returns 202 because the response was queued for SSE delivery
|
||||
assert resp.status_code == 202
|
||||
|
||||
# Verify the response was placed in the SSE queue
|
||||
result = await asyncio.wait_for(queue.get(), timeout=2.0)
|
||||
assert result["id"] == 99
|
||||
assert result["result"]["content"][0]["text"] == "test-ws-info"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_tool_call — remaining tool branches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_tools_call_send_message_to_user_with_mixed_attachments():
|
||||
"""attachments with non-string elements are filtered; the list branch is exercised."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
with patch("a2a_mcp_server.tool_send_message_to_user", AsyncMock(return_value="sent ok")) as mock_fn:
|
||||
req = _DummyRequest({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 21,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "send_message_to_user",
|
||||
"arguments": {
|
||||
"message": "hello",
|
||||
# Mixed types: list contains a dict (non-string) and an empty string
|
||||
"attachments": [{"url": "http://x"}, "", "valid.zip", None],
|
||||
},
|
||||
},
|
||||
})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["result"]["content"][0]["text"] == "sent ok"
|
||||
# Only string, non-empty values passed through
|
||||
mock_fn.assert_called_once()
|
||||
_, kwargs = mock_fn.call_args
|
||||
assert kwargs["attachments"] == ["valid.zip"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_tools_call_wait_for_message():
|
||||
"""wait_for_message is dispatched and returns the wrapped result."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
with patch("a2a_mcp_server.tool_wait_for_message", AsyncMock(return_value="no messages")):
|
||||
req = _DummyRequest({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 22,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "wait_for_message", "arguments": {"timeout_secs": 5.0}},
|
||||
})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["result"]["content"][0]["text"] == "no messages"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_tools_call_inbox_peek():
|
||||
"""inbox_peek is dispatched with the limit argument."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
with patch("a2a_mcp_server.tool_inbox_peek", AsyncMock(return_value="2 items")):
|
||||
req = _DummyRequest({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 23,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "inbox_peek", "arguments": {"limit": 5}},
|
||||
})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["result"]["content"][0]["text"] == "2 items"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_tools_call_inbox_pop():
|
||||
"""inbox_pop is dispatched with the activity_id argument."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
with patch("a2a_mcp_server.tool_inbox_pop", AsyncMock(return_value="acked")):
|
||||
req = _DummyRequest({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 24,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "inbox_pop", "arguments": {"activity_id": "abc-123"}},
|
||||
})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["result"]["content"][0]["text"] == "acked"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_handle_http_mcp_tools_call_chat_history():
|
||||
"""chat_history is dispatched with peer_id, limit, and before_ts arguments."""
|
||||
from a2a_mcp_server import _handle_http_mcp
|
||||
|
||||
with patch("a2a_mcp_server.tool_chat_history", AsyncMock(return_value="history")):
|
||||
req = _DummyRequest({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 25,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "chat_history",
|
||||
"arguments": {"peer_id": "ws-peer-1", "limit": 10, "before_ts": ""},
|
||||
},
|
||||
})
|
||||
resp = await _handle_http_mcp(req)
|
||||
|
||||
assert resp["result"]["content"][0]["text"] == "history"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cli_main argparse — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mcp_post_falls_back_to_json_when_sse_queue_is_full(_clear_http_globals):
|
||||
"""When the SSE queue is full (>100 pending), the handler returns JSON directly."""
|
||||
import a2a_mcp_server
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
# Pre-register a queue and fill it to capacity
|
||||
conn_id = str(uuid.uuid4())
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=2) # small queue for testing
|
||||
|
||||
async def _setup():
|
||||
async with a2a_mcp_server._http_connection_lock:
|
||||
a2a_mcp_server._http_connection_queues[conn_id] = queue
|
||||
queue.put_nowait({"id": 1})
|
||||
queue.put_nowait({"id": 2})
|
||||
|
||||
_sync_run(_setup())
|
||||
assert queue.full()
|
||||
|
||||
app = _build_test_app()
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/mcp",
|
||||
headers={"x-mcp-conn-id": conn_id},
|
||||
json={"jsonrpc": "2.0", "id": 99, "method": "initialize", "params": {}},
|
||||
)
|
||||
|
||||
# With a full queue, the handler returns the response as JSON (not 202)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == 99
|
||||
assert "result" in resp.json()
|
||||
|
||||
|
||||
def _sync_run(coro):
|
||||
"""Run a coroutine synchronously for test isolation (no real event loop needed)."""
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
|
||||
def test_cli_main_transport_stdio_calls_main(monkeypatch):
|
||||
"""cli_main(transport='stdio') calls asyncio.run(main) without HTTP."""
|
||||
import a2a_mcp_server
|
||||
|
||||
run_calls: list = []
|
||||
|
||||
async def fake_main():
|
||||
run_calls.append("called")
|
||||
|
||||
monkeypatch.setattr(a2a_mcp_server, "main", fake_main)
|
||||
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
|
||||
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
|
||||
|
||||
a2a_mcp_server.cli_main(transport="stdio", port=9100)
|
||||
|
||||
assert "called" in run_calls
|
||||
|
||||
|
||||
def test_cli_main_transport_http_calls_run_http_server(monkeypatch):
|
||||
"""cli_main(transport='http') calls _run_http_server without stdio."""
|
||||
import a2a_mcp_server
|
||||
|
||||
run_http_calls = []
|
||||
|
||||
async def fake_run_http(port):
|
||||
run_http_calls.append(port)
|
||||
|
||||
# asyncio.run must execute the coroutine for _run_http_server to be called
|
||||
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
|
||||
monkeypatch.setattr(a2a_mcp_server, "_run_http_server", fake_run_http)
|
||||
# stdio path must not be entered
|
||||
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
|
||||
|
||||
a2a_mcp_server.cli_main(transport="http", port=9102)
|
||||
|
||||
assert run_http_calls == [9102]
|
||||
|
||||
|
||||
def test_cli_main_http_skips_stdio_check(monkeypatch):
|
||||
"""When transport=http, _assert_stdio_is_pipe_compatible must NOT be called."""
|
||||
import a2a_mcp_server
|
||||
|
||||
called = []
|
||||
|
||||
def fake_assert():
|
||||
called.append("assert_called")
|
||||
|
||||
# Patch on the module object directly
|
||||
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", fake_assert)
|
||||
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", lambda fn: None)
|
||||
|
||||
a2a_mcp_server.cli_main(transport="http", port=9100)
|
||||
|
||||
assert "assert_called" not in called
|
||||
|
||||
|
||||
def test_cli_main_default_transport_is_stdio(monkeypatch):
|
||||
"""cli_main() with no args defaults to stdio transport."""
|
||||
import a2a_mcp_server
|
||||
|
||||
called_as: list = []
|
||||
|
||||
async def fake_main():
|
||||
called_as.append("called")
|
||||
|
||||
monkeypatch.setattr(a2a_mcp_server, "main", fake_main)
|
||||
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
|
||||
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
|
||||
|
||||
a2a_mcp_server.cli_main() # No args — defaults to stdio
|
||||
|
||||
assert "called" in called_as
|
||||
|
||||
|
||||
def test_cli_main_main_raises_propagates(monkeypatch):
|
||||
"""If main() raises, cli_main() re-raises (doesn't swallow)."""
|
||||
import a2a_mcp_server
|
||||
|
||||
async def fake_main():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(a2a_mcp_server, "main", fake_main)
|
||||
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
|
||||
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
a2a_mcp_server.cli_main(transport="stdio")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# uvicorn/starlette lazy-import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_http_server_is_coroutine_function():
|
||||
"""_run_http_server is a coroutine function accepting a port argument."""
|
||||
import inspect
|
||||
from a2a_mcp_server import _run_http_server
|
||||
|
||||
assert inspect.iscoroutinefunction(_run_http_server)
|
||||
|
||||
|
||||
def test_run_http_server_signature_port_int():
|
||||
"""_run_http_server accepts port as int."""
|
||||
import inspect
|
||||
from a2a_mcp_server import _run_http_server
|
||||
|
||||
sig = inspect.signature(_run_http_server)
|
||||
assert "port" in sig.parameters
|
||||
assert sig.parameters["port"].annotation == int
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Test coverage for builtin_tools.security._redact_secrets().
|
||||
|
||||
Issue #834 (C2): commit_memory must not persist API keys verbatim.
|
||||
|
||||
Pre-commit hook blocks bare secret-like strings (ghp_, sk-ant-, etc.) to prevent
|
||||
accidental commits of real credentials. These tests focus on the functional
|
||||
behaviour of the redaction logic: idempotency, contextual keyword=value patterns,
|
||||
boundary cases, and mixed content — without triggering the hook's length thresholds.
|
||||
The pre-commit hook itself is the primary guard for bare-pattern detection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from builtin_tools.security import REDACTED, _redact_secrets
|
||||
|
||||
|
||||
class TestRedactContextual:
|
||||
"""Keyword=value patterns with high-entropy values (under pre-commit threshold)."""
|
||||
|
||||
def test_api_key_contextual(self):
|
||||
"""api_key=X where X ≥ 40 base64 chars → value replaced, keyword preserved."""
|
||||
value = "A" * 40
|
||||
assert _redact_secrets(f"api_key={value}") == f"api_key={REDACTED}"
|
||||
|
||||
def test_keyword_contextual(self):
|
||||
"""Generic 'key=' also matches."""
|
||||
value = "B" * 45
|
||||
assert _redact_secrets(f"key={value}") == f"key={REDACTED}"
|
||||
|
||||
def test_secret_contextual(self):
|
||||
value = "C" * 50
|
||||
assert _redact_secrets(f"secret= {value}") == f"secret= {REDACTED}"
|
||||
|
||||
def test_token_contextual(self):
|
||||
value = "D" * 40
|
||||
assert _redact_secrets(f"token={value}") == f"token={REDACTED}"
|
||||
|
||||
def test_password_contextual(self):
|
||||
value = "E" * 50
|
||||
assert _redact_secrets(f"password={value}") == f"password={REDACTED}"
|
||||
|
||||
def test_keyword_spacing_tolerated(self):
|
||||
"""Spaces around = are tolerated by the pattern."""
|
||||
value = "F" * 40
|
||||
assert _redact_secrets(f"key = {value}") == f"key = {REDACTED}"
|
||||
|
||||
def test_contextual_too_short_not_redacted(self):
|
||||
"""Value shorter than 40 chars is not redacted."""
|
||||
short = "A" * 39
|
||||
assert _redact_secrets(f"api_key={short}") == f"api_key={short}"
|
||||
|
||||
def test_case_insensitive_keyword(self):
|
||||
"""Keyword matching is case-insensitive."""
|
||||
value = "G" * 40
|
||||
assert _redact_secrets(f"API_KEY={value}") == f"API_KEY={REDACTED}"
|
||||
assert _redact_secrets(f"Token={value}") == f"Token={REDACTED}"
|
||||
assert _redact_secrets(f"SECRET={value}") == f"SECRET={REDACTED}"
|
||||
|
||||
def test_boundary_preserved(self):
|
||||
"""Contextual pattern preserves the keyword; only value is replaced."""
|
||||
value = "H" * 40
|
||||
result = _redact_secrets(f"api_key={value}")
|
||||
assert result.startswith("api_key=")
|
||||
assert result.endswith(REDACTED)
|
||||
assert result == f"api_key={REDACTED}"
|
||||
|
||||
def test_base64_chars_in_value(self):
|
||||
"""Base64 alphabet chars (/ +) in value are covered by the charset."""
|
||||
# 40-char string with base64 chars
|
||||
value = "A" * 20 + "/+" + "A" * 18
|
||||
result = _redact_secrets(f"api_key={value}")
|
||||
assert result == f"api_key={REDACTED}"
|
||||
|
||||
|
||||
class TestRedactEdgeCases:
|
||||
"""Non-secret strings, idempotency, and boundary conditions."""
|
||||
|
||||
def test_idempotent(self):
|
||||
"""Calling redaction twice produces the same result."""
|
||||
text = f"token={'A' * 40}"
|
||||
first = _redact_secrets(text)
|
||||
second = _redact_secrets(first)
|
||||
assert second == first
|
||||
assert REDACTED in first
|
||||
|
||||
def test_already_redacted_string(self):
|
||||
"""The [REDACTED] sentinel itself is not matched by any pattern."""
|
||||
assert _redact_secrets(f"see {REDACTED} here") == f"see {REDACTED} here"
|
||||
|
||||
def test_no_match_passthrough(self):
|
||||
"""Normal prose passes through unchanged."""
|
||||
assert _redact_secrets("The answer is 42.") == "The answer is 42."
|
||||
assert _redact_secrets("Hello, world!") == "Hello, world!"
|
||||
assert _redact_secrets("api_key short") == "api_key short"
|
||||
assert _redact_secrets("") == ""
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _redact_secrets("") == ""
|
||||
|
||||
def test_short_value_not_secret(self):
|
||||
"""A short string after a keyword= prefix is not a secret."""
|
||||
assert _redact_secrets("token=short") == "token=short"
|
||||
|
||||
def test_mixed_content(self):
|
||||
"""Real text with a secret-like prefix → only the secret is redacted."""
|
||||
value = "A" * 40
|
||||
result = _redact_secrets(f"found secret: api_key={value} in config")
|
||||
assert result == f"found secret: api_key={REDACTED} in config"
|
||||
Reference in New Issue
Block a user