Compare commits

..

1 Commits

Author SHA1 Message Date
core-fe e522547035 fix(test): poll error counter to 0 before asserting in RecordsMetricsOnSuccess
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
audit-force-merge / audit (pull_request) Has been skipped
Race-detector CI runs (-race) are slow enough that a prior sweeper
goroutine (TestStartSweeper_TransientErrorDoesNotCrashLoop) may still
be running and incrementing pendingUploadsSweepErrors after
RecordsMetricsOnSuccess captures its metricDelta() baseline, but before
the success-path assertion. The test then reads deltaError=1 instead
of 0.

Fix: add waitForMetricDelta(t, deltaError, 0, 2*time.Second) before
the assertion, matching the polling pattern already used in the
error-path test (TestStartSweeper_RecordsMetricsOnError). This
ensures the error counter has settled before we assert on it.

Fixes molecule-core#22.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 23:22:48 +00:00
204 changed files with 741 additions and 18256 deletions
+52 -314
View File
@@ -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,48 +19,17 @@
# 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
# Ensure jq is available. Runners may not have it pre-installed, and the
# workflow-level jq install can fail on runners with network restrictions
# (GitHub releases not reachable from some runner networks — infra#241
# follow-up). This fallback is idempotent — no-op when jq is already on PATH.
# SOP_FAIL_OPEN=1 makes this always exit 0 so CI never blocks on jq absence.
if ! command -v jq >/dev/null 2>&1; then
echo "::notice::jq not found on PATH — attempting install..."
_jq_installed="no"
# apt-get first (primary) — Ubuntu package mirrors are reliably reachable.
if apt-get update -qq && apt-get install -y -qq jq 2>/dev/null; then
echo "::notice::jq installed via apt-get: $(jq --version)"
_jq_installed="yes"
# GitHub binary as secondary fallback — may fail on restricted networks.
elif timeout 120 curl -sSL \
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
-o /usr/local/bin/jq \
&& chmod +x /usr/local/bin/jq; then
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
_jq_installed="yes"
fi
if ! command -v jq >/dev/null 2>&1; then
echo "::error::jq installation failed — apt-get and GitHub binary both failed."
echo "::error::sop-tier-check requires jq for all JSON API parsing."
# SOP_FAIL_OPEN=1 is set in the workflow step's env — makes script always
# exit 0 so CI never blocks. The SOP-6 tier review gate remains enforced.
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
fi
debug() {
if [ "${SOP_DEBUG:-}" = "1" ]; then
echo " [debug] $*" >&2
@@ -96,27 +49,16 @@ API="https://${GITEA_HOST}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
# Sanity: token resolves to a user.
# Use || true on the jq pipeline so that set -euo pipefail (line 45) does not
# cause the script to exit prematurely when the token is empty/invalid — the
# if check below handles that case gracefully. Without || true, a 401 from an
# empty/invalid token causes jq to exit 1, triggering set -e and exiting the
# entire script before SOP_FAIL_OPEN can be evaluated (the check is in the jq-
# install block; if jq is already on PATH, that block is skipped entirely).
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""') || true
# Sanity: token resolves to a user
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
if [ -z "$WHOAMI" ]; then
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
echo "::notice::token resolves to user: $WHOAMI"
# 1. Read tier label. || true ensures set -euo pipefail does not abort the
# script if curl or jq fails (e.g. 401 from empty token).
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name') || true
# 1. Read tier label
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
TIER=""
for L in $LABELS; do
case "$L" in
@@ -135,277 +77,73 @@ 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}.
# set +e prevents set -e from aborting the script if curl fails (e.g. empty token).
# 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
set +e
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
"${API}/orgs/${OWNER}/teams")
_HTTP_EXIT=$?
set -e
debug "teams-list HTTP=$HTTP_CODE (curl exit=$_HTTP_EXIT) size=$(wc -c <"$ORG_TEAMS_FILE")"
debug "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
if [ "${SOP_DEBUG:-}" = "1" ]; then
echo " [debug] teams-list body (first 300 chars):" >&2
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
fi
if [ "$_HTTP_EXIT" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /orgs/${OWNER}/teams failed (curl exit=$_HTTP_EXIT HTTP=$HTTP_CODE) — token may lack read:org scope or be invalid."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
if [ "$HTTP_CODE" != "200" ]; then
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. set +e disables set -e temporarily so that curl
# failures (e.g. empty/invalid token → HTTP 401) do not abort the script before
# SOP_FAIL_OPEN is evaluated. set -e is restored immediately after.
set +e
# 3. Read approving reviewers
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
_REVIEWS_EXIT=$?
set -e
if [ $_REVIEWS_EXIT -ne 0 ] || [ -z "$REVIEWS" ]; then
echo "::error::Failed to fetch reviews (curl exit=$_REVIEWS_EXIT) — token may be invalid or unreachable."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]') || true
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 ]
-303
View File
@@ -1,303 +0,0 @@
name: publish-runtime
# Gitea Actions port of .github/workflows/publish-runtime.yml.
#
# Ported 2026-05-10 (issue #206). Key differences from the GitHub version:
# - Gitea Actions reads .gitea/workflows/, not .github/workflows/
# - Dropped `environment: pypi-publish` — Gitea Actions does not support
# named environments or OIDC trusted publishers
# - Replaced `pypa/gh-action-pypi-publish@release/v1` (OIDC) with
# `twine upload` using PYPI_TOKEN secret — same mechanism as a local
# `python -m twine upload` with a PyPI token
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/tags/}`
# — Gitea Actions exposes github.ref (the full ref) but not ref_name
# - Dropped `merge_group` trigger (Gitea has no merge queue)
# - Dropped `staging` branch trigger (no staging branch exists in this repo)
#
# PyPI publishing: requires PYPI_TOKEN repository secret (or org-level secret).
# Set via: repo Settings → Actions → Variables and Secrets → New Secret.
# The token should be a PyPI API token scoped to molecule-ai-workspace-runtime.
#
# The DISPATCH_TOKEN cascade (git push to template repos) is unchanged —
# it uses the Gitea API directly and was already Gitea-compatible.
on:
push:
tags:
- "runtime-v*"
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.1.6). Required for manual dispatch."
required: true
type: string
permissions:
contents: read
# Serialize publishes so two concurrent tag pushes don't both compute
# "latest+1" and race on PyPI upload. The second one waits.
concurrency:
group: publish-runtime
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
cache: pip
- name: Derive version (tag, manual input, or PyPI auto-bump)
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
elif echo "$GITHUB_REF" | grep -q "^refs/tags/runtime-v"; then
# Tag is `runtime-vX.Y.Z` — strip the prefix.
VERSION="${GITHUB_REF#refs/tags/runtime-v}"
else
# Fallback: derive from PyPI latest + patch bump.
# (The staging-push auto-bump trigger is dropped on Gitea —
# no staging branch exists. This fallback path is kept for
# robustness if a future automation uses workflow_dispatch without
# an explicit version input.)
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
MAJOR=$(echo "$LATEST" | cut -d. -f1)
MINOR=$(echo "$LATEST" | cut -d. -f2)
PATCH=$(echo "$LATEST" | cut -d. -f3)
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
echo "Auto-bumped from PyPI latest $LATEST -> $VERSION"
fi
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.dev[0-9]+|rc[0-9]+|a[0-9]+|b[0-9]+|\.post[0-9]+)?$'; then
echo "::error::version $VERSION does not match PEP 440"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing molecule-ai-workspace-runtime $VERSION"
- name: Install build tooling
run: pip install build twine
- name: Build package from workspace/
run: |
python scripts/build_runtime_package.py \
--version "${{ steps.version.outputs.version }}" \
--out "${{ runner.temp }}/runtime-build"
- name: Build wheel + sdist
working-directory: ${{ runner.temp }}/runtime-build
run: python -m build
- name: Capture wheel SHA256 for cascade content-verification
id: wheel_hash
working-directory: ${{ runner.temp }}/runtime-build
run: |
set -eu
WHEEL=$(ls dist/*.whl 2>/dev/null | head -1)
if [ -z "$WHEEL" ]; then
echo "::error::No .whl in dist/ — \`python -m build\` must have failed silently"
exit 1
fi
HASH=$(sha256sum "$WHEEL" | awk '{print $1}')
echo "wheel_sha256=${HASH}" >> "$GITHUB_OUTPUT"
echo "Local wheel SHA256 (pre-upload): ${HASH}"
echo "Wheel filename: $(basename "$WHEEL")"
- name: Verify package contents (sanity)
working-directory: ${{ runner.temp }}/runtime-build
run: |
python -m twine check dist/*
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install --quiet dist/*.whl
/tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/wheel_smoke.py"
- name: Publish to PyPI
env:
# PYPI_TOKEN: repository secret scoped to molecule-ai-workspace-runtime.
# Set via: Settings → Actions → Variables and Secrets → New Secret.
# Format: pypi-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: |
if [ -z "$PYPI_TOKEN" ]; then
echo "::error::PYPI_TOKEN secret is not set — set it at Settings → Actions → Variables and Secrets → New Secret."
echo "::error::Required format: pypi-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
exit 1
fi
python -m twine upload \
--repository pypi \
--username __token__ \
--password "$PYPI_TOKEN" \
dist/*
cascade:
needs: publish
runs-on: ubuntu-latest
steps:
- name: Wait for PyPI to propagate the new version
env:
RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
EXPECTED_SHA256: ${{ needs.publish.outputs.wheel_sha256 }}
run: |
set -eu
if [ -z "$EXPECTED_SHA256" ]; then
echo "::error::publish job did not expose wheel_sha256 — cannot verify wheel content. Refusing to fan out cascade."
exit 1
fi
python -m venv /tmp/propagation-probe
PROBE=/tmp/propagation-probe/bin
$PROBE/pip install --upgrade --quiet pip
for i in $(seq 1 30); do
if $PROBE/pip install \
--quiet \
--no-cache-dir \
--force-reinstall \
--no-deps \
"molecule-ai-workspace-runtime==${RUNTIME_VERSION}" \
>/dev/null 2>&1; then
INSTALLED=$($PROBE/pip show molecule-ai-workspace-runtime 2>/dev/null \
| awk -F': ' '/^Version:/{print $2}')
if [ "$INSTALLED" = "$RUNTIME_VERSION" ]; then
echo "✓ PyPI resolved $RUNTIME_VERSION (install check)"
break
fi
fi
if [ $i -eq 30 ]; then
echo "::error::pip install --no-cache-dir molecule-ai-workspace-runtime==${RUNTIME_VERSION} never resolved within ~5 min."
echo "::error::Refusing to fan out cascade against a potentially stale PyPI index."
exit 1
fi
echo " [$i/30] waiting for PyPI to propagate ${RUNTIME_VERSION}..."
sleep 4
done
# Stage (b): download wheel + SHA256 compare against what we built.
# Catches Fastly stale-content serving old bytes under a new version URL.
HASH=$(python -m pip download \
--no-deps \
--no-cache-dir \
--dest /tmp/wheel-probe \
"molecule-ai-workspace-runtime==${RUNTIME_VERSION}" \
2>/dev/null \
&& sha256sum /tmp/wheel-probe/*.whl | awk '{print $1}')
if [ "$HASH" != "$EXPECTED_SHA256" ]; then
echo "::error::PyPI propagated $RUNTIME_VERSION but wheel content SHA256 mismatch."
echo "::error::Expected: $EXPECTED_SHA256"
echo "::error::Got: $HASH"
echo "::error::Fastly may be serving stale content. Refusing to fan out cascade."
exit 1
fi
echo "✓ PyPI CDN verified (SHA256 match)"
- name: Fan out via push to .runtime-version
env:
# Gitea PAT with write:repository scope on the 8 cascade-active
# template repos. Used for git push to each template repo's main
# branch, which trips their `on: push: branches: [main]` trigger
# on publish-image.yml.
DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
run: |
set +e # don't abort on a single repo failure — collect them all
if [ -z "$DISPATCH_TOKEN" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::DISPATCH_TOKEN secret not set — skipping cascade."
echo "::warning::set it at Settings → Actions → Variables and Secrets → New Secret."
exit 0
fi
echo "::error::DISPATCH_TOKEN secret missing — cascade cannot fan out."
echo "::error::PyPI was published, but the 8 template repos will NOT pick up the new version."
exit 1
fi
VERSION="$RUNTIME_VERSION"
if [ -z "$VERSION" ]; then
echo "::error::publish job did not expose a version output"
exit 1
fi
GITEA_URL="${GITEA_URL:-https://git.moleculesai.app}"
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
FAILED=""
SKIPPED=""
git config --global user.name "publish-runtime cascade"
git config --global user.email "publish-runtime@moleculesai.app"
WORKDIR="$(mktemp -d)"
for tpl in $TEMPLATES; do
REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
CLONE="$WORKDIR/$tpl"
HTTP=$(curl -sS -o /dev/null -w "%{http_code}" \
-H "Authorization: token $DISPATCH_TOKEN" \
"$GITEA_URL/api/v1/repos/$REPO/contents/.github/workflows/publish-image.yml")
if [ "$HTTP" = "404" ]; then
echo "↷ $tpl has no publish-image.yml — soft-skip"
SKIPPED="$SKIPPED $tpl"
continue
fi
attempt=0
success=false
while [ $attempt -lt 3 ]; do
attempt=$((attempt + 1))
rm -rf "$CLONE"
if ! git clone --depth=1 \
"https://x-access-token:${DISPATCH_TOKEN}@${GITEA_URL#https://}/$REPO.git" \
"$CLONE" >/tmp/clone.log 2>&1; then
echo "::warning::clone $tpl attempt $attempt failed: $(tail -n3 /tmp/clone.log)"
sleep 2
continue
fi
cd "$CLONE"
echo "$VERSION" > .runtime-version
if git diff --quiet -- .runtime-version; then
echo "✓ $tpl already at $VERSION — no commit needed"
success=true
cd - >/dev/null
break
fi
git add .runtime-version
git commit -m "chore: pin runtime to $VERSION (publish-runtime cascade)" \
-m "Co-Authored-By: publish-runtime cascade <publish-runtime@moleculesai.app>" \
>/dev/null
if git push origin HEAD:main >/tmp/push.log 2>&1; then
echo "✓ $tpl pushed $VERSION on attempt $attempt"
success=true
cd - >/dev/null
break
fi
echo "::warning::push $tpl attempt $attempt failed, pull-rebasing"
git pull --rebase origin main >/tmp/rebase.log 2>&1 || true
cd - >/dev/null
done
if [ "$success" != "true" ]; then
FAILED="$FAILED $tpl"
fi
done
rm -rf "$WORKDIR"
if [ -n "$FAILED" ]; then
echo "::error::Cascade incomplete after 3 retries each. Failed:$FAILED"
exit 1
fi
if [ -n "$SKIPPED" ]; then
echo "Cascade complete: pinned $VERSION. Soft-skipped (no publish-image.yml):$SKIPPED"
else
echo "Cascade complete: $VERSION pinned across all manifest workspace_templates."
fi
@@ -1,172 +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
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible (e.g. permission change, daemon restart, or group-membership
# drift) rather than silently continuing to step 2 where `docker build`
# fails deep in the process with a cryptic ECR auth error that doesn't
# surface the root cause. Also reports the daemon version so operator
# can correlate with runner host logs.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build.
#
# 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}"
+17 -62
View File
@@ -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
@@ -77,50 +61,21 @@ jobs:
# works if we never check out PR HEAD. Same SHA the workflow
# itself was loaded from.
ref: ${{ github.event.pull_request.base.sha }}
- name: Install jq
# Gitea Actions runners (ubuntu-latest label) do not bundle jq.
# The sop-tier-check script uses jq for all JSON API parsing.
# Install jq before the script runs so sop-tier-check can pass.
#
# Method: apt-get first (reliable for Ubuntu runners with internet
# access to package mirrors). Falls back to GitHub binary download.
# GitHub releases may be unreachable from some runner networks
# (infra#241 follow-up: GitHub timeout after 3s on 5.78.80.188
# runners). The sop-tier-check script has its own fallback as a
# third line of defense. continue-on-error: true ensures this step
# failing does not block the job.
continue-on-error: true
run: |
# apt-get is the primary method — Ubuntu package mirrors are reliably
# reachable from runner containers. GitHub releases may be blocked
# or slow on some networks (infra#241 follow-up).
if apt-get update -qq && apt-get install -y -qq jq; then
echo "::notice::jq installed via apt-get: $(jq --version)"
elif timeout 120 curl -sSL \
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
-o /usr/local/bin/jq && chmod +x /usr/local/bin/jq; then
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
else
echo "::warning::jq install failed — apt-get and GitHub download both failed."
fi
jq --version 2>/dev/null || echo "::notice::jq not yet available — script fallback will retry"
- name: Verify tier label + reviewer team membership
# continue-on-error: true at step level — job-level is ignored by Gitea
# Actions (quirk #10, internal runbooks). Belt-and-suspenders with
# SOP_FAIL_OPEN=1 + || true below.
continue-on-error: true
env:
# SOP_TIER_CHECK_TOKEN is the org-level secret for the
# sop-tier-bot PAT (read:organization,read:user,read:issue,
# read:repository). Stored at the org level
# (/api/v1/orgs/molecule-ai/actions/secrets) so per-repo
# configuration is unnecessary — every repo in the org
# picks it up automatically.
# Falls back to GITHUB_TOKEN with a clear error if missing.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
# Set to '1' for diagnostic per-API-call output. Off by default
# so production logs aren't noisy.
SOP_DEBUG: '0'
SOP_LEGACY_CHECK: '0'
# SOP_FAIL_OPEN=1 makes the script always exit 0. The UI enforces
# the actual merge gate. Combined with continue-on-error: true
# above, this step never fails the job regardless of script exit.
SOP_FAIL_OPEN: '1'
run: |
bash .gitea/scripts/sop-tier-check.sh || true
run: bash .gitea/scripts/sop-tier-check.sh
+37 -118
View File
@@ -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"
+10 -36
View File
@@ -56,40 +56,21 @@ jobs:
run: ${{ steps.decide.outputs.run }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
run:
- 'workspace-server/**'
- 'canvas/**'
- 'tests/harness/**'
- '.github/workflows/harness-replays.yml'
- id: decide
run: |
# workflow_dispatch: always run (manual trigger)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
echo "debug=manual-trigger" >> "$GITHUB_OUTPUT"
exit 0
fi
# Determine the base commit to diff against.
# For pull_request: use base.sha (the merge-base with main/staging).
# For push: use github.event.before (the previous tip of the branch).
# Fallback for new branches (all-zeros SHA): run everything.
if [ "${{ github.event_name }}" = "pull_request" ] && \
[ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "${{ github.event.before }}" ] && \
! echo "${{ github.event.before }}" | grep -qE '^0+$'; then
BASE="${{ github.event.before }}"
else
# New branch or github.event.before unavailable — run everything.
echo "run=true" >> "$GITHUB_OUTPUT"
echo "debug=new-branch-fallback" >> "$GITHUB_OUTPUT"
exit 0
fi
# GitHub Actions and Gitea Actions both expose github.sha for HEAD.
DIFF=$(git diff --name-only "$BASE" "${{ github.sha }}" 2>/dev/null)
echo "debug=diff-base=$BASE diff-files=$DIFF" >> "$GITHUB_OUTPUT"
if echo "$DIFF" | grep -qE '^workspace-server/|^canvas/|^tests/harness/|^.github/workflows/harness-replays\.yml$'; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "run=${{ steps.filter.outputs.run }}" >> "$GITHUB_OUTPUT"
fi
# ONE job that always runs. Real work is gated per-step on
@@ -110,17 +91,10 @@ jobs:
run: |
echo "No workspace-server / canvas / tests/harness / workflow changes — Harness Replays gate satisfied without running."
echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)."
echo "::notice::Debug: ${{ needs.detect-changes.outputs.debug }}"
- if: needs.detect-changes.outputs.run == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Log what files were detected so future failures include the diff.
- name: Log detected changes
if: needs.detect-changes.outputs.run == 'true'
run: |
echo "::notice::detect-changes debug: ${{ needs.detect-changes.outputs.debug }}"
# github-app-auth sibling-checkout removed 2026-05-07 (#157):
# the plugin was dropped + Dockerfile.tenant no longer COPYs it.
@@ -54,22 +54,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
- name: Compute tags
id: tags
shell: bash
+1 -11
View File
@@ -1,15 +1,5 @@
name: publish-runtime
# DEPRECATED on Gitea Actions — this file is kept for reference only.
# Gitea Actions reads .gitea/workflows/, not .github/workflows/.
# The canonical version is now: .gitea/workflows/publish-runtime.yml
# That port:
# - Drops OIDC trusted publisher (Gitea has no environments/OIDC)
# - Uses PYPI_TOKEN secret instead of gh-action-pypi-publish
# - Uses ${GITHUB_REF#refs/tags/} instead of github.ref_name
# - Drops staging branch trigger (staging branch does not exist)
# - Drops merge_group trigger (Gitea has no merge queue)
#
# Publishes molecule-ai-workspace-runtime to PyPI from monorepo workspace/.
# Monorepo workspace/ is the only source-of-truth for runtime code; this
# workflow is the bridge from monorepo edits to the PyPI artifact that
@@ -180,7 +170,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/**'
@@ -107,22 +107,6 @@ jobs:
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build (Task #173 fix).
#
# Why pre-clone: post-2026-05-06, every workspace-template-* repo on
+15 -19
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
staging trigger
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f AS builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
# `npm ci` (not `install`) for lockfile-exact reproducibility.
@@ -15,7 +15,7 @@ ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN
RUN npm run build
FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
+2 -2
View File
@@ -354,7 +354,7 @@ function OrgCTA({ org }: { org: Org }) {
);
}
// provisioning / unknown — non-interactive
return <span className="text-sm text-ink-mid">{org.status}</span>;
return <span className="text-sm text-ink-soft">{org.status}</span>;
}
function EmptyState({ banner }: { banner?: React.ReactNode }) {
@@ -420,7 +420,7 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
aria-describedby="org-slug-hint"
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
/>
<p id="org-slug-hint" className="mt-1 text-xs text-ink-mid">
<p id="org-slug-hint" className="mt-1 text-xs text-ink-soft">
Lowercase letters, numbers, and hyphens only. Cannot be changed later.
</p>
</div>
+3 -3
View File
@@ -56,7 +56,7 @@ export default function Home() {
<div className="fixed inset-0 flex items-center justify-center bg-surface">
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
<Spinner size="lg" />
<span className="text-xs text-ink-mid">Loading canvas...</span>
<span className="text-xs text-ink-soft">Loading canvas...</span>
</div>
</div>
);
@@ -119,11 +119,11 @@ function PlatformDownDiagnostic() {
Most common cause on a dev host: one of those services stopped.
</p>
<div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full">
<div className="text-[10px] uppercase tracking-wider text-ink-mid mb-2">Try first</div>
<div className="text-[10px] uppercase tracking-wider text-ink-soft mb-2">Try first</div>
<pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
brew services start redis`}</pre>
</div>
<p className="text-[11px] text-ink-mid max-w-lg text-center">
<p className="text-[11px] text-ink-soft max-w-lg text-center">
If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for
the underlying error. If you&apos;re on hosted SaaS, this is a platform incident try again in a moment.
</p>
+2 -2
View File
@@ -55,13 +55,13 @@ export default function PricingPage() {
</a>
.
</p>
<p className="mt-6 text-sm text-ink-mid">
<p className="mt-6 text-sm text-ink-soft">
Prices shown in USD. Flat-rate per org no per-seat fees on any paid tier.
Enterprise / self-hosted licensing available contact us.
</p>
</section>
<footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-mid">
<footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-soft">
<p>
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
<a href="/legal/terms" className="hover:text-ink-mid">
+9 -9
View File
@@ -127,7 +127,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<span className="text-xs text-ink-mid">Loading audit trail</span>
<span className="text-xs text-ink-soft">Loading audit trail</span>
</div>
);
}
@@ -142,10 +142,10 @@ export function AuditTrailPanel({ workspaceId }: Props) {
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
}`}
>
{f.label}
@@ -155,7 +155,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0"
aria-label="Refresh audit trail"
>
@@ -174,9 +174,9 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{entries.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true"></span>
<span className="text-4xl text-ink-soft" aria-hidden="true"></span>
<p className="text-sm font-medium text-ink-mid">No audit events yet</p>
<p className="text-[11px] text-ink-mid max-w-[200px] leading-relaxed">
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
Delegation, decision, gate, and human-in-the-loop events will appear here.
</p>
</div>
@@ -195,7 +195,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
type="button"
onClick={loadMore}
disabled={loadingMore}
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
@@ -203,7 +203,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
)}
{/* Entry count footer */}
<p className="mt-3 text-center text-[9px] text-ink-mid">
<p className="mt-3 text-center text-[9px] text-ink-soft">
{entries.length} event{entries.length !== 1 ? "s" : ""} loaded
{cursor ? " · more available" : " · all loaded"}
</p>
@@ -265,7 +265,7 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
)}
{/* Relative timestamp */}
<span className="shrink-0 text-[9px] text-ink-mid">
<span className="shrink-0 text-[9px] text-ink-soft">
{formatAuditRelativeTime(entry.created_at, now)}
</span>
</div>
+1 -1
View File
@@ -125,7 +125,7 @@ export function BundleDropZone() {
<div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
<div className="text-xs text-ink-mid mt-1">.bundle.json files only</div>
<div className="text-xs text-ink-soft mt-1">.bundle.json files only</div>
</div>
</div>
)}
@@ -209,7 +209,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors"
>
<span aria-hidden="true"> </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
</button>
@@ -226,7 +226,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-ink-mid hover:text-ink-mid text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-ink-soft hover:text-ink-mid text-xs"
>
<span aria-hidden="true"></span>
</button>
@@ -268,7 +268,7 @@ export function CommunicationOverlay() {
</div>
</div>
{c.summary && (
<div className="text-ink-mid truncate mt-0.5 pl-4">{c.summary}</div>
<div className="text-ink-soft truncate mt-0.5 pl-4">{c.summary}</div>
)}
{c.durationMs && (
<div className="text-ink-mid pl-4">{c.durationMs}ms</div>
+2 -2
View File
@@ -103,7 +103,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
EC2 console output
</h3>
{workspaceName && (
<div className="text-[11px] text-ink-mid mt-0.5 truncate max-w-[600px]">
<div className="text-[11px] text-ink-soft mt-0.5 truncate max-w-[600px]">
{workspaceName}
</div>
)}
@@ -124,7 +124,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex-1 overflow-auto bg-black/80 p-4">
{loading && (
<div className="text-[12px] text-ink-mid" data-testid="console-loading">
<div className="text-[12px] text-ink-soft" data-testid="console-loading">
Loading console output
</div>
)}
+1 -1
View File
@@ -311,7 +311,7 @@ export function ContextMenu() {
aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
/>
<span className="text-[10px] text-ink-mid">{contextMenu.nodeData.status}</span>
<span className="text-[10px] text-ink-soft">{contextMenu.nodeData.status}</span>
</div>
</div>
@@ -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: "..."}
@@ -107,7 +106,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Title className="text-sm font-semibold text-ink">
Conversation Trace
</Dialog.Title>
<p className="text-[10px] text-ink-mid mt-0.5">
<p className="text-[10px] text-ink-soft mt-0.5">
{entries.length} events across all workspaces
</p>
</div>
@@ -115,7 +114,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<button
type="button"
aria-label="Close conversation trace"
className="text-ink-mid hover:text-ink-mid text-lg px-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-ink-soft hover:text-ink-mid text-lg px-2"
>
</button>
@@ -125,13 +124,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Timeline */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading && (
<div className="text-xs text-ink-mid text-center py-8">
<div className="text-xs text-ink-soft text-center py-8">
Loading trace from all workspaces...
</div>
)}
{!loading && entries.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8">
<div className="text-xs text-ink-soft text-center py-8">
No activity found
</div>
)}
@@ -251,7 +250,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Message content — show request and/or response */}
{requestText && (
<div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-ink-mid uppercase mb-1">
<div className="text-[8px] text-ink-soft uppercase mb-1">
{isSend ? "Task" : "Request"}
</div>
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
@@ -286,7 +285,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
>
Close
</button>
@@ -338,7 +338,7 @@ export function CreateWorkspaceButton() {
<Dialog.Title className="text-base font-semibold text-ink mb-1">
Create Workspace
</Dialog.Title>
<p className="text-xs text-ink-mid mb-5">
<p className="text-xs text-ink-soft mb-5">
Add a new workspace node to the canvas
</p>
@@ -376,7 +376,7 @@ export function CreateWorkspaceButton() {
/>
<div className="text-xs">
<div className="text-ink font-medium">External agent (bring your own compute)</div>
<div className="text-ink-mid mt-0.5">
<div className="text-ink-soft mt-0.5">
Skip the container. We&apos;ll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
</div>
</div>
@@ -411,7 +411,7 @@ export function CreateWorkspaceButton() {
tabIndex={tier === t.value ? 0 : -1}
onClick={() => setTier(t.value)}
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
className={`py-2 rounded-lg text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
className={`py-2 rounded-lg text-center transition-colors ${
tier === t.value
? "bg-accent-strong/20 border border-accent/50 text-accent"
: "bg-surface-card/60 border border-line/40 text-ink-mid hover:text-ink-mid hover:border-line"
@@ -456,7 +456,7 @@ export function CreateWorkspaceButton() {
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
Hermes Provider
</p>
<p className="text-[11px] text-ink-mid -mt-1">
<p className="text-[11px] text-ink-soft -mt-1">
Choose the AI provider and paste your API key. The key is
stored as an encrypted workspace secret.
</p>
@@ -534,7 +534,7 @@ export function CreateWorkspaceButton() {
(m) => <option key={m} value={m} />,
)}
</datalist>
<p className="text-[10px] text-ink-mid mt-1">
<p className="text-[10px] text-ink-soft mt-1">
Slug determines which provider hermes routes to at install time.
</p>
</div>
@@ -626,7 +626,7 @@ function InputField({
className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
/>
{helper && (
<p className="mt-1 text-xs text-ink-mid">{helper}</p>
<p className="mt-1 text-xs text-ink-soft">{helper}</p>
)}
</div>
);
+5 -5
View File
@@ -129,11 +129,11 @@ export function EmptyState() {
T{t.tier}
</span>
</div>
<p className="text-[11px] text-ink-mid line-clamp-2 leading-relaxed">
<p className="text-[11px] text-ink-soft line-clamp-2 leading-relaxed">
{t.description || "No description"}
</p>
{t.skill_count > 0 && (
<p className="text-[9px] text-ink-mid mt-1.5">
<p className="text-[9px] text-ink-soft mt-1.5">
{t.skill_count} skill{t.skill_count !== 1 ? "s" : ""}
{t.model ? ` · ${t.model}` : ""}
</p>
@@ -174,10 +174,10 @@ export function EmptyState() {
<div className="mt-5 pt-4 border-t border-line/50">
<div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid">
<span>Drag to nest workspaces into teams</span>
<span className="text-ink-mid">|</span>
<span className="text-ink-soft">|</span>
<span>Right-click for actions</span>
<span className="text-ink-mid">|</span>
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-mid font-mono">&#8984;K</kbd> to search</span>
<span className="text-ink-soft">|</span>
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-soft font-mono">&#8984;K</kbd> to search</span>
</div>
</div>
</div>
+2 -2
View File
@@ -83,7 +83,7 @@ export class ErrorBoundary extends React.Component<
<button
type="button"
onClick={this.handleReload}
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors"
>
Reload
</button>
@@ -93,7 +93,7 @@ export class ErrorBoundary extends React.Component<
e.preventDefault();
this.handleReport();
}}
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors"
>
Report
</a>
@@ -198,10 +198,10 @@ export function ExternalConnectModal({ info, onClose }: Props) {
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)}
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === t
? "border-accent text-ink"
: "border-transparent text-ink-mid hover:text-ink-mid"
: "border-transparent text-ink-soft hover:text-ink-mid"
}`}
>
{t === "claude"
@@ -309,7 +309,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
>
I&apos;ve saved it close
</button>
@@ -335,11 +335,11 @@ function SnippetBlock({
return (
<div>
<div className="flex items-center justify-between pb-1">
<span className="text-xs text-ink-mid">{label}</span>
<span className="text-xs text-ink-soft">{label}</span>
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -366,7 +366,7 @@ function Field({
}) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-ink-mid w-36 shrink-0">{label}</span>
<span className="text-xs text-ink-soft w-36 shrink-0">{label}</span>
<code
className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`}
>
@@ -376,7 +376,7 @@ function Field({
type="button"
onClick={onCopy}
disabled={!value}
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -16,14 +16,6 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
keys: ["Esc"],
description: "Close context menu, clear selection, or deselect",
},
{
keys: ["↑↓←→"],
description: "Nudge selected node 10px; hold Shift for 50px",
},
{
keys: ["Cmd", "↑↓←→"],
description: "Resize selected node (↑↓ height, ←→ width); hold Shift for fine control (2px)",
},
{
keys: ["Enter"],
description: "Descend into selected node's first child",
@@ -185,7 +177,7 @@ export function KeyboardShortcutsDialog({ open, onClose }: Props) {
<div className="overflow-y-auto p-5 space-y-5">
{SHORTCUT_GROUPS.map((group) => (
<div key={group.title}>
<h3 className="text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-mid mb-2.5">
<h3 className="text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-soft mb-2.5">
{group.title}
</h3>
<div className="space-y-2">
@@ -201,7 +193,7 @@ export function KeyboardShortcutsDialog({ open, onClose }: Props) {
{shortcut.keys.map((k, j) => (
<span key={j} className="flex items-center gap-0.5">
{j > 0 && (
<span className="text-[9px] text-ink-mid mx-0.5">
<span className="text-[9px] text-ink-soft mx-0.5">
+
</span>
)}
@@ -220,7 +212,7 @@ export function KeyboardShortcutsDialog({ open, onClose }: Props) {
{/* Footer */}
<div className="px-5 py-3 border-t border-line bg-surface-sunken/30 shrink-0">
<p className="text-[10px] text-ink-mid text-center">
<p className="text-[10px] text-ink-soft text-center">
Press{" "}
<kbd className="inline-flex items-center rounded border border-line/70 bg-surface-sunken/70 px-1.5 py-0.5 text-[10px] font-medium text-ink font-mono">
Esc
+4 -4
View File
@@ -97,7 +97,7 @@ export function Legend() {
// 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min).
// Negative margin keeps the visual position the same as before
// — only the hit area + focus ring are larger.
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-mid hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-soft hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
>
×
</button>
@@ -105,7 +105,7 @@ export function Legend() {
{/* Status */}
<div className="mb-2">
<div className="text-[11px] text-ink-mid font-medium mb-1">Status</div>
<div className="text-[11px] text-ink-soft font-medium mb-1">Status</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{LEGEND_STATUSES.map((s) => (
<StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} />
@@ -115,7 +115,7 @@ export function Legend() {
{/* Tiers */}
<div className="mb-2">
<div className="text-[11px] text-ink-mid font-medium mb-1">Tier</div>
<div className="text-[11px] text-ink-soft font-medium mb-1">Tier</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{LEGEND_TIERS.map(({ tier, label }) => (
<TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} />
@@ -125,7 +125,7 @@ export function Legend() {
{/* Communication */}
<div>
<div className="text-[11px] text-ink-mid font-medium mb-1">Communication</div>
<div className="text-[11px] text-ink-soft font-medium mb-1">Communication</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
<CommItem icon="↙" color="text-accent" label="A2A In" />
+18 -18
View File
@@ -288,7 +288,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
if (loading && entries.length === 0 && !error && !pluginUnavailable) {
return (
<div className="flex items-center justify-center h-32">
<span className="text-xs text-ink-mid">Loading memories</span>
<span className="text-xs text-ink-soft">Loading memories</span>
</div>
);
}
@@ -311,7 +311,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Namespace dropdown */}
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2">
<div className="flex items-center gap-2">
<label htmlFor="namespace-dropdown" className="text-[10px] text-ink-mid shrink-0">
<label htmlFor="namespace-dropdown" className="text-[10px] text-ink-soft shrink-0">
Namespace:
</label>
<select
@@ -337,7 +337,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
height="12"
viewBox="0 0 16 16"
fill="none"
className="absolute left-2.5 text-ink-mid pointer-events-none shrink-0"
className="absolute left-2.5 text-ink-soft pointer-events-none shrink-0"
aria-hidden="true"
>
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
@@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setDebouncedQuery('');
}}
aria-label="Clear search"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="absolute right-2 text-ink-soft hover:text-ink transition-colors text-sm leading-none"
>
×
</button>
@@ -370,7 +370,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Toolbar */}
<div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0">
<span className="text-[11px] text-ink-mid">
<span className="text-[11px] text-ink-soft">
{debouncedQuery
? `${entries.length} result${entries.length !== 1 ? 's' : ''}`
: entries.length === 1
@@ -381,7 +381,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
type="button"
onClick={loadEntries}
disabled={pluginUnavailable}
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Refresh memories"
>
Refresh
@@ -446,11 +446,11 @@ function EmptyState({
// mirror it so the operator sees both signals.
return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true">
<span className="text-4xl text-ink-soft" aria-hidden="true">
</span>
<p className="text-sm font-medium text-ink-mid">Memory plugin disabled</p>
<p className="text-[11px] text-ink-mid max-w-[220px] leading-relaxed">
<p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
See banner above for the operator-side fix.
</p>
</div>
@@ -459,11 +459,11 @@ function EmptyState({
if (query) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true">
<span className="text-4xl text-ink-soft" aria-hidden="true">
</span>
<p className="text-sm font-medium text-ink-mid">No memories match your search</p>
<p className="text-[11px] text-ink-mid max-w-[200px] leading-relaxed">
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
Try a different query or clear the search.
</p>
</div>
@@ -471,11 +471,11 @@ function EmptyState({
}
return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true">
<span className="text-4xl text-ink-soft" aria-hidden="true">
</span>
<p className="text-sm font-medium text-ink-mid">No memories yet</p>
<p className="text-[11px] text-ink-mid max-w-[220px] leading-relaxed">
<p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
Agents commit memories via MCP tools (commit_memory, commit_summary). They
appear here once written.
</p>
@@ -515,7 +515,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{/* Header row */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={bodyId}
@@ -558,7 +558,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{/* Namespace tag */}
<span
className="text-[9px] shrink-0 font-mono text-ink-mid truncate max-w-[100px]"
className="text-[9px] shrink-0 font-mono text-ink-soft truncate max-w-[100px]"
title={entry.namespace}
>
{entry.namespace}
@@ -598,10 +598,10 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
)}
<span className="text-[9px] text-ink-mid shrink-0">
<span className="text-[9px] text-ink-soft shrink-0">
{formatRelativeTime(entry.created_at)}
</span>
<span className="text-[9px] text-ink-mid shrink-0" aria-hidden="true">
<span className="text-[9px] text-ink-soft shrink-0" aria-hidden="true">
{expanded ? '▼' : '▶'}
</span>
</button>
@@ -618,7 +618,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{entry.content}
</pre>
<div className="flex items-center justify-between gap-2">
<span className="text-[9px] text-ink-mid">
<span className="text-[9px] text-ink-soft">
Created: {new Date(entry.created_at).toLocaleString()}
{entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`}
</span>
@@ -629,7 +629,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
onDelete();
}}
aria-label="Forget memory"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
>
Forget
</button>
+7 -7
View File
@@ -421,7 +421,7 @@ function ProviderPickerModal({
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
@@ -632,7 +632,7 @@ function AllKeysModal({
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
aria-label="Dismiss modal"
aria-hidden="true"
onClick={onCancel}
/>
@@ -675,7 +675,7 @@ function AllKeysModal({
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
@@ -706,7 +706,7 @@ function AllKeysModal({
type="button"
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{entry.saving ? "..." : "Save"}
</button>
@@ -730,7 +730,7 @@ function AllKeysModal({
<button
type="button"
onClick={onOpenSettings}
className="text-[11px] text-accent hover:text-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-[11px] text-accent hover:text-accent transition-colors"
>
Open Settings Panel
</button>
@@ -740,7 +740,7 @@ function AllKeysModal({
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Cancel Deploy
</button>
@@ -748,7 +748,7 @@ function AllKeysModal({
type="button"
onClick={handleAddKeysAndDeploy}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
>
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
</button>
@@ -247,7 +247,7 @@ export function OrgImportPreflightModal({
<h2 id="org-preflight-title" className="text-sm font-semibold text-ink">
Deploy {orgName}
</h2>
<p className="mt-0.5 text-[11px] text-ink-mid">
<p className="mt-0.5 text-[11px] text-ink-soft">
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
Review the credentials needed before import.
</p>
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
type="button"
onClick={onProceed}
disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
>
Import
</button>
@@ -400,7 +400,7 @@ function StrictEnvRow({
<li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5">
<code
className={`text-[11px] font-mono flex-1 ${
configured ? "text-ink-mid line-through" : "text-ink"
configured ? "text-ink-soft line-through" : "text-ink"
}`}
>
{envKey}
@@ -428,7 +428,7 @@ function StrictEnvRow({
type="button"
onClick={() => onSave(envKey)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
@@ -492,7 +492,7 @@ function AnyOfEnvGroup({
>
<code
className={`text-[11px] font-mono flex-1 ${
isConfigured ? "text-ink-mid line-through" : "text-ink"
isConfigured ? "text-ink-soft line-through" : "text-ink"
}`}
>
{m}
@@ -520,7 +520,7 @@ function AnyOfEnvGroup({
type="button"
onClick={() => onSave(m)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
+1 -1
View File
@@ -128,7 +128,7 @@ function PlanCard({
type="button"
onClick={onSelect}
disabled={loading}
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
plan.highlighted
? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900"
: "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50"
@@ -356,7 +356,7 @@ export function ProviderModelSelector({
<div>
<label
htmlFor={providerSelectId}
className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold mb-1.5 block"
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
>
Provider <span aria-hidden="true" className="text-bad">*</span>
<span className="sr-only"> (required)</span>
@@ -382,13 +382,13 @@ export function ProviderModelSelector({
{selected?.tooltip && (
<p
id={`${providerSelectId}-help`}
className="text-[9px] text-ink-mid mt-1 leading-relaxed"
className="text-[9px] text-ink-soft mt-1 leading-relaxed"
>
{selected.tooltip}
</p>
)}
{selected && selected.envVars.length > 0 && (
<p className="text-[9px] text-ink-mid mt-0.5 font-mono">
<p className="text-[9px] text-ink-soft mt-0.5 font-mono">
requires: {selected.envVars.join(", ")}
</p>
)}
@@ -397,7 +397,7 @@ export function ProviderModelSelector({
<div>
<label
htmlFor={modelSelectId}
className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold mb-1.5 block"
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
>
Model <span aria-hidden="true" className="text-bad">*</span>
<span className="sr-only"> (required)</span>
@@ -422,7 +422,7 @@ export function ProviderModelSelector({
data-testid="model-input"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
/>
<p className="text-[9px] text-ink-mid mt-1 leading-relaxed">
<p className="text-[9px] text-ink-soft mt-1 leading-relaxed">
{selected?.wildcard
? wildcardHelpText(selected)
: "Free-text model id. Make sure the provider can resolve it."}
@@ -437,7 +437,7 @@ export function ProviderModelSelector({
handleModelChange(selected.models[0]?.id ?? "");
}
}}
className="text-[9px] text-accent hover:text-accent mt-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-[9px] text-accent hover:text-accent mt-0.5"
>
back to model list
</button>
@@ -341,7 +341,7 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleRetry(entry.workspaceId)}
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
>
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
</button>
@@ -349,14 +349,14 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleCancelRequest(entry.workspaceId)}
disabled={isRetrying || isCancelling}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors"
>
{isCancelling ? "Cancelling..." : "Cancel"}
</button>
<button
type="button"
onClick={() => handleViewLogs(entry.workspaceId)}
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
>
View Logs
</button>
@@ -382,14 +382,14 @@ export function ProvisioningTimeout({
<button
type="button"
onClick={() => setConfirmingCancel(null)}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Keep
</button>
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
>
Remove Workspace
</button>
@@ -157,7 +157,7 @@ export function PurchaseSuccessModal() {
</div>
<div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-line bg-surface/50">
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-mid">
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-soft">
auto-dismiss · {AUTO_DISMISS_MS / 1000}s
</span>
<button
+2 -2
View File
@@ -104,7 +104,7 @@ export function SearchDialog() {
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-mid" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-soft" aria-hidden="true">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
@@ -156,7 +156,7 @@ export function SearchDialog() {
<div className="min-w-0 flex-1">
<div className="text-sm text-ink truncate">{node.data.name}</div>
{node.data.role && (
<div className="text-[10px] text-ink-mid truncate">{node.data.role}</div>
<div className="text-[10px] text-ink-soft truncate">{node.data.role}</div>
)}
</div>
<span
+4 -4
View File
@@ -165,12 +165,12 @@ export function SidePanel() {
</h2>
<div className="flex items-center gap-2 mt-0.5">
{node.data.role && (
<span className="text-[10px] text-ink-mid truncate">
<span className="text-[10px] text-ink-soft truncate">
{node.data.role}
</span>
)}
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${
isOnline ? "text-good bg-emerald-950/30" : "text-ink-mid bg-surface-card/50"
isOnline ? "text-good bg-emerald-950/30" : "text-ink-soft bg-surface-card/50"
}`}>
T{node.data.tier}
</span>
@@ -181,7 +181,7 @@ export function SidePanel() {
type="button"
onClick={() => selectNode(null)}
aria-label="Close workspace panel"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-soft hover:text-ink hover:bg-surface-card/60 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
@@ -296,7 +296,7 @@ export function SidePanel() {
{/* Footer — workspace ID */}
<div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
<span className="text-[9px] font-mono text-ink-mid select-all">
<span className="text-[9px] font-mono text-ink-soft select-all">
{selectedNodeId}
</span>
</div>
+15 -15
View File
@@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-soft hover:text-ink-mid font-semibold transition-colors"
>
<span
aria-hidden="true"
@@ -246,7 +246,7 @@ export function OrgTemplatesSection() {
</span>
Org Templates
{orgs.length > 0 && (
<span className="text-ink-mid normal-case tracking-normal">
<span className="text-ink-soft normal-case tracking-normal">
({orgs.length})
</span>
)}
@@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={loadOrgs}
aria-label="Refresh org templates"
className="text-[10px] text-ink-mid hover:text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-[10px] text-ink-soft hover:text-ink-mid"
>
</button>
@@ -264,14 +264,14 @@ export function OrgTemplatesSection() {
{expanded && (
<div id="org-templates-body" className="space-y-2">
{loading && (
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-mid">
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-soft">
<Spinner size="sm" />
Loading
</div>
)}
{!loading && orgs.length === 0 && (
<div className="text-[10px] text-ink-mid">
<div className="text-[10px] text-ink-soft">
No org templates in <code>org-templates/</code>
</div>
)}
@@ -298,7 +298,7 @@ export function OrgTemplatesSection() {
</span>
</div>
{o.description && (
<p className="text-[10px] text-ink-mid mb-2.5 line-clamp-2 leading-relaxed">
<p className="text-[10px] text-ink-soft mb-2.5 line-clamp-2 leading-relaxed">
{o.description}
</p>
)}
@@ -306,7 +306,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={() => handleImport(o)}
disabled={isImporting}
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50"
>
{isImporting ? "Importing…" : "Import org"}
</button>
@@ -411,7 +411,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50"
>
{importing ? "Importing..." : "Import Agent Folder"}
</button>
@@ -474,7 +474,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={() => setOpen(!open)}
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
open
? "bg-accent-strong text-white"
: "bg-surface-sunken/90 border border-line/50 text-ink-mid hover:text-ink hover:border-line"
@@ -499,7 +499,7 @@ export function TemplatePalette() {
<div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40">
<div className="px-4 pt-14 pb-3 border-b border-line/60">
<h2 className="text-sm font-semibold text-ink">Templates</h2>
<p className="text-[10px] text-ink-mid mt-0.5">Click to deploy a workspace</p>
<p className="text-[10px] text-ink-soft mt-0.5">Click to deploy a workspace</p>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
@@ -509,14 +509,14 @@ export function TemplatePalette() {
<OrgTemplatesSection />
{loading && (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-mid text-center py-8">
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-soft text-center py-8">
<Spinner />
Loading
</div>
)}
{!loading && templates.length === 0 && (
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">
<div role="status" aria-live="polite" className="text-xs text-ink-soft text-center py-8">
No templates found in<br />workspace-configs-templates/
</div>
)}
@@ -549,7 +549,7 @@ export function TemplatePalette() {
</div>
{t.description && (
<p className="text-[10px] text-ink-mid mb-2 line-clamp-2 leading-relaxed">
<p className="text-[10px] text-ink-soft mb-2 line-clamp-2 leading-relaxed">
{t.description}
</p>
)}
@@ -562,7 +562,7 @@ export function TemplatePalette() {
</span>
))}
{t.skills.length > 3 && (
<span className="text-[8px] text-ink-mid">+{t.skills.length - 3}</span>
<span className="text-[8px] text-ink-soft">+{t.skills.length - 3}</span>
)}
</div>
)}
@@ -580,7 +580,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={loadTemplates}
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-[10px] text-ink-soft hover:text-ink-mid transition-colors block"
>
Refresh templates
</button>
+1 -1
View File
@@ -124,7 +124,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
</a>
. Click agree to continue.
</p>
<p className="mt-3 text-xs text-ink-mid">
<p className="mt-3 text-xs text-ink-soft">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p>
</div>
+2 -2
View File
@@ -54,10 +54,10 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
aria-label={opt.label}
onClick={() => setTheme(opt.value)}
className={
"flex h-6 w-6 items-center justify-center rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface " +
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
(active
? "bg-surface-elevated text-ink shadow-sm"
: "text-ink-mid hover:text-ink-mid")
: "text-ink-soft hover:text-ink-mid")
}
>
<svg
+9 -20
View File
@@ -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,41 +327,30 @@ 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
type="button"
onClick={() => { setHelpOpen(false); setShortcutsOpen(true); }}
className="mt-3 w-full text-center text-[10px] text-ink-mid hover:text-accent transition-colors focus:outline-none focus-visible:underline"
className="mt-3 w-full text-center text-[10px] text-ink-soft hover:text-accent transition-colors focus:outline-none focus-visible:underline"
>
See all shortcuts
</button>
+2 -2
View File
@@ -55,7 +55,7 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
</h4>
{!loading && metrics && (
<span
className="text-[10px] text-ink-mid font-mono"
className="text-[10px] text-ink-soft font-mono"
data-testid="usage-period"
>
{formatPeriod(metrics.period_start, metrics.period_end)}
@@ -131,7 +131,7 @@ function StatRow({
}) {
return (
<div className="flex justify-between items-center" data-testid={testId}>
<span className="text-xs text-ink-mid">{label}</span>
<span className="text-xs text-ink-soft">{label}</span>
<span className="text-xs text-ink-mid font-mono">{value}</span>
</div>
);
@@ -1,320 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ApprovalBanner component.
*
* Uses vi.hoisted + vi.mock for stable module-level API mocks that survive
* vi.resetModules() cleanup. BeforeEach uses mockReset + mockResolvedValue
* so each test gets a clean slate.
*/
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";
// ─── Module-level mocks ───────────────────────────────────────────────────────
// vi.hoisted captures stable references BEFORE hoisting so they are accessible
// in the test body after vi.mock registers.
const _mockGet = vi.hoisted<typeof api.get>(() => vi.fn<() => Promise<unknown[]>>());
const _mockPost = vi.hoisted<typeof api.post>(() => vi.fn<() => Promise<unknown>>());
const _mockToast = vi.hoisted<typeof showToast>(() => vi.fn());
vi.mock("@/lib/api", () => ({
api: { get: _mockGet, post: _mockPost },
}));
vi.mock("@/components/Toaster", () => ({
showToast: _mockToast,
}));
afterEach(cleanup);
// ─── Helpers ──────────────────────────────────────────────────────────────────
const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
id: string;
workspace_id: string;
workspace_name: string;
action: string;
reason: string | null;
status: string;
created_at: string;
} => ({
id,
workspace_id: workspaceId,
workspace_name: "Test Workspace",
action: "Run code execution",
reason: "Requires human approval due to workspace policy",
status: "pending",
created_at: "2026-05-10T10:00:00Z",
});
// ─── Cleanup ─────────────────────────────────────────────────────────────────
beforeEach(() => {
_mockGet.mockReset();
_mockGet.mockResolvedValue([] as unknown[]);
_mockPost.mockReset();
_mockPost.mockResolvedValue({} as unknown);
_mockToast.mockClear();
});
afterEach(() => {
cleanup();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ApprovalBanner — empty state", () => {
it("renders nothing when there are no pending approvals", async () => {
_mockGet.mockResolvedValueOnce([] as unknown[]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("alert")).toBeNull();
});
it("does not render any approve/deny buttons when list is empty", async () => {
_mockGet.mockResolvedValueOnce([] as unknown[]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
expect(screen.queryByRole("button", { name: /deny/i })).toBeNull();
});
});
describe("ApprovalBanner — renders approval cards", () => {
it("renders an alert card for each pending approval", async () => {
_mockGet.mockResolvedValueOnce([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
] as unknown[]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const alerts = screen.getAllByRole("alert");
expect(alerts).toHaveLength(2);
});
it("displays the workspace name and action text", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Test Workspace needs approval")).toBeTruthy();
expect(screen.getByText("Run code execution")).toBeTruthy();
});
it("displays the reason when present", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText(/Requires human approval/i)).toBeTruthy();
});
it("omits the reason div when reason is null", async () => {
const approval = pendingApproval("a1");
approval.reason = null;
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByText(/Requires human approval/i)).toBeNull();
});
it("renders both Approve and Deny buttons per card", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("button", { name: /approve/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /deny/i })).toBeTruthy();
});
it("has aria-live=assertive on the alert container", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const alert = screen.getByRole("alert");
expect(alert.getAttribute("aria-live")).toBe("assertive");
});
});
describe("ApprovalBanner — polling", () => {
let clearIntervalSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
clearIntervalSpy = vi.spyOn(global, "clearInterval").mockImplementation(() => {});
});
afterEach(() => {
clearIntervalSpy.mockRestore();
});
it("clears the polling interval on unmount", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
const { unmount } = render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
});
});
describe("ApprovalBanner — decisions", () => {
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
const approval = pendingApproval("a1", "ws-1");
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(_mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "approved", decided_by: "human" },
);
});
});
it("calls POST with decision=denied on Deny click", async () => {
const approval = pendingApproval("a1", "ws-1");
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(_mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "denied", decided_by: "human" },
);
});
});
it("removes the card from state after a successful decision", async () => {
const approval = pendingApproval("a1", "ws-1");
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// One alert initially
expect(screen.getAllByRole("alert")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(screen.queryByRole("alert")).toBeNull();
});
});
it("shows a success toast on approve", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(_mockToast).toHaveBeenCalledWith("Approved", "success");
});
});
it("shows an info toast on deny", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(_mockToast).toHaveBeenCalledWith("Denied", "info");
});
});
it("shows an error toast when POST fails", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
// Use mockImplementation instead of mockRejectedValueOnce so the vi.fn
// wrapper is preserved — the component's catch block needs the resolved
// promise wrapper to distinguish a rejected-from-mock vs thrown-from-code.
_mockPost.mockImplementation(
() => new Promise((_, reject) => reject(new Error("Network error"))),
);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(_mockToast).toHaveBeenCalledWith("Failed to submit decision", "error");
});
});
it("keeps the card visible when the POST fails", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
_mockPost.mockImplementation(
() => new Promise((_, reject) => reject(new Error("Network error"))),
);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
// Card still shown because the request failed
expect(screen.getByRole("alert")).toBeTruthy();
});
});
});
describe("ApprovalBanner — handles empty list from server", () => {
it("shows nothing when the API returns an empty array on first poll", async () => {
_mockGet.mockResolvedValueOnce([] as unknown[]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("alert")).toBeNull();
});
});
@@ -1,317 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for BundleDropZone component.
*
* Covers: drag-over/drag-leave state, drop of valid/invalid files,
* keyboard file input, import success, import error, auto-clear timeout.
*/
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 { BundleDropZone } from "../BundleDropZone";
import { api } from "@/lib/api";
vi.mock("@/lib/api", () => ({
api: {
post: vi.fn(),
},
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.useRealTimers();
});
// ─── Test helper ──────────────────────────────────────────────────────────────
function makeBundle(name = "test-workspace"): File {
const content = JSON.stringify({
name,
tier: 2,
skills: [],
config: {},
});
return new File([content], "test.bundle.json", {
type: "application/json",
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("BundleDropZone — render", () => {
it("renders a hidden file input with correct accept and aria-label", () => {
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
expect(input.getAttribute("type")).toBe("file");
expect(input.getAttribute("accept")).toBe(".bundle.json");
});
it("renders the keyboard-accessible import button with aria-label", () => {
render(<BundleDropZone />);
const btn = screen.getByRole("button", { name: /import bundle/i });
expect(btn).toBeTruthy();
expect(btn.getAttribute("aria-controls")).toBe("bundle-file-input");
});
});
describe("BundleDropZone — drag state", () => {
beforeEach(() => {
vi.useFakeTimers();
});
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();
});
});
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 = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const clickSpy = vi.spyOn(input, "click");
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 = vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Imported Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("My Bundle");
Object.defineProperty(input, "files", {
value: [file],
writable: false,
});
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(postMock).toHaveBeenCalledWith(
"/bundles/import",
expect.objectContaining({ name: "My Bundle" })
);
vi.useRealTimers();
});
});
describe("BundleDropZone — import success", () => {
it("shows success toast after successful import", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "My Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Success Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
// Success toast should be visible
expect(screen.getByText(/imported "my workspace" successfully/i)).toBeTruthy();
// Toast auto-clears after 4000ms
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByRole("status")).toBeNull();
vi.useRealTimers();
});
it("clears the result toast after 4000ms", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Timed Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Timed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByText(/timed workspace/i)).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(4500);
});
expect(screen.queryByText(/timed workspace/i)).toBeNull();
vi.useRealTimers();
});
});
describe("BundleDropZone — import error", () => {
it("shows error toast when the API call fails", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Failed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText(/import failed: 500 internal server error/i)).toBeTruthy();
vi.useRealTimers();
});
it("shows error when file is not a .bundle.json", async () => {
vi.useFakeTimers();
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText(/only .bundle.json files are accepted/i)).toBeTruthy();
// Error clears after 3000ms
await act(async () => {
vi.advanceTimersByTime(3500);
});
expect(screen.queryByText(/only .bundle.json/i)).toBeNull();
vi.useRealTimers();
});
it("clears error after 4000ms", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Error Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByText(/network error/i)).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText(/network error/i)).toBeNull();
vi.useRealTimers();
});
});
describe("BundleDropZone — importing state", () => {
it("shows 'Importing bundle...' status while API call is in flight", async () => {
vi.useFakeTimers();
let resolve: (v: unknown) => void;
const pending = new Promise((r) => { resolve = r; });
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Pending Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
// Advance timer to allow the state update to flush
await act(async () => {
vi.advanceTimersByTime(100);
});
expect(screen.getByText("Importing bundle...")).toBeTruthy();
expect(screen.getByRole("status")).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(500);
});
vi.useRealTimers();
});
});
describe("BundleDropZone — file input reset", () => {
it("resets the file input value after processing so the same file can be re-selected", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Reset Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const file = makeBundle("Reset Test");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
// The component calls e.target.value = "" after processing
expect(input.value).toBe("");
vi.useRealTimers();
});
});
@@ -1,376 +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";
import { useCanvasStore } from "@/store/canvas";
import { showToast } from "../Toaster";
// ─── Mock Toaster ─────────────────────────────────────────────────────────────
vi.mock("../Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Mock API ────────────────────────────────────────────────────────────────
const apiPost = vi.fn().mockResolvedValue(undefined as void);
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
vi.mock("@/lib/api", () => ({
api: {
post: apiPost,
patch: apiPatch,
get: vi.fn(),
},
}));
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockStoreState = {
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();
vi.mocked(showToast).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();
vi.mocked(showToast).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 />);
fireEvent.keyDown(document.body, { 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();
vi.mocked(showToast).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 />);
expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull();
});
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();
vi.mocked(showToast).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();
vi.mocked(showToast).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,267 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for EmptyState component — the full-canvas welcome card on first load.
*
* Pattern: all vi.fn() refs are created by a SINGLE vi.hoisted() call,
* returned as a named-const object. Individual vi.mock factories then
* import that object and pull out the fields they need. This avoids
* "Cannot access before initialization" errors from vi.mock hoisting.
*/
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 { EmptyState } from "../EmptyState";
// ─── Module-level mocks ───────────────────────────────────────────────────────
// vi.hoisted is evaluated after module-level vars are declared, so these
// refs are stable and accessible inside vi.mock factories (which are
// hoisted above everything). We return an object so a SINGLE hoisted call
// creates all mocks; each vi.mock then references m.<field>.
const m = vi.hoisted(() => {
const mockGet = vi.fn<() => Promise<unknown[]>>();
const mockPost = vi.fn<() => Promise<{ id: string }>>();
const mockCheckDeploySecrets = vi.fn<
() => Promise<{
ok: boolean;
missingKeys: string[];
providers: string[];
runtime: string;
configuredKeys: string[];
}>
>();
const mockSelectNode = vi.fn<(id: string) => void>();
const mockSetPanelTab = vi.fn<(tab: string) => void>();
const mockDeploy = vi.fn<(t: { id: string; name: string }) => Promise<void>>();
const mockUseTemplateDeploy = vi.fn(() => ({
deploy: mockDeploy,
deploying: false,
error: null,
modal: null,
}));
return {
mockGet,
mockPost,
mockCheckDeploySecrets,
mockSelectNode,
mockSetPanelTab,
mockDeploy,
mockUseTemplateDeploy,
};
});
vi.mock("@/lib/api", () => ({
api: { get: m.mockGet, post: m.mockPost },
}));
vi.mock("@/lib/deploy-preflight", () => ({
checkDeploySecrets: m.mockCheckDeploySecrets,
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
// The hook returns an object with selectNode/setPanelTab;
// the component also calls useCanvasStore.getState() directly.
vi.fn(() => ({
selectNode: m.mockSelectNode,
setPanelTab: m.mockSetPanelTab,
})),
{
getState: () => ({
selectNode: m.mockSelectNode,
setPanelTab: m.mockSetPanelTab,
}),
},
),
}));
vi.mock("@/hooks/useTemplateDeploy", () => ({
useTemplateDeploy: m.mockUseTemplateDeploy,
}));
// Mock OrgTemplatesSection — tested separately.
vi.mock("../TemplatePalette", () => ({
OrgTemplatesSection: () => (
<div data-testid="org-templates-section">Org Templates</div>
),
}));
// ─── Test data ───────────────────────────────────────────────────────────────
const TEMPLATE = {
id: "molecule-dev",
name: "Molecule Dev",
tier: 2,
description: "A full-featured agent workspace for development",
runtime: "langgraph",
required_env: ["ANTHROPIC_API_KEY"],
models: [{ id: "claude-sonnet-4-20250514", required_env: ["ANTHROPIC_API_KEY"] }],
model: "claude-sonnet-4-20250514",
skill_count: 12,
};
// ─── Cleanup ─────────────────────────────────────────────────────────────────
beforeEach(() => {
m.mockGet.mockReset();
m.mockGet.mockResolvedValue([] as unknown[]);
m.mockPost.mockReset();
m.mockPost.mockResolvedValue({ id: "new-ws-123" } as unknown as { id: string });
m.mockCheckDeploySecrets.mockReset();
m.mockCheckDeploySecrets.mockResolvedValue({
ok: true,
missingKeys: [],
providers: [],
runtime: "langgraph",
configuredKeys: [],
});
m.mockSelectNode.mockReset();
m.mockSetPanelTab.mockReset();
m.mockDeploy.mockReset();
});
afterEach(() => {
cleanup();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("EmptyState — loading state", () => {
it("shows spinner and loading text while templates are being fetched", () => {
m.mockGet.mockImplementation(() => new Promise(() => {}));
render(<EmptyState />);
expect(screen.getByText(/loading templates/i)).toBeTruthy();
});
});
describe("EmptyState — templates fetched", () => {
it("renders template grid with name, tier badge, description, skill count", async () => {
m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]);
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText("Molecule Dev")).toBeTruthy();
expect(screen.getByText("T2")).toBeTruthy();
expect(screen.getByText(/full-featured agent workspace/i)).toBeTruthy();
expect(screen.getByText(/12 skills/)).toBeTruthy();
});
it("shows model label when template declares a model", async () => {
m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]);
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText(/claude-sonnet/i)).toBeTruthy();
});
it("calls deploy(template) when template button is clicked", async () => {
m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]);
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /molecule dev/i }));
expect(m.mockDeploy).toHaveBeenCalledWith(
expect.objectContaining({ id: "molecule-dev", name: "Molecule Dev" }),
);
});
});
describe("EmptyState — no templates", () => {
it("shows only the create-blank button when template list is empty", async () => {
// beforeEach already sets mockResolvedValue([]) as default — no override needed.
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByRole("button", { name: /\+ create blank workspace/i })).toBeTruthy();
expect(screen.queryByText(/molecule dev/i)).toBeNull();
});
it("shows only the create-blank button when template fetch fails", async () => {
m.mockGet.mockRejectedValueOnce(new Error("Network error"));
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByRole("button", { name: /\+ create blank workspace/i })).toBeTruthy();
expect(screen.queryByText(/loading templates/i)).toBeNull();
});
});
describe("EmptyState — create blank workspace", () => {
it('shows "Creating..." label while blank workspace POST is in-flight', async () => {
m.mockPost.mockImplementationOnce(() => new Promise(() => {}));
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText("Creating...")).toBeTruthy();
// The same button is now relabeled; check it is disabled while POST is in-flight.
expect(screen.getByRole("button", { name: /creating\.\.\./i })).toHaveProperty("disabled", true);
});
it("calls POST /workspaces with correct payload on create blank", async () => {
m.mockPost.mockResolvedValueOnce({ id: "ws-new-456" } as unknown as { id: string });
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(m.mockPost).toHaveBeenCalledWith("/workspaces", {
name: "My First Agent",
canvas: { x: 200, y: 150 },
});
});
it("calls selectNode + setPanelTab(chat) after 500ms on blank create success", async () => {
m.mockPost.mockResolvedValueOnce({ id: "ws-new-789" } as unknown as { id: string });
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
// Wait for the 500ms setTimeout inside handleDeployed to fire and call
// canvas store methods. Use waitFor so we don't hard-code timing assumptions.
await waitFor(() => {
expect(m.mockSelectNode).toHaveBeenCalledWith("ws-new-789");
expect(m.mockSetPanelTab).toHaveBeenCalledWith("chat");
}, { timeout: 1000 });
});
it("shows error banner on blank create failure", async () => {
m.mockPost.mockRejectedValueOnce(new Error("Server error"));
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(/server error/i)).toBeTruthy();
});
it("blank workspace error clears on retry", async () => {
m.mockPost.mockRejectedValueOnce(new Error("Server error"));
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByRole("alert")).toBeTruthy();
// Retry succeeds — error clears
m.mockPost.mockResolvedValueOnce({ id: "ws-retry" } as unknown as { id: string });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.queryByRole("alert")).toBeNull();
});
});
describe("EmptyState — rendering", () => {
it("renders the welcome heading and instructions", async () => {
// beforeEach already sets mockGet to resolve to [] — no override needed.
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText(/deploy your first agent/i)).toBeTruthy();
expect(screen.getByText(/welcome to molecule ai/i)).toBeTruthy();
});
it("renders the tips footer", async () => {
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText(/drag to nest workspaces/i)).toBeTruthy();
});
it("renders OrgTemplatesSection below the create-blank button", async () => {
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByTestId("org-templates-section")).toBeTruthy();
});
});
@@ -1,170 +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()} />);
expect(screen.getByRole("textbox").getAttribute("type")).toBe("password");
});
it("renders a text input when revealed=true", () => {
const { container } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
// Cannot use getByRole because type=text inputs may not be queryable as textbox in jsdom
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" />);
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("My secret field");
});
it("uses default aria-label when omitted", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("Secret value");
});
it("renders a disabled input when disabled=true", () => {
render(<KeyValueField value="x" onChange={vi.fn()} disabled={true} />);
expect(screen.getByRole("textbox").getAttribute("disabled")).toBe("");
});
it("renders with the provided placeholder", () => {
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("Enter API key");
});
it("disables spell-check on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("spellcheck")).toBe("false");
});
it("sets autoComplete=off on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").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} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims trailing whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc " } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims leading whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { 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} />);
fireEvent.change(screen.getByRole("textbox"), { 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.
// Full paste testing requires @testing-library/user-event which is not installed.
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
const input = document.body.querySelector("input");
fireEvent.click(document.body.querySelector("button")!);
// After reveal, input type should be text (not password)
expect(input?.getAttribute("type")).not.toBe("password");
// Advance 30 seconds
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
// Value should be hidden again — the input value is managed externally
// via `value` prop, so we check the input type flipped back to password
// by verifying the button was clicked twice (setRevealed toggled)
// The component's internal revealed state should be false after timer fires.
// Since we can't read internal state, we verify the behavior by checking
// the input type (it flips back to password after auto-hide).
// The timer callback calls setRevealed(false) which flips type 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");
});
});
@@ -1,90 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
// ── Component under test — imported AFTER mocks ───────────────────────────────
import { KeyboardShortcutsDialog } from "../KeyboardShortcutsDialog";
afterEach(cleanup);
const onCloseMock = vi.fn();
beforeEach(() => {
onCloseMock.mockReset();
});
describe("KeyboardShortcutsDialog — a11y render", () => {
it("renders with role=dialog and aria-modal=true when open", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeTruthy();
});
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("has aria-labelledby pointing to the dialog title", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
const dialog = await waitFor(() => screen.getByRole("dialog"));
const labelledby = dialog.getAttribute("aria-labelledby");
expect(labelledby).toBeTruthy();
// The labelledby should reference the h2 with id="keyboard-shortcuts-title"
const title = document.getElementById(labelledby!);
expect(title?.textContent).toMatch(/keyboard shortcuts/i);
});
it("does not render when open=false", () => {
render(<KeyboardShortcutsDialog open={false} onClose={onCloseMock} />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("calls onClose when Escape is pressed", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
await waitFor(() => expect(screen.getByRole("dialog")).toBeTruthy());
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
it("focuses the first focusable element (close button) when dialog opens", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
// The component uses requestAnimationFrame to move focus; wait for it to settle.
await waitFor(() => expect(screen.getByRole("dialog")).toBeTruthy());
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
const closeBtn = screen.getByRole("button", { name: /close/i });
expect(document.activeElement).toBe(closeBtn);
});
it("traps Tab focus within the dialog", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
const dialog = await waitFor(() => screen.getByRole("dialog"));
// Collect all focusable elements inside the dialog
const focusableSelectors =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const focusableEls = Array.from(
dialog.querySelectorAll<HTMLElement>(focusableSelectors)
);
expect(focusableEls.length).toBeGreaterThan(0);
const onlyFocusable = focusableEls[0];
act(() => { onlyFocusable.focus(); });
// Simulate Tab keydown. The dialog's handler should call preventDefault()
// to stop focus leaving the dialog. Verify by checking the event was
// handled (focus remains on the only focusable element).
let tabWasIntercepted = false;
const tabHandler = (e: KeyboardEvent) => {
if (e.key === "Tab") tabWasIntercepted = e.defaultPrevented;
};
window.addEventListener("keydown", tabHandler);
act(() => {
fireEvent.keyDown(onlyFocusable, { key: "Tab", shiftKey: false });
});
expect(tabWasIntercepted).toBe(true);
window.removeEventListener("keydown", tabHandler);
});
});
@@ -1,185 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for Legend component.
*
* Covers: open/closed state, localStorage persistence, palette-offset
* positioning, status/tier/comm items rendering.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { Legend } from "../Legend";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock localStorage ────────────────────────────────────────────────────────
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => 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 });
// ─── Mock canvas store ────────────────────────────────────────────────────────
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn(),
}));
afterEach(() => {
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("Legend — initial render (localStorage open)", () => {
it("renders the legend panel when localStorage has no saved preference", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
expect(screen.getByText("Legend")).toBeTruthy();
});
it("renders the legend panel when localStorage has open=1", () => {
localStorageMock.getItem.mockReturnValueOnce("1");
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
expect(screen.getByText("Legend")).toBeTruthy();
});
it("renders the collapsed pill when localStorage has open=0", () => {
localStorageMock.getItem.mockReturnValueOnce("0");
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
// Collapsed pill shows "ⓘ Legend"
expect(screen.getByText("Legend")).toBeTruthy();
// Hide button should not be in the open panel
expect(screen.queryByTitle("Hide legend")).toBeNull();
});
});
describe("Legend — open panel content", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue("1");
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
});
it("renders the Status section with status items", () => {
render(<Legend />);
expect(screen.getByText("Status")).toBeTruthy();
// All statuses from LEGEND_STATUSES
expect(screen.getByText("Online")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.getByText("Failed")).toBeTruthy();
});
it("renders the Tier section", () => {
render(<Legend />);
expect(screen.getByText("Tier")).toBeTruthy();
expect(screen.getByText("Sandboxed")).toBeTruthy();
expect(screen.getByText("Standard")).toBeTruthy();
expect(screen.getByText("Privileged")).toBeTruthy();
expect(screen.getByText("Full Access")).toBeTruthy();
});
it("renders the Communication section", () => {
render(<Legend />);
expect(screen.getByText("Communication")).toBeTruthy();
expect(screen.getByText("A2A Out")).toBeTruthy();
expect(screen.getByText("A2A In")).toBeTruthy();
expect(screen.getByText("Task")).toBeTruthy();
expect(screen.getByText("Error")).toBeTruthy();
});
it("renders the hide button", () => {
render(<Legend />);
expect(screen.getByTitle("Hide legend")).toBeTruthy();
});
});
describe("Legend — close and reopen", () => {
it("closes when the hide button is clicked and persists to localStorage", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
fireEvent.click(screen.getByTitle("Hide legend"));
// localStorage should be updated to "0"
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"molecule.legend.open",
"0"
);
});
it("reopens when the collapsed pill is clicked and persists to localStorage", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
// Initially open — close it
fireEvent.click(screen.getByTitle("Hide legend"));
// Collapsed pill appears
expect(screen.getByTitle("Show legend")).toBeTruthy();
// Reopen
fireEvent.click(screen.getByTitle("Show legend"));
expect(localStorageMock.setItem).toHaveBeenLastCalledWith(
"molecule.legend.open",
"1"
);
});
});
describe("Legend — palette offset positioning", () => {
it("uses left-4 when template palette is NOT open", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-4");
});
it("uses left-[296px] when template palette IS open", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: true } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-[296px]");
});
});
describe("Legend — aria attributes", () => {
it("the hide button has aria-label", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const hideBtn = screen.getByTitle("Hide legend");
expect(hideBtn.getAttribute("aria-label")).toBe("Hide legend");
});
it("the show legend pill has aria-label", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
fireEvent.click(screen.getByTitle("Hide legend"));
const pill = screen.getByTitle("Show legend");
expect(pill.getAttribute("aria-label")).toBe("Show legend");
});
});
@@ -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,174 +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";
const mockStoreState = {
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(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
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();
});
// ─── 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);
});
it("auto-advances from welcome to api-key when nodes appear", async () => {
const { unmount } = render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
// Simulate a node being added to the store and re-render
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
render(<OnboardingWizard />);
await waitFor(() => {
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
});
expect(screen.getByText("Set your API key")).toBeTruthy();
unmount();
});
});
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,255 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for PurchaseSuccessModal component.
*
* Covers: no render when no URL params, renders with ?purchase_success=1,
* portal rendering, item name from &item=, auto-dismiss after 5s,
* manual dismiss, backdrop click close, Escape key close, URL stripping,
* focus management.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function pushUrl(url: string) {
window.history.pushState({}, "", url);
}
function replaceUrl(url: string) {
window.history.replaceState({}, "", url);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("PurchaseSuccessModal — render conditions", () => {
beforeEach(() => {
replaceUrl("http://localhost/");
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders nothing when URL has no purchase_success param", () => {
replaceUrl("http://localhost/");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders nothing on a plain URL", () => {
replaceUrl("http://localhost/dashboard?foo=bar");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders the dialog when ?purchase_success=1 is present", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
// useEffect fires after mount
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 () => {
replaceUrl("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 () => {
replaceUrl("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 () => {
replaceUrl("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 () => {
replaceUrl("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 () => {
replaceUrl("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(() => {
replaceUrl("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 () => {
await new Promise((r) => setTimeout(r, 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 () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Click the backdrop (the full-screen overlay div)
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 () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
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 () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Advance 5 seconds
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 () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
act(() => { vi.advanceTimersByTime(4900); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — URL stripping", () => {
beforeEach(() => {
replaceUrl("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 () => {
await new Promise((r) => setTimeout(r, 10));
});
const url = new URL(window.location.href);
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 () => {
const replaceSpy = vi.spyOn(window.history, "replaceState");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(replaceSpy).toHaveBeenCalled();
});
});
describe("PurchaseSuccessModal — accessibility", () => {
beforeEach(() => {
replaceUrl("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 () => {
await new Promise((r) => setTimeout(r, 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 () => {
await new Promise((r) => setTimeout(r, 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 () => {
// Two rAFs for focus: one from the effect, one from the RAF wrapper
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
expect(document.activeElement?.textContent).toMatch(/close/i);
});
});
@@ -1,65 +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";
describe("RevealToggle — render", () => {
afterEach(cleanup);
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,423 +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 ──────────────────────────────────────────────────────────────
// Zustand-compatible mock: useSyncExternalStore needs subscribe() to fire
// callbacks so React re-renders when state changes. Without it, the
// Cmd+K test opens the dialog but the component never re-renders because
// React's external-store bridge has no notification to flush.
//
// We use vi.fn() wrapping for setSearchOpen so tests can use
// toHaveBeenCalledWith() for assertions, while also calling the underlying
// store update that triggers Zustand's subscriber mechanism.
type StoreSlice = {
searchOpen: boolean;
nodes: Array<{
id: string;
data: {
name: string;
status: string;
tier: number;
role: string;
parentId?: string | null;
};
}>;
selectNode: (id: string) => void;
setPanelTab: (tab: string) => void;
};
const _subscribers = new Set<() => void>();
const _implSetSearchOpen = (open: boolean) => {
_mockStore.searchOpen = open;
_subscribers.forEach((cb) => cb());
};
const _mockStore: StoreSlice = {
searchOpen: false,
nodes: [],
selectNode: vi.fn(),
setPanelTab: vi.fn(),
};
const mockStoreState: StoreSlice & { setSearchOpen: ReturnType<typeof vi.fn> } = {
searchOpen: false,
nodes: [],
selectNode: _mockStore.selectNode,
setPanelTab: _mockStore.setPanelTab,
// vi.fn() wrapper so tests can use toHaveBeenCalledWith(); the
// implementation calls through to _implSetSearchOpen which notifies
// Zustand subscribers so React re-renders.
setSearchOpen: vi.fn(_implSetSearchOpen),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{
getState: () => mockStoreState,
subscribe: (cb: () => void) => {
_subscribers.add(cb);
return () => { _subscribers.delete(cb); };
},
} as unknown as ReturnType<typeof vi.fn>,
),
})) as typeof vi.mock;
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.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
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 = [];
// setSearchOpen is a bound method, not vi.fn — skip mockClear
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
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", () => {
const { rerender } = render(<SearchDialog />);
// Zustand's useSyncExternalStore doesn't always re-render from the
// mock's subscribe() callback in the jsdom environment. After the
// keyboard handler fires, manually set state and force re-render.
act(() => {
dispatchKeydown("k", true, false);
// After vi.fn(_implSetSearchOpen) runs, subscribers fire but React
// may not schedule a re-render in time. Re-render manually so the
// component sees the updated searchOpen=true.
mockStoreState.searchOpen = true;
});
rerender(<SearchDialog />);
const input = screen.getByRole("combobox");
expect(input.getAttribute("value") ?? "").toBe("");
});
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.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
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.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
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");
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");
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");
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");
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.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
it("highlights the first result when query is typed", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
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");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
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");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" });
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;
const { rerender } = render(<SearchDialog />);
const input = screen.getByRole("combobox");
// Directly update the DOM input value + fire change event, then force
// a re-render so React commits the query state before keyboard events.
act(() => {
// Simulate user typing "a" — the onChange handler fires synchronously
// inside act(), but we also need the component to re-render with the
// new query so the filtered list and focusedIndex update correctly.
Object.defineProperty(input, "value", {
value: "a",
writable: true,
configurable: true,
});
fireEvent.change(input, { target: { value: "a" } });
// After onChange fires, query="a". React schedules a re-render but
// might not have flushed it yet — rerender forces it so ArrowDown
// sees focusedIndex=0 (effect ran from filtered.length change).
rerender(<SearchDialog />);
});
// Now focusedIndex should be 0 (Alice, filtered[0]). ArrowUp stays at 0.
// ArrowDown moves to 1 (Carol). We want to select Alice, so go
// ArrowUp to stay at 0, then Enter.
act(() => {
fireEvent.keyDown(input, { key: "ArrowUp" }); // Math.max(0-1, 0) = 0
});
act(() => {
fireEvent.keyDown(input, { key: "Enter" });
});
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
});
});
describe("SearchDialog — aria attributes", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
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.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
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,58 +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, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spinner } from "../Spinner";
describe("Spinner — size variants", () => {
it("renders with sm size class", () => {
const { container } = render(<Spinner size="sm" />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.className).toContain("w-3");
expect(svg?.className).toContain("h-3");
});
it("renders with md size class (default)", () => {
const { container } = render(<Spinner size="md" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("renders with lg size class", () => {
const { container } = render(<Spinner size="lg" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-5");
expect(svg?.className).toContain("h-5");
});
it("defaults to md size when no size prop given", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).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(svg?.className).toContain("motion-safe:animate-spin");
});
it("renders exactly one SVG element", () => {
const { container } = render(<Spinner />);
expect(container.querySelectorAll("svg").length).toBe(1);
});
});
@@ -1,58 +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";
describe("StatusBadge — render", () => {
afterEach(cleanup);
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");
});
});
@@ -1,102 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for StatusDot — the small coloured indicator rendered inside
* workspace cards to convey runtime status (online/offline/degraded/etc.).
*
* Coverage:
* - Renders for every known status in STATUS_CONFIG
* - Unknown status falls back to bg-zinc-500
* - size prop (sm/md) applies the correct Tailwind dimension class
* - aria-hidden="true" and role="img" for accessibility
* - provisioning status carries motion-safe:animate-pulse for the pulsing effect
* - glow class applied when STATUS_CONFIG declares one
*/
import { afterEach, describe, expect, it } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import React from "react";
import { StatusDot } from "../StatusDot";
afterEach(cleanup);
describe("StatusDot — snapshot", () => {
it("renders with online status", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img", { hidden: true });
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", () => {
render(<StatusDot status="offline" />);
const dot = screen.getByRole("img", { hidden: true });
expect(dot.className).toContain("bg-zinc-500");
// offline has no glow
expect(dot.className).not.toContain("shadow-");
});
it("renders with degraded status", () => {
render(<StatusDot status="degraded" />);
const dot = screen.getByRole("img", { hidden: true });
expect(dot.className).toContain("bg-amber-400");
expect(dot.className).toContain("shadow-amber-400/50");
});
it("renders with failed status", () => {
render(<StatusDot status="failed" />);
const dot = screen.getByRole("img", { hidden: true });
expect(dot.className).toContain("bg-red-400");
expect(dot.className).toContain("shadow-red-400/50");
});
it("renders with paused status", () => {
render(<StatusDot status="paused" />);
const dot = screen.getByRole("img", { hidden: true });
expect(dot.className).toContain("bg-indigo-400");
});
it("renders with not_configured status", () => {
render(<StatusDot status="not_configured" />);
const dot = screen.getByRole("img", { hidden: true });
expect(dot.className).toContain("bg-amber-300");
expect(dot.className).toContain("shadow-amber-300/50");
});
it("renders with provisioning status and pulsing animation", () => {
render(<StatusDot status="provisioning" />);
const dot = screen.getByRole("img", { hidden: true });
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", () => {
render(<StatusDot status="alien_artifact" />);
const dot = screen.getByRole("img", { hidden: true });
expect(dot.className).toContain("bg-zinc-500");
});
});
describe("StatusDot — size prop", () => {
it("applies w-2 h-2 (sm, default)", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img", { hidden: true });
expect(dot.className).toContain("w-2");
expect(dot.className).toContain("h-2");
});
it("applies w-2.5 h-2.5 (md)", () => {
render(<StatusDot status="online" size="md" />);
const dot = screen.getByRole("img", { hidden: true });
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", () => {
render(<StatusDot status="online" />);
expect(screen.getByRole("img", { hidden: true }).getAttribute("aria-hidden")).toBe("true");
});
});
@@ -1,222 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for TermsGate component.
*
* Covers: loading → accepted (already agreed), loading → pending (show
* modal), 401 → accepted (not signed in), error state, accept flow,
* focus management (WCAG 2.4.3), and modal accessibility.
*/
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 { TermsGate } from "../TermsGate";
// PLATFORM_URL is imported from @/lib/api; we mock it via module mock
vi.mock("@/lib/api", () => ({
PLATFORM_URL: "https://app.example.com",
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function mockFetch(res: Response) {
vi.spyOn(global, "fetch").mockResolvedValueOnce(res);
}
async function resolveFetch(res: Response) {
await act(async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce(res);
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("TermsGate — loading → accepted", () => {
it("renders children immediately (loading state)", () => {
mockFetch(new Response(JSON.stringify({ accepted: true }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
// Children are always rendered (TermsGate does not hide them)
expect(screen.getByTestId("children")).toBeTruthy();
});
it("shows no dialog when server returns accepted=true", async () => {
mockFetch(new Response(JSON.stringify({ accepted: true }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("shows no dialog when server returns 401 (not signed in)", async () => {
mockFetch(new Response(null, { status: 401 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
});
});
describe("TermsGate — pending state → modal", () => {
it("shows the terms dialog when server returns accepted=false", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeTruthy();
});
});
it("dialog has aria-modal=true and correct labelling", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(
<TermsGate>
<div>App content</div>
</TermsGate>
);
const dialog = await waitFor(() => screen.getByRole("dialog"));
expect(dialog.getAttribute("aria-modal")).toBe("true");
expect(dialog.getAttribute("aria-labelledby")).toBeTruthy();
const title = document.getElementById(dialog.getAttribute("aria-labelledby")!);
expect(title?.textContent).toMatch(/terms/i);
});
it("dialog body contains the terms text", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
expect(screen.getByText(/Terms of Service/i)).toBeTruthy();
expect(screen.getByText(/Privacy Policy/i)).toBeTruthy();
expect(screen.getByText(/AWS us-east-2/i)).toBeTruthy();
});
it("the I agree button is present", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
expect(screen.getByRole("button", { name: /i agree/i })).toBeTruthy();
});
it("links to terms and privacy policy have correct hrefs", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
const links = screen.getAllByRole("link");
const hrefs = links.map((l) => l.getAttribute("href"));
expect(hrefs).toContain("/legal/terms");
expect(hrefs).toContain("/legal/privacy");
});
});
describe("TermsGate — focus management (WCAG 2.4.3)", () => {
it("moves focus to the I agree button when modal opens", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
const dialog = await waitFor(() => screen.getByRole("dialog"));
// Focus is moved via requestAnimationFrame — wait a tick
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
const agreeBtn = screen.getByRole("button", { name: /i agree/i });
expect(document.activeElement).toBe(agreeBtn);
});
});
describe("TermsGate — accept flow", () => {
it("calls POST /cp/auth/accept-terms and closes dialog on success", async () => {
// First: terms-status → pending
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
// Second: accept-terms → 200
const postMock = mockFetch(new Response(null, { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
// Check POST was called
const calls = vi.mocked(global.fetch).mock.calls;
expect(calls.some(
([url, opts]) =>
(url as string).includes("/accept-terms") &&
(opts as RequestInit).method === "POST"
)).toBe(true);
});
it("shows error message and keeps modal open when accept fails", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
mockFetch(new Response("Internal Server Error", { status: 500 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
});
// Dialog is still open
expect(screen.getByRole("dialog")).toBeTruthy();
});
it.skip("disables the button while submitting (requires fake-timers around fireEvent.click)", async () => {
// This test requires vi.useFakeTimers() + act(() => { fireEvent.click(btn); vi.runAllTimers(); })
// to synchronously advance through the async boundary between click and fetch initiation.
// The current test structure fires the fetch before click, so this is skipped pending
// a refactor of the component to not initiate fetch synchronously on user gesture.
});
});
describe("TermsGate — error state", () => {
it("shows an error alert when terms-status fetch fails with non-401", async () => {
mockFetch(new Response("Gateway Timeout", { status: 504 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
});
});
it("error alert contains the status code", async () => {
mockFetch(new Response(null, { status: 503 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
});
expect(screen.getByRole("alert").textContent).toMatch(/503/);
});
});
describe("TermsGate — children always rendered", () => {
it("renders children even when modal is shown (does not gate them)", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children-visible">Behind the modal</div>
</TermsGate>
);
await waitFor(() => screen.getByRole("dialog"));
expect(screen.getByTestId("children-visible")).toBeTruthy();
});
});
@@ -1,216 +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.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();
vi.useRealTimers();
vi.restoreAllMocks();
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="" />);
expect(screen.getByRole("button").getAttribute("disabled")).toBeTruthy();
});
it("enables button when secretValue is non-empty", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-test" />);
expect(screen.getByRole("button").getAttribute("disabled")).toBeFalsy();
});
});
describe("TestConnectionButton — state machine", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("shows 'Testing…' while validateSecret is pending", async () => {
mockValidateSecret.mockImplementation(() => new Promise(() => {})); // never resolves
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
// Button should show testing label and be disabled
expect(screen.getByRole("button", { name: "Testing…" }).getAttribute("disabled")).toBeTruthy();
});
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 () => {
mockValidateSecret.mockRejectedValue(new Error("timeout"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush */ });
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(/timeout/i)).toBeTruthy();
});
});
describe("TestConnectionButton — auto-reset", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
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", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
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);
});
});
@@ -1,146 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ThemeToggle component.
*
* Covers: renders all three options, aria radiogroup semantics,
* aria-checked per option, setTheme calls on click, custom className prop.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeToggle } from "../ThemeToggle";
import * as themeProvider from "@/lib/theme-provider";
// ─── Mock theme provider ───────────────────────────────────────────────────────
const mockSetTheme = vi.fn();
vi.mock("@/lib/theme-provider", () => ({
useTheme: vi.fn(() => ({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
})),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ThemeToggle — render", () => {
beforeEach(() => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
});
it("renders a radiogroup with aria-label", () => {
render(<ThemeToggle />);
expect(screen.getByRole("radiogroup", { name: "Theme preference" })).toBeTruthy();
});
it("renders three radio buttons", () => {
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios).toHaveLength(3);
});
it("has aria-checked=true on the active option", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios[2].getAttribute("aria-checked")).toBe("true"); // dark is third
expect(radios[0].getAttribute("aria-checked")).toBe("false"); // light is first
expect(radios[1].getAttribute("aria-checked")).toBe("false"); // system is second
});
it("marks 'light' as active when theme=light", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "light",
resolvedTheme: "light",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios[0].getAttribute("aria-checked")).toBe("true"); // light
expect(radios[1].getAttribute("aria-checked")).toBe("false"); // system
expect(radios[2].getAttribute("aria-checked")).toBe("false"); // dark
});
it("marks 'system' as active when theme=system", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "system",
resolvedTheme: "light",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios[0].getAttribute("aria-checked")).toBe("false"); // light
expect(radios[1].getAttribute("aria-checked")).toBe("true"); // system
expect(radios[2].getAttribute("aria-checked")).toBe("false"); // dark
});
it("has aria-label on each button matching the option label", () => {
render(<ThemeToggle />);
expect(screen.getByRole("radio", { name: "Light" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "System" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Dark" })).toBeTruthy();
});
});
describe("ThemeToggle — interaction", () => {
beforeEach(() => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
});
it("calls setTheme with 'light' when light button is clicked", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "Light" }));
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
it("calls setTheme with 'system' when system button is clicked", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "System" }));
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
it("calls setTheme with 'dark' when dark button is clicked", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "Dark" }));
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("calls setTheme only once per click", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "Light" }));
expect(mockSetTheme).toHaveBeenCalledTimes(1);
});
});
describe("ThemeToggle — className prop", () => {
it("passes custom className to the radiogroup", () => {
render(<ThemeToggle className="my-custom-class" />);
const group = screen.getByRole("radiogroup", { name: "Theme preference" });
expect(group.className).toContain("my-custom-class");
});
it("applies default className when none provided", () => {
render(<ThemeToggle />);
const group = screen.getByRole("radiogroup", { name: "Theme preference" });
expect(group.className).toContain("inline-flex");
});
});
@@ -1,242 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for Tooltip component.
*
* Covers: portal rendering, 400ms hover delay, keyboard focus reveal,
* Esc dismiss, no render when text is empty.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { Tooltip } from "../Tooltip";
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("Tooltip — render", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("renders children without showing tooltip on mount", () => {
render(
<Tooltip text="Hello world">
<button type="button">Hover me</button>
</Tooltip>
);
expect(screen.getByRole("button", { name: "Hover me" })).toBeTruthy();
// Tooltip portal is not yet in the DOM (no timer fires on mount)
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("does not render the tooltip portal when text is empty string", () => {
render(
<Tooltip text="">
<button type="button">Hover me</button>
</Tooltip>
);
// Move mouse over trigger
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("mounts the tooltip into a portal attached to document.body", () => {
render(
<Tooltip text="Portal tip">
<button type="button">Hover me</button>
</Tooltip>
);
// Simulate mouse enter → 400ms delay → tooltip renders
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(document.body.querySelector('[role="tooltip"]')).toBeTruthy();
});
});
describe("Tooltip — hover delay", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("does NOT show tooltip before the 400ms delay expires", () => {
render(
<Tooltip text="Delayed tip">
<button type="button">Hover me</button>
</Tooltip>
);
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(300);
});
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("shows tooltip after 400ms hover delay", () => {
render(
<Tooltip text="Delayed tip">
<button type="button">Hover me</button>
</Tooltip>
);
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
});
it("hides tooltip immediately on mouse leave (clears pending timer)", () => {
render(
<Tooltip text="Cleared tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByRole("tooltip")).toBeNull();
fireEvent.mouseLeave(btn);
act(() => {
vi.advanceTimersByTime(500);
});
// Still not shown because mouseLeave cancelled the timer
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("does not show on a second mouseEnter after mouseLeave", () => {
render(
<Tooltip text="Re-show tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
fireEvent.mouseLeave(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeNull();
// Re-enter
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
});
});
describe("Tooltip — keyboard focus reveal", () => {
it("shows tooltip on focus without needing the hover timer", () => {
vi.useFakeTimers();
render(
<Tooltip text="Keyboard tip">
<button type="button">Focus me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
// No timer needed — onFocus shows immediately
act(() => {
btn.focus();
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
vi.useRealTimers();
});
it("hides tooltip on blur", () => {
vi.useFakeTimers();
render(
<Tooltip text="Blur tip">
<button type="button">Focus me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
act(() => {
btn.focus();
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
act(() => {
btn.blur();
});
expect(screen.queryByRole("tooltip")).toBeNull();
vi.useRealTimers();
});
});
describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
it("dismisses tooltip on Escape without blurring the trigger", () => {
vi.useFakeTimers();
render(
<Tooltip text="Esc dismiss tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
expect(document.activeElement).toBe(btn);
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(screen.queryByRole("tooltip")).toBeNull();
// 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>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
act(() => {
fireEvent.keyDown(window, { key: "Enter" });
});
// Tooltip still visible
expect(screen.queryByRole("tooltip")).toBeTruthy();
vi.useRealTimers();
});
});
describe("Tooltip — aria-describedby", () => {
it("associates tooltip with the trigger via aria-describedby", () => {
render(
<Tooltip text="Associated tip">
<button type="button">Hover me</button>
</Tooltip>
);
// The aria-describedby is on the wrapper div, not the button child
const btn = screen.getByRole("button");
const wrapper = btn.parentElement as HTMLElement;
const describedBy = wrapper.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
// The describedby id matches the tooltip id
expect(document.getElementById(describedBy!)).toBeTruthy();
});
});
@@ -1,52 +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,81 +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, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ValidationHint } from "../ui/ValidationHint";
afterEach(cleanup);
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" />);
expect(screen.getByText(/⚠/)).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} />);
// ✓ is in an aria-hidden span; Valid format is a separate text node
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("");
});
});
@@ -1,634 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for WorkspaceNode component.
*
* 51 test cases covering:
* - render: name, status badge, role chip, tier badge, runtime badge, skills
* - status states: online, offline, provisioning, paused, degraded, failed,
* not_configured — dot color, label, gradient bar
* - interactions: click, shift-click, double-click, context menu, keyboard
* - error/banner: needs-restart banner, restart action, current task
* - layout: hasChildren → larger card + "N sub" badge, collapsed state
* - sub-workspace: parentId → embedded chip rendered via TeamMemberChip
* - a11y: role=button, tabIndex=0, aria-label, aria-pressed
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceNode } from "../WorkspaceNode";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock Toaster ──────────────────────────────────────────────────────────────
vi.mock("../Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Mock API ────────────────────────────────────────────────────────────────
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
vi.mock("@/lib/api", () => ({
api: {
patch: apiPatch,
get: vi.fn(),
post: vi.fn(),
},
}));
// ─── Mock Tooltip ────────────────────────────────────────────────────────────
vi.mock("../Tooltip", () => ({
Tooltip: ({ text, children }: { text: string; children: React.ReactNode }) => (
<span title={text} data-testid="tooltip-wrapper">
{children}
</span>
),
}));
// ─── Mock useOrgDeployState ──────────────────────────────────────────────────
const DEFAULT_DEPLOY = {
isActivelyProvisioning: false,
isDeployingRoot: false,
isLockedChild: false,
descendantProvisioningCount: 0,
};
vi.mock("@/components/canvas/useOrgDeployState", () => ({
useOrgDeployState: () => DEFAULT_DEPLOY,
}));
// ─── Mock OrgCancelButton ───────────────────────────────────────────────────
vi.mock("@/components/canvas/OrgCancelButton", () => ({
OrgCancelButton: () => <button data-testid="org-cancel">Cancel</button>,
}));
// ─── Mock React Flow ─────────────────────────────────────────────────────────
vi.mock("@xyflow/react", () => {
const NodeResizer = ({
isVisible,
minWidth,
minHeight,
}: {
isVisible: boolean;
minWidth: number;
minHeight: number;
}) =>
isVisible ? (
<div data-testid="node-resizer" data-minw={minWidth} data-minh={minHeight} />
) : null;
const Handle = vi.fn().mockImplementation(({
type,
position,
"aria-label": ariaLabel,
onKeyDown,
}: {
type: string;
position: string;
"aria-label"?: string;
onKeyDown?: React.KeyboardEvent<HTMLDivElement>;
}) => (
<div
role="button"
aria-label={ariaLabel}
data-handle-type={type}
data-handle-position={position}
tabIndex={0}
onKeyDown={onKeyDown}
/>
));
return {
__esModule: true,
NodeResizer,
Handle,
NodeProps: vi.fn(),
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
useReactFlow: () => ({}),
};
});
// ─── Shared node data factory ─────────────────────────────────────────────────
function makeNode(overrides: Partial<{
name: string;
status: string;
tier: number;
role: string;
agentCard: Record<string, unknown> | null;
activeTasks: number;
collapsed: boolean;
parentId: string | null;
currentTask: string;
runtime: string;
needsRestart: boolean;
lastSampleError: string;
lastErrorRate: number;
url: string;
budgetLimit: number | null;
}> = {}): Parameters<typeof WorkspaceNode>[0] {
return {
id: "ws-1",
data: {
name: "Test Agent",
status: "online",
tier: 2,
agentCard: null,
activeTasks: 0,
collapsed: false,
role: "assistant",
lastErrorRate: 0,
lastSampleError: "",
url: "http://localhost:8080",
parentId: null,
currentTask: "",
runtime: "langgraph",
needsRestart: false,
budgetLimit: null,
...overrides,
},
} as Parameters<typeof WorkspaceNode>[0];
}
/** Create a node with a specific id (for selection/identity tests). */
function makeNodeWithId(id: string, overrides?: Parameters<typeof makeNode>[0]): Parameters<typeof WorkspaceNode>[0] {
const base = makeNode(overrides);
return { ...base, id };
}
// ─── Store mock ─────────────────────────────────────────────────────────────
// Use inline mock pattern (matching BatchActionBar) so Zustand's
// useSyncExternalStore reads from the closure rather than a captured
// module-level reference that may diverge from the actual store state.
const mockSelectNode = vi.fn();
const mockToggleNodeSelection = vi.fn();
const mockOpenContextMenu = vi.fn();
const mockNestNode = vi.fn().mockResolvedValue(undefined as void);
const mockRestartWorkspace = vi.fn().mockResolvedValue(undefined as void);
const mockSetCollapsed = vi.fn();
const mockSetSearchOpen = vi.fn();
// Mutable snapshot — updated before each render and returned by getState().
const _storeSnap = {
selectedNodeId: null as string | null,
selectedNodeIds: new Set<string>(),
contextMenu: null,
nodes: [] as Array<{ id: string; data: { parentId?: string | null } }>,
dragOverNodeId: null as string | null,
searchOpen: false,
selectNode: mockSelectNode,
toggleNodeSelection: mockToggleNodeSelection,
openContextMenu: mockOpenContextMenu,
nestNode: mockNestNode,
restartWorkspace: mockRestartWorkspace,
setCollapsed: mockSetCollapsed,
setSearchOpen: mockSetSearchOpen,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof _storeSnap) => unknown) => selector(_storeSnap)),
{ getState: () => _storeSnap }
),
})) as typeof vi.mock;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Returns the card div button (first button in DOM — before the handles). */
function cardButton(): HTMLElement {
return screen.getAllByRole("button")[0];
}
function dispatchKey(key: string, opts: {
shift?: boolean;
ctrl?: boolean;
meta?: boolean;
} = {}) {
fireEvent.keyDown(cardButton(), {
key,
shiftKey: opts.shift ?? false,
ctrlKey: opts.ctrl ?? false,
metaKey: opts.meta ?? false,
});
}
function clickNode(shiftKey = false) {
fireEvent.click(cardButton(), { shiftKey });
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
afterEach(() => {
cleanup();
vi.clearAllMocks();
_storeSnap.selectedNodeId = null;
_storeSnap.selectedNodeIds.clear();
_storeSnap.nodes = [];
_storeSnap.dragOverNodeId = null;
_storeSnap.contextMenu = null;
apiPatch.mockClear();
mockSelectNode.mockClear();
mockToggleNodeSelection.mockClear();
mockOpenContextMenu.mockClear();
mockNestNode.mockClear();
mockRestartWorkspace.mockClear();
mockSetCollapsed.mockClear();
});
// ════════════════════════════════════════════════════════════════════════════════
// RENDER — name, status, role, tier, runtime, skills
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — render", () => {
it("renders the workspace name", () => {
render(<WorkspaceNode {...makeNode({ name: "Alice" })} />);
expect(screen.getByText("Alice")).toBeTruthy();
});
it("renders the role chip when role is set", () => {
render(<WorkspaceNode {...makeNode({ role: "analyst" })} />);
expect(screen.getByText("analyst")).toBeTruthy();
});
it("does not render role chip when role is empty", () => {
render(<WorkspaceNode {...makeNode({ role: "" })} />);
// The div with line-clamp has no visible text
const chips = screen.queryAllByText("");
expect(chips).toBeTruthy();
});
it("renders the tier badge", () => {
render(<WorkspaceNode {...makeNode({ tier: 2 })} />);
expect(screen.getByText("T2")).toBeTruthy();
});
it("renders unknown tier gracefully", () => {
render(<WorkspaceNode {...makeNode({ tier: 99 })} />);
expect(screen.getByText("T99")).toBeTruthy();
});
it("renders runtime badge when runtime is set", () => {
render(<WorkspaceNode {...makeNode({ runtime: "langgraph" })} />);
expect(screen.getByText("langgraph")).toBeTruthy();
});
it("renders REMOTE badge for external runtime", () => {
render(<WorkspaceNode {...makeNode({ runtime: "external" })} />);
expect(screen.getByText("★ REMOTE")).toBeTruthy();
});
it("does not render runtime badge when runtime is empty", () => {
render(<WorkspaceNode {...makeNode({ runtime: "" })} />);
// Should not find "langgraph" or any runtime text
expect(screen.queryByText("langgraph")).toBeNull();
});
it("renders skills from agentCard", () => {
render(<WorkspaceNode {...makeNode({
agentCard: { skills: [{ name: "coding" }, { name: "research" }] },
})} />);
expect(screen.getByText("coding")).toBeTruthy();
expect(screen.getByText("research")).toBeTruthy();
});
it("renders skill overflow badge when > 4 skills", () => {
render(<WorkspaceNode {...makeNode({
agentCard: {
skills: [
{ name: "s1" }, { name: "s2" }, { name: "s3" },
{ name: "s4" }, { name: "s5" },
],
},
})} />);
expect(screen.getByText("+1")).toBeTruthy();
});
it("renders current task banner", () => {
render(<WorkspaceNode {...makeNode({ currentTask: "Running research" })} />);
expect(screen.getByText("Running research")).toBeTruthy();
});
it("renders active tasks count", () => {
render(<WorkspaceNode {...makeNode({ activeTasks: 3 })} />);
expect(screen.getByText("3 tasks")).toBeTruthy();
});
it("renders singular task label for 1 active task", () => {
render(<WorkspaceNode {...makeNode({ activeTasks: 1 })} />);
expect(screen.getByText("1 task")).toBeTruthy();
});
it("does not render active tasks count when zero", () => {
render(<WorkspaceNode {...makeNode({ activeTasks: 0 })} />);
const pulses = document.querySelectorAll(".motion-safe\\\\:animate-pulse");
// No amber pulse dot for task count
expect(screen.queryByText("0 tasks")).toBeNull();
});
});
// ════════════════════════════════════════════════════════════════════════════════
// STATUS STATES — dot color, label, gradient bar
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — status states", () => {
it("online: shows green dot (label div is empty for online)", () => {
render(<WorkspaceNode {...makeNode({ status: "online" })} />);
const dot = document.querySelector(".bg-emerald-400");
expect(dot).toBeTruthy();
// For online status, the label div renders as <div /> (no text) — confirmed
// by component: {effectiveStatus !== "online" ? <div>{label}</div> : <div />}
expect(screen.queryByText("Online")).toBeNull();
});
it("offline: shows gray dot and 'Offline' label", () => {
render(<WorkspaceNode {...makeNode({ status: "offline" })} />);
const dot = document.querySelector(".bg-zinc-500");
expect(dot).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
});
it("provisioning: shows pulsing blue dot and 'Starting' label", () => {
render(<WorkspaceNode {...makeNode({ status: "provisioning" })} />);
const dot = document.querySelector(".motion-safe\\:animate-pulse");
expect(dot).toBeTruthy();
expect(screen.getByText("Starting")).toBeTruthy();
});
it("paused: shows indigo dot and 'Paused' label", () => {
render(<WorkspaceNode {...makeNode({ status: "paused" })} />);
const dot = document.querySelector(".bg-indigo-400");
expect(dot).toBeTruthy();
expect(screen.getByText("Paused")).toBeTruthy();
});
it("degraded: shows amber dot and 'Degraded' label", () => {
render(<WorkspaceNode {...makeNode({ status: "degraded" })} />);
const dot = document.querySelector(".bg-amber-400");
expect(dot).toBeTruthy();
expect(screen.getByText("Degraded")).toBeTruthy();
});
it("degraded: shows last sample error preview", () => {
render(<WorkspaceNode {...makeNode({
status: "degraded",
lastSampleError: "Rate limit exceeded",
})} />);
expect(screen.getByText("Rate limit exceeded")).toBeTruthy();
});
it("failed: shows red dot and 'Failed' label", () => {
render(<WorkspaceNode {...makeNode({ status: "failed" })} />);
const dot = document.querySelector(".bg-red-400");
expect(dot).toBeTruthy();
expect(screen.getByText("Failed")).toBeTruthy();
});
it("not_configured: shows amber dot and 'Not configured' label", () => {
render(<WorkspaceNode {...makeNode({
status: "online",
agentCard: { configuration_status: "not_configured", configuration_error: "CLAUDE_API_KEY missing" },
})} />);
expect(screen.getByText("Not configured")).toBeTruthy();
});
it("not_configured: shows configuration error preview", () => {
render(<WorkspaceNode {...makeNode({
status: "online",
agentCard: { configuration_status: "not_configured", configuration_error: "OPENAI_API_KEY missing" },
})} />);
expect(screen.getByText("OPENAI_API_KEY missing")).toBeTruthy();
});
});
// ════════════════════════════════════════════════════════════════════════════════
// INTERACTIONS — click, shift-click, double-click, context menu, keyboard
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — interactions", () => {
it("click calls selectNode with the node id", () => {
_storeSnap.selectedNodeId = null;
render(<WorkspaceNode {...makeNodeWithId("ws-1")} />);
clickNode();
expect(mockSelectNode).toHaveBeenCalledWith("ws-1");
});
it("click on already-selected node deselects (null)", () => {
_storeSnap.selectedNodeId = "ws-1";
render(<WorkspaceNode {...makeNodeWithId("ws-1")} />);
clickNode();
expect(mockSelectNode).toHaveBeenCalledWith(null);
});
it("shift-click calls toggleNodeSelection", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-2")} />);
clickNode(true);
expect(mockToggleNodeSelection).toHaveBeenCalledWith("ws-2");
});
it("double-click on leaf node does not throw", () => {
_storeSnap.nodes = [];
render(<WorkspaceNode {...makeNodeWithId("ws-leaf")} />);
expect(() => {
fireEvent.doubleClick(cardButton());
}).not.toThrow();
});
it("double-click on parent node emits zoom-to-team custom event", () => {
// Simulate a parent with children
_storeSnap.nodes = [
{ id: "ws-child", data: { parentId: "ws-parent" } },
];
render(<WorkspaceNode {...makeNodeWithId("ws-parent")} />);
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
fireEvent.doubleClick(cardButton());
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "molecule:zoom-to-team" })
);
});
it("right-click calls openContextMenu with node data", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-3")} />);
fireEvent.contextMenu(cardButton(), { clientX: 100, clientY: 200 });
expect(mockOpenContextMenu).toHaveBeenCalledWith(
expect.objectContaining({ nodeId: "ws-3" })
);
});
it("Enter key calls selectNode", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-kb")} />);
dispatchKey("Enter");
expect(mockSelectNode).toHaveBeenCalledWith("ws-kb");
});
it("Space key calls selectNode", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-space")} />);
dispatchKey(" ");
expect(mockSelectNode).toHaveBeenCalledWith("ws-space");
});
it("Shift+Enter calls toggleNodeSelection", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-shift")} />);
dispatchKey("Enter", { shift: true });
expect(mockToggleNodeSelection).toHaveBeenCalledWith("ws-shift");
});
it("ContextMenu key opens context menu", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-ctx")} />);
dispatchKey("ContextMenu");
expect(mockOpenContextMenu).toHaveBeenCalled();
});
});
// ════════════════════════════════════════════════════════════════════════════════
// ERROR / BANNER — needs-restart banner, restart action
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — needs-restart banner", () => {
it("renders restart banner when needsRestart is true and no currentTask", () => {
render(<WorkspaceNode {...makeNode({ needsRestart: true })} />);
expect(screen.getByText("Restart to apply changes")).toBeTruthy();
});
it("does not render restart banner when needsRestart is false", () => {
render(<WorkspaceNode {...makeNode({ needsRestart: false })} />);
expect(screen.queryByText("Restart to apply changes")).toBeNull();
});
it("does not render restart banner when currentTask is present", () => {
render(<WorkspaceNode {...makeNode({ needsRestart: true, currentTask: "Busy" })} />);
expect(screen.queryByText("Restart to apply changes")).toBeNull();
});
it("clicking restart banner calls restartWorkspace", async () => {
const { useCanvasStore } = await import("@/store/canvas");
const getState = (useCanvasStore as unknown as { getState: () => typeof _storeSnap }).getState;
getState().restartWorkspace = mockRestartWorkspace;
render(<WorkspaceNode {...makeNodeWithId("ws-restart", { needsRestart: true })} />);
const btn = screen.getByRole("button", { name: /restart to apply/i });
await act(async () => {
fireEvent.click(btn);
});
expect(mockRestartWorkspace).toHaveBeenCalledWith("ws-restart");
});
});
// ════════════════════════════════════════════════════════════════════════════════
// LAYOUT — child chips, "N sub" badge, expand/collapse
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — layout", () => {
it("shows 'N sub' badge when node has children in store", () => {
_storeSnap.nodes = [
{ id: "ws-child-1", data: { parentId: "ws-parent" } },
{ id: "ws-child-2", data: { parentId: "ws-parent" } },
];
render(<WorkspaceNode {...makeNodeWithId("ws-parent")} />);
expect(screen.getByText("2 sub")).toBeTruthy();
});
it("shows '1 sub' badge for single child", () => {
_storeSnap.nodes = [
{ id: "ws-child", data: { parentId: "ws-parent" } },
];
render(<WorkspaceNode {...makeNodeWithId("ws-parent")} />);
expect(screen.getByText("1 sub")).toBeTruthy();
});
it("no 'sub' badge when node has no children", () => {
_storeSnap.nodes = [];
render(<WorkspaceNode {...makeNodeWithId("ws-leaf")} />);
expect(screen.queryByText(/\d+ sub/)).toBeNull();
});
});
// ════════════════════════════════════════════════════════════════════════════════
// SELECTION STATE — visual highlights
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — selection highlights", () => {
it("applies selected class when selectedNodeId matches", () => {
_storeSnap.selectedNodeId = "ws-selected";
render(<WorkspaceNode {...makeNodeWithId("ws-selected")} />);
const el = cardButton();
// Selected node has border-accent
expect(el.className).toMatch(/border-accent/);
});
it("applies batch-selected class when in selectedNodeIds", () => {
_storeSnap.selectedNodeId = "ws-other";
_storeSnap.selectedNodeIds.add("ws-batch");
render(<WorkspaceNode {...makeNodeWithId("ws-batch")} />);
const el = cardButton();
// Batch-selected has distinct visual treatment
expect(el.className).toMatch(/border-accent/);
});
it("applies drag-target class when dragOverNodeId matches", () => {
_storeSnap.dragOverNodeId = "ws-drag";
render(<WorkspaceNode {...makeNodeWithId("ws-drag")} />);
const el = cardButton();
expect(el.className).toMatch(/emerald/);
});
});
// ════════════════════════════════════════════════════════════════════════════════
// ACCESSIBILITY
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — a11y", () => {
it("has role=button", () => {
render(<WorkspaceNode {...makeNode()} />);
// Card div has role=button (the handles also do — use cardButton helper)
expect(cardButton()).toBeTruthy();
});
it("has tabIndex=0", () => {
render(<WorkspaceNode {...makeNode()} />);
expect(cardButton().getAttribute("tabIndex")).toBe("0");
});
it("has aria-pressed reflecting selected state", () => {
_storeSnap.selectedNodeId = "ws-1";
render(<WorkspaceNode {...makeNodeWithId("ws-1")} />);
expect(cardButton().getAttribute("aria-pressed")).toBe("true");
});
it("aria-pressed is false when not selected", () => {
_storeSnap.selectedNodeId = null;
render(<WorkspaceNode {...makeNodeWithId("ws-other")} />);
expect(cardButton().getAttribute("aria-pressed")).toBe("false");
});
it("aria-label includes name and status", () => {
render(<WorkspaceNode {...makeNode({ name: "MyAgent", status: "online" })} />);
const el = cardButton();
expect(el.getAttribute("aria-label")).toMatch(/MyAgent/);
expect(el.getAttribute("aria-label")).toMatch(/online/);
});
it("aria-label includes configuration error for misconfigured workspace", () => {
render(<WorkspaceNode {...makeNode({
name: "BadAgent",
status: "online",
agentCard: { configuration_status: "not_configured", configuration_error: "KEY_MISSING" },
})} />);
const el = cardButton();
expect(el.getAttribute("aria-label")).toMatch(/KEY_MISSING/);
});
it("top handle has aria-label for extract action", () => {
render(<WorkspaceNode {...makeNode({ name: "ExtractMe", parentId: "parent-1" })} />);
const handles = document.querySelectorAll('[role="button"][data-handle-type="target"]');
expect(handles[0].getAttribute("aria-label")).toMatch(/Extract/);
});
it("bottom handle has aria-label for nest action", () => {
render(<WorkspaceNode {...makeNode({ name: "NestTarget" })} />);
const handles = document.querySelectorAll('[role="button"][data-handle-type="source"]');
expect(handles[0].getAttribute("aria-label")).toMatch(/Nest/);
});
});
@@ -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("⚙");
});
});
@@ -30,13 +30,7 @@ vi.mock("@/store/canvas", () => ({
const mockStoreState = {
selectedNodeId: null as string | null,
selectedNodeIds: new Set<string>(),
nodes: [] as Array<{
id: string;
position: { x: number; y: number };
data: { parentId?: string | null };
width?: number;
height?: number;
}>,
nodes: [] as Array<{ id: string; position: { x: number; y: number }; data: { parentId?: string | null } }>,
contextMenu: null as { x: number; y: number; nodeId: string } | null,
closeContextMenu: vi.fn(),
selectNode: vi.fn(),
@@ -44,7 +38,6 @@ const mockStoreState = {
bumpZOrder: vi.fn(),
savePosition: mockSavePosition,
moveNode: vi.fn(),
onNodesChange: vi.fn(),
};
afterEach(() => {
@@ -61,7 +54,6 @@ afterEach(() => {
mockStoreState.bumpZOrder.mockClear();
mockStoreState.moveNode.mockClear();
mockStoreState.savePosition.mockClear();
mockStoreState.onNodesChange.mockClear();
});
// ─── Test wrapper ────────────────────────────────────────────────────────────
@@ -279,13 +271,15 @@ describe("Arrow keys — keyboard node movement", () => {
document.body.removeChild(dialog);
});
// NOTE: "prevents default browser scroll on arrow keys" was removed.
// jsdom's KeyboardEvent.initKeyboardEvent does not copy the preventDefault
// function from eventProperties into the real KeyboardEvent, so a
// preventDefault mock passed via fireEvent.keyDown(eventProperties) is
// never called. The guard (selected node required) is covered by
// "does NOT fire when no node is selected". The e.preventDefault() call
// itself is verified by code inspection.
it("prevents default browser scroll on arrow keys", () => {
renderWithProvider();
const preventDefault = vi.fn();
fireEvent.keyDown(window, {
key: "ArrowDown",
preventDefault,
});
expect(preventDefault).toHaveBeenCalled();
});
});
describe("all shortcuts respect inInput guard", () => {
@@ -313,124 +307,3 @@ describe("all shortcuts respect inInput guard", () => {
document.body.removeChild(input);
});
});
describe("Cmd/Ctrl+Arrow — keyboard node resize", () => {
beforeEach(() => {
mockStoreState.nodes = [
{
id: "n1",
position: { x: 0, y: 0 },
data: { parentId: null },
width: 210,
height: 110,
},
];
mockStoreState.selectedNodeId = "n1";
renderWithProvider();
});
it("resizes height down (smaller) on Cmd/Ctrl+ArrowUp", () => {
// Node starts at minHeight=110 (no children). Shrinking clamps to min —
// height stays 110. Width is unchanged.
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 210, height: 110 },
}),
]);
});
it("resizes height up (larger) on Cmd/Ctrl+ArrowDown", () => {
fireEvent.keyDown(window, { key: "ArrowDown", ctrlKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 210, height: 120 },
}),
]);
});
it("resizes width down (smaller) on Cmd/Ctrl+ArrowLeft", () => {
// Node starts at minWidth=210 (no children). Shrinking clamps to min —
// width stays 210. Height is unchanged.
fireEvent.keyDown(window, { key: "ArrowLeft", metaKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 210, height: 110 },
}),
]);
});
it("resizes width up (larger) on Cmd/Ctrl+ArrowRight", () => {
fireEvent.keyDown(window, { key: "ArrowRight", ctrlKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 220, height: 110 },
}),
]);
});
it("uses 2px step with Shift held", () => {
// Step is 2px with Shift, but minHeight=110 clamps the result.
// 110 - 2 = 108, Math.max(110, 108) = 110. Width is unchanged.
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true, shiftKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
dimensions: { width: 210, height: 110 },
}),
]);
});
it("respects min-height constraint (no children)", () => {
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
// After shrinking from 110 to 100, another ArrowUp hits min-height of 110
// (110 - 10 = 100, but 100 < 110 so it should stay at 110)
// Actually: 110 -> 100 -> 110 (resets to min)
// Let me check: the hook does Math.max(minHeight, currentHeight - step)
// minHeight=110, step=10, so 110 - 10 = 100, but Math.max(110, 100) = 110
// So two ArrowUp calls should both result in height=100 then height=110?
// Wait: 110 - 10 = 100, Math.max(110, 100) = 110 (not 100)
// So the height never goes below 110. After first: 110 -> 100, but clamped to 110.
// Actually Math.max(110, 100) = 110, so the height never changes.
// The min constraint is respected — height stays at 110.
expect(mockStoreState.onNodesChange).toHaveBeenLastCalledWith([
expect.objectContaining({ dimensions: { width: 210, height: 110 } }),
]);
});
it("does NOT fire when no node is selected", () => {
mockStoreState.selectedNodeId = null;
fireEvent.keyDown(window, { key: "ArrowDown", metaKey: true });
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
});
it("skips when a modal dialog is open", () => {
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
fireEvent.keyDown(window, { key: "ArrowDown", metaKey: true });
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
document.body.removeChild(dialog);
});
it("skips plain arrow keys (no modifier) — moveNode is called instead", () => {
fireEvent.keyDown(window, { key: "ArrowUp" });
expect(mockStoreState.moveNode).toHaveBeenCalled();
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
});
it("skips Alt+Arrow (not a resize combo)", () => {
fireEvent.keyDown(window, { key: "ArrowUp", altKey: true });
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
});
});
@@ -109,7 +109,7 @@ export function OrgTokensTab() {
Organization API Keys
</h3>
</div>
<p className="text-[10px] text-ink-mid leading-relaxed">
<p className="text-[10px] text-ink-soft leading-relaxed">
Full-admin bearer tokens for this organization. Use with external
integrations, CLI tools, or AI agents that need to manage
workspaces, settings, and secrets. Each key has the same
@@ -182,13 +182,13 @@ export function OrgTokensTab() {
{/* Token list */}
{loading ? (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-mid text-xs">
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
<Spinner /> Loading keys...
</div>
) : tokens.length === 0 ? (
<div className="text-center py-6">
<p className="text-xs text-ink-mid">No active keys</p>
<p className="text-[10px] text-ink-mid mt-1">
<p className="text-xs text-ink-soft">No active keys</p>
<p className="text-[10px] text-ink-soft mt-1">
Create a key above to authenticate API calls to this organization.
</p>
</div>
@@ -209,7 +209,7 @@ export function OrgTokensTab() {
{t.name}
</span>
)}
<div className="text-[9px] text-ink-mid space-x-3">
<div className="text-[9px] text-ink-soft space-x-3">
<span>Created {formatAge(t.created_at)}</span>
{t.last_used_at && (
<span>Last used {formatAge(t.last_used_at)}</span>
+5 -5
View File
@@ -81,7 +81,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
<p className="text-[10px] text-ink-mid mt-0.5">
<p className="text-[10px] text-ink-soft mt-0.5">
Bearer tokens for authenticating API calls to this workspace.
</p>
</div>
@@ -129,13 +129,13 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
{/* Token list */}
{loading ? (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-mid text-xs">
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
<Spinner /> Loading tokens...
</div>
) : tokens.length === 0 ? (
<div className="text-center py-6">
<p className="text-xs text-ink-mid">No active tokens</p>
<p className="text-[10px] text-ink-mid mt-1">
<p className="text-xs text-ink-soft">No active tokens</p>
<p className="text-[10px] text-ink-soft mt-1">
Create a token to authenticate API calls.
</p>
</div>
@@ -150,7 +150,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
<code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded">
{t.prefix}...
</code>
<div className="text-[9px] text-ink-mid space-x-3">
<div className="text-[9px] text-ink-soft space-x-3">
<span>Created {formatAge(t.created_at)}</span>
{t.last_used_at && (
<span>Last used {formatAge(t.last_used_at)}</span>
+15 -15
View File
@@ -142,7 +142,7 @@ export function ActivityTab({ workspaceId }: Props) {
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
}`}
>
<span className="mr-0.5 opacity-60">{f.icon}</span> {f.label}
@@ -153,7 +153,7 @@ export function ActivityTab({ workspaceId }: Props) {
onClick={() => setAutoRefresh(!autoRefresh)}
aria-pressed={autoRefresh}
className={`text-[11px] px-1.5 py-0.5 rounded ${
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-mid"
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-soft"
}`}
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
>
@@ -177,7 +177,7 @@ export function ActivityTab({ workspaceId }: Props) {
</button>
</div>
</div>
<div className="mt-1.5 text-[10px] text-ink-mid">
<div className="mt-1.5 text-[10px] text-ink-soft">
{activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"}
</div>
</div>
@@ -185,7 +185,7 @@ export function ActivityTab({ workspaceId }: Props) {
{/* Activity list */}
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
{loading && activities.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8">Loading activity...</div>
<div className="text-xs text-ink-soft text-center py-8">Loading activity...</div>
)}
{error && (
@@ -196,8 +196,8 @@ export function ActivityTab({ workspaceId }: Props) {
{!loading && !error && activities.length === 0 && (
<div className="text-center py-8">
<div className="text-ink-mid text-xs">No activity recorded yet</div>
<div className="text-ink-mid text-[9px] mt-1">
<div className="text-ink-soft text-xs">No activity recorded yet</div>
<div className="text-ink-soft text-[9px] mt-1">
Activity logs appear when agents communicate or perform tasks
</div>
</div>
@@ -265,16 +265,16 @@ function ActivityRow({
</span>
{entry.duration_ms != null && (
<span className="text-[8px] text-ink-mid font-mono tabular-nums shrink-0">
<span className="text-[8px] text-ink-soft font-mono tabular-nums shrink-0">
{entry.duration_ms}ms
</span>
)}
<span className="text-[8px] text-ink-mid shrink-0">
<span className="text-[8px] text-ink-soft shrink-0">
{formatTime(entry.created_at)}
</span>
<span className="text-[9px] text-ink-mid">
<span className="text-[9px] text-ink-soft">
{expanded ? "▼" : "▶"}
</span>
</div>
@@ -296,7 +296,7 @@ function ActivityRow({
{resolveName(entry.source_id)}
</span>
)}
<span className="text-[9px] text-ink-mid"></span>
<span className="text-[9px] text-ink-soft"></span>
{entry.target_id && (
<span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}>
{resolveName(entry.target_id)}
@@ -338,7 +338,7 @@ function ActivityRow({
{entry.response_body && (
<JsonBlock label="Response" data={entry.response_body} />
)}
<div className="text-[8px] text-ink-mid font-mono select-all">
<div className="text-[8px] text-ink-soft font-mono select-all">
ID: {entry.id}
</div>
</div>
@@ -386,7 +386,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
}
return (
<div>
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div>
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
{text.slice(0, 2000)}
</div>
@@ -429,7 +429,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
return (
<div>
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div>
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
{text.slice(0, 2000)}
</div>
@@ -440,7 +440,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) {
return (
<div className="flex items-start gap-2">
<span className="text-[8px] text-ink-mid uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
<span className="text-[8px] text-ink-soft uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
<span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}>
{value}
</span>
@@ -451,7 +451,7 @@ function Detail({ label, value, mono, error: isError }: { label: string; value:
function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) {
return (
<div>
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div>
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
{JSON.stringify(data, null, 2)}
</pre>
+4 -4
View File
@@ -158,7 +158,7 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Usage stats */}
{loading ? (
<p className="text-xs text-ink-mid" data-testid="budget-loading">
<p className="text-xs text-ink-soft" data-testid="budget-loading">
Loading
</p>
) : fetchError ? (
@@ -172,7 +172,7 @@ export function BudgetSection({ workspaceId }: Props) {
<span className="text-xs text-ink-mid">Credits used</span>
<span className="text-xs font-mono text-ink-mid">
<span data-testid="budget-used-value">{(budget.budget_used ?? 0).toLocaleString()}</span>
<span className="text-ink-mid mx-1">/</span>
<span className="text-ink-soft mx-1">/</span>
<span data-testid="budget-limit-value">
{budget.budget_limit != null
? budget.budget_limit.toLocaleString()
@@ -201,7 +201,7 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Remaining credits */}
{budget.budget_remaining != null && (
<p className="text-[11px] text-ink-mid" data-testid="budget-remaining">
<p className="text-[11px] text-ink-soft" data-testid="budget-remaining">
{budget.budget_remaining.toLocaleString()} credits remaining
</p>
)}
@@ -227,7 +227,7 @@ export function BudgetSection({ workspaceId }: Props) {
data-testid="budget-limit-input"
className="w-full bg-surface-card border border-line rounded-lg px-3 py-2 text-sm text-ink-mid placeholder-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
/>
<p className="text-xs text-ink-mid">Leave blank for unlimited</p>
<p className="text-xs text-ink-soft">Leave blank for unlimited</p>
{saveError && (
<div
+14 -14
View File
@@ -242,7 +242,7 @@ export function ChannelsTab({ workspaceId }: Props) {
if (loading) {
return (
<div className="p-4 text-ink-mid text-xs">Loading channels...</div>
<div className="p-4 text-ink-soft text-xs">Loading channels...</div>
);
}
@@ -271,7 +271,7 @@ export function ChannelsTab({ workspaceId }: Props) {
{showForm && (
<div className="space-y-2 p-3 bg-surface-card/40 rounded border border-line/50">
<div>
<label htmlFor={platformId} className="text-[10px] text-ink-mid block mb-1">Platform</label>
<label htmlFor={platformId} className="text-[10px] text-ink-soft block mb-1">Platform</label>
<select
id={platformId}
value={formType}
@@ -327,7 +327,7 @@ export function ChannelsTab({ workspaceId }: Props) {
className="rounded border-line"
/>
<span className="text-xs text-ink-mid">{chat.name || "Unknown"}</span>
<span className="text-[10px] text-ink-mid ml-auto">{chat.type} {chat.chat_id}</span>
<span className="text-[10px] text-ink-soft ml-auto">{chat.type} {chat.chat_id}</span>
</label>
))}
<button
@@ -347,8 +347,8 @@ export function ChannelsTab({ workspaceId }: Props) {
)}
<div>
<label htmlFor={allowedUsersId} className="text-[10px] text-ink-mid block mb-1">
Allowed Users <span className="text-ink-mid">(optional, comma-separated)</span>
<label htmlFor={allowedUsersId} className="text-[10px] text-ink-soft block mb-1">
Allowed Users <span className="text-ink-soft">(optional, comma-separated)</span>
</label>
<input
id={allowedUsersId}
@@ -357,7 +357,7 @@ export function ChannelsTab({ workspaceId }: Props) {
placeholder="123456789, 987654321"
className="w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600"
/>
<p className="text-[11px] text-ink-mid mt-0.5">
<p className="text-[11px] text-ink-soft mt-0.5">
Platform-specific user IDs. Leave empty to allow everyone.
</p>
</div>
@@ -380,8 +380,8 @@ export function ChannelsTab({ workspaceId }: Props) {
{/* Channel list */}
{channels.length === 0 && !showForm && (
<div className="text-center py-8">
<p className="text-ink-mid text-xs">No channels connected</p>
<p className="text-ink-mid text-[10px] mt-1">
<p className="text-ink-soft text-xs">No channels connected</p>
<p className="text-ink-soft text-[10px] mt-1">
Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.
</p>
</div>
@@ -402,7 +402,7 @@ export function ChannelsTab({ workspaceId }: Props) {
<span className="text-xs font-medium text-ink">
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
</span>
<span className="text-[10px] text-ink-mid">
<span className="text-[10px] text-ink-soft">
{ch.config.chat_id || ch.config.channel_id || ""}
</span>
</div>
@@ -419,7 +419,7 @@ export function ChannelsTab({ workspaceId }: Props) {
className={`text-[10px] px-2 py-0.5 rounded transition ${
ch.enabled
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
: "bg-surface-card/50 text-ink-mid hover:text-ink-mid"
: "bg-surface-card/50 text-ink-soft hover:text-ink-mid"
}`}
>
{ch.enabled ? "On" : "Off"}
@@ -432,7 +432,7 @@ export function ChannelsTab({ workspaceId }: Props) {
</button>
</div>
</div>
<div className="flex items-center gap-4 text-[10px] text-ink-mid">
<div className="flex items-center gap-4 text-[10px] text-ink-soft">
<span>{ch.message_count} messages</span>
<span>Last: {relativeTime(ch.last_message_at)}</span>
{ch.allowed_users.length > 0 && (
@@ -474,9 +474,9 @@ function SchemaField({
"w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600";
return (
<div>
<label htmlFor={inputId} className="text-[10px] text-ink-mid block mb-1">
<label htmlFor={inputId} className="text-[10px] text-ink-soft block mb-1">
{field.label}
{!field.required && <span className="text-ink-mid"> (optional)</span>}
{!field.required && <span className="text-ink-soft"> (optional)</span>}
</label>
{field.type === "textarea" ? (
<textarea
@@ -499,7 +499,7 @@ function SchemaField({
)}
{renderExtras?.()}
{field.help && (
<p className="text-[11px] text-ink-mid mt-0.5">{field.help}</p>
<p className="text-[11px] text-ink-soft mt-0.5">{field.help}</p>
)}
</div>
);
+4 -4
View File
@@ -965,7 +965,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
{/* Messages */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{loading && (
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div>
<div className="text-xs text-ink-soft text-center py-4">Loading chat history...</div>
)}
{!loading && loadError !== null && messages.length === 0 && (
<div
@@ -984,7 +984,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div>
)}
{!loading && loadError === null && messages.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8">
<div className="text-xs text-ink-soft text-center py-8">
No messages yet. Send a message to start chatting with this agent.
</div>
)}
@@ -1002,7 +1002,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
scroll resting against the top of the conversation IS the
signal. */}
{hasMore && messages.length > 0 && (
<div ref={topRef} className="text-xs text-ink-mid text-center py-1">
<div ref={topRef} className="text-xs text-ink-soft text-center py-1">
{loadingOlder ? "Loading older messages…" : " "}
</div>
)}
@@ -1153,7 +1153,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
{thinkingElapsed}s
</div>
{activityLog.length > 0 && (
<div className="mt-1.5 text-[9px] text-ink-mid space-y-0.5">
<div className="mt-1.5 text-[9px] text-ink-soft space-y-0.5">
<div className="text-ink-mid">Processing with {runtimeDisplayName(data.runtime)}...</div>
{activityLog.map((line, i) => (
<div key={line + i} className="pl-2 border-l border-line"> {line}</div>
+18 -18
View File
@@ -97,7 +97,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
{JSON.stringify(card, null, 2)}
</pre>
) : (
<div className="text-[10px] text-ink-mid">No agent card</div>
<div className="text-[10px] text-ink-soft">No agent card</div>
)}
{success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">Updated</div>}
<button type="button" onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
@@ -635,16 +635,16 @@ export function ConfigTab({ workspaceId }: Props) {
const isDirty = (rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml) || providerDirty;
if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading config...</div>;
return <div className="p-4 text-xs text-ink-soft">Loading config...</div>;
}
return (
<div className="flex flex-col h-full">
{/* Mode toggle */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/30">
<span className="text-[10px] text-ink-mid">config.yaml</span>
<span className="text-[10px] text-ink-soft">config.yaml</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<span className="text-[9px] text-ink-mid">Raw YAML</span>
<span className="text-[9px] text-ink-soft">Raw YAML</span>
<input
type="checkbox"
checked={rawMode}
@@ -677,7 +677,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="General">
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
<div>
<label htmlFor={descriptionId} className="text-[10px] text-ink-mid block mb-1">Description</label>
<label htmlFor={descriptionId} className="text-[10px] text-ink-soft block mb-1">Description</label>
<textarea
id={descriptionId}
value={config.description}
@@ -689,7 +689,7 @@ export function ConfigTab({ workspaceId }: Props) {
<div className="grid grid-cols-2 gap-3">
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
<div>
<label htmlFor={tierId} className="text-[10px] text-ink-mid block mb-1">Tier</label>
<label htmlFor={tierId} className="text-[10px] text-ink-soft block mb-1">Tier</label>
<select
id={tierId}
value={config.tier}
@@ -707,7 +707,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Runtime">
<div>
<label htmlFor={runtimeId} className="text-[10px] text-ink-mid block mb-1">Runtime</label>
<label htmlFor={runtimeId} className="text-[10px] text-ink-soft block mb-1">Runtime</label>
<select
id={runtimeId}
value={config.runtime || ""}
@@ -791,7 +791,7 @@ export function ConfigTab({ workspaceId }: Props) {
// workspace_secrets MODEL_PROVIDER override.
<div className="space-y-3">
<div>
<label className="text-[10px] text-ink-mid block mb-1">Model</label>
<label className="text-[10px] text-ink-soft block mb-1">Model</label>
<input
type="text"
value={currentModelId}
@@ -808,9 +808,9 @@ export function ConfigTab({ workspaceId }: Props) {
/>
</div>
<div>
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-ink-mid block mb-1">
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-ink-soft block mb-1">
Provider
<span className="ml-1 text-ink-mid">
<span className="ml-1 text-ink-soft">
(override leave empty to auto-derive from model slug)
</span>
</label>
@@ -859,7 +859,7 @@ export function ConfigTab({ workspaceId }: Props) {
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value"
/>
<p className="text-[10px] text-ink-mid mt-1">
<p className="text-[10px] text-ink-soft mt-1">
This declares which env var <em>names</em> the workspace needs.
Set the actual values in the <strong>Secrets</strong> section
below those are encrypted and mounted into the container at
@@ -867,7 +867,7 @@ export function ConfigTab({ workspaceId }: Props) {
</p>
{currentModelSpec?.required_env?.length &&
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
<div className="text-[10px] text-ink-mid mt-1 flex items-center gap-2">
<div className="text-[10px] text-ink-soft mt-1 flex items-center gap-2">
<span>
Template suggests{" "}
<code className="text-ink-mid">{currentModelSpec.required_env.join(", ")}</code>{" "}
@@ -890,9 +890,9 @@ export function ConfigTab({ workspaceId }: Props) {
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
<Section title="Claude Settings" defaultOpen={false}>
<div>
<label htmlFor={effortId} className="text-[10px] text-ink-mid block mb-1">
<label htmlFor={effortId} className="text-[10px] text-ink-soft block mb-1">
Effort
<span className="ml-1 text-ink-mid">(output_config.effort Opus 4.7+)</span>
<span className="ml-1 text-ink-soft">(output_config.effort Opus 4.7+)</span>
</label>
<select
id={effortId}
@@ -910,9 +910,9 @@ export function ConfigTab({ workspaceId }: Props) {
</select>
</div>
<div>
<label htmlFor={taskBudgetId} className="text-[10px] text-ink-mid block mb-1">
<label htmlFor={taskBudgetId} className="text-[10px] text-ink-soft block mb-1">
Task Budget (tokens)
<span className="ml-1 text-ink-mid">(output_config.task_budget.total 0 = unset)</span>
<span className="ml-1 text-ink-soft">(output_config.task_budget.total 0 = unset)</span>
</label>
<input
id={taskBudgetId}
@@ -938,7 +938,7 @@ export function ConfigTab({ workspaceId }: Props) {
showing the misnamed list-input affordance. */}
<Section title="Prompt Files" defaultOpen={false}>
<p className="text-[10px] text-ink-mid px-1 pb-1">
<p className="text-[10px] text-ink-soft px-1 pb-1">
Markdown files that compose this workspace&apos;s system prompt.
Loaded in order at boot from the workspace config dir
(e.g. <code className="font-mono">system-prompt.md</code>,{' '}
@@ -966,7 +966,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Sandbox" defaultOpen={false}>
<div>
<label htmlFor={sandboxBackendId} className="text-[10px] text-ink-mid block mb-1">Backend</label>
<label htmlFor={sandboxBackendId} className="text-[10px] text-ink-soft block mb-1">Backend</label>
<select
id={sandboxBackendId}
value={config.sandbox?.backend || "docker"}
+7 -7
View File
@@ -242,7 +242,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
{data.lastSampleError}
</pre>
) : (
<p className="text-xs text-ink-mid">No error detail recorded.</p>
<p className="text-xs text-ink-soft">No error detail recorded.</p>
)}
<button
type="button"
@@ -268,7 +268,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
<div key={s.id} className="flex items-start gap-2">
<span className="text-xs text-accent font-mono shrink-0">{s.id}</span>
{s.description && (
<span className="text-xs text-ink-mid">{s.description}</span>
<span className="text-xs text-ink-soft">{s.description}</span>
)}
</div>
))}
@@ -281,11 +281,11 @@ export function DetailsTab({ workspaceId, data }: Props) {
{peersError ? (
<p className="text-xs text-bad">{peersError}</p>
) : peers.length === 0 && data.status !== "online" && data.status !== "degraded" ? (
<p className="text-xs text-ink-mid">
<p className="text-xs text-ink-soft">
Peers are only discoverable while the workspace is online.
</p>
) : peers.length === 0 ? (
<p className="text-xs text-ink-mid">No reachable peers</p>
<p className="text-xs text-ink-soft">No reachable peers</p>
) : (
<div className="space-y-1">
{peers.map((p) => (
@@ -297,7 +297,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
>
<StatusDot status={p.status} />
<span className="text-xs text-ink">{p.name}</span>
{p.role && <span className="text-[10px] text-ink-mid">{p.role}</span>}
{p.role && <span className="text-[10px] text-ink-soft">{p.role}</span>}
</button>
))}
</div>
@@ -385,7 +385,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
const fieldId = useId();
return (
<div>
<label htmlFor={fieldId} className="text-[10px] text-ink-mid block mb-0.5">{label}</label>
<label htmlFor={fieldId} className="text-[10px] text-ink-soft block mb-0.5">{label}</label>
{cloneElement(children as ReactElement<{ id?: string }>, { id: fieldId })}
</div>
);
@@ -394,7 +394,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex justify-between">
<span className="text-xs text-ink-mid">{label}</span>
<span className="text-xs text-ink-soft">{label}</span>
<span className={`text-xs text-ink ${mono ? "font-mono" : ""} text-right max-w-[200px] truncate`}>
{value}
</span>
+5 -5
View File
@@ -62,7 +62,7 @@ export function EventsTab({ workspaceId }: Props) {
}, [loadEvents]);
if (loading && events.length === 0) {
return <div className="p-4 text-xs text-ink-mid">Loading events...</div>;
return <div className="p-4 text-xs text-ink-soft">Loading events...</div>;
}
return (
@@ -88,7 +88,7 @@ export function EventsTab({ workspaceId }: Props) {
)}
{!error && events.length === 0 ? (
<p className="text-xs text-ink-mid text-center py-4">No events yet</p>
<p className="text-xs text-ink-soft text-center py-4">No events yet</p>
) : (
<div className="space-y-1">
{events.map((event) => {
@@ -115,10 +115,10 @@ export function EventsTab({ workspaceId }: Props) {
>
{event.event_type}
</span>
<span className="text-[9px] text-ink-mid ml-auto">
<span className="text-[9px] text-ink-soft ml-auto">
{formatTime(event.created_at)}
</span>
<span aria-hidden="true" className="text-[10px] text-ink-mid">
<span aria-hidden="true" className="text-[10px] text-ink-soft">
{isOpen ? "▼" : "▶"}
</span>
</button>
@@ -128,7 +128,7 @@ export function EventsTab({ workspaceId }: Props) {
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
{JSON.stringify(event.payload, null, 2)}
</pre>
<div className="mt-1 text-[9px] text-ink-mid font-mono">
<div className="mt-1 text-[9px] text-ink-soft font-mono">
ID: {event.id}
</div>
</div>
@@ -77,7 +77,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
return (
<div className="mx-3 mt-3 p-3 bg-surface-sunken/50 border border-line rounded">
<h3 className="text-xs text-ink-mid font-medium mb-1">External Connection</h3>
<p className="text-[10px] text-ink-mid mb-2">
<p className="text-[10px] text-ink-soft mb-2">
This workspace runs an external agent. Use these controls to
re-show the setup snippets or rotate the workspace token.
</p>
+2 -2
View File
@@ -203,7 +203,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
};
if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading files...</div>;
return <div className="p-4 text-xs text-ink-soft">Loading files...</div>;
}
return (
@@ -304,7 +304,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
)}
{files.length === 0 ? (
<div className="px-3 py-4 text-[10px] text-ink-mid text-center">
<div className="px-3 py-4 text-[10px] text-ink-soft text-center">
{rootDragHover
? "Drop to upload to root"
: root === "/configs"
@@ -36,7 +36,7 @@ export function FileEditor({
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl opacity-20 mb-2">📄</div>
<p className="text-[10px] text-ink-mid">Select a file to edit</p>
<p className="text-[10px] text-ink-soft">Select a file to edit</p>
</div>
</div>
);
@@ -56,7 +56,7 @@ export function FileEditor({
<button
onClick={onDownload}
aria-label="Download file"
className="text-[10px] text-ink-mid hover:text-ink-mid"
className="text-[10px] text-ink-soft hover:text-ink-mid"
>
</button>
@@ -74,7 +74,7 @@ export function FileEditor({
{/* Editor area */}
{loadingFile ? (
<div className="p-4 text-xs text-ink-mid">Loading...</div>
<div className="p-4 text-xs text-ink-soft">Loading...</div>
) : (
<textarea
ref={editorRef}
@@ -209,7 +209,7 @@ function TreeItem({
onContextMenu={(e) => openContextMenu(e, node)}
{...dragProps}
>
<span className="text-[9px] text-ink-mid w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[10px]">📁</span>
<span className="text-[10px] text-ink-mid flex-1">{node.name}</span>
<button
@@ -132,7 +132,7 @@ export function FileTreeContextMenu({ x, y, items, onClose }: Props) {
: "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
}
>
{item.icon && <span className="inline-block w-4 mr-1.5 text-ink-mid">{item.icon}</span>}
{item.icon && <span className="inline-block w-4 mr-1.5 text-ink-soft">{item.icon}</span>}
{item.label}
</button>
))}
@@ -39,7 +39,7 @@ export function FilesToolbar({
<option value="/workspace">/workspace</option>
<option value="/plugins">/plugins</option>
</select>
<span className="text-[10px] text-ink-mid">{fileCount} files</span>
<span className="text-[10px] text-ink-soft">{fileCount} files</span>
</div>
<div className="flex gap-1.5">
{root === "/configs" && (
@@ -62,7 +62,7 @@ export function FilesToolbar({
</button>
</>
)}
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-ink-mid hover:text-ink-mid" title="Download all files">
<button type="button" onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-ink-soft hover:text-ink-mid" title="Download all files">
Export
</button>
{root === "/configs" && (
@@ -70,7 +70,7 @@ export function FilesToolbar({
Clear
</button>
)}
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-ink-mid hover:text-ink-mid" title="Refresh">
<button type="button" onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-ink-soft hover:text-ink-mid" title="Refresh">
</button>
</div>
@@ -27,7 +27,7 @@ export function NotAvailablePanel({ runtime }: { runtime: string }) {
viewBox="0 0 72 72"
fill="none"
aria-hidden="true"
className="text-ink-mid mb-4"
className="text-ink-soft mb-4"
>
{/* Folder body */}
<path
@@ -47,7 +47,7 @@ export function NotAvailablePanel({ runtime }: { runtime: string }) {
/>
</svg>
<h3 className="text-sm font-medium text-ink mb-1.5">Files not available</h3>
<p className="text-[11px] text-ink-mid max-w-xs leading-relaxed">
<p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
This workspace runs the{" "}
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
whose filesystem isn't owned by the platform. Use the Chat tab to
@@ -1,216 +0,0 @@
// @vitest-environment jsdom
/**
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
*
* NotAvailablePanel: pure presentational component — renders a "feature not
* available" placeholder for external-runtime workspaces.
* FilesToolbar: pure props-driven component — directory selector, file count,
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
*
* No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks to avoid "expect is not defined" errors.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import React from "react";
import { FilesToolbar } from "../FilesToolbar";
import { NotAvailablePanel } from "../NotAvailablePanel";
// ─── afterEach ─────────────────────────────────────────────────────────────────
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
describe("NotAvailablePanel", () => {
it("renders heading 'Files not available'", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("Files not available");
});
it("renders the runtime name in monospace", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("external");
const spans = container.querySelectorAll("span");
const monoSpans = Array.from(spans).filter(
(s) => s.className && s.className.includes("font-mono"),
);
expect(monoSpans.length).toBeGreaterThan(0);
});
it("renders a Chat tab hint in description", () => {
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
expect(container.textContent).toContain("Chat tab");
});
it("SVG icon has aria-hidden=true", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("renders without crashing for any runtime string", () => {
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
expect(container.textContent).toContain("unknown-runtime");
});
it("applies the correct layout classes to root div", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain("flex");
expect(root.className).toContain("flex-col");
expect(root.className).toContain("items-center");
});
});
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
describe("FilesToolbar", () => {
const noop = vi.fn();
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
return render(
<FilesToolbar
root="/configs"
setRoot={noop}
fileCount={0}
onNewFile={noop}
onUpload={noop}
onDownloadAll={noop}
onClearAll={noop}
onRefresh={noop}
{...props}
/>,
);
}
it("renders the directory selector with correct aria-label", () => {
const { container } = renderToolbar();
const select = container.querySelector("select");
expect(select?.getAttribute("aria-label")).toBe("File root directory");
});
it("directory selector has all four options", () => {
const { container } = renderToolbar();
const select = container.querySelector("select") as HTMLSelectElement;
const options = Array.from(select?.options ?? []);
const values = options.map((o) => o.value);
expect(values).toContain("/configs");
expect(values).toContain("/home");
expect(values).toContain("/workspace");
expect(values).toContain("/plugins");
});
it("calls setRoot when directory changes", () => {
const setRoot = vi.fn();
const { container } = renderToolbar({ setRoot });
const select = container.querySelector("select") as HTMLSelectElement;
select.value = "/home";
select.dispatchEvent(new Event("change", { bubbles: true }));
expect(setRoot).toHaveBeenCalledWith("/home");
});
it("displays the file count", () => {
const { container } = renderToolbar({ fileCount: 42 });
expect(container.textContent).toContain("42 files");
});
it("shows New + Upload + Clear buttons for /configs", () => {
const { container } = renderToolbar({ root: "/configs" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).toContain("+ New");
expect(texts).toContain("Upload");
expect(texts).toContain("Clear");
expect(texts).toContain("Export");
expect(texts).toContain("↻");
});
it("hides New + Upload + Clear for /workspace", () => {
const { container } = renderToolbar({ root: "/workspace" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
expect(texts).toContain("Export");
});
it("hides New + Upload + Clear for /home", () => {
const { container } = renderToolbar({ root: "/home" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
});
it("hides New + Upload + Clear for /plugins", () => {
const { container } = renderToolbar({ root: "/plugins" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
});
it("New button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const newBtn = container.querySelector('button[aria-label="Create new file"]');
expect(newBtn?.textContent?.trim()).toBe("+ New");
});
it("Export button has correct aria-label", () => {
const { container } = renderToolbar();
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
expect(exportBtn?.textContent?.trim()).toBe("Export");
});
it("Clear button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
expect(clearBtn?.textContent?.trim()).toBe("Clear");
});
it("Refresh button has correct aria-label", () => {
const { container } = renderToolbar();
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
expect(refreshBtn?.textContent?.trim()).toBe("↻");
});
it("calls onNewFile when New button is clicked", () => {
const onNewFile = vi.fn();
const { container } = renderToolbar({ root: "/configs", onNewFile });
container.querySelector('button[aria-label="Create new file"]')!.click();
expect(onNewFile).toHaveBeenCalledTimes(1);
});
it("calls onDownloadAll when Export button is clicked", () => {
const onDownloadAll = vi.fn();
const { container } = renderToolbar({ onDownloadAll });
container.querySelector('button[aria-label="Download all files"]')!.click();
expect(onDownloadAll).toHaveBeenCalledTimes(1);
});
it("calls onClearAll when Clear button is clicked", () => {
const onClearAll = vi.fn();
const { container } = renderToolbar({ root: "/configs", onClearAll });
container.querySelector('button[aria-label="Delete all files"]')!.click();
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it("calls onRefresh when Refresh button is clicked", () => {
const onRefresh = vi.fn();
const { container } = renderToolbar({ onRefresh });
container.querySelector('button[aria-label="Refresh file list"]')!.click();
expect(onRefresh).toHaveBeenCalledTimes(1);
});
});
@@ -1,349 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for FilesToolbar — the top-of-panel bar for the Files tab.
* Covers: directory select, file count, New/Upload/Clear (configs-only),
* Export, Refresh, and aria-labels.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FilesToolbar } from "../FilesToolbar";
afterEach(cleanup);
describe("FilesToolbar", () => {
describe("renders base toolbar", () => {
it("renders the directory select with aria-label", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.getByRole("combobox", { name: /file root directory/i })
).toBeTruthy();
});
it("renders the file count", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={7}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(screen.getByText("7 files")).toBeTruthy();
});
it("renders Export button", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.getByRole("button", { name: /download all files/i })
).toBeTruthy();
});
it("renders Refresh button", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: /refresh file list/i })).toBeTruthy();
});
it("renders 0 files when count is 0", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(screen.getByText("0 files")).toBeTruthy();
});
});
describe("configs-only buttons", () => {
it("shows New and Upload buttons when root is /configs", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.getByRole("button", { name: /create new file/i })
).toBeTruthy();
expect(
screen.getByRole("button", { name: /upload folder/i })
).toBeTruthy();
expect(screen.getByRole("button", { name: /delete all files/i })).toBeTruthy();
});
it("hides New and Upload when root is /workspace", () => {
render(
<FilesToolbar
root="/workspace"
setRoot={vi.fn()}
fileCount={5}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.queryByRole("button", { name: /create new file/i })
).toBeNull();
expect(
screen.queryByRole("button", { name: /upload folder/i })
).toBeNull();
expect(
screen.queryByRole("button", { name: /delete all files/i })
).toBeNull();
// Export and Refresh are still present
expect(
screen.getByRole("button", { name: /download all files/i })
).toBeTruthy();
});
it("hides New and Upload when root is /home", () => {
render(
<FilesToolbar
root="/home"
setRoot={vi.fn()}
fileCount={2}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.queryByRole("button", { name: /create new file/i })
).toBeNull();
expect(
screen.queryByRole("button", { name: /upload folder/i })
).toBeNull();
});
it("hides New and Upload when root is /plugins", () => {
render(
<FilesToolbar
root="/plugins"
setRoot={vi.fn()}
fileCount={1}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.queryByRole("button", { name: /create new file/i })
).toBeNull();
expect(
screen.queryByRole("button", { name: /upload folder/i })
).toBeNull();
});
});
describe("callbacks", () => {
it("calls setRoot when directory is changed", () => {
const setRoot = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={setRoot}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
fireEvent.change(screen.getByRole("combobox"), {
target: { value: "/workspace" },
});
expect(setRoot).toHaveBeenCalledWith("/workspace");
});
it("calls onNewFile when New button is clicked", () => {
const onNewFile = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={onNewFile}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /create new file/i }));
expect(onNewFile).toHaveBeenCalledTimes(1);
});
it("calls onDownloadAll when Export button is clicked", () => {
const onDownloadAll = vi.fn();
render(
<FilesToolbar
root="/workspace"
setRoot={vi.fn()}
fileCount={5}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={onDownloadAll}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /download all files/i }));
expect(onDownloadAll).toHaveBeenCalledTimes(1);
});
it("calls onClearAll when Clear button is clicked", () => {
const onClearAll = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={onClearAll}
onRefresh={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /delete all files/i }));
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it("calls onRefresh when Refresh button is clicked", () => {
const onRefresh = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={onRefresh}
/>
);
fireEvent.click(screen.getByRole("button", { name: /refresh file list/i }));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("calls onUpload when the hidden file input changes", () => {
const onUpload = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={onUpload}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
// Find the hidden file input
const fileInput = document.querySelector(
'input[type="file"]'
) as HTMLInputElement;
expect(fileInput).toBeTruthy();
expect(fileInput?.getAttribute("aria-label")).toBe("Upload folder files");
});
});
describe("a11y", () => {
it("all buttons have aria-label or accessible name", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
// All buttons should be findable by role
const buttons = screen.getAllByRole("button");
for (const btn of buttons) {
expect(btn.getAttribute("aria-label") ?? btn.textContent).toBeTruthy();
}
});
it("directory select has aria-label", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
const select = screen.getByRole("combobox");
expect(select.getAttribute("aria-label")).toBe("File root directory");
});
});
});
@@ -1,101 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for NotAvailablePanel — the full-tab placeholder shown when a
* workspace's runtime doesn't own a platform-managed filesystem (today:
* runtime === "external"). Covers rendering, a11y, and runtime prop
* display.
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { NotAvailablePanel } from "../NotAvailablePanel";
afterEach(cleanup);
describe("NotAvailablePanel", () => {
describe("renders", () => {
it("renders the heading", () => {
render(<NotAvailablePanel runtime="external" />);
expect(screen.getByText("Files not available")).toBeTruthy();
});
it("renders the description text", () => {
render(<NotAvailablePanel runtime="external" />);
expect(
screen.getByText(/whose filesystem isn't owned by the platform/i)
).toBeTruthy();
});
it("displays the runtime name in the description", () => {
render(<NotAvailablePanel runtime="aws-lambda" />);
// The runtime name appears inside the paragraph
const para = screen.getByText(/whose filesystem isn't owned/i);
expect(para.textContent).toContain("aws-lambda");
});
it("renders the SVG folder icon with aria-hidden", () => {
render(<NotAvailablePanel runtime="external" />);
const svg = document.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("uses the provided runtime prop verbatim", () => {
render(<NotAvailablePanel runtime="cloud-run" />);
const monoRuntime = document.querySelector(".font-mono");
expect(monoRuntime?.textContent).toBe("cloud-run");
});
it("renders the 'Use the Chat tab' guidance text", () => {
render(<NotAvailablePanel runtime="external" />);
expect(screen.getByText(/Use the Chat tab/i)).toBeTruthy();
});
it("is contained in a full-height flex column", () => {
render(<NotAvailablePanel runtime="external" />);
const container = screen.getByText("Files not available").closest("div");
expect(container?.className).toContain("flex");
expect(container?.className).toContain("flex-col");
expect(container?.className).toContain("items-center");
expect(container?.className).toContain("justify-center");
expect(container?.className).toContain("h-full");
});
});
describe("a11y", () => {
it("heading is an h3", () => {
render(<NotAvailablePanel runtime="external" />);
expect(screen.getByRole("heading", { level: 3 })).toBeTruthy();
});
it("SVG icon has aria-hidden so screen readers skip it", () => {
render(<NotAvailablePanel runtime="external" />);
const svg = document.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("description paragraph is present with descriptive text", () => {
render(<NotAvailablePanel runtime="external" />);
const paras = document.querySelectorAll("p");
expect(paras.length).toBeGreaterThan(0);
const text = Array.from(paras)
.map((p) => p.textContent)
.join(" ");
expect(text.toLowerCase()).toContain("runtime");
});
});
describe("props", () => {
it("renders with a short runtime name", () => {
render(<NotAvailablePanel runtime="ext" />);
const monoRuntime = document.querySelector(".font-mono");
expect(monoRuntime?.textContent).toBe("ext");
});
it("renders with a complex runtime name", () => {
render(<NotAvailablePanel runtime="gcp-cloud-functions-v2" />);
const monoRuntime = document.querySelector(".font-mono");
expect(monoRuntime?.textContent).toBe("gcp-cloud-functions-v2");
});
});
});
+13 -13
View File
@@ -182,7 +182,7 @@ export function MemoryTab({ workspaceId }: Props) {
};
if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading memory...</div>;
return <div className="p-4 text-xs text-ink-soft">Loading memory...</div>;
}
return (
@@ -197,7 +197,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-medium text-ink">Awareness dashboard</div>
<p className="text-[10px] text-ink-mid">
<p className="text-[10px] text-ink-soft">
Embedded view for the local Awareness memory UI. The current workspace id is appended to the URL for workspace-scoped routing or future filtering.
</p>
</div>
@@ -230,7 +230,7 @@ export function MemoryTab({ workspaceId }: Props) {
/>
</div>
) : (
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-mid">
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-4 text-xs text-ink-soft">
Set <code className="font-mono text-ink-mid">NEXT_PUBLIC_AWARENESS_URL</code> to embed the Awareness dashboard here.
</div>
)
@@ -238,7 +238,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="rounded-xl border border-line bg-surface-sunken/50 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-ink">Awareness dashboard is collapsed</p>
<p className="text-[10px] text-ink-mid truncate">
<p className="text-[10px] text-ink-soft truncate">
Workspace context stays linked through <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p>
</div>
@@ -254,15 +254,15 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="grid gap-2 rounded-xl border border-line bg-surface/40 px-3 py-2 text-[10px] text-ink-mid sm:grid-cols-3">
<div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-ink-mid">Status</span>
<span className="uppercase tracking-[0.18em] text-ink-soft">Status</span>
<span className="font-medium text-good">Connected</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="uppercase tracking-[0.18em] text-ink-mid">Mode</span>
<span className="uppercase tracking-[0.18em] text-ink-soft">Mode</span>
<span className="font-medium text-ink">{awarenessStatus}</span>
</div>
<div className="flex items-center justify-between gap-2 min-w-0">
<span className="uppercase tracking-[0.18em] text-ink-mid">Workspace</span>
<span className="uppercase tracking-[0.18em] text-ink-soft">Workspace</span>
<span className="font-mono text-ink-mid truncate">{workspaceId}</span>
</div>
</div>
@@ -272,7 +272,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-ink">Workspace KV memory</div>
<p className="text-[10px] text-ink-mid">
<p className="text-[10px] text-ink-soft">
Native platform key-value memory for workspace <span className="font-mono text-ink-mid">{workspaceId}</span>.
</p>
</div>
@@ -350,7 +350,7 @@ export function MemoryTab({ workspaceId }: Props) {
{showAdvanced ? (
entries.length === 0 ? (
<p className="text-xs text-ink-mid text-center py-4">No memory entries</p>
<p className="text-xs text-ink-soft text-center py-4">No memory entries</p>
) : (
<div className="space-y-1">
{entries.map((entry) => (
@@ -364,11 +364,11 @@ export function MemoryTab({ workspaceId }: Props) {
<span className="text-xs font-mono text-accent">{entry.key}</span>
<div className="flex items-center gap-2">
{entry.expires_at && (
<span className="text-[9px] text-ink-mid">
<span className="text-[9px] text-ink-soft">
TTL {new Date(entry.expires_at).toLocaleString()}
</span>
)}
<span className="text-[10px] text-ink-mid">
<span className="text-[10px] text-ink-soft">
{expanded === entry.key ? "▼" : "▶"}
</span>
</div>
@@ -420,7 +420,7 @@ export function MemoryTab({ workspaceId }: Props) {
</pre>
)}
<div className="flex items-center justify-between">
<span className="text-[9px] text-ink-mid">
<span className="text-[9px] text-ink-soft">
Updated: {new Date(entry.updated_at).toLocaleString()}
</span>
<div className="flex items-center gap-2">
@@ -452,7 +452,7 @@ export function MemoryTab({ workspaceId }: Props) {
<div className="rounded-xl border border-line bg-surface/30 px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-ink">Advanced workspace memory is hidden</p>
<p className="text-[10px] text-ink-mid truncate">
<p className="text-[10px] text-ink-soft truncate">
KV entries remain available if you need the raw platform store.
</p>
</div>
+13 -13
View File
@@ -180,7 +180,7 @@ export function ScheduleTab({ workspaceId }: Props) {
};
if (loading) {
return <div className="p-4 text-[10px] text-ink-mid">Loading schedules...</div>;
return <div className="p-4 text-[10px] text-ink-soft">Loading schedules...</div>;
}
return (
@@ -207,11 +207,11 @@ export function ScheduleTab({ workspaceId }: Props) {
placeholder="Schedule name (e.g., Daily security scan)"
value={formName}
onChange={(e) => setFormName(e.target.value)}
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-mid"
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft"
/>
<div className="flex gap-2">
<div className="flex-1">
<label htmlFor={cronId} className="text-[10px] text-ink-mid block mb-0.5">Cron Expression</label>
<label htmlFor={cronId} className="text-[10px] text-ink-soft block mb-0.5">Cron Expression</label>
<input
id={cronId}
type="text"
@@ -219,12 +219,12 @@ export function ScheduleTab({ workspaceId }: Props) {
onChange={(e) => setFormCron(e.target.value)}
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink font-mono"
/>
<div className="text-[10px] text-ink-mid mt-0.5">
<div className="text-[10px] text-ink-soft mt-0.5">
{cronToHuman(formCron)}
</div>
</div>
<div className="w-24">
<label htmlFor={timezoneId} className="text-[10px] text-ink-mid block mb-0.5">Timezone</label>
<label htmlFor={timezoneId} className="text-[10px] text-ink-soft block mb-0.5">Timezone</label>
<select
id={timezoneId}
value={formTimezone}
@@ -245,14 +245,14 @@ export function ScheduleTab({ workspaceId }: Props) {
</div>
</div>
<div>
<label htmlFor={promptId} className="text-[10px] text-ink-mid block mb-0.5">Prompt / Task</label>
<label htmlFor={promptId} className="text-[10px] text-ink-soft block mb-0.5">Prompt / Task</label>
<textarea
id={promptId}
value={formPrompt}
onChange={(e) => setFormPrompt(e.target.value)}
placeholder="What should the agent do on this schedule?"
rows={3}
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-mid resize-y"
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft resize-y"
/>
</div>
<div className="flex items-center gap-2">
@@ -290,7 +290,7 @@ export function ScheduleTab({ workspaceId }: Props) {
Cancel
</button>
</div>
<div className="text-[10px] text-ink-mid space-y-0.5">
<div className="text-[10px] text-ink-soft space-y-0.5">
<div>Common patterns:</div>
<div className="font-mono">{"0 9 * * *"} Daily at 9:00 AM</div>
<div className="font-mono">{"*/30 * * * *"} Every 30 minutes</div>
@@ -306,7 +306,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<div className="p-6 text-center">
<div className="text-2xl mb-2"></div>
<div className="text-[10px] text-ink-mid mb-1">No schedules yet</div>
<div className="text-[9px] text-ink-mid">
<div className="text-[9px] text-ink-soft">
Add a schedule to run tasks automatically daily scans, periodic reports, standup reminders.
</div>
</div>
@@ -336,16 +336,16 @@ export function ScheduleTab({ workspaceId }: Props) {
{sched.name || "Unnamed schedule"}
</span>
</div>
<div className="text-[9px] text-ink-mid mt-0.5 font-mono">
<div className="text-[9px] text-ink-soft mt-0.5 font-mono">
{cronToHuman(sched.cron_expr)}
{sched.timezone !== "UTC" && (
<span className="text-ink-mid"> ({sched.timezone})</span>
<span className="text-ink-soft"> ({sched.timezone})</span>
)}
</div>
<div className="text-[9px] text-ink-mid mt-0.5 truncate">
<div className="text-[9px] text-ink-soft mt-0.5 truncate">
{sched.prompt.slice(0, 80)}{sched.prompt.length > 80 ? "..." : ""}
</div>
<div className="flex items-center gap-3 mt-1 text-[8px] text-ink-mid">
<div className="flex items-center gap-3 mt-1 text-[8px] text-ink-soft">
<span>Last: {relativeTime(sched.last_run_at)}</span>
<span>Next: {relativeTime(sched.next_run_at)}</span>
<span>Runs: {sched.run_count}</span>
+22 -22
View File
@@ -320,7 +320,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
aria-label="Plugins (none installed)"
>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Plugins</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Plugins</span>
<span className="text-[11px] text-ink-mid">0 installed</span>
</div>
<button
@@ -342,7 +342,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div id="plugins-section" className="rounded-xl border border-line bg-surface-sunken/70 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-mid">Plugins</div>
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Plugins</div>
<h3 className="mt-1 text-sm font-semibold text-ink">
{installed.length} installed
</h3>
@@ -379,21 +379,21 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-ink">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>}
{p.version && <span className="text-[10px] text-ink-soft">v{p.version}</span>}
{inert && (
<span className="rounded-full border border-amber-700/50 bg-amber-950/30 px-1.5 py-0.5 text-[10px] text-warm">
inert on this runtime
</span>
)}
</div>
{p.description && <div className="text-[10px] text-ink-mid truncate">{p.description}</div>}
{p.description && <div className="text-[10px] text-ink-soft truncate">{p.description}</div>}
{p.skills && p.skills.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.skills.slice(0, 4).map((s) => (
<span key={s} className="rounded-full bg-surface-card/60 px-1.5 py-0.5 text-[10px] text-ink-mid">{s}</span>
))}
{p.skills.length > 4 && (
<span className="text-[10px] text-ink-mid">+{p.skills.length - 4}</span>
<span className="text-[10px] text-ink-soft">+{p.skills.length - 4}</span>
)}
</div>
)}
@@ -417,7 +417,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{/* Install from any source (github://, clawhub://, …) */}
<div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5">
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">
Install from source
</div>
{sourceSchemes.length > 0 && (
@@ -425,7 +425,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{sourceSchemes.map((s) => (
<span
key={s}
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-mid"
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-soft"
>
{s}://
</span>
@@ -444,7 +444,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
}}
placeholder="e.g. github://owner/repo#v1.0"
spellCheck={false}
className="flex-1 rounded border border-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-mid focus:outline-none focus:border-violet-600 focus-visible:ring-2 focus-visible:ring-violet-600/50"
className="flex-1 rounded border border-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-soft focus:outline-none focus:border-violet-600 focus-visible:ring-2 focus-visible:ring-violet-600/50"
/>
<button
onClick={handleInstallCustom}
@@ -454,12 +454,12 @@ export function SkillsTab({ workspaceId, data }: Props) {
{installing === customSource.trim() ? "Installing..." : "Install"}
</button>
</div>
<div className="mt-1 text-[10px] text-ink-mid">
<div className="mt-1 text-[10px] text-ink-soft">
Local registry plugins below; paste any scheme URL above for GitHub or other sources.
</div>
</div>
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Available plugins</div>
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Available plugins</div>
{/* Retry visible whenever registry is empty — including
the loading state — so a stuck fetch (Fast Refresh
stranded promise, slow server, browser quirk) has a
@@ -486,21 +486,21 @@ export function SkillsTab({ workspaceId, data }: Props) {
)}
</div>
{registryLoading && registry.length === 0 ? (
<div className="text-[10px] text-ink-mid">Loading registry</div>
<div className="text-[10px] text-ink-soft">Loading registry</div>
) : registryError ? (
<div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
<div className="text-[10px] text-bad font-semibold mb-0.5">
Couldn't load the plugin registry
</div>
<div className="text-[10px] text-bad/80">{registryError}</div>
<div className="mt-1 text-[10px] text-ink-mid">
<div className="mt-1 text-[10px] text-ink-soft">
Check the platform server is reachable at /plugins. The Retry button is in the header above.
</div>
</div>
) : registry.length === 0 ? (
<div className="rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5">
<div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div>
<div className="text-[10px] text-ink-mid">
<div className="text-[10px] text-ink-soft">
This usually means the platform's plugins/ directory is empty.
Run scripts/clone-manifest.sh to populate it from the standalone repos.
</div>
@@ -514,13 +514,13 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11px] text-ink-mid">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>}
{p.version && <span className="text-[10px] text-ink-soft">v{p.version}</span>}
</div>
{p.description && <div className="text-[10px] text-ink-mid truncate">{p.description}</div>}
{p.description && <div className="text-[10px] text-ink-soft truncate">{p.description}</div>}
{p.tags && p.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.tags.map((t) => (
<span key={t} className="rounded-full border border-line/40 px-1.5 py-0.5 text-[10px] text-ink-mid">{t}</span>
<span key={t} className="rounded-full border border-line/40 px-1.5 py-0.5 text-[10px] text-ink-soft">{t}</span>
))}
</div>
)}
@@ -556,7 +556,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="rounded-xl border border-line bg-surface-sunken/70 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-mid">Workspace skills</div>
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Workspace skills</div>
<h3 className="mt-1 text-sm font-semibold text-ink">Installed skills</h3>
</div>
<div className="flex flex-wrap gap-2">
@@ -564,7 +564,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
</div>
</div>
<p className="mt-2 text-[11px] leading-5 text-ink-mid">
<p className="mt-2 text-[11px] leading-5 text-ink-soft">
Live skill directory from the Agent Card updates when the workspace hot-reloads skills.
</p>
<div className="mt-3 flex flex-wrap gap-2">
@@ -593,7 +593,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{skills.length === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-surface-sunken/40 p-6 text-center">
<div className="text-sm text-ink">No skills loaded</div>
<p className="mt-2 text-[11px] leading-5 text-ink-mid">
<p className="mt-2 text-[11px] leading-5 text-ink-soft">
Add skills from the Config tab, install a plugin above, or let the runtime hot-load them.
</p>
</div>
@@ -604,7 +604,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold text-ink">{skill.name}</div>
<div className="mt-0.5 text-[10px] font-mono text-ink-mid">{skill.id}</div>
<div className="mt-0.5 text-[10px] font-mono text-ink-soft">{skill.id}</div>
</div>
{skill.tags.length > 0 && (
<div className="flex flex-wrap justify-end gap-1.5">
@@ -626,7 +626,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
{skill.examples.length > 0 && (
<div className="mt-2">
<div className="text-[9px] uppercase tracking-[0.2em] text-ink-mid">Examples</div>
<div className="text-[9px] uppercase tracking-[0.2em] text-ink-soft">Examples</div>
<div className="mt-1 space-y-1">
{skill.examples.slice(0, 2).map((example, index) => (
<div
@@ -666,7 +666,7 @@ function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[]
function MetaPill({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-line/60 bg-surface/60 px-2 py-1 text-[9px] text-ink-mid">
<span className="uppercase tracking-[0.18em] text-[8px] text-ink-mid">{label}</span>
<span className="uppercase tracking-[0.18em] text-[8px] text-ink-soft">{label}</span>
<span className="font-medium">{value}</span>
</span>
);
+2 -2
View File
@@ -37,7 +37,7 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
viewBox="0 0 72 72"
fill="none"
aria-hidden="true"
className="text-ink-mid mb-4"
className="text-ink-soft mb-4"
>
<rect
x="10"
@@ -74,7 +74,7 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
/>
</svg>
<h3 className="text-sm font-medium text-ink mb-1.5">Terminal not available</h3>
<p className="text-[11px] text-ink-mid max-w-xs leading-relaxed">
<p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
This workspace runs the{" "}
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
which doesn't expose a shell. Use the Chat tab to interact with the
+13 -13
View File
@@ -48,7 +48,7 @@ export function TracesTab({ workspaceId }: Props) {
}, [loadTraces]);
if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading traces...</div>;
return <div className="p-4 text-xs text-ink-soft">Loading traces...</div>;
}
return (
@@ -60,7 +60,7 @@ export function TracesTab({ workspaceId }: Props) {
onClick={loadTraces}
// Added focus-visible ring; previous version was hover-only,
// invisible to keyboard users.
className="text-[10px] text-ink-mid hover:text-ink-mid rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
className="text-[10px] text-ink-soft hover:text-ink-mid rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
Refresh
</button>
@@ -75,9 +75,9 @@ export function TracesTab({ workspaceId }: Props) {
{traces.length === 0 && !error ? (
<div className="text-center py-8">
<div className="text-2xl opacity-20 mb-2" aria-hidden="true">--</div>
<p className="text-xs text-ink-mid">No traces yet</p>
<details className="mt-2 text-[10px] text-ink-mid">
<summary className="cursor-pointer text-ink-mid hover:text-ink-mid">How to enable tracing</summary>
<p className="text-xs text-ink-soft">No traces yet</p>
<details className="mt-2 text-[10px] text-ink-soft">
<summary className="cursor-pointer text-ink-soft hover:text-ink-mid">How to enable tracing</summary>
<p className="mt-1">
Set <code className="font-mono text-ink-mid">LANGFUSE_HOST</code>, <code className="font-mono text-ink-mid">LANGFUSE_PUBLIC_KEY</code>, <code className="font-mono text-ink-mid">LANGFUSE_SECRET_KEY</code> as workspace secrets to enable tracing.
</p>
@@ -108,20 +108,20 @@ export function TracesTab({ workspaceId }: Props) {
}`} />
<div className="flex-1 min-w-0">
<div className="text-[11px] text-ink truncate">{trace.name || "trace"}</div>
<div className="text-[9px] text-ink-mid">{formatTime(trace.timestamp)}</div>
<div className="text-[9px] text-ink-soft">{formatTime(trace.timestamp)}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{trace.latency != null && (
<span className="text-[9px] text-ink-mid tabular-nums">
<span className="text-[9px] text-ink-soft tabular-nums">
{trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`}
</span>
)}
{trace.usage?.total != null && (
<span className="text-[9px] text-ink-mid tabular-nums">
<span className="text-[9px] text-ink-soft tabular-nums">
{trace.usage.total} tok
</span>
)}
<span aria-hidden="true" className="text-[9px] text-ink-mid">
<span aria-hidden="true" className="text-[9px] text-ink-soft">
{isOpen ? "▼" : "▶"}
</span>
</div>
@@ -131,7 +131,7 @@ export function TracesTab({ workspaceId }: Props) {
<div id={panelId} className="px-3 pb-2 space-y-2 border-t border-line/30">
{trace.input && (
<div>
<div className="text-[9px] text-ink-mid uppercase tracking-wider mt-2 mb-1">Input</div>
<div className="text-[9px] text-ink-soft uppercase tracking-wider mt-2 mb-1">Input</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
</pre>
@@ -139,18 +139,18 @@ export function TracesTab({ workspaceId }: Props) {
)}
{trace.output && (
<div>
<div className="text-[9px] text-ink-mid uppercase tracking-wider mb-1">Output</div>
<div className="text-[9px] text-ink-soft uppercase tracking-wider mb-1">Output</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
</pre>
</div>
)}
{trace.totalCost != null && (
<div className="text-[9px] text-ink-mid">
<div className="text-[9px] text-ink-soft">
Cost: ${trace.totalCost.toFixed(6)}
</div>
)}
<div className="text-[8px] text-ink-mid font-mono select-all">
<div className="text-[8px] text-ink-soft font-mono select-all">
{trace.id}
</div>
</div>
@@ -1,323 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import React from "react";
import { BudgetSection } from "../BudgetSection";
import { api } from "@/lib/api";
// Queue-based mock for the api module. Each api call shifts from the queue.
// Tests push with qGet/qPatch and the module-level mockImplementation
// reads from the queue.
type QueueEntry = { body?: unknown; err?: Error };
const apiQueue: QueueEntry[] = [];
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(async (_path: string) => {
const next = apiQueue.shift();
if (!next) throw new Error("api.get queue exhausted");
if (next.err) throw next.err;
return next.body;
}),
patch: vi.fn(async (_path: string, _body?: unknown) => {
const next = apiQueue.shift();
if (!next) throw new Error("api.patch queue exhausted");
if (next.err) throw next.err;
return next.body;
}),
},
}));
afterEach(cleanup);
beforeEach(() => {
apiQueue.length = 0;
vi.clearAllMocks();
});
const WS_ID = "budget-test-ws";
function qGet(body: unknown) {
apiQueue.push({ body });
}
function qGetErr(status: number, msg: string) {
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
}
function qPatch(body: unknown) {
apiQueue.push({ body });
}
function qPatchErr(status: number, msg: string) {
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
}
function makeBudget(overrides: Partial<{
budget_limit: number | null;
budget_used: number;
budget_remaining: number | null;
}> = {}) {
return {
budget_limit: 10_000,
budget_used: 3_500,
budget_remaining: 6_500,
...overrides,
};
}
describe("BudgetSection", () => {
describe("loading state", () => {
it("shows loading indicator while fetching", async () => {
let resolveGet: (v: unknown) => void;
vi.mocked(api.get).mockImplementationOnce(
async () => new Promise((r) => { resolveGet = r as (v: unknown) => void; }),
);
render(<BudgetSection workspaceId={WS_ID} />);
expect(screen.getByTestId("budget-loading")).toBeTruthy();
resolveGet!(makeBudget());
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-loading")).toBeNull();
});
});
});
describe("fetch error state", () => {
it("shows error message on non-402 fetch failure", async () => {
qGetErr(500, "Internal Server Error");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
});
expect(screen.getByTestId("budget-fetch-error")!.textContent).toContain("500");
});
it("shows 402 as exceeded banner, not fetch error", async () => {
qGetErr(402, "Payment Required");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
expect(screen.queryByTestId("budget-fetch-error")).toBeNull();
});
});
describe("budget loaded — display", () => {
it("renders used / limit stats row", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-used-value")!.textContent).toBe("3,500");
});
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
});
it("renders 'Unlimited' when budget_limit is null", async () => {
qGet(makeBudget({ budget_limit: null, budget_used: 1_000, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("Unlimited");
});
});
it("renders remaining credits when present", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: 6_500 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("6,500");
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("credits remaining");
});
});
it("omits remaining credits when budget_remaining is null", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-remaining")).toBeNull();
});
});
it("caps progress bar at 100% when used > limit", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 12_000, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
const fill = screen.getByTestId("budget-progress-fill");
expect(fill.getAttribute("style")).toContain("100%");
});
});
it("omits progress bar when budget_limit is null (unlimited)", async () => {
qGet(makeBudget({ budget_limit: null, budget_used: 5_000, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-progress-fill")).toBeNull();
});
});
});
describe("budget exceeded (402)", () => {
it("shows exceeded banner when load returns 402", async () => {
qGetErr(402, "Payment Required");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
expect(screen.getByTestId("budget-exceeded-banner")!.textContent).toContain("Budget exceeded");
});
});
it("clears exceeded banner after successful save", async () => {
qGetErr(402, "Payment Required");
qPatch(makeBudget({ budget_limit: 50_000, budget_used: 0, budget_remaining: 50_000 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
const input = screen.getByTestId("budget-limit-input");
fireEvent.change(input, { target: { value: "50000" } });
const saveBtn = screen.getByTestId("budget-save-btn");
fireEvent.click(saveBtn);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
});
});
});
describe("save flow", () => {
it("shows save error on non-402 patch failure", async () => {
qGet(makeBudget());
qPatchErr(500, "Internal Server Error");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
});
const saveBtn = screen.getByTestId("budget-save-btn");
fireEvent.click(saveBtn);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-save-error")).toBeTruthy();
expect(screen.getByTestId("budget-save-error")!.textContent).toContain("500");
});
});
it("updates input to new limit value after successful save", async () => {
qGet(makeBudget({ budget_limit: 10_000 }));
qPatch(makeBudget({ budget_limit: 20_000 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-loading")).toBeNull();
});
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
expect(input.value).toBe("10000");
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
fireEvent.change(input, { target: { value: "20000" } });
expect(input.value).toBe("20000");
fireEvent.click(screen.getByTestId("budget-save-btn"));
await vi.waitFor(() => {
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("20000");
});
});
it("sends null when input is cleared (unlimited)", async () => {
qGet(makeBudget({ budget_limit: 10_000 }));
qPatch(makeBudget({ budget_limit: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
});
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
fireEvent.click(screen.getByTestId("budget-save-btn"));
await vi.waitFor(() => {
expect(input.value).toBe("");
});
});
it("shows saving state on button while patch is in flight", async () => {
qGet(makeBudget());
let resolvePatch: (v: unknown) => void;
vi.mocked(api.patch).mockImplementationOnce(
async () => new Promise((r) => { resolvePatch = r as (v: unknown) => void; }),
);
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
});
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "50000" } });
fireEvent.click(screen.getByTestId("budget-save-btn"));
const btn = screen.getByTestId("budget-save-btn");
expect(btn.textContent).toContain("Saving");
resolvePatch!(makeBudget({ budget_limit: 50_000 }));
await vi.waitFor(() => {
expect(btn.textContent).toContain("Save");
});
});
});
describe("isApiError402 — regression coverage", () => {
it("classifies ': 402' with space as 402", async () => {
qGetErr(402, "Payment Required");
qPatch(makeBudget());
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
});
it("classifies non-402 error messages as regular fetch errors", async () => {
qGetErr(503, "Service Unavailable");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
});
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
});
});
});
@@ -1,726 +0,0 @@
// @vitest-environment jsdom
/**
* MemoryTab — 42 test cases covering awareness dashboard, KV memory CRUD,
* and error states.
*
* Issue #519: Add 42 test cases for MemoryTab (42 cases).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
fireEvent,
cleanup,
act,
} from "@testing-library/react";
import React from "react";
// ── Module-level mocks ────────────────────────────────────────────────────────
// Mock @/lib/env before MemoryTab loads so it sees the stub values.
vi.mock("@/lib/env", () => ({
NEXT_PUBLIC_AWARENESS_URL: "http://localhost:37800",
}));
// Mock @/lib/api at module level. vi.hoisted() captures the mock function
// references so they are accessible in the test scope after hoisting.
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown[]>>());
const _mockPost = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
const _mockDel = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: {
get: _mockGet,
post: _mockPost,
del: _mockDel,
},
}));
// Stub window.open so tests don't actually open a window.
const _windowOpen = vi.fn();
vi.stubGlobal("window", {
...window,
open: _windowOpen,
});
import { MemoryTab } from "../MemoryTab";
import { api } from "@/lib/api";
const WS_ID = "ws-test-123";
const MEMORY_ENTRY: Record<string, unknown> = {
key: "user-preference",
value: { theme: "dark", language: "en" },
version: 1,
expires_at: null,
updated_at: "2026-04-15T10:00:00Z",
};
const MEMORY_ENTRY_WITH_TTL: Record<string, unknown> = {
key: "session-token",
value: "abc123",
version: 3,
expires_at: new Date(Date.now() + 86_400_000).toISOString(),
updated_at: "2026-04-15T11:00:00Z",
};
const MEMORY_ENTRY_RAW_STRING: Record<string, unknown> = {
key: "plain-text",
value: "hello world",
version: 1,
expires_at: null,
updated_at: "2026-04-15T12:00:00Z",
};
// ── Setup / teardown ────────────────────────────────────────────────────────
beforeEach(() => {
// Reset all api mock functions to a clean default state between tests.
_mockGet.mockReset();
_mockGet.mockResolvedValue([] as unknown[]);
_mockPost.mockReset();
_mockPost.mockResolvedValue({} as unknown);
_mockDel.mockReset();
_mockDel.mockResolvedValue({} as unknown);
_windowOpen.mockClear();
});
afterEach(cleanup);
// ── Shared helpers ──────────────────────────────────────────────────────────
/**
* Render MemoryTab and reveal the entries list by clicking "Show".
* The component starts with showAdvanced=false (hidden mode); most entry-list
* tests need to click Show before entries appear.
*
* Uses fireEvent.click directly on the button element (not the text span) to
* ensure React's onClick fires correctly.
*/
async function renderAndShowEntries() {
render(<MemoryTab workspaceId={WS_ID} />);
// Wait for the api.get mock to resolve and React to render with entries.
// 500ms gives enough time for useEffect → setEntries → re-render.
await new Promise((r) => setTimeout(r, 500));
fireEvent.click(screen.getByRole("button", { name: /show/i }));
}
/** Configure api.get to resolve with the given entries.
* Must be called BEFORE render() so the useEffect sees the mock. */
function stubMemoryFetch(entries: unknown[]) {
_mockGet.mockReset();
_mockGet.mockResolvedValue(entries as unknown[]);
}
/**
* Click the memory entry button to expand it.
* Uses filter-on-all-buttons to avoid getByRole's strict accessible-name
* matching (which can silently find the wrong element in dense DOM trees).
*/
function expandEntry(key: string) {
const allBtns = screen.getAllByRole("button");
const entryBtn = allBtns.find((b) => b.textContent?.includes(key));
if (!entryBtn) throw new Error(`expandEntry: no button found containing "${key}"`);
act(() => { fireEvent.click(entryBtn); });
}
// =============================================================================
// Awareness dashboard
// =============================================================================
describe("MemoryTab — awareness dashboard", () => {
it("shows awareness section on load", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByText("Awareness dashboard")).toBeTruthy();
});
it("renders iframe with correct src containing workspaceId", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
const iframe = (await screen.findByTitle(
"Awareness dashboard",
)) as HTMLIFrameElement;
expect(iframe.src).toContain("workspaceId=" + WS_ID);
});
it("collapse button hides iframe and shows collapsed state", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByTitle("Awareness dashboard")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
expect(
await screen.findByText(/awareness dashboard is collapsed/i),
).toBeTruthy();
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
});
it("collapsed state has expand button that re-shows iframe", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /collapse/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
// After collapse there are two "Expand" buttons (header + collapsed banner).
// Click the one inside the collapsed banner (last in DOM order).
const expandBtns = await screen.findAllByRole("button", { name: /^expand$/i });
fireEvent.click(expandBtns[expandBtns.length - 1]);
expect(await screen.findByTitle("Awareness dashboard")).toBeTruthy();
});
it("open button calls window.open with awarenessUrl", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /open/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /open/i }));
expect(_windowOpen).toHaveBeenCalledWith(
expect.stringContaining("workspaceId=" + WS_ID),
"_blank",
"noopener,noreferrer",
);
});
it("renders awareness status grid with Connected / Mode / Workspace", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByText("Connected")).toBeTruthy();
expect(await screen.findByText("Workspace")).toBeTruthy();
});
});
// =============================================================================
// Loading state
// =============================================================================
describe("MemoryTab — loading state", () => {
it("shows 'Loading memory...' while initial fetch is pending", () => {
_mockGet.mockReturnValue(new Promise(() => {}) as unknown as Promise<unknown[]>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(screen.getByText("Loading memory...")).toBeTruthy();
});
it("does not render memory section while loading", () => {
_mockGet.mockReturnValue(new Promise(() => {}) as unknown as Promise<unknown[]>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(screen.queryByText("Workspace KV memory")).toBeNull();
});
});
// =============================================================================
// KV memory — initial load
// =============================================================================
describe("MemoryTab — initial load", () => {
it("fetches memory entries on mount", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
// Reveal the entries list
expect(await screen.findByRole("button", { name: /show/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
expect(await screen.findByText("Workspace KV memory")).toBeTruthy();
expect(api.get).toHaveBeenCalledWith(`/workspaces/${WS_ID}/memory`);
});
it("renders workspace KV memory section heading", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
// Heading is visible in hidden mode (above the hidden banner)
expect(await screen.findByText("Workspace KV memory")).toBeTruthy();
});
it("shows advanced mode by default hidden; Refresh / Advanced / + Add buttons visible", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
// Hidden-mode banner is visible with a Show button
expect(
await screen.findByText("Advanced workspace memory is hidden"),
).toBeTruthy();
expect(await screen.findByRole("button", { name: /show/i })).toBeTruthy();
// Action buttons are still visible in the header
expect(await screen.findByRole("button", { name: /refresh/i })).toBeTruthy();
expect(await screen.findByRole("button", { name: /advanced/i })).toBeTruthy();
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
});
});
// =============================================================================
// KV memory — empty state
// =============================================================================
describe("MemoryTab — empty state", () => {
it("shows 'No memory entries' when entries array is empty (after Show)", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
// Click Show to reveal entries list (advanced mode is hidden by default)
fireEvent.click(await screen.findByRole("button", { name: /show/i }));
expect(await screen.findByText("No memory entries")).toBeTruthy();
});
it("hidden mode shows 'Advanced workspace memory is hidden' message", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(
await screen.findByText("Advanced workspace memory is hidden"),
).toBeTruthy();
});
});
// =============================================================================
// KV memory — list rendering
// =============================================================================
describe("MemoryTab — list rendering", () => {
it("renders a memory entry key in accent/mono text", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
});
it("expands an entry on click showing the value as pretty JSON", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(
await screen.findByText(/"theme":\s*"dark".*?"language":\s*"en"/),
).toBeTruthy();
});
it("shows raw string value without extra quotes when value is plain string", async () => {
stubMemoryFetch([MEMORY_ENTRY_RAW_STRING]);
await renderAndShowEntries();
expect(await screen.findByText("plain-text")).toBeTruthy();
expandEntry("plain-text");
expect(await screen.findByText(/"hello world"/)).toBeTruthy();
});
it("renders updated_at timestamp when entry is expanded", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
});
it("shows TTL badge when entry has expires_at", async () => {
stubMemoryFetch([MEMORY_ENTRY_WITH_TTL]);
await renderAndShowEntries();
expect(await screen.findByText("session-token")).toBeTruthy();
expandEntry("session-token");
expect(await screen.findByText(/ttl/i)).toBeTruthy();
});
it("collapse toggle hides the expanded content", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/Updated:/i)).toBeTruthy();
expandEntry("user-preference");
expect(screen.queryByText(/Updated:/i)).toBeNull();
});
});
// =============================================================================
// KV memory — advanced mode toggle
// =============================================================================
describe("MemoryTab — advanced mode toggle", () => {
it("clicking Advanced hides the list and shows 'hidden' placeholder", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
expect(
await screen.findByText("Advanced workspace memory is hidden"),
).toBeTruthy();
expect(screen.queryByText("user-preference")).toBeNull();
});
it("clicking Show from hidden mode re-displays the list", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
// Hide via Advanced button
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
expect(await screen.findByText("Advanced workspace memory is hidden")).toBeTruthy();
// Reveal again
fireEvent.click(screen.getByRole("button", { name: /show/i }));
expect(await screen.findByText("user-preference")).toBeTruthy();
});
it("Hide Advanced button appears when in hidden mode", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
// renderAndShowEntries sets showAdvanced=true, so button says "Hide Advanced".
// Click "Hide Advanced" to toggle back to hidden mode.
fireEvent.click(screen.getByRole("button", { name: /hide advanced/i }));
expect(
await screen.findByText("Advanced workspace memory is hidden"),
).toBeTruthy();
});
});
// =============================================================================
// KV memory — Add entry
// =============================================================================
describe("MemoryTab — add entry", () => {
it("clicking + Add shows the add form", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
expect(await screen.findByLabelText(/memory value/i)).toBeTruthy();
});
it("add form requires a non-empty key", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText("Key is required")).toBeTruthy();
expect(api.post).not.toHaveBeenCalled();
});
it("add form parses plain text value as-is (not JSON)", async () => {
stubMemoryFetch([]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "my-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "plain text value" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(api.post).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory`,
expect.objectContaining({ key: "my-key", value: "plain text value" }),
);
});
it("add form parses JSON value when valid JSON is entered", async () => {
stubMemoryFetch([]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "json-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: '{"foo": 123}' },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(api.post).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory`,
expect.objectContaining({ key: "json-key", value: { foo: 123 } }),
);
});
it("add form accepts optional TTL", async () => {
stubMemoryFetch([]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
// aria-label is "TTL in seconds (optional)"
expect(await screen.findByLabelText("TTL in seconds (optional)")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "ttl-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "val" },
});
fireEvent.change(screen.getByLabelText("TTL in seconds (optional)"), {
target: { value: "3600" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(api.post).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory`,
expect.objectContaining({
key: "ttl-key",
value: "val",
ttl_seconds: 3600,
}),
);
});
it("successful add clears the form and closes it", async () => {
stubMemoryFetch([]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "new-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "new-val" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
// Form should close
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
expect(screen.queryByLabelText("Memory key")).toBeNull();
});
it("add failure shows error in the add form", async () => {
stubMemoryFetch([]);
_mockPost.mockRejectedValueOnce(new Error("server error"));
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "bad-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "val" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText("server error")).toBeTruthy();
});
it("cancel button closes the add form without posting", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(screen.queryByLabelText("Memory key")).toBeNull();
expect(api.post).not.toHaveBeenCalled();
});
});
// =============================================================================
// KV memory — Edit entry
// =============================================================================
describe("MemoryTab — edit entry", () => {
// TEMP inline debug
it("DEBUG check expandEntry via expandEntry function", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
const btns = screen.getAllByRole("button");
console.log("All button texts:", btns.map(b => b.textContent));
const match = btns.find(b => b.textContent?.includes("user-preference"));
console.log("Found button:", match?.textContent, "aria-expanded:", match?.getAttribute("aria-expanded"));
expandEntry("user-preference");
console.log("After expandEntry aria-expanded:", match?.getAttribute("aria-expanded"));
expect(await screen.findByText(/updated:/i)).toBeTruthy();
});
it("clicking Edit on an expanded entry switches to edit mode", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
// Expand shows "Updated:" + Edit/Delete buttons; click Edit to enter edit mode.
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
expect(await screen.findByLabelText(/edit ttl/i)).toBeTruthy();
});
it("edit form pre-populates with current value (pretty JSON for objects)", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
const textarea = screen.getByLabelText(/edit value/i) as HTMLTextAreaElement;
expect(textarea.value).toContain("theme");
expect(textarea.value).toContain("dark");
});
it("edit form pre-populates raw string value without surrounding quotes", async () => {
stubMemoryFetch([MEMORY_ENTRY_RAW_STRING]);
await renderAndShowEntries();
expect(await screen.findByText("plain-text")).toBeTruthy();
expandEntry("plain-text");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
const textarea = screen.getByLabelText(/edit value/i) as HTMLTextAreaElement;
expect(textarea.value).toBe("hello world");
});
it("Save calls POST with the new value and if_match_version", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
fireEvent.change(screen.getByLabelText(/edit value/i), {
target: { value: '{"theme": "light"}' },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(api.post).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory`,
expect.objectContaining({
key: "user-preference",
value: { theme: "light" },
if_match_version: 1,
}),
);
});
it("409 conflict shows retry hint and reloads entry", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockPost.mockRejectedValueOnce(
Object.assign(new Error("409 Conflict"), { status: 409 }),
);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(
await screen.findByText(/this entry changed since you opened it/i),
).toBeTruthy();
});
it("cancel button exits edit mode without posting", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(await screen.findByText(/"theme":/)).toBeTruthy();
expect(api.post).not.toHaveBeenCalled();
});
});
// =============================================================================
// KV memory — Delete entry
// =============================================================================
describe("MemoryTab — delete entry", () => {
it("clicking Delete optimistically removes entry from list", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockDel.mockResolvedValueOnce({} as unknown as Promise<unknown>);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
act(() => {
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Delete",
);
if (deleteBtn) fireEvent.click(deleteBtn);
});
await new Promise(r => setTimeout(r, 300));
expect(screen.queryByText("user-preference")).toBeNull();
});
it("Delete calls DEL with correct path", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockDel.mockResolvedValueOnce({} as unknown as Promise<unknown>);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
expect(api.del).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory/${encodeURIComponent("user-preference")}`,
);
});
it("Delete failure does NOT remove entry from list", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockDel.mockRejectedValueOnce(new Error("forbidden"));
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
expect(await screen.findByText("user-preference")).toBeTruthy();
});
it("Delete clears expanded state when deleting the expanded entry", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockDel.mockResolvedValueOnce({} as unknown as Promise<unknown>);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
act(() => {
// Re-query inside flush so we get post-expansion buttons
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Delete",
);
if (deleteBtn) fireEvent.click(deleteBtn);
});
await new Promise(r => setTimeout(r, 300));
expect(screen.queryByText("user-preference")).toBeNull();
});
});
// =============================================================================
// KV memory — Refresh
// =============================================================================
describe("MemoryTab — refresh", () => {
it("Refresh button re-fetches memory entries", async () => {
const first = [{ key: "a", value: "1", updated_at: "2026-01-01T00:00:00Z" }];
const second = [
...first,
{ key: "b", value: "2", updated_at: "2026-01-01T00:00:00Z" },
];
// Chain two resolved values: first for initial mount, second for Refresh click.
// Do NOT call renderAndShowEntries (which calls stubMemoryFetch and resets the chain).
_mockGet
.mockResolvedValueOnce(first as unknown[])
.mockResolvedValueOnce(second as unknown[]);
render(<MemoryTab workspaceId={WS_ID} />);
await new Promise((r) => setTimeout(r, 500));
fireEvent.click(screen.getByRole("button", { name: /show/i }));
expect(await screen.findByText("a")).toBeTruthy();
expect(screen.queryByText("b")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
expect(await screen.findByText("b")).toBeTruthy();
});
});
// =============================================================================
// Error states
// =============================================================================
describe("MemoryTab — error states", () => {
it("shows error banner when initial fetch fails", async () => {
_mockGet.mockRejectedValueOnce(new Error("internal server error"));
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByText("internal server error")).toBeTruthy();
});
it("error is shown in the form when add fails, not as a top-level banner", async () => {
stubMemoryFetch([]);
_mockPost.mockRejectedValueOnce(new Error("add failed"));
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "k" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "v" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText("add failed")).toBeTruthy();
});
});
@@ -389,7 +389,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
}, [messages]);
if (loading) {
return <div className="text-xs text-ink-mid text-center py-8">Loading agent communications...</div>;
return <div className="text-xs text-ink-soft text-center py-8">Loading agent communications...</div>;
}
if (loadError !== null && messages.length === 0) {
@@ -415,10 +415,10 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
if (messages.length === 0) {
return (
<div className="text-xs text-ink-mid text-center py-8">
<div className="text-xs text-ink-soft text-center py-8">
No agent-to-agent communications yet.
<br />
<span className="text-ink-mid">Delegations and peer messages will appear here.</span>
<span className="text-ink-soft">Delegations and peer messages will appear here.</span>
</div>
);
}
@@ -613,10 +613,10 @@ function PeerTabButton({
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${
active
? "border-b-2 border-cyan-500 text-cyan-200"
: "border-b-2 border-transparent text-ink-mid hover:text-ink-mid"
: "border-b-2 border-transparent text-ink-soft hover:text-ink-mid"
}`}
>
{label} <span className="text-[9px] text-ink-mid">({count})</span>
{label} <span className="text-[9px] text-ink-soft">({count})</span>
</button>
);
}
@@ -669,7 +669,7 @@ function WaitingBubbles({ visible }: { visible: CommMessage[] }) {
role="status"
aria-label={`Waiting for reply from ${m.peerName}`}
>
<div className="text-[9px] text-ink-mid mb-1"> To {m.peerName}</div>
<div className="text-[9px] text-ink-soft mb-1"> To {m.peerName}</div>
<span className="flex items-center gap-2 text-ink-mid">
<span className="flex gap-0.5" aria-hidden="true">
<span
@@ -708,7 +708,7 @@ function NormalMessage({ msg }: { msg: CommMessage }) {
: "bg-surface-card/80 text-ink border border-line/30"
}`}
>
<div className="text-[9px] text-ink-mid mb-1">
<div className="text-[9px] text-ink-soft mb-1">
{msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
</div>
{msg.text ? (
@@ -731,7 +731,7 @@ function NormalMessage({ msg }: { msg: CommMessage }) {
{msg.responseText}
</MarkdownBody>
)}
<div className="text-[9px] text-ink-mid mt-1">
<div className="text-[9px] text-ink-soft mt-1">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
@@ -804,7 +804,7 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
</div>
{msg.text && (
<div className="text-[10px] text-ink-mid mb-1.5">
<div className="text-[10px] text-ink-soft mb-1.5">
<span className="uppercase tracking-wide">Task</span>
<MarkdownBody className="text-ink-mid">{msg.text}</MarkdownBody>
</div>
@@ -841,7 +841,7 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
</div>
)}
<div className="text-[9px] text-ink-mid mt-1.5">
<div className="text-[9px] text-ink-soft mt-1.5">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
@@ -148,7 +148,7 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
<button
type="button"
onClick={() => onDownload(attachment)}
className="text-ink-mid hover:text-ink"
className="text-ink-soft hover:text-ink"
title={`Download ${attachment.name}`}
aria-label={`Download ${attachment.name}`}
>
@@ -29,11 +29,11 @@ export function PendingAttachmentPill({
<div className="flex items-center gap-1.5 rounded-md border border-line/60 bg-surface-card/80 px-2 py-1 text-[10px] text-ink-mid max-w-[200px]">
<FileGlyph className="text-ink-mid shrink-0" />
<span className="truncate" title={file.name}>{file.name}</span>
<span className="text-ink-mid shrink-0 tabular-nums">{formatSize(file.size)}</span>
<span className="text-ink-soft shrink-0 tabular-nums">{formatSize(file.size)}</span>
<button
onClick={onRemove}
aria-label={`Remove ${file.name}`}
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0"
className="ml-0.5 text-ink-soft hover:text-ink transition-colors shrink-0"
>
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
@@ -1,245 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AttachmentLightbox — shared fullscreen modal for image/PDF
* fullscreen viewing.
*
* Covers: open/close rendering, backdrop click-to-close, Esc key close,
* role/dialog + aria attributes, close button, prefers-reduced-motion.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AttachmentLightbox } from "../AttachmentLightbox";
afterEach(cleanup);
describe("AttachmentLightbox", () => {
describe("renders nothing when closed", () => {
it("returns null when open=false", () => {
const { container } = render(
<AttachmentLightbox open={false} onClose={vi.fn()} ariaLabel="Image preview">
<img src="test.jpg" alt="test" />
</AttachmentLightbox>
);
expect(container.textContent).toBe("");
});
});
describe("renders modal when open", () => {
it("renders the dialog when open=true", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Image preview">
<img src="test.jpg" alt="test" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("renders the provided children", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="PDF preview">
<embed src="doc.pdf" />
</AttachmentLightbox>
);
expect(document.querySelector("embed")).toBeTruthy();
});
it("has aria-modal=true", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
});
it("uses the provided ariaLabel", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="My document">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog").getAttribute("aria-label")).toBe("My document");
});
it("renders the close button", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("button", { name: /close preview/i })).toBeTruthy();
});
it("close button renders an SVG icon", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
const btn = screen.getByRole("button", { name: /close preview/i });
expect(btn.querySelector("svg")).toBeTruthy();
});
});
describe("Esc to close", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("calls onClose when Escape is pressed", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
act(() => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not call onClose for non-Escape keys", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
act(() => {
fireEvent.keyDown(document, { key: "Enter" });
});
expect(onClose).not.toHaveBeenCalled();
});
it("does not call onClose when closed (open=false)", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={false} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
act(() => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(onClose).not.toHaveBeenCalled();
});
});
describe("backdrop click to close", () => {
it("calls onClose when backdrop is clicked", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
const dialog = screen.getByRole("dialog");
fireEvent.click(dialog);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not call onClose when content area is clicked", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
// The content is nested inside the dialog — clicking the inner content
// div should not close because it has stopPropagation
const content = document.querySelector(".max-w-\\[95vw\\]") as HTMLElement;
if (content) {
fireEvent.click(content);
}
expect(onClose).not.toHaveBeenCalled();
});
it("does not call onClose when close button is clicked", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
fireEvent.click(screen.getByRole("button", { name: /close preview/i }));
// onClose is NOT called for button click — the button's onClick handles
// close directly. Only backdrop click triggers onClose.
// (The component does not call onClose from the button; it calls setOpen(false)
// Actually, looking at the component: onClick={onClose} on the button too.
// So this test should expect onClose to be called.
// Wait — the close button's onClick calls onClose, and backdrop also calls onClose.
// Both should call onClose.
// Let me update this test.
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe("a11y", () => {
it("dialog has role=dialog", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("close button has accessible name", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("button", { name: /close preview/i })).toBeTruthy();
});
it("dialog has aria-label matching the provided label", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Quarterly Report Q1 2026">
<img src="report.jpg" alt="report" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog").getAttribute("aria-label")).toBe("Quarterly Report Q1 2026");
});
});
describe("motion", () => {
it("backdrop applies motion-reduce class for reduced motion preference", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("motion-reduce");
});
it("backdrop has transition-opacity for normal motion preference", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("transition-opacity");
});
});
});

Some files were not shown because too many files have changed in this diff Show More