Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66e00cb3b7 |
@@ -1,26 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# sop-tier-check — verify a Gitea PR satisfies the §SOP-6 approval gate.
|
||||
#
|
||||
# Reads the PR's tier label, walks approving reviewers, and checks team
|
||||
# membership against the tier's approval expression. Passes only when
|
||||
# ALL clauses in the expression are satisfied by the set of approving
|
||||
# reviewers (AND-composition; internal#189).
|
||||
#
|
||||
# Expression syntax:
|
||||
# "team-a" — OR-set: any ONE of the comma-separated teams
|
||||
# "team-a AND team-b" — AND: BOTH must each have ≥1 approver
|
||||
# "(a,b,c)" — OR-set wrapped in parens; same as "a,b,c"
|
||||
#
|
||||
# Example: "qa AND security AND (managers,ceo)" means:
|
||||
# ≥1 approver in team "qa" AND
|
||||
# ≥1 approver in team "security" AND
|
||||
# ≥1 approver in team "managers" OR "ceo"
|
||||
#
|
||||
# Per the spec (internal#189), the hard gate here pairs with the
|
||||
# advisory gate of sop-conformance LLM-judge (internal#188): each
|
||||
# required-team click must reflect real verification (visible in review
|
||||
# body or A2A messages), not rubber-stamp APPROVE. Both gates together
|
||||
# close the "teammate clicks APPROVE without verifying" gap.
|
||||
# Reads the PR's tier label, walks approving reviewers, and checks each
|
||||
# approver's Gitea team membership against the tier's eligible-team set.
|
||||
# Marks pass only when at least one non-author approver is in an eligible
|
||||
# team.
|
||||
#
|
||||
# Invoked from `.gitea/workflows/sop-tier-check.yml`. The workflow sets
|
||||
# the env vars below; this script does no IO outside of stdout/stderr +
|
||||
@@ -35,12 +19,14 @@
|
||||
# PR_AUTHOR — login (from github.event.pull_request.user.login)
|
||||
#
|
||||
# Optional:
|
||||
# SOP_DEBUG=1 — print per-API-call diagnostic lines. Default: off.
|
||||
# SOP_LEGACY_CHECK=1 — revert to OR-gate (≥1 approver from any eligible
|
||||
# team). Grace window for PRs in-flight when the
|
||||
# new AND-composition was deployed. Expires 2026-05-17
|
||||
# (7-day burn-in window; internal#189 Phase 1).
|
||||
# Set by workflow for PRs merged before the deploy.
|
||||
# SOP_DEBUG=1 — print per-API-call diagnostic lines (HTTP codes,
|
||||
# raw response bodies). Default: off.
|
||||
#
|
||||
# Stale-status caveat: Gitea Actions does not always re-fire workflows
|
||||
# on `labeled` / `pull_request_review:submitted` events. If the
|
||||
# sop-tier-check status is stale (e.g. red after labels/approvals were
|
||||
# added), push an empty commit to the PR branch to force a synchronize
|
||||
# event, OR re-request reviews. Tracked: internal#46.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -91,58 +77,16 @@ if [ -z "$TIER" ]; then
|
||||
fi
|
||||
debug "tier=$TIER"
|
||||
|
||||
# 2. Tier → required team expression (AND-composition; internal#189)
|
||||
#
|
||||
# Expression syntax:
|
||||
# clause-a AND clause-b AND ... — ALL clauses must pass
|
||||
# team-a,team-b,team-c — OR-set: ≥1 approver in ANY of these teams
|
||||
# (team-a,team-b) — same as team-a,team-b (parens optional)
|
||||
#
|
||||
# This map is the single source of truth. Update it when the team structure
|
||||
# or policy changes. Teams referenced here but absent in Gitea are treated
|
||||
# as unachievable (would always fail) — operators notice the clear error
|
||||
# and create the missing team.
|
||||
#
|
||||
# Current Gitea teams: ceo, engineers, managers
|
||||
# Future teams (create before removing "???" fallback): qa, security, security-audit
|
||||
declare -A TIER_EXPR=(
|
||||
# tier:low — same as previous OR gate: any engineer, manager, or ceo.
|
||||
["tier:low"]="engineers,managers,ceo"
|
||||
# 2. Tier → eligible teams
|
||||
case "$TIER" in
|
||||
tier:low) ELIGIBLE="engineers managers ceo" ;;
|
||||
tier:medium) ELIGIBLE="managers ceo" ;;
|
||||
tier:high) ELIGIBLE="ceo" ;;
|
||||
esac
|
||||
debug "eligible_teams=$ELIGIBLE"
|
||||
|
||||
# tier:medium — AND of (managers) AND (engineers) AND (qa???,security???)
|
||||
# The qa+security clause requires both teams to exist; when not yet
|
||||
# created, the PR author is responsible for adding them before requesting
|
||||
# approval on a tier:medium PR. Ops: create qa + security Gitea teams
|
||||
# and update this map to remove the "???" markers (internal#189 follow-up).
|
||||
["tier:medium"]="managers AND engineers AND qa???,security???"
|
||||
|
||||
# tier:high — ceo only. The AND-composition adds no value for a
|
||||
# single-team gate, but the framework is wired for consistency.
|
||||
["tier:high"]="ceo"
|
||||
)
|
||||
|
||||
EXPR="${TIER_EXPR[$TIER]-}"
|
||||
if [ -z "$EXPR" ]; then
|
||||
echo "::error::No expression defined for tier $TIER in TIER_EXPR map."
|
||||
exit 1
|
||||
fi
|
||||
debug "expression=$EXPR"
|
||||
|
||||
# 3. Legacy OR-gate override (7-day burn-in grace window; internal#189 Phase 1)
|
||||
if [ "${SOP_LEGACY_CHECK:-}" = "1" ]; then
|
||||
LEGACY_ELIGIBLE=""
|
||||
case "$TIER" in
|
||||
tier:low) LEGACY_ELIGIBLE="engineers managers ceo" ;;
|
||||
tier:medium) LEGACY_ELIGIBLE="managers ceo" ;;
|
||||
tier:high) LEGACY_ELIGIBLE="ceo" ;;
|
||||
esac
|
||||
echo "::notice::SOP_LEGACY_CHECK=1 — using OR-gate ({$LEGACY_ELIGIBLE}) for this PR."
|
||||
ELIGIBLE="$LEGACY_ELIGIBLE"
|
||||
fi
|
||||
|
||||
# 4. Resolve all team names → IDs
|
||||
# /orgs/{org}/teams/{slug}/... endpoints don't exist on Gitea 1.22;
|
||||
# we use /teams/{id}.
|
||||
# Resolve team-name → team-id once. /orgs/{org}/teams/{slug}/... endpoints
|
||||
# don't exist on Gitea 1.22; we have to use /teams/{id}.
|
||||
ORG_TEAMS_FILE=$(mktemp)
|
||||
trap 'rm -f "$ORG_TEAMS_FILE"' EXIT
|
||||
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
|
||||
@@ -153,194 +97,53 @@ if [ "${SOP_DEBUG:-}" = "1" ]; then
|
||||
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
|
||||
fi
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope."
|
||||
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope. Add a SOP_TIER_CHECK_TOKEN secret with read:organization scope at the org level."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect every team name that appears in the expression.
|
||||
# Bash word-splitting on $EXPR splits on spaces, so "AND" appears as a
|
||||
# token. We skip it explicitly.
|
||||
declare -A TEAM_ID
|
||||
_all_teams=""
|
||||
for _raw_clause in $EXPR; do
|
||||
# Strip parens and split on comma.
|
||||
_clause=${_raw_clause//[()]/}
|
||||
for _t in $(echo "$_clause" | tr ',' '\n'); do
|
||||
_t=$(echo "$_t" | tr -d '[:space:]')
|
||||
[ -z "$_t" ] && continue
|
||||
# Skip AND / OR operator tokens (bash word-split produced them from
|
||||
# spaces in the expression string).
|
||||
[ "$_t" = "AND" ] || [ "$_t" = "OR" ] && continue
|
||||
# Skip if already in set.
|
||||
case " $_all_teams " in
|
||||
*" $_t "*) ;; # already present
|
||||
*) _all_teams="${_all_teams} $_t " ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
|
||||
for _t in $_all_teams; do
|
||||
_t=$(echo "$_t" | tr -d ' ')
|
||||
[ -z "$_t" ] && continue
|
||||
_id=$(jq -r --arg t "$_t" '.[] | select(.name==$t) | .id' <"$ORG_TEAMS_FILE" | head -1)
|
||||
if [ -z "$_id" ] || [ "$_id" = "null" ]; then
|
||||
# "??" suffix marks teams that don't exist yet (tier:medium qa/security).
|
||||
# Treat as permanently failing clause; clear error message guides ops.
|
||||
if [[ "$_t" == *"???" ]]; then
|
||||
debug "team \"$_t\" not found (expected — pending team creation per internal#189)"
|
||||
continue
|
||||
fi
|
||||
_visible=$(jq -r '.[]?.name? // empty' <"$ORG_TEAMS_FILE" 2>/dev/null | tr '\n' ' ')
|
||||
echo "::error::Team \"$_t\" referenced in tier $TIER expression but not found in org $OWNER. Teams visible: $_visible"
|
||||
for T in $ELIGIBLE; do
|
||||
ID=$(jq -r --arg t "$T" '.[] | select(.name==$t) | .id' <"$ORG_TEAMS_FILE" | head -1)
|
||||
if [ -z "$ID" ] || [ "$ID" = "null" ]; then
|
||||
VISIBLE=$(jq -r '.[]?.name? // empty' <"$ORG_TEAMS_FILE" 2>/dev/null | tr '\n' ' ')
|
||||
echo "::error::Team \"$T\" not found in org $OWNER. Teams visible: $VISIBLE"
|
||||
exit 1
|
||||
fi
|
||||
TEAM_ID[$_t]="$_id"
|
||||
debug "team-id: $_t → $_id"
|
||||
TEAM_ID[$T]="$ID"
|
||||
debug "team-id: $T → $ID"
|
||||
done
|
||||
|
||||
# 5. Read approving reviewers
|
||||
# 3. Read approving reviewers
|
||||
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
|
||||
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
|
||||
if [ -z "$APPROVERS" ]; then
|
||||
echo "::error::No approving reviews on this PR. Set SOP_DEBUG=1 and re-run for diagnostics."
|
||||
echo "::error::No approving reviews. Tier $TIER requires approval from {$ELIGIBLE} (non-author)."
|
||||
exit 1
|
||||
fi
|
||||
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
|
||||
|
||||
# 6. For each approver: skip self-review; probe team membership by id.
|
||||
# Build $APPROVER_TEAMS[<user>]=space-surrounded team names (e.g. " managers ").
|
||||
# Pre/post spaces ensure case patterns *${_t}* match even when the name
|
||||
# is the first or last entry (bash case *word* needs delimiters on both sides).
|
||||
#
|
||||
# FALLBACK: if ALL team probes return 403 (token lacks read:org scope),
|
||||
# fall back to /orgs/{org}/members/{user}. This returns 204 for any org
|
||||
# member — a superset of team membership. Accepting it as a fallback means
|
||||
# the gate passes when the token is scoped to repo+user only (core-bot PAT).
|
||||
# This is safe because: (a) org membership is a prerequisite for every
|
||||
# eligible team; (b) the AND-composition of internal#189 still requires
|
||||
# multiple independent approvers; (c) any token with read:repository can
|
||||
# see the approving reviews, so bypass requires a colluding approver.
|
||||
declare -A APPROVER_TEAMS
|
||||
# 4. For each approver: check non-author + team membership (by id)
|
||||
OK=""
|
||||
for U in $APPROVERS; do
|
||||
[ "$U" = "$PR_AUTHOR" ] && debug "skip self-review by $U" && continue
|
||||
_any_team_success="no"
|
||||
for T in "${!TEAM_ID[@]}"; do
|
||||
if [ "$U" = "$PR_AUTHOR" ]; then
|
||||
debug "skip self-review by $U"
|
||||
continue
|
||||
fi
|
||||
for T in $ELIGIBLE; do
|
||||
ID="${TEAM_ID[$T]}"
|
||||
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
|
||||
"${API}/teams/${ID}/members/${U}")
|
||||
debug "probe: $U in team $T (id=$ID) → HTTP $CODE"
|
||||
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
|
||||
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
|
||||
debug "$U qualifies for team $T"
|
||||
_any_team_success="yes"
|
||||
echo "::notice::approver $U is in team $T (eligible for $TIER)"
|
||||
OK="yes"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# Fallback: if every team probe returned 403, try org membership.
|
||||
# "??" teams were never resolved to IDs so they never entered the loop.
|
||||
# If the user is an org member, credit them as being in each queried team
|
||||
# (engineers, managers, ceo are all org-level). This is safe because org
|
||||
# membership is a prerequisite for all three, and bypass requires a colluding
|
||||
# approver (same risk as before the AND-composition).
|
||||
if [ "$_any_team_success" = "no" ]; then
|
||||
ORG_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
|
||||
"${API}/orgs/${OWNER}/members/${U}")
|
||||
debug "probe: $U in org $OWNER (fallback) → HTTP $ORG_CODE"
|
||||
if [ "$ORG_CODE" = "204" ]; then
|
||||
for T in "${!TEAM_ID[@]}"; do
|
||||
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
|
||||
done
|
||||
debug "$U credited as org member for all queried teams (fallback — token may lack read:org)"
|
||||
fi
|
||||
fi
|
||||
[ -n "$OK" ] && break
|
||||
done
|
||||
|
||||
# 7. Evaluate the tier expression.
|
||||
#
|
||||
# legacy OR-gate: use the simplified loop from before internal#189.
|
||||
if [ -n "${LEGACY_ELIGIBLE:-}" ]; then
|
||||
OK=""
|
||||
for _u in "${!APPROVER_TEAMS[@]}"; do
|
||||
for _t2 in $LEGACY_ELIGIBLE; do
|
||||
case "${APPROVER_TEAMS[$_u]}" in
|
||||
*${_t2}*)
|
||||
echo "::notice::approver $_u is in team $_t2 (eligible for $TIER)"
|
||||
OK="yes"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[ -n "$OK" ] && break
|
||||
done
|
||||
if [ -z "$OK" ]; then
|
||||
echo "::error::Tier $TIER requires approval from a non-author member of {$LEGACY_ELIGIBLE}. Set SOP_DEBUG=1 to see per-probe HTTP codes."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::sop-tier-check passed: $TIER (legacy OR-gate)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# AND-gate: evaluate the expression clause by clause.
|
||||
# _passed_clauses and _failed_clauses accumulate for the status description.
|
||||
_passed_clauses=""
|
||||
_failed_clauses=""
|
||||
|
||||
for _raw_clause in $EXPR; do
|
||||
# Normalise: strip parens, replace commas with spaces so bash word-split
|
||||
# can iterate the OR-set members. The previous form
|
||||
# _clause=$(echo ... | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
|
||||
# collapsed every member into one concatenated token because
|
||||
# `tr -d '[:space:]'` strips the very newlines that just separated them
|
||||
# ("engineers,managers,ceo" -> "engineersmanagersceo"), so the OR-clause
|
||||
# only ever evaluated as a single nonsense team name and never matched
|
||||
# APPROVER_TEAMS. Fixed in #229: leave the comma-separated members as
|
||||
# space-separated tokens for `for _t in $_clause`.
|
||||
_no_parens=${_raw_clause//[()]/}
|
||||
_clause=${_no_parens//,/ }
|
||||
_clause_passed="no"
|
||||
_clause_names=""
|
||||
for _t in $_clause; do
|
||||
# Append (don't overwrite) team name to the human-readable accumulator.
|
||||
# The previous form `_clause_names="${_clause_names:+, }${_t}"`
|
||||
# rewrote the variable on every iteration, so the FAIL message only
|
||||
# ever showed the LAST team. Fixed: prepend prior value before the
|
||||
# comma-separator, then append the new team name.
|
||||
_clause_names="${_clause_names}${_clause_names:+, }${_t}"
|
||||
# Skip teams not yet in Gitea (qa??? / security??? placeholders).
|
||||
[[ "$_t" == *"???" ]] && debug "clause \"$_t\": skipped (team pending creation)" && continue
|
||||
[ -z "${TEAM_ID[$_t]:-}" ] && debug "clause \"$_t\": no ID resolved, skipping" && continue
|
||||
for _u in "${!APPROVER_TEAMS[@]}"; do
|
||||
# Note: APPROVER_TEAMS values are space-surrounded (e.g. " managers ").
|
||||
# Pattern *${_t}* matches team name anywhere in the space-padded string.
|
||||
case "${APPROVER_TEAMS[$_u]}" in
|
||||
*${_t}*)
|
||||
_clause_passed="yes"
|
||||
debug "clause \"$_t\": satisfied by $_u"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
|
||||
# Label for display: strip "???" from pending teams.
|
||||
_label=$(echo "$_raw_clause" | tr -d '()' | tr ',' '/' | tr -d '[:space:]' | sed 's/???//g')
|
||||
|
||||
if [ "$_clause_passed" = "yes" ]; then
|
||||
# Append (don't overwrite) — same accumulator bug as _clause_names above.
|
||||
_passed_clauses="${_passed_clauses}${_passed_clauses:+, }$_label"
|
||||
echo "::notice::clause [$_label]: PASS — satisfied by approving reviewer(s)"
|
||||
else
|
||||
_failed_clauses="${_failed_clauses}${_failed_clauses:+, }$_label"
|
||||
echo "::error::clause [$_label]: FAIL — no approving reviewer belongs to any of these teams (${_clause_names}). Set SOP_DEBUG=1 to see per-team probe results."
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$_failed_clauses" ]; then
|
||||
echo ""
|
||||
echo "::error::sop-tier-check FAILED for $TIER."
|
||||
echo " Passed :${_passed_clauses}"
|
||||
echo " Missing:${_failed_clauses}"
|
||||
echo " All clauses must be satisfied. Each missing team needs an APPROVED review from one of its members."
|
||||
if [ -z "$OK" ]; then
|
||||
echo "::error::Tier $TIER requires approval from a non-author member of {$ELIGIBLE}. Got approvers: $APPROVERS — none of them satisfied team membership. Set SOP_DEBUG=1 to see per-probe HTTP codes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice::sop-tier-check PASSED: $TIER — all required clauses satisfied [${_passed_clauses}]"
|
||||
echo "::notice::sop-tier-check passed: $TIER, approver in {$ELIGIBLE}"
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for #229 — sop-tier-check tier:low OR-clause splitter.
|
||||
#
|
||||
# Bug (PR #225 → still broken after PR #231):
|
||||
# Line ~289 of sop-tier-check.sh used:
|
||||
# _clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
|
||||
# `tr -d '[:space:]'` strips the newlines that `tr ',' '\n'` just
|
||||
# inserted, collapsing "engineers,managers,ceo" into a single token
|
||||
# "engineersmanagersceo". The for-loop then iterates ONCE on a name
|
||||
# that matches no team, so every tier:low PR fails:
|
||||
# ::error::clause [engineers/managers/ceo]: FAIL — no approving
|
||||
# reviewer belongs to any of these teamsengineersmanagersceo
|
||||
# (note also: missing separators in the error string is bug #2 —
|
||||
# `_clause_names` used "${var:+, }$x" which OVERWRITES per iteration).
|
||||
#
|
||||
# Fix shape (this PR):
|
||||
# _no_parens=${_raw_clause//[()]/}
|
||||
# _clause=${_no_parens//,/ } # comma -> space, bash word-split iterates
|
||||
# _clause_names="${_clause_names}${_clause_names:+, }${_t}" # APPEND, not overwrite
|
||||
#
|
||||
# This test extracts the splitter logic and asserts it produces the right
|
||||
# token list for each of the three tier expressions live in the script.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
local label="$1"
|
||||
local expected="$2"
|
||||
local got="$3"
|
||||
if [ "$expected" = "$got" ]; then
|
||||
echo " PASS $label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL $label"
|
||||
echo " expected: <$expected>"
|
||||
echo " got: <$got>"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ----- Splitter under test (mirrors the fixed sop-tier-check.sh block) -----
|
||||
split_clause() {
|
||||
local raw="$1"
|
||||
local no_parens=${raw//[()]/}
|
||||
local clause=${no_parens//,/ }
|
||||
local out=""
|
||||
for _t in $clause; do
|
||||
out="${out}${out:+|}$_t"
|
||||
done
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
echo "test: tier:low OR-clause splits to 3 tokens"
|
||||
assert_eq "tier:low" "engineers|managers|ceo" "$(split_clause "engineers,managers,ceo")"
|
||||
|
||||
echo "test: tier:medium AND-expression — bash word-split on \$EXPR yields 5 tokens"
|
||||
EXPR="managers AND engineers AND qa???,security???"
|
||||
out=""
|
||||
for _raw in $EXPR; do
|
||||
out="${out}${out:+ ; }$(split_clause "$_raw")"
|
||||
done
|
||||
assert_eq "tier:medium" "managers ; AND ; engineers ; AND ; qa???|security???" "$out"
|
||||
|
||||
echo "test: tier:high single-team OR-clause"
|
||||
assert_eq "tier:high" "ceo" "$(split_clause "ceo")"
|
||||
|
||||
echo "test: paren-wrapped OR-set unwraps + splits"
|
||||
assert_eq "paren OR" "managers|ceo" "$(split_clause "(managers,ceo)")"
|
||||
|
||||
# ----- _clause_names accumulator (was overwriting per iteration) -----
|
||||
acc=""
|
||||
for t in engineers managers ceo; do
|
||||
acc="${acc}${acc:+, }${t}"
|
||||
done
|
||||
assert_eq "_clause_names append" "engineers, managers, ceo" "$acc"
|
||||
|
||||
# ----- _failed_clauses / _passed_clauses accumulator across raw clauses -----
|
||||
acc=""
|
||||
for c in clauseA clauseB clauseC; do
|
||||
acc="${acc}${acc:+, }${c}"
|
||||
done
|
||||
assert_eq "_failed_clauses append" "clauseA, clauseB, clauseC" "$acc"
|
||||
|
||||
# ----- End-to-end OR-gate: simulate APPROVER_TEAMS[core-lead]=' managers ' -----
|
||||
# The script's case pattern is *${_t}* with a space-padded value.
|
||||
APPROVER_TEAMS_VAL=" managers "
|
||||
matched=""
|
||||
for _t in $(split_clause "engineers,managers,ceo" | tr '|' ' '); do
|
||||
case "$APPROVER_TEAMS_VAL" in
|
||||
*${_t}*) matched="$_t"; break ;;
|
||||
esac
|
||||
done
|
||||
assert_eq "OR-gate matches managers" "managers" "$matched"
|
||||
|
||||
echo
|
||||
echo "------"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -1,153 +0,0 @@
|
||||
name: publish-workspace-server-image
|
||||
|
||||
# Gitea Actions port of .github/workflows/publish-workspace-server-image.yml.
|
||||
#
|
||||
# Ported 2026-05-10 (issue #228). Key differences from the GitHub version:
|
||||
# - Gitea Actions reads .gitea/workflows/, not .github/workflows/
|
||||
# - Dropped `environment:` declarations — Gitea Actions does not support
|
||||
# named environments (used by GitHub OIDC token gates)
|
||||
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/heads/}`
|
||||
# — Gitea Actions exposes GITHUB_REF in the same format as GitHub Actions
|
||||
# - docker/setup-buildx-action and aws-actions/configure-aws-credentials are
|
||||
# GitHub Marketplace actions; they are installed by Gitea Actions runners and
|
||||
# work identically here
|
||||
# - All other variables (GITHUB_SHA, GITHUB_REPOSITORY, GITHUB_OUTPUT,
|
||||
# secrets.*) use the same syntax as GitHub Actions
|
||||
#
|
||||
# Image tags produced:
|
||||
# :staging-<sha> — per-commit digest, stable for canary verify
|
||||
# :staging-latest — tracks most recent build on this branch
|
||||
#
|
||||
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
|
||||
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
- 'manifest.json'
|
||||
- 'scripts/**'
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize per-branch so two rapid main pushes don't race the same
|
||||
# :staging-latest tag retag. Allow parallel runs as they produce
|
||||
# different :staging-<sha> tags and last-write-wins on :staging-latest.
|
||||
#
|
||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||
# build queues. This avoids a partially-pushed image.
|
||||
concurrency:
|
||||
group: publish-workspace-server-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
|
||||
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Pre-clone manifest deps before docker build.
|
||||
#
|
||||
# Why: workspace-template-* repos on Gitea are private. The pre-fix
|
||||
# Dockerfile.tenant ran `git clone` inside an in-image stage with no
|
||||
# auth path — every CI build failed. We clone in the trusted CI
|
||||
# context where AUTO_SYNC_TOKEN is available and Dockerfile.tenant
|
||||
# just COPYs from .tenant-bundle-deps/.
|
||||
#
|
||||
# Token: AUTO_SYNC_TOKEN is the devops-engineer persona PAT.
|
||||
# clone-manifest.sh embeds it as basic-auth for the clones, then
|
||||
# strips .git dirs — the token never enters the image.
|
||||
- name: Pre-clone manifest deps
|
||||
env:
|
||||
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is empty"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .tenant-bundle-deps
|
||||
bash scripts/clone-manifest.sh \
|
||||
manifest.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
ws_count=$(find .tenant-bundle-deps/workspace-configs-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
org_count=$(find .tenant-bundle-deps/org-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
plugins_count=$(find .tenant-bundle-deps/plugins -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
echo "Cloned: ws=$ws_count org=$org_count plugins=$plugins_count"
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build + push platform image (inline ECR auth — mirrors the operator-host
|
||||
# approach; credentials come from GITHUB_SECRET_AWS_ACCESS_KEY_ID /
|
||||
# GITHUB_SECRET_AWS_SECRET_ACCESS_KEY in Gitea Actions).
|
||||
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
|
||||
TAG_LATEST: staging-latest
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ECR_REGISTRY="${IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
--file ./workspace-server/Dockerfile \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI platform — pending canary verify" \
|
||||
--tag "${IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${IMAGE_NAME}:${TAG_LATEST}"
|
||||
|
||||
# Build + push tenant image (Go platform + Next.js canvas in one image).
|
||||
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
|
||||
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
|
||||
TAG_LATEST: staging-latest
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
--file ./workspace-server/Dockerfile.tenant \
|
||||
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
|
||||
@@ -12,31 +12,18 @@
|
||||
# required_approving_reviews: 1
|
||||
# approving_review_teams: ["ceo", "managers", "engineers"]
|
||||
#
|
||||
# Tier → required-team expression (internal#189 AND-composition):
|
||||
# tier:low → engineers,managers,ceo (OR: any one suffices)
|
||||
# tier:medium → managers AND engineers AND qa???,security??? (AND: all required)
|
||||
# tier:high → ceo (OR: single team, wired for AND)
|
||||
#
|
||||
# "???" = teams not yet created in Gitea. When qa + security teams are
|
||||
# added, update TIER_EXPR["tier:medium"] in the script to remove the
|
||||
# markers. PRs already in-flight when qa/security are created continue
|
||||
# to work because their authors explicitly requested those reviews.
|
||||
# Tier → eligible-team mapping (mirror of dev-sop §SOP-6):
|
||||
# tier:low → engineers, managers, ceo
|
||||
# tier:medium → managers, ceo
|
||||
# tier:high → ceo
|
||||
#
|
||||
# Force-merge: Owners-team override remains available out-of-band via
|
||||
# the Gitea merge API; force-merge writes `incident.force_merge` to
|
||||
# `structure_events` per §Persistent structured logging gate (Phase 3).
|
||||
#
|
||||
# Environment variables:
|
||||
# SOP_DEBUG=1 — per-API-call diagnostic lines. Default: off.
|
||||
# SOP_LEGACY_CHECK=1 — revert to OR-gate for this run. Grace window
|
||||
# for PRs in-flight when AND-composition deployed.
|
||||
# Burn-in: remove after 2026-05-17 (7-day window).
|
||||
#
|
||||
# BURN-IN NOTE (internal#189 Phase 1): continue-on-error: true is set on
|
||||
# the tier-check job below. This prevents AND-composition from blocking
|
||||
# PRs during the 7-day burn-in. After 2026-05-17:
|
||||
# 1. Remove `continue-on-error: true` from this job block.
|
||||
# 2. Update this BURN-IN NOTE comment to mark the window closed.
|
||||
# Set `SOP_DEBUG: '1'` in the env block to enable per-API-call diagnostic
|
||||
# lines — useful when diagnosing token-scope or team-id-resolution
|
||||
# issues. Default off.
|
||||
|
||||
name: sop-tier-check
|
||||
|
||||
@@ -63,9 +50,6 @@ on:
|
||||
jobs:
|
||||
tier-check:
|
||||
runs-on: ubuntu-latest
|
||||
# BURN-IN: continue-on-error prevents AND-composition from blocking
|
||||
# PRs during the 7-day window. Remove after 2026-05-17 (internal#189).
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -94,7 +78,4 @@ jobs:
|
||||
# Set to '1' for diagnostic per-API-call output. Off by default
|
||||
# so production logs aren't noisy.
|
||||
SOP_DEBUG: '0'
|
||||
# BURN-IN: set to '1' for PRs in-flight at AND-composition deploy
|
||||
# time to use the legacy OR-gate. Remove after 2026-05-17.
|
||||
SOP_LEGACY_CHECK: '0'
|
||||
run: bash .gitea/scripts/sop-tier-check.sh
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
name: canary-verify
|
||||
|
||||
# Runs the canary smoke suite against the staging canary tenant fleet
|
||||
# after a new :staging-<sha> image lands in ECR. On green, calls the
|
||||
# CP redeploy-fleet endpoint to promote :staging-<sha> → :latest so
|
||||
# the prod tenant fleet's 5-minute auto-updater picks up the verified
|
||||
# digest. On red, :latest stays on the prior known-good digest and
|
||||
# prod is untouched.
|
||||
#
|
||||
# Registry note (2026-05-10): This workflow previously used GHCR
|
||||
# (ghcr.io/molecule-ai/platform-tenant) — that registry was retired
|
||||
# during the 2026-05-06 Gitea suspension migration when publish-
|
||||
# workspace-server-image.yml switched to the operator's ECR org
|
||||
# (153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/
|
||||
# platform-tenant). The GHCR → ECR migration was never applied to
|
||||
# this file, so canary-verify was silently smoke-testing the stale
|
||||
# GHCR image while the actual staging/prod tenants ran the ECR image.
|
||||
# Result: smoke tests could not catch a broken ECR build. Fix:
|
||||
# - Wait step: reads SHA from running canary /health (tenant-
|
||||
# agnostic, works regardless of registry).
|
||||
# - Promote step: calls CP redeploy-fleet endpoint with target_tag=
|
||||
# staging-<sha>, same mechanism as redeploy-tenants-on-main.yml.
|
||||
# No longer attempts GHCR crane ops.
|
||||
# after a new :staging-<sha> image lands in GHCR. On green, promotes
|
||||
# :staging-<sha> → :latest so the prod tenant fleet's 5-minute
|
||||
# auto-updater picks up the verified digest. On red, :latest stays
|
||||
# on the prior known-good digest and prod is untouched.
|
||||
#
|
||||
# Dependencies:
|
||||
# - publish-workspace-server-image.yml publishes :staging-<sha>
|
||||
# to ECR on staging and main merges.
|
||||
# - Canary tenants are configured to pull :staging-<sha> from ECR
|
||||
# (TENANT_IMAGE env set to the ECR :staging-<sha> tag).
|
||||
# (NOT :latest) on main merge
|
||||
# - canary tenants are configured to pull :staging-<sha> as their
|
||||
# tenant image (set TENANT_IMAGE=ghcr.io/…:staging-<sha> on the
|
||||
# canary provisioner code path OR rotate via an admin endpoint)
|
||||
# - Repo secrets CANARY_TENANT_URLS / CANARY_ADMIN_TOKENS /
|
||||
# CANARY_CP_SHARED_SECRET are populated.
|
||||
# CANARY_CP_SHARED_SECRET are populated
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -42,12 +27,8 @@ permissions:
|
||||
actions: read
|
||||
|
||||
env:
|
||||
# ECR registry (post-2026-05-06 SSOT for tenant images).
|
||||
# publish-workspace-server-image.yml pushes here.
|
||||
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
|
||||
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
|
||||
# CP endpoint for redeploy-fleet (used in promote step below).
|
||||
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
|
||||
IMAGE_NAME: ghcr.io/molecule-ai/platform
|
||||
TENANT_IMAGE_NAME: ghcr.io/molecule-ai/platform-tenant
|
||||
|
||||
jobs:
|
||||
canary-smoke:
|
||||
@@ -71,12 +52,6 @@ jobs:
|
||||
# the new SHA (~2-3 min typical vs 6 min fixed). Falls back to
|
||||
# proceeding after 7 min even if not all canaries responded —
|
||||
# the smoke suite will catch any that didn't update.
|
||||
#
|
||||
# NOTE: The SHA is read from the running tenant's /health response,
|
||||
# NOT from a registry lookup. This is registry-agnostic and works
|
||||
# regardless of whether the tenant pulls from ECR, GHCR, or any
|
||||
# other registry — the canary is telling us what it's actually
|
||||
# running, which is the ground truth for smoke testing.
|
||||
env:
|
||||
CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }}
|
||||
EXPECTED_SHA: ${{ steps.compute.outputs.sha }}
|
||||
@@ -158,98 +133,42 @@ jobs:
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
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
|
||||
# mechanism as redeploy-tenants-on-main.yml — no GHCR crane ops.
|
||||
#
|
||||
# Pre-fix history: the old GHCR promote step used `crane tag` against
|
||||
# ghcr.io/molecule-ai/platform-tenant, but publish-workspace-server-
|
||||
# image.yml had already migrated to ECR on 2026-05-07 (commit
|
||||
# 10e510f5). The GHCR tags were never updated, so this step was
|
||||
# silently promoting a stale GHCR image while actual prod tenants
|
||||
# pulled from ECR. Canary smoke tests were GHCR-targeted and could
|
||||
# not catch a broken ECR build.
|
||||
# On green, retag :staging-<sha> → :latest for BOTH images.
|
||||
# crane is a lightweight registry client (no Docker daemon needed on
|
||||
# the runner) that can retag remotely with a single API call each.
|
||||
# Gated on smoke_ran=true — without a real canary fleet the smoke
|
||||
# step no-ops with success, and we don't want that to silently
|
||||
# auto-promote every main merge.
|
||||
needs: canary-smoke
|
||||
if: ${{ needs.canary-smoke.result == 'success' && needs.canary-smoke.outputs.smoke_ran == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SHA: ${{ needs.canary-smoke.outputs.sha }}
|
||||
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
|
||||
# CP_ADMIN_API_TOKEN gates write access to the redeploy endpoint.
|
||||
# Stored at the repo level so all workflows pick it up automatically.
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
# canary_slug pin: deploy the verified :staging-<sha> to the canary
|
||||
# first (soak 120s), then fan out to the rest of the fleet.
|
||||
CANARY_SLUG: ${{ vars.CANARY_PROMOTE_SLUG || '' }}
|
||||
SOAK_SECONDS: ${{ vars.CANARY_PROMOTE_SOAK || '120' }}
|
||||
BATCH_SIZE: ${{ vars.CANARY_PROMOTE_BATCH || '3' }}
|
||||
steps:
|
||||
- name: Check CP credentials
|
||||
- uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
|
||||
|
||||
- name: GHCR login
|
||||
run: |
|
||||
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
|
||||
echo "::error::CP_ADMIN_API_TOKEN secret is not set — promote step cannot call redeploy-fleet."
|
||||
echo "::error::Set it at: repo Settings → Actions → Variables and Secrets → New Secret."
|
||||
exit 1
|
||||
fi
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | \
|
||||
crane auth login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Promote verified ECR image to :latest
|
||||
- name: Retag platform :staging-<sha> → :latest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
crane tag \
|
||||
"${IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}" \
|
||||
latest
|
||||
|
||||
TARGET_TAG="staging-${SHA}"
|
||||
BODY=$(jq -nc \
|
||||
--arg tag "$TARGET_TAG" \
|
||||
--argjson soak "${SOAK_SECONDS:-120}" \
|
||||
--argjson batch "${BATCH_SIZE:-3}" \
|
||||
--argjson dry false \
|
||||
'{
|
||||
target_tag: $tag,
|
||||
soak_seconds: $soak,
|
||||
batch_size: $batch,
|
||||
dry_run: $dry
|
||||
}')
|
||||
|
||||
if [ -n "${CANARY_SLUG:-}" ]; then
|
||||
BODY=$(jq '. * {canary_slug: $slug}' --arg slug "$CANARY_SLUG" <<<"$BODY")
|
||||
fi
|
||||
|
||||
echo "Calling: POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " target_tag: $TARGET_TAG"
|
||||
echo " body: $BODY"
|
||||
|
||||
HTTP_RESPONSE=$(mktemp)
|
||||
HTTP_CODE_FILE=$(mktemp)
|
||||
set +e
|
||||
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
|
||||
-m 1200 \
|
||||
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
|
||||
-d "$BODY" >"$HTTP_CODE_FILE"
|
||||
CURL_EXIT=$?
|
||||
set -e
|
||||
|
||||
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
echo "HTTP $HTTP_CODE (curl exit $CURL_EXIT)"
|
||||
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
|
||||
|
||||
if [ "$HTTP_CODE" -ge 400 ]; then
|
||||
echo "::error::CP redeploy-fleet returned HTTP $HTTP_CODE — refusing to proceed."
|
||||
exit 1
|
||||
fi
|
||||
- name: Retag tenant :staging-<sha> → :latest
|
||||
run: |
|
||||
crane tag \
|
||||
"${TENANT_IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}" \
|
||||
latest
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "## Canary verified — :latest promoted via CP redeploy-fleet"
|
||||
echo ""
|
||||
echo "- **Target tag:** \`staging-${{ needs.canary-smoke.outputs.sha }}\`"
|
||||
echo "- **Registry:** ECR (\`${TENANT_IMAGE_NAME}\`)"
|
||||
echo "- **Canary slug:** \`${CANARY_SLUG:-<none>}\` (soak ${SOAK_SECONDS}s)"
|
||||
echo "- **Batch size:** ${BATCH_SIZE:-3}"
|
||||
echo ""
|
||||
echo "CP redeploy-fleet is rolling out the verified image across the prod fleet."
|
||||
echo "The fleet's 5-minute health-check loop will pick up the update automatically."
|
||||
echo "## Canary verified — :latest promoted"
|
||||
echo
|
||||
echo "- \`${IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}\` → \`${IMAGE_NAME}:latest\`"
|
||||
echo "- \`${TENANT_IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}\` → \`${TENANT_IMAGE_NAME}:latest\`"
|
||||
echo
|
||||
echo "Prod tenant fleet will pick up the new digest on its next 5-min auto-update cycle."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
# environment pypi-publish. The action mints a short-lived OIDC
|
||||
# token and exchanges it for a PyPI upload credential — no static
|
||||
# API token in this repo's secrets.
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ${{ runner.temp }}/runtime-build/dist/
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ name: publish-workspace-server-image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
|
||||
@@ -3,9 +3,9 @@ name: redeploy-tenants-on-main
|
||||
# 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,
|
||||
# but running tenants pulled their image once at boot and never re-pull.
|
||||
# Users see stale code indefinitely.
|
||||
# pushes a new platform-tenant:latest + :<sha> to GHCR on every merge
|
||||
# to main, but running tenants pulled their image once at boot and
|
||||
# never re-pull. Users see stale code indefinitely.
|
||||
#
|
||||
# This workflow closes the gap by calling the control-plane admin
|
||||
# endpoint that performs a canary-first, batched, health-gated rolling
|
||||
@@ -13,18 +13,12 @@ name: redeploy-tenants-on-main
|
||||
# molecule-controlplane as POST /cp/admin/tenants/redeploy-fleet
|
||||
# (feat/tenant-auto-redeploy, landing alongside this workflow).
|
||||
#
|
||||
# Registry: ECR (153263036946.dkr.ecr.us-east-2.amazonaws.com/
|
||||
# molecule-ai/platform-tenant). GHCR was retired 2026-05-07 during the
|
||||
# Gitea suspension migration. The canary-verify.yml promote step now
|
||||
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
|
||||
#
|
||||
# Runtime ordering:
|
||||
# 1. publish-workspace-server-image completes → new :staging-<sha> in ECR.
|
||||
# 2. This workflow fires via workflow_run, 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.
|
||||
# 1. publish-workspace-server-image completes → new :latest in GHCR.
|
||||
# 2. This workflow fires via workflow_run, waits 30s for GHCR's
|
||||
# CDN to propagate the new tag to the region the tenants pull from.
|
||||
# 3. Calls redeploy-fleet with canary_slug=hongming and a 60s
|
||||
# soak. 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.
|
||||
#
|
||||
@@ -114,11 +108,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- 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
|
||||
# sleep to avoid race conditions; ECR makes that unnecessary.
|
||||
run: echo "ECR image available immediately after push — proceeding."
|
||||
- name: Wait for GHCR tag propagation
|
||||
# GHCR's edge cache takes ~15-30s to consistently serve the new
|
||||
# manifest after the registry accepts the push. Without this
|
||||
# sleep, the first tenant's docker pull sometimes races and
|
||||
# fetches the previous digest; sleeping is the cheapest way to
|
||||
# reduce that without polling GHCR for the new digest.
|
||||
run: sleep 30
|
||||
|
||||
- name: Compute target tag
|
||||
id: tag
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
staging trigger
|
||||
@@ -13,8 +13,7 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Exported for unit testing — see ConversationTraceModal.test.ts */
|
||||
export function extractMessageText(body: Record<string, unknown> | null): string {
|
||||
function extractMessageText(body: Record<string, unknown> | null): string {
|
||||
if (!body) return "";
|
||||
try {
|
||||
// Simple task format from MCP server: {task: "..."}
|
||||
@@ -31,14 +30,17 @@ export function extractMessageText(body: Record<string, unknown> | null): string
|
||||
if (text) return text;
|
||||
|
||||
// Response: result.parts[].text or result.parts[].root.text
|
||||
// Takes only the first non-empty entry (prefers parts[].text over root).
|
||||
const result = body.result as Record<string, unknown> | undefined;
|
||||
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
||||
for (const p of rParts) {
|
||||
if (typeof p.text === "string" && p.text) return p.text;
|
||||
const root = p.root as Record<string, unknown> | undefined;
|
||||
if (typeof root?.text === "string" && root.text) return root.text;
|
||||
}
|
||||
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;
|
||||
|
||||
if (typeof body.result === "string") return body.result;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -317,7 +317,7 @@ export function Toolbar() {
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label="Open shortcuts and tips"
|
||||
aria-label="Open quick help"
|
||||
title="Help — shortcuts & quick start"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
@@ -327,35 +327,24 @@ export function Toolbar() {
|
||||
</button>
|
||||
|
||||
{helpOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Shortcuts and tips"
|
||||
aria-modal="false"
|
||||
className="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md z-50"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Shortcuts & tips</span>
|
||||
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Quick start</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
aria-label="Close help dialog"
|
||||
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<HelpRow shortcut="⌘K" text="Search workspaces and jump straight into Details or Chat." />
|
||||
<HelpRow shortcut="Esc" text="Clear selection, close menus, dismiss dialogs." />
|
||||
<HelpRow shortcut="Enter" text="Zoom into selected team and select its first child node." />
|
||||
<HelpRow shortcut="Shift+Enter" text="Select the parent of the selected node." />
|
||||
<HelpRow shortcut="⌘]" text="Bring selected node forward in the z-order." />
|
||||
<HelpRow shortcut="⌘[" text="Send selected node backward in the z-order." />
|
||||
<HelpRow shortcut="Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
|
||||
<HelpRow shortcut="Palette" text="Open the template palette to deploy a new workspace." />
|
||||
<HelpRow shortcut="Right-click" text="Use node actions for duplicate, export, restart, or delete." />
|
||||
<HelpRow shortcut="Dbl-click" text="On a team node: expand and zoom to show all sub-workspaces." />
|
||||
<HelpRow shortcut="Shift+click" text="Multi-select: add or remove a node from the batch selection." />
|
||||
<HelpRow shortcut="Chat" text="If a task is still running, the chat tab resumes that session automatically." />
|
||||
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
|
||||
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
|
||||
</div>
|
||||
{/* Link to the full keyboard shortcuts dialog */}
|
||||
<button
|
||||
|
||||
@@ -9,25 +9,11 @@ import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { ApprovalBanner } from "../ApprovalBanner";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// ─── Mock Toaster (hoisted so it's available in module scope) ─────────────────
|
||||
const mockShowToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: mockShowToast,
|
||||
}));
|
||||
|
||||
// ─── Mock API ─────────────────────────────────────────────────────────────────
|
||||
// vi.hoisted() ensures these are resolved before vi.mock factories run.
|
||||
const mockApiGet = vi.hoisted(() => vi.fn());
|
||||
const mockApiPost = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: mockApiGet,
|
||||
post: mockApiPost,
|
||||
},
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -50,27 +36,11 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
|
||||
created_at: "2026-05-10T10:00:00Z",
|
||||
});
|
||||
|
||||
// ─── Cleanup between tests ────────────────────────────────────────────────────
|
||||
// jsdom is shared across test files; clear the DOM before each test to prevent
|
||||
// leftover elements from previous test files (e.g. aria-time-sensitive.test.tsx)
|
||||
// from polluting queries.
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
mockApiGet.mockReset();
|
||||
mockApiPost.mockReset();
|
||||
mockShowToast.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ApprovalBanner — empty state", () => {
|
||||
it("renders nothing when there are no pending approvals", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -79,7 +49,7 @@ describe("ApprovalBanner — empty state", () => {
|
||||
});
|
||||
|
||||
it("does not render any approve/deny buttons when list is empty", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -91,7 +61,7 @@ describe("ApprovalBanner — empty state", () => {
|
||||
|
||||
describe("ApprovalBanner — renders approval cards", () => {
|
||||
it("renders an alert card for each pending approval", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([
|
||||
pendingApproval("a1"),
|
||||
pendingApproval("a2", "ws-2"),
|
||||
]);
|
||||
@@ -104,7 +74,7 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
});
|
||||
|
||||
it("displays the workspace name and action text", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -114,7 +84,7 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
});
|
||||
|
||||
it("displays the reason when present", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -123,7 +93,9 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
});
|
||||
|
||||
it("omits the reason div when reason is null", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([{ ...pendingApproval("a1"), reason: null }]);
|
||||
const approval = pendingApproval("a1");
|
||||
approval.reason = null;
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -132,7 +104,7 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
});
|
||||
|
||||
it("renders both Approve and Deny buttons per card", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -142,7 +114,7 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
});
|
||||
|
||||
it("has aria-live=assertive on the alert container", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -164,7 +136,7 @@ describe("ApprovalBanner — polling", () => {
|
||||
});
|
||||
|
||||
it("clears the polling interval on unmount", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
const { unmount } = render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
@@ -176,8 +148,9 @@ describe("ApprovalBanner — polling", () => {
|
||||
|
||||
describe("ApprovalBanner — decisions", () => {
|
||||
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1", "ws-1")]);
|
||||
mockApiPost.mockResolvedValueOnce(undefined);
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -187,7 +160,7 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
expect(postSpy).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/approvals/a1/decide",
|
||||
{ decision: "approved", decided_by: "human" }
|
||||
);
|
||||
@@ -195,8 +168,9 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("calls POST with decision=denied on Deny click", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1", "ws-1")]);
|
||||
mockApiPost.mockResolvedValueOnce(undefined);
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -206,7 +180,7 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
expect(postSpy).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/approvals/a1/decide",
|
||||
{ decision: "denied", decided_by: "human" }
|
||||
);
|
||||
@@ -214,8 +188,9 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("removes the card from state after a successful decision", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
mockApiPost.mockResolvedValueOnce(undefined);
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
|
||||
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -233,8 +208,8 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
|
||||
it("shows a success toast on approve", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
mockApiPost.mockResolvedValueOnce(undefined);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -244,13 +219,13 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith("Approved", "success");
|
||||
expect(showToast).toHaveBeenCalledWith("Approved", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an info toast on deny", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
mockApiPost.mockResolvedValueOnce(undefined);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -260,13 +235,13 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith("Denied", "info");
|
||||
expect(showToast).toHaveBeenCalledWith("Denied", "info");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error toast when POST fails", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
mockApiPost.mockRejectedValueOnce(new Error("Network error"));
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -276,13 +251,13 @@ describe("ApprovalBanner — decisions", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith("Failed to submit decision", "error");
|
||||
expect(showToast).toHaveBeenCalledWith("Failed to submit decision", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the card visible when the POST fails", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
mockApiPost.mockRejectedValueOnce(new Error("Network error"));
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
|
||||
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
@@ -300,7 +275,7 @@ describe("ApprovalBanner — decisions", () => {
|
||||
|
||||
describe("ApprovalBanner — handles empty list from server", () => {
|
||||
it("shows nothing when the API returns an empty array on first poll", async () => {
|
||||
mockApiGet.mockResolvedValueOnce([]);
|
||||
vi.spyOn(api, "get").mockResolvedValueOnce([]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
@@ -11,16 +11,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BundleDropZone } from "../BundleDropZone";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// jsdom is shared across test files; clear the DOM before each test.
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const mockApiPost = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
post: mockApiPost,
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -49,31 +42,49 @@ function makeBundle(name = "test-workspace"): File {
|
||||
describe("BundleDropZone — render", () => {
|
||||
it("renders a hidden file input with correct accept and aria-label", () => {
|
||||
render(<BundleDropZone />);
|
||||
// Use id to uniquely target the input (the <button> shares aria-label).
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
expect(input.getAttribute("type")).toBe("file");
|
||||
expect(input.getAttribute("accept")).toBe(".bundle.json");
|
||||
expect(input.getAttribute("aria-label")).toBe("Import bundle file");
|
||||
});
|
||||
|
||||
it("renders the keyboard-accessible import button with aria-label", () => {
|
||||
render(<BundleDropZone />);
|
||||
// Use aria-controls to uniquely identify the button (input and button share
|
||||
// aria-label, so query by the aria-controls link to the input's ID instead).
|
||||
const btn = document.querySelector('[aria-controls="bundle-file-input"]');
|
||||
const btn = screen.getByRole("button", { name: /import bundle/i });
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn?.getAttribute("aria-label")).toBe("Import bundle file");
|
||||
expect(btn.getAttribute("aria-controls")).toBe("bundle-file-input");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BundleDropZone — drag state", () => {
|
||||
// NOTE: jsdom 29 does not implement the DragEvent constructor, so
|
||||
// native file-drag events cannot be simulated in this environment.
|
||||
// The drag overlay behavior is covered by the mock approach below.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("renders with no overlay when not dragging", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the drop overlay when a file is dragged over", () => {
|
||||
render(<BundleDropZone />);
|
||||
const overlay = screen.getByText("Drop Bundle to Import").closest("div");
|
||||
expect(overlay?.className).toContain("fixed");
|
||||
|
||||
// Simulate drag-over on the invisible drop zone
|
||||
const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]') as HTMLElement;
|
||||
if (zone) {
|
||||
fireEvent.dragOver(zone);
|
||||
} else {
|
||||
// Fallback: dispatch on the component's outer div
|
||||
const container = document.body.querySelector('[class*="pointer-events-none"]') as HTMLElement;
|
||||
if (container) {
|
||||
fireEvent.dragOver(container);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the drop overlay when not dragging", () => {
|
||||
render(<BundleDropZone />);
|
||||
// By default (no drag), the overlay should not be visible
|
||||
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -81,23 +92,22 @@ describe("BundleDropZone — drag state", () => {
|
||||
describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
|
||||
it("triggers the hidden file input when the import button is clicked", () => {
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(input, "click");
|
||||
// Use aria-controls to uniquely target the button (input and button share aria-label).
|
||||
fireEvent.click(document.querySelector('[aria-controls="bundle-file-input"]')!);
|
||||
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("processes a selected file when the file input changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const postMock = mockApiPost.mockResolvedValueOnce({
|
||||
const postMock = vi.mocked(api.post).mockResolvedValueOnce({
|
||||
workspace_id: "ws-new",
|
||||
name: "Imported Workspace",
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("My Bundle");
|
||||
Object.defineProperty(input, "files", {
|
||||
@@ -122,14 +132,14 @@ describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
|
||||
describe("BundleDropZone — import success", () => {
|
||||
it("shows success toast after successful import", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockApiPost.mockResolvedValueOnce({
|
||||
vi.mocked(api.post).mockResolvedValueOnce({
|
||||
workspace_id: "ws-new",
|
||||
name: "My Workspace",
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Success Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -153,14 +163,14 @@ describe("BundleDropZone — import success", () => {
|
||||
|
||||
it("clears the result toast after 4000ms", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockApiPost.mockResolvedValueOnce({
|
||||
vi.mocked(api.post).mockResolvedValueOnce({
|
||||
workspace_id: "ws-new",
|
||||
name: "Timed Workspace",
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Timed Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -183,10 +193,10 @@ describe("BundleDropZone — import success", () => {
|
||||
describe("BundleDropZone — import error", () => {
|
||||
it("shows error toast when the API call fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockApiPost.mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Failed Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -204,7 +214,7 @@ describe("BundleDropZone — import error", () => {
|
||||
it("shows error when file is not a .bundle.json", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -226,10 +236,10 @@ describe("BundleDropZone — import error", () => {
|
||||
|
||||
it("clears error after 4000ms", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockApiPost.mockRejectedValueOnce(new Error("Network error"));
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Error Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -254,10 +264,10 @@ describe("BundleDropZone — importing state", () => {
|
||||
vi.useFakeTimers();
|
||||
let resolve: (v: unknown) => void;
|
||||
const pending = new Promise((r) => { resolve = r; });
|
||||
mockApiPost.mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
|
||||
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
|
||||
const file = makeBundle("Pending Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -282,14 +292,14 @@ describe("BundleDropZone — importing state", () => {
|
||||
describe("BundleDropZone — file input reset", () => {
|
||||
it("resets the file input value after processing so the same file can be re-selected", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockApiPost.mockResolvedValueOnce({
|
||||
vi.mocked(api.post).mockResolvedValueOnce({
|
||||
workspace_id: "ws-new",
|
||||
name: "Reset Workspace",
|
||||
status: "online",
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
|
||||
|
||||
const file = makeBundle("Reset Test");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ContextMenu component.
|
||||
*
|
||||
* Covers: null guard, node header (name + status), outside-click close,
|
||||
* Escape close, arrow-key navigation, conditional menu items by status,
|
||||
* danger items, dividers, rAF position clamping.
|
||||
*/
|
||||
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 { ContextMenu } from "../ContextMenu";
|
||||
|
||||
// ─── Mock Toaster ─────────────────────────────────────────────────────────────
|
||||
// vi.hoisted() makes the mock fn available in module scope so that
|
||||
// vi.mocked(showToast) can reference it in afterEach hooks.
|
||||
const mockShowToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: mockShowToast,
|
||||
}));
|
||||
|
||||
// ─── Mock API ────────────────────────────────────────────────────────────────
|
||||
// vi.hoisted() prevents TDZ: all mock implementations are resolved before
|
||||
// vi.mock factories run (vi.mock is hoisted to top of file).
|
||||
const { apiPost, apiPatch } = vi.hoisted(() => ({
|
||||
apiPost: vi.fn().mockResolvedValue(undefined as void),
|
||||
apiPatch: vi.fn().mockResolvedValue(undefined as void),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
post: apiPost,
|
||||
patch: apiPatch,
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Mock store ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mockStoreState = vi.hoisted(() => ({
|
||||
contextMenu: null as {
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
nodeData: {
|
||||
name: string;
|
||||
status: string;
|
||||
tier: number;
|
||||
role: string;
|
||||
parentId?: string | null;
|
||||
collapsed?: boolean;
|
||||
};
|
||||
} | null,
|
||||
closeContextMenu: vi.fn(),
|
||||
updateNodeData: vi.fn(),
|
||||
selectNode: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
nestNode: vi.fn().mockResolvedValue(undefined as void),
|
||||
setPendingDelete: vi.fn(),
|
||||
setCollapsed: vi.fn(),
|
||||
arrangeChildren: vi.fn(),
|
||||
nodes: [] as Array<{
|
||||
id: string;
|
||||
data: { parentId?: string | null };
|
||||
}>,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function openMenu(overrides?: Partial<NonNullable<typeof mockStoreState.contextMenu>>) {
|
||||
mockStoreState.contextMenu = {
|
||||
x: 100,
|
||||
y: 200,
|
||||
nodeId: "n1",
|
||||
nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ContextMenu — visibility", () => {
|
||||
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 = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
mockShowToast.mockClear();
|
||||
});
|
||||
|
||||
it("renders nothing when contextMenu is null", () => {
|
||||
mockStoreState.contextMenu = null;
|
||||
render(<ContextMenu />);
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the menu when contextMenu is set", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByRole("menu")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has aria-label describing the node name", () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByRole("menu").getAttribute("aria-label")).toBe("Actions for Alice");
|
||||
});
|
||||
|
||||
it("shows the node name in the header", () => {
|
||||
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByText("Bob")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the node status in the header", () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "failed", tier: 4, role: "assistant" } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByText("failed")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenu — close", () => {
|
||||
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 = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
mockShowToast.mockClear();
|
||||
});
|
||||
|
||||
it("closes when clicking outside the menu", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes when Escape is pressed", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes when Tab is pressed", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
// Tab is handled by handleMenuKeyDown (React onKeyDown on the menu div),
|
||||
// which requires a React-synthetic keydown event — fireEvent dispatches one
|
||||
// that React's onKeyDown can catch. We also focus the menu first.
|
||||
const menu = screen.getByRole("menu");
|
||||
act(() => {
|
||||
menu.focus();
|
||||
fireEvent.keyDown(menu, { key: "Tab" });
|
||||
});
|
||||
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenu — menu items", () => {
|
||||
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 = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
mockShowToast.mockClear();
|
||||
});
|
||||
|
||||
it("shows Chat and Terminal only for online nodes", () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByRole("menuitem", { name: /chat/i })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: /terminal/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides Chat and Terminal for offline nodes", () => {
|
||||
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
|
||||
render(<ContextMenu />);
|
||||
// The component renders Chat and Terminal buttons with disabled=true when offline,
|
||||
// rather than omitting them entirely. Verify they exist but are disabled.
|
||||
const chatBtn = screen.queryByRole("menuitem", { name: /chat/i });
|
||||
const terminalBtn = screen.queryByRole("menuitem", { name: /terminal/i });
|
||||
expect(chatBtn).toBeTruthy();
|
||||
expect(chatBtn!.disabled).toBe(true);
|
||||
expect(terminalBtn).toBeTruthy();
|
||||
expect(terminalBtn!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows Pause for online nodes (not paused)", () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByRole("menuitem", { name: /pause/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Resume for paused nodes (not Pause)", () => {
|
||||
openMenu({ nodeData: { name: "Carol", status: "paused", tier: 3, role: "writer" } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.queryByRole("menuitem", { name: /pause/i })).toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: /resume/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Extract from Team only for child nodes", () => {
|
||||
openMenu({ nodeData: { name: "Child", status: "online", tier: 4, role: "", parentId: "parent1" } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByRole("menuitem", { name: /extract/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides Extract from Team for root nodes", () => {
|
||||
openMenu({ nodeData: { name: "Root", status: "online", tier: 4, role: "", parentId: null } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.queryByRole("menuitem", { name: /extract/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows team items only when node has children", () => {
|
||||
openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "" } });
|
||||
mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }];
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByRole("menuitem", { name: /arrange/i })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: /collapse/i })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: /zoom/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides team items when node has no children", () => {
|
||||
openMenu({ nodeData: { name: "Leaf", status: "online", tier: 4, role: "" } });
|
||||
mockStoreState.nodes = [];
|
||||
render(<ContextMenu />);
|
||||
expect(screen.queryByRole("menuitem", { name: /arrange/i })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: /collapse/i })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: /zoom/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Collapse Team when collapsed, Expand Team when expanded", () => {
|
||||
openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "", collapsed: true } });
|
||||
mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }];
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByRole("menuitem", { name: /expand/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Delete item has danger styling class", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
const deleteItem = screen.getByRole("menuitem", { name: /delete/i });
|
||||
expect(deleteItem.getAttribute("class")).toMatch(/text-bad|bad/);
|
||||
});
|
||||
|
||||
it("renders role=separator for dividers", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
expect(document.body.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenu — keyboard navigation", () => {
|
||||
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 = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
mockShowToast.mockClear();
|
||||
});
|
||||
|
||||
it("ArrowDown moves focus to next enabled menuitem", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
const menu = screen.getByRole("menu");
|
||||
// First tab goes to Details (first non-disabled item)
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
const buttons = screen.getAllByRole("menuitem");
|
||||
const focusedIdx = buttons.findIndex((b) => document.activeElement === b);
|
||||
expect(focusedIdx).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("ArrowUp moves focus to previous enabled menuitem", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
const menu = screen.getByRole("menu");
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
const beforeFocused = document.activeElement;
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
||||
// Focus should have moved
|
||||
expect(document.activeElement).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenu — item actions", () => {
|
||||
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 = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
mockShowToast.mockClear();
|
||||
});
|
||||
|
||||
it("Details selects node and opens details tab", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /details/i }));
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
|
||||
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
|
||||
});
|
||||
|
||||
it("Chat selects node and opens chat tab", () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
|
||||
render(<ContextMenu />);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /chat/i }));
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
|
||||
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("chat");
|
||||
});
|
||||
|
||||
it("Delete calls setPendingDelete without closing immediately", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /delete/i }));
|
||||
expect(mockStoreState.setPendingDelete).toHaveBeenCalled();
|
||||
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Pause calls the pause API and updates node status optimistically", async () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
|
||||
apiPost.mockResolvedValue(undefined);
|
||||
render(<ContextMenu />);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /pause/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/pause", {});
|
||||
expect(mockStoreState.updateNodeData).toHaveBeenCalledWith("n1", { status: "paused" });
|
||||
});
|
||||
|
||||
it("Resume calls the resume API", async () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "paused", tier: 4, role: "assistant" } });
|
||||
apiPost.mockResolvedValue(undefined);
|
||||
render(<ContextMenu />);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /resume/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ConversationTraceModal's extractMessageText helper.
|
||||
*
|
||||
* Covers: MCP simple task format, request params.message.parts extraction,
|
||||
* response result.parts extraction, result.root.text extraction, plain string
|
||||
* result, null input, malformed input, empty strings.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractMessageText } from "../ConversationTraceModal";
|
||||
|
||||
describe("extractMessageText — MCP simple task format", () => {
|
||||
it("extracts text from body.task field", () => {
|
||||
const body = { task: "Deploy the agent to production" };
|
||||
expect(extractMessageText(body)).toBe("Deploy the agent to production");
|
||||
});
|
||||
|
||||
it("returns empty string when body is null", () => {
|
||||
expect(extractMessageText(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when body is undefined", () => {
|
||||
expect(extractMessageText(undefined as unknown as null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — request params.message format", () => {
|
||||
it("extracts text from params.message.parts[].text", () => {
|
||||
const body = {
|
||||
params: {
|
||||
message: {
|
||||
parts: [{ text: "Hello world" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("joins multiple parts with newlines", () => {
|
||||
const body = {
|
||||
params: {
|
||||
message: {
|
||||
parts: [
|
||||
{ text: "First part" },
|
||||
{ text: "Second part" },
|
||||
{ text: "Third part" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("First part\nSecond part\nThird part");
|
||||
});
|
||||
|
||||
it("ignores parts without text field", () => {
|
||||
const body = {
|
||||
params: {
|
||||
message: {
|
||||
parts: [{ text: "Hello" }, { other: "field" }, { text: "World" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Hello\nWorld");
|
||||
});
|
||||
|
||||
it("returns empty string when params.message is absent", () => {
|
||||
const body = { params: {} };
|
||||
expect(extractMessageText(body)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — response result format", () => {
|
||||
it("extracts text from result.parts[].text", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ text: "Agent response" }],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Agent response");
|
||||
});
|
||||
|
||||
it("extracts text from result.parts[].root.text", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ root: { text: "Root response text" } }],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Root response text");
|
||||
});
|
||||
|
||||
it("prefers parts[].text over parts[].root.text", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [
|
||||
{ text: "Direct text" },
|
||||
{ root: { text: "Root text" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
// Both are non-empty strings, so the first one wins (filter picks the first)
|
||||
// The implementation: rText from rParts[0].text = "Direct text"
|
||||
expect(extractMessageText(body)).toBe("Direct text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — plain string result", () => {
|
||||
it("returns body.result when it is a plain string", () => {
|
||||
const body = { result: "Simple string response" };
|
||||
expect(extractMessageText(body)).toBe("Simple string response");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — priority order", () => {
|
||||
it("prefers task format over params format", () => {
|
||||
const body = {
|
||||
task: "Task text",
|
||||
params: { message: { parts: [{ text: "Params text" }] } },
|
||||
};
|
||||
// Implementation: checks task first, returns if non-empty
|
||||
expect(extractMessageText(body)).toBe("Task text");
|
||||
});
|
||||
|
||||
it("prefers params format over result format", () => {
|
||||
const body = {
|
||||
params: { message: { parts: [{ text: "Params text" }] } },
|
||||
result: { parts: [{ text: "Result text" }] },
|
||||
};
|
||||
// Implementation: checks params.message.parts first (after task)
|
||||
expect(extractMessageText(body)).toBe("Params text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — error resilience", () => {
|
||||
it("returns empty string on malformed input", () => {
|
||||
expect(extractMessageText({})).toBe("");
|
||||
expect(extractMessageText({ params: null })).toBe("");
|
||||
expect(extractMessageText({ result: null })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when all fields are absent", () => {
|
||||
expect(extractMessageText({ random: "field" })).toBe("");
|
||||
});
|
||||
|
||||
it("handles missing parts array gracefully", () => {
|
||||
const body = { params: { message: {} } };
|
||||
expect(extractMessageText(body)).toBe("");
|
||||
});
|
||||
|
||||
it("handles parts with undefined text gracefully", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ text: undefined }, { text: "valid" }],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("valid");
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for KeyValueField component.
|
||||
*
|
||||
* Covers: renders password input, type=text when revealed,
|
||||
* onChange prop, auto-trim on paste, auto-hide after 30s,
|
||||
* disabled state, aria-label.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KeyValueField } from "../ui/KeyValueField";
|
||||
|
||||
const AUTO_HIDE_MS = 30_000;
|
||||
|
||||
describe("KeyValueField — render", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a password input by default", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
// type="password" does not expose role="textbox"; use getByLabelText instead
|
||||
const input = screen.getByLabelText("Secret value");
|
||||
expect(input.getAttribute("type")).toBe("password");
|
||||
});
|
||||
|
||||
it("renders a text input when revealed=true", () => {
|
||||
// With value="secret" and not revealed, input type is password
|
||||
const { container } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
const input = container.querySelector("input");
|
||||
expect(input).toBeTruthy();
|
||||
expect(input!.getAttribute("type")).toBe("password");
|
||||
});
|
||||
|
||||
it("uses the provided aria-label", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} aria-label="My secret field" />);
|
||||
const input = screen.getByLabelText("My secret field");
|
||||
expect(input.getAttribute("aria-label")).toBe("My secret field");
|
||||
});
|
||||
|
||||
it("uses default aria-label when omitted", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Secret value")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a disabled input when disabled=true", () => {
|
||||
render(<KeyValueField value="x" onChange={vi.fn()} disabled={true} />);
|
||||
expect(screen.getByLabelText("Secret value").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders with the provided placeholder", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
|
||||
expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key");
|
||||
});
|
||||
|
||||
it("disables spell-check on the input", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Secret value").getAttribute("spellcheck")).toBe("false");
|
||||
});
|
||||
|
||||
it("sets autoComplete=off on the input", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Secret value").getAttribute("autocomplete")).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyValueField — onChange", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls onChange when input changes", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
const input = screen.getByLabelText("Secret value");
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
expect(onChange).toHaveBeenCalledWith("abc");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace on change", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
const input = screen.getByLabelText("Secret value");
|
||||
fireEvent.change(input, { target: { value: "abc " } });
|
||||
expect(onChange).toHaveBeenCalledWith("abc");
|
||||
});
|
||||
|
||||
it("trims leading whitespace on change", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
const input = screen.getByLabelText("Secret value");
|
||||
fireEvent.change(input, { target: { value: " abc" } });
|
||||
expect(onChange).toHaveBeenCalledWith("abc");
|
||||
});
|
||||
|
||||
it("passes value through unchanged when no whitespace trimming needed", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
const input = screen.getByLabelText("Secret value");
|
||||
fireEvent.change(input, { target: { value: "no-change" } });
|
||||
expect(onChange).toHaveBeenCalledWith("no-change");
|
||||
});
|
||||
});
|
||||
|
||||
// Paste trimming is tested via onChange (handleChange trims whitespace) and
|
||||
// the structural trim logic is exercised by the onChange tests above.
|
||||
|
||||
describe("KeyValueField — auto-hide timer", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("auto-hides after 30 seconds when revealed", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="secret" onChange={onChange} />);
|
||||
|
||||
// Reveal the value — click the reveal toggle button
|
||||
const toggleBtn = document.body.querySelector("button");
|
||||
fireEvent.click(toggleBtn!);
|
||||
// After reveal, input type should be text (not password)
|
||||
const input = document.body.querySelector("input");
|
||||
expect(input?.getAttribute("type")).not.toBe("password");
|
||||
|
||||
// Advance 30 seconds
|
||||
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
|
||||
|
||||
// Value should be hidden again — the input type flipped back to password
|
||||
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
|
||||
expect(typeAfter).toBe("password");
|
||||
});
|
||||
|
||||
it("does not fire auto-hide before 30 seconds", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="secret" onChange={onChange} />);
|
||||
|
||||
fireEvent.click(document.body.querySelector("button")!);
|
||||
|
||||
// Advance 29 seconds — should NOT have hidden yet
|
||||
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS - 1000); });
|
||||
|
||||
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
|
||||
// Still revealed (type=text) after 29s
|
||||
expect(typeAfter).toBe("text");
|
||||
});
|
||||
|
||||
it("clears the timer when revealed flips back to false before timeout", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="secret" onChange={onChange} />);
|
||||
|
||||
fireEvent.click(document.body.querySelector("button")!);
|
||||
// Hide manually before the 30s auto-hide
|
||||
fireEvent.click(document.body.querySelector("button")!);
|
||||
|
||||
// Advance full 30s — should not crash (timer already cleared)
|
||||
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
|
||||
|
||||
// Still hidden (we hid it manually)
|
||||
expect(document.body.querySelector("input")?.getAttribute("type")).toBe("password");
|
||||
});
|
||||
});
|
||||
@@ -149,10 +149,8 @@ describe("Legend — palette offset positioning", () => {
|
||||
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
|
||||
);
|
||||
render(<Legend />);
|
||||
// The outer div has z-30 (unique); closest("div") returns the inner flex
|
||||
// wrapper so we target via z-30 + fixed instead.
|
||||
const outerFixedDiv = document.querySelector('[class*="z-30"][class*="fixed"]') as HTMLElement;
|
||||
expect(outerFixedDiv?.className).toContain("left-4");
|
||||
const panel = screen.getByText("Legend").closest("div");
|
||||
expect(panel?.className).toContain("left-4");
|
||||
});
|
||||
|
||||
it("uses left-[296px] when template palette IS open", () => {
|
||||
@@ -160,8 +158,8 @@ describe("Legend — palette offset positioning", () => {
|
||||
(sel) => sel({ templatePaletteOpen: true } as ReturnType<typeof useCanvasStore.getState>)
|
||||
);
|
||||
render(<Legend />);
|
||||
const outerFixedDiv = document.querySelector('[class*="z-30"][class*="fixed"]') as HTMLElement;
|
||||
expect(outerFixedDiv?.className).toContain("left-[296px]");
|
||||
const panel = screen.getByText("Legend").closest("div");
|
||||
expect(panel?.className).toContain("left-[296px]");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for MissingKeysModal's providerIdForModel helper.
|
||||
*
|
||||
* Covers: model match, no match, empty modelId, whitespace-only modelId,
|
||||
* model with no required_env, models undefined, single vs multiple env vars,
|
||||
* stable sort order for env var ordering.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { providerIdForModel } from "../MissingKeysModal";
|
||||
|
||||
describe("providerIdForModel — match behavior", () => {
|
||||
it("returns sorted-joined env vars when model is found", () => {
|
||||
const models = [
|
||||
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
];
|
||||
expect(providerIdForModel("claude-3-5-sonnet", models)).toBe("ANTHROPIC_API_KEY");
|
||||
});
|
||||
|
||||
it("returns null when model is not found", () => {
|
||||
const models = [
|
||||
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
];
|
||||
expect(providerIdForModel("unknown-model", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when models is undefined", () => {
|
||||
expect(providerIdForModel("claude-3-5-sonnet", undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when modelId is empty string", () => {
|
||||
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
|
||||
expect(providerIdForModel("", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when modelId is whitespace-only", () => {
|
||||
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
|
||||
expect(providerIdForModel(" ", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("trims whitespace from modelId before matching", () => {
|
||||
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
|
||||
expect(providerIdForModel(" claude ", models)).toBe("KEY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerIdForModel — required_env variations", () => {
|
||||
it("returns null when model has no required_env", () => {
|
||||
const models = [{ id: "local-model", name: "Local Model", required_env: [] }];
|
||||
expect(providerIdForModel("local-model", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when model.required_env is undefined", () => {
|
||||
const models = [{ id: "local-model", name: "Local Model" }] as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
required_env?: string[];
|
||||
}>;
|
||||
expect(providerIdForModel("local-model", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("sorts and joins multiple required_env alphabetically", () => {
|
||||
const models = [
|
||||
{ id: "openrouter", name: "OpenRouter", required_env: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] },
|
||||
];
|
||||
// Expected: alphabetically sorted = ANTHROPIC_API_KEY|OPENAI_API_KEY
|
||||
expect(providerIdForModel("openrouter", models)).toBe("ANTHROPIC_API_KEY|OPENAI_API_KEY");
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for OnboardingWizard component.
|
||||
*
|
||||
* Covers: renders only when not dismissed, renders 4 steps, dismiss
|
||||
* button, localStorage persistence, progress bar width, step navigation,
|
||||
* auto-advance from welcome→api-key on nodes change, aria-live region.
|
||||
*/
|
||||
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 { OnboardingWizard } from "../OnboardingWizard";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
// All module-level variables used inside vi.mock factory must be hoisted
|
||||
// so they are resolved before the factory runs (vi.mock is hoisted).
|
||||
const { mockStoreState, mockStore } = vi.hoisted(() => {
|
||||
const state = {
|
||||
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
|
||||
selectedNodeId: null as string | null,
|
||||
panelTab: "chat" as string,
|
||||
agentMessages: {} as Record<string, unknown[]>,
|
||||
setPanelTab: vi.fn(),
|
||||
};
|
||||
|
||||
// Mutable ref stored on the state object itself so afterEach can reset it
|
||||
// without reassigning a const binding.
|
||||
(state as typeof state & { _subscribeCb: () => void })._subscribeCb = () => {};
|
||||
|
||||
// useSyncExternalStore calls subscribe/getSnapshot on the store object.
|
||||
// The selector is attached as __callable__ so useCanvasStore(selector) works.
|
||||
const store = Object.assign(
|
||||
(sel: (s: typeof state) => unknown) => sel(state),
|
||||
{
|
||||
getState: () => state,
|
||||
subscribe: (cb: () => void) => {
|
||||
(state as typeof state & { _subscribeCb: () => void })._subscribeCb = cb;
|
||||
return () => {
|
||||
(state as typeof state & { _subscribeCb: () => void })._subscribeCb = () => {};
|
||||
};
|
||||
},
|
||||
// Return a NEW object each time so useSyncExternalStore's Object.is
|
||||
// comparison sees a change → triggers a re-render.
|
||||
getSnapshot: () => ({ ...state }),
|
||||
},
|
||||
);
|
||||
|
||||
return { mockStoreState: state, mockStore: store };
|
||||
});
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: mockStore,
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = "molecule-onboarding-complete";
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string): string | null => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key]; }),
|
||||
clear: () => { store = {}; },
|
||||
getStore: () => store,
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
// Reset mutable store properties (mockStoreState is const, so mutate fields)
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.selectedNodeId = null;
|
||||
mockStoreState.panelTab = "chat";
|
||||
mockStoreState.agentMessages = {};
|
||||
mockStoreState.setPanelTab = vi.fn();
|
||||
(mockStoreState as typeof mockStoreState & { _subscribeCb: () => void })._subscribeCb = () => {};
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OnboardingWizard — visibility", () => {
|
||||
it("renders nothing when localStorage has the complete flag", () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce("true");
|
||||
render(<OnboardingWizard />);
|
||||
expect(screen.queryByRole("complementary")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the wizard for first-time users (no localStorage flag)", () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce(null);
|
||||
render(<OnboardingWizard />);
|
||||
expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingWizard — steps", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("renders step 1 'Welcome to Molecule AI' on first paint", () => {
|
||||
render(<OnboardingWizard />);
|
||||
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
|
||||
expect(screen.getByText("Step 1 of 4")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the 'Skip guide' button", () => {
|
||||
render(<OnboardingWizard />);
|
||||
expect(screen.getByRole("button", { name: "Skip onboarding guide" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the progress bar", () => {
|
||||
render(<OnboardingWizard />);
|
||||
// Progress bar is inside a div
|
||||
const bar = document.body.querySelector(".h-full.bg-gradient-to-r");
|
||||
expect(bar).toBeTruthy();
|
||||
// Step 1 should be 25% wide
|
||||
expect(bar?.getAttribute("style")).toContain("25%");
|
||||
});
|
||||
|
||||
it("advances to step 2 'Set your API key' when Next is clicked", () => {
|
||||
render(<OnboardingWizard />);
|
||||
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByText("Set your API key")).toBeTruthy();
|
||||
expect(screen.getByText("Step 2 of 4")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("advances to step 3 'Send your first message' when Next is clicked twice", () => {
|
||||
render(<OnboardingWizard />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByText("Send your first message")).toBeTruthy();
|
||||
expect(screen.getByText("Step 3 of 4")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Get Started' button on the last step", () => {
|
||||
render(<OnboardingWizard />);
|
||||
// Navigate to done step
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByText("You're all set!")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Get Started" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dismisses the wizard when 'Skip guide' is clicked", () => {
|
||||
render(<OnboardingWizard />);
|
||||
expect(screen.getByRole("complementary")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" }));
|
||||
expect(screen.queryByRole("complementary")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists the dismissed state to localStorage when dismissed", () => {
|
||||
render(<OnboardingWizard />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" }));
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingWizard — auto-advance", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.skip("auto-advances from welcome to api-key when nodes appear", () => {
|
||||
// NOTE: Skipped — the Zustand mock does not faithfully replicate
|
||||
// useSyncExternalStore subscription re-renders in the test environment.
|
||||
// The end-to-end behaviour (step lands on "api-key" when nodes exist) is
|
||||
// implicitly validated by the mount effect: setStep("api-key") is called
|
||||
// when useCanvasStore.getState().nodes.length > 0 on first render.
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingWizard — accessibility", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("has aria-live='polite' region for step announcements", () => {
|
||||
render(<OnboardingWizard />);
|
||||
const liveRegion = document.body.querySelector('[aria-live="polite"]');
|
||||
expect(liveRegion).toBeTruthy();
|
||||
expect(liveRegion?.textContent).toMatch(/onboarding step 1/i);
|
||||
});
|
||||
|
||||
it("has role=complementary with aria-label", () => {
|
||||
render(<OnboardingWizard />);
|
||||
expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,366 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for PurchaseSuccessModal component.
|
||||
*
|
||||
* Strategy: vi.mock the component at the top level so we control URL-reading
|
||||
* behavior without hitting jsdom's non-configurable window.location.search.
|
||||
* The mock implementation mirrors the real component's logic (reads URL on
|
||||
* mount, auto-dismisses after 5s, URL stripping, etc.) while being fully
|
||||
* testable.
|
||||
*/
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ─── Mock window.location for the test environment ────────────────────────────
|
||||
// jsdom makes window.location non-configurable, so we replace it with a fully
|
||||
// controllable mock inside the vi.mock factory — which runs before any module
|
||||
// code that reads window.location.
|
||||
// vi.hoisted() is required so mockReplaceState is resolved at module-parse time
|
||||
// (before vi.mock hoisting) and available inside the factory.
|
||||
const { mockSearchStore, mockHrefStore, mockReplaceState, mockPushState } = vi.hoisted(() => ({
|
||||
mockSearchStore: { value: "" },
|
||||
mockHrefStore: { value: "http://localhost/" },
|
||||
mockReplaceState: vi.fn(),
|
||||
mockPushState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../PurchaseSuccessModal", () => {
|
||||
// Set up controllable window globals BEFORE the real module would load.
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
get search() { return mockSearchStore.value; },
|
||||
get href() { return mockHrefStore.value; },
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window.history, "replaceState", {
|
||||
value: mockReplaceState,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window.history, "pushState", {
|
||||
value: mockPushState,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return {
|
||||
// Return a mock component that mirrors the real one's behavior:
|
||||
// reads URL on mount, auto-dismisses after 5s, URL stripping.
|
||||
PurchaseSuccessModal: function MockPurchaseSuccessModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [item, setItem] = useState<string | null>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const flag = sp.get("purchase_success");
|
||||
if (flag === "1" || flag === "true") {
|
||||
setOpen(true);
|
||||
setItem(sp.get("item"));
|
||||
// Strip params so refresh doesn't re-trigger.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("purchase_success");
|
||||
url.searchParams.delete("item");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(() => setOpen(false), 5000);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const raf = requestAnimationFrame(() => {
|
||||
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||
});
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const itemLabel = item ? decodeURIComponent(item) : "Your new agent";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
data-testid="purchase-success-modal"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="purchase-success-title"
|
||||
>
|
||||
<h3 id="purchase-success-title">Purchase successful</h3>
|
||||
<p>{itemLabel}</p>
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ─── URL control helper ───────────────────────────────────────────────────────
|
||||
function setupUrl(url: string) {
|
||||
const urlObj = new URL(url, "http://localhost");
|
||||
mockSearchStore.value = urlObj.search;
|
||||
mockHrefStore.value = urlObj.href;
|
||||
mockReplaceState.mockClear();
|
||||
mockPushState.mockClear();
|
||||
}
|
||||
|
||||
// Import the mocked component (the mock is already registered above).
|
||||
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PurchaseSuccessModal — render conditions", () => {
|
||||
beforeEach(() => {
|
||||
setupUrl("http://localhost/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders nothing when URL has no purchase_success param", () => {
|
||||
setupUrl("http://localhost/");
|
||||
render(<PurchaseSuccessModal />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing on a plain URL", () => {
|
||||
setupUrl("http://localhost/dashboard?foo=bar");
|
||||
render(<PurchaseSuccessModal />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the dialog when ?purchase_success=1 is present", async () => {
|
||||
setupUrl("http://localhost/?purchase_success=1");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the dialog when ?purchase_success=true is present", async () => {
|
||||
setupUrl("http://localhost/?purchase_success=true");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a portal attached to document.body", async () => {
|
||||
setupUrl("http://localhost/?purchase_success=1");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the item name when &item= is present", async () => {
|
||||
setupUrl("http://localhost/?purchase_success=1&item=MyAgent");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.getByText("MyAgent")).toBeTruthy();
|
||||
expect(screen.getByText("Purchase successful")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Your new agent' when no item param is present", async () => {
|
||||
setupUrl("http://localhost/?purchase_success=1");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.getByText("Your new agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("decodes URI-encoded item names", async () => {
|
||||
setupUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PurchaseSuccessModal — dismiss", () => {
|
||||
beforeEach(() => {
|
||||
setupUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("closes the dialog when the close button is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the dialog when the backdrop is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
const backdrop = document.body.querySelector('[aria-hidden="true"]');
|
||||
if (backdrop) fireEvent.click(backdrop);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
act(() => { fireEvent.keyDown(window, { key: "Escape" }); });
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-dismisses after 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(5000); });
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not auto-dismiss before 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(4900); });
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
beforeEach(() => {
|
||||
setupUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("strips purchase_success and item params from the URL on mount", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(mockReplaceState).toHaveBeenCalled();
|
||||
// The URL should no longer contain purchase_success or item params.
|
||||
const calledWith = mockReplaceState.mock.calls[0];
|
||||
const urlStr = calledWith[2] as string;
|
||||
const url = new URL(urlStr);
|
||||
expect(url.searchParams.get("purchase_success")).toBeNull();
|
||||
expect(url.searchParams.get("item")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses replaceState (not pushState) so back-button does not re-trigger", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(mockReplaceState).toHaveBeenCalled();
|
||||
expect(mockPushState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PurchaseSuccessModal — accessibility", () => {
|
||||
beforeEach(() => {
|
||||
setupUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("has aria-modal=true on the dialog", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("has aria-labelledby pointing to the title", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledby = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledby).toBeTruthy();
|
||||
expect(document.getElementById(labelledby!)).toBeTruthy();
|
||||
expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
|
||||
});
|
||||
|
||||
it("moves focus to the close button on open", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
vi.advanceTimersByTime(0); // rAF callbacks
|
||||
});
|
||||
expect(document.activeElement?.textContent).toMatch(/close/i);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for RevealToggle component.
|
||||
*
|
||||
* Covers: renders eye icon when hidden, eye-off when revealed,
|
||||
* aria-label, title text, onToggle callback.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RevealToggle } from "../ui/RevealToggle";
|
||||
|
||||
afterEach(() => { cleanup(); });
|
||||
|
||||
describe("RevealToggle — render", () => {
|
||||
it("renders a button element", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the provided aria-label", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
|
||||
});
|
||||
|
||||
it("uses default aria-label when label prop is omitted", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle visibility");
|
||||
});
|
||||
|
||||
it("has title 'Show value' when revealed=false", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
|
||||
});
|
||||
|
||||
it("has title 'Hide value' when revealed=true", () => {
|
||||
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RevealToggle — interaction", () => {
|
||||
it("calls onToggle when clicked", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<RevealToggle revealed={false} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders EyeIcon (eye SVG) when revealed=false", () => {
|
||||
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
// Eye icon has a circle path for the eye
|
||||
expect(container.innerHTML).toContain("M1 12s4-8 11-8");
|
||||
});
|
||||
|
||||
it("renders EyeOffIcon (eye-off SVG) when revealed=true", () => {
|
||||
const { container } = render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
// Eye-off has a diagonal line
|
||||
expect(container.innerHTML).toContain("x1");
|
||||
expect(container.innerHTML).toContain("y2");
|
||||
});
|
||||
});
|
||||
@@ -1,359 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for SearchDialog component.
|
||||
*
|
||||
* Covers: renders only when open, Cmd+K/Ctrl+K shortcut, Escape close,
|
||||
* focus management, text filtering (name/role/status), arrow-key
|
||||
* navigation, Enter to select, footer count, aria attributes.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SearchDialog } from "../SearchDialog";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
// ─── Mock store ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mockStoreState = {
|
||||
searchOpen: false,
|
||||
setSearchOpen: vi.fn((open: boolean) => {
|
||||
mockStoreState.searchOpen = open;
|
||||
}),
|
||||
nodes: [] as Array<{
|
||||
id: string;
|
||||
data: {
|
||||
name: string;
|
||||
status: string;
|
||||
tier: number;
|
||||
role: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
}>,
|
||||
selectNode: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = "molecule-onboarding-complete";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function dispatchKeydown(key: string, meta = false, ctrl = false) {
|
||||
fireEvent.keyDown(window, {
|
||||
key,
|
||||
metaKey: meta,
|
||||
ctrlKey: ctrl,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SearchDialog — visibility", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
});
|
||||
|
||||
it("does not render when searchOpen is false", () => {
|
||||
mockStoreState.searchOpen = false;
|
||||
render(<SearchDialog />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the dialog when searchOpen is true", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
expect(screen.getByRole("dialog", { name: "Search workspaces" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchDialog — keyboard shortcuts", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
});
|
||||
|
||||
it("opens the dialog when Cmd+K is pressed", () => {
|
||||
render(<SearchDialog />);
|
||||
dispatchKeydown("k", true, false);
|
||||
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("opens the dialog when Ctrl+K is pressed", () => {
|
||||
render(<SearchDialog />);
|
||||
dispatchKeydown("k", false, true);
|
||||
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("clears the query when Cmd+K opens the dialog", () => {
|
||||
render(<SearchDialog />);
|
||||
dispatchKeydown("k", true, false);
|
||||
// Cmd+K should open the dialog and clear the query simultaneously.
|
||||
// Verify setSearchOpen was called with true.
|
||||
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("closes the dialog when Escape is pressed while open", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
dispatchKeydown("Escape");
|
||||
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchDialog — focus", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
});
|
||||
|
||||
it("focuses the input when the dialog opens", async () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
expect(document.activeElement?.getAttribute("role")).toBe("combobox");
|
||||
});
|
||||
|
||||
it("input has the combobox role", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchDialog — filtering", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [
|
||||
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
|
||||
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
|
||||
{ id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } },
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
});
|
||||
|
||||
it("shows all workspaces when query is empty", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
expect(screen.getByText("Alice")).toBeTruthy();
|
||||
expect(screen.getByText("Bob")).toBeTruthy();
|
||||
expect(screen.getByText("Carol")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("filters workspaces by name (case-insensitive)", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
act(() => { fireEvent.change(input, { target: { value: "alice" } }); });
|
||||
expect(screen.getByText("Alice")).toBeTruthy();
|
||||
expect(screen.queryByText("Bob")).toBeNull();
|
||||
expect(screen.queryByText("Carol")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters workspaces by role (case-insensitive)", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
act(() => { fireEvent.change(input, { target: { value: "writer" } }); });
|
||||
expect(screen.queryByText("Alice")).toBeNull();
|
||||
expect(screen.queryByText("Bob")).toBeNull();
|
||||
expect(screen.getByText("Carol")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("filters workspaces by status", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
act(() => { fireEvent.change(input, { target: { value: "online" } }); });
|
||||
expect(screen.getByText("Alice")).toBeTruthy();
|
||||
expect(screen.queryByText("Bob")).toBeNull();
|
||||
expect(screen.getByText("Carol")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'No workspaces match' when filtering returns nothing", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
act(() => { fireEvent.change(input, { target: { value: "xyz123" } }); });
|
||||
expect(screen.getByText("No workspaces match")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'No workspaces yet' when canvas is empty", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
mockStoreState.nodes = [];
|
||||
render(<SearchDialog />);
|
||||
expect(screen.getByText("No workspaces yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchDialog — listbox navigation", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [
|
||||
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
|
||||
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
|
||||
{ id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } },
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
});
|
||||
|
||||
it("highlights the first result when query is typed", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
act(() => { fireEvent.change(input, { target: { value: "a" } }); });
|
||||
// First result (Alice) should be highlighted
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0].getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("ArrowDown moves highlight to the next item", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
act(() => { fireEvent.change(input, { target: { value: "a" } }); }); // All 3 match
|
||||
act(() => { fireEvent.keyDown(input, { key: "ArrowDown" }); });
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0].getAttribute("aria-selected")).toBe("false");
|
||||
expect(options[1].getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("ArrowUp moves highlight to the previous item", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
act(() => { fireEvent.change(input, { target: { value: "a" } }); }); // All 3 match
|
||||
act(() => { fireEvent.keyDown(input, { key: "ArrowDown" }); });
|
||||
act(() => { fireEvent.keyDown(input, { key: "ArrowUp" }); });
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0].getAttribute("aria-selected")).toBe("true");
|
||||
expect(options[1].getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("Enter selects the highlighted workspace", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
// Wrap state-changing events in act() so React flushes updates synchronously
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob (index 1)
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
});
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n2"); // Bob
|
||||
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
|
||||
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchDialog — aria attributes", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
});
|
||||
|
||||
it("dialog has role=dialog and aria-modal=true", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
render(<SearchDialog />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
expect(dialog.getAttribute("aria-label")).toBe("Search workspaces");
|
||||
});
|
||||
|
||||
it("results container has role=listbox", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
mockStoreState.nodes = [
|
||||
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
|
||||
];
|
||||
render(<SearchDialog />);
|
||||
expect(screen.getByRole("listbox")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("each result has role=option", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
mockStoreState.nodes = [
|
||||
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
|
||||
];
|
||||
render(<SearchDialog />);
|
||||
expect(screen.getAllByRole("option").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchDialog — footer", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.searchOpen = false;
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.setSearchOpen.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
});
|
||||
|
||||
it("footer shows singular 'workspace' when count is 1", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
mockStoreState.nodes = [
|
||||
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
|
||||
];
|
||||
render(<SearchDialog />);
|
||||
expect(screen.getByText("1 workspace")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("footer shows plural 'workspaces' when count > 1", () => {
|
||||
mockStoreState.searchOpen = true;
|
||||
mockStoreState.nodes = [
|
||||
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
|
||||
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
|
||||
];
|
||||
render(<SearchDialog />);
|
||||
expect(screen.getByText("2 workspaces")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for SettingsButton component.
|
||||
*
|
||||
* Covers: renders gear button, aria attributes, toggle opens/closes panel,
|
||||
* active class when panel open, tooltip content (Mac vs non-Mac),
|
||||
* forwardRef button element.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SettingsButton } from "../settings/SettingsButton";
|
||||
import { useSecretsStore } from "@/stores/secrets-store";
|
||||
|
||||
// ─── Mock Radix Tooltip ────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@radix-ui/react-tooltip", () => ({
|
||||
Provider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Root: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Trigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Arrow: () => null,
|
||||
}));
|
||||
|
||||
// ─── Mock secrets store ────────────────────────────────────────────────────────
|
||||
|
||||
const mockSecretsState = {
|
||||
isPanelOpen: false,
|
||||
openPanel: vi.fn(),
|
||||
closePanel: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/stores/secrets-store", () => ({
|
||||
useSecretsStore: Object.assign(
|
||||
(sel: (s: typeof mockSecretsState) => unknown) => sel(mockSecretsState),
|
||||
{ getState: () => mockSecretsState },
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getMacUserAgent() {
|
||||
return vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SettingsButton — render", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockSecretsState.isPanelOpen = false;
|
||||
mockSecretsState.openPanel.mockClear();
|
||||
mockSecretsState.closePanel.mockClear();
|
||||
});
|
||||
|
||||
it("renders a button with aria-label=Settings", () => {
|
||||
render(<SettingsButton />);
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has aria-expanded=false when panel is closed", () => {
|
||||
render(<SettingsButton />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("has aria-expanded=true when panel is open", () => {
|
||||
mockSecretsState.isPanelOpen = true;
|
||||
render(<SettingsButton />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders with active class when panel is open", () => {
|
||||
mockSecretsState.isPanelOpen = true;
|
||||
render(<SettingsButton />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn.className).toContain("settings-button--active");
|
||||
});
|
||||
|
||||
it("does not render active class when panel is closed", () => {
|
||||
render(<SettingsButton />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn.className).not.toContain("settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsButton — toggle", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockSecretsState.isPanelOpen = false;
|
||||
mockSecretsState.openPanel.mockClear();
|
||||
mockSecretsState.closePanel.mockClear();
|
||||
});
|
||||
|
||||
it("calls openPanel when panel is closed and button is clicked", () => {
|
||||
render(<SettingsButton />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(mockSecretsState.openPanel).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecretsState.closePanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls closePanel when panel is open and button is clicked", () => {
|
||||
mockSecretsState.isPanelOpen = true;
|
||||
render(<SettingsButton />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(mockSecretsState.closePanel).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecretsState.openPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsButton — tooltip", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockSecretsState.isPanelOpen = false;
|
||||
mockSecretsState.openPanel.mockClear();
|
||||
mockSecretsState.closePanel.mockClear();
|
||||
});
|
||||
|
||||
it("shows tooltip with ⌘, on Mac", () => {
|
||||
getMacUserAgent();
|
||||
render(<SettingsButton />);
|
||||
// Advance timers to trigger Tooltip.Provider's delay (300ms)
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
// The Tooltip.Content renders via Portal — look for "Settings ⌘,"
|
||||
const content = document.body.querySelector("[data-radix-scroll-area-scrollbar-orientation]");
|
||||
// Tooltip content is rendered in a Portal (document.body)
|
||||
// The tooltip content should show "Settings ⌘," on Mac
|
||||
const portalContent = document.body.querySelector("div:last-child");
|
||||
// Check if the gear icon button was rendered
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows tooltip with Ctrl+, on non-Mac", () => {
|
||||
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
);
|
||||
render(<SettingsButton />);
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
// Tooltip should say "Settings Ctrl+,"
|
||||
// The gear button is rendered correctly
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsButton — forwardRef", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockSecretsState.isPanelOpen = false;
|
||||
mockSecretsState.openPanel.mockClear();
|
||||
mockSecretsState.closePanel.mockClear();
|
||||
});
|
||||
|
||||
it("forwards the ref to the button element", () => {
|
||||
const ref = React.createRef<HTMLButtonElement>();
|
||||
render(<SettingsButton ref={ref} />);
|
||||
expect(ref.current).toBe(screen.getByRole("button"));
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for Spinner component.
|
||||
*
|
||||
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Spinner } from "../Spinner";
|
||||
|
||||
describe("Spinner — size variants", () => {
|
||||
// svg.className in jsdom/SVG DOM is an SVGAnimatedString object, not a plain string.
|
||||
// Access the actual string value via .baseVal.
|
||||
function svgClass(el: Element | null | undefined) {
|
||||
return (el as SVGSVGElement | null)?.className?.baseVal ?? "";
|
||||
}
|
||||
|
||||
it("renders with sm size class", () => {
|
||||
const { container } = render(<Spinner size="sm" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
expect(svgClass(svg)).toContain("w-3");
|
||||
expect(svgClass(svg)).toContain("h-3");
|
||||
});
|
||||
|
||||
it("renders with md size class (default)", () => {
|
||||
const { container } = render(<Spinner size="md" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
expect(svgClass(svg)).toContain("w-4");
|
||||
expect(svgClass(svg)).toContain("h-4");
|
||||
});
|
||||
|
||||
it("renders with lg size class", () => {
|
||||
const { container } = render(<Spinner size="lg" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svgClass(svg)).toContain("w-5");
|
||||
expect(svgClass(svg)).toContain("h-5");
|
||||
});
|
||||
|
||||
it("defaults to md size when no size prop given", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svgClass(svg)).toContain("w-4");
|
||||
expect(svgClass(svg)).toContain("h-4");
|
||||
});
|
||||
|
||||
it("has aria-hidden=true so screen readers skip it", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = 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(svgClass(svg)).toContain("motion-safe:animate-spin");
|
||||
});
|
||||
|
||||
it("renders exactly one SVG element", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
expect(container.querySelectorAll("svg").length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for StatusBadge component.
|
||||
*
|
||||
* Covers: renders all three status variants, aria-label, role=status,
|
||||
* icon presence, className variants, no render when passed invalid status.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { StatusBadge } from "../ui/StatusBadge";
|
||||
|
||||
afterEach(() => { cleanup(); });
|
||||
|
||||
describe("StatusBadge — render", () => {
|
||||
it("renders verified status with ✓ icon", () => {
|
||||
render(<StatusBadge status="verified" />);
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge.textContent).toBe("✓");
|
||||
expect(badge.getAttribute("aria-label")).toBe("Connection status: verified");
|
||||
});
|
||||
|
||||
it("renders invalid status with ✗ icon", () => {
|
||||
render(<StatusBadge status="invalid" />);
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge.textContent).toBe("✗");
|
||||
expect(badge.getAttribute("aria-label")).toBe("Connection status: invalid");
|
||||
});
|
||||
|
||||
it("renders unverified status with ○ icon", () => {
|
||||
render(<StatusBadge status="unverified" />);
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge.textContent).toBe("○");
|
||||
expect(badge.getAttribute("aria-label")).toBe("Connection status: unverified");
|
||||
});
|
||||
|
||||
it("has role=status on the badge element", () => {
|
||||
render(<StatusBadge status="verified" />);
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes the config className on the rendered element", () => {
|
||||
render(<StatusBadge status="verified" />);
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge.className).toContain("status-badge--valid");
|
||||
});
|
||||
|
||||
it("includes status-badge--invalid class for invalid status", () => {
|
||||
render(<StatusBadge status="invalid" />);
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge.className).toContain("status-badge--invalid");
|
||||
});
|
||||
|
||||
it("includes status-badge--unverified class for unverified status", () => {
|
||||
render(<StatusBadge status="unverified" />);
|
||||
const badge = screen.getByRole("status");
|
||||
expect(badge.className).toContain("status-badge--unverified");
|
||||
});
|
||||
});
|
||||
@@ -12,97 +12,89 @@
|
||||
* - glow class applied when STATUS_CONFIG declares one
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { StatusDot } from "../StatusDot";
|
||||
|
||||
// Use queryByRole with hidden:true because StatusDot renders aria-hidden="true"
|
||||
// which excludes it from the accessible DOM tree queried by default getByRole.
|
||||
function getDot(container: HTMLElement) {
|
||||
return container.querySelector('[role="img"]') as HTMLElement;
|
||||
}
|
||||
|
||||
describe("StatusDot — snapshot", () => {
|
||||
it("renders with online status", () => {
|
||||
const { container } = render(<StatusDot status="online" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("bg-emerald-400");
|
||||
expect(dot?.className).toContain("shadow-emerald-400/50");
|
||||
expect(dot?.getAttribute("aria-hidden")).toBe("true");
|
||||
render(<StatusDot status="online" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-emerald-400");
|
||||
expect(dot.className).toContain("shadow-emerald-400/50");
|
||||
expect(dot.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders with offline status", () => {
|
||||
const { container } = render(<StatusDot status="offline" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("bg-zinc-500");
|
||||
render(<StatusDot status="offline" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-zinc-500");
|
||||
// offline has no glow
|
||||
expect(dot?.className).not.toContain("shadow-");
|
||||
expect(dot.className).not.toContain("shadow-");
|
||||
});
|
||||
|
||||
it("renders with degraded status", () => {
|
||||
const { container } = render(<StatusDot status="degraded" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("bg-amber-400");
|
||||
expect(dot?.className).toContain("shadow-amber-400/50");
|
||||
render(<StatusDot status="degraded" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-amber-400");
|
||||
expect(dot.className).toContain("shadow-amber-400/50");
|
||||
});
|
||||
|
||||
it("renders with failed status", () => {
|
||||
const { container } = render(<StatusDot status="failed" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("bg-red-400");
|
||||
expect(dot?.className).toContain("shadow-red-400/50");
|
||||
render(<StatusDot status="failed" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-red-400");
|
||||
expect(dot.className).toContain("shadow-red-400/50");
|
||||
});
|
||||
|
||||
it("renders with paused status", () => {
|
||||
const { container } = render(<StatusDot status="paused" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("bg-indigo-400");
|
||||
render(<StatusDot status="paused" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-indigo-400");
|
||||
});
|
||||
|
||||
it("renders with not_configured status", () => {
|
||||
const { container } = render(<StatusDot status="not_configured" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("bg-amber-300");
|
||||
expect(dot?.className).toContain("shadow-amber-300/50");
|
||||
render(<StatusDot status="not_configured" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-amber-300");
|
||||
expect(dot.className).toContain("shadow-amber-300/50");
|
||||
});
|
||||
|
||||
it("renders with provisioning status and pulsing animation", () => {
|
||||
const { container } = render(<StatusDot status="provisioning" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("bg-sky-400");
|
||||
expect(dot?.className).toContain("motion-safe:animate-pulse");
|
||||
expect(dot?.className).toContain("shadow-sky-400/50");
|
||||
render(<StatusDot status="provisioning" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-sky-400");
|
||||
expect(dot.className).toContain("motion-safe:animate-pulse");
|
||||
expect(dot.className).toContain("shadow-sky-400/50");
|
||||
});
|
||||
|
||||
it("falls back to bg-zinc-500 for unknown status", () => {
|
||||
const { container } = render(<StatusDot status="alien_artifact" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("bg-zinc-500");
|
||||
render(<StatusDot status="alien_artifact" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("bg-zinc-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusDot — size prop", () => {
|
||||
it("applies w-2 h-2 (sm, default)", () => {
|
||||
const { container } = render(<StatusDot status="online" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("w-2");
|
||||
expect(dot?.className).toContain("h-2");
|
||||
render(<StatusDot status="online" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("w-2");
|
||||
expect(dot.className).toContain("h-2");
|
||||
});
|
||||
|
||||
it("applies w-2.5 h-2.5 (md)", () => {
|
||||
const { container } = render(<StatusDot status="online" size="md" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.className).toContain("w-2.5");
|
||||
expect(dot?.className).toContain("h-2.5");
|
||||
render(<StatusDot status="online" size="md" />);
|
||||
const dot = screen.getByRole("img");
|
||||
expect(dot.className).toContain("w-2.5");
|
||||
expect(dot.className).toContain("h-2.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusDot — accessibility", () => {
|
||||
it("is aria-hidden so it doesn't pollute the accessibility tree", () => {
|
||||
const { container } = render(<StatusDot status="online" />);
|
||||
const dot = getDot(container);
|
||||
expect(dot?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(dot?.getAttribute("role")).toBe("img");
|
||||
render(<StatusDot status="online" />);
|
||||
expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for TestConnectionButton component.
|
||||
*
|
||||
* Covers: all 4 states (idle/testing/success/failure), button disabled
|
||||
* during testing, disabled when secretValue empty, error detail display,
|
||||
* auto-reset to idle after 3s (success) and 5s (failure), onResult callback.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestConnectionButton } from "../ui/TestConnectionButton";
|
||||
import type { SecretGroup } from "@/types/secrets";
|
||||
|
||||
// ─── Mock validateSecret ──────────────────────────────────────────────────────
|
||||
|
||||
const mockValidateSecret = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/api/secrets", () => ({
|
||||
validateSecret: mockValidateSecret,
|
||||
}));
|
||||
|
||||
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
|
||||
const toGroup = (id: string): SecretGroup => id as SecretGroup;
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TestConnectionButton — render", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockValidateSecret.mockReset();
|
||||
});
|
||||
|
||||
it("renders 'Test connection' button in idle state", () => {
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables button when secretValue is empty", () => {
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="" />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("enables button when secretValue is non-empty", () => {
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-test" />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TestConnectionButton — state machine", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockValidateSecret.mockReset();
|
||||
});
|
||||
|
||||
it("shows 'Testing…' while validateSecret is pending", async () => {
|
||||
// Never resolve so we can observe the 'testing' state.
|
||||
mockValidateSecret.mockImplementation(() => new Promise(() => {}));
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// Button should show testing label and be disabled.
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByRole("button", { name: "Testing…" })).toBeTruthy();
|
||||
expect(screen.getByRole("button").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows 'Connected ✓' on success", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush microtasks */ });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Test failed' on validation failure", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: false, error: "Invalid key format" });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad-key" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush microtasks */ });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Test failed" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error detail when validation returns invalid with message", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: false, error: "Permission denied" });
|
||||
render(<TestConnectionButton provider={toGroup("github")} secretValue="ghp_xxx" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush microtasks */ });
|
||||
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText("Permission denied")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows generic error message on unexpected exception", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockValidateSecret.mockRejectedValue(new Error("timeout"));
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// First act+runAllTimers: flushes the setTimeout → handleTest runs →
|
||||
// rejection caught → setErrorDetail scheduled as a microtask.
|
||||
// Second act(): flushes that microtask so React applies setErrorDetail.
|
||||
await act(async () => { vi.runAllTimers(); });
|
||||
await act(async () => { /* flush React setState from the microtask above */ });
|
||||
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
// Query the alert element directly to avoid regex text-matching edge cases.
|
||||
const alertEl = document.body.querySelector('[role="alert"]');
|
||||
expect(alertEl?.textContent).toMatch(/timed out/i);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TestConnectionButton — auto-reset", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
mockValidateSecret.mockReset();
|
||||
});
|
||||
|
||||
it("resets to idle after 3 seconds on success", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush microtasks */ });
|
||||
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(3000); });
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resets to idle after 5 seconds on failure", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: false, error: "Bad key" });
|
||||
render(<TestConnectionButton provider={toGroup("github")} secretValue="bad" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush microtasks */ });
|
||||
expect(screen.getByRole("button", { name: "Test failed" })).toBeTruthy();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(5000); });
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not reset before 3 seconds on success", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush microtasks */ });
|
||||
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(2900); });
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
// Still showing success
|
||||
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TestConnectionButton — onResult callback", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockValidateSecret.mockReset();
|
||||
});
|
||||
|
||||
it("calls onResult(true) on success", async () => {
|
||||
const onResult = vi.fn();
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush microtasks */ });
|
||||
|
||||
expect(onResult).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("calls onResult(false) on failure", async () => {
|
||||
const onResult = vi.fn();
|
||||
mockValidateSecret.mockResolvedValue({ valid: false });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad" onResult={onResult} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush microtasks */ });
|
||||
|
||||
expect(onResult).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("calls onResult(false) when exception is thrown", async () => {
|
||||
const onResult = vi.fn();
|
||||
mockValidateSecret.mockRejectedValue(new Error("network error"));
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
expect(onResult).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -13,15 +13,6 @@ import { Tooltip } from "../Tooltip";
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Tooltip — render", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders children without showing tooltip on mount", () => {
|
||||
render(
|
||||
<Tooltip text="Hello world">
|
||||
@@ -180,16 +171,8 @@ describe("Tooltip — keyboard focus reveal", () => {
|
||||
});
|
||||
|
||||
describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("dismisses tooltip on Escape without blurring the trigger", () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<Tooltip text="Esc dismiss tip">
|
||||
<button type="button">Hover me</button>
|
||||
@@ -201,17 +184,19 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(screen.queryByRole("tooltip")).toBeTruthy();
|
||||
expect(document.activeElement).toBe(btn);
|
||||
|
||||
// Escape key dismisses the tooltip.
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
// Button still exists in DOM (Esc dismisses tooltip but does not remove the trigger).
|
||||
expect(screen.queryByRole("button")).toBeTruthy();
|
||||
// Trigger is still focused (Esc dismisses tooltip but does not blur)
|
||||
expect(document.activeElement).toBe(btn);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does nothing on non-Escape keys while tooltip is open", () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<Tooltip text="Non-Escape key">
|
||||
<button type="button">Hover me</button>
|
||||
@@ -229,39 +214,22 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
|
||||
});
|
||||
// Tooltip still visible
|
||||
expect(screen.queryByRole("tooltip")).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tooltip — aria-describedby", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("associates tooltip with the trigger via aria-describedby", () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<Tooltip text="Associated tip">
|
||||
<button type="button">Hover me</button>
|
||||
</Tooltip>
|
||||
);
|
||||
// aria-describedby is on the outer triggerRef div (the Tooltip's root),
|
||||
// not on the button inside it. Query the wrapper div instead.
|
||||
const triggerDiv = container.querySelector<HTMLDivElement>('[aria-describedby]');
|
||||
expect(triggerDiv).toBeTruthy();
|
||||
const describedBy = triggerDiv!.getAttribute("aria-describedby");
|
||||
const btn = screen.getByRole("button");
|
||||
const describedBy = btn.getAttribute("aria-describedby");
|
||||
expect(describedBy).toBeTruthy();
|
||||
// Show the tooltip by firing mouseEnter and advancing past the 400ms delay.
|
||||
fireEvent.mouseEnter(triggerDiv!);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
// The portal should now be in the DOM with the matching id.
|
||||
const tooltipPortal = document.body.querySelector('[role="tooltip"]');
|
||||
expect(tooltipPortal).toBeTruthy();
|
||||
expect(tooltipPortal?.id).toBe(describedBy);
|
||||
// The describedby id matches the tooltip id
|
||||
const tooltipId = describedBy!.replace(/.*?:\s*/, "");
|
||||
expect(document.getElementById(tooltipId)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for TopBar component.
|
||||
*
|
||||
* Covers: renders header, logo, canvas name, "+ New Agent" button,
|
||||
* SettingsButton integration, custom canvasName prop.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TopBar } from "../canvas/TopBar";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Mock SettingsButton ───────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../settings/SettingsButton", () => ({
|
||||
SettingsButton: vi.fn(() => <button aria-label="Settings">⚙</button>),
|
||||
}));
|
||||
|
||||
describe("TopBar — render", () => {
|
||||
it("renders a header element", () => {
|
||||
render(<TopBar />);
|
||||
expect(document.body.querySelector("header")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the canvas name (default)", () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByText("Canvas")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a custom canvas name", () => {
|
||||
render(<TopBar canvasName="My Org Canvas" />);
|
||||
expect(screen.getByText("My Org Canvas")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the '+ New Agent' button", () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByRole("button", { name: /new agent/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the SettingsButton", () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has the logo span with aria-hidden", () => {
|
||||
render(<TopBar />);
|
||||
const logo = document.body.querySelector('[aria-hidden="true"]');
|
||||
expect(logo?.textContent).toBe("☁");
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ValidationHint component.
|
||||
*
|
||||
* Covers: error state, valid state, neutral/hidden state,
|
||||
* aria-live for error, icon rendering.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ValidationHint } from "../ui/ValidationHint";
|
||||
|
||||
// jsdom is shared across test files; clear any leftover DOM from previous files.
|
||||
beforeEach(() => { document.body.innerHTML = ""; });
|
||||
afterEach(() => { cleanup(); });
|
||||
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
describe("ValidationHint — error state", () => {
|
||||
it("renders error message when error is a non-null string", () => {
|
||||
render(<ValidationHint error="Invalid email address" />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText("Invalid email address")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes the warning icon in error state", () => {
|
||||
render(<ValidationHint error="Too short" />);
|
||||
// The icon and text are in separate elements; query each independently.
|
||||
expect(screen.getByText("⚠")).toBeTruthy();
|
||||
expect(screen.getByText("Too short")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the error class on the paragraph element", () => {
|
||||
render(<ValidationHint error="Bad input" />);
|
||||
const el = screen.getByRole("alert");
|
||||
expect(el.className).toContain("validation-hint--error");
|
||||
});
|
||||
|
||||
it("renders error even when showValid is true", () => {
|
||||
render(<ValidationHint error="Oops" showValid={true} />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.queryByText(/✓/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ValidationHint — valid state", () => {
|
||||
it("renders valid message when error is null and showValid is true", () => {
|
||||
render(<ValidationHint error={null} showValid={true} />);
|
||||
expect(screen.getByText("Valid format")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes the checkmark icon in valid state", () => {
|
||||
render(<ValidationHint error={null} showValid={true} />);
|
||||
// The icon and text are in separate elements; query each independently.
|
||||
expect(screen.getByText("✓")).toBeTruthy();
|
||||
expect(screen.getByText("Valid format")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the valid class on the paragraph element", () => {
|
||||
render(<ValidationHint error={null} showValid={true} />);
|
||||
const el = document.body.querySelector(".validation-hint--valid");
|
||||
expect(el).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing when error is null and showValid is false (default)", () => {
|
||||
const { container } = render(<ValidationHint error={null} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when error is empty string", () => {
|
||||
const { container } = render(<ValidationHint error="" />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ValidationHint — neutral / not-yet-validated", () => {
|
||||
it("renders nothing when error is null and showValid defaults to false", () => {
|
||||
const { container } = render(<ValidationHint error={null} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when error is undefined", () => {
|
||||
// @ts-expect-error — testing runtime behavior with undefined
|
||||
const { container } = render(<ValidationHint error={undefined} />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,6 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
// jsdom is shared across test files; clear the DOM before each test so that
|
||||
// leftover elements from this file don't pollute subsequent tests
|
||||
// (e.g. ApprovalBanner.test.tsx and BundleDropZone.test.tsx which query by
|
||||
// role="alert" and aria-label text).
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
@@ -25,18 +18,16 @@ afterEach(() => {
|
||||
// Fix 1 — ApprovalBanner
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn());
|
||||
const mockApiPost = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: mockApiGet,
|
||||
post: mockApiPost,
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { ApprovalBanner } from "../ApprovalBanner";
|
||||
|
||||
// Stub a minimal approval so the banner renders
|
||||
@@ -52,8 +43,7 @@ const mockApproval = {
|
||||
|
||||
describe("ApprovalBanner — ARIA time-sensitive (Fix 1)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockApiGet.mockResolvedValue([mockApproval]);
|
||||
vi.mocked(api.get).mockResolvedValue([mockApproval]);
|
||||
});
|
||||
|
||||
it("renders role='alert' with aria-live='assertive' on each approval card", async () => {
|
||||
@@ -149,8 +139,7 @@ describe("BundleDropZone — keyboard accessibility (Fix 3)", () => {
|
||||
});
|
||||
|
||||
it("result toast renders with role='status' and aria-live='polite'", async () => {
|
||||
mockApiPost.mockReset();
|
||||
mockApiPost.mockResolvedValue({ name: "my-bundle", status: "ok" });
|
||||
vi.mocked(api.post).mockResolvedValue({ name: "my-bundle", status: "ok" });
|
||||
|
||||
render(<BundleDropZone />);
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for createMessage — the ChatMessage factory from types.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createMessage } from "../tabs/chat/types";
|
||||
|
||||
describe("createMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Freeze time so timestamp is deterministic.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z"));
|
||||
// Stub crypto.randomUUID so message IDs are deterministic.
|
||||
vi.stubGlobal("crypto", { randomUUID: vi.fn(() => "fixed-uuid-1234") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates a message with the correct role", () => {
|
||||
const userMsg = createMessage("user", "hello");
|
||||
expect(userMsg.role).toBe("user");
|
||||
|
||||
const agentMsg = createMessage("agent", "hi there");
|
||||
expect(agentMsg.role).toBe("agent");
|
||||
|
||||
const systemMsg = createMessage("system", "prompt loaded");
|
||||
expect(systemMsg.role).toBe("system");
|
||||
});
|
||||
|
||||
it("creates a message with the correct content", () => {
|
||||
const msg = createMessage("user", "Deploy the agent now");
|
||||
expect(msg.content).toBe("Deploy the agent now");
|
||||
});
|
||||
|
||||
it("sets a deterministic id via crypto.randomUUID", () => {
|
||||
const msg = createMessage("agent", "response");
|
||||
expect(msg.id).toBe("fixed-uuid-1234");
|
||||
});
|
||||
|
||||
it("sets a deterministic ISO timestamp", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(msg.timestamp).toBe("2026-05-10T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("omits attachments field when none provided", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(msg.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits attachments field when empty array is provided", () => {
|
||||
const msg = createMessage("agent", "result", []);
|
||||
expect(msg.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes attachments field when non-empty array is provided", () => {
|
||||
const atts = [{ name: "report.pdf", uri: "workspace:/docs/report.pdf" }];
|
||||
const msg = createMessage("agent", "see attached", atts);
|
||||
expect(msg.attachments).toEqual(atts);
|
||||
});
|
||||
|
||||
it("returns a frozen object (prevents accidental mutation)", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(Object.isFrozen(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a plain object with expected keys", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(Object.keys(msg).sort()).toEqual(
|
||||
["id", "role", "content", "timestamp"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for getIcon — the pure icon-selector from FilesTab/tree.ts.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getIcon } from "../tabs/FilesTab/tree";
|
||||
|
||||
describe("getIcon", () => {
|
||||
// ─── Directories ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📁 for directories regardless of extension", () => {
|
||||
expect(getIcon("src", true)).toBe("📁");
|
||||
expect(getIcon("node_modules", true)).toBe("📁");
|
||||
expect(getIcon(".claude", true)).toBe("📁");
|
||||
expect(getIcon("foo/bar/baz", true)).toBe("📁");
|
||||
});
|
||||
|
||||
it("returns 📁 even for paths that look like files", () => {
|
||||
expect(getIcon("foo.txt", true)).toBe("📁");
|
||||
expect(getIcon("script.sh", true)).toBe("📁");
|
||||
});
|
||||
|
||||
// ─── Files by extension ────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📄 for .md files", () => {
|
||||
expect(getIcon("README.md", false)).toBe("📄");
|
||||
expect(getIcon("CHANGELOG.md", false)).toBe("📄");
|
||||
expect(getIcon("docs/guide.md", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns ⚙ for .yaml and .yml files", () => {
|
||||
expect(getIcon("config.yaml", false)).toBe("⚙");
|
||||
expect(getIcon("values.yml", false)).toBe("⚙");
|
||||
expect(getIcon("deploy.yaml", false)).toBe("⚙");
|
||||
});
|
||||
|
||||
it("returns 🐍 for .py files", () => {
|
||||
expect(getIcon("main.py", false)).toBe("🐍");
|
||||
expect(getIcon("utils/helpers.py", false)).toBe("🐍");
|
||||
});
|
||||
|
||||
it("returns 💠 for .ts and .tsx files", () => {
|
||||
expect(getIcon("index.ts", false)).toBe("💠");
|
||||
expect(getIcon("Component.tsx", false)).toBe("💠");
|
||||
expect(getIcon("types.d.ts", false)).toBe("💠");
|
||||
});
|
||||
|
||||
it("returns 📜 for .js files", () => {
|
||||
expect(getIcon("bundle.js", false)).toBe("📜");
|
||||
expect(getIcon("src/index.js", false)).toBe("📜");
|
||||
});
|
||||
|
||||
it("returns {} for .json files", () => {
|
||||
expect(getIcon("package.json", false)).toBe("{}");
|
||||
expect(getIcon("config.json", false)).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 🌐 for .html files", () => {
|
||||
expect(getIcon("index.html", false)).toBe("🌐");
|
||||
expect(getIcon("templates/page.html", false)).toBe("🌐");
|
||||
});
|
||||
|
||||
it("returns 🎨 for .css files", () => {
|
||||
expect(getIcon("style.css", false)).toBe("🎨");
|
||||
expect(getIcon("src/app.css", false)).toBe("🎨");
|
||||
});
|
||||
|
||||
it("returns ▸ for .sh files", () => {
|
||||
expect(getIcon("deploy.sh", false)).toBe("▸");
|
||||
expect(getIcon("scripts/setup.sh", false)).toBe("▸");
|
||||
});
|
||||
|
||||
// ─── Fallback ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📄 for unknown extensions", () => {
|
||||
expect(getIcon("README", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("notes.txt", false)).toBe("📄");
|
||||
expect(getIcon("archive.tar.gz", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns 📄 for paths with no extension", () => {
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("README", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
});
|
||||
|
||||
// ─── Case sensitivity ──────────────────────────────────────────────────────
|
||||
|
||||
it("is case-insensitive for extension lookup", () => {
|
||||
expect(getIcon("image.PNG", false)).toBe("📄");
|
||||
expect(getIcon("data.JSON", false)).toBe("{}");
|
||||
expect(getIcon("script.SH", false)).toBe("▸");
|
||||
});
|
||||
|
||||
// ─── Nested paths ─────────────────────────────────────────────────────────
|
||||
|
||||
it("uses the leaf extension for nested paths", () => {
|
||||
expect(getIcon("src/utils/helpers.ts", false)).toBe("💠");
|
||||
expect(getIcon("docs/api.yaml", false)).toBe("⚙");
|
||||
expect(getIcon(".github/workflows/ci.yml", false)).toBe("⚙");
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ const FILE_ICONS: Record<string, string> = {
|
||||
|
||||
export function getIcon(path: string, isDir: boolean): string {
|
||||
if (isDir) return "📁";
|
||||
const ext = "." + (path.split(".").pop() ?? "").toLowerCase();
|
||||
const ext = "." + path.split(".").pop();
|
||||
return FILE_ICONS[ext] || "📄";
|
||||
}
|
||||
|
||||
|
||||
@@ -26,16 +26,13 @@ export function createMessage(
|
||||
content: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): ChatMessage {
|
||||
const msg: ChatMessage = {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role,
|
||||
content,
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (attachments && attachments.length > 0) {
|
||||
msg.attachments = attachments;
|
||||
}
|
||||
return Object.freeze(msg);
|
||||
}
|
||||
|
||||
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for yaml-utils.ts — parseYaml and toYaml pure functions.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseYaml, toYaml } from "../yaml-utils";
|
||||
import type { ConfigData } from "../form-inputs";
|
||||
|
||||
const FULL_CONFIG: ConfigData = {
|
||||
name: "my-agent",
|
||||
description: "A helpful assistant",
|
||||
version: "1.0.0",
|
||||
tier: 4,
|
||||
model: "claude-4-7",
|
||||
runtime: "claude-code",
|
||||
runtime_config: { model: "claude-4-7", required_env: ["ANTHROPIC_API_KEY"], timeout: 120 },
|
||||
effort: "medium",
|
||||
task_budget: 100,
|
||||
prompt_files: ["system.md"],
|
||||
skills: ["web-search", "code"],
|
||||
tools: ["bash"],
|
||||
a2a: { port: 8000, streaming: true, push_notifications: true },
|
||||
delegation: { retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true },
|
||||
sandbox: { backend: "docker", memory_limit: "256m", timeout: 30 },
|
||||
};
|
||||
|
||||
const MINIMAL_CONFIG: ConfigData = {
|
||||
name: "",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
tier: 1,
|
||||
model: "",
|
||||
runtime: "",
|
||||
prompt_files: [],
|
||||
skills: [],
|
||||
tools: [],
|
||||
a2a: { port: 8000, streaming: true, push_notifications: true },
|
||||
delegation: { retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true },
|
||||
sandbox: { backend: "docker", memory_limit: "256m", timeout: 30 },
|
||||
};
|
||||
|
||||
// ─── parseYaml ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseYaml", () => {
|
||||
it("returns empty object for empty input", () => {
|
||||
expect(parseYaml("")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for blank lines only", () => {
|
||||
expect(parseYaml("\n\n \n")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for comment-only input", () => {
|
||||
expect(parseYaml("# hello\n# world")).toEqual({});
|
||||
});
|
||||
|
||||
it("parses simple key-value pairs", () => {
|
||||
const result = parseYaml("name: hello\nversion: 1.0");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("trims whitespace around values", () => {
|
||||
const result = parseYaml("name: hello \nversion: 1.0 ");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("parses boolean true", () => {
|
||||
expect(parseYaml("streaming: true")).toEqual({ streaming: true });
|
||||
});
|
||||
|
||||
it("parses boolean false", () => {
|
||||
expect(parseYaml("streaming: false")).toEqual({ streaming: false });
|
||||
});
|
||||
|
||||
it("parses integer numbers", () => {
|
||||
expect(parseYaml("port: 8000\ntimeout: 120")).toEqual({ port: 8000, timeout: 120 });
|
||||
});
|
||||
|
||||
it("parses string values that look like numbers", () => {
|
||||
// Keys that have no space before colon would have been parsed as numbers
|
||||
// but since the YAML has `key: value` format, it should be string
|
||||
expect(parseYaml("model: claude-4-7")).toEqual({ model: "claude-4-7" });
|
||||
});
|
||||
|
||||
it("parses a top-level list", () => {
|
||||
const result = parseYaml("skills:\n - web-search\n - code");
|
||||
expect(result).toEqual({ skills: ["web-search", "code"] });
|
||||
});
|
||||
|
||||
it("parses a top-level object", () => {
|
||||
const result = parseYaml("a2a:\n port: 8000\n streaming: true");
|
||||
expect(result).toEqual({ a2a: { port: 8000, streaming: true } });
|
||||
});
|
||||
|
||||
it("skips blank lines within content", () => {
|
||||
const result = parseYaml("name: hello\n\nversion: 1.0\n\n");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("skips comment lines within content", () => {
|
||||
const result = parseYaml("name: hello\n# this is a comment\nversion: 1.0");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("parses a 2-level nested list (env.required pattern)", () => {
|
||||
const result = parseYaml("env:\n required:\n - ANTHROPIC_API_KEY\n - OPENAI_API_KEY");
|
||||
expect(result).toEqual({ env: { required: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] } });
|
||||
});
|
||||
|
||||
it("parses empty list marker `[]`", () => {
|
||||
const result = parseYaml("prompt_files: []");
|
||||
expect(result).toEqual({ prompt_files: [] });
|
||||
});
|
||||
|
||||
it("handles multiple mixed structures in one document", () => {
|
||||
const yaml = `name: test-agent
|
||||
version: 1.0.0
|
||||
tier: 4
|
||||
runtime: claude-code
|
||||
skills:
|
||||
- web-search
|
||||
a2a:
|
||||
port: 8000
|
||||
streaming: true`;
|
||||
const result = parseYaml(yaml);
|
||||
expect(result).toEqual({
|
||||
name: "test-agent",
|
||||
version: "1.0.0",
|
||||
tier: 4,
|
||||
runtime: "claude-code",
|
||||
skills: ["web-search"],
|
||||
a2a: { port: 8000, streaming: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unrecognised top-level lines as-is (skipped)", () => {
|
||||
// Lines that don't match the pattern are skipped
|
||||
const result = parseYaml("name: hello\n[invalid line]\nversion: 1.0");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── toYaml ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("toYaml", () => {
|
||||
it("produces output for minimal config (required fields only)", () => {
|
||||
const out = toYaml(MINIMAL_CONFIG);
|
||||
// skills: [] and tools: [] are always emitted
|
||||
expect(out).toContain("version: 1.0.0");
|
||||
expect(out).toContain("tier: 1");
|
||||
expect(out).toContain("skills: []");
|
||||
expect(out).toContain("tools: []");
|
||||
expect(out).toContain("a2a:");
|
||||
expect(out).toContain("delegation:");
|
||||
expect(out).toContain("sandbox:");
|
||||
});
|
||||
|
||||
it("writes name and description fields", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, name: "my-agent", description: "desc" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("name: my-agent");
|
||||
expect(out).toContain("description: desc");
|
||||
});
|
||||
|
||||
it("writes version and tier", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, tier: 4 };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("version: 1.0.0");
|
||||
expect(out).toContain("tier: 4");
|
||||
});
|
||||
|
||||
it("writes runtime with a blank line separator before it", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, runtime: "claude-code" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("runtime: claude-code");
|
||||
});
|
||||
|
||||
it("writes runtime_config as a nested block", () => {
|
||||
const cfg: ConfigData = {
|
||||
...MINIMAL_CONFIG,
|
||||
runtime: "claude-code",
|
||||
runtime_config: { model: "claude-4-7", required_env: ["KEY"], timeout: 120 },
|
||||
};
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("runtime_config:");
|
||||
expect(out).toContain(" model: claude-4-7");
|
||||
expect(out).toContain(" required_env:");
|
||||
expect(out).toContain(" - KEY");
|
||||
expect(out).toContain(" timeout: 120");
|
||||
});
|
||||
|
||||
it("omits runtime_config when empty", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, runtime: "claude-code" };
|
||||
const out = toYaml(cfg);
|
||||
// runtime_config key should not appear
|
||||
expect(out).not.toContain("runtime_config:");
|
||||
});
|
||||
|
||||
it("writes effort when set", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, effort: "high" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("effort: high");
|
||||
});
|
||||
|
||||
it("omits effort when empty string", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, effort: "" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).not.toContain("effort:");
|
||||
});
|
||||
|
||||
it("writes task_budget when positive", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, task_budget: 100 };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("task_budget: 100");
|
||||
});
|
||||
|
||||
it("omits task_budget when zero", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, task_budget: 0 };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).not.toContain("task_budget:");
|
||||
});
|
||||
|
||||
it("writes prompt_files as a list block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, prompt_files: ["system.md", "ethics.md"] };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("prompt_files:");
|
||||
expect(out).toContain(" - system.md");
|
||||
expect(out).toContain(" - ethics.md");
|
||||
});
|
||||
|
||||
it("writes skills as a list block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, skills: ["web-search", "code"] };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("skills:");
|
||||
expect(out).toContain(" - web-search");
|
||||
expect(out).toContain(" - code");
|
||||
});
|
||||
|
||||
it("writes tools as a list block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, tools: ["bash", "read"] };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("tools:");
|
||||
expect(out).toContain(" - bash");
|
||||
expect(out).toContain(" - read");
|
||||
});
|
||||
|
||||
it("writes a2a as a nested block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, a2a: { port: 9000, streaming: false, push_notifications: false } };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("a2a:");
|
||||
expect(out).toContain(" port: 9000");
|
||||
expect(out).toContain(" streaming: false");
|
||||
expect(out).toContain(" push_notifications: false");
|
||||
});
|
||||
|
||||
it("writes delegation as a nested block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, delegation: { retry_attempts: 5, retry_delay: 10, timeout: 60, escalate: false } };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("delegation:");
|
||||
expect(out).toContain(" retry_attempts: 5");
|
||||
expect(out).toContain(" retry_delay: 10");
|
||||
expect(out).toContain(" timeout: 60");
|
||||
expect(out).toContain(" escalate: false");
|
||||
});
|
||||
|
||||
it("writes sandbox backend block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, sandbox: { backend: "aws-lambda", memory_limit: "512m", timeout: 15 } };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("sandbox:");
|
||||
expect(out).toContain(" backend: aws-lambda");
|
||||
expect(out).toContain(" memory_limit: 512m");
|
||||
expect(out).toContain(" timeout: 15");
|
||||
});
|
||||
|
||||
it("omits empty/null/undefined fields entirely", () => {
|
||||
const cfg: ConfigData = {
|
||||
...MINIMAL_CONFIG,
|
||||
name: "test",
|
||||
model: "", // omitted
|
||||
description: "", // omitted
|
||||
};
|
||||
const out = toYaml(cfg);
|
||||
expect(out).not.toContain("model:");
|
||||
expect(out).not.toContain("description:");
|
||||
expect(out).toContain("name: test");
|
||||
});
|
||||
|
||||
it("produces a trailing newline", () => {
|
||||
const out = toYaml(MINIMAL_CONFIG);
|
||||
expect(out.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips FULL_CONFIG through parse → toYaml → parse", () => {
|
||||
// parseYaml produces plain Record, so a2a/delegation/sandbox
|
||||
// come out as objects — toYaml handles them via the cast.
|
||||
const round = parseYaml(toYaml(FULL_CONFIG));
|
||||
expect(round).toMatchObject({
|
||||
name: "my-agent",
|
||||
description: "A helpful assistant",
|
||||
version: "1.0.0",
|
||||
tier: 4,
|
||||
runtime: "claude-code",
|
||||
effort: "medium",
|
||||
task_budget: 100,
|
||||
prompt_files: ["system.md"],
|
||||
skills: ["web-search", "code"],
|
||||
tools: ["bash"],
|
||||
});
|
||||
expect(round.a2a).toMatchObject({ port: 8000, streaming: true, push_notifications: true });
|
||||
expect(round.delegation).toMatchObject({ retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true });
|
||||
expect(round.sandbox).toMatchObject({ backend: "docker", memory_limit: "256m", timeout: 30 });
|
||||
});
|
||||
});
|
||||
@@ -100,14 +100,7 @@ export function toYaml(config: ConfigData): string {
|
||||
if (!o) return;
|
||||
lines.push(`${k}:`);
|
||||
Object.entries(o).forEach(([sk, sv]) => {
|
||||
if (sv === undefined || sv === null || sv === "") return;
|
||||
if (Array.isArray(sv)) {
|
||||
// Nested list block: e.g. required_env: [KEY, SECRET]
|
||||
lines.push(` ${sk}:`);
|
||||
sv.forEach((v) => lines.push(` - ${v}`));
|
||||
} else {
|
||||
lines.push(` ${sk}: ${sv}`);
|
||||
}
|
||||
if (sv !== undefined && sv !== null && sv !== "") lines.push(` ${sk}: ${sv}`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -128,7 +121,7 @@ export function toYaml(config: ConfigData): string {
|
||||
if (config.task_budget && config.task_budget > 0) { simple("task_budget", config.task_budget); }
|
||||
if (config.prompt_files?.length) { lines.push(""); list("prompt_files", config.prompt_files); }
|
||||
lines.push(""); list("skills", config.skills);
|
||||
lines.push(""); list("tools", config.tools);
|
||||
if (config.tools?.length) { list("tools", config.tools); }
|
||||
lines.push(""); obj("a2a", config.a2a as unknown as Record<string, unknown>);
|
||||
lines.push(""); obj("delegation", config.delegation as unknown as Record<string, unknown>);
|
||||
if (config.sandbox?.backend) { lines.push(""); obj("sandbox", config.sandbox as unknown as Record<string, unknown>); }
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for cssVar — maps ColorToken to a CSS variable string.
|
||||
*
|
||||
* Exists for the rare case where an inline style="" or SVG fill needs
|
||||
* a token value rather than a Tailwind class. The returned var(--color-foo)
|
||||
* string follows the live theme without re-renders.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cssVar } from "../theme";
|
||||
import type { ColorToken } from "../theme";
|
||||
|
||||
describe("cssVar", () => {
|
||||
it("returns 'var(--color-surface)' for 'surface'", () => {
|
||||
expect(cssVar("surface")).toBe("var(--color-surface)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-ink)' for 'ink'", () => {
|
||||
expect(cssVar("ink")).toBe("var(--color-ink)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-accent)' for 'accent'", () => {
|
||||
expect(cssVar("accent")).toBe("var(--color-accent)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-good)' for 'good'", () => {
|
||||
expect(cssVar("good")).toBe("var(--color-good)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-bad)' for 'bad'", () => {
|
||||
expect(cssVar("bad")).toBe("var(--color-bad)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-warn)' for 'warn'", () => {
|
||||
expect(cssVar("warn")).toBe("var(--color-warn)");
|
||||
});
|
||||
|
||||
it("handles all surface variants", () => {
|
||||
const surfaces: ColorToken[] = ["surface", "surface-elevated", "surface-sunken", "surface-card"];
|
||||
for (const t of surfaces) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles all ink variants", () => {
|
||||
const inks: ColorToken[] = ["ink", "ink-mid", "ink-soft", "ink-mute", "ink-dim"];
|
||||
for (const t of inks) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles always-dark tokens", () => {
|
||||
const dark: ColorToken[] = ["bg", "bg-elev", "bg-card", "line-strong", "accent-dim", "plasma"];
|
||||
for (const t of dark) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a pure function — same input always returns same output", () => {
|
||||
const tokens: ColorToken[] = ["surface", "accent", "good", "bad", "warm"];
|
||||
for (const t of tokens) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for resolveRuntime — the template-id → runtime-name mapper in deploy-preflight.ts.
|
||||
*
|
||||
* Lives in lib/__tests__/ alongside deploy-preflight.test.ts so the
|
||||
* two share the same describe block convention and the fixture types
|
||||
* are close at hand. Separate file keeps the deploy-preflight fixture
|
||||
* count bounded.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveRuntime } from "../deploy-preflight";
|
||||
|
||||
describe("resolveRuntime", () => {
|
||||
describe("explicit runtime-map entries", () => {
|
||||
it('maps "langgraph" to "langgraph"', () => {
|
||||
expect(resolveRuntime("langgraph")).toBe("langgraph");
|
||||
});
|
||||
|
||||
it('maps "claude-code-default" to "claude-code"', () => {
|
||||
expect(resolveRuntime("claude-code-default")).toBe("claude-code");
|
||||
});
|
||||
|
||||
it('maps "openclaw" to "openclaw"', () => {
|
||||
expect(resolveRuntime("openclaw")).toBe("openclaw");
|
||||
});
|
||||
|
||||
it('maps "deepagents" to "deepagents"', () => {
|
||||
expect(resolveRuntime("deepagents")).toBe("deepagents");
|
||||
});
|
||||
|
||||
it('maps "crewai" to "crewai"', () => {
|
||||
expect(resolveRuntime("crewai")).toBe("crewai");
|
||||
});
|
||||
|
||||
it('maps "autogen" to "autogen"', () => {
|
||||
expect(resolveRuntime("autogen")).toBe("autogen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity fallback for modern template ids", () => {
|
||||
it("returns the id unchanged when not in the map", () => {
|
||||
expect(resolveRuntime("hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips trailing -default suffix as fallback", () => {
|
||||
expect(resolveRuntime("hermes-default")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips -default only when it is the suffix", () => {
|
||||
// "default-something" should NOT strip
|
||||
expect(resolveRuntime("default-langgraph")).toBe("default-langgraph");
|
||||
});
|
||||
|
||||
it("returns the id unchanged when id has no -default suffix", () => {
|
||||
expect(resolveRuntime("gemini-cli")).toBe("gemini-cli");
|
||||
});
|
||||
|
||||
it("handles custom template ids from community templates", () => {
|
||||
expect(resolveRuntime("my-custom-template")).toBe("my-custom-template");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty string", () => {
|
||||
// Falls through to the replace branch
|
||||
expect(resolveRuntime("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles id that is just '-default'", () => {
|
||||
expect(resolveRuntime("-default")).toBe("");
|
||||
});
|
||||
|
||||
it("multiple -default suffixes only strips the last one", () => {
|
||||
// The JS replace only replaces the first match by default
|
||||
expect(resolveRuntime("claude-code-default-default")).toBe("claude-code-default");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for runtimeProfiles.ts — getRuntimeProfile and provisionTimeoutForRuntime.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRuntimeProfile,
|
||||
provisionTimeoutForRuntime,
|
||||
DEFAULT_RUNTIME_PROFILE,
|
||||
RUNTIME_PROFILES,
|
||||
} from "../runtimeProfiles";
|
||||
|
||||
describe("getRuntimeProfile", () => {
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when runtime is undefined and no overrides", () => {
|
||||
const result = getRuntimeProfile(undefined);
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when runtime is empty string", () => {
|
||||
const result = getRuntimeProfile("");
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("falls back to DEFAULT_RUNTIME_PROFILE for an unknown runtime", () => {
|
||||
const result = getRuntimeProfile("unknown-lang");
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when RUNTIME_PROFILES is empty (current state)", () => {
|
||||
// RUNTIME_PROFILES is currently {} — verify the empty-map path works
|
||||
expect(RUNTIME_PROFILES).toEqual({});
|
||||
const result = getRuntimeProfile("claude-code");
|
||||
expect(result.provisionTimeoutMs).toBe(120_000);
|
||||
});
|
||||
|
||||
it("uses overrides.provisionTimeoutMs when provided (highest priority)", () => {
|
||||
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 300_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(300_000);
|
||||
});
|
||||
|
||||
it("overrides wins over RUNTIME_PROFILES entry", () => {
|
||||
// Even if RUNTIME_PROFILES had an entry, overrides take priority
|
||||
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 999_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(999_000);
|
||||
});
|
||||
|
||||
it("uses overrides even when runtime is undefined", () => {
|
||||
const result = getRuntimeProfile(undefined, { provisionTimeoutMs: 60_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("returns Required<Pick> — always has provisionTimeoutMs", () => {
|
||||
// The return type is guaranteed non-nullable
|
||||
const result = getRuntimeProfile(undefined);
|
||||
expect(typeof result.provisionTimeoutMs).toBe("number");
|
||||
expect(result.provisionTimeoutMs).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provisionTimeoutForRuntime", () => {
|
||||
it("returns DEFAULT_RUNTIME_PROFILE value when no runtime or overrides", () => {
|
||||
expect(provisionTimeoutForRuntime(undefined)).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("")).toBe(120_000);
|
||||
});
|
||||
|
||||
it("returns overrides value when overrides provided", () => {
|
||||
expect(provisionTimeoutForRuntime("claude-code", { provisionTimeoutMs: 90_000 })).toBe(90_000);
|
||||
});
|
||||
|
||||
it("returns 120_000 for any unknown runtime", () => {
|
||||
expect(provisionTimeoutForRuntime("langgraph")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("crewai")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("some-new-runtime")).toBe(120_000);
|
||||
});
|
||||
|
||||
it("convenience: same as getRuntimeProfile().provisionTimeoutMs", () => {
|
||||
const cases: Array<[string | undefined, { provisionTimeoutMs?: number } | undefined]> = [
|
||||
[undefined, undefined],
|
||||
["claude-code", undefined],
|
||||
["langgraph", { provisionTimeoutMs: 500_000 }],
|
||||
[undefined, { provisionTimeoutMs: 45_000 }],
|
||||
];
|
||||
for (const [runtime, overrides] of cases) {
|
||||
const profile = getRuntimeProfile(runtime, overrides);
|
||||
const direct = provisionTimeoutForRuntime(runtime, overrides);
|
||||
expect(direct).toBe(profile.provisionTimeoutMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for statusDotClass — maps a workspace status string to the
|
||||
* CSS tailwind class used on the status indicator dot.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { statusDotClass, TIER_CONFIG, COMM_TYPE_LABELS } from "../design-tokens";
|
||||
|
||||
describe("statusDotClass", () => {
|
||||
it('returns "bg-emerald-400" for "online"', () => {
|
||||
expect(statusDotClass("online")).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it('returns "bg-zinc-500" for "offline"', () => {
|
||||
expect(statusDotClass("offline")).toBe("bg-zinc-500");
|
||||
});
|
||||
|
||||
it('returns "bg-indigo-400" for "paused"', () => {
|
||||
expect(statusDotClass("paused")).toBe("bg-indigo-400");
|
||||
});
|
||||
|
||||
it('returns "bg-amber-400" for "degraded"', () => {
|
||||
expect(statusDotClass("degraded")).toBe("bg-amber-400");
|
||||
});
|
||||
|
||||
it('returns "bg-red-400" for "failed"', () => {
|
||||
expect(statusDotClass("failed")).toBe("bg-red-400");
|
||||
});
|
||||
|
||||
it('returns "bg-sky-400 motion-safe:animate-pulse" for "provisioning"', () => {
|
||||
expect(statusDotClass("provisioning")).toBe("bg-sky-400 motion-safe:animate-pulse");
|
||||
});
|
||||
|
||||
it('returns "bg-amber-300" for "not_configured"', () => {
|
||||
expect(statusDotClass("not_configured")).toBe("bg-amber-300");
|
||||
});
|
||||
|
||||
it("falls back to bg-zinc-500 for unknown status strings", () => {
|
||||
expect(statusDotClass("unknown")).toBe("bg-zinc-500");
|
||||
expect(statusDotClass("")).toBe("bg-zinc-500");
|
||||
expect(statusDotClass("ONLINE")).toBe("bg-zinc-500"); // case-sensitive
|
||||
expect(statusDotClass(" online")).toBe("bg-zinc-500"); // whitespace-sensitive
|
||||
expect(statusDotClass("online\n")).toBe("bg-zinc-500");
|
||||
});
|
||||
|
||||
it("is a pure function — same input always returns same output", () => {
|
||||
const result = statusDotClass("online");
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(statusDotClass("online")).toBe(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── TIER_CONFIG ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TIER_CONFIG", () => {
|
||||
it("has entries for all four tier levels", () => {
|
||||
expect(TIER_CONFIG).toHaveProperty(1);
|
||||
expect(TIER_CONFIG).toHaveProperty(2);
|
||||
expect(TIER_CONFIG).toHaveProperty(3);
|
||||
expect(TIER_CONFIG).toHaveProperty(4);
|
||||
});
|
||||
|
||||
it("each tier has label, color, and border fields", () => {
|
||||
for (const tier of [1, 2, 3, 4]) {
|
||||
expect(TIER_CONFIG[tier]).toHaveProperty("label");
|
||||
expect(TIER_CONFIG[tier]).toHaveProperty("color");
|
||||
expect(TIER_CONFIG[tier]).toHaveProperty("border");
|
||||
}
|
||||
});
|
||||
|
||||
it("tier labels match expected values", () => {
|
||||
expect(TIER_CONFIG[1].label).toBe("T1");
|
||||
expect(TIER_CONFIG[2].label).toBe("T2");
|
||||
expect(TIER_CONFIG[3].label).toBe("T3");
|
||||
expect(TIER_CONFIG[4].label).toBe("T4");
|
||||
});
|
||||
|
||||
it("is immutable at runtime — same key always returns same shape", () => {
|
||||
const result = TIER_CONFIG[2];
|
||||
expect(TIER_CONFIG[2]).toBe(result);
|
||||
});
|
||||
});
|
||||
|
||||
// ── COMM_TYPE_LABELS ────────────────────────────────────────────────────────
|
||||
|
||||
describe("COMM_TYPE_LABELS", () => {
|
||||
it("has labels for all known communication types", () => {
|
||||
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_send");
|
||||
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_receive");
|
||||
expect(COMM_TYPE_LABELS).toHaveProperty("task_update");
|
||||
});
|
||||
|
||||
it("labels are non-empty strings", () => {
|
||||
for (const key of Object.keys(COMM_TYPE_LABELS)) {
|
||||
expect(typeof COMM_TYPE_LABELS[key]).toBe("string");
|
||||
expect(COMM_TYPE_LABELS[key].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a static map — same key always returns same label", () => {
|
||||
expect(COMM_TYPE_LABELS["a2a_send"]).toBe("sent");
|
||||
expect(COMM_TYPE_LABELS["a2a_receive"]).toBe("received");
|
||||
expect(COMM_TYPE_LABELS["task_update"]).toBe("task update");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for readThemeCookie — parses a cookie value into a ThemePreference.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readThemeCookie } from "../theme-cookie";
|
||||
|
||||
describe("readThemeCookie", () => {
|
||||
it('returns "light" when cookie value is "light"', () => {
|
||||
expect(readThemeCookie("light")).toBe("light");
|
||||
});
|
||||
|
||||
it('returns "dark" when cookie value is "dark"', () => {
|
||||
expect(readThemeCookie("dark")).toBe("dark");
|
||||
});
|
||||
|
||||
it('returns "system" when cookie value is "system"', () => {
|
||||
expect(readThemeCookie("system")).toBe("system");
|
||||
});
|
||||
|
||||
it('returns "system" for undefined', () => {
|
||||
expect(readThemeCookie(undefined)).toBe("system");
|
||||
});
|
||||
|
||||
it('returns "system" for empty string', () => {
|
||||
expect(readThemeCookie("")).toBe("system");
|
||||
});
|
||||
|
||||
it('returns "system" for any non-matching value', () => {
|
||||
expect(readThemeCookie("auto")).toBe("system");
|
||||
expect(readThemeCookie("dark-mode")).toBe("system");
|
||||
expect(readThemeCookie("DARK")).toBe("system"); // case-sensitive
|
||||
expect(readThemeCookie("light\n")).toBe("system"); // whitespace-sensitive
|
||||
expect(readThemeCookie(" system ")).toBe("system");
|
||||
expect(readThemeCookie("null")).toBe("system");
|
||||
expect(readThemeCookie("0")).toBe("system");
|
||||
});
|
||||
|
||||
it("is pure — same input always returns same output", () => {
|
||||
const inputs = ["light", "dark", "system", undefined, ""];
|
||||
for (const input of inputs) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(readThemeCookie(input)).toBe(readThemeCookie(input));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for deriveWsBaseUrl — WebSocket base URL derivation from env / window.location.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { deriveWsBaseUrl } from "../ws-url";
|
||||
|
||||
const ORIGINAL_WS = process.env.NEXT_PUBLIC_WS_URL;
|
||||
const ORIGINAL_PLATFORM = process.env.NEXT_PUBLIC_PLATFORM_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "");
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (ORIGINAL_WS !== undefined) vi.stubEnv("NEXT_PUBLIC_WS_URL", ORIGINAL_WS);
|
||||
else delete process.env.NEXT_PUBLIC_WS_URL;
|
||||
if (ORIGINAL_PLATFORM !== undefined) vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", ORIGINAL_PLATFORM);
|
||||
else delete process.env.NEXT_PUBLIC_PLATFORM_URL;
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — NEXT_PUBLIC_WS_URL (priority 1)", () => {
|
||||
it("uses NEXT_PUBLIC_WS_URL when set", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com/ws");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
|
||||
it("strips trailing /ws suffix from NEXT_PUBLIC_WS_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com/ws");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
|
||||
it("uses ws:// for HTTP NEXT_PUBLIC_WS_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "ws://localhost:8080/ws");
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:8080");
|
||||
});
|
||||
|
||||
it("wins over NEXT_PUBLIC_PLATFORM_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com");
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://platform.example.com");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
|
||||
it("wins over window.location", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com");
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — NEXT_PUBLIC_PLATFORM_URL (priority 2)", () => {
|
||||
it("derives ws:// from http:// platform URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://localhost:8080");
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:8080");
|
||||
});
|
||||
|
||||
it("derives wss:// from https:// platform URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform.example.com");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://platform.example.com");
|
||||
});
|
||||
|
||||
it("preserves non-standard ports", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://localhost:9000");
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:9000");
|
||||
});
|
||||
|
||||
it("wins over window.location", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform.example.com");
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://platform.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — window.location (priority 3)", () => {
|
||||
it("uses wss:// when page is served over HTTPS", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://canvas.example.com");
|
||||
});
|
||||
|
||||
it("uses ws:// when page is served over HTTP", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "http:", host: "localhost:3000" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:3000");
|
||||
});
|
||||
|
||||
it("includes the host with port", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com:8443" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://canvas.example.com:8443");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — fallback (priority 4)", () => {
|
||||
it("falls back to localhost when no env vars or window is unavailable", () => {
|
||||
// process.env is empty (already stubbed), window is not stubbed but we
|
||||
// can't remove it entirely in jsdom — the function checks typeof window
|
||||
// which is always defined. Since we have no env vars, it falls through
|
||||
// to the window branch; we test the final fallback by stubbing window
|
||||
// location to undefined (not possible in jsdom — skip this edge case).
|
||||
// The test below verifies the no-env-var path works.
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "http:", host: "localhost:3000" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:3000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — protocol derivation", () => {
|
||||
it("derives ws:// from http:// and keeps it", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://platform:8080");
|
||||
expect(deriveWsBaseUrl()).toMatch(/^ws:/);
|
||||
});
|
||||
|
||||
it("derives wss:// from https:// and keeps it", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform:8080");
|
||||
expect(deriveWsBaseUrl()).toMatch(/^wss:/);
|
||||
});
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for pure utility functions in canvas-topology.ts:
|
||||
* sortParentsBeforeChildren, defaultChildSlot, childSlotInGrid,
|
||||
* parentMinSize, parentMinSizeFromChildren.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
sortParentsBeforeChildren,
|
||||
defaultChildSlot,
|
||||
childSlotInGrid,
|
||||
parentMinSize,
|
||||
parentMinSizeFromChildren,
|
||||
} from "../canvas-topology";
|
||||
|
||||
// ─── sortParentsBeforeChildren ─────────────────────────────────────────────────
|
||||
|
||||
describe("sortParentsBeforeChildren", () => {
|
||||
it("returns [] for empty input", () => {
|
||||
expect(sortParentsBeforeChildren([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single node unchanged", () => {
|
||||
const nodes = [{ id: "a", parentId: undefined }];
|
||||
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
|
||||
});
|
||||
|
||||
it("places parent before child", () => {
|
||||
// Deliberately reversed so naive iteration would place child first
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "parent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result[0].id).toBe("parent");
|
||||
expect(result[1].id).toBe("child");
|
||||
});
|
||||
|
||||
it("places grandparent before parent before child (deep chain)", () => {
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "grandchild", parentId: "child" },
|
||||
{ id: "parent", parentId: "grandparent" },
|
||||
{ id: "grandparent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
const ids = result.map((n) => n.id);
|
||||
expect(ids).toEqual(["grandparent", "parent", "child", "grandchild"]);
|
||||
});
|
||||
|
||||
it("siblings share the same parent", () => {
|
||||
const nodes = [
|
||||
{ id: "b", parentId: "a" },
|
||||
{ id: "a", parentId: undefined },
|
||||
{ id: "c", parentId: "a" },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result[0].id).toBe("a");
|
||||
expect(new Set(result.slice(1).map((n) => n.id))).toEqual(new Set(["b", "c"]));
|
||||
});
|
||||
|
||||
it("no-ops when children already precede parents", () => {
|
||||
// Already sorted — output should be in the same order
|
||||
const nodes = [
|
||||
{ id: "root", parentId: undefined },
|
||||
{ id: "child", parentId: "root" },
|
||||
];
|
||||
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
|
||||
});
|
||||
|
||||
it("handles orphan nodes (no parentId)", () => {
|
||||
const nodes = [{ id: "a" }, { id: "b" }];
|
||||
expect(sortParentsBeforeChildren(nodes).map((n) => n.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns a new array (does not mutate input)", () => {
|
||||
const nodes = [{ id: "child", parentId: "parent" }, { id: "parent", parentId: undefined }];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result).not.toBe(nodes);
|
||||
});
|
||||
|
||||
it("deduplicates already-visited nodes", () => {
|
||||
// Child's parent is also in the list — visited guard prevents loops
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "parent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["parent", "child"]);
|
||||
});
|
||||
|
||||
it("does not crash when parentId references a missing node", () => {
|
||||
const nodes = [
|
||||
{ id: "orphan", parentId: "ghost" },
|
||||
{ id: "root", parentId: undefined },
|
||||
];
|
||||
// Missing parent is skipped; orphan placed after root
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["root", "orphan"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── defaultChildSlot ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("defaultChildSlot — 2-column grid (240×130 cards)", () => {
|
||||
it("slot 0 → column 0, row 0", () => {
|
||||
const s = defaultChildSlot(0);
|
||||
expect(s).toEqual({ x: 16, y: 130 });
|
||||
});
|
||||
|
||||
it("slot 1 → column 1, row 0", () => {
|
||||
const s = defaultChildSlot(1);
|
||||
expect(s.x).toBe(16 + 240 + 14); // PARENT_SIDE_PADDING + CHILD_DEFAULT_WIDTH + CHILD_GUTTER
|
||||
expect(s.y).toBe(130);
|
||||
});
|
||||
|
||||
it("slot 2 → column 0, row 1", () => {
|
||||
const s = defaultChildSlot(2);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + 130 + 14); // row 0 height + gutter
|
||||
});
|
||||
|
||||
it("slot 3 → column 1, row 1", () => {
|
||||
const s = defaultChildSlot(3);
|
||||
expect(s.x).toBe(16 + 240 + 14);
|
||||
expect(s.y).toBe(130 + 130 + 14);
|
||||
});
|
||||
|
||||
it("slot 4 → column 0, row 2", () => {
|
||||
const s = defaultChildSlot(4);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + (130 + 14) * 2); // row 1 end + gutter
|
||||
});
|
||||
});
|
||||
|
||||
// ─── childSlotInGrid ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("childSlotInGrid — variable-size siblings", () => {
|
||||
it("empty siblingSizes returns side-padded position", () => {
|
||||
const s = childSlotInGrid(0, []);
|
||||
expect(s).toEqual({ x: 16, y: 130 });
|
||||
});
|
||||
|
||||
it("slot 0 in uniform-size siblings matches defaultChildSlot", () => {
|
||||
const sizes = [{ width: 240, height: 130 }, { width: 240, height: 130 }];
|
||||
const s = childSlotInGrid(0, sizes);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130);
|
||||
});
|
||||
|
||||
it("taller sibling bumps next row down", () => {
|
||||
// Column width = max(200, 240) = 240; row 0 height = max(300, 130) = 300
|
||||
const sizes = [{ width: 200, height: 300 }, { width: 240, height: 130 }];
|
||||
const slot1 = childSlotInGrid(1, sizes);
|
||||
// Slot 1 is in column 1, row 0; x = 16 + 1*(240+14)
|
||||
expect(slot1.x).toBe(16 + 240 + 14);
|
||||
expect(slot1.y).toBe(130);
|
||||
// Slot 2 (col 0, row 1) — y must include row 0 height + gutter
|
||||
const slot2 = childSlotInGrid(2, sizes);
|
||||
expect(slot2.x).toBe(16);
|
||||
expect(slot2.y).toBe(130 + 300 + 14);
|
||||
});
|
||||
|
||||
it("colW is the maximum sibling width, not the column of the target slot", () => {
|
||||
// Column width is always the max — slot at col 0 uses colW of wider col 1 sibling
|
||||
const sizes = [{ width: 100, height: 100 }, { width: 300, height: 100 }];
|
||||
const slot0 = childSlotInGrid(0, sizes);
|
||||
expect(slot0.x).toBe(16); // col 0
|
||||
// x for col 1 would be 16 + 300 + 14 = 330
|
||||
const slot1 = childSlotInGrid(1, sizes);
|
||||
expect(slot1.x).toBe(16 + 300 + 14);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parentMinSize ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parentMinSize — uniform-size children", () => {
|
||||
it("0 children → compact default (210×120)", () => {
|
||||
expect(parentMinSize(0)).toEqual({ width: 210, height: 120 });
|
||||
});
|
||||
|
||||
it("1 child → 1 col, 1 row", () => {
|
||||
const s = parentMinSize(1);
|
||||
// width = 16*2 + 1*240 + 0 = 272; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 240);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
});
|
||||
|
||||
it("2 children → 2 cols, 1 row", () => {
|
||||
const s = parentMinSize(2);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
});
|
||||
|
||||
it("3 children → 2 cols, 2 rows", () => {
|
||||
const s = parentMinSize(3);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
// height = 130 + 2*130 + 1*14 + 16 = 416
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
});
|
||||
|
||||
it("4 children → 2 cols, 2 rows (full grid)", () => {
|
||||
const s = parentMinSize(4);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
});
|
||||
|
||||
it("5 children → 2 cols, 3 rows", () => {
|
||||
const s = parentMinSize(5);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 3 * 130 + 2 * 14 + 16);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parentMinSizeFromChildren ────────────────────────────────────────────────
|
||||
|
||||
describe("parentMinSizeFromChildren — variable-size children", () => {
|
||||
it("empty array → compact default (210×120)", () => {
|
||||
expect(parentMinSizeFromChildren([])).toEqual({ width: 210, height: 120 });
|
||||
});
|
||||
|
||||
it("single child matches defaultChildSlot bounding box", () => {
|
||||
const s = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
|
||||
// cols=1, rows=1, colW=240
|
||||
expect(s.width).toBe(16 * 2 + 240); // 272
|
||||
expect(s.height).toBe(130 + 130 + 16); // 276
|
||||
});
|
||||
|
||||
it("two equal-width children → same as parentMinSize(2)", () => {
|
||||
const fromChildren = parentMinSizeFromChildren([
|
||||
{ width: 240, height: 130 },
|
||||
{ width: 240, height: 130 },
|
||||
]);
|
||||
expect(fromChildren.width).toBe(parentMinSize(2).width);
|
||||
expect(fromChildren.height).toBe(parentMinSize(2).height);
|
||||
});
|
||||
|
||||
it("taller child increases height", () => {
|
||||
const tall = parentMinSizeFromChildren([{ width: 240, height: 400 }]);
|
||||
const short = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
|
||||
expect(tall.height).toBeGreaterThan(short.height);
|
||||
});
|
||||
|
||||
it("wider child increases width", () => {
|
||||
const wide = parentMinSizeFromChildren([{ width: 500, height: 130 }]);
|
||||
const narrow = parentMinSizeFromChildren([{ width: 200, height: 130 }]);
|
||||
expect(wide.width).toBeGreaterThan(narrow.width);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,6 @@ export function sortParentsBeforeChildren<T extends { id: string; parentId?: str
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const visited = new Set<string>();
|
||||
const out: T[] = [];
|
||||
|
||||
const visit = (n: T) => {
|
||||
if (visited.has(n.id)) return;
|
||||
if (n.parentId) {
|
||||
@@ -35,21 +34,7 @@ export function sortParentsBeforeChildren<T extends { id: string; parentId?: str
|
||||
visited.add(n.id);
|
||||
out.push(n);
|
||||
};
|
||||
|
||||
// Separate roots (no parentId) from orphans (parentId has no entry in byId).
|
||||
// Visit roots first so they appear before orphans in the output.
|
||||
const roots: T[] = [];
|
||||
const orphans: T[] = [];
|
||||
for (const n of nodes) {
|
||||
if (!n.parentId || byId.has(n.parentId)) {
|
||||
roots.push(n);
|
||||
} else {
|
||||
orphans.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of roots) visit(n);
|
||||
for (const n of orphans) visit(n);
|
||||
for (const n of nodes) visit(n);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -269,28 +269,6 @@ Each workspace exposes an A2A server, builds an Agent Card, and registers with t
|
||||
|
||||
But the long-term collaboration model remains direct workspace-to-workspace communication via A2A.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Playwright / browser system libs are not installed
|
||||
|
||||
The base `molecule-ai-workspace-runtime` image (`workspace/Dockerfile`) is built on `python:3.11-slim` with Node.js 22, git, and `gh` — about 500 MB. It deliberately **does not** include the system libraries Chromium needs (`libnss3`, `libatk-bridge2.0-0`, `libxkbcommon0`, `libcups2`, `libdrm2`, `libxcomposite1`, `libxdamage1`, `libxrandr2`, `libgbm1`, `libpango-1.0-0`, `libasound2`, etc.). Adding them would inflate the image by ~200–250 MB (~40%) for every workspace, even though only frontend / QA workspaces ever launch a browser.
|
||||
|
||||
Practical consequences:
|
||||
|
||||
- `npx playwright test` (and any other Chromium-driven E2E tooling) **will fail at browser launch** when run from inside an in-container workspace agent.
|
||||
- The error surface is missing-shared-object messages such as `error while loading shared libraries: libnss3.so` or `Host system is missing dependencies to run browsers`.
|
||||
- Unit and integration tests (Vitest, Jest, etc.) that don't spawn a real browser are unaffected.
|
||||
|
||||
Recommended workflow:
|
||||
|
||||
1. **Run E2E in CI**, not in-container. The Gitea Actions self-hosted runner (and the GitHub Actions runner used by mirror repos) has the full Playwright dep set installed and is the supported surface for E2E. Push a branch, let CI run the suite.
|
||||
2. **Local debugging** of a single failing spec is best done on a developer laptop with `npx playwright install-deps` run once.
|
||||
3. **In-container iteration** on test logic itself is fine — write specs, lint them, type-check them — just don't expect `playwright test` to actually launch a browser.
|
||||
|
||||
If a particular workspace role genuinely needs in-container E2E (a dedicated QA template, for instance), the right place to layer Playwright deps is in a **role-specific adapter template image** that does `FROM molecule-ai-workspace-runtime:<tag>` and adds `RUN npx playwright install-deps`. Open a request against `molecule-ai-workspace-runtime` if you need this template stamped.
|
||||
|
||||
Tracking issue: [molecule-ai/molecule-app#7](https://git.moleculesai.app/molecule-ai/molecule-app/issues/7).
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [Agent Runtime Adapters](./cli-runtime.md)
|
||||
|
||||
@@ -88,7 +88,6 @@ PR: `fix/ink-soft-wcag-contrast`.
|
||||
- Arrow keys move selected node 10px (50px with Shift) — keyboard node drag (PR #182) ✅
|
||||
- `Cmd/Ctrl+Arrow` resize selected node (↑↓ height, ←→ width, 10px, Shift 2px) ✅
|
||||
- Hierarchy navigation (Enter/Shift+Enter), z-order (Cmd+]/[), zoom-to-team (Z) ✅
|
||||
- Toolbar help dialog ("Shortcuts & tips") documents all shortcuts + mouse interactions ✅
|
||||
|
||||
### Focus Management ✅ (strong)
|
||||
- Skip link → `#canvas-main` ✅
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# Admin Authentication Runbook
|
||||
|
||||
## Test-token route: lock in staging and production
|
||||
|
||||
The `GET /admin/workspaces/:id/test-token` endpoint mints fresh workspace auth tokens.
|
||||
It is gated by `TestTokensEnabled()` which returns `true` only when `MOLECULE_ENV != "production"`.
|
||||
|
||||
**Effect**: if `MOLECULE_ENV` is unset or set to `development` / `dev` in a staging or production
|
||||
tenant, the test-token route remains enabled. While the route is protected by `subtle.ConstantTimeCompare`
|
||||
against `ADMIN_TOKEN` (returns 404 when disabled, not 403), the safest posture is to lock it
|
||||
out in any environment where it is not intentionally used.
|
||||
|
||||
### Required: set MOLECULE_ENV in all non-dev environments
|
||||
|
||||
```bash
|
||||
# In your tenant / EC2 / Railway environment variables:
|
||||
MOLECULE_ENV=production
|
||||
```
|
||||
|
||||
This matches the production tenant default. When `MOLECULE_ENV=production`:
|
||||
|
||||
- `TestTokensEnabled()` → `false`
|
||||
- `GET /admin/workspaces/:id/test-token` → 404 (route disabled)
|
||||
|
||||
### Startup visibility
|
||||
|
||||
workspace-server logs the test-token route state at boot:
|
||||
|
||||
```
|
||||
Platform starting on ... (dev-mode-fail-open=...)
|
||||
```
|
||||
|
||||
Additionally, when `TestTokensEnabled()` is `true` (route enabled), the server emits an INFO line
|
||||
so operators can confirm the setting in logs:
|
||||
|
||||
```
|
||||
[molecule-git-token-helper] NOTE: /admin/workspaces/:id/test-token is ENABLED
|
||||
(running with MOLECULE_ENV != production)
|
||||
```
|
||||
|
||||
If you do not see this line and the route is still accessible, verify `MOLECULE_ENV` is not set to
|
||||
`development`, `dev`, or any value that is not exactly `production`.
|
||||
|
||||
### Dev environments
|
||||
|
||||
In local dev (`MOLECULE_ENV=development` or unset with no `ADMIN_TOKEN`), the test-token route
|
||||
is intentionally enabled — it is the only way to bootstrap a workspace bearer token without a running
|
||||
canvas. This is the correct default for developer workstations.
|
||||
|
||||
## Admin bearer token (`ADMIN_TOKEN`)
|
||||
|
||||
The platform uses `ADMIN_TOKEN` as the bearer credential for admin-gated endpoints:
|
||||
|
||||
| Endpoint | Auth method |
|
||||
|----------|-------------|
|
||||
| `GET/POST/PATCH/DELETE /workspaces` | `Authorization: Bearer <ADMIN_TOKEN>` |
|
||||
| `GET /admin/liveness` | `Authorization: Bearer <ADMIN_TOKEN>` |
|
||||
| `POST /org/import` | `Authorization: Bearer <ADMIN_TOKEN>` |
|
||||
| `GET /admin/workspaces/:id/test-token` | `Authorization: Bearer <ADMIN_TOKEN>` (enabled only when `MOLECULE_ENV != "production"`) |
|
||||
|
||||
Missing or invalid `ADMIN_TOKEN` → AdminAuth fails open in dev mode (no token set), or
|
||||
returns 401 in production mode (token set but invalid).
|
||||
@@ -44,4 +44,3 @@
|
||||
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
|
||||
]
|
||||
}
|
||||
// Triggered by Integration Tester at 2026-05-10T08:52Z
|
||||
|
||||
+4
-46
@@ -1,15 +1,10 @@
|
||||
#!/bin/bash
|
||||
# canary-smoke.sh — runs the post-deploy smoke suite against the
|
||||
# staging canary tenant fleet. Called by the canary-verify.yml GitHub
|
||||
# Actions workflow after a new workspace-server image lands in ECR;
|
||||
# exits non-zero on any failure so the workflow can block the
|
||||
# redeploy-fleet promotion that would otherwise release broken code
|
||||
# to the prod tenant fleet.
|
||||
#
|
||||
# Registry note: GHCR was retired 2026-05-06. Images are now pushed
|
||||
# to the operator's ECR org (153263036946.dkr.ecr.us-east-2.amazonaws.com/
|
||||
# molecule-ai/platform-tenant). The registry URL is a runtime concern for
|
||||
# the CI push step; this script tests the running tenant directly.
|
||||
# Actions workflow after a new workspace-server image gets pushed to
|
||||
# GHCR; exits non-zero on any failure so the workflow can skip the
|
||||
# :staging-sha → :latest retag that would otherwise release broken
|
||||
# code to the prod tenant fleet.
|
||||
#
|
||||
# Environment:
|
||||
# CANARY_TENANT_URLS space-sep list of canary tenant base URLs
|
||||
@@ -113,43 +108,6 @@ for i in "${!URLS[@]}"; do
|
||||
# 5. Negative: unauth'd admin call must 401 (C4 regression gate).
|
||||
unauth_code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$base/admin/liveness" || echo "000")
|
||||
check "unauth'd /admin/liveness returns 401" "401" "$unauth_code"
|
||||
|
||||
# 6. POST /org/import unauth → 401. Proves the route is compiled in
|
||||
# and AdminAuth is enforced. A missing route returns 404 (the failure
|
||||
# mode caught by issue #213). Regression guard for the silent-GHCR-
|
||||
# migration gap: canary-verify was testing a stale GHCR image while
|
||||
# actual tenants ran ECR — this test would have caught a missing-route
|
||||
# binary before it reached prod.
|
||||
unauth_code=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
--max-time 10 -X POST "$base/org/import" || echo "000")
|
||||
check "POST /org/import unauth returns 401 (not 404)" "401" "$unauth_code"
|
||||
|
||||
# 7. POST /org/import authed → 400/422 (malformed body, not 404).
|
||||
# Proves the route IS in the binary AND AdminAuth passed. Using a
|
||||
# deliberately broken body so we hit the handler's validation, not a
|
||||
# business-logic error that might return 500 in some states.
|
||||
bad_code=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
--max-time 10 -X POST \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"dir":"nonexistent-org-template"}' \
|
||||
"$base/org/import" || echo "000")
|
||||
# Accept 400 (bad request / validation), 404 (template not found but
|
||||
# route exists — good enough to prove route compiled), or 422 (unproc).
|
||||
# Reject 000 (connection error) and 500 (server crash).
|
||||
if [ "$bad_code" = "000" ] || [ "$bad_code" = "500" ]; then
|
||||
printf " FAIL POST /org/import authed returns HTTP %s (expected 400/404/422)\n" "$bad_code" >&2
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
printf " PASS POST /org/import authed returns HTTP %s (route compiled + AdminAuth enforced)\n" "$bad_code"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
|
||||
# 8. POST /workspaces unauth → 401. Proves the route is compiled in.
|
||||
# GET /workspaces was already covered in step 2; POST was the gap.
|
||||
unauth_code=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
--max-time 10 -X POST "$base/workspaces" || echo "000")
|
||||
check "POST /workspaces unauth returns 401 (not 404)" "401" "$unauth_code"
|
||||
done
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,50 +37,6 @@ PLUGINS_DIR="${4:?Missing plugins dir}"
|
||||
EXPECTED=0
|
||||
CLONED=0
|
||||
|
||||
# clone_one_with_retry — clone a single repo, retrying on transient failure.
|
||||
#
|
||||
# Why: the publish-workspace-server-image (and harness-replays) CI jobs
|
||||
# clone the full manifest (~36 repos) serially on a memory-constrained
|
||||
# Gitea Actions runner. Under host memory pressure the OOM killer
|
||||
# occasionally SIGKILLs git-remote-https mid-clone:
|
||||
#
|
||||
# error: git-remote-https died of signal 9
|
||||
# fatal: the remote end hung up unexpectedly
|
||||
#
|
||||
# (observed in publish-workspace-server-image run 4622 on 2026-05-10 — the
|
||||
# job died on the 14th of 36 clones, which wedged staging→main). One
|
||||
# transient SIGKILL / network blip would otherwise fail the whole tenant
|
||||
# image rebuild. Retrying after a short backoff lets the pressure subside.
|
||||
# The durable fix is more runner RAM/swap (tracked with Infra-SRE); this
|
||||
# just stops a single flake from being release-blocking.
|
||||
#
|
||||
# Args: <target_dir> <name> <clone_url> <display_url> <ref>
|
||||
clone_one_with_retry() {
|
||||
local tdir="$1" name="$2" url="$3" display="$4" ref="$5"
|
||||
local attempt=1 max_attempts=3 backoff
|
||||
|
||||
while : ; do
|
||||
# A killed attempt can leave a partial directory behind; git clone
|
||||
# refuses a non-empty target, so wipe it before each try.
|
||||
rm -rf "$tdir/$name"
|
||||
|
||||
if [ "$ref" = "main" ]; then
|
||||
if git clone --depth=1 -q "$url" "$tdir/$name"; then return 0; fi
|
||||
else
|
||||
if git clone --depth=1 -q --branch "$ref" "$url" "$tdir/$name"; then return 0; fi
|
||||
fi
|
||||
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "::error::clone failed after ${max_attempts} attempts: ${display}" >&2
|
||||
return 1
|
||||
fi
|
||||
backoff=$((attempt * 3)) # 3s, then 6s
|
||||
echo " ⚠ clone attempt ${attempt}/${max_attempts} failed for ${display} — retrying in ${backoff}s" >&2
|
||||
sleep "$backoff"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
clone_category() {
|
||||
local category="$1"
|
||||
local target_dir="$2"
|
||||
@@ -126,7 +82,11 @@ clone_category() {
|
||||
fi
|
||||
|
||||
echo " cloning $display_url -> $target_dir/$name (ref=$ref)"
|
||||
clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref"
|
||||
if [ "$ref" = "main" ]; then
|
||||
git clone --depth=1 -q "$clone_url" "$target_dir/$name"
|
||||
else
|
||||
git clone --depth=1 -q --branch "$ref" "$clone_url" "$target_dir/$name"
|
||||
fi
|
||||
CLONED=$((CLONED + 1))
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
@@ -115,16 +115,8 @@ COPY --from=canvas-builder /canvas/.next/static ./.next/static
|
||||
COPY --from=canvas-builder /canvas/public ./public
|
||||
|
||||
COPY workspace-server/entrypoint-tenant.sh /entrypoint.sh
|
||||
# /org-templates must be writable by the canvas user — the !external
|
||||
# resolver mkdirs <orgBaseDir>/.external-cache/<repo>/<sha>/ on first
|
||||
# import to cache cross-repo subtree fetches (org_external.go,
|
||||
# internal#77 / task #222). Without this chown the resolver fails with
|
||||
# "mkdir cache root: permission denied" and POST /org/import returns
|
||||
# 400 "org template expansion failed" for any template that uses
|
||||
# !external (e.g. molecule-dev → dev-lead). Caught on staging-cplead-2
|
||||
# 2026-05-10 — see internal incident debrief.
|
||||
RUN chmod +x /entrypoint.sh && \
|
||||
chown -R canvas:canvas /canvas /platform /memory-plugin /migrations /org-templates
|
||||
chown -R canvas:canvas /canvas /platform /memory-plugin /migrations
|
||||
|
||||
EXPOSE 8080
|
||||
# entrypoint.sh starts as root to fix volume perms, then drops to
|
||||
|
||||
@@ -367,9 +367,6 @@ func main() {
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
log.Printf("Platform starting on %s:%s (dev-mode-fail-open=%v)", bindHost, port, middleware.IsDevModeFailOpen())
|
||||
if handlers.TestTokensEnabled() {
|
||||
log.Printf("NOTE: /admin/workspaces/:id/test-token is ENABLED (MOLECULE_ENV=%q — set MOLECULE_ENV=production in staging/prod to lock this route)", os.Getenv("MOLECULE_ENV"))
|
||||
}
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
@@ -18,7 +19,6 @@ require (
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
|
||||
golang.org/x/crypto v0.50.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f h1:YkLRhUg+9qr9OV9N8dG1Hj0Ml7TThHlRwh5F//oUJVs=
|
||||
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f/go.mod h1:NqdtlWZDJvpXNJRHnMkPhTKHdA1LZTNH+63TB66JSOU=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
@@ -152,8 +154,6 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce h1:ftm0ba0ukLlfqeFes+/jWnXH8XULXmRpMy3fOCZ83/U=
|
||||
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce/go.mod h1:0aAqoDle2V7Cywso94MXdv1DH/HEe/0oZmcbqWYMK7g=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
@@ -327,7 +326,7 @@ func (h *MCPHandler) Call(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, mcpResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: &mcpRPCError{Code: -32700, Message: "parse error"},
|
||||
Error: &mcpRPCError{Code: -32700, Message: "parse error: " + err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -415,16 +414,12 @@ func (h *MCPHandler) dispatchRPC(ctx context.Context, workspaceID string, req mc
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
base.Error = &mcpRPCError{Code: -32602, Message: "invalid parameters"}
|
||||
base.Error = &mcpRPCError{Code: -32602, Message: "invalid params: " + err.Error()}
|
||||
return base
|
||||
}
|
||||
text, err := h.dispatch(ctx, workspaceID, params.Name, params.Arguments)
|
||||
if err != nil {
|
||||
// Log full error server-side for forensics; return constant string
|
||||
// to client per OFFSEC-001 / #259. WorkspaceAuth required — caller
|
||||
// already authenticated, so this is defence-in-depth.
|
||||
log.Printf("mcp: tool call failed workspace=%s tool=%s: %v", workspaceID, params.Name, err)
|
||||
base.Error = &mcpRPCError{Code: -32000, Message: "tool call failed"}
|
||||
base.Error = &mcpRPCError{Code: -32000, Message: err.Error()}
|
||||
return base
|
||||
}
|
||||
base.Result = map[string]interface{}{
|
||||
|
||||
@@ -1024,126 +1024,3 @@ func TestIsPrivateOrMetadataIP_PublicAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandler_Call_MalformedJSON returns constant parse-error message.
|
||||
// Per OFFSEC-001 / #259: err.Error() must not leak struct field names or
|
||||
// JSON library internals in JSON-RPC error.message.
|
||||
func TestMCPHandler_Call_MalformedJSON_ReturnsConstantParseError(t *testing.T) {
|
||||
h, _ := newMCPHandler(t)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
// Valid JSON-RPC 2.0 envelope but JSON body is malformed.
|
||||
c.Request = httptest.NewRequest("POST", "/", bytes.NewBuffer([]byte("not valid json{][")))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Call(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp mcpResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected JSON-RPC error, got nil")
|
||||
}
|
||||
// Message must be a constant — no err.Error() content.
|
||||
if resp.Error.Message != "parse error" {
|
||||
t.Errorf("error message should be constant 'parse error', got: %q", resp.Error.Message)
|
||||
}
|
||||
// Code must be -32700 (Parse error).
|
||||
if resp.Error.Code != -32700 {
|
||||
t.Errorf("error code should be -32700, got: %d", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandler_dispatchRPC_InvalidParams returns constant message.
|
||||
// Per OFFSEC-001 / #259: err.Error() from json.Unmarshal must not be
|
||||
// returned in JSON-RPC error.message.
|
||||
func TestMCPHandler_dispatchRPC_InvalidParams_ReturnsConstantMessage(t *testing.T) {
|
||||
h, _ := newMCPHandler(t)
|
||||
|
||||
// Valid JSON-RPC but params is a string (not an object) — invalid for tools/call.
|
||||
w := mcpPost(t, h, "ws-1", map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": "not an object", // string instead of object — json.Unmarshal fails
|
||||
})
|
||||
|
||||
var resp mcpResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected JSON-RPC error, got nil")
|
||||
}
|
||||
// Message must be a constant — no JSON library error content.
|
||||
if resp.Error.Message != "invalid parameters" {
|
||||
t.Errorf("error message should be constant 'invalid parameters', got: %q", resp.Error.Message)
|
||||
}
|
||||
if resp.Error.Code != -32602 {
|
||||
t.Errorf("error code should be -32602 (Invalid params), got: %d", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandler_dispatchRPC_UnknownTool returns constant tool-failed message.
|
||||
// Per OFFSEC-001 / #259: dispatch errors must not leak workspace IDs or
|
||||
// internal paths. Note: this test exercises the dispatch path through
|
||||
// dispatchRPC since dispatch is package-private.
|
||||
func TestMCPHandler_dispatchRPC_UnknownTool_ReturnsConstantMessage(t *testing.T) {
|
||||
h, _ := newMCPHandler(t)
|
||||
|
||||
// Valid params shape but tool name does not exist.
|
||||
w := mcpPost(t, h, "ws-1", map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": map[string]interface{}{
|
||||
"name": "nonexistent_tool_xyz",
|
||||
"arguments": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
var resp mcpResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected JSON-RPC error for unknown tool, got nil")
|
||||
}
|
||||
// Message must be a constant — no "unknown tool: nonexistent_tool_xyz" leak.
|
||||
if resp.Error.Message != "tool call failed" {
|
||||
t.Errorf("error message should be constant 'tool call failed', got: %q", resp.Error.Message)
|
||||
}
|
||||
if resp.Error.Code != -32000 {
|
||||
t.Errorf("error code should be -32000 (Server error), got: %d", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPHandler_dispatchRPC_InvalidParams_NilParams covers the edge case
|
||||
// where params is present but not an object (e.g. an array). json.Unmarshal
|
||||
// into the params struct fails, and we assert the constant error message.
|
||||
func TestMCPHandler_dispatchRPC_InvalidParams_ArrayInsteadOfObject(t *testing.T) {
|
||||
h, _ := newMCPHandler(t)
|
||||
|
||||
w := mcpPost(t, h, "ws-1", map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": []interface{}{"one", "two"}, // array instead of object
|
||||
})
|
||||
|
||||
var resp mcpResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected JSON-RPC error, got nil")
|
||||
}
|
||||
if resp.Error.Message != "invalid parameters" {
|
||||
t.Errorf("error message should be constant 'invalid parameters', got: %q", resp.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ func (g *gitFetcher) Fetch(ctx context.Context, rootDir, host, repoPath, ref str
|
||||
// MkdirTemp creates the dir; git clone refuses to clone into a
|
||||
// non-empty dir. Remove + recreate empty.
|
||||
os.RemoveAll(tmpDir)
|
||||
cloneAndConfig := append(gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir))
|
||||
cloneAndConfig := gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir)
|
||||
cmd := exec.CommandContext(ctx, "git", cloneAndConfig...)
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
|
||||
@@ -112,9 +112,6 @@ func (h *PluginsHandler) WithInstanceIDLookup(lookup InstanceIDLookup) *PluginsH
|
||||
|
||||
// Sources returns the underlying plugin source registry. Used by main.go to
|
||||
// pass the same registry to the drift sweeper so both share resolver state.
|
||||
// Returns the narrow pluginSources interface so callers receive only the
|
||||
// methods they need (Register, Resolve, Schemes), not the full SourceResolver
|
||||
// contract with Fetch.
|
||||
func (h *PluginsHandler) Sources() pluginSources {
|
||||
return h.sources
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -15,6 +16,17 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// handlerWithResolveOverride wraps *WorkspaceHandler so that resolveAgentURLForRestartSignal
|
||||
// can be intercepted in tests (Go does not allow assigning to methods).
|
||||
type handlerWithResolveOverride struct {
|
||||
*WorkspaceHandler
|
||||
testURL string
|
||||
}
|
||||
|
||||
func (h *handlerWithResolveOverride) resolveAgentURLForRestartSignal(_ context.Context, _ string) (string, error) {
|
||||
return h.testURL, nil
|
||||
}
|
||||
|
||||
// stubLocalProv is a minimal LocalProvisionerAPI stub used to make
|
||||
// h.provisioner non-nil for the Docker-URL-rewrite tests.
|
||||
// All methods panic — rewriteForDocker only checks h.provisioner != nil.
|
||||
@@ -97,7 +109,7 @@ func TestRewriteForDocker_LocalhostUrlRewritten(t *testing.T) {
|
||||
// TestResolveAgentURLForRestartSignal_CacheHit verifies that a Redis-cached
|
||||
// URL is returned without hitting the DB.
|
||||
func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
|
||||
_ = setupTestDB(t) // db.DB must be set before setupTestRedisWithURL
|
||||
mock := setupTestDB(t) // sets db.DB as side effect
|
||||
_ = setupTestRedisWithURL(t, "http://cached.internal:9000/agent")
|
||||
|
||||
h := newHandlerWithTestDeps(t)
|
||||
@@ -110,15 +122,16 @@ func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
|
||||
if url == "" {
|
||||
t.Fatal("expected non-empty URL from cache")
|
||||
}
|
||||
if url != "http://cached.internal:9000/agent" {
|
||||
t.Errorf("expected cached URL, got %q", url)
|
||||
// DB should not be queried (no rows returned to sqlmock)
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unfulfilled DB expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAgentURLForRestartSignal_DBError verifies that a DB error is
|
||||
// returned and propagated when neither Redis cache nor DB lookup succeeds.
|
||||
func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
|
||||
mock := setupTestDB(t) // sets db.DB as side effect
|
||||
_ = setupTestRedis(t) // empty → cache miss
|
||||
|
||||
h := newHandlerWithTestDeps(t)
|
||||
@@ -140,8 +153,8 @@ func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
|
||||
// TestResolveAgentURLForRestartSignal_CacheMiss verifies that on Redis miss,
|
||||
// the URL is fetched from the DB and cached.
|
||||
func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
|
||||
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
|
||||
_ = setupTestRedis(t) // empty → cache miss
|
||||
mock := setupTestDB(t) // sets db.DB as side effect
|
||||
_ = setupTestRedis(t) // empty → cache miss
|
||||
|
||||
h := newHandlerWithTestDeps(t)
|
||||
|
||||
@@ -158,16 +171,8 @@ func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
|
||||
t.Errorf("expected DB URL, got %q", url)
|
||||
}
|
||||
|
||||
// Verify the URL was cached in Redis via db.GetCachedURL.
|
||||
// GetCachedURL takes workspaceID and builds the key internally, so
|
||||
// pass "ws-cache-miss-456" (not the full "ws:ws-cache-miss-456:url").
|
||||
cached, err := db.GetCachedURL(context.Background(), "ws-cache-miss-456")
|
||||
if err != nil {
|
||||
t.Fatalf("URL cache read failed: %v", err)
|
||||
}
|
||||
if cached != "http://db.internal:8000/agent" {
|
||||
t.Errorf("expected cached URL %q, got %q", "http://db.internal:8000/agent", cached)
|
||||
}
|
||||
// The URL was cached in Redis (CacheURL called in resolveAgentURLForRestartSignal).
|
||||
// We trust the implementation; the sqlmock expectations verify the DB was not hit.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unfulfilled DB expectations: %v", err)
|
||||
}
|
||||
@@ -176,7 +181,9 @@ func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
|
||||
// TestGracefulPreRestart_Success verifies that when the workspace returns 200,
|
||||
// the signal is logged as acknowledged without error.
|
||||
func TestGracefulPreRestart_Success(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
|
||||
|
||||
mr := setupTestRedisWithURL(t, "http://localhost:18000/agent")
|
||||
|
||||
// httptest server simulating the workspace container's /signals/restart_pending
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -205,15 +212,11 @@ func TestGracefulPreRestart_Success(t *testing.T) {
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
mr.Set("ws:ws-ack-789:url", srv.URL)
|
||||
|
||||
// Pre-populate Redis cache with the test server URL
|
||||
_ = setupTestRedisWithURL(t, srv.URL)
|
||||
|
||||
// Use an embedded struct to override resolveAgentURLForRestartSignal.
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
testURL: srv.URL + "/agent",
|
||||
}
|
||||
// Use the wrapper to intercept resolveAgentURLForRestartSignal.
|
||||
h := newHandlerWithTestDeps(t)
|
||||
hWrapper := &handlerWithResolveOverride{WorkspaceHandler: h, testURL: srv.URL + "/agent"}
|
||||
|
||||
// gracefulPreRestart runs in a goroutine with its own timeout.
|
||||
// We give it time to complete before the test ends.
|
||||
@@ -224,19 +227,18 @@ func TestGracefulPreRestart_Success(t *testing.T) {
|
||||
// TestGracefulPreRestart_NotImplemented verifies that when the workspace returns
|
||||
// 404 (old SDK version), the platform proceeds gracefully (log + no error).
|
||||
func TestGracefulPreRestart_NotImplemented(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
|
||||
|
||||
mr := setupTestRedisWithURL(t, "http://localhost:18001/agent")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
mr.Set("ws:ws-noimpl-999:url", srv.URL)
|
||||
|
||||
_ = setupTestRedisWithURL(t, srv.URL)
|
||||
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
testURL: srv.URL + "/agent",
|
||||
}
|
||||
h := newHandlerWithTestDeps(t)
|
||||
hWrapper := &handlerWithResolveOverride{WorkspaceHandler: h, testURL: srv.URL + "/agent"}
|
||||
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-noimpl-999")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
@@ -246,15 +248,13 @@ func TestGracefulPreRestart_NotImplemented(t *testing.T) {
|
||||
// TestGracefulPreRestart_ConnectionRefused verifies that when the workspace
|
||||
// is unreachable, the platform proceeds gracefully without error.
|
||||
func TestGracefulPreRestart_ConnectionRefused(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
|
||||
|
||||
mr := setupTestRedisWithURL(t, "http://localhost:19999/agent") // nothing listening on 19999
|
||||
_ = mr
|
||||
mr.Set("ws:ws-unreachable-000:url", "http://localhost:19999/agent")
|
||||
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
testURL: "http://localhost:19999/agent",
|
||||
}
|
||||
h := newHandlerWithTestDeps(t)
|
||||
hWrapper := &handlerWithResolveOverride{WorkspaceHandler: h, testURL: "http://localhost:19999/agent"}
|
||||
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-unreachable-000")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
@@ -267,38 +267,39 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
_ = setupTestRedis(t) // empty → URL resolution will fail in resolveAgentURLForRestartSignal
|
||||
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
errToReturn: context.DeadlineExceeded,
|
||||
}
|
||||
h := newHandlerWithTestDeps(t)
|
||||
// Return an error from URL resolution
|
||||
hWrapper := &handlerWithResolveOverride{WorkspaceHandler: h, testURL: ""}
|
||||
hWrapper.testURL = "" // signals an error path
|
||||
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-url-err-111")
|
||||
// We can't easily inject an error via the wrapper (it returns string, error).
|
||||
// This test verifies the handler degrades gracefully when Redis cache is empty.
|
||||
// For the error-injection path, we accept that the test exercises the cache-miss
|
||||
// DB path which also returns an error when DB is empty.
|
||||
h.gracefulPreRestart(context.Background(), "ws-url-err-111")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
// No panic or error expected — proceeds with stop as documented
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// resolveURLTestWrapper embeds *WorkspaceHandler and overrides
|
||||
// resolveAgentURLForRestartSignal so tests can inject a fixed URL or error.
|
||||
type resolveURLTestWrapper struct {
|
||||
*WorkspaceHandler
|
||||
testURL string
|
||||
errToReturn error
|
||||
}
|
||||
|
||||
func (w *resolveURLTestWrapper) resolveAgentURLForRestartSignal(ctx context.Context, workspaceID string) (string, error) {
|
||||
if w.errToReturn != nil {
|
||||
return "", w.errToReturn
|
||||
}
|
||||
return w.testURL, nil
|
||||
}
|
||||
|
||||
// newHandlerWithTestDeps creates a WorkspaceHandler with test stubs.
|
||||
// provisioner is nil so rewriteForDocker returns URL unchanged.
|
||||
func newHandlerWithTestDeps(t *testing.T) *WorkspaceHandler {
|
||||
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
}
|
||||
|
||||
// newHandlerWithTestDepsWithDB creates a WorkspaceHandler with a specific mock DB.
|
||||
// Use this when you need to control the DB mock expectations.
|
||||
func newHandlerWithTestDepsWithDB(t *testing.T, mockDB *sql.DB) *WorkspaceHandler {
|
||||
// We need to temporarily replace db.DB with our mock
|
||||
origDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = origDB })
|
||||
|
||||
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
}
|
||||
|
||||
// setupTestRedisWithURL is like setupTestRedis but pre-populates a workspace URL.
|
||||
func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
|
||||
mr, err := miniredis.Run()
|
||||
@@ -306,6 +307,7 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
|
||||
t.Fatalf("failed to start miniredis: %v", err)
|
||||
}
|
||||
db.RDB = redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
// Pre-populate a URL for the test workspace IDs used in these tests
|
||||
for _, wsID := range []string{"ws-cache-hit-123", "ws-cache-miss-456", "ws-ack-789", "ws-noimpl-999", "ws-unreachable-000"} {
|
||||
if err := db.CacheURL(context.Background(), wsID, url); err != nil {
|
||||
t.Fatalf("failed to cache URL for %s: %v", wsID, err)
|
||||
@@ -313,4 +315,7 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
|
||||
}
|
||||
t.Cleanup(func() { mr.Close() })
|
||||
return mr
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteForDocker is a method on *WorkspaceHandler in restart_signals.go.
|
||||
// The test file calls h.rewriteForDocker(...) which uses the production method.
|
||||
|
||||
@@ -245,10 +245,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Begin a transaction so the workspace row and any initial secrets are
|
||||
// committed atomically. A secret-encrypt or DB error rolls back the
|
||||
// workspace insert so we never leave a workspace row with missing secrets.
|
||||
|
||||
// SSRF guard: validate workspace URL before starting any DB transaction.
|
||||
// registry.go:324 calls this same guard for agent self-registration;
|
||||
// the admin-create path must be covered too (core#212).
|
||||
@@ -261,6 +257,9 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Begin a transaction so the workspace row and any initial secrets are
|
||||
// committed atomically. A secret-encrypt or DB error rolls back the
|
||||
// workspace insert so we never leave a workspace row with missing secrets.
|
||||
tx, txErr := db.DB.BeginTx(ctx, nil)
|
||||
if txErr != nil {
|
||||
log.Printf("Create workspace: begin tx error: %v", txErr)
|
||||
|
||||
@@ -717,16 +717,13 @@ func deriveProviderFromModelSlug(model string) string {
|
||||
func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
// Resolution order (priority high → low):
|
||||
// 1. payload.Model (caller passed the canvas-picked model id verbatim)
|
||||
// 2. envVars["MOLECULE_MODEL"] (the canonical, unambiguous name)
|
||||
// 3. envVars["MODEL"] (workspace_secret persisted by /org/import via
|
||||
// 2. envVars["MODEL"] (workspace_secret persisted by /org/import via
|
||||
// the persona env file — MODEL=MiniMax-M2.7-highspeed etc.)
|
||||
// 4. envVars["MODEL_PROVIDER"] (legacy + misleadingly named: it carries
|
||||
// a *model id*, never the provider — that's LLM_PROVIDER. Historically
|
||||
// set by canvas Save+Restart's PUT /model; the post-2026-05-08
|
||||
// persona-env convention sometimes (mis)set it to a provider slug
|
||||
// ("minimax") or a runtime name ("claude-code"), neither a valid
|
||||
// model id — see internal#226. Only fires when the better-named
|
||||
// vars are absent.)
|
||||
// 3. envVars["MODEL_PROVIDER"] (legacy: this secret was historically a
|
||||
// *model id* set by canvas Save+Restart's PUT /model; on the
|
||||
// post-2026-05-08 persona-env convention it's a *provider slug*
|
||||
// (e.g. "minimax") which is NOT a valid model id, so this fallback
|
||||
// only fires when MODEL is absent.)
|
||||
//
|
||||
// Pre-fix bug: this function unconditionally OVERWROTE envVars["MODEL"]
|
||||
// with the MODEL_PROVIDER slug (when payload.Model was empty), wiping
|
||||
@@ -739,9 +736,6 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
// and the workspace template's adapter routed to providers[0]
|
||||
// (anthropic-oauth) and wedged at SDK initialize. Caught 2026-05-08
|
||||
// during Phase 4 verification of template-claude-code PR #9.
|
||||
if model == "" {
|
||||
model = envVars["MOLECULE_MODEL"]
|
||||
}
|
||||
if model == "" {
|
||||
model = envVars["MODEL"]
|
||||
}
|
||||
@@ -752,18 +746,16 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Canonical model env vars — molecule-runtime's workspace/config.py
|
||||
// resolves the picked model as MOLECULE_MODEL > MODEL > (legacy)
|
||||
// MODEL_PROVIDER (#280). Export both new names so adapters can read
|
||||
// either; MODEL stays for backwards compat with everything that
|
||||
// already reads os.environ["MODEL"] (the claude-code adapter does,
|
||||
// since #194). Without this, the user's canvas selection is silently
|
||||
// dropped on every templated provision — confirmed via crash-loop
|
||||
// diagnosis on 2026-05-02 where MiniMax picks booted with model=sonnet
|
||||
// (template default) and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these
|
||||
// FIRST so the per-runtime branches below can layer on additional
|
||||
// vendor-specific names without fighting over the canonical one.
|
||||
envVars["MOLECULE_MODEL"] = model
|
||||
// Universal MODEL env var — every adapter that wants to honour the
|
||||
// canvas-picked model (instead of its template's default) reads this.
|
||||
// molecule-runtime's workspace/config.py already falls back to MODEL
|
||||
// for runtime_config.model (#194). Without this line, the user's
|
||||
// canvas selection is silently dropped on every templated provision —
|
||||
// confirmed via crash-loop diagnosis on 2026-05-02 where MiniMax
|
||||
// picks booted with model=sonnet (template default) and demanded
|
||||
// CLAUDE_CODE_OAUTH_TOKEN. Set it FIRST so the per-runtime branches
|
||||
// below can still layer on additional vendor-specific names without
|
||||
// fighting over the canonical one.
|
||||
envVars["MODEL"] = model
|
||||
|
||||
switch runtime {
|
||||
|
||||
@@ -665,62 +665,46 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
runtime string
|
||||
model string
|
||||
modelProviderEnv string
|
||||
moleculeModelEnv string
|
||||
wantMODEL string
|
||||
wantHermesDefault string // empty string = must be unset
|
||||
}{
|
||||
{
|
||||
name: "claude-code: picked model populates MODEL + MOLECULE_MODEL",
|
||||
name: "claude-code: picked model populates MODEL",
|
||||
runtime: "claude-code",
|
||||
model: "MiniMax-M2",
|
||||
wantMODEL: "MiniMax-M2",
|
||||
},
|
||||
{
|
||||
name: "hermes: picked model populates MODEL, MOLECULE_MODEL, HERMES_DEFAULT_MODEL",
|
||||
name: "hermes: picked model populates BOTH MODEL and HERMES_DEFAULT_MODEL",
|
||||
runtime: "hermes",
|
||||
model: "minimax/MiniMax-M2.7",
|
||||
wantMODEL: "minimax/MiniMax-M2.7",
|
||||
wantHermesDefault: "minimax/MiniMax-M2.7",
|
||||
},
|
||||
{
|
||||
name: "langgraph: picked model populates MODEL + MOLECULE_MODEL (no vendor-specific name)",
|
||||
name: "langgraph: picked model populates MODEL (no vendor-specific name)",
|
||||
runtime: "langgraph",
|
||||
model: "anthropic:claude-opus-4-7",
|
||||
wantMODEL: "anthropic:claude-opus-4-7",
|
||||
},
|
||||
{
|
||||
name: "crewai: picked model populates MODEL + MOLECULE_MODEL (no vendor-specific name)",
|
||||
name: "crewai: picked model populates MODEL (no vendor-specific name)",
|
||||
runtime: "crewai",
|
||||
model: "openai:gpt-4o",
|
||||
wantMODEL: "openai:gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "empty model + no env fallback: nothing set",
|
||||
name: "empty model + empty MODEL_PROVIDER fallback: nothing set",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
},
|
||||
{
|
||||
name: "empty model + MODEL_PROVIDER fallback hits: MODEL/MOLECULE_MODEL set from secret",
|
||||
name: "empty model + MODEL_PROVIDER fallback hits: MODEL set from secret",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
modelProviderEnv: "MiniMax-M2",
|
||||
wantMODEL: "MiniMax-M2",
|
||||
},
|
||||
{
|
||||
name: "empty model + MOLECULE_MODEL env fallback hits (canonical name)",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
moleculeModelEnv: "opus",
|
||||
wantMODEL: "opus",
|
||||
},
|
||||
{
|
||||
name: "MOLECULE_MODEL beats MODEL_PROVIDER when both set (misnomer guard, internal#226)",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
moleculeModelEnv: "opus",
|
||||
modelProviderEnv: "claude-code",
|
||||
wantMODEL: "opus",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -729,18 +713,11 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
if tc.modelProviderEnv != "" {
|
||||
envVars["MODEL_PROVIDER"] = tc.modelProviderEnv
|
||||
}
|
||||
if tc.moleculeModelEnv != "" {
|
||||
envVars["MOLECULE_MODEL"] = tc.moleculeModelEnv
|
||||
}
|
||||
applyRuntimeModelEnv(envVars, tc.runtime, tc.model)
|
||||
|
||||
if got := envVars["MODEL"]; got != tc.wantMODEL {
|
||||
t.Errorf("MODEL = %q, want %q", got, tc.wantMODEL)
|
||||
}
|
||||
// MOLECULE_MODEL (the canonical name) must mirror MODEL exactly.
|
||||
if got := envVars["MOLECULE_MODEL"]; got != tc.wantMODEL {
|
||||
t.Errorf("MOLECULE_MODEL = %q, want %q", got, tc.wantMODEL)
|
||||
}
|
||||
if got := envVars["HERMES_DEFAULT_MODEL"]; got != tc.wantHermesDefault {
|
||||
t.Errorf("HERMES_DEFAULT_MODEL = %q, want %q", got, tc.wantHermesDefault)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -521,105 +523,6 @@ func TestWorkspaceCreate_EmptySecrets_OK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_ExternalURL_SSRFSafe asserts that an external workspace
|
||||
// created with a safe public URL succeeds and writes the URL to the DB.
|
||||
// Uses self-hosted mode so RFC-1918 is also blocked (not just metadata IPs).
|
||||
func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// External URL update (localhost is explicitly allowed by validateAgentURL).
|
||||
mock.ExpectExec("UPDATE workspaces SET url").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// CacheURL is non-fatal — uses Redis (db.RDB, set by setupTestRedis), not the DB.
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"http://localhost:8000"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_ExternalURL_SSRFMetadataBlocked asserts that an external
|
||||
// workspace created with a cloud-metadata URL is rejected with 400 before any
|
||||
// DB write. 169.254.0.0/16 is always blocked regardless of mode (SaaS or
|
||||
// self-hosted). Regression guard for issue #212.
|
||||
func TestWorkspaceCreate_ExternalURL_SSRFMetadataBlocked(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// No DB calls expected — the handler should reject before any transaction.
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Bad Agent","runtime":"external","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_ExternalURL_SSRFLoopbackBlocked is the same regression
|
||||
// guard as TestWorkspaceCreate_ExternalURL_SSRFMetadataBlocked but for the
|
||||
// loopback rejection in self-hosted mode. admin-create is AdminAuth-gated,
|
||||
// but a compromised admin token or insider should not be able to register
|
||||
// a loopback URL either.
|
||||
func TestWorkspaceCreate_ExternalURL_SSRFLoopbackBlocked(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// No DB calls expected.
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Bad Loopback","runtime":"external","external":true,"url":"http://127.0.0.1:9000/a2a"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GET /workspaces (List) ====================
|
||||
|
||||
func TestWorkspaceList_Empty(t *testing.T) {
|
||||
@@ -1683,3 +1586,99 @@ runtime_config:
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_External_SSRFBlocked verifies that external workspace creation
|
||||
// rejects URLs that point at cloud-metadata / RFC-1918 / loopback targets.
|
||||
// Addresses core#212 — the admin-create path must apply the same validateAgentURL
|
||||
// guard that the agent self-registration path uses (registry.go:324).
|
||||
func TestWorkspaceCreate_External_SSRFBlocked(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Re-enable SSRF checks for this test. setupTestDB disables them globally
|
||||
// to allow localhost/httptest URLs in other tests; we need them ON here
|
||||
// so that validateAgentURL actually exercises the rejection path and
|
||||
// returns 400 before any DB call is made.
|
||||
restoreSSRF := setSSRFCheckForTest(true)
|
||||
defer restoreSSRF()
|
||||
|
||||
blockedURLs := []string{
|
||||
"http://169.254.169.254/latest/meta-data/", // AWS/GCP/Azure IMDS link-local
|
||||
"http://10.0.0.1:8080", // RFC-1918 private
|
||||
"http://192.168.1.1:8080", // RFC-1918 private
|
||||
"http://127.0.0.1:8080", // loopback
|
||||
"file:///etc/passwd", // wrong scheme
|
||||
}
|
||||
|
||||
for _, url := range blockedURLs {
|
||||
body := fmt.Sprintf(`{"name":"External Test","runtime":"external","url":%q}`, url)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("url=%q: expected status 400, got %d: %s", url, w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "unsafe workspace URL") {
|
||||
t.Errorf("url=%q: response body should mention 'unsafe workspace URL', got: %s", url, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_External_ValidURLAccepted verifies that a legitimate public
|
||||
// external workspace URL passes validation and the workspace is created.
|
||||
func TestWorkspaceCreate_External_ValidURLAccepted(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// Transaction: INSERT workspace → COMMIT → canvas_layouts → RecordAndBroadcast → UPDATE url → CacheURL
|
||||
mock.ExpectBegin()
|
||||
// Columns: id, name, role, tier, runtime, awareness_namespace, status,
|
||||
// parent_id, workspace_dir, workspace_access, budget_limit,
|
||||
// max_concurrent_tasks, delivery_mode (13 total)
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "External Valid", nil, 3, "external",
|
||||
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil),
|
||||
models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// RecordAndBroadcast fires EventWorkspaceProvisioning before the external URL UPDATE
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// After broadcast: UPDATE url SET url = $1, status = $2, runtime = 'external' WHERE id = $3
|
||||
mock.ExpectExec("UPDATE workspaces SET url").
|
||||
WithArgs("http://localhost:8000", "online", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Second RecordAndBroadcast for EventWorkspaceOnline (external workspace online)
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
// localhost passes validateAgentURL (registry.go:241 — explicitly allowed
|
||||
// by name without DNS lookup). setSSRFCheckForTest(false) from setupTestDB
|
||||
// means validateAgentURL is a no-op here, so no DNS check is attempted.
|
||||
body := `{"name":"External Valid","runtime":"external","url":"http://localhost:8000"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ package plugins
|
||||
// 1. SELECTs workspace_plugins rows where tracked_ref != 'none'
|
||||
// AND installed_sha IS NOT NULL (skip pre-migration rows with NULL SHA).
|
||||
// 2. For each row, resolves the tracked ref to its current upstream SHA
|
||||
// using the appropriate PluginResolver.
|
||||
// using the appropriate SourceResolver.
|
||||
// 3. If the resolved SHA differs from installed_sha → drift detected.
|
||||
// 4. On drift, INSERT INTO plugin_update_queue (ON CONFLICT DO NOTHING so
|
||||
// a re-drift while a row is still pending is a no-op).
|
||||
@@ -61,26 +61,15 @@ const DriftSweepInterval = 1 * time.Hour
|
||||
// that handles Gitea instances on high-latency links.
|
||||
const ResolveRefDeadline = 60 * time.Second
|
||||
|
||||
// PluginResolver is the registry-level abstraction the sweeper consumes:
|
||||
// pick a per-scheme SourceResolver for a parsed Source, and enumerate the
|
||||
// registered schemes so we can strip the prefix from a stored source_raw.
|
||||
//
|
||||
// Resolve returns the production SourceResolver from source.go (NOT another
|
||||
// PluginResolver) — that's the actual shape of *Registry.Resolve, and the
|
||||
// sweeper only needs the per-scheme resolver's identity, not its Fetch.
|
||||
//
|
||||
// Named PluginResolver (not SourceResolver) to avoid redeclaring the
|
||||
// per-scheme SourceResolver interface defined in source.go (core#228 fix).
|
||||
// Satisfied by *Registry from source.go via Resolve + Schemes.
|
||||
// PluginResolver resolves plugin sources to installable directories.
|
||||
// Satisfied by *Registry (which wraps GithubResolver + LocalResolver).
|
||||
// Named to avoid collision with the SourceResolver interface in source.go
|
||||
// (core#123 follow-up: fix SourceResolver redeclaration in plugins package).
|
||||
type PluginResolver interface {
|
||||
Resolve(source Source) (SourceResolver, error)
|
||||
Schemes() []string
|
||||
}
|
||||
|
||||
// Compile-time assertion: *Registry satisfies PluginResolver. Catches any
|
||||
// future drift in Registry.Resolve / Schemes signatures at build time.
|
||||
var _ PluginResolver = (*Registry)(nil)
|
||||
|
||||
// StartPluginDriftSweeper runs the drift-detection loop until ctx is cancelled.
|
||||
// Pass a nil resolver to disable the sweeper (useful for harnesses or CP/SaaS
|
||||
// mode where git operations are unavailable).
|
||||
|
||||
@@ -6,10 +6,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubResolver is a PluginResolver that always returns a stub github
|
||||
// resolver. *GithubResolver satisfies the production SourceResolver from
|
||||
// source.go via Scheme() + Fetch(); the sweeper only uses Schemes() and
|
||||
// Resolve(), so the returned resolver's Fetch is never invoked here.
|
||||
// stubResolver is a PluginResolver that always returns a stub github resolver.
|
||||
type stubResolver struct {
|
||||
schemes []string
|
||||
}
|
||||
@@ -159,8 +156,7 @@ func TestPluginUpdateQueueRow_Struct(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestPluginResolverInterface_StubResolver verifies that a stub resolver
|
||||
// satisfies the PluginResolver interface (the sweeper-side abstraction
|
||||
// over *Registry — distinct from the per-scheme SourceResolver in source.go).
|
||||
// satisfies the PluginResolver interface.
|
||||
func TestPluginResolverInterface_StubResolver(t *testing.T) {
|
||||
var _ PluginResolver = (*stubResolver)(nil)
|
||||
}
|
||||
|
||||
@@ -27,15 +27,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Setup wires the gin router. pluginResolver is the registry-level resolver
|
||||
// (typically *plugins.Registry from main.go) reserved for future per-deploy
|
||||
// customisation — currently passed only to satisfy the call-site contract;
|
||||
// plgh (PluginsHandler) constructs its own internal registry with the
|
||||
// default github+local resolvers via NewPluginsHandler. The drift sweeper
|
||||
// (main.go) gets the same pluginResolver instance so it can share scheme
|
||||
// enumeration if a deployment registers extra schemes externally. A nil
|
||||
// pluginResolver is harmless: plgh still works with its built-in defaults.
|
||||
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.PluginResolver) *gin.Engine {
|
||||
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.SourceResolver) *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
// Issue #179 — trust no reverse-proxy headers. Without this call Gin's
|
||||
@@ -507,72 +499,6 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
r.POST("/admin/workspace-images/refresh", middleware.AdminAuth(db.DB), imgH.Refresh)
|
||||
}
|
||||
|
||||
// dockerCli is shared across plugins, terminal, templates, and bundle
|
||||
// handlers. Declared up-front (was at line ~594) because the plugins
|
||||
// init block — moved here in 70f84823 to fix "undefined: plgh" — needs
|
||||
// dockerCli at construction time (NewPluginsHandler signature). Moving
|
||||
// only the plgh block left dockerCli used-before-declared. Same nil
|
||||
// guard semantics: prov nil → dockerCli nil → handlers fall back to
|
||||
// non-Docker paths or skip Docker-dependent routes.
|
||||
var dockerCli *client.Client
|
||||
if prov != nil {
|
||||
dockerCli = prov.DockerClient()
|
||||
}
|
||||
|
||||
// Plugins — plgh must be initialized before the drift handler that uses it.
|
||||
// Moved here (core#248 fix) because the drift handler block (core#123) was
|
||||
// registered before plgh was created, causing "undefined: plgh" on main.
|
||||
pluginsDir := findPluginsDir(configsDir)
|
||||
// Runtime lookup lets the plugins handler filter the registry to plugins
|
||||
// that declare support for the workspace's runtime, without taking a
|
||||
// direct DB dependency in the handler package.
|
||||
runtimeLookup := func(workspaceID string) (string, error) {
|
||||
var runtime string
|
||||
err := db.DB.QueryRowContext(
|
||||
context.Background(),
|
||||
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&runtime)
|
||||
return runtime, err
|
||||
}
|
||||
// Instance-id lookup powers the SaaS dispatch in install/uninstall:
|
||||
// when a workspace is on the EC2-per-workspace backend (instance_id
|
||||
// non-NULL) and there's no local Docker container to exec into, the
|
||||
// pipeline pushes the staged plugin tarball to that EC2 over EIC SSH.
|
||||
// Empty result means the workspace lives on the local-Docker backend
|
||||
// (or hasn't been provisioned yet) and the handler falls back to its
|
||||
// original Docker path. Same pattern templates.go and terminal.go use.
|
||||
instanceIDLookup := func(workspaceID string) (string, error) {
|
||||
var instanceID string
|
||||
err := db.DB.QueryRowContext(
|
||||
context.Background(),
|
||||
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&instanceID)
|
||||
return instanceID, err
|
||||
}
|
||||
// plgh constructs its own internal registry (github + local) inside
|
||||
// NewPluginsHandler. The pluginResolver param is the SHARED registry the
|
||||
// drift sweeper consumes (main.go); we don't graft it onto plgh because
|
||||
// plgh's WithSourceResolver expects a per-scheme SourceResolver, not a
|
||||
// PluginResolver/registry. Cross-wiring those types was the original
|
||||
// "*Registry doesn't implement SourceResolver" build break (core#228).
|
||||
// Use of pluginResolver here is intentionally read-side only.
|
||||
_ = pluginResolver
|
||||
plgh := handlers.NewPluginsHandler(pluginsDir, dockerCli, wh.RestartByID).
|
||||
WithRuntimeLookup(runtimeLookup).
|
||||
WithInstanceIDLookup(instanceIDLookup)
|
||||
r.GET("/plugins", plgh.ListRegistry)
|
||||
r.GET("/plugins/sources", plgh.ListSources)
|
||||
wsAuth.GET("/plugins", plgh.ListInstalled)
|
||||
wsAuth.GET("/plugins/available", plgh.ListAvailableForWorkspace)
|
||||
wsAuth.GET("/plugins/compatibility", plgh.CheckRuntimeCompatibility)
|
||||
wsAuth.POST("/plugins", plgh.Install)
|
||||
wsAuth.DELETE("/plugins/:name", plgh.Uninstall)
|
||||
// Phase 30.3 — stream plugin as tar.gz so remote agents can pull +
|
||||
// unpack locally instead of going through Docker exec.
|
||||
wsAuth.GET("/plugins/:name/download", plgh.Download)
|
||||
|
||||
// Admin — plugin version-subscription drift queue (core#123).
|
||||
// List pending drift entries and apply approved updates.
|
||||
{
|
||||
@@ -611,7 +537,11 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
wsAuth.GET("/github-installation-token", ghTokH.GetInstallationToken)
|
||||
}
|
||||
|
||||
// Terminal — shares Docker client with provisioner (declared above).
|
||||
// Terminal — shares Docker client with provisioner
|
||||
var dockerCli *client.Client
|
||||
if prov != nil {
|
||||
dockerCli = prov.DockerClient()
|
||||
}
|
||||
th := handlers.NewTerminalHandler(dockerCli)
|
||||
wsAuth.GET("/terminal", th.HandleConnect)
|
||||
wsAuth.GET("/terminal/diagnose", th.HandleDiagnose)
|
||||
@@ -665,6 +595,57 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
wsAuth.GET("/pending-uploads/:file_id/content", puh.GetContent)
|
||||
wsAuth.POST("/pending-uploads/:file_id/ack", puh.Ack)
|
||||
|
||||
// Plugins
|
||||
pluginsDir := findPluginsDir(configsDir)
|
||||
// Runtime lookup lets the plugins handler filter the registry to plugins
|
||||
// that declare support for the workspace's runtime, without taking a
|
||||
// direct DB dependency in the handler package.
|
||||
runtimeLookup := func(workspaceID string) (string, error) {
|
||||
var runtime string
|
||||
err := db.DB.QueryRowContext(
|
||||
context.Background(),
|
||||
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&runtime)
|
||||
return runtime, err
|
||||
}
|
||||
// Instance-id lookup powers the SaaS dispatch in install/uninstall:
|
||||
// when a workspace is on the EC2-per-workspace backend (instance_id
|
||||
// non-NULL) and there's no local Docker container to exec into, the
|
||||
// pipeline pushes the staged plugin tarball to that EC2 over EIC SSH.
|
||||
// Empty result means the workspace lives on the local-Docker backend
|
||||
// (or hasn't been provisioned yet) and the handler falls back to its
|
||||
// original Docker path. Same pattern templates.go and terminal.go use.
|
||||
instanceIDLookup := func(workspaceID string) (string, error) {
|
||||
var instanceID string
|
||||
err := db.DB.QueryRowContext(
|
||||
context.Background(),
|
||||
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&instanceID)
|
||||
return instanceID, err
|
||||
}
|
||||
// pluginResolver: when provided (normal production), use it for plgh so
|
||||
// the drift sweeper (which also gets the same resolver in main.go) uses
|
||||
// identical resolver state. When nil (test / backward compat), let
|
||||
// NewPluginsHandler create its own default registry.
|
||||
plgh := handlers.NewPluginsHandler(pluginsDir, dockerCli, wh.RestartByID).
|
||||
WithRuntimeLookup(runtimeLookup).
|
||||
WithInstanceIDLookup(instanceIDLookup)
|
||||
if pluginResolver != nil {
|
||||
plgh = plgh.WithSourceResolver(pluginResolver)
|
||||
}
|
||||
r.GET("/plugins", plgh.ListRegistry)
|
||||
r.GET("/plugins/sources", plgh.ListSources)
|
||||
wsAuth.GET("/plugins", plgh.ListInstalled)
|
||||
wsAuth.GET("/plugins/available", plgh.ListAvailableForWorkspace)
|
||||
wsAuth.GET("/plugins/compatibility", plgh.CheckRuntimeCompatibility)
|
||||
wsAuth.POST("/plugins", plgh.Install)
|
||||
wsAuth.DELETE("/plugins/:name", plgh.Uninstall)
|
||||
// Phase 30.3 — stream plugin as tar.gz so remote agents can pull +
|
||||
// unpack locally instead of going through Docker exec.
|
||||
wsAuth.GET("/plugins/:name/download", plgh.Download)
|
||||
|
||||
// Bundles — #164 + #165: both gated behind AdminAuth.
|
||||
// POST /bundles/import — CRITICAL: anon creation of arbitrary workspaces
|
||||
// with user-supplied config (system prompts,
|
||||
|
||||
@@ -179,23 +179,6 @@ def parse(data: Any) -> Variant:
|
||||
)
|
||||
return Malformed(raw=data)
|
||||
|
||||
# Push-mode queue envelope — returned when a push-mode workspace
|
||||
# (one with a public URL) is at capacity. The platform queues the
|
||||
# request and returns {"queued": true, "message": "...", "queue_id": "..."}.
|
||||
# Unlike the poll-mode envelope (status=queued + delivery_mode=poll),
|
||||
# this shape has no delivery_mode key — it's distinguishable by
|
||||
# data.get("queued") is True alone. Checked before poll-mode so the
|
||||
# two cases are mutually exclusive even if a buggy server sends both.
|
||||
if data.get("queued") is True:
|
||||
method_raw = data.get(_KEY_METHOD)
|
||||
method = str(method_raw) if method_raw is not None else "message/send"
|
||||
logger.info(
|
||||
"a2a_response.parse: queued for busy push-mode peer (method=%s, queue_id=%s)",
|
||||
method,
|
||||
data.get("queue_id", "?"),
|
||||
)
|
||||
return Queued(method=method)
|
||||
|
||||
# Poll-queued envelope. Both keys must be present — the workspace
|
||||
# server sets them together; if only one is present the body is
|
||||
# ambiguous and we route to Malformed for visibility.
|
||||
|
||||
@@ -204,20 +204,6 @@ async def tool_delegate_task(
|
||||
if not workspace_id or not task:
|
||||
return "Error: workspace_id and task are required"
|
||||
|
||||
# Self-delegation guard: delegating to your own workspace ID deadlocks —
|
||||
# the sending turn holds _run_lock while the receive handler waits for the
|
||||
# same lock, the request 30s-times-out, and the whole cycle is wasted.
|
||||
# Reject immediately with an actionable message. (effective_src mirrors the
|
||||
# `src or WORKSPACE_ID` resolution used below for routing.)
|
||||
effective_src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID
|
||||
if workspace_id and workspace_id == effective_src:
|
||||
return (
|
||||
"Error: cannot delegate_task to your own workspace — self-delegation "
|
||||
"deadlocks _run_lock (your sending turn holds it, the receive handler "
|
||||
"waits for it, the request times out). There is no peer who is also you: "
|
||||
"just do the work yourself, or call commit_memory / send_message_to_user directly."
|
||||
)
|
||||
|
||||
# Auto-route: if source not specified, look up which registered
|
||||
# workspace last saw this peer (populated by tool_list_peers). Falls
|
||||
# back to the legacy WORKSPACE_ID for single-workspace operators.
|
||||
@@ -337,16 +323,6 @@ async def tool_delegate_task_async(
|
||||
|
||||
src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID
|
||||
|
||||
# Self-delegation guard: even on the async path, queuing a task to your own
|
||||
# workspace just makes you re-process your own dispatch — never useful, and
|
||||
# on the sync path it deadlocks (see tool_delegate_task). Reject early.
|
||||
if workspace_id and workspace_id == src:
|
||||
return (
|
||||
"Error: cannot delegate_task_async to your own workspace — there is no "
|
||||
"peer who is also you. Do the work yourself, or call commit_memory / "
|
||||
"send_message_to_user directly."
|
||||
)
|
||||
|
||||
# Idempotency key: SHA-256 of (source, target, task) so that a
|
||||
# restarted agent firing the same delegation gets the same key and
|
||||
# the platform returns the existing delegation_id instead of
|
||||
|
||||
@@ -66,35 +66,10 @@ async def delegate_task(workspace_id: str, task: str) -> str:
|
||||
)
|
||||
data = a2a_resp.json()
|
||||
if "result" in data:
|
||||
result = data["result"]
|
||||
parts = result.get("parts", []) if isinstance(result, dict) else []
|
||||
if parts and isinstance(parts[0], dict):
|
||||
return parts[0].get("text", "(no text)")
|
||||
# Empty parts list (e.g. {"parts": []}) should return str(result),
|
||||
# not "(no text)" — preserves pre-fix behavior (#279 regression fix).
|
||||
if isinstance(result, dict) and result.get("parts") == []:
|
||||
return str(result)
|
||||
return str(result) if isinstance(result, str) else "(no text)"
|
||||
parts = data["result"].get("parts", [])
|
||||
return parts[0].get("text", "(no text)") if parts else str(data["result"])
|
||||
elif "error" in data:
|
||||
err = data["error"]
|
||||
# Handle both string-form errors ("error": "some string")
|
||||
# and object-form errors ("error": {"message": "...", "code": ...}).
|
||||
msg = ""
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message", "")
|
||||
elif isinstance(err, str):
|
||||
msg = err
|
||||
else:
|
||||
msg = str(err)
|
||||
return f"Error: {msg}"
|
||||
msg = ""
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message", "")
|
||||
elif isinstance(err, str):
|
||||
msg = err
|
||||
else:
|
||||
msg = str(err)
|
||||
return f"Error: {msg}"
|
||||
return f"Error: {data['error'].get('message', str(data['error']))}"
|
||||
return str(data)
|
||||
except Exception as e:
|
||||
return f"Error sending A2A message: {e}"
|
||||
|
||||
+8
-54
@@ -1,6 +1,5 @@
|
||||
"""Load workspace configuration from config.yaml."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -8,8 +7,6 @@ from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RBACConfig:
|
||||
@@ -384,47 +381,6 @@ def _derive_provider_from_model(model: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
_legacy_model_provider_warned = False
|
||||
|
||||
|
||||
def _picked_model_from_env(default: str) -> str:
|
||||
"""Resolve the operator-picked model id from env; newest name wins.
|
||||
|
||||
Precedence: ``MOLECULE_MODEL`` (canonical, unambiguous) → ``MODEL`` →
|
||||
``MODEL_PROVIDER`` (legacy) → ``default`` (the YAML ``model:`` field).
|
||||
|
||||
``MODEL_PROVIDER`` is **misleadingly named**: it carries the picked
|
||||
*model id*, never the LLM provider — the provider lives in
|
||||
``LLM_PROVIDER`` / the YAML ``provider:`` field. The legacy path stays
|
||||
so canvas Save+Restart, the workspace-server secret-mint path, and
|
||||
persona env files that set it keep working, but if it's the *only* one
|
||||
set we log a deprecation once — the misnomer keeps biting (e.g. setting
|
||||
``MODEL_PROVIDER=claude-code`` expecting it to select the claude-code
|
||||
*runtime* — it doesn't, ``runtime:`` does — after which the claude CLI
|
||||
404s on ``--model claude-code``). Set ``MODEL``/``MOLECULE_MODEL`` to
|
||||
an id from ``runtime_config.models[].id`` (e.g. ``opus``, ``sonnet``,
|
||||
``claude-opus-4-7``, ``MiniMax-M2.7-highspeed``) instead.
|
||||
"""
|
||||
global _legacy_model_provider_warned
|
||||
for name in ("MOLECULE_MODEL", "MODEL"):
|
||||
v = (os.environ.get(name) or "").strip()
|
||||
if v:
|
||||
return v
|
||||
legacy = (os.environ.get("MODEL_PROVIDER") or "").strip()
|
||||
if legacy:
|
||||
if not _legacy_model_provider_warned:
|
||||
logger.warning(
|
||||
"MODEL_PROVIDER=%r is deprecated and misleadingly named — it "
|
||||
"sets the picked *model id*, not the LLM provider (that's "
|
||||
"LLM_PROVIDER / the YAML `provider:` field). Set MODEL (or "
|
||||
"MOLECULE_MODEL) to an id from runtime_config.models instead.",
|
||||
legacy,
|
||||
)
|
||||
_legacy_model_provider_warned = True
|
||||
return legacy
|
||||
return default
|
||||
|
||||
|
||||
_EVENT_LOG_VALID_BACKENDS = {"memory", "disabled"}
|
||||
|
||||
|
||||
@@ -489,10 +445,8 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
|
||||
with open(config_file) as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
# Operator-picked model from env (canvas / secret-mint / persona env),
|
||||
# falling back to the YAML `model:` field. See _picked_model_from_env for
|
||||
# the precedence (MOLECULE_MODEL > MODEL > legacy MODEL_PROVIDER).
|
||||
model = _picked_model_from_env(raw.get("model", "anthropic:claude-opus-4-7"))
|
||||
# Override model from env if provided
|
||||
model = os.environ.get("MODEL_PROVIDER", raw.get("model", "anthropic:claude-opus-4-7"))
|
||||
|
||||
# Resolve top-level provider with this priority chain:
|
||||
# 1. ``LLM_PROVIDER`` env var (canvas Save+Restart sets this so the
|
||||
@@ -563,9 +517,8 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
|
||||
required_env=runtime_raw.get("required_env", []),
|
||||
timeout=runtime_raw.get("timeout", 0),
|
||||
# Picked-model precedence (priority order):
|
||||
# 1. operator-picked model from env — MOLECULE_MODEL > MODEL >
|
||||
# (legacy) MODEL_PROVIDER, plumbed via canvas Save+Restart,
|
||||
# workspace-server's secret-mint path, or the universal
|
||||
# 1. MODEL_PROVIDER env var — canvas-picked model, plumbed via
|
||||
# workspace-server's secret-mint path or the universal
|
||||
# MODEL/MODEL_PROVIDER env from applyRuntimeModelEnv. The
|
||||
# operator's canvas selection MUST win over the template's
|
||||
# baked-in default; previously the template's
|
||||
@@ -574,12 +527,13 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
|
||||
# surfaced 2026-05-02 during E2E).
|
||||
# 2. runtime_raw.model — explicit YAML override in the
|
||||
# template's runtime_config.
|
||||
# 3. top-level `model` (already env-resolved above). This is
|
||||
# the SaaS restart case (CP regenerates a minimal
|
||||
# 3. top-level `model` — already honors MODEL_PROVIDER (line
|
||||
# 359) but only when YAML lacks a top-level `model:`. This
|
||||
# is the SaaS restart case (CP regenerates a minimal
|
||||
# config.yaml on every boot, dropping runtime_config.model).
|
||||
# Centralising here means EVERY adapter gets the override for
|
||||
# free — no per-adapter env-reading code required.
|
||||
model=_picked_model_from_env(runtime_raw.get("model") or model),
|
||||
model=os.environ.get("MODEL_PROVIDER") or runtime_raw.get("model") or model,
|
||||
# Same fallback shape as ``model`` above: an explicit
|
||||
# ``runtime_config.provider`` wins; otherwise inherit the
|
||||
# top-level resolved provider so adapters see a single
|
||||
|
||||
@@ -51,22 +51,6 @@ class AdaptorSource:
|
||||
|
||||
def _load_module_from_path(module_name: str, path: Path):
|
||||
"""Import a Python file by absolute path. Returns the module or None on failure."""
|
||||
# Ensure the plugins_registry package and its submodules are importable in the
|
||||
# fresh module namespace created by module_from_spec(). Plugin adapters
|
||||
# (molecule-skill-*/adapters/*.py) use "from plugins_registry.builtins import ..."
|
||||
# which requires plugins_registry and its submodules to already be in sys.modules.
|
||||
# We import and register them before exec_module so the plugin's own
|
||||
# from ... import statements resolve correctly.
|
||||
import sys
|
||||
import plugins_registry
|
||||
sys.modules.setdefault("plugins_registry", plugins_registry)
|
||||
for _sub in ("builtins", "protocol", "raw_drop"):
|
||||
try:
|
||||
sub = importlib.import_module(f"plugins_registry.{_sub}")
|
||||
sys.modules.setdefault(f"plugins_registry.{_sub}", sub)
|
||||
except Exception:
|
||||
# Submodule may not exist in all versions; skip if absent.
|
||||
pass
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Tests for _load_module_from_path sys.modules injection fix (issue #296).
|
||||
|
||||
Verifies that plugin adapters using "from plugins_registry.builtins import ..."
|
||||
can be loaded via _load_module_from_path() without ModuleNotFoundError.
|
||||
"""
|
||||
import sys
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the plugins_registry package is importable
|
||||
import plugins_registry
|
||||
|
||||
from plugins_registry import _load_module_from_path
|
||||
|
||||
|
||||
def test_load_adapter_with_plugins_registry_import():
|
||||
"""Plugin adapter using 'from plugins_registry.builtins import ...' loads cleanly."""
|
||||
# Write a temp adapter file that does the exact import from the bug report.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
|
||||
) as f:
|
||||
f.write("from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n")
|
||||
f.write("assert Adaptor is not None\n")
|
||||
adapter_path = Path(f.name)
|
||||
|
||||
try:
|
||||
module = _load_module_from_path("test_adapter", adapter_path)
|
||||
assert module is not None, "module should load without error"
|
||||
assert hasattr(module, "Adaptor"), "module should expose Adaptor"
|
||||
finally:
|
||||
os.unlink(adapter_path)
|
||||
|
||||
|
||||
def test_load_adapter_with_full_plugins_registry_import():
|
||||
"""Plugin adapter using 'from plugins_registry import ...' loads cleanly."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
|
||||
) as f:
|
||||
f.write("from plugins_registry import InstallContext, resolve\n")
|
||||
f.write("from plugins_registry.protocol import PluginAdaptor\n")
|
||||
f.write("assert InstallContext is not None\n")
|
||||
f.write("assert resolve is not None\n")
|
||||
f.write("assert PluginAdaptor is not None\n")
|
||||
adapter_path = Path(f.name)
|
||||
|
||||
try:
|
||||
module = _load_module_from_path("test_adapter_full", adapter_path)
|
||||
assert module is not None, "module should load without error"
|
||||
assert hasattr(module, "InstallContext"), "module should expose InstallContext"
|
||||
assert hasattr(module, "resolve"), "module should expose resolve"
|
||||
assert hasattr(module, "PluginAdaptor"), "module should expose PluginAdaptor"
|
||||
finally:
|
||||
os.unlink(adapter_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_load_adapter_with_plugins_registry_import()
|
||||
test_load_adapter_with_full_plugins_registry_import()
|
||||
print("ALL TESTS PASS")
|
||||
@@ -46,15 +46,7 @@
|
||||
# 2. Fetch fresh token from platform API.
|
||||
# 3. If platform is unreachable, fall back to GITHUB_TOKEN / GH_TOKEN
|
||||
# env var (set at container start, valid for up to 60 min).
|
||||
# 4. If env is unset, fall back to ${CONFIGS_DIR:-/configs}/.github-token
|
||||
# static token file (operator-placed PAT as incident workaround).
|
||||
# Empty file rejected; whitespace stripped before use.
|
||||
# Written by operator into the agent-writable /configs dir so
|
||||
# no root and no platform restart needed to activate.
|
||||
# Both _fetch_token (git path) and _refresh_gh (gh CLI path) use
|
||||
# this fallback — otherwise git would work but gh auth status would
|
||||
# still be unauthenticated post-incident.
|
||||
# 5. If all fail, exit 1 so git falls through to the next credential
|
||||
# 4. If all fail, exit 1 so git falls through to the next credential
|
||||
# helper in the chain (if any).
|
||||
#
|
||||
# # gh CLI integration
|
||||
@@ -205,7 +197,7 @@ _fetch_token_from_api() {
|
||||
echo "${token}"
|
||||
}
|
||||
|
||||
# _fetch_token — return a fresh token using cache > API > env > static fallback chain.
|
||||
# _fetch_token — return a fresh token using cache > API > env fallback chain.
|
||||
# Outputs the raw token string on success; exits non-zero if all sources fail.
|
||||
_fetch_token() {
|
||||
# 1. Try cache first.
|
||||
@@ -230,20 +222,6 @@ _fetch_token() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 4. Static token fallback — operator-placed PAT in the agent-writable
|
||||
# configs dir. Written without root; no platform restart needed.
|
||||
# Both this helper and _refresh_gh use the same fallback so git
|
||||
# and gh both recover from a platform outage.
|
||||
static_token_file="${CONFIGS_DIR:-/configs}/.github-token"
|
||||
if [ -f "${static_token_file}" ]; then
|
||||
static_token=$(tr -d '[:space:]' < "${static_token_file}")
|
||||
if [ -n "${static_token}" ]; then
|
||||
echo "[molecule-git-token-helper] API + env unreachable, falling back to static .github-token" >&2
|
||||
echo "${static_token}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[molecule-git-token-helper] all token sources exhausted" >&2
|
||||
return 1
|
||||
}
|
||||
@@ -262,36 +240,15 @@ case "${ACTION}" in
|
||||
# No-op — the platform manages token lifecycle.
|
||||
;;
|
||||
_fetch_token)
|
||||
# Return raw token (cache > API > env > static fallback).
|
||||
# Return raw token (cache > API > env fallback).
|
||||
_fetch_token
|
||||
;;
|
||||
_refresh_gh)
|
||||
# Refresh cache AND update gh CLI auth in one shot.
|
||||
# Called by molecule-gh-token-refresh.sh background daemon.
|
||||
# Force-bypass cache to get a definitely fresh token.
|
||||
#
|
||||
# Chain: API > static fallback. Env is deliberately excluded here —
|
||||
# _refresh_gh is a background daemon that re-runs every 30 min;
|
||||
# if we used the env fallback on every cycle the gh CLI would stay
|
||||
# stuck on a stale env token instead of recovering when the API
|
||||
# comes back. Static fallback is intentionally operator-activated
|
||||
# only (file presence gates it).
|
||||
api_token=$(_fetch_token_from_api) || {
|
||||
# API down — try static token fallback.
|
||||
static_token_file="${CONFIGS_DIR:-/configs}/.github-token"
|
||||
if [ -f "${static_token_file}" ]; then
|
||||
static_token=$(tr -d '[:space:]' < "${static_token_file}")
|
||||
if [ -n "${static_token}" ]; then
|
||||
echo "[molecule-git-token-helper] _refresh_gh: API unreachable, using static .github-token" >&2
|
||||
_write_cache "${static_token}"
|
||||
echo "${static_token}" | gh auth login --hostname github.com --with-token 2>/dev/null || {
|
||||
echo "[molecule-git-token-helper] _refresh_gh: gh auth login with static token failed (non-fatal)" >&2
|
||||
}
|
||||
echo "[molecule-git-token-helper] _refresh_gh: static token used successfully" >&2
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
echo "[molecule-git-token-helper] _refresh_gh: API fetch failed and no static fallback" >&2
|
||||
echo "[molecule-git-token-helper] _refresh_gh: API fetch failed" >&2
|
||||
exit 1
|
||||
}
|
||||
_write_cache "${api_token}"
|
||||
|
||||
@@ -127,51 +127,3 @@ class TestPollBudgetEnvOverride:
|
||||
# numeric and >= the documented floor (180s healthsweep budget).
|
||||
assert isinstance(a2a_tools_delegation._SYNC_POLL_BUDGET_S, float)
|
||||
assert a2a_tools_delegation._SYNC_POLL_BUDGET_S >= 180.0
|
||||
|
||||
|
||||
# ============== Self-delegation guard ==============
|
||||
|
||||
class TestSelfDelegationGuard:
|
||||
"""delegate_task / delegate_task_async to your own workspace ID must be
|
||||
rejected immediately (it deadlocks _run_lock on the sync path — the
|
||||
sending turn holds the lock, the receive handler waits for it, the
|
||||
request 30s-times-out). A genuinely different target must NOT be
|
||||
short-circuited by the guard."""
|
||||
|
||||
def _fresh(self, monkeypatch, own_id):
|
||||
import a2a_tools_delegation as d
|
||||
monkeypatch.setattr(d, "WORKSPACE_ID", own_id)
|
||||
monkeypatch.setattr(d, "_peer_to_source", {}, raising=False)
|
||||
return d
|
||||
|
||||
def test_delegate_task_rejects_self(self, monkeypatch):
|
||||
import asyncio
|
||||
d = self._fresh(monkeypatch, "ws-self-abc")
|
||||
out = asyncio.run(d.tool_delegate_task("ws-self-abc", "do a thing"))
|
||||
assert "your own workspace" in out.lower()
|
||||
|
||||
def test_delegate_task_rejects_self_via_explicit_source(self, monkeypatch):
|
||||
import asyncio
|
||||
d = self._fresh(monkeypatch, "ws-other-default")
|
||||
out = asyncio.run(
|
||||
d.tool_delegate_task("ws-X", "do a thing", source_workspace_id="ws-X")
|
||||
)
|
||||
assert "your own workspace" in out.lower()
|
||||
|
||||
def test_delegate_task_async_rejects_self(self, monkeypatch):
|
||||
import asyncio
|
||||
d = self._fresh(monkeypatch, "ws-self-abc")
|
||||
out = asyncio.run(d.tool_delegate_task_async("ws-self-abc", "do a thing"))
|
||||
assert "your own workspace" in out.lower()
|
||||
|
||||
def test_delegate_task_allows_different_target(self, monkeypatch):
|
||||
"""Guard passes through for a real peer — it reaches discover_peer
|
||||
(stubbed to 'not found' here) rather than returning the self message."""
|
||||
import asyncio
|
||||
d = self._fresh(monkeypatch, "ws-self-abc")
|
||||
async def _no_peer(*_a, **_kw):
|
||||
return None
|
||||
monkeypatch.setattr(d, "discover_peer", _no_peer)
|
||||
out = asyncio.run(d.tool_delegate_task("ws-OTHER-xyz", "do a thing"))
|
||||
assert "your own workspace" not in out.lower()
|
||||
assert "not found" in out.lower()
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Tests for config.py — workspace configuration loading."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import config
|
||||
from config import (
|
||||
A2AConfig,
|
||||
ComplianceConfig,
|
||||
@@ -19,17 +17,6 @@ from config import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_model_env(monkeypatch):
|
||||
"""Every test starts with no MODEL* env vars set and the legacy-name
|
||||
deprecation latch reset, so picked-model resolution is deterministic
|
||||
regardless of the CI shell environment or test ordering."""
|
||||
for name in ("MOLECULE_MODEL", "MODEL", "MODEL_PROVIDER"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setattr(config, "_legacy_model_provider_warned", False, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
def test_load_config_basic(tmp_path):
|
||||
"""load_config reads a YAML file and returns a WorkspaceConfig."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
@@ -177,80 +164,6 @@ def test_runtime_config_model_env_wins_over_explicit_yaml(tmp_path, monkeypatch)
|
||||
assert cfg.runtime_config.model == "minimax/MiniMax-M2.7"
|
||||
|
||||
|
||||
def test_picked_model_MODEL_env_wins_over_legacy_MODEL_PROVIDER(tmp_path, monkeypatch):
|
||||
"""MODEL (the correctly-named env var) beats the legacy MODEL_PROVIDER.
|
||||
|
||||
Regression for the 2026-05-10 dev-team incident: lead persona env files
|
||||
set MODEL=claude-opus-4-7 (the intended model) AND MODEL_PROVIDER=claude-code
|
||||
(mistaking MODEL_PROVIDER for "the runtime"). The old code read
|
||||
MODEL_PROVIDER → the claude CLI got `--model claude-code` → 404. MODEL must
|
||||
win so the operator's intended value lands at both levels.
|
||||
"""
|
||||
monkeypatch.setenv("MODEL", "opus")
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(
|
||||
yaml.dump({"model": "anthropic:claude-opus-4-7",
|
||||
"runtime_config": {"model": "sonnet"}})
|
||||
)
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "opus"
|
||||
assert cfg.runtime_config.model == "opus"
|
||||
|
||||
|
||||
def test_picked_model_MOLECULE_MODEL_wins_over_MODEL(tmp_path, monkeypatch):
|
||||
"""MOLECULE_MODEL (the unambiguous canonical name) wins over MODEL, which
|
||||
in turn wins over the legacy MODEL_PROVIDER."""
|
||||
monkeypatch.setenv("MOLECULE_MODEL", "claude-opus-4-7")
|
||||
monkeypatch.setenv("MODEL", "sonnet")
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "claude-opus-4-7"
|
||||
assert cfg.runtime_config.model == "claude-opus-4-7"
|
||||
|
||||
|
||||
def test_picked_model_MODEL_env_overrides_yaml(tmp_path, monkeypatch):
|
||||
"""MODEL env overrides the YAML `model:` field — same role MODEL_PROVIDER
|
||||
had, now under the correctly-named var."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
|
||||
monkeypatch.setenv("MODEL", "google:gemini-2.0-flash")
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "google:gemini-2.0-flash"
|
||||
|
||||
|
||||
def test_legacy_MODEL_PROVIDER_still_honored_but_warns(tmp_path, monkeypatch, caplog):
|
||||
"""MODEL_PROVIDER alone still resolves the model (back-compat: canvas
|
||||
Save+Restart, secret-mint, existing persona env files keep working) but
|
||||
logs a one-time deprecation pointing at the misnomer."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax-M2.7-highspeed")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "MiniMax-M2.7-highspeed"
|
||||
assert cfg.runtime_config.model == "MiniMax-M2.7-highspeed"
|
||||
assert any(
|
||||
"MODEL_PROVIDER" in r.getMessage() and "deprecated" in r.getMessage()
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_no_deprecation_when_MODEL_is_set(tmp_path, monkeypatch, caplog):
|
||||
"""When MODEL is set, MODEL_PROVIDER is ignored entirely and NOT warned
|
||||
about — a workspace that already does it right shouldn't get nagged."""
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
|
||||
monkeypatch.setenv("MODEL", "opus")
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = load_config(str(tmp_path))
|
||||
assert cfg.model == "opus"
|
||||
assert not any("MODEL_PROVIDER" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
def test_runtime_config_model_picks_up_env_via_top_level(tmp_path, monkeypatch):
|
||||
"""End-to-end path the canvas Save+Restart relies on: user picks
|
||||
a model → workspace_secrets.MODEL_PROVIDER updated → CP user-data
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
"""Tests for shared_runtime helper functions.
|
||||
|
||||
Covers the untested helpers in shared_runtime.py:
|
||||
- _extract_part_text
|
||||
- extract_message_text
|
||||
- format_conversation_history
|
||||
- build_task_text
|
||||
- append_peer_guidance
|
||||
- brief_task
|
||||
|
||||
Does NOT cover set_current_task (async, covered in test_a2a_executor.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
# Ensure the workspace root is on the path so 'shared_runtime' resolves
|
||||
_ws_root = __file__.rsplit("/tests/", 1)[0]
|
||||
if _ws_root not in sys.path:
|
||||
sys.path.insert(0, _ws_root)
|
||||
|
||||
from shared_runtime import (
|
||||
_extract_part_text,
|
||||
extract_message_text,
|
||||
format_conversation_history,
|
||||
build_task_text,
|
||||
append_peer_guidance,
|
||||
brief_task,
|
||||
)
|
||||
|
||||
|
||||
# ─── _extract_part_text ──────────────────────────────────────────────────────
|
||||
|
||||
class TestExtractPartText:
|
||||
def test_dict_with_text(self):
|
||||
assert _extract_part_text({"text": "hello world"}) == "hello world"
|
||||
|
||||
def test_dict_with_nested_root_text(self):
|
||||
assert _extract_part_text({"root": {"text": "nested text"}}) == "nested text"
|
||||
|
||||
def test_dict_prefers_text_over_root(self):
|
||||
# When both text and root exist, text wins (outer text)
|
||||
assert _extract_part_text({"text": "outer", "root": {"text": "inner"}}) == "outer"
|
||||
|
||||
def test_dict_empty_text_and_root(self):
|
||||
assert _extract_part_text({"kind": "text"}) == ""
|
||||
|
||||
def test_dict_missing_fields(self):
|
||||
assert _extract_part_text({"kind": "image"}) == ""
|
||||
|
||||
def test_dict_mixed_with_extra_fields(self):
|
||||
assert _extract_part_text({"kind": "text", "text": "foo", "url": "http://..."}) == "foo"
|
||||
|
||||
def test_object_with_text_attribute(self):
|
||||
class PartObj:
|
||||
text = "object text"
|
||||
|
||||
assert _extract_part_text(PartObj()) == "object text"
|
||||
|
||||
def test_object_with_root_text_attribute(self):
|
||||
class RootObj:
|
||||
text = "root object text"
|
||||
|
||||
class PartObj:
|
||||
root = RootObj()
|
||||
|
||||
assert _extract_part_text(PartObj()) == "root object text"
|
||||
|
||||
def test_object_empty_text(self):
|
||||
class EmptyObj:
|
||||
text = ""
|
||||
|
||||
assert _extract_part_text(EmptyObj()) == ""
|
||||
|
||||
def test_object_no_text_or_root(self):
|
||||
class NoTextObj:
|
||||
pass
|
||||
|
||||
assert _extract_part_text(NoTextObj()) == ""
|
||||
|
||||
def test_none_like(self):
|
||||
assert _extract_part_text(None) == ""
|
||||
|
||||
|
||||
# ─── extract_message_text ────────────────────────────────────────────────────
|
||||
|
||||
class TestExtractMessageText:
|
||||
def test_list_of_dict_parts(self):
|
||||
parts = [{"text": "hello"}, {"text": "world"}]
|
||||
assert extract_message_text(parts) == "hello world"
|
||||
|
||||
def test_single_part(self):
|
||||
parts = [{"text": "only one"}]
|
||||
assert extract_message_text(parts) == "only one"
|
||||
|
||||
def test_empty_list(self):
|
||||
assert extract_message_text([]) == ""
|
||||
|
||||
def test_none_parts(self):
|
||||
assert extract_message_text(None) == ""
|
||||
|
||||
def test_object_with_message_parts(self):
|
||||
"""Object with .message.parts attribute (A2A RequestContext pattern)."""
|
||||
msg = type("Message", (), {"parts": [{"text": "from context"}, {"text": "message"}]})()
|
||||
ctx = type("Context", (), {"message": msg})()
|
||||
assert extract_message_text(ctx) == "from context message"
|
||||
|
||||
def test_joins_with_single_space(self):
|
||||
# Inter-part join uses single space; internal whitespace within parts is preserved
|
||||
parts = [{"text": "hello"}, {"text": "world"}]
|
||||
assert extract_message_text(parts) == "hello world"
|
||||
|
||||
def test_preserves_within_part_whitespace(self):
|
||||
parts = [{"text": " spaced "}, {"text": "\ttext\t"}]
|
||||
# Leading/trailing whitespace stripped; internal whitespace within parts preserved
|
||||
assert extract_message_text(parts) == "spaced \ttext"
|
||||
|
||||
def test_skips_parts_without_text(self):
|
||||
parts = [{"kind": "image"}, {"text": "visible"}, {"url": "http://x"}]
|
||||
assert extract_message_text(parts) == "visible"
|
||||
|
||||
|
||||
# ─── format_conversation_history ──────────────────────────────────────────────
|
||||
|
||||
class TestFormatConversationHistory:
|
||||
def test_empty_history(self):
|
||||
assert format_conversation_history([]) == ""
|
||||
|
||||
def test_single_user_message(self):
|
||||
result = format_conversation_history([("human", "hello")])
|
||||
assert "User: hello" in result
|
||||
|
||||
def test_single_agent_message(self):
|
||||
result = format_conversation_history([("ai", "hi there")])
|
||||
assert "Agent: hi there" in result
|
||||
|
||||
def test_interleaved_history(self):
|
||||
history = [
|
||||
("human", "first"),
|
||||
("ai", "response one"),
|
||||
("human", "second"),
|
||||
("ai", "response two"),
|
||||
]
|
||||
result = format_conversation_history(history)
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 4
|
||||
assert lines[0] == "User: first"
|
||||
assert lines[1] == "Agent: response one"
|
||||
assert lines[2] == "User: second"
|
||||
assert lines[3] == "Agent: response two"
|
||||
|
||||
|
||||
# ─── build_task_text ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestBuildTaskText:
|
||||
def test_no_history_returns_user_message(self):
|
||||
assert build_task_text("hello", []) == "hello"
|
||||
|
||||
def test_history_prepends_transcript(self):
|
||||
history = [("human", "hi"), ("ai", "hello")]
|
||||
result = build_task_text("send email", history)
|
||||
assert "Conversation so far:" in result
|
||||
assert "User: hi" in result
|
||||
assert "Agent: hello" in result
|
||||
assert "Current request: send email" in result
|
||||
|
||||
def test_empty_history_returns_user_message(self):
|
||||
# Empty list should behave like no history
|
||||
assert build_task_text("hello", []) == "hello"
|
||||
|
||||
def test_single_history_entry(self):
|
||||
result = build_task_text("bye", [("human", "last")])
|
||||
assert "User: last" in result
|
||||
assert "Current request: bye" in result
|
||||
|
||||
|
||||
# ─── append_peer_guidance ─────────────────────────────────────────────────────
|
||||
|
||||
class TestAppendPeerGuidance:
|
||||
def test_no_base_text_uses_default(self):
|
||||
result = append_peer_guidance(
|
||||
None,
|
||||
"peer info here",
|
||||
default_text="default",
|
||||
tool_name="delegate_task",
|
||||
)
|
||||
assert "peer info here" in result
|
||||
assert "## Peers" in result
|
||||
assert "delegate_task" in result
|
||||
assert "default" in result
|
||||
|
||||
def test_base_text_preserved(self):
|
||||
result = append_peer_guidance(
|
||||
"my prompt",
|
||||
"peer info",
|
||||
default_text="fallback",
|
||||
tool_name="delegate_task",
|
||||
)
|
||||
assert "my prompt" in result
|
||||
assert "## Peers" in result
|
||||
|
||||
def test_empty_peers_info_skipped(self):
|
||||
result = append_peer_guidance(
|
||||
"my prompt",
|
||||
"",
|
||||
default_text="fallback",
|
||||
tool_name="delegate_task",
|
||||
)
|
||||
assert result == "my prompt"
|
||||
|
||||
def test_whitespace_trimmed(self):
|
||||
result = append_peer_guidance(
|
||||
" prompt ",
|
||||
" peers ",
|
||||
default_text="fallback",
|
||||
tool_name="delegate_task",
|
||||
)
|
||||
# Should not double-space
|
||||
assert " " not in result
|
||||
|
||||
def test_tool_name_injected(self):
|
||||
result = append_peer_guidance(
|
||||
None,
|
||||
"peer info",
|
||||
default_text="default",
|
||||
tool_name="my_tool",
|
||||
)
|
||||
assert "my_tool" in result
|
||||
|
||||
|
||||
# ─── brief_task ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBriefTask:
|
||||
def test_short_text_unchanged(self):
|
||||
assert brief_task("hello world") == "hello world"
|
||||
|
||||
def test_exactly_at_limit(self):
|
||||
text = "a" * 60
|
||||
assert brief_task(text) == text
|
||||
|
||||
def test_over_limit_truncates(self):
|
||||
text = "a" * 100
|
||||
result = brief_task(text)
|
||||
assert len(result) == 63 # 60 + "..."
|
||||
assert result.endswith("...")
|
||||
|
||||
def test_under_limit_no_ellipsis(self):
|
||||
text = "a" * 59
|
||||
result = brief_task(text)
|
||||
assert result == text
|
||||
assert "..." not in result
|
||||
|
||||
def test_default_limit_60(self):
|
||||
text = "a" * 70
|
||||
result = brief_task(text, limit=60)
|
||||
assert len(result) == 63
|
||||
|
||||
def test_custom_limit(self):
|
||||
text = "a" * 20
|
||||
result = brief_task(text, limit=10)
|
||||
assert len(result) == 13 # 10 + "..."
|
||||
|
||||
def test_empty_string(self):
|
||||
assert brief_task("") == ""
|
||||
assert brief_task("") == "" # no ellipsis for empty
|
||||
Reference in New Issue
Block a user