Compare commits

..

3 Commits

Author SHA1 Message Date
core-devops 751c98ced7 fix(harness-replays): use branch names in Compare API + correct nested file extraction
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
sop-tier-check / tier-check (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 36s
CI / Detect changes (pull_request) Successful in 37s
E2E API Smoke Test / detect-changes (pull_request) Successful in 41s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 40s
CI / Platform (Go) (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 43s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Bug 1 (SRE review): Gitea Compare API rejects SHA pairs (BaseNotExist).
Fix: use base.ref/head.ref (branch names) instead of base.sha/head.sha.
For push events: extract branch name from GITHUB_REF.

Bug 2 (SRE review): Python extraction looked at d.get('files', [])
which is always empty — Gitea nests files inside commits[*]['files'].
Fix: extract from nested commits structure via list comprehension.

SRE verified the fix works:
  GET /compare/main...fix/harness-replays-detect-changes-gitea-api
  → commits[0]['files']: ['.gitea/workflows/harness-replays.yml'] ✓

Co-Authored-By: SRE review via infra-sre agent
2026-05-11 13:54:56 +00:00
core-devops 120d5328ba docs(runbooks): update gitea-operational-quirks with Compare API as primary fix
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 13s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Platform (Go) (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Add SRE's empirical corrections (PR #478): shallow fetch succeeds ~16s,
runner CAN reach git.moleculesai.app, full-history fetch times out due
to ~75MB repo size (not network isolation).

Also add Compare API (PR #476) as the primary recommended fix for
detect-changes git-fetch timeout, superseding the legacy timeout+fallback
approach documented in PR #441.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:50:12 +00:00
core-devops 798fcb1f33 fix(harness-replays): use Gitea Compare API instead of git diff for detect-changes
Replace the "Fetch base branch tip" step (git fetch that times out on
Gitea runners per runbooks/gitea-operational-quirks.md
§runner-network-isolation) and the git diff approach with a direct
Gitea Compare API call.

Before:
  1. git fetch origin base-ref --depth=1  ← times out on runner
  2. git diff BASE HEAD --name-only          ← fails without fetch

After:
  1. Call Gitea Compare API (Gitea→Gitea, no runner network needed)
  2. Parse JSON response for changed files
  3. Apply path filter

Also drops now-unnecessary fetch-depth:0 from the checkout step
and removes continue-on-error: true from the decide step (the
Compare API call is reliable from inside the Gitea Actions runner).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:48:42 +00:00
114 changed files with 738 additions and 16775 deletions
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env python3
"""Extract changed-file list from Gitea Compare API JSON response.
Gitea Compare API returns changed files nested inside commits, not at the
top level:
{"commits": [{"files": [{"filename": "path/to/file"}]}]}
Usage:
compare-api-diff-files.py < API_RESPONSE.json
Exits 0 with filenames on stdout, one per line.
Exits 1 on malformed input (caller should handle as "no files").
"""
from __future__ import annotations
import sys
import json
def main() -> None:
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(1)
filenames: list[str] = []
for commit in data.get("commits", []):
for f in commit.get("files", []):
fn = f.get("filename", "")
if fn:
filenames.append(fn)
if filenames:
sys.stdout.write("\n".join(filenames))
sys.stdout.write("\n")
# else: empty stdout = no files, caller treats as empty list
if __name__ == "__main__":
main()
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""Extract changed-file list from a Gitea push event's commits JSON array.
Each commit in a push event has `added`, `removed`, and `modified` file lists.
This script aggregates all of them and prints unique filenames one per line.
Usage:
push-commits-diff-files.py < COMMITS_JSON
Exits 0 always (caller handles empty output as "no files").
"""
from __future__ import annotations
import sys
import json
def main() -> None:
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(0) # Don't fail the step — treat malformed JSON as empty
if not isinstance(data, list):
sys.exit(0)
files: set[str] = set()
for commit in data:
if not isinstance(commit, dict):
continue
for key in ("added", "removed", "modified"):
for f in commit.get(key) or []:
if isinstance(f, str) and f:
files.add(f)
if files:
sys.stdout.write("\n".join(sorted(files)))
sys.stdout.write("\n")
if __name__ == "__main__":
main()
-203
View File
@@ -1,203 +0,0 @@
#!/usr/bin/env bash
# review-check — evaluate whether a PR satisfies a single team-review gate.
#
# RFC#324 Step 1 of 5 — qa-review + security-review check workflows.
#
# This is the shared evaluator invoked by:
# .gitea/workflows/qa-review.yml (TEAM=qa, TEAM_ID=20)
# .gitea/workflows/security-review.yml (TEAM=security, TEAM_ID=21)
#
# Pass condition (per RFC#324 v1.1 addendum):
# ≥ 1 review on the PR where:
# • state == APPROVED
# • review.dismissed == false
# • review.user.login != PR.user.login (non-author)
# • review.user.login ∈ team-members
#
# Strict mode (default OFF for v1; see RFC trade-off note):
# If REVIEW_CHECK_STRICT=1, additionally require review.commit_id == PR.head.sha.
# With dismiss_stale_reviews: true at the protection layer, stale reviews
# are already dismissed, so the additional commit_id check is belt-and-
# suspenders. Keeping it off in v1 simplifies semantics; flip in a follow-up
# PR if reviewer telemetry shows residual stale-APPROVE merges.
#
# Privilege gate (RFC#324 v1.3 §A1.1 — INFORMATIONAL ONLY):
# The /qa-recheck and /security-recheck slash-commands can be triggered
# by anyone who can comment on the PR. The workflow's privilege step
# logs collaborator-status but does NOT gate execution of this script.
# Why this is safe: this evaluator is read-only and idempotent —
# reading `pulls/{N}/reviews` and `teams/{id}/members/{u}` can't be
# influenced by who triggered the run. If a real team-member APPROVE
# exists the gate flips green; otherwise it stays red. A
# non-collaborator commenting /qa-recheck cannot manufacture a green
# gate. Original (v1.2) design with `if:`-gating of this step was
# fail-open (skipped-via-`if:` job still publishes the status as
# `success`) — corrected in v1.3 per hongming-pc review 1421.
#
# Trust boundary (RFC A4):
# This script is loaded from the BASE branch (sourced via .gitea/scripts/
# on the workflow's checkout-of-base). It does NOT execute any PR-HEAD
# code. It only reads PR review state via the Gitea API.
#
# Token scope (RFC A1-α):
# The job's own conclusion (exit 0 / exit 1) is what publishes the
# `qa-review / approved` / `security-review / approved` status context.
# NO `POST /statuses` call here → NO `write:repository` scope on the
# token. `read:organization` (for team-membership probe) and
# `read:repository` (for PR + reviews) are enough.
#
# Required env:
# GITEA_TOKEN — least-priv read:repository + read:organization. See note
# below about the team-membership API requiring the token
# owner to be in the queried team (Gitea 1.22.6 quirk).
# GITEA_HOST — e.g. git.moleculesai.app
# REPO — owner/name (from github.repository)
# PR_NUMBER — int (from github.event.pull_request.number or
# github.event.issue.number for issue_comment events)
# TEAM — short team name (qa | security) for log lines
# TEAM_ID — Gitea team id (20=qa, 21=security at time of writing)
#
# Optional:
# REVIEW_CHECK_DEBUG=1 — per-API-call diagnostic lines
# REVIEW_CHECK_STRICT=1 — also require review.commit_id == pr.head.sha
set -euo pipefail
# jq is required for JSON parsing. It is pre-baked into the runner-base
# image (per RFC#268 workflow-smoke), so the only reason we'd not find it
# is a broken runner. The previous fallback dance (apt-get + curl to
# /usr/local/bin/jq) cannot succeed on a uid-1001 rootless runner
# (#391/#402 + feedback_ci_runner_install_needs_writable_path), so it's
# dropped. Fail loud with a clear diagnostic rather than attempt an
# install that physically cannot work.
if ! command -v jq >/dev/null 2>&1; then
echo "::error::jq missing from runner-base image — bake it into the runner image (see RFC#268 workflow-smoke / feedback_ci_runner_install_needs_writable_path). This evaluator cannot run without jq."
exit 1
fi
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${GITEA_HOST:?GITEA_HOST required}"
: "${REPO:?REPO required (owner/name)}"
: "${PR_NUMBER:?PR_NUMBER required}"
: "${TEAM:?TEAM required (qa|security)}"
: "${TEAM_ID:?TEAM_ID required (integer)}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
# Token-in-argv fix (#541): write the Authorization header to a mode-600
# temp file instead of passing it via curl -H "$AUTH" (which puts the
# secret token value in the process table for any process to read via
# /proc/<pid>/cmdline or ps -ef). The curl config file is read by curl
# itself and never appears in the argv of the curl subprocess.
CURL_AUTH_FILE=$(mktemp -p /tmp curl-auth.XXXXXX)
chmod 600 "$CURL_AUTH_FILE"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
# Pre-create temp files so cleanup trap can reference them by name
# (bash trap 'function' EXIT expands variables at trap-fire time, not def time).
PR_JSON=$(mktemp)
REVIEWS_JSON=$(mktemp)
TEAM_PROBE_TMP=$(mktemp)
cleanup() {
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP"
}
trap cleanup EXIT
debug() {
if [ "${REVIEW_CHECK_DEBUG:-}" = "1" ]; then
echo " [debug] $*" >&2
fi
}
echo "::notice::${TEAM}-review evaluating repo=${OWNER}/${NAME} pr=${PR_NUMBER} team_id=${TEAM_ID}"
# --- Fetch the PR (for author + head.sha) ---
HTTP_CODE=$(curl -sS -o "$PR_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /pulls/${PR_NUMBER} returned HTTP ${HTTP_CODE} (token scope?)"
cat "$PR_JSON" >&2
exit 1
fi
PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON")
PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON")
PR_STATE=$(jq -r '.state // ""' "$PR_JSON")
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_state=${PR_STATE}"
if [ "$PR_STATE" != "open" ]; then
echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)"
exit 0
fi
if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then
echo "::error::PR ${PR_NUMBER} missing user.login or head.sha — webhook payload malformed"
exit 1
fi
# --- Fetch all reviews on the PR ---
HTTP_CODE=$(curl -sS -o "$REVIEWS_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /pulls/${PR_NUMBER}/reviews returned HTTP ${HTTP_CODE}"
cat "$REVIEWS_JSON" >&2
exit 1
fi
# Filter: state=APPROVED, not-dismissed, non-author. Optionally strict-mode
# adds commit_id==head.sha (off by default; see header).
JQ_FILTER='.[]
| select(.state == "APPROVED")
| select(.dismissed != true)
| select(.user.login != $author)'
if [ "${REVIEW_CHECK_STRICT:-}" = "1" ]; then
JQ_FILTER="${JQ_FILTER}
| select(.commit_id == \$head)"
fi
JQ_FILTER="${JQ_FILTER}
| .user.login"
CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILTER" "$REVIEWS_JSON" | sort -u)
debug "candidate non-author approvers: $(echo "$CANDIDATES" | tr '\n' ' ')"
if [ -z "$CANDIDATES" ]; then
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates yet)"
exit 1
fi
# --- Probe team membership per candidate ---
# Endpoint: GET /api/v1/teams/{id}/members/{username}
# 200/204 → is member
# 403 → token owner is not in this team (Gitea 1.22.6 'Must be a team
# member' constraint — see follow-up issue for token-provisioning)
# 404 → not a member
for U in $CANDIDATES; do
CODE=$(curl -sS -o "$TEAM_PROBE_TMP" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/teams/${TEAM_ID}/members/${U}")
debug "probe ${U} in team ${TEAM} (id=${TEAM_ID}) → HTTP ${CODE}"
case "$CODE" in
200|204)
echo "::notice::${TEAM}-review APPROVED by ${U} (team=${TEAM})"
exit 0
;;
403)
# Token owner is not in the team being probed; the API refuses to
# confirm membership. This is the RFC#324 follow-up token-scope gap.
# Fail closed — never grant approval on a 403; surface clearly.
echo "::error::team-probe for ${U} in ${TEAM} returned 403 (token owner not in ${TEAM} team — RFC#324 token-scope follow-up). Cannot confirm membership; failing closed."
cat "$TEAM_PROBE_TMP" >&2
exit 1
;;
404)
debug "${U} not a member of ${TEAM}"
;;
*)
echo "::warning::team-probe for ${U} in ${TEAM} returned unexpected HTTP ${CODE}"
cat "$TEAM_PROBE_TMP" >&2
;;
esac
done
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (candidates: $(echo "$CANDIDATES" | tr '\n' ',' | sed 's/,$//') — none are in team)"
exit 1
-673
View File
@@ -1,673 +0,0 @@
#!/usr/bin/env python3
"""status-reaper — Option B compensating-status POST for Gitea 1.22.6's
hardcoded `(push)` suffix on default-branch commit statuses.
Tracking: this PR (workflow + script + tests + audit issue). Sibling
bots: internal#327 (publish-runtime-bot), internal#328 (mc-drift-bot).
Upstream RFC: internal#80. Persona provisioned by sub-agent aefaac1b
(2026-05-11 21:39Z; Gitea uid 94, scope=write:repository).
What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
1. Walk `.gitea/workflows/*.yml`. For each file, build the workflow_id
using this resolution (per hongming-pc 22:08Z review):
- If YAML has top-level `name:` → use that.
- Else → use filename stem (basename minus `.yml`).
Fail-LOUD on:
- Two workflows resolving to the SAME identifier (collision).
- Any identifier containing `/` (it would break context parsing
downstream — Gitea uses ` / ` as the workflow/job separator).
Classify each by whether `on:` contains a `push:` trigger.
2. List the last N (=10) commits on WATCH_BRANCH via
GET /repos/{o}/{r}/commits?sha={branch}&limit={N}. rev2 sweeps
N commits per tick instead of HEAD only — schedule workflows
post `failure` to whatever SHA was HEAD when they COMPLETED, so
by the next */5 tick main has often moved forward and the red
gets stranded on a stale commit (Phase 1+2 evidence: rev1 saw
`compensated:0` every tick across ~6 cycles).
3. For EACH SHA in the list:
- GET combined commit status. Per-SHA error isolation
(refinement #7): if this call raises ApiError or any 5xx,
LOG `::warning::` + continue to the next SHA. Different from
the single-HEAD pre-rev2 path where fail-loud was correct;
the sweep is best-effort across historical commits, so one
transient blip on a stale SHA must not strand reds on the
OTHER stale SHAs.
- If combined.state == "success": skip — cost optimization
(refinement #2), common case (most commits are green).
- Otherwise iterate per-context entries. For each entry where:
state == "failure" AND context.endswith(" (push)")
Parse context as `<workflow_name> / <job_name> (push)`.
Look up workflow_name in the trigger map:
- missing → log ::notice:: and skip (conservative).
- has_push_trigger=True → preserve (real defect signal).
- has_push_trigger=False → POST a compensating
`state=success` status to /statuses/{sha} with the same
context (Gitea de-dups by context) and a description
documenting the workaround + this script's path.
4. Exit 0. Re-running is idempotent — Gitea's commit-status table
stores the LATEST state-per-context, so the success POST sticks
even if another tick happens before the runner finishes.
What it does NOT do:
- Touch any context NOT ending in ` (push)`. The required-checks on
main (verified 2026-05-11) all have ` (pull_request)` suffixes;
they CANNOT be reached by this code path.
- Compensate `error`/`pending` states. Only `failure` — the only one
Gitea emits for the hardcoded-suffix bug.
- Write to non-default branches. WATCH_BRANCH is sourced from
`github.event.repository.default_branch` in the workflow.
- Mutate workflows or runs. The Actions UI still shows the
underlying schedule-triggered run as failed; this script edits
the commit-status surface only.
Halt conditions (script-level — orchestrator-level halts are in the
workflow comments):
- PyYAML missing → fail-loud at import (no fallback parse).
- Workflow `name:` collision → exit 1 with ::error:: message.
- Workflow `name:` containing `/` → exit 1 with ::error:: message.
- Ambiguous `on:` shape (e.g. neither str/list/dict) → treat as
"has_push_trigger=True" and log ::notice:: (preserve, never
compensate the unknown).
- api() non-2xx → raise ApiError, fail the workflow run loudly so
a subsequent tick retries (per
`feedback_api_helper_must_raise_not_return_dict`).
Local dry-run (no network):
GITEA_TOKEN=... GITEA_HOST=git.moleculesai.app REPO=owner/repo \\
WATCH_BRANCH=main WORKFLOWS_DIR=.gitea/workflows \\
python3 .gitea/scripts/status-reaper.py --dry-run
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
import yaml # PyYAML 6.0.2 — installed by the workflow before this runs.
# --------------------------------------------------------------------------
# Environment
# --------------------------------------------------------------------------
def _env(key: str, *, default: str = "") -> str:
"""Read an env var with a default. Module-import-safe — tests can
import this script without setting the full env contract."""
return os.environ.get(key, default)
GITEA_TOKEN = _env("GITEA_TOKEN")
GITEA_HOST = _env("GITEA_HOST")
REPO = _env("REPO")
WATCH_BRANCH = _env("WATCH_BRANCH", default="main")
WORKFLOWS_DIR = _env("WORKFLOWS_DIR", default=".gitea/workflows")
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
# Compensating-status description prefix. Used as the marker so a human
# auditing commit statuses can tell at a glance that the green was
# synthetic, not a real CI pass. Kept stable; downstream tooling
# (e.g. main-red-watchdog visual diff) MAY key on it.
COMPENSATION_DESCRIPTION = (
"Compensated by status-reaper (workflow has no push: trigger; "
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
)
# Context suffix the reaper acts on. Gitea hardcodes this for ALL
# default-branch workflow runs.
PUSH_SUFFIX = " (push)"
def _require_runtime_env() -> None:
"""Enforce env contract — called from `main()` only.
Tests import individual functions without setting the full env
contract. Mirrors `main-red-watchdog.py`/`ci-required-drift.py`.
"""
for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "WATCH_BRANCH", "WORKFLOWS_DIR"):
if not os.environ.get(key):
sys.stderr.write(f"::error::missing required env var: {key}\n")
sys.exit(2)
# --------------------------------------------------------------------------
# Tiny HTTP helper — raises on non-2xx + on JSON-decode-of-expected-JSON.
# --------------------------------------------------------------------------
class ApiError(RuntimeError):
"""Raised when a Gitea API call cannot be trusted to have succeeded.
Per `feedback_api_helper_must_raise_not_return_dict`: soft-failure is
opt-in via `expect_json=False`, never the default. A pre-fix
implementation that returned `{}` on non-2xx would skip the
compensating POST on a transient outage AND silently lose the
failed-status enumeration, painting main green via omission.
"""
def api(
method: str,
path: str,
*,
body: dict | None = None,
query: dict[str, str] | None = None,
expect_json: bool = True,
) -> tuple[int, Any]:
"""Tiny HTTP helper around urllib. Same contract as
`main-red-watchdog.py` and `ci-required-drift.py` so behaviour
is cross-checkable."""
url = f"{API}{path}"
if query:
url = f"{url}?{urllib.parse.urlencode(query)}"
data = None
headers = {
"Authorization": f"token {GITEA_TOKEN}",
"Accept": "application/json",
}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, method=method, data=data, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
status = resp.status
except urllib.error.HTTPError as e:
raw = e.read()
status = e.code
if not (200 <= status < 300):
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
raise ApiError(f"{method} {path} -> HTTP {status}: {snippet}")
if not raw:
return status, None
try:
return status, json.loads(raw)
except json.JSONDecodeError as e:
if expect_json:
raise ApiError(
f"{method} {path} -> HTTP {status} but body is not JSON: {e}"
) from e
return status, {"_raw": raw.decode("utf-8", errors="replace")}
# --------------------------------------------------------------------------
# Workflow scan + classification
# --------------------------------------------------------------------------
def _on_block(doc: dict) -> Any:
"""Extract the `on:` block from a parsed YAML doc.
PyYAML parses bareword `on:` as Python `True` (YAML 1.1 boolean
spec — `on/off/yes/no` are booleans). The actual key in the dict
is therefore `True`, NOT the string `"on"`. We accept both for
forward-compat with YAML 1.2 loaders (which keep it as `"on"`).
"""
if True in doc:
return doc[True]
return doc.get("on")
def _has_push_trigger(on_block: Any, workflow_id: str) -> bool:
"""Return True if `on:` block declares a `push` trigger.
Accepts the three common shapes:
- str: `on: push` → True only if == "push"
- list: `on: [push, pull_request]` → True if "push" in list
- dict: `on: { push: {...}, schedule: ... }` → True if "push" key
Defensive: for anything else (including None/empty), return True
so we preserve rather than over-compensate. Logged via ::notice::.
"""
if isinstance(on_block, str):
return on_block == "push"
if isinstance(on_block, list):
return "push" in on_block
if isinstance(on_block, dict):
return "push" in on_block
# None or unexpected shape — preserve, log.
print(
f"::notice::ambiguous on: for {workflow_id}; preserving "
f"(value={on_block!r}, type={type(on_block).__name__})"
)
return True
def scan_workflows(workflows_dir: str) -> dict[str, bool]:
"""Walk `workflows_dir` and return `{workflow_id: has_push_trigger}`.
Workflow ID resolution (per hongming-pc 22:08Z review):
- Top-level `name:` if present.
- Else filename stem (basename minus `.yml`).
Fail-LOUD on:
- Two workflows resolving to the same ID (collision).
- Any ID containing `/` (would break ` / `-separated context
parsing on the downstream side).
Returns a dict for O(1) lookup in the per-status loop.
"""
path = Path(workflows_dir)
if not path.is_dir():
# Workflow dir missing → no workflows to classify. Empty map is
# safe: per-status loop will hit "unknown workflow; skip" for
# every entry, which is correct (we cannot tell if a push
# trigger exists, so we preserve).
print(f"::warning::workflows dir not found: {workflows_dir}")
return {}
out: dict[str, bool] = {}
sources: dict[str, str] = {} # workflow_id -> source file (for collision msg)
for yml in sorted(path.glob("*.yml")):
try:
with yml.open() as f:
doc = yaml.safe_load(f)
except yaml.YAMLError as e:
# A malformed YAML in the workflows dir is a real defect
# (the workflow wouldn't load on Gitea either). Surface it
# and keep going — the reaper's job is to compensate the
# OTHER workflows even if one is broken.
print(f"::warning::yaml parse failed for {yml.name}: {e}; skip")
continue
if not isinstance(doc, dict):
print(f"::warning::workflow {yml.name} not a dict; skip")
continue
# Resolve workflow_id.
name_field = doc.get("name")
if isinstance(name_field, str) and name_field.strip():
workflow_id = name_field.strip()
else:
workflow_id = yml.stem # basename minus .yml
# Halt-loud: `/` in workflow_id breaks ` / ` context parsing.
if "/" in workflow_id:
sys.stderr.write(
f"::error::workflow name contains '/' which breaks "
f"context parsing: {workflow_id} (file={yml.name})\n"
)
sys.exit(1)
# Halt-loud: ID collision.
if workflow_id in out:
sys.stderr.write(
f"::error::workflow name collision detected: {workflow_id} "
f"(files: {sources[workflow_id]} + {yml.name})\n"
)
sys.exit(1)
on_block = _on_block(doc)
out[workflow_id] = _has_push_trigger(on_block, workflow_id)
sources[workflow_id] = yml.name
return out
# --------------------------------------------------------------------------
# Gitea reads
# --------------------------------------------------------------------------
def get_head_sha(branch: str) -> str:
"""HEAD SHA of `branch`. Raises ApiError on non-2xx."""
_, body = api("GET", f"/repos/{OWNER}/{NAME}/branches/{branch}")
if not isinstance(body, dict):
raise ApiError(f"branch {branch} response not a JSON object")
commit = body.get("commit")
if not isinstance(commit, dict):
raise ApiError(f"branch {branch} response missing `commit` object")
sha = commit.get("id") or commit.get("sha")
if not isinstance(sha, str) or len(sha) < 7:
raise ApiError(f"branch {branch} response has no usable commit SHA")
return sha
def get_combined_status(sha: str) -> dict:
"""Combined commit status for `sha`. Gitea returns:
{
"state": "success" | "failure" | "pending" | "error",
"statuses": [
{"context": "...", "state": "...", "target_url": "...",
"description": "..."},
...
],
...
}
Raises ApiError on non-2xx.
"""
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
if not isinstance(body, dict):
raise ApiError(f"status for {sha} response not a JSON object")
return body
# --------------------------------------------------------------------------
# Context parsing
# --------------------------------------------------------------------------
def parse_push_context(context: str) -> tuple[str, str] | None:
"""Parse `<workflow_name> / <job_name> (push)` into
(workflow_name, job_name).
Returns None if the context doesn't match the shape (caller skips).
Strict: requires the trailing ` (push)` and at least one ` / `
separator. Anything else is left alone.
"""
if not context.endswith(PUSH_SUFFIX):
return None
head = context[: -len(PUSH_SUFFIX)] # strip " (push)"
if " / " not in head:
# No workflow/job separator — not the bug shape we compensate.
return None
workflow_name, job_name = head.split(" / ", 1)
return workflow_name, job_name
# --------------------------------------------------------------------------
# Compensating POST
# --------------------------------------------------------------------------
def post_compensating_status(
sha: str,
context: str,
target_url: str | None,
*,
dry_run: bool = False,
) -> None:
"""POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the
given context. Gitea de-dups by context (latest write wins).
Description references this script so the compensation is
self-documenting on the commit's status view.
"""
payload: dict[str, Any] = {
"context": context,
"state": "success",
"description": COMPENSATION_DESCRIPTION,
}
# Echo the original target_url when present so a human auditing
# the (now-green) compensated status can still reach the run logs
# that produced the original red.
if target_url:
payload["target_url"] = target_url
if dry_run:
print(
f"::notice::[dry-run] would compensate {context!r} on {sha[:10]} "
f"with state=success"
)
return
api("POST", f"/repos/{OWNER}/{NAME}/statuses/{sha}", body=payload)
print(f"::notice::compensated {context!r} on {sha[:10]} (state=success)")
# --------------------------------------------------------------------------
# Main reap loop
# --------------------------------------------------------------------------
def reap(
workflow_trigger_map: dict[str, bool],
combined: dict,
sha: str,
*,
dry_run: bool = False,
) -> dict[str, Any]:
"""Walk `combined.statuses[]` and compensate where appropriate.
Per-SHA worker. The multi-SHA orchestrator (`reap_branch`) calls
this once per stale main commit each tick.
Returns counters for observability:
{compensated, preserved_real_push, preserved_unknown,
preserved_non_failure, preserved_non_push_suffix,
preserved_unparseable,
compensated_contexts: [<context>, ...]}
`compensated_contexts` is rev2-added so `reap_branch` can build
`compensated_per_sha` without re-deriving it from the POST stream.
"""
counters: dict[str, Any] = {
"compensated": 0,
"preserved_real_push": 0,
"preserved_unknown": 0,
"preserved_non_failure": 0,
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_contexts": [],
}
statuses = combined.get("statuses") or []
for s in statuses:
if not isinstance(s, dict):
continue
context = s.get("context") or ""
state = s.get("state") or ""
# Only `failure` is the bug shape. `error`/`pending`/`success`
# left alone — they have other meanings.
if state != "failure":
counters["preserved_non_failure"] += 1
continue
# Only `(push)`-suffix contexts hit the hardcoded-suffix bug.
# Branch-protection required checks (e.g. `Secret scan / Scan
# diff (pull_request)`) are NOT reachable from this path.
if not context.endswith(PUSH_SUFFIX):
counters["preserved_non_push_suffix"] += 1
continue
parsed = parse_push_context(context)
if parsed is None:
# Has ` (push)` suffix but missing ` / ` separator — not
# the bug shape. Preserve.
counters["preserved_unparseable"] += 1
continue
workflow_name, _job_name = parsed
if workflow_name not in workflow_trigger_map:
# Real workflow but renamed/deleted/external — we can't
# tell if it has push trigger. Conservative: preserve.
print(f"::notice::unknown workflow {workflow_name!r}; skip")
counters["preserved_unknown"] += 1
continue
if workflow_trigger_map[workflow_name]:
# Real push trigger → real defect signal. Preserve.
counters["preserved_real_push"] += 1
continue
# Class-O: schedule/dispatch/etc.-only workflow with a fake
# (push) status from Gitea's hardcoded-suffix bug. Compensate.
post_compensating_status(
sha, context, s.get("target_url"), dry_run=dry_run
)
counters["compensated"] += 1
counters["compensated_contexts"].append(context)
return counters
# --------------------------------------------------------------------------
# rev2: multi-SHA sweep over the last N commits on WATCH_BRANCH
# --------------------------------------------------------------------------
# How many main commits to sweep per tick. Sized to cover a burst-merge
# window where multiple PRs land in the 5-min interval between reaper
# ticks. Older reds falling off the window is acceptable — they were
# already stale enough that the schedule-run that posted them has long
# since been overwritten by a real push trigger. See `reference_post_
# suspension_pipeline` for the merge-cadence baseline.
DEFAULT_SWEEP_LIMIT = 10
def list_recent_commit_shas(branch: str, limit: int) -> list[str]:
"""List the most recent `limit` commit SHAs on `branch`, newest
first.
Wraps GET /repos/{o}/{r}/commits?sha={branch}&limit={limit}. Gitea
1.22.6 returns a JSON list of commit objects each with a `sha` key
(verified via vendor-truth probe 2026-05-11 against
git.moleculesai.app — `feedback_smoke_test_vendor_truth_not_shape_match`).
Raises ApiError on non-2xx OR on unexpected response shape. This is
a HARD halt — without the commit list the sweep can't proceed. (The
per-SHA error isolation downstream is a different concern: tolerating
a transient 5xx on ONE commit's status is best-effort; losing the
commit list itself means we don't even know which commits to try.)
"""
_, body = api(
"GET",
f"/repos/{OWNER}/{NAME}/commits",
query={"sha": branch, "limit": str(limit)},
)
if not isinstance(body, list):
raise ApiError(
f"commits listing for {branch} not a JSON array "
f"(got {type(body).__name__})"
)
shas: list[str] = []
for entry in body:
if not isinstance(entry, dict):
continue
sha = entry.get("sha")
if isinstance(sha, str) and len(sha) >= 7:
shas.append(sha)
if not shas:
raise ApiError(
f"commits listing for {branch} returned no usable SHAs"
)
return shas
def reap_branch(
workflow_trigger_map: dict[str, bool],
branch: str,
*,
limit: int = DEFAULT_SWEEP_LIMIT,
dry_run: bool = False,
) -> dict[str, Any]:
"""Sweep the last `limit` commits on `branch`, applying `reap()`
to each (with per-SHA error isolation).
Returns aggregated counters PLUS rev2 observability fields:
- scanned_shas: how many SHAs we actually iterated
- compensated_per_sha: {<sha_full>: [<context>, ...]} — only
SHAs that actually got at least one compensation are included
"""
shas = list_recent_commit_shas(branch, limit)
aggregate: dict[str, Any] = {
"scanned_shas": 0,
"compensated": 0,
"preserved_real_push": 0,
"preserved_unknown": 0,
"preserved_non_failure": 0,
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_per_sha": {},
}
for sha in shas:
aggregate["scanned_shas"] += 1
# Per-SHA error isolation (refinement #7). One transient blip
# on a historical commit must NOT abort the whole tick — the
# OTHER stale SHAs may still hold strandable reds.
try:
combined = get_combined_status(sha)
except ApiError as e:
print(
f"::warning::get_combined_status({sha[:10]}) failed; "
f"skipping this SHA: {e}"
)
continue
# Cost optimization (refinement #2): the common case is a green
# commit. Skip the per-context loop entirely when combined is
# already success — saves a tight loop over ~20 statuses per SHA
# on green commits, the dominant majority.
if combined.get("state") == "success":
continue
per_sha = reap(
workflow_trigger_map, combined, sha, dry_run=dry_run
)
# Aggregate scalar counters.
for key in (
"compensated",
"preserved_real_push",
"preserved_unknown",
"preserved_non_failure",
"preserved_non_push_suffix",
"preserved_unparseable",
):
aggregate[key] += per_sha[key]
# Record per-SHA compensated contexts (only when non-empty —
# keep the summary readable when most SHAs are no-ops).
contexts = per_sha.get("compensated_contexts") or []
if contexts:
aggregate["compensated_per_sha"][sha] = list(contexts)
return aggregate
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run",
action="store_true",
help="Skip the compensating POST; print what would be done.",
)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_SWEEP_LIMIT,
help=(
"How many recent commits on WATCH_BRANCH to sweep per tick "
f"(default: {DEFAULT_SWEEP_LIMIT})."
),
)
args = parser.parse_args()
_require_runtime_env()
workflow_trigger_map = scan_workflows(WORKFLOWS_DIR)
print(
f"::notice::scanned {len(workflow_trigger_map)} workflows; "
f"push-triggered={sum(1 for v in workflow_trigger_map.values() if v)}, "
f"class-O candidates={sum(1 for v in workflow_trigger_map.values() if not v)}"
)
counters = reap_branch(
workflow_trigger_map,
WATCH_BRANCH,
limit=args.limit,
dry_run=args.dry_run,
)
# Observability: print one JSON line summarising the tick. Loki
# ingestion via the runner's stdout (`source="gitea-actions"`).
print(
"status-reaper summary: "
+ json.dumps(
{
"branch": WATCH_BRANCH,
"dry_run": args.dry_run,
"limit": args.limit,
**counters,
},
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""Stub Gitea API for review-check.sh test scenarios.
Reads $FIXTURE_STATE_DIR/scenario to decide what to return for each
endpoint the review-check.sh script calls.
Reads $FIXTURE_STATE_DIR/token_owner_in_teams to decide whether
the team membership probe returns 200/204 (member) or 403 (not in team).
Scenarios:
T1_pr_open — open PR, author=alice, sha=deadbeef → continue
T2_pr_closed — closed PR → script exits 0 (no-op)
T3_reviews_approved_non_author — one APPROVED from non-author → candidates exist
T4_reviews_empty — zero APPROVED non-author → exit 1 (no candidates)
T5_reviews_only_author — only author reviews → exit 1 (no candidates)
T6_reviews_dismissed — dismissed APPROVED → treated as no approval
T7_team_member — team membership → 204 (member) → exit 0
T8_team_not_member — team membership → 404 (not a member) → exit 1
T9_team_403 — team membership → 403 (token not in team) → exit 1
Usage:
FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080
"""
import http.server
import json
import os
import re
import sys
import urllib.parse
STATE_DIR = os.environ.get("FIXTURE_STATE_DIR", "/tmp")
def scenario() -> str:
p = os.path.join(STATE_DIR, "scenario")
if not os.path.isfile(p):
return "T1_pr_open"
with open(p) as f:
return f.read().strip()
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args, **kwargs):
pass # keep stdout for explicit logs only
def _json(self, code: int, body: dict) -> None:
payload = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _empty(self, code: int) -> None:
self.send_response(code)
self.send_header("Content-Length", "0")
self.end_headers()
def _text(self, code: int, body: str) -> None:
payload = body.encode()
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
u = urllib.parse.urlparse(self.path)
path = u.path
sc = scenario()
if path == "/_ping":
return self._json(200, {"ok": True})
# GET /repos/{owner}/{name}/pulls/{pr_number}
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)$", path)
if m:
owner, name, pr_num = m.group(1), m.group(2), m.group(3)
if sc == "T2_pr_closed":
return self._json(200, {
"number": int(pr_num),
"state": "closed",
"head": {"sha": "deadbeef0000111122223333444455556666"},
"user": {"login": "alice"},
})
return self._json(200, {
"number": int(pr_num),
"state": "open",
"head": {"sha": "deadbeef0000111122223333444455556666"},
"user": {"login": "alice"},
})
# GET /repos/{owner}/{name}/pulls/{pr_number}/reviews
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)/reviews$", path)
if m:
if sc in ("T4_reviews_empty", "T5_reviews_only_author"):
return self._json(200, [])
if sc == "T6_reviews_dismissed":
return self._json(200, [{
"state": "APPROVED",
"dismissed": True,
"user": {"login": "core-devops"},
"commit_id": "abc1234",
}])
if sc == "T3_reviews_approved_non_author":
return self._json(200, [
{"state": "CHANGES_REQUESTED", "dismissed": False, "user": {"login": "bob"}, "commit_id": "abc1234"},
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"},
])
# Default: one non-author APPROVED
return self._json(200, [
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"},
])
# GET /teams/{team_id}/members/{username}
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
if m:
team_id, login = m.group(1), m.group(2)
if sc == "T8_team_not_member":
return self._empty(404)
if sc == "T9_team_403":
return self._empty(403)
# T7_team_member: member
return self._empty(204)
return self._json(404, {"path": path, "msg": "fixture: no route"})
def do_POST(self):
self._json(404, {"path": self.path, "msg": "fixture: no POST routes"})
def main():
port = int(sys.argv[1])
srv = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
srv.serve_forever()
if __name__ == "__main__":
main()
-332
View File
@@ -1,332 +0,0 @@
#!/usr/bin/env bash
# Regression tests for .gitea/scripts/review-check.sh (RFC#324 Step 1).
#
# Covers:
# T1 — open PR: script fetches PR + reviews, continues to team probe
# T2 — closed PR: script exits 0 (no-op)
# T3 — APPROVED non-author review exists → candidates exist
# T4 — no non-author APPROVED reviews → exit 1 (no candidates)
# T5 — only author reviews (no non-author APPROVE) → exit 1
# T6 — dismissed APPROVED review → treated as no approval
# T7 — team membership probe → 204 (member) → script exits 0
# T8 — team membership probe → 404 (not a member) → script exits 1
# T9 — team membership probe → 403 (token not in team) → script exits 1 (fail closed)
# T10 — CURL_AUTH_FILE created with mode 600 and correct header content
# T11 — bash syntax check (bash -n passes)
# T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded
# T13 — missing required env GITEA_TOKEN → exits 1 with error
#
# Hostile-self-review (per feedback_assert_exact_not_substring):
# this test MUST FAIL if the script is absent. Verified by running
# the test before the file exists (covered in the PR body).
set -euo pipefail
THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
SCRIPT="$SCRIPT_DIR/review-check.sh"
PASS=0
FAIL=0
FAILED_TESTS=""
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))
FAILED_TESTS="${FAILED_TESTS} ${label}"
fi
}
assert_contains() {
local label="$1"
local needle="$2"
local haystack="$3"
if printf '%s' "$haystack" | grep -qF "$needle"; then
echo " PASS $label"
PASS=$((PASS + 1))
else
echo " FAIL $label"
echo " needle: <$needle>"
echo " haystack: <$(printf '%s' "$haystack" | head -c 200)>"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} ${label}"
fi
}
assert_file_mode() {
local label="$1"
local path="$2"
local expected_mode="$3"
if [ ! -f "$path" ]; then
echo " FAIL $label (file not found: $path)"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} ${label}"
return
fi
local got_mode
got_mode=$(stat -c '%a' "$path" 2>/dev/null || echo "000")
if [ "$expected_mode" = "$got_mode" ]; then
echo " PASS $label (mode=$got_mode)"
PASS=$((PASS + 1))
else
echo " FAIL $label (expected mode=$expected_mode, got=$got_mode)"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} ${label}"
fi
}
assert_file_contains() {
local label="$1"
local path="$2"
local needle="$3"
if [ ! -f "$path" ]; then
echo " FAIL $label (file not found: $path)"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} ${label}"
return
fi
if grep -qF "$needle" "$path"; then
echo " PASS $label"
PASS=$((PASS + 1))
else
echo " FAIL $label (needle not found: <$needle>)"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} ${label}"
fi
}
# Existence check (foundation)
echo
echo "== existence =="
if [ -f "$SCRIPT" ]; then
echo " PASS script exists: $SCRIPT"
PASS=$((PASS + 1))
else
echo " FAIL script not found: $SCRIPT"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} script_exists"
echo
echo "------"
echo "PASS=$PASS FAIL=$FAIL (existence)"
echo "Cannot proceed without the script."
exit 1
fi
# T11 — bash syntax check
echo
echo "== T11 bash syntax =="
if bash -n "$SCRIPT" 2>&1; then
echo " PASS T11 bash -n passes"
PASS=$((PASS + 1))
else
echo " FAIL T11 bash -n failed"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T11"
fi
# T13 — missing required env
echo
echo "== T13 missing GITEA_TOKEN =="
set +e
T13_OUT=$(PATH="/tmp:$PATH" GITEA_TOKEN= GITEA_HOST=git.example.com REPO=x/y PR_NUMBER=1 TEAM=qa TEAM_ID=1 bash "$SCRIPT" 2>&1 || true)
set -e
assert_contains "T13 exits non-zero when GITEA_TOKEN missing" "GITEA_TOKEN required" "$T13_OUT"
# Start fixture HTTP server
echo
echo "== fixture setup =="
FIXTURE_DIR=$(mktemp -d)
trap 'rm -rf "$FIXTURE_DIR"; [ -n "${FIX_PID:-}" ] && kill "$FIX_PID" 2>/dev/null || true' EXIT
FIXTURE_PY="$THIS_DIR/_review_check_fixture.py"
if [ ! -f "$FIXTURE_PY" ]; then
echo "::error::fixture server $FIXTURE_PY missing"
exit 1
fi
FIX_LOG="$FIXTURE_DIR/fixture.log"
FIX_STATE_DIR="$FIXTURE_DIR/state"
mkdir -p "$FIX_STATE_DIR"
# Find an unused port
FIX_PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
FIXTURE_STATE_DIR="$FIX_STATE_DIR" python3 "$FIXTURE_PY" "$FIX_PORT" \
>"$FIX_LOG" 2>&1 &
FIX_PID=$!
# Wait for fixture readiness
for _ in $(seq 1 50); do
if curl -fsS "http://127.0.0.1:${FIX_PORT}/_ping" >/dev/null 2>&1; then
break
fi
sleep 0.1
done
if ! curl -fsS "http://127.0.0.1:${FIX_PORT}/_ping" >/dev/null 2>&1; then
echo "::error::fixture server failed to start. Log:"
cat "$FIX_LOG"
exit 1
fi
echo " fixture running on port $FIX_PORT"
# Install a curl shim that rewrites https://fixture.local/* -> http://127.0.0.1:$FIX_PORT/*
# Use double-quoted heredoc so FIX_PORT is expanded into the shim at creation time.
mkdir -p "$FIXTURE_DIR/bin"
cat >"$FIXTURE_DIR/bin/curl" <<"CURL_SHIM"
#!/usr/bin/env bash
# Shim: rewrite https://fixture.local/* -> http://127.0.0.1:FIXPORT/*
# Generated at test-run time; FIXPORT is substituted when this file is written.
new_args=()
for a in "$@"; do
if [[ "$a" == https://fixture.local/* ]]; then
rest="${a#https://fixture.local}"
a="http://127.0.0.1:FIXPORT${rest}"
fi
new_args+=("$a")
done
exec /usr/bin/curl "${new_args[@]}"
CURL_SHIM
# Now substitute FIXPORT with the actual port number
sed -i "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl"
chmod +x "$FIXTURE_DIR/bin/curl"
# Helper: run the script with fixture environment
run_review_check() {
local scenario="$1"
echo "$scenario" >"$FIX_STATE_DIR/scenario"
local out
set +e
out=$(
PATH="$FIXTURE_DIR/bin:/tmp:$PATH" \
GITEA_TOKEN="fixture-token" \
GITEA_HOST="fixture.local" \
REPO="molecule-ai/molecule-core" \
PR_NUMBER="999" \
TEAM="qa" \
TEAM_ID="20" \
REVIEW_CHECK_DEBUG="0" \
REVIEW_CHECK_STRICT="0" \
bash "$SCRIPT" 2>&1
)
local rc=$?
set -e
echo "$out" >"$FIX_STATE_DIR/last_run.log"
echo "$rc" >"$FIX_STATE_DIR/last_rc"
echo "$out"
}
# T1 — open PR: script fetches PR and continues
echo
echo "== T1 open PR =="
T1_OUT=$(run_review_check "T1_pr_open")
T1_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T1 exit code 0 (approver exists + team member)" "0" "$T1_RC"
assert_contains "T1 qa-review APPROVED by core-devops" "APPROVED by core-devops" "$T1_OUT"
# T2 — closed PR: exits 0 immediately (no-op)
echo
echo "== T2 closed PR =="
T2_OUT=$(run_review_check "T2_pr_closed")
T2_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T2 exit code 0 (closed PR no-op)" "0" "$T2_RC"
# T3 — APPROVED non-author reviews exist
echo
echo "== T3 approved non-author reviews =="
T3_OUT=$(run_review_check "T3_reviews_approved_non_author")
T3_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T3 exit code 0 (candidates + team member)" "0" "$T3_RC"
# T4 — no non-author APPROVED reviews → exit 1
echo
echo "== T4 no non-author APPROVED reviews =="
T4_OUT=$(run_review_check "T4_reviews_empty")
T4_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T4 exit code 1 (no candidates)" "1" "$T4_RC"
assert_contains "T4 awaiting non-author APPROVE" "awaiting non-author APPROVE" "$T4_OUT"
# T5 — only author reviews → exit 1
echo
echo "== T5 only author reviews =="
T5_OUT=$(run_review_check "T5_reviews_only_author")
T5_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T5 exit code 1 (only author reviews, no candidates)" "1" "$T5_RC"
# T6 — dismissed APPROVED review → treated as no approval
echo
echo "== T6 dismissed APPROVED review =="
T6_OUT=$(run_review_check "T6_reviews_dismissed")
T6_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T6 exit code 1 (dismissed = no approval)" "1" "$T6_RC"
# T7 — team member → exit 0
echo
echo "== T7 team membership 204 (member) =="
T7_OUT=$(run_review_check "T7_team_member")
T7_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T7 exit code 0 (member, APPROVED)" "0" "$T7_RC"
assert_contains "T7 APPROVED by core-devops (team member)" "APPROVED by core-devops" "$T7_OUT"
# T8 — not a team member → exit 1 (fail closed)
echo
echo "== T8 team membership 404 (not a member) =="
T8_OUT=$(run_review_check "T8_team_not_member")
T8_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T8 exit code 1 (not in team)" "1" "$T8_RC"
# T9 — 403 token-not-in-team → exit 1 (fail closed)
echo
echo "== T9 team membership 403 (token not in team) =="
T9_OUT=$(run_review_check "T9_team_403")
T9_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T9 exit code 1 (403 token-not-in-team, fail closed)" "1" "$T9_RC"
assert_contains "T9 403 error in output" "403" "$T9_OUT"
# T10 — token file creation and permissions
echo
echo "== T10 CURL_AUTH_FILE =="
# Verify the token-file logic directly: create a temp file with the
# same mktemp pattern, write the header with printf, chmod 600, then assert.
T10_TOKEN="secret-test-token-abc123"
T10_AUTHFILE=$(mktemp -p /tmp curl-auth.test.XXXXXX)
chmod 600 "$T10_AUTHFILE"
printf 'header = "Authorization: token %s"\n' "$T10_TOKEN" > "$T10_AUTHFILE"
assert_file_mode "T10a mktemp -p /tmp mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600"
assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-test-token-abc123"
assert_file_contains "T10c 'header =' curl-config syntax" "$T10_AUTHFILE" 'header = "Authorization: token '
rm -f "$T10_AUTHFILE"
# T12 — jq filter: non-author APPROVED included, dismissed excluded
echo
echo "== T12 jq filter =="
# These are tested indirectly via T3 and T6 above, but let's also test
# the jq expression directly.
JQ_FILTER='.[]
| select(.state == "APPROVED")
| select(.dismissed != true)
| select(.user.login != "alice")
| .user.login'
T12_INPUT='[{"state":"APPROVED","dismissed":false,"user":{"login":"core-devops"}},{"state":"CHANGES_REQUESTED","dismissed":false,"user":{"login":"bob"}},{"state":"APPROVED","dismissed":false,"user":{"login":"alice"}},{"state":"APPROVED","dismissed":true,"user":{"login":"carol"}}]'
JQ_CMD=$(command -v jq 2>/dev/null || echo /tmp/jq)
T12_CANDIDATES=$(echo "$T12_INPUT" | "$JQ_CMD" -r "$JQ_FILTER" 2>/dev/null | sort -u)
assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core-devops" "$T12_CANDIDATES"
assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)"
assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)"
echo
echo "------"
echo "PASS=$PASS FAIL=$FAIL"
if [ "$FAIL" -gt 0 ]; then
echo "Failed:$FAILED_TESTS"
fi
[ "$FAIL" -eq 0 ]
+7 -12
View File
@@ -77,18 +77,13 @@ jobs:
run: python -m pip install --quiet 'PyYAML==6.0.2'
- name: Run drift detector
env:
# DRIFT_BOT_TOKEN is owned by mc-drift-bot, a least-privilege
# Gitea persona whose ONLY job is reading branch_protections
# and posting the [ci-drift] tracking issue. The endpoint
# `GET /repos/.../branch_protections/{branch}` requires
# repo-ADMIN role (Gitea 1.22.6) — SOP_TIER_CHECK_TOKEN and the
# auto-injected GITHUB_TOKEN do NOT have it (read-only / write
# without admin), so the previous fallback chain 403'd.
# Mirrors the controlplane fix landed in CP PR#134.
# Provisioning trail: internal#329 (audit) + parent pattern
# internal#327 (publish-runtime-bot). Per
# `feedback_per_agent_gitea_identity_default`.
GITEA_TOKEN: ${{ secrets.DRIFT_BOT_TOKEN }}
# GITEA_TOKEN reads protection + writes issues. molecule-core
# uses `SOP_TIER_CHECK_TOKEN` as the org-level secret name for
# read-only Gitea API access from CI (set by audit-force-merge
# and sop-tier-check too). Falls back to the auto-injected
# GITHUB_TOKEN if the org-level secret isn't set
# (transitional repos).
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
# Branches whose protection we compare against. molecule-core
-100
View File
@@ -148,21 +148,6 @@ jobs:
- if: needs.changes.outputs.platform == 'true'
name: Run golangci-lint
run: golangci-lint run --timeout 3m ./... || true
- if: needs.changes.outputs.platform == 'true'
name: Diagnostic — per-package verbose 60s
run: |
set +e
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
handlers_exit=$?
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
pu_exit=$?
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
tail -100 /tmp/test-handlers.log
echo "::endgroup::"
echo "::group::pendinguploads exit=$pu_exit (last 100 lines)"
tail -100 /tmp/test-pu.log
echo "::endgroup::"
continue-on-error: true
- if: needs.changes.outputs.platform == 'true'
name: Run tests with race detection and coverage
run: go test -race -coverprofile=coverage.out ./...
@@ -466,88 +451,3 @@ jobs:
echo " adjusting the floor with rationale in COVERAGE_FLOOR.md."
exit 1
fi
all-required:
# Aggregator sentinel — RFC internal#219 §2 (Phase 4 — closes internal#286).
#
# Single stable required-status name that branch protection points at;
# CI churns underneath in `needs:` without any protection edits. Mirrors
# the molecule-controlplane Phase 2a impl shipped in CP PR#112 and
# referenced by `internal#286` ("Phase 4 is a single small PR... mirrors
# CP's existing one").
#
# Closes the failure mode where status_check_contexts on molecule-core/main
# only listed `Secret scan` + `sop-tier-check` (the 2 meta-gates), so real
# `Platform (Go)` / `Canvas (Next.js)` / `Python Lint & Test` / `Shellcheck`
# red silently merged through. See internal#286 for the three concrete
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
#
# Three properties of this job each close a failure mode:
#
# 1. `if: always()` — runs even when an upstream fails. Without it the
# sentinel is `skipped` and protection treats that as missing → merge
# ungated.
#
# 2. Assertion is `result == "success"` per dep, NOT `!= "failure"`.
# A `skipped` upstream (job gated by `if:` evaluating false, matrix
# entry that couldn't run) must NOT silently pass through.
# `skipped`-as-green is exactly the failure mode this gate closes.
#
# 3. `needs:` is the canonical list of "what counts as required."
# status_check_contexts will reference only `ci/all-required` (Step 5
# follow-up — branch-protection PATCH is Owners-tier per
# `feedback_never_admin_merge_bypass`, separate PR); a new job is
# added simply by listing it in `needs:` here.
# `.gitea/workflows/ci-required-drift.yml` files a [ci-drift] issue
# hourly if this list diverges from status_check_contexts or from
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
#
# Excluded from `needs:`: `canvas-deploy-reminder` — gated by
# `if: ... github.event_name == 'push' && github.ref == 'refs/heads/main'`,
# so on PR events it's legitimately `skipped`. The drift detector
# explicitly excludes `github.event_name`-gated jobs from F1 (see
# `.gitea/scripts/ci-required-drift.py::ci_job_names`).
#
# Phase 3 (RFC #219 §1) safety: continue-on-error here so the sentinel
# does not hard-fail and block PRs while the underlying build jobs are
# still in Phase 3 (continue-on-error: true suppresses their status to null).
# When Phase 3 ends (defects fixed, continue-on-error flipped off on build
# jobs), remove continue-on-error here so the sentinel again hard-fails.
continue-on-error: true
runs-on: ubuntu-latest
timeout-minutes: 1
needs:
- changes
- platform-build
- canvas-build
- shellcheck
- python-lint
if: always()
steps:
- name: Assert every required dependency succeeded
run: |
set -euo pipefail
# `needs.*.result` is one of: success | failure | cancelled | skipped | null.
# We assert success per dep (not != failure) — see RFC §2 reasoning above.
# Null results are skipped: they come from Phase 3 (continue-on-error: true
# suppresses status) or from jobs still in-flight. The sentinel succeeds
# rather than blocking PRs on Phase 3 noise.
results='${{ toJSON(needs) }}'
echo "$results"
echo "$results" | python3 -c '
import json, sys
ns = json.load(sys.stdin)
# Exclude null (Phase 3 suppressed / in-flight) from the bad list.
bad = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") not in ("success", None)]
if bad:
print(f"FAIL: jobs not green:", file=sys.stderr)
for k, r in bad:
print(f" - {k}: {r}", file=sys.stderr)
sys.exit(1)
pending = [(k, v.get("result")) for k, v in ns.items() if v.get("result") is None]
if pending:
print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " +
", ".join(k for k, _ in pending), file=sys.stderr)
print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)")
'
+6 -39
View File
@@ -24,22 +24,17 @@ name: E2E Staging SaaS (full lifecycle)
# PRs don't need to read.
#
# Triggers:
# - Push to main (regression guard — fires on merges to main, not on PR updates)
# - pull_request: pr-validate always posts success; real E2E step runs only
# when provisioning-critical files change (detect-changes gates the step).
# - Push to main (regression guard)
# - workflow_dispatch (manual re-run from UI)
# - Nightly cron (catches drift even when no pushes land)
#
# NOTE: A separate pr-validate job handles the pull_request path so this
# workflow posts CI status for workflow-only PRs. Without it, a PR that
# only touches the workflow file has no status check (workflow only fires
# on push, not PR branches), which blocks merge under branch protection.
# The E2E step itself only runs when provisioning-critical files change —
# pr-validate always posts success, avoiding the double-fire that motivated
# the pull_request-trigger removal in PRs #516/#530.
# - Changes to any provisioning-critical file under PR review (opt-in
# via the same paths watcher that e2e-api.yml uses)
on:
# Trunk-based (Phase 3 of internal#81): main is the only branch.
# Previously this fired on staging push too because staging was a
# superset of main and ran the gate ahead of auto-promote; with no
# staging branch, main is where E2E gates the deploy.
push:
branches: [main]
paths:
@@ -60,7 +55,6 @@ on:
- 'workspace-server/internal/provisioner/**'
- 'tests/e2e/test_staging_full_saas.sh'
- '.gitea/workflows/e2e-staging-saas.yml'
workflow_dispatch:
schedule:
# 07:00 UTC every day — catches AMI drift, WorkOS cert rotation,
# Cloudflare API regressions, etc. even on quiet days.
@@ -78,36 +72,9 @@ env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# PR-validation path: always posts success so branch protection can merge
# workflow-only PRs. The actual E2E step only runs when provisioning-
# critical files change (git-paths filter + if: guard below).
# All steps use continue-on-error: true so runner issues do not block merge.
pr-validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
continue-on-error: true
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
continue-on-error: true
- name: YAML validation (best-effort)
run: |
echo "e2e-staging-saas.yml — PR validation: workflow YAML is valid."
echo "E2E step runs only when provisioning-critical files change."
continue-on-error: true
# Actual E2E: runs on trunk pushes (main + staging). NOT the PR-fire-only
# path — pr-validate above posts success for workflow-only PRs.
e2e-staging-saas:
name: E2E Staging SaaS
runs-on: ubuntu-latest
# Only runs on trunk pushes. PR paths get pr-validate instead.
if: github.event.pull_request.base.ref == ''
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
continue-on-error: true
timeout-minutes: 45
+12 -18
View File
@@ -23,14 +23,17 @@ on:
schedule:
# Hourly: refresh all open PRs
- cron: '8 * * * *'
# NOTE: `workflow_dispatch.inputs` block intentionally omitted.
# Gitea 1.22.6 parser rejects `workflow_dispatch.inputs.X` with
# "unknown on type" — it mis-treats the inputs sub-keys as top-level
# `on:` event types. Dropping the inputs block restores parsing.
# Manual dispatch from the Gitea UI works without the inputs schema
# (github.event.inputs.X returns empty); the script falls back to
# iterating all open PRs when PR_NUMBER is empty.
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to check (omit for all open PRs)'
required: false
type: string
post_comment:
description: 'Post comment on PR'
required: false
type: string
default: 'true'
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
@@ -40,12 +43,7 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true # Never block on our own detector failing
steps:
- name: Check out BASE ref (never PR-head under pull_request_target)
# pull_request_target runs with repo secrets-context, so checking out
# the PR HEAD would execute PR-branch gate_check.py with secrets.
# Fix: always load gate_check.py from the trusted base/default ref.
# Bug-1 (self-loop exclusion) + Bug-3 (403→exit0) from #547 are
# kept; only this checkout-ref regresses to pre-#547 behavior.
- name: Check out base branch (for the script)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha || github.ref_name }}
@@ -71,12 +69,8 @@ jobs:
run: |
set -euo pipefail
# Fetch all open PRs and run gate-check on each
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
# gate_check.py uses timeout=15 on every urlopen call; this catches the
# inline Python polling loop too (issue #603).
pr_numbers=$(python3 -c "
import socket, urllib.request, json, os
socket.setdefaulttimeout(15)
import urllib.request, json, os
token = os.environ['GITEA_TOKEN']
req = urllib.request.Request(
'https://git.moleculesai.app/api/v1/repos/${{ github.repository }}/pulls?state=open&limit=100',
+29 -39
View File
@@ -34,7 +34,7 @@ name: Harness Replays
# One job → one check run → branch-protection-clean (the SKIPPED-in-set
# trap from PR #2264 is documented in e2e-api.yml's e2e-api job comment).
"on":
on:
push:
branches: [main, staging]
paths:
@@ -74,16 +74,6 @@ jobs:
# GitHub event variables, so no local history is needed.
fetch-depth: 1
- id: decide
env:
# Pass via env block — env values bypass shell quoting so single
# quotes in merge-commit messages (e.g. "Merge pull request 'fix: ...'
# from branch into main") cannot break the bash parser. The prior
# `echo '${{ toJSON(...) }}'` form broke on every main-push because
# every main commit is a merge commit with single quotes in the
# message body — the embedded `'` ended the single-quoted shell string
# mid-JSON, and a subsequent `(` (e.g. in `(#523)`) was parsed as a
# subshell, causing "syntax error near unexpected token `('".
COMMITS_JSON: ${{ toJSON(github.event.commits) }}
run: |
set -euo pipefail
@@ -94,31 +84,21 @@ jobs:
exit 0
fi
# Determine changed files.
# workflow_dispatch: always run.
# pull_request: use Compare API (branch-to-branch works fine).
# push: use github.event.commits array (Compare API rejects SHA-to-branch).
# new-branch: run everything.
# Determine base and head refs for the Compare API call.
# Gitea Compare API requires branch/tag names (SHAs return BaseNotExist).
# Pull request: base.ref + head.ref are in the event payload.
# Push: github.ref → extract branch name for the Compare API.
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.ref }}"
HEAD="${{ github.event.pull_request.head.ref }}"
elif [ -n "${{ github.event.before }}" ] && \
! echo "${{ github.event.before }}" | grep -qE '^0+$'; then
# Push event: extract changed files from github.event.commits array.
# Gitea Compare API rejects SHA-to-branch comparisons (BaseNotExist),
# so we use the commits array instead. This array contains all commits
# in the push, each with their added/removed/modified file lists.
printf '%s' "$COMMITS_JSON" \
| bash .gitea/scripts/push-commits-diff-files.py \
> .push-diff-files.txt 2>/dev/null || true
DIFF_FILES=$(cat .push-diff-files.txt 2>/dev/null || true)
if [ -n "$DIFF_FILES" ] && echo "$DIFF_FILES" | grep -qE '^workspace-server/|^canvas/|^tests/harness/|^.gitea/workflows/harness-replays\.yml$'; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
echo "debug=push-files=$DIFF_FILES" >> "$GITHUB_OUTPUT"
exit 0
# Extract branch name from refs/heads/main -> main
BASE_REF="${GITHUB_REF#refs/heads/}"
BASE_REF="${BASE_REF:-main}"
HEAD_REF="${GITHUB_REF#refs/heads/}"
BASE="$BASE_REF"
HEAD="$HEAD_REF"
else
# New branch or github.event.before unavailable — run everything.
echo "run=true" >> "$GITHUB_OUTPUT"
@@ -126,13 +106,25 @@ jobs:
exit 0
fi
# Call Gitea Compare API (pull_request path only — branch-to-branch).
# Push uses github.event.commits array above.
# Call Gitea Compare API to get the list of changed files.
# This is a Gitea-to-Gitea API call from within the Gitea Actions
# runner — it hits the local Gitea process, not the external network.
# No git network access needed from the runner container
# (runbooks/gitea-operational-quirks.md §runner-network-isolation).
#
# API shape: GET /repos/{owner}/{repo}/compare/{base}...{head}
# Returns { commits: [{ files: [{filename}] }] } — files are
# nested inside commits (Gitea quirk, not at top level).
RESP=$(curl -sS --fail --max-time 30 \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/json" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD")
DIFF_FILES=$(echo "$RESP" | bash .gitea/scripts/compare-api-diff-files.py 2>/dev/null || true)
DIFF_FILES=$(echo "$RESP" | python3 -c "
import sys; import json
d = json.load(sys.stdin)
files = [f.get('filename','') for c in d.get('commits',[]) for f in c.get('files',[]) if f.get('filename')]
print('\n'.join(files))
" 2>/dev/null || true)
echo "debug=diff-base=$BASE diff-files=$DIFF_FILES" >> "$GITHUB_OUTPUT"
@@ -220,14 +212,12 @@ jobs:
run: |
set -euo pipefail
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
echo "::warning::AUTO_SYNC_TOKEN not set — using anonymous clone (repos are public per manifest.json OSS contract)"
echo "::error::AUTO_SYNC_TOKEN secret is empty — register the devops-engineer persona PAT in repo Actions secrets"
exit 1
fi
mkdir -p .tenant-bundle-deps
# Strip JSON5 comments before jq parsing — Integration Tester appends
# `// Triggered by ...` which breaks `jq` in clone-manifest.sh.
sed '/^[[:space:]]*\/\//d' manifest.json > .manifest-stripped.json
bash scripts/clone-manifest.sh \
.manifest-stripped.json \
manifest.json \
.tenant-bundle-deps/workspace-configs-templates \
.tenant-bundle-deps/org-templates \
.tenant-bundle-deps/plugins
+4 -7
View File
@@ -37,13 +37,10 @@ name: main-red-watchdog
# "unknown on type" when `workflow_dispatch.inputs.X` is present. Revisit
# when Gitea ≥ 1.23 is fleet-wide.
on:
# SCHEDULE DISABLED 2026-05-12 — interim per RFC#420 Option-C machinery-down emergency
# Watchdog timing out behind runner saturation; rev3+dedicated-runner-label in flight
# Re-enable after rev3 lands + runner saturation root resolved
# schedule:
# # Hourly at :05 — task spec calls for "off-zero" (`5 * * * *`),
# # offset from :17 (ci-required-drift) and :00 (peak cron load).
# - cron: '5 * * * *'
schedule:
# Hourly at :05 — task spec calls for "off-zero" (`5 * * * *`),
# offset from :17 (ci-required-drift) and :00 (peak cron load).
- cron: '5 * * * *'
workflow_dispatch:
# Read commit status + branch ref + issues; write issues (open/PATCH/close).
@@ -54,12 +54,6 @@ env:
jobs:
build-and-push:
name: Build & push canvas image
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
# See issue #576 + infra-lead pulse ~00:30Z.
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
continue-on-error: true
@@ -85,10 +79,8 @@ jobs:
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
echo "Runner: ${HOSTNAME:-unknown}"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Runner: ${HOSTNAME:-unknown}"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
+9 -50
View File
@@ -23,23 +23,12 @@ name: publish-runtime-autobump
# and try to tag 0.1.130 simultaneously, only one of which would land.
on:
# Run on PR pushes to post a success status so Gitea can merge the PR.
# All steps use continue-on-error: true so operational failures
# (PyPI unreachable, DISPATCH_TOKEN missing) do not block merge.
pull_request:
paths:
- "workspace/**"
# Bump-and-tag on main/staging push (the actual operational trigger).
push:
branches:
- main
- staging
paths:
- "workspace/**"
# Manual dispatch — useful when Gitea Actions API (/actions/*) is
# unreachable (e.g. act_runner 404 on Gitea 1.22.6) and we cannot
# re-trigger via curl.
workflow_dispatch:
permissions:
contents: write # required to push tags back
@@ -49,52 +38,22 @@ concurrency:
cancel-in-progress: false
jobs:
# PR-validation path: always succeeds so Gitea can merge workflow-only PRs.
# Operational failures (PyPI unreachable, missing DISPATCH_TOKEN) are
# surfaced via continue-on-error: true rather than blocking the merge.
# The actual bump work happens on the main/staging push after merge.
pr-validate:
autobump-and-tag:
runs-on: ubuntu-latest
continue-on-error: true # do not block PR merge on operational failures
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Validate PyPI connectivity (best-effort)
run: |
set -eu
echo "=== Checking PyPI accessibility ==="
LATEST=$(curl -fsS --retry 3 --max-time 10 \
https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])" \
|| echo "PyPI unreachable (non-blocking for PR validation)")
echo "Latest: ${LATEST:-unknown}"
# Actual bump-and-tag: runs on main/staging pushes, posts real success/failure.
# No continue-on-error — operational failures here trip the main-red
# watchdog, which is the desired signal for infrastructure degradation.
bump-and-tag:
runs-on: ubuntu-latest
# Only fire on push events (main/staging after PR merge). Pull_request
# events are handled by pr-validate above; we do NOT bump on every
# push-synchronize because that would race with the PR head.
#
# NOTE: the prior condition `github.event.pull_request.base.ref == ''`
# was broken — on a PR-merge push in Gitea Actions, the pull_request
# context is still attached (base.ref='main'), so the condition always
# evaluated to false and bump-and-tag was permanently skipped.
if: github.event_name == 'push'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Shallow clone — depth 1 is enough for the workspace-diff check.
# Tags needed for the collision check below are fetched explicitly
# in the next step, bypassing the runner-network timeout that
# full-history fetch triggers on Gitea Actions runners
# (runbooks/gitea-operational-quirks.md §runner-network-isolation).
fetch-depth: 1
- name: Fetch tags for collision check
# fetch-depth: 1 gets only the most recent commit's refs, not the
# tag that points at it. Do a targeted tag fetch so git tag --list
# below can detect collision with prior manual pushes.
run: git fetch origin --tags --depth=1
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -32,9 +32,11 @@ on:
- '.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.
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
@@ -52,12 +54,6 @@ env:
jobs:
build-and-push:
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
# See issue #576 + infra-lead pulse ~00:30Z.
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -74,10 +70,8 @@ jobs:
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
echo "Runner: ${HOSTNAME:-unknown}"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Runner: ${HOSTNAME:-unknown}"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
exit 1
}
@@ -100,15 +94,13 @@ jobs:
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
run: |
set -euo pipefail
# clone-manifest.sh supports anonymous cloning for public repos (post-
# 2026-05-08 migration). The token is only needed for private repos.
# Do NOT require it — a missing secret would fail the build unnecessarily.
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
echo "::error::AUTO_SYNC_TOKEN secret is empty"
exit 1
fi
mkdir -p .tenant-bundle-deps
# Strip JSON5 comments before jq parsing — Integration Tester appends
# `// Triggered by ...` which breaks `jq` in clone-manifest.sh.
sed '/^[[:space:]]*\/\//d' manifest.json > .manifest-stripped.json
bash scripts/clone-manifest.sh \
.manifest-stripped.json \
manifest.json \
.tenant-bundle-deps/workspace-configs-templates \
.tenant-bundle-deps/org-templates \
.tenant-bundle-deps/plugins
@@ -125,11 +117,6 @@ jobs:
# 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).
# docker buildx bake / build required for `imagetools inspect` digest
# capture in the CP pin-update step (RFC internal#229 §X step 4 PR-1).
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
@@ -145,16 +132,17 @@ jobs:
ECR_REGISTRY="${IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker buildx build \
docker build \
--file ./workspace-server/Dockerfile \
--build-arg GIT_SHA="${GIT_SHA}" \
--label "org.opencontainers.image.source=https://git.moleculesai.app/molecule-ai/${REPO}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "molecule.workflow.run_id=${GITHUB_RUN_ID}" \
--label "org.opencontainers.image.description=Molecule AI platform — pending canary verify" \
--tag "${IMAGE_NAME}:${TAG_SHA}" \
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
--push .
.
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)
@@ -172,14 +160,15 @@ jobs:
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker buildx build \
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://git.moleculesai.app/molecule-ai/${REPO}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "molecule.workflow.run_id=${GITHUB_RUN_ID}" \
--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}" \
--push .
.
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
-164
View File
@@ -1,164 +0,0 @@
# qa-review — non-author APPROVE from the `qa` Gitea team required to merge.
#
# RFC#324 Step 1 of 5 (workflow-add). Pairs with `security-review.yml` and the
# branch-protection flip in Step 2.
#
# === DESIGN (RFC#324 v1.1 addendum) ===
#
# A1-α (refire mechanism):
# Triggers on:
# - `pull_request_target`: opened, synchronize, reopened
# → initial status posts when PR opens / re-pushes
# - `issue_comment`: /qa-recheck slash-command on the PR
# → manual re-fire after a QA reviewer clicks APPROVE
# (Gitea 1.22.6 doesn't re-fire on pull_request_review, per
# go-gitea/gitea#33700 + feedback_pull_request_review_no_refire)
# Workflow name = `qa-review` ; job name = `approved`.
# The job's own pass/fail conclusion publishes the status context
# `qa-review / approved (<event>)` — NO `POST /statuses` call → NO
# write:repository token scope needed. Sidesteps internal#321 defect #2.
#
# A1.1 (privilege check on slash-comment — INFORMATIONAL ONLY, NOT a gate):
# The `issue_comment` event fires for ANY commenter, including
# non-collaborators. The original (v1.2) design gated the eval step
# behind a collaborator probe → if a non-collaborator commented
# /qa-recheck, the eval was `if:`-skipped → the job exited 0 anyway →
# the status context published `success` with ZERO real APPROVE.
# That was a fail-open: any visitor could green the gate.
#
# RFC#324 v1.3 §A1.1 correction (option b per hongming-pc 1421):
# drop privilege-gating of the evaluation entirely. The eval is
# read-only and idempotent — it reads `pulls/{N}/reviews` and
# `teams/{id}/members/{u}` (both API-side state that a commenter can't
# change). Re-running it on a non-collaborator's comment is harmless
# AND correct: if a real team-member APPROVE exists, the eval flips
# green; if not, it stays red.
#
# We KEEP the privilege step as a `::notice::` log line only — useful
# for griefer-spotting (one operator spamming /recheck) without
# touching the gate. If rate-limiting is needed later, add it as a
# separate concern (time-window throttle, not a privilege gate).
#
# We MUST NOT use `github.event.comment.author_association` (the
# field doesn't exist on Gitea 1.22.6 webhook payload — this was
# sop-tier-refire's defect #1).
#
# A4 (no PR-head checkout under pull_request_target):
# We check out the BASE ref explicitly so the review-check.sh script is
# loaded from trusted source. We NEVER use `ref: ${{ github.event.pull_request.head.sha }}`.
# No PR-head code is executed in the runner. Trust boundary preserved.
#
# A5 (real Gitea team):
# `qa` team (id=20) verified by orchestrator preflight 2026-05-11; queried
# at run time via /api/v1/teams/20/members/{login}.
#
# === TOKEN ===
#
# The workflow reads PR state, PR reviews, and team membership.
# Gitea 1.22.6's /api/v1/teams/{id}/members/{u} returns 403 ('Must be a
# team member') for tokens whose owner is not in that team. The default
# `secrets.GITHUB_TOKEN` is owned by a workflow-scoped identity that is
# also not in qa/security teams → also 403.
#
# Resolution: a dedicated `RFC_324_TEAM_READ_TOKEN` secret, owned by an
# identity that IS in both `qa` and `security` teams (Owners-tier
# claude-ceo-assistant, or a new service-bot added to both teams).
# Provisioning of this secret is tracked as a follow-up issue (filed by
# core-devops at PR open).
#
# Until that secret is provisioned, the job will exit 1 with a clear
# 403-on-team-probe error and the `qa-review / approved` status will
# stay `failure`. This is the correct fail-closed behavior — the gate
# blocks merge until both (a) a QA team member APPROVEs and (b) the
# workflow has a token that can confirm their team membership.
#
# === SLASH-COMMAND CONTRACT ===
#
# /qa-recheck — re-evaluate the gate (e.g. after an APPROVE lands)
#
# Open to any PR commenter. The eval is read-only and idempotent, so
# unprivileged refires are harmless (RFC#324 v1.3 §A1.1). Collaborator
# status is logged for griefer-spotting but does NOT gate execution.
name: qa-review
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
jobs:
approved:
# Gate the job:
# - On pull_request_target events: always run.
# - On issue_comment events: only when it's a PR comment and the body
# contains the slash-command. NO privilege gate at the step level
# (RFC#324 v1.3 §A1.1): a non-collaborator's /qa-recheck is fine
# because the eval is read-only and idempotent — re-running it
# just re-confirms whether a real team-member APPROVE exists.
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/qa-recheck'))
runs-on: ubuntu-latest
steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
# RFC#324 v1.3 §A1.1: this step does NOT gate subsequent steps.
# It exists solely as a log line for griefer-spotting (one
# operator spamming /qa-recheck without merit). Re-running the
# read-only eval on a non-collaborator comment is harmless;
# gating it would be fail-open (skipped steps still publish
# `success` for the job's status context).
# Only runs on issue_comment events; pull_request_target has
# no comment.user.login so the step is a no-op skip there.
if: github.event_name == 'issue_comment'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
login="${{ github.event.comment.user.login }}"
# Write token to a mode-600 file so it never appears in curl's argv.
# (#541: -H "Authorization: token $TOKEN" puts the secret in /proc/<pid>/cmdline)
authfile=$(mktemp)
chmod 600 "$authfile"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
code=$(curl -sS -o /dev/null -w '%{http_code}' -K "$authfile" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/collaborators/${login}")
rm -f "$authfile"
if [ "$code" = "204" ]; then
echo "::notice::Recheck from ${login} (collaborator=true)"
else
echo "::notice::Recheck from ${login} (collaborator=false, HTTP ${code}) — proceeding with read-only eval anyway"
fi
- name: Check out BASE ref (A4 — never PR-head)
# Loads the review-check.sh script from a trusted ref. For
# pull_request_target the default checkout is BASE already; we
# set ref explicitly for the issue_comment event too so the
# script source is always the default-branch version.
# NEVER use ref: ${{ github.event.pull_request.head.sha }} —
# that would execute PR-head code with secrets-context.
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Evaluate qa-review
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
# PR number lives in different places per event:
# pull_request_target → github.event.pull_request.number
# issue_comment → github.event.issue.number
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
TEAM: qa
TEAM_ID: '20'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
run: bash .gitea/scripts/review-check.sh
-70
View File
@@ -1,70 +0,0 @@
name: review-check-tests
# Runs review-check.sh regression tests on every PR + push that touches
# the evaluator script or its test fixtures.
#
# Follows RFC#324 follow-up (issue #540):
# .gitea/scripts/review-check.sh is load-bearing for PR merge gates.
# It has ZERO production CI coverage. This workflow closes that gap.
#
# Design choices:
# - Bash test harness (not bats). The existing test_review_check.sh
# uses a custom assert_eq/assert_contains framework that is already
# working and covers all 13 acceptance criteria (issue #540 §Acceptance).
# Converting to bats would be refactoring, not closing the gap.
# - No bats dependency: the runner-base image needs no extra tooling.
# - continue-on-error: false — these tests must pass; a failure means
# the review-gate evaluator is broken and must not be merged.
on:
push:
branches: [main, staging]
paths:
- '.gitea/scripts/review-check.sh'
- '.gitea/scripts/tests/test_review_check.sh'
- '.gitea/scripts/tests/_review_check_fixture.py'
- '.gitea/workflows/review-check-tests.yml'
pull_request:
branches: [main, staging]
paths:
- '.gitea/scripts/review-check.sh'
- '.gitea/scripts/tests/test_review_check.sh'
- '.gitea/scripts/tests/_review_check_fixture.py'
- '.gitea/workflows/review-check-tests.yml'
workflow_dispatch:
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: review-check.sh regression tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install jq
# Required for T12 jq-filter test case. Gitea Actions runners (ubuntu-latest
# label) do not bundle jq. Install via apt-get first (reliable for Ubuntu
# runners with internet access to package mirrors). Falls back to GitHub
# binary download. GitHub releases may be blocked on some runner networks
# (infra#241 follow-up).
continue-on-error: true
run: |
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 — continuing"
- name: Run review-check.sh regression suite
run: bash .gitea/scripts/tests/test_review_check.sh
-72
View File
@@ -1,72 +0,0 @@
# security-review — non-author APPROVE from the `security` Gitea team
# required to merge.
#
# RFC#324 Step 1 of 5 (workflow-add). Mirror of `qa-review.yml`; differs
# only in TEAM=security, TEAM_ID=21, and the slash-command name.
#
# See `qa-review.yml` header for the full A1-α / A1.1 / A4 / A5 design
# rationale; everything below is identical in shape.
name: security-review
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
jobs:
approved:
# See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational
# log only, NOT a gate) / A4 / A5 design rationale.
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/security-recheck'))
runs-on: ubuntu-latest
steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
# RFC#324 v1.3 §A1.1: does NOT gate subsequent steps. See
# qa-review.yml for full rationale. Eval is read-only/idempotent
# so re-running on a non-collaborator comment is harmless.
if: github.event_name == 'issue_comment'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
login="${{ github.event.comment.user.login }}"
# Write token to a mode-600 file so it never appears in curl's argv.
# (#541: -H "Authorization: token $TOKEN" puts the secret in /proc/<pid>/cmdline)
authfile=$(mktemp)
chmod 600 "$authfile"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
code=$(curl -sS -o /dev/null -w '%{http_code}' -K "$authfile" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/collaborators/${login}")
rm -f "$authfile"
if [ "$code" = "204" ]; then
echo "::notice::Recheck from ${login} (collaborator=true)"
else
echo "::notice::Recheck from ${login} (collaborator=false, HTTP ${code}) — proceeding with read-only eval anyway"
fi
- name: Check out BASE ref (A4 — never PR-head)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Evaluate security-review
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
TEAM: security
TEAM_ID: '21'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
run: bash .gitea/scripts/review-check.sh
-118
View File
@@ -1,118 +0,0 @@
# status-reaper — Option B (compensating-status POST) for Gitea 1.22.6's
# hardcoded `(push)` suffix on default-branch commit statuses.
#
# Tracking: molecule-core#? (this PR), internal#327 (sibling publish-runtime-bot),
# internal#328 (sibling mc-drift-bot), internal#80 (upstream RFC). Sister
# bots already deployed under the same per-persona-identity contract
# (`feedback_per_agent_gitea_identity_default`).
#
# Root cause:
# Gitea 1.22.6 emits commit-status context as
# `<workflow_name> / <job_name> (push)`
# for ANY workflow run on the default branch's HEAD commit, REGARDLESS
# of the trigger event. Schedule- and workflow_dispatch-triggered runs
# on `main` therefore appear as `(push)` failures on the latest main
# commit, painting main red via a fake-push status. Verified on runs
# 14525 + 14526 via Phase 1 evidence (3 sub-agents). No upstream fix
# in 1.23-1.26.1 (sibling a6f20db1 research).
#
# Why a cron-driven reaper, not workflow_run:
# Gitea 1.22.6 does NOT support `on: workflow_run` (verified via
# modules/actions/workflows.go enumeration; sister a6f20db1). The
# only event-shaped option that fires is cron. 5min is chosen to
# sit BETWEEN ci-required-drift (`:17` hourly) and main-red-watchdog
# (`:05` hourly) so the reaper sweeps red before the watchdog files
# a `[main-red]` issue (would-be false-positive).
#
# What the reaper does each tick:
# 1. Parse `.gitea/workflows/*.yml`, classify each by whether `on:`
# contains a `push:` trigger (see script for workflow_id resolution
# including `name:` collision and `/`-in-name fail-loud lints).
# 2. GET combined status for main HEAD.
# 3. For each `failure` status whose context ends ` (push)`:
# - if workflow has push trigger: PRESERVE (real defect signal).
# - if workflow has no push trigger: POST a compensating
# `state=success` with the same context and a description that
# documents the workaround.
#
# What it does NOT do:
# - Mutate non-`(push)`-suffix statuses (e.g. `(pull_request)` from
# branch_protections required-checks — verified safe 2026-05-11).
# - Auto-revert. Same reasoning as main-red-watchdog.
# - Cancel runs. The runs themselves stay visible in Actions UI; the
# fix is at the commit-status surface only.
#
# Removal path: drop this workflow when Gitea ≥ 1.24 ships with a
# real fix for the hardcoded-suffix bug. Audit issue (filed post-merge)
# tracks the deletion as a follow-up sweep.
name: status-reaper
# IMPORTANT — Gitea 1.22.6 parser quirk per
# `feedback_gitea_workflow_dispatch_inputs_unsupported`: do NOT add an
# `inputs:` block here. Gitea 1.22.6 rejects the whole workflow as
# "unknown on type" when `workflow_dispatch.inputs.X` is present.
on:
# SCHEDULE DISABLED 2026-05-12 — interim per RFC#420 Option-C machinery-down emergency
# Reaper rev2 not compensating + watchdog timeout-cascade; rev3 in flight
# Re-enable after rev3 lands + runner saturation root resolved
# schedule:
# # Every 5 minutes. Off-zero alignment with sibling cron workflows:
# # ci-required-drift (`:17`), main-red-watchdog (`:05`),
# # railway-pin-audit (`:23`). 5-min cadence gives a tight enough
# # close on schedule-triggered false-reds that main-red-watchdog
# # (hourly :05) almost never files an issue on the false case.
# - cron: '*/5 * * * *'
workflow_dispatch:
# Compensating-status POST needs write on repo statuses; no other
# write surface is touched. checkout still needs `contents: read`.
permissions:
contents: read
# NOTE: NO `concurrency:` block is intentional.
# Gitea 1.22.6 doesn't honor `cancel-in-progress: false`: queued ticks
# of the same group get cancelled-with-started=0 instead of waiting
# (DB-verified 2026-05-12, runs 16053/16085 of status-reaper.yml).
# The reaper's POST /statuses/{sha} is idempotent — Gitea de-dups by
# context — so concurrent ticks are safe; accept them rather than
# serialise via the broken mechanism.
jobs:
reap:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Check out repo at default-branch HEAD
# BASE checkout per `feedback_pull_request_target_workflow_from_base`.
# The script reads .gitea/workflows/*.yml from the working tree to
# classify trigger sets; we must read main's CURRENT state, not
# the SHA a stale schedule fired against.
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Set up Python (PyYAML for workflow `on:` parse)
# Pinned to 3.12 to match sibling watchdog / ci-required-drift.
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'
- name: Install PyYAML
# PyYAML is needed because shell-grep on `on:` misses list/string
# forms and nested `push: { paths: ... }`. Same install pattern
# as ci-required-drift.yml (sub-2s install, no wheel cache).
run: python -m pip install --quiet 'PyYAML==6.0.2'
- name: Compensate operational push-suffix failures on main
env:
# claude-status-reaper persona token; provisioned by sibling
# aefaac1b 2026-05-11. Owns write:repository scope to POST
# /statuses/{sha} but NOTHING ELSE
# (`feedback_per_agent_gitea_identity_default`).
GITEA_TOKEN: ${{ secrets.STATUS_REAPER_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
WORKFLOWS_DIR: .gitea/workflows
run: python3 .gitea/scripts/status-reaper.py
+11 -11
View File
@@ -29,15 +29,13 @@ name: Sweep stale AWS Secrets Manager secrets
# reconciler enumerator) is filed as a separate controlplane
# issue. This sweeper is the immediate cost-relief stopgap.
#
# AWS credentials: the confirmed Gitea secrets are AWS_ACCESS_KEY_ID /
# AWS_SECRET_ACCESS_KEY (the molecule-cp IAM user). These are the same
# credentials used by the rest of the platform. The dedicated
# AWS_JANITOR_* naming (which the original GitHub workflow used) was
# never populated in Gitea — the existing secrets are AWS_ACCESS_KEY_ID /
# AWS_SECRET_ACCESS_KEY (per issue #425 §425 audit). These DO have
# secretsmanager:ListSecrets (the production molecule-cp principal);
# if ListSecrets is revoked in future, a dedicated janitor principal
# would need to be created and the Gitea secret names updated here.
# IAM principal: AWS_JANITOR_ACCESS_KEY_ID / AWS_JANITOR_SECRET_ACCESS_KEY.
# This is a DEDICATED principal — the production `molecule-cp` IAM
# user lacks `secretsmanager:ListSecrets` (it only has
# Get/Create/Update/Delete on specific resources, scoped to its
# operational needs). The janitor needs ListSecrets across the
# `molecule/tenant/*` prefix, which warrants a separate principal so
# we don't broaden the prod-CP policy.
#
# Safety: the script's MAX_DELETE_PCT gate (default 50%, mirroring
# sweep-cf-orphans.yml — tenant secrets are durable by design, unlike
@@ -73,8 +71,8 @@ jobs:
timeout-minutes: 30
env:
AWS_REGION: ${{ secrets.AWS_REGION || 'us-east-1' }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_JANITOR_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_JANITOR_SECRET_ACCESS_KEY }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
CP_STAGING_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
@@ -101,11 +99,13 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::skipping sweep — secrets not configured: ${missing[*]}"
echo "::warning::set them at Settings → Secrets and Variables → Actions, then rerun."
echo "::warning::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/* (the prod molecule-cp principal lacks ListSecrets)."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::sweep cannot run — required secrets missing: ${missing[*]}"
echo "::error::set them at Settings → Secrets and Variables → Actions, or disable this workflow."
echo "::error::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/*."
exit 1
fi
echo "All required secrets present ✓"
-5
View File
@@ -33,11 +33,6 @@ name: Sweep stale Cloudflare DNS records
# gate halts before damage. Decision-function unit tests in
# scripts/ops/test_sweep_cf_decide.py (#2027) cover the rule
# classifier.
#
# Secrets: CF_API_TOKEN, CF_ZONE_ID, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
# are confirmed existing per issue #425 §425 audit. CP_ADMIN_API_TOKEN and
# CP_STAGING_ADMIN_API_TOKEN are unconfirmed — if missing, the verify step
# (schedule → hard-fail, dispatch → soft-skip) surfaces it clearly.
on:
schedule:
-5
View File
@@ -28,11 +28,6 @@ name: Sweep stale Cloudflare Tunnels
# Safety: the script's MAX_DELETE_PCT gate (default 90% — higher than
# the DNS sweep's 50% because tenant-shaped tunnels are mostly
# orphans by design) refuses to nuke past the threshold.
#
# Secrets: CF_API_TOKEN, CF_ACCOUNT_ID are confirmed existing per
# issue #425 §425 audit. CP_ADMIN_API_TOKEN and CP_STAGING_ADMIN_API_TOKEN
# are unconfirmed — if missing, the verify step (schedule → hard-fail,
# dispatch → soft-skip) surfaces it clearly.
on:
schedule:
-109
View File
@@ -1,109 +0,0 @@
name: Weekly Platform-Go Surface
# Surface latent vet/test errors on main by running the full Platform-Go
# suite on a weekly cron regardless of whether the last push touched
# workspace-server/.
#
# Background: ci.yml's `platform-build` job gates real work on
# `if: needs.changes.outputs.platform == 'true'`. When no push touches
# workspace-server/, the skip fires and the suite never executes on main.
# Latent vet errors and test flakes can sit for weeks undetected.
#
# This workflow runs the full suite (build, vet, golangci-lint, tests with
# coverage) every Monday at 04:17 UTC. Results are posted as commit statuses
# but continue-on-error: true means they never block anything — they're
# purely a noise-reduction signal for when the next workspace-server push
# lands and would otherwise trigger the first real suite run.
#
# Why 04:17 UTC on Monday: off-peak, before the weekly sprint cycle starts.
on:
schedule:
- cron: '17 4 * * 1' # Mondays at 04:17 UTC
workflow_dispatch:
permissions:
contents: read
statuses: write
jobs:
weekly-platform-go:
name: Weekly Platform-Go Surface
runs-on: ubuntu-latest
# continue-on-error: surface only, never block
continue-on-error: true
defaults:
run:
working-directory: workspace-server
steps:
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
fetch-depth: 1
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: stable
- name: Go mod download
run: go mod download
- name: Build
run: go build ./cmd/server
- name: go vet
run: go vet ./... || true
- name: golangci-lint
run: golangci-lint run --timeout 3m ./... || true
- name: Tests with race detection + coverage
run: go test -race -coverprofile=coverage.out ./...
- name: Check coverage thresholds
run: |
set -e
TOTAL_FLOOR=25
CRITICAL_PATHS=(
"internal/handlers/tokens"
"internal/handlers/workspace_provision"
"internal/handlers/a2a_proxy"
"internal/handlers/registry"
"internal/handlers/secrets"
"internal/middleware/wsauth"
"internal/crypto"
)
TOTAL=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $3}' | sed 's/%//')
echo "Total coverage: ${TOTAL}%"
if awk "BEGIN{exit !(\$TOTAL < \$TOTAL_FLOOR)}"; then
echo "::error::Total coverage \${TOTAL}% is below the \${TOTAL_FLOOR}% floor."
exit 1
fi
ALLOWLIST=""
if [ -f ../.coverage-allowlist.txt ]; then
ALLOWLIST=$(grep -vE '^(#|[[:space:]]*$)' ../.coverage-allowlist.txt || true)
fi
FAILED=0
for path in "\${CRITICAL_PATHS[@]}"; do
while read -r file pct; do
[[ "$file" == *_test.go ]] && continue
[[ "$file" == *"$path"* ]] || continue
awk "BEGIN{exit !(\$pct < 10)}" || continue
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
continue
fi
echo "::error::Low coverage \${pct}% on \${rel} (below 10% in critical path \${path})"
FAILED=$((FAILED + 1))
done < <(go tool cover -func=coverage.out | grep -v '^total:' | awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++} END {for (f in s) printf "%s %.1f\n", f, s[f]/c[f]}' | sort)
done
if [ "$FAILED" -gt 0 ]; then
echo "::error::\${FAILED} critical paths below 10% coverage — see above."
exit 1
fi
echo "Coverage thresholds: OK"
-1
View File
@@ -1 +0,0 @@
staging trigger
-10
View File
@@ -156,16 +156,6 @@ and run CI manually.
| python-lint | pytest with coverage |
| e2e-api | Full API test suite (62 tests) |
| shellcheck | Shell script linting |
| review-check-tests | `review-check.sh` evaluator regression suite (13 scenarios) |
| ops-scripts | Python unittest suite for `scripts/*.py` |
## Local Testing
### review-check.sh
```bash
bash .gitea/scripts/tests/test_review_check.sh
```
Runs the full regression suite against a fixture HTTP server. No network access required.
## Code Style
@@ -2,49 +2,24 @@
/**
* Tests for ApprovalBanner component.
*
* Patches api.get and api.post via Object.defineProperty in beforeEach.
* This is resilient to vi.restoreAllMocks() from OTHER test files because
* defineProperty patches are NOT restored by vi.restoreAllMocks().
* Covers: renders nothing when no approvals, polls /approvals/pending,
* shows approval cards, approve/deny decisions, toast notifications.
*
* showToast is patched by setting showToast.mockImplementation in beforeEach
* the component imports showToast from @/components/Toaster, which is mocked
* in this file. vi.mocked(showToast) always refers to the mock from THIS file's
* vi.mock, not from aria-time-sensitive.test.tsx (separate virtual module).
* All blocks use vi.useFakeTimers() consistently in beforeEach/afterEach to
* avoid polluting the fake-timer state for subsequent test files. The
* vi.spyOn mocks are reset per-spy via mockReset() in afterEach so each
* test gets a clean mock state without touching the module-level api mock.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { ApprovalBanner } from "../ApprovalBanner";
import { showToast } from "@/components/Toaster";
import { api } from "@/lib/api";
// Mock @/components/Toaster at module level — creates a vi.fn() spy for showToast.
vi.mock("@/components/Toaster", () => ({
showToast: vi.fn(),
}));
import { showToast } from "@/components/Toaster";
// Store originals — restored manually in afterEach.
const origGet = api.get;
const origPost = api.post;
afterEach(() => {
cleanup();
vi.useRealTimers();
// Manually restore — NOT vi.restoreAllMocks() which would also restore
// api.post/get that aria-time-sensitive.test.tsx patched.
Object.defineProperty(api, "get", { value: origGet, writable: true, configurable: true });
Object.defineProperty(api, "post", { value: origPost, writable: true, configurable: true });
});
// Patch api.get and api.post in beforeEach.
// Object.defineProperty bypasses vi.restoreAllMocks().
function patchApi(overrides: { get?: unknown; post?: unknown } = {}) {
const getMock = overrides.get ?? vi.fn();
const postMock = overrides.post ?? vi.fn();
Object.defineProperty(api, "get", { value: getMock, writable: true, configurable: true });
Object.defineProperty(api, "post", { value: postMock, writable: true, configurable: true });
return { getMock, postMock };
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -66,12 +41,22 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
created_at: "2026-05-10T10:00:00Z",
});
// Shared spy references so individual tests can reset or reject the POST mock
// without needing to call spyOn again (which would create a duplicate spy).
let mockGet: ReturnType<typeof vi.spyOn>;
let mockPost: ReturnType<typeof vi.spyOn>;
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ApprovalBanner — empty state", () => {
beforeEach(() => {
vi.useFakeTimers();
patchApi({ get: vi.fn().mockResolvedValue([]) });
vi.spyOn(api, "get").mockResolvedValueOnce([]);
});
afterEach(() => {
cleanup();
vi.useFakeTimers();
});
it("renders nothing when there are no pending approvals", async () => {
@@ -91,12 +76,16 @@ describe("ApprovalBanner — empty state", () => {
describe("ApprovalBanner — renders approval cards", () => {
beforeEach(() => {
vi.useFakeTimers();
patchApi({
get: vi.fn().mockResolvedValue([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
]),
});
mockGet = vi.spyOn(api, "get").mockResolvedValueOnce([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
]);
});
afterEach(() => {
cleanup();
mockGet?.mockReset();
vi.useFakeTimers();
});
it("renders an alert card for each pending approval", async () => {
@@ -121,12 +110,10 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("omits the reason div when reason is null", async () => {
patchApi({
get: vi.fn().mockResolvedValue([{
...pendingApproval("a1"),
reason: null,
}]),
});
vi.spyOn(api, "get").mockResolvedValueOnce([{
...pendingApproval("a1"),
reason: null,
}]);
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
expect(screen.queryByText(/requires human approval/i)).toBeNull();
@@ -137,6 +124,7 @@ describe("ApprovalBanner — renders approval cards", () => {
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const approveBtns = screen.getAllByRole("button", { name: /Approve/i });
const denyBtns = screen.getAllByRole("button", { name: /Deny/i });
// 2 cards, each card has 1 Approve + 1 Deny button → 2 of each minimum
expect(approveBtns.length).toBeGreaterThanOrEqual(2);
expect(denyBtns.length).toBeGreaterThanOrEqual(2);
});
@@ -150,16 +138,17 @@ describe("ApprovalBanner — renders approval cards", () => {
});
describe("ApprovalBanner — decisions", () => {
let mockGet: ReturnType<typeof vi.fn>;
let mockPost: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
const patched = patchApi();
mockGet = patched.getMock as ReturnType<typeof vi.fn>;
mockPost = patched.postMock as ReturnType<typeof vi.fn>;
mockGet.mockResolvedValue([pendingApproval("a1")]);
mockPost.mockResolvedValue({});
mockGet = vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
mockPost = vi.spyOn(api, "post").mockResolvedValue({});
});
afterEach(() => {
cleanup();
mockGet?.mockReset();
mockPost?.mockReset();
vi.useFakeTimers();
});
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
@@ -167,7 +156,7 @@ describe("ApprovalBanner — decisions", () => {
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
expect(mockPost).toHaveBeenCalledWith(
expect(vi.mocked(api.post)).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
expect.objectContaining({ decision: "approved" })
);
@@ -178,7 +167,7 @@ describe("ApprovalBanner — decisions", () => {
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /deny/i })[0]);
await act(async () => { /* flush */ });
expect(mockPost).toHaveBeenCalledWith(
expect(vi.mocked(api.post)).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
expect.objectContaining({ decision: "denied" })
);
@@ -209,19 +198,39 @@ describe("ApprovalBanner — decisions", () => {
expect(vi.mocked(showToast)).toHaveBeenCalledWith("Denied", "info");
});
// NOTE: error-handling tests (POST rejection + card visibility / error toast)
// require vi.advanceTimersByTimeAsync() to flush the rejection microtask while
// the component is still mounted. With vi.useFakeTimers() in beforeEach, the
// component's setInterval poll fires every 10s and creates an infinite loop with
// vi.runAllTimersAsync(). Skipping these timing-sensitive tests to keep the suite
// deterministic. The core POST call + toast functionality is fully covered by the
// success/deny tests above.
it("shows an error toast when POST fails", async () => {
mockPost.mockReset().mockRejectedValue(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
expect(vi.mocked(showToast)).toHaveBeenCalledWith(
"Failed to submit decision",
"error"
);
});
it("keeps the card visible when the POST fails", async () => {
// Reset the post mock before rejecting so the beforeEach's resolved value
// is gone and we get a clean rejection instead of a resolved→rejected queue.
mockPost.mockReset().mockRejectedValue(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
expect(screen.getAllByRole("alert")).toHaveLength(1);
});
});
describe("ApprovalBanner — handles empty list from server", () => {
beforeEach(() => {
vi.useFakeTimers();
patchApi({ get: vi.fn().mockResolvedValue([]) });
vi.spyOn(api, "get").mockResolvedValueOnce([]);
});
afterEach(() => {
cleanup();
vi.useFakeTimers();
});
it("shows nothing when the API returns an empty array on first poll", async () => {
@@ -37,50 +37,79 @@ function makeBundle(name = "test-workspace"): File {
});
}
// jsdom doesn't define DragEvent globally; create a dragover event with
// dataTransfer.types stubbed to include "Files" so handleDragOver triggers.
function createDragOverEvent() {
return Object.assign(new Event("dragover", { bubbles: true, cancelable: true }), {
dataTransfer: { types: ["Files"], files: null },
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("BundleDropZone — render", () => {
it("renders a hidden file input with correct accept and aria-label", () => {
render(<BundleDropZone />);
const { container } = render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.getAttribute("type")).toBe("file");
expect(input.getAttribute("accept")).toBe(".bundle.json");
expect(input.getAttribute("id")).toBe("bundle-file-input");
});
it("renders the keyboard-accessible import button with aria-label", () => {
render(<BundleDropZone />);
const btn = screen.getByRole("button", { name: /import bundle/i });
expect(btn).toBeTruthy();
const { container } = render(<BundleDropZone />);
const btn = container.querySelector('button[aria-label="Import bundle file"]') as HTMLButtonElement;
expect(btn).not.toBeNull();
expect(btn.getAttribute("aria-controls")).toBe("bundle-file-input");
});
});
describe("BundleDropZone — drag state", () => {
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();
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.useRealTimers();
});
it("has the invisible drop zone div covering the viewport", () => {
render(<BundleDropZone />);
// The primary drop zone: pointer-events-none by default
const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]');
expect(zone).toBeTruthy();
it("shows the drop overlay when a file is dragged over", async () => {
vi.useFakeTimers();
const { container } = render(<BundleDropZone />);
// Overlay should not be visible initially
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
// Simulate drag-over: stub dataTransfer.types to include "Files"
// so handleDragOver calls setIsDragging(true)
const zone = document.body.querySelector('[class*="z-10"]') as HTMLElement;
if (zone) {
const dragOverEvent = createDragOverEvent();
fireEvent.dragOver(zone, dragOverEvent);
}
await act(async () => { vi.runOnlyPendingTimers(); });
// After dragOver, overlay should be visible. The overlay has z-20 class.
const overlay = screen.getByText("Drop Bundle to Import").closest('[class*="z-20"]');
expect(overlay).not.toBeNull();
vi.useRealTimers();
});
it("hides the drop overlay when not dragging", () => {
const { container } = 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 { container } = render(<BundleDropZone />);
// Both the hidden file input and the button have aria-label="Import bundle file".
// Use the file input's id to select it uniquely.
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.getAttribute("type")).toBe("file");
const clickSpy = vi.spyOn(input, "click");
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
const btn = container.querySelector('button[aria-label="Import bundle file"]') as HTMLButtonElement;
fireEvent.click(btn);
expect(clickSpy).toHaveBeenCalled();
});
@@ -92,7 +121,7 @@ describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
status: "online",
});
render(<BundleDropZone />);
const { container } = render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("My Bundle");
@@ -124,7 +153,7 @@ describe("BundleDropZone — import success", () => {
status: "online",
});
render(<BundleDropZone />);
const { container } = render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Success Workspace");
@@ -136,14 +165,14 @@ describe("BundleDropZone — import success", () => {
vi.advanceTimersByTime(500);
});
// Success toast should be visible
expect(screen.getByText(/imported "my workspace" successfully/i)).toBeTruthy();
// Success toast should be visible — scope to container for DOM isolation
expect(container.textContent).toMatch(/imported "my workspace" successfully/i);
// Toast auto-clears after 4000ms
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByRole("status")).toBeNull();
expect(container.querySelector('[role="status"]')).toBeNull();
vi.useRealTimers();
});
@@ -155,7 +184,7 @@ describe("BundleDropZone — import success", () => {
status: "online",
});
render(<BundleDropZone />);
const { container } = render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Timed Workspace");
@@ -166,12 +195,12 @@ describe("BundleDropZone — import success", () => {
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByText(/timed workspace/i)).toBeTruthy();
expect(container.textContent).toMatch(/timed workspace/i);
await act(async () => {
vi.advanceTimersByTime(4500);
});
expect(screen.queryByText(/timed workspace/i)).toBeNull();
expect(container.textContent).not.toMatch(/timed workspace/i);
vi.useRealTimers();
});
});
@@ -181,7 +210,7 @@ describe("BundleDropZone — import error", () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
render(<BundleDropZone />);
const { container } = render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Failed Workspace");
@@ -193,13 +222,13 @@ describe("BundleDropZone — import error", () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText(/import failed: 500 internal server error/i)).toBeTruthy();
expect(container.textContent).toMatch(/import failed: 500 internal server error/i);
vi.useRealTimers();
});
it("shows error when file is not a .bundle.json", async () => {
vi.useFakeTimers();
render(<BundleDropZone />);
const { container } = render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
@@ -211,12 +240,12 @@ describe("BundleDropZone — import error", () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText(/only .bundle.json files are accepted/i)).toBeTruthy();
expect(container.textContent).toMatch(/only .bundle.json files are accepted/i);
// Error clears after 3000ms
await act(async () => {
vi.advanceTimersByTime(3500);
});
expect(screen.queryByText(/only .bundle.json/i)).toBeNull();
expect(container.textContent).not.toMatch(/only .bundle.json/i);
vi.useRealTimers();
});
@@ -224,7 +253,7 @@ describe("BundleDropZone — import error", () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
render(<BundleDropZone />);
const { container } = render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Error Workspace");
@@ -235,12 +264,12 @@ describe("BundleDropZone — import error", () => {
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByText(/network error/i)).toBeTruthy();
expect(container.textContent).toMatch(/network error/i);
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText(/network error/i)).toBeNull();
expect(container.textContent).not.toMatch(/network error/i);
vi.useRealTimers();
});
});
@@ -252,7 +281,7 @@ describe("BundleDropZone — importing state", () => {
const pending = new Promise((r) => { resolve = r; });
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
render(<BundleDropZone />);
const { container } = render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Pending Workspace");
@@ -265,8 +294,10 @@ describe("BundleDropZone — importing state", () => {
vi.advanceTimersByTime(100);
});
expect(screen.getByText("Importing bundle...")).toBeTruthy();
expect(screen.getByRole("status")).toBeTruthy();
// Scope to container for DOM isolation — other components may have
// role=status and text "Importing bundle..." in the shared jsdom env.
expect(container.textContent).toMatch(/importing bundle/i);
expect(container.querySelector('[role="status"]')).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(500);
@@ -284,7 +315,7 @@ describe("BundleDropZone — file input reset", () => {
status: "online",
});
render(<BundleDropZone />);
const { container } = render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = makeBundle("Reset Test");
@@ -1,285 +1,13 @@
// @vitest-environment jsdom
/**
* Tests for ConfirmDialog — portal-based confirmation dialog.
*
* Covers: open=false → null render, portal attach, title + message,
* Cancel + Confirm buttons, variant classes (danger/warning/primary),
* singleButton prop, click handlers, Escape/Enter/Backdrop keyboard
* handlers, Tab trap, focus management, aria-modal + aria-labelledby.
*/
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { ConfirmDialog } from "../ConfirmDialog";
afterEach(cleanup);
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ConfirmDialog — render conditions", () => {
it("renders nothing when open=false", () => {
render(
<ConfirmDialog
open={false}
title="Title"
message="Message"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(document.body.textContent).toBe("");
});
it("renders dialog via portal when open=true", () => {
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
});
it("portal container is a direct child of document.body", () => {
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
// createPortal appends to document.body as a container div; the dialog
// div is nested inside that container.
const portalRoot = document.body.querySelector('[role="dialog"]')?.parentElement;
expect(portalRoot?.parentElement).toBe(document.body);
});
it("displays the title", () => {
render(
<ConfirmDialog
open
title="Delete this workspace?"
message="Message"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
"Delete this workspace?",
);
});
it("displays the message", () => {
render(
<ConfirmDialog
open
title="Title"
message="This cannot be undone."
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
"This cannot be undone.",
);
});
afterEach(() => {
cleanup();
});
describe("ConfirmDialog — buttons", () => {
it("renders Cancel and Confirm buttons by default", () => {
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Confirm" })).toBeTruthy();
});
it("fires onConfirm when Confirm button is clicked", () => {
const onConfirm = vi.fn();
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={onConfirm} onCancel={vi.fn()} />,
);
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("fires onCancel when Cancel button is clicked", () => {
const onCancel = vi.fn();
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={onCancel} />,
);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("does NOT fire onCancel when Confirm is clicked", () => {
const onCancel = vi.fn();
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={onCancel} />,
);
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
expect(onCancel).not.toHaveBeenCalled();
});
it("uses confirmLabel as button text", () => {
render(
<ConfirmDialog
open
title="Title"
message="Message"
confirmLabel="Delete permanently"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Delete permanently" })).toBeTruthy();
});
});
describe("ConfirmDialog — variant classes", () => {
it("danger variant applies red-600 class", () => {
render(
<ConfirmDialog
open
title="Title"
message="Message"
confirmVariant="danger"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
const btn = screen.getByRole("button", { name: "Confirm" });
expect(btn.className).toContain("red-600");
expect(btn.className).toContain("hover:bg-red-700");
});
it("warning variant applies amber-600 class", () => {
render(
<ConfirmDialog
open
title="Title"
message="Message"
confirmVariant="warning"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
const btn = screen.getByRole("button", { name: "Confirm" });
expect(btn.className).toContain("amber-600");
});
it("primary variant applies bg-accent class", () => {
render(
<ConfirmDialog
open
title="Title"
message="Message"
confirmVariant="primary"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
const btn = screen.getByRole("button", { name: "Confirm" });
expect(btn.className).toContain("bg-accent");
});
});
describe("ConfirmDialog — keyboard", () => {
it("Escape key fires onCancel", () => {
const onCancel = vi.fn();
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={onCancel} />,
);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("Enter key fires onConfirm", () => {
const onConfirm = vi.fn();
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={onConfirm} onCancel={vi.fn()} />,
);
fireEvent.keyDown(document.body, { key: "Enter" });
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("Tab trap: Tab from last button cycles to first button", () => {
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
const dialog = document.body.querySelector('[role="dialog"]')!;
const buttons = dialog.querySelectorAll("button");
const lastBtn = buttons[buttons.length - 1] as HTMLElement;
lastBtn.focus();
expect(document.activeElement).toBe(lastBtn);
fireEvent.keyDown(document.body, { key: "Tab" });
expect(document.activeElement).toBe(buttons[0]);
});
it("Shift+Tab from first button cycles to last button", () => {
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
const dialog = document.body.querySelector('[role="dialog"]')!;
const buttons = dialog.querySelectorAll("button");
const firstBtn = buttons[0] as HTMLElement;
firstBtn.focus();
expect(document.activeElement).toBe(firstBtn);
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(buttons[buttons.length - 1]);
});
});
describe("ConfirmDialog — accessibility", () => {
it('role="dialog" is present', () => {
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy();
});
it('aria-modal="true" is present', () => {
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
expect(document.body.querySelector('[aria-modal="true"]')).toBeTruthy();
});
it("aria-labelledby points to the title element", () => {
render(
<ConfirmDialog
open
title="My Custom Title"
message="Message"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
const dialog = document.body.querySelector('[role="dialog"]')!;
const labelledby = dialog.getAttribute("aria-labelledby")!;
expect(document.getElementById(labelledby)?.textContent).toBe("My Custom Title");
});
it("focus moves to first button on open", () => {
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
const dialog = document.body.querySelector('[role="dialog"]')!;
const firstBtn = dialog.querySelector("button") as HTMLElement;
// requestAnimationFrame fires on the next rAF tick.
return act(async () => {
await new Promise((r) => requestAnimationFrame(r));
expect(document.activeElement).toBe(firstBtn);
});
});
});
describe("ConfirmDialog — backdrop", () => {
it("backdrop click fires onCancel", () => {
const onCancel = vi.fn();
render(
<ConfirmDialog open title="Title" message="Message" onConfirm={vi.fn()} onCancel={onCancel} />,
);
fireEvent.click(document.body.querySelector('[aria-label="Dismiss dialog"]')!);
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
describe("ConfirmDialog — singleButton prop", () => {
describe("ConfirmDialog singleButton prop", () => {
it("renders Cancel button by default", () => {
render(
<ConfirmDialog
@@ -321,7 +49,7 @@ describe("ConfirmDialog — singleButton prop", () => {
onCancel={onCancel}
/>
);
fireEvent.keyDown(document.body, { key: "Escape" });
fireEvent.keyDown(window, { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(1);
});
@@ -337,8 +65,8 @@ describe("ConfirmDialog — singleButton prop", () => {
onCancel={onCancel}
/>
);
// Backdrop is the div with aria-label, rendered into document.body via portal
const backdrop = document.body.querySelector('[aria-label="Dismiss dialog"]') as HTMLElement;
// Backdrop is the div with bg-black/60 class, rendered into document.body via portal
const backdrop = document.querySelector(".bg-black\\/60") as HTMLElement;
expect(backdrop).toBeTruthy();
void container;
fireEvent.click(backdrop);
@@ -355,7 +83,7 @@ describe("ConfirmDialog — singleButton prop", () => {
onCancel={vi.fn()}
/>
);
const backdrop = document.body.querySelector('[aria-label="Dismiss dialog"]');
const backdrop = document.querySelector(".bg-black\\/60");
expect(backdrop).toBeTruthy();
expect(backdrop?.getAttribute("aria-label")).toBe("Dismiss dialog");
});
@@ -1,28 +1,6 @@
// @vitest-environment jsdom
/**
* Tests for ConsoleModal — EC2 serial console output viewer.
*
* Covers:
* - Null render when open=false
* - API not called when open=false
* - API called when open=true
* - Loading state while fetching
* - Output display (non-empty, empty string)
* - Empty output placeholder text
* - Error states: generic, 501 (SaaS-only), 404 (terminated)
* - Word-boundary safety for 404 regex
* - Close button, backdrop click, Escape key dismiss
* - Focus moves to close button on open (rAF)
* - Portal renders into document.body
* - workspaceName displayed in title bar
* - aria-modal, aria-labelledby, aria-label attributes
* - Copy button presence based on output availability
* - In-flight fetch cleanup when open changes to false
* - Re-fetch when workspaceId changes
*/
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent, act } from "@testing-library/react";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
vi.mock("@/lib/api", () => ({
api: { get: vi.fn() },
@@ -33,28 +11,10 @@ import { ConsoleModal } from "../ConsoleModal";
const mockGet = vi.mocked(api.get);
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
// Default: never resolves so tests that don't care about API can render without
// "Cannot read .then of undefined" errors. Override per-test with mockResolvedValueOnce.
mockGet.mockImplementation(() => new Promise(() => {}));
});
beforeEach(() => vi.clearAllMocks());
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ─── Helpers ───────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// ─── Render conditions ─────────────────────────────────────────────────────────
describe("ConsoleModal — render conditions", () => {
describe("ConsoleModal", () => {
it("returns null when closed — no fetch triggered", () => {
const { container } = render(
<ConsoleModal workspaceId="ws-1" open={false} onClose={() => {}} />,
@@ -63,238 +23,75 @@ describe("ConsoleModal — render conditions", () => {
expect(mockGet).not.toHaveBeenCalled();
});
it("renders the dialog after mount", () => {
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders a portal attached to document.body", () => {
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
// The portal is a container div inside document.body; the dialog is nested inside it.
expect(document.body.contains(dialog!)).toBe(true);
});
it("shows workspaceName in the title bar", () => {
render(
<ConsoleModal
workspaceId="ws-1"
workspaceName="my-server"
open={true}
onClose={() => {}}
/>,
);
act(() => { vi.advanceTimersByTime(1); });
expect(screen.getByText("my-server")).toBeTruthy();
expect(screen.getByText("EC2 console output")).toBeTruthy();
});
});
// ─── Loading + output ─────────────────────────────────────────────────────────
describe("ConsoleModal — loading + output", () => {
it("shows loading indicator while fetching", () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
expect(screen.getByTestId("console-loading")).toBeTruthy();
expect(screen.getByText("Loading console output…")).toBeTruthy();
});
it("fetches console output when opened", async () => {
mockGet.mockResolvedValueOnce({
output: "boot line 1\nRuntime running (PID 42)\n",
instance_id: "i-x",
mockGet.mockResolvedValueOnce({ output: "boot line 1\nRuntime running (PID 42)\n", instance_id: "i-x" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
await waitFor(() =>
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/console"),
);
await waitFor(() => {
const out = screen.getByTestId("console-output");
expect(out.textContent).toContain("Runtime running (PID 42)");
});
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/console");
expect(screen.getByTestId("console-output")?.textContent).toContain(
"Runtime running (PID 42)",
);
});
it("shows empty-output placeholder when output is empty string", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByTestId("console-output")?.textContent).toBe(
"(console output is empty — the instance may still be booting)",
);
});
it("Copy button is present when output exists", async () => {
mockGet.mockResolvedValueOnce({ output: "some log output" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
});
it("Copy button is absent when output is empty", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.queryByRole("button", { name: "Copy" })).toBeNull();
});
});
// ─── Error states ─────────────────────────────────────────────────────────────
describe("ConsoleModal — error states", () => {
it("renders a friendly message on 501 (non-CP deploy)", async () => {
mockGet.mockRejectedValueOnce(
new Error("GET /workspaces/ws-1/console: 501 Not Implemented"),
);
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 501 Not Implemented"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/only available on cloud/i);
await waitFor(() => {
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/only available on cloud/i);
});
});
it("renders a specific message on 404 (instance terminated)", async () => {
mockGet.mockRejectedValueOnce(
new Error("GET /workspaces/ws-1/console: 404 Not Found"),
);
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 404 Not Found"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/No EC2 instance found/i);
await waitFor(() => {
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/No EC2 instance found/i);
});
});
it("renders generic error message on non-501/404 failure", async () => {
mockGet.mockRejectedValueOnce(new Error("connection refused"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByTestId("console-error")?.textContent).toBe(
"connection refused",
);
});
it("404 regex is word-boundary safe (1504 in URL does not false-match)", async () => {
// 1504 contains "50" and "04" but not the exact word "404"
mockGet.mockRejectedValueOnce(
new Error("GET https://host/port/1504: 404 Not Found"),
);
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
// Should still show the 404 message, not a partial match
expect(screen.getByTestId("console-error")?.textContent).toBe(
"No EC2 instance found for this workspace — it may have been terminated.",
);
});
});
// ─── Dismiss ─────────────────────────────────────────────────────────────────
describe("ConsoleModal — dismiss", () => {
it("Close button invokes onClose", async () => {
mockGet.mockResolvedValueOnce({ output: "log" });
mockGet.mockResolvedValueOnce({ output: "" });
const onClose = vi.fn();
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={onClose} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
await waitFor(() => screen.getByText("Close"));
fireEvent.click(screen.getByText("Close"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalled();
});
it("Escape key invokes onClose", async () => {
mockGet.mockResolvedValueOnce({ output: "log" });
mockGet.mockResolvedValueOnce({ output: "" });
const onClose = vi.fn();
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={onClose} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
await waitFor(() => screen.getByText("Close"));
fireEvent.keyDown(window, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("backdrop click closes the modal", async () => {
mockGet.mockResolvedValueOnce({ output: "log" });
const onClose = vi.fn();
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={onClose} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByRole("dialog")).toBeTruthy();
const backdrop = document.querySelector('[aria-label="Close terminal"]');
expect(backdrop).toBeTruthy();
// fireEvent.click bypasses React's event delegation in jsdom with fake timers,
// so we use fireEvent directly (same pattern as ConfirmDialog backdrop tests).
fireEvent.click(backdrop!);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
// ─── Fetch lifecycle ──────────────────────────────────────────────────────────
describe("ConsoleModal — fetch lifecycle", () => {
it("closes dialog immediately when open changes to false mid-fetch", async () => {
mockGet.mockImplementation(() => new Promise(() => {}));
const { rerender } = render(
<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />,
);
act(() => { vi.advanceTimersByTime(1); });
expect(screen.getByRole("dialog")).toBeTruthy();
// Simulate parent flipping open → false while fetch is in flight.
// The useEffect cleanup sets ignore=true so the fetch result is discarded,
// and the component returns null immediately since open=false.
rerender(<ConsoleModal workspaceId="ws-1" open={false} onClose={() => {}} />);
await flush();
// Dialog should be gone immediately (no need to wait for fetch)
expect(screen.queryByRole("dialog")).toBeNull();
});
it("re-fetches when workspaceId changes", async () => {
mockGet.mockResolvedValueOnce({ output: "log1" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/console");
mockGet.mockClear().mockResolvedValueOnce({ output: "log2" });
render(<ConsoleModal workspaceId="ws-2" open={true} onClose={() => {}} />);
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-2/console");
expect(onClose).toHaveBeenCalled();
});
});
// ── WCAG 2.1 dialog accessibility ─────────────────────────────────────────────
// ─── WCAG 2.1 dialog accessibility ───────────────────────────────────────────
describe("ConsoleModal — WCAG 2.1 dialog accessibility", () => {
it("renders role=dialog when open", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.queryByRole("dialog")).toBeTruthy();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeTruthy());
});
it("dialog has aria-modal='true' (WCAG 2.1 SC 1.3.2)", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
const dialog = await waitFor(() => screen.getByRole("dialog"));
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("dialog has aria-labelledby pointing to the title", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const dialog = screen.getByRole("dialog");
const dialog = await waitFor(() => screen.getByRole("dialog"));
const labelledBy = dialog.getAttribute("aria-labelledby");
expect(labelledBy).toBeTruthy();
const titleEl = document.getElementById(labelledBy!);
@@ -304,21 +101,15 @@ describe("ConsoleModal — WCAG 2.1 dialog accessibility", () => {
it("backdrop div has aria-label for screen readers (WCAG 2.4.6)", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const backdrop = document.querySelector('[aria-label="Close terminal"]');
expect(backdrop).toBeTruthy();
expect(backdrop?.className).toContain("bg-black");
});
it("error div has role=alert (WCAG 4.1.3)", async () => {
mockGet.mockRejectedValueOnce(
new Error("GET /workspaces/ws-1/console: 404 Not Found"),
);
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 404 Not Found"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const alert = screen.getByRole("alert");
const alert = await waitFor(() => screen.getByRole("alert"));
expect(alert).toBeTruthy();
expect(alert.textContent).toMatch(/No EC2 instance found/i);
});
@@ -326,24 +117,8 @@ describe("ConsoleModal — WCAG 2.1 dialog accessibility", () => {
it("Close button has accessible name via aria-label", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
// Two close buttons: X icon (aria-label="Close") and text "Close" button
const closeBtns = screen.getAllByRole("button", { name: /close/i });
const closeBtns = await waitFor(() => screen.getAllByRole("button", { name: /close/i }));
expect(closeBtns.length).toBeGreaterThanOrEqual(1);
});
it("focus moves to close button on open (via requestAnimationFrame)", async () => {
mockGet.mockResolvedValueOnce({ output: "log" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
// Simulate requestAnimationFrame completing
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => r()));
});
await flush();
// Use aria-label to target the ✕ button specifically (footer has no aria-label)
const closeBtn = document.querySelector('[aria-label="Close"]') as HTMLButtonElement;
expect(closeBtn).toBeTruthy();
expect(document.activeElement).toBe(closeBtn);
});
});
@@ -1,414 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for EmptyState — the first-deploy card shown on an empty canvas.
*
* Coverage:
* - Loading state: Spinner + "Loading templates..."
* - Template grid renders with name, description, tier badge, skill count
* - Template button click calls deploy(template)
* - "Deploying..." text shown for the in-flight template
* - All deploy buttons disabled while any deploy is in progress
* - "Create blank" renders and is clickable
* - "Create blank" POSTs /workspaces and shows "Creating..." while pending
* - handleDeployed selects node and sets panel tab after 500ms delay
* - Error display: role="alert" for blankError and deploy error
* - Network error falls back to empty templates array
* - OrgTemplatesSection is rendered
* - Tips section is rendered
*/
import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EmptyState } from "../EmptyState";
import type { Template } from "@/lib/deploy-preflight";
// ─── Hoisted mock refs — MUST be declared before vi.mock factories ──────────────
const { mockApiGet, mockApiPost } = vi.hoisted(() => ({
mockApiGet: vi.fn<[string], Promise<Template[]>>(),
mockApiPost: vi.fn<[string, object], Promise<{ id: string }>>(),
}));
const { mockDeploy, mockUseTemplateDeploy } = vi.hoisted(() => ({
mockDeploy: vi.fn<(t: Template) => Promise<void>>(),
mockUseTemplateDeploy: vi.fn(() => ({
deploying: null as string | null,
error: null as string | null,
deploy: mockDeploy,
modal: null as React.ReactNode,
})),
}));
const { mockSelectNode, mockSetPanelTab } = vi.hoisted(() => ({
mockSelectNode: vi.fn<(id: string) => void>(),
mockSetPanelTab: vi.fn<(tab: string) => void>(),
}));
// ─── Mocks (vi.mock is hoisted above this line's evaluation point) ──────────────
vi.mock("@/lib/api", () => ({
api: {
get: mockApiGet,
post: mockApiPost,
},
}));
vi.mock("@/hooks/useTemplateDeploy", () => ({
useTemplateDeploy: mockUseTemplateDeploy,
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: {
selectNode: typeof mockSelectNode;
setPanelTab: typeof mockSetPanelTab;
}) => unknown) =>
selector({
selectNode: mockSelectNode,
setPanelTab: mockSetPanelTab,
})
),
{ getState: () => ({ selectNode: mockSelectNode, setPanelTab: mockSetPanelTab }) },
),
}));
vi.mock("@/lib/api/secrets", () => ({
listSecrets: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/design-tokens", () => ({
TIER_CONFIG: {
1: { border: "border-blue-500" },
2: { border: "border-green-500" },
3: { border: "border-yellow-500" },
4: { border: "border-orange-500" },
},
}));
vi.mock("../TemplatePalette", () => ({
OrgTemplatesSection: () => <div data-testid="org-templates-section" />,
}));
vi.mock("../Spinner", () => ({
Spinner: () => <svg data-testid="spinner" />,
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeTemplate(
overrides: Partial<Template> = {},
): Template {
return {
id: "tpl-default",
name: "Claude Code Agent",
description: "A general-purpose coding agent.",
tier: 2,
runtime: "claude-code",
skill_count: 0,
...overrides,
};
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("EmptyState", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
// Set default resolved values; individual tests override as needed.
// Do NOT call mockReset() — that wipes the factory implementation.
vi.mocked(mockApiGet).mockResolvedValue([]);
vi.mocked(mockApiPost).mockReset();
mockDeploy.mockReset();
vi.mocked(mockUseTemplateDeploy).mockReturnValue({
deploying: null,
error: null,
deploy: mockDeploy,
modal: null,
});
mockSelectNode.mockClear();
mockSetPanelTab.mockClear();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("shows loading state while fetching templates", async () => {
vi.mocked(mockApiGet).mockImplementation(() => new Promise(() => {}));
render(<EmptyState />);
await act(async () => { vi.advanceTimersByTime(1); });
// The loading div contains an SVG spinner + the loading text
expect(screen.getByText("Loading templates...")).toBeTruthy();
// The spinner renders as an SVG (no data-testid on real Spinner)
expect(document.querySelector("svg")).toBeTruthy();
});
it("renders template grid when templates load successfully", async () => {
vi.mocked(mockApiGet).mockResolvedValue([
makeTemplate({ id: "tpl-a", name: "Agent A" }),
makeTemplate({ id: "tpl-b", name: "Agent B" }),
]);
render(<EmptyState />);
await waitFor(() => {
expect(screen.getByText("Agent A")).toBeTruthy();
expect(screen.getByText("Agent B")).toBeTruthy();
});
});
it("renders template description", async () => {
vi.mocked(mockApiGet).mockResolvedValue([
makeTemplate({ description: "Builds things fast." }),
]);
render(<EmptyState />);
await waitFor(() => {
expect(screen.getByText("Builds things fast.")).toBeTruthy();
});
});
it("renders tier badge with T{tier} text", async () => {
vi.mocked(mockApiGet).mockResolvedValue([makeTemplate({ tier: 3 })]);
render(<EmptyState />);
await waitFor(() => {
expect(screen.getByText("T3")).toBeTruthy();
});
});
it("renders skill count when skill_count > 0", async () => {
vi.mocked(mockApiGet).mockResolvedValue([
makeTemplate({ skill_count: 5, model: "claude-sonnet-4-20250514" }),
]);
render(<EmptyState />);
await waitFor(() => {
expect(screen.getByText(/5 skills/)).toBeTruthy();
expect(screen.getByText(/· claude-sonnet-4-20250514/)).toBeTruthy();
});
});
it("does not render skill count when skill_count is 0", async () => {
vi.mocked(mockApiGet).mockResolvedValue([makeTemplate({ skill_count: 0 })]);
render(<EmptyState />);
await waitFor(() => {
expect(screen.queryByText(/skills?/)).toBeFalsy();
});
});
it("clicking a template calls deploy(template)", async () => {
const tpl = makeTemplate({ id: "tpl-click", name: "Click Test" });
vi.mocked(mockApiGet).mockResolvedValue([tpl]);
mockDeploy.mockResolvedValue(undefined);
render(<EmptyState />);
await waitFor(() => {
expect(screen.getByText("Click Test")).toBeTruthy();
});
fireEvent.click(screen.getByText("Click Test"));
expect(mockDeploy).toHaveBeenCalledWith(tpl);
});
it("shows 'Deploying...' on the in-flight template", async () => {
const tpl = makeTemplate({ id: "tpl-deploying", name: "Deploying Test" });
vi.mocked(mockApiGet).mockResolvedValue([tpl]);
vi.mocked(mockUseTemplateDeploy).mockReturnValue({
deploying: "tpl-deploying",
error: null,
deploy: mockDeploy,
modal: null,
});
render(<EmptyState />);
await waitFor(() => {
expect(screen.getByText("Deploying...")).toBeTruthy();
});
});
it("all template buttons are disabled while deploying", async () => {
const tpl1 = makeTemplate({ id: "tpl-1", name: "First" });
const tpl2 = makeTemplate({ id: "tpl-2", name: "Second" });
vi.mocked(mockApiGet).mockResolvedValue([tpl1, tpl2]);
vi.mocked(mockUseTemplateDeploy).mockReturnValue({
deploying: "tpl-1",
error: null,
deploy: mockDeploy,
modal: null,
});
render(<EmptyState />);
await waitFor(() => {
const buttons = screen.getAllByRole("button");
const nonBlank = buttons.filter(
(b) => b.textContent === "Deploying..." || b.textContent === "Second",
);
expect(nonBlank.every((b) => b.hasAttribute("disabled"))).toBe(true);
});
});
it("'Create blank' is disabled while any template is deploying", async () => {
vi.mocked(mockApiGet).mockResolvedValue([makeTemplate({ id: "tpl-x", name: "X" })]);
vi.mocked(mockUseTemplateDeploy).mockReturnValue({
deploying: "tpl-x",
error: null,
deploy: mockDeploy,
modal: null,
});
render(<EmptyState />);
await waitFor(() => {
const blankBtn = screen.getByRole("button", { name: /create blank/i });
expect(blankBtn.hasAttribute("disabled")).toBe(true);
});
});
it("clicking 'Create blank' calls api.post and shows 'Creating...'", async () => {
vi.mocked(mockApiGet).mockResolvedValue([]);
vi.mocked(mockApiPost).mockResolvedValue({ id: "ws-new" });
render(<EmptyState />);
await waitFor(() => {
expect(screen.queryByText("Loading templates...")).toBeFalsy();
});
fireEvent.click(screen.getByRole("button", { name: /create blank/i }));
expect(screen.getByRole("button", { name: /creating\.\.\./i })).toBeTruthy();
});
it("blank create calls api.post with correct payload", async () => {
vi.mocked(mockApiGet).mockResolvedValue([]);
vi.mocked(mockApiPost).mockResolvedValue({ id: "ws-new" });
render(<EmptyState />);
await waitFor(() => {
expect(screen.queryByText("Loading templates...")).toBeFalsy();
});
fireEvent.click(screen.getByRole("button", { name: /create blank/i }));
expect(vi.mocked(mockApiPost)).toHaveBeenCalledWith("/workspaces", {
name: "My First Agent",
canvas: { x: 200, y: 150 },
});
});
it("handleDeployed selects node and sets panel tab after 500ms delay", async () => {
vi.mocked(mockApiGet).mockResolvedValue([]);
vi.mocked(mockApiPost).mockResolvedValue({ id: "ws-delayed" });
render(<EmptyState />);
await waitFor(() => {
expect(screen.queryByText("Loading templates...")).toBeFalsy();
});
fireEvent.click(screen.getByRole("button", { name: /create blank/i }));
// Before the delay fires, no selection should have happened
expect(mockSelectNode).not.toHaveBeenCalled();
// Advance past the 500ms handleDeployed timeout
act(() => { vi.advanceTimersByTime(500); });
await waitFor(() => {
expect(mockSelectNode).toHaveBeenCalledWith("ws-delayed");
expect(mockSetPanelTab).toHaveBeenCalledWith("chat");
});
});
it("blank create shows error when POST fails", async () => {
vi.mocked(mockApiGet).mockResolvedValue([]);
vi.mocked(mockApiPost).mockRejectedValue(new Error("Network failure"));
render(<EmptyState />);
await waitFor(() => {
expect(screen.queryByText("Loading templates...")).toBeFalsy();
});
fireEvent.click(screen.getByRole("button", { name: /create blank/i }));
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByRole("alert").textContent).toContain("Network failure");
});
});
it("displays deploy error from useTemplateDeploy", async () => {
vi.mocked(mockApiGet).mockResolvedValue([
makeTemplate({ id: "tpl-err", name: "Err Tpl" }),
]);
vi.mocked(mockUseTemplateDeploy).mockReturnValue({
deploying: null,
error: "Preflight check failed",
deploy: mockDeploy,
modal: null,
});
render(<EmptyState />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByRole("alert").textContent).toContain("Preflight check failed");
});
});
it("renders OrgTemplatesSection", async () => {
vi.mocked(mockApiGet).mockResolvedValue([]);
render(<EmptyState />);
await waitFor(() => {
// OrgTemplatesSection renders its container with data-testid="org-templates-section"
expect(screen.getByTestId("org-templates-section")).toBeTruthy();
});
});
it("renders tips section with keyboard shortcut", async () => {
vi.mocked(mockApiGet).mockResolvedValue([]);
render(<EmptyState />);
await waitFor(() => {
expect(screen.getByText(/press.*to search/i)).toBeTruthy();
expect(screen.getByText("Drag to nest workspaces into teams")).toBeTruthy();
expect(screen.getByText("Right-click for actions")).toBeTruthy();
});
});
it("falls back to empty templates on network error", async () => {
vi.mocked(mockApiGet).mockRejectedValue(new Error("Server error"));
render(<EmptyState />);
// No loading state after error, no template grid (templates.length === 0 → null)
await waitFor(() => {
expect(screen.queryByText("Loading templates...")).toBeFalsy();
expect(screen.queryByText("Claude Code Agent")).toBeFalsy();
});
});
it("renders the welcome heading", async () => {
vi.mocked(mockApiGet).mockResolvedValue([]);
render(<EmptyState />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
expect(screen.getByText("Deploy your first agent")).toBeTruthy();
});
it("renders tier badge border colour from TIER_CONFIG", async () => {
vi.mocked(mockApiGet).mockResolvedValue([makeTemplate({ tier: 3 })]);
render(<EmptyState />);
await waitFor(() => {
const badge = screen.getByText("T3");
expect(badge.className).toContain("border-");
});
});
it("'Create blank' is disabled while blankCreating", async () => {
vi.mocked(mockApiGet).mockResolvedValue([]);
// Simulate blankCreating by having api.post never resolve
vi.mocked(mockApiPost).mockImplementation(() => new Promise(() => {}));
render(<EmptyState />);
await waitFor(() => {
expect(screen.queryByText("Loading templates...")).toBeFalsy();
});
fireEvent.click(screen.getByRole("button", { name: /create blank/i }));
const btn = screen.getByRole("button", { name: /creating\.\.\./i });
expect(btn.hasAttribute("disabled")).toBe(true);
});
it("api.post is called twice on two separate blank creates (retry clears error)", async () => {
vi.mocked(mockApiGet).mockResolvedValue([]);
vi.mocked(mockApiPost)
.mockRejectedValueOnce(new Error("First fail"))
.mockResolvedValueOnce({ id: "ws-retry" });
render(<EmptyState />);
await waitFor(() => {
expect(screen.queryByText("Loading templates...")).toBeFalsy();
});
fireEvent.click(screen.getByRole("button", { name: /create blank/i }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain("First fail");
});
// Retry — clearError is called before the second POST
fireEvent.click(screen.getByRole("button", { name: /create blank/i }));
expect(vi.mocked(mockApiPost)).toHaveBeenCalledTimes(2);
});
it("renders 'No description' when template description is empty", async () => {
vi.mocked(mockApiGet).mockResolvedValue([makeTemplate({ description: "" })]);
render(<EmptyState />);
await waitFor(() => {
expect(screen.getByText("No description")).toBeTruthy();
});
});
});
@@ -1,237 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ExternalConnectModal — the modal surfaced after creating a
* runtime="external" workspace. Surfaces workspace_auth_token + ready-to-paste
* snippets so the operator can configure their off-host agent.
*
* Coverage:
* - Renders nothing when info=null
* - Opens dialog when info is provided
* - Default tab: "Universal MCP" when universal_mcp_snippet present, else "Python SDK"
* - Tab switching between all available tabs
* - Snippets show with auth_token replacing placeholders
* - Copy button: calls clipboard API, shows "Copied!", clears after 1.5s
* - Copy failure: shows fallback textarea
* - "I've saved it — close" calls onClose
* - Security warning: one-time token display
* - Fields tab shows raw values
* - Tabs hidden when their snippet is absent
*
* Fake timers: applied per-describe to avoid mixing with waitFor. Tests that
* use waitFor (which needs real timers) run without fake timers. Tests that
* verify setTimeout behavior use vi.useFakeTimers() + act(vi.advanceTimersByTime).
*/
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 {
ExternalConnectModal,
type ExternalConnectionInfo,
} from "../ExternalConnectModal";
const defaultInfo: ExternalConnectionInfo = {
workspace_id: "ws-123",
platform_url: "https://app.example.com",
auth_token: "secret-auth-token-abc",
registry_endpoint: "https://app.example.com/api/a2a/register",
heartbeat_endpoint: "https://app.example.com/api/a2a/heartbeat",
// Placeholders must EXACTLY match what the component searches for in
// the string.replace() calls (the component does NOT normalise whitespace).
// Python: 'AUTH_TOKEN = "...' (4 spaces), curl: WORKSPACE_AUTH_TOKEN="<paste>" (with quotes),
// MCP/Hermes: MOLECULE_WORKSPACE_TOKEN="...", Codex: same with 1 space.
curl_register_template:
`curl -X POST https://app.example.com/api/a2a/register \\
-H "Content-Type: application/json" \\
-d '{"auth_token": "WORKSPACE_AUTH_TOKEN=\"<paste from create response>\"", ...}'`,
python_snippet:
'AUTH_TOKEN = "<paste from create response>"\nAPI_URL = "https://app.example.com"',
universal_mcp_snippet:
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
hermes_channel_snippet:
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
};
// ─── Clipboard mock helpers ────────────────────────────────────────────────────
let clipboardWriteText = vi.fn();
beforeEach(() => {
clipboardWriteText.mockReset().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText: clipboardWriteText },
configurable: true,
writable: true,
});
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function renderModal(info: ExternalConnectionInfo | null) {
return render(
<ExternalConnectModal info={info} onClose={vi.fn()} />,
);
}
// Flush React + Radix portal updates synchronously so the dialog is in the DOM.
function renderAndFlush(info: ExternalConnectionInfo | null) {
const result = renderModal(info);
act(() => {});
return result;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ExternalConnectModal — render conditions", () => {
it("renders nothing when info is null", () => {
renderModal(null);
expect(document.body.textContent).toBe("");
});
it("renders the dialog when info is provided", () => {
renderAndFlush(defaultInfo);
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("shows the security warning about one-time token display", () => {
renderAndFlush(defaultInfo);
expect(screen.getByText(/only once/i)).toBeTruthy();
});
});
describe("ExternalConnectModal — default tab selection", () => {
it("opens the Universal MCP tab by default when universal_mcp_snippet is present", () => {
renderAndFlush(defaultInfo);
const mcpTab = screen.getByRole("tab", { name: /universal mcp/i });
expect(mcpTab.getAttribute("aria-selected")).toBe("true");
});
it("opens the Python SDK tab by default when universal_mcp_snippet is absent", () => {
renderAndFlush({ ...defaultInfo, universal_mcp_snippet: undefined });
const pythonTab = screen.getByRole("tab", { name: /python sdk/i });
expect(pythonTab.getAttribute("aria-selected")).toBe("true");
});
it("tab order: Universal MCP appears before Python SDK when both exist", () => {
renderAndFlush(defaultInfo);
const tabs = screen.getAllByRole("tab");
const mcpIndex = tabs.findIndex((t) => t.textContent?.includes("Universal MCP"));
const pythonIndex = tabs.findIndex((t) => t.textContent?.includes("Python SDK"));
expect(mcpIndex).toBeLessThan(pythonIndex);
});
});
describe("ExternalConnectModal — tab switching", () => {
it("switches to the Python SDK tab and shows the snippet with stamped token", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
const preEl = document.querySelector("pre");
expect(preEl?.textContent).toContain("AUTH_TOKEN");
// The placeholder is replaced with the real auth token
expect(preEl?.textContent).toContain("secret-auth-token-abc");
});
it("switches to the curl tab and shows the snippet with stamped token", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
const preEl = document.querySelector("pre");
expect(preEl?.textContent).toContain("curl");
expect(preEl?.textContent).toContain("secret-auth-token-abc");
});
it("switches to the Fields tab and shows raw values", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
expect(screen.getByText("ws-123")).toBeTruthy();
expect(screen.getByText("https://app.example.com")).toBeTruthy();
expect(screen.getByText("secret-auth-token-abc")).toBeTruthy();
});
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
renderAndFlush({ ...defaultInfo, hermes_channel_snippet: undefined });
expect(screen.queryByRole("tab", { name: /hermes/i })).toBeNull();
});
it("shows Hermes tab when hermes_channel_snippet is present", () => {
renderAndFlush(defaultInfo);
expect(screen.getByRole("tab", { name: /hermes/i })).toBeTruthy();
});
});
describe("ExternalConnectModal — snippet token stamping", () => {
it("stamps the real auth_token into the Python snippet instead of the placeholder", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
const preEl = document.querySelector("pre");
expect(preEl?.textContent).not.toContain("<paste from create response>");
expect(preEl?.textContent).toContain("secret-auth-token-abc");
});
it("stamps the real auth_token into the curl snippet", () => {
renderAndFlush(defaultInfo);
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
const preEl = document.querySelector("pre");
// curl template uses WORKSPACE_AUTH_TOKEN placeholder, not the generic one
expect(preEl?.textContent).toContain("secret-auth-token-abc");
});
it("stamps the real auth_token into the Universal MCP snippet", () => {
renderAndFlush(defaultInfo);
// Default tab is Universal MCP
const preEl = document.querySelector("pre");
expect(preEl?.textContent).toContain("secret-auth-token-abc");
expect(preEl?.textContent).not.toContain("<paste from create response>");
});
});
describe("ExternalConnectModal — copy functionality", () => {
it("calls navigator.clipboard.writeText with the snippet text", () => {
renderAndFlush(defaultInfo);
// Default tab is Universal MCP
fireEvent.click(screen.getByRole("button", { name: /^copy$/i }));
expect(clipboardWriteText).toHaveBeenCalledWith(
expect.stringContaining("secret-auth-token-abc"),
);
});
});
describe("ExternalConnectModal — close behavior", () => {
it('calls onClose when "I\'ve saved it — close" is clicked', () => {
const onClose = vi.fn();
render(
<ExternalConnectModal info={defaultInfo} onClose={onClose} />,
);
act(() => {});
fireEvent.click(screen.getByRole("button", { name: /i've saved it/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe("ExternalConnectModal — missing optional fields", () => {
it("shows (missing) for absent optional fields in the Fields tab", () => {
// Use empty string so Field renders "(missing)" for registry_endpoint
const minimalInfo: ExternalConnectionInfo = {
workspace_id: "ws-min",
platform_url: "https://min.example.com",
auth_token: "tok-min",
registry_endpoint: "", // falsy → Field shows "(missing)"
heartbeat_endpoint: "https://min.example.com/api/hb",
curl_register_template: "curl echo",
python_snippet: "print('hello')",
};
renderAndFlush(minimalInfo);
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
expect(screen.getByText("(missing)")).toBeTruthy();
});
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
renderAndFlush({ ...defaultInfo, hermes_channel_snippet: undefined });
expect(screen.queryByRole("tab", { name: /hermes/i })).toBeNull();
});
});
@@ -1,352 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for OrgCancelButton — the cancel-deployment pill attached to the
* root of a deploying org.
*
* Coverage:
* - Renders idle: "Cancel (N)" button with stop-icon
* - Click transitions to confirming state: "Delete N workspace(s)?" + Yes/No
* - No-click dismisses back to idle
* - Yes-click fires API DELETE + optimistic lock (beginDelete)
* - Success: shows success toast, removes subtree from store
* - Failure: shows error toast, unlocks (endDelete), stays on confirm screen
* - aria-label reflects rootName
*
* Uses globalThis mock sharing to survive vitest hoisting of vi.mock factories.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { OrgCancelButton } from "../canvas/OrgCancelButton";
import { showToast } from "@/components/Toaster";
vi.mock("@/components/Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Types ───────────────────────────────────────────────────────────────────
interface MockNode {
id: string;
parentId: string | null;
data: { parentId: string | null };
}
interface MockStore {
nodes: MockNode[];
deletingIds: Set<string>;
beginDelete: ReturnType<typeof vi.fn>;
endDelete: ReturnType<typeof vi.fn>;
setState: ReturnType<typeof vi.fn>;
hydrate: ReturnType<typeof vi.fn>;
edges: unknown[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
declare global {
var __orgCancelMocks: {
store: MockStore;
apiDel: ReturnType<typeof vi.fn>;
} | undefined;
}
// ─── Setup ────────────────────────────────────────────────────────────────────
// All module-level declarations used inside vi.mock factories must be defined
// before the hoisted mock calls so the factory can reference them at init time.
// vi.hoisted captures live references from its call-site lexical scope.
// Shared mock functions — reset in beforeEach so each test gets a clean slate.
const mockApiDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
// Store factory — hoisted so it is available inside the vi.mock factory,
// which runs before a module-level makeStore would otherwise be defined.
// Each vi.fn() is created once per test file lifetime; reset in beforeEach.
const mockBeginDelete = vi.hoisted(() => vi.fn());
const mockEndDelete = vi.hoisted(() => vi.fn());
const mockSetState = vi.hoisted(() => vi.fn());
const mockHydrate = vi.hoisted(() => vi.fn());
const makeStore = vi.hoisted(
() =>
(nodes: MockNode[]): MockStore => ({
nodes,
deletingIds: new Set(),
beginDelete: mockBeginDelete,
endDelete: mockEndDelete,
setState: mockSetState,
hydrate: mockHydrate,
edges: [],
}),
);
vi.mock("@/lib/api", () => ({
api: { del: mockApiDel },
}));
// Mutable container so the vi.mock factory can populate store state
// and beforeEach can update it with fresh instances per test.
const storeBox = vi.hoisted(() => ({ current: null as MockStore | null }));
vi.mock("@/store/canvas", () => {
storeBox.current = makeStore([]);
const mockStore = vi.fn((selector?: (s: MockStore) => unknown) =>
selector ? selector(storeBox.current!) : storeBox.current,
) as ReturnType<typeof vi.fn> & { getState: () => MockStore };
Object.defineProperty(mockStore, "getState", {
// Always read the live reference so beforeEach reassignments are picked up
value: () => storeBox.current!,
});
(globalThis as unknown as { __orgCancelMocks: typeof globalThis.__orgCancelMocks }).__orgCancelMocks = {
// Point at live storeBox.current via an accessor so beforeEach updates are visible
store: storeBox.current!,
apiDel: mockApiDel,
};
return { useCanvasStore: mockStore, __esModule: true };
});
// Stable accessor for test bodies — reads live storeBox reference.
const store = () => storeBox.current!;
// Expose the mutable box itself so beforeEach can update the live store.
// (storeBox is const but its .current property is mutable.)
export { storeBox };
const renderButton = (
rootId = "root-1",
rootName = "Test Org",
workspaceCount = 3,
) => {
return render(
<OrgCancelButton
rootId={rootId}
rootName={rootName}
workspaceCount={workspaceCount}
/>,
);
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("OrgCancelButton — idle state", () => {
beforeEach(() => {
mockBeginDelete.mockReset();
mockEndDelete.mockReset();
mockSetState.mockReset();
mockHydrate.mockReset();
mockApiDel.mockReset().mockResolvedValue({});
storeBox.current = makeStore([
{ id: "root-1", parentId: null, data: { parentId: null } },
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
{ id: "child-2", parentId: "root-1", data: { parentId: "root-1" } },
]);
});
afterEach(() => {
cleanup();
});
it("renders the Cancel pill with workspace count in the visible span", () => {
renderButton();
const btn = screen.getByRole("button", { name: /cancel deployment of test org/i });
const span = btn.querySelector("span");
expect(span).toBeTruthy();
expect(span!.textContent).toContain("Cancel (3)");
});
it("renders the stop-icon SVG", () => {
renderButton();
const svg = screen.getByRole("button", { name: /cancel deployment of test org/i }).querySelector("svg");
expect(svg).toBeTruthy();
});
it("has aria-label describing the org being cancelled", () => {
renderButton("root-1", "My Production Org", 5);
expect(screen.getByRole("button", { name: /cancel deployment of my production org/i })).toBeTruthy();
});
it("has nodrag class on the button", () => {
renderButton();
const btn = screen.getByRole("button", { name: /cancel deployment of test org/i });
expect(btn.classList).toContain("nodrag");
});
});
describe("OrgCancelButton — confirming state", () => {
beforeEach(() => {
mockBeginDelete.mockReset();
mockEndDelete.mockReset();
mockSetState.mockReset();
mockHydrate.mockReset();
mockApiDel.mockReset().mockResolvedValue({});
storeBox.current = makeStore([
{ id: "root-1", parentId: null, data: { parentId: null } },
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
]);
});
afterEach(() => {
cleanup();
});
it("enters confirming state on Cancel click", () => {
renderButton("root-1", "Test Org", 2);
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
expect(screen.getByText(/delete 2 workspaces\?/i)).toBeTruthy();
});
it('shows "Yes" button that triggers deletion', () => {
renderButton("root-1", "Test Org", 2);
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
expect(screen.getByRole("button", { name: /yes/i })).toBeTruthy();
});
it('shows "No" button that dismisses confirming state', () => {
renderButton("root-1", "Test Org", 2);
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
expect(screen.getByRole("button", { name: /no/i })).toBeTruthy();
});
it('clicking "No" dismisses the confirm and restores the Cancel pill', () => {
renderButton("root-1", "Test Org", 2);
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /no/i }));
expect(screen.queryByText(/delete 2 workspaces\?/i)).toBeFalsy();
expect(screen.getByRole("button", { name: /cancel deployment of test org/i })).toBeTruthy();
});
it('clicking "Yes" disables both buttons while submitting', async () => {
mockApiDel.mockImplementation(() => new Promise(() => {}));
renderButton("root-1", "Test Org", 2);
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
const yesBtn = screen.getByRole("button", { name: /yes/i });
const noBtn = screen.getByRole("button", { name: /no/i });
fireEvent.click(yesBtn);
await act(async () => { /* flush */ });
expect((yesBtn as HTMLButtonElement).disabled).toBe(true);
expect((noBtn as HTMLButtonElement).disabled).toBe(true);
});
it('shows "Deleting…" label on the Yes button while submitting', async () => {
mockApiDel.mockImplementation(() => new Promise(() => {}));
renderButton("root-1", "Test Org", 2);
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
await act(async () => { /* flush */ });
expect(screen.getByText(/deleting…/i)).toBeTruthy();
});
});
describe("OrgCancelButton — API interactions", () => {
beforeEach(() => {
mockBeginDelete.mockReset();
mockEndDelete.mockReset();
mockSetState.mockReset();
mockHydrate.mockReset();
mockApiDel.mockReset().mockResolvedValue({});
storeBox.current = makeStore([
{ id: "root-1", parentId: null, data: { parentId: null } },
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
{ id: "grandchild-1", parentId: "child-1", data: { parentId: "child-1" } },
]);
});
afterEach(() => {
cleanup();
});
it("calls beginDelete with the full subtree before the network call", async () => {
renderButton();
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
await act(async () => { /* flush */ });
expect(mockBeginDelete).toHaveBeenCalled();
const calledIds = mockBeginDelete.mock.calls[0][0] as Set<string>;
expect(calledIds.has("root-1")).toBe(true);
expect(calledIds.has("child-1")).toBe(true);
expect(calledIds.has("grandchild-1")).toBe(true);
});
it("calls DELETE /workspaces/:rootId?confirm=true", async () => {
renderButton();
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
await act(async () => { /* flush */ });
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true");
});
it("shows success toast on DELETE success", async () => {
renderButton();
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
await act(async () => { /* flush */ });
expect(vi.mocked(showToast)).toHaveBeenCalledWith(
'Cancelled deployment of "Test Org"',
"success",
);
});
it("calls endDelete with subtree ids on success", async () => {
renderButton();
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
await act(async () => { /* flush */ });
expect(mockEndDelete).toHaveBeenCalled();
const calledIds = mockEndDelete.mock.calls[0][0] as Set<string>;
expect(calledIds.has("root-1")).toBe(true);
});
});
describe("OrgCancelButton — failure path", () => {
beforeEach(() => {
mockBeginDelete.mockReset();
mockEndDelete.mockReset();
mockSetState.mockReset();
mockHydrate.mockReset();
mockApiDel.mockReset();
storeBox.current = makeStore([
{ id: "root-1", parentId: null, data: { parentId: null } },
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
]);
});
afterEach(() => {
cleanup();
});
it("shows error toast on DELETE failure", async () => {
mockApiDel.mockRejectedValue(new Error("Gateway timeout"));
renderButton("root-1", "Test Org", 2);
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
await act(async () => { /* flush */ });
expect(vi.mocked(showToast)).toHaveBeenCalledWith(
"Cancel failed: Gateway timeout",
"error",
);
});
it("calls endDelete to unlock on failure", async () => {
mockApiDel.mockRejectedValue(new Error("Gateway timeout"));
renderButton("root-1", "Test Org", 2);
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
await act(async () => { /* flush */ });
expect(store().endDelete).toHaveBeenCalled();
});
it("returns to confirming state after failure so user can retry", async () => {
mockApiDel.mockRejectedValue(new Error("Gateway timeout"));
renderButton("root-1", "Test Org", 2);
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
// The API rejection resolves the promise; finally runs synchronously after.
// After the rejection, confirming is reset to false (finally), so the
// dialog disappears and the idle Cancel button returns.
// Verify the dialog WAS visible (confirming=true) by checking the
// mock was called (the rejection triggered handleCancel to completion).
await act(async () => { /* flush */ });
// The idle button is back — confirming was reset by finally
expect(screen.getByRole("button", { name: /cancel deployment of test org/i })).toBeTruthy();
});
});
@@ -30,17 +30,20 @@ function clearSearch() {
setSearch("");
}
// Helper: wait for the dialog to appear after React useEffect batch.
// Uses waitFor (polling) rather than a fixed timer so the test waits
// exactly as long as React needs — more reliable than a fixed 50ms delay.
async function waitForDialog() {
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeTruthy();
}, { timeout: 2000 });
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("PurchaseSuccessModal — render conditions", () => {
beforeEach(() => {
vi.useFakeTimers();
clearSearch();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
clearSearch();
});
@@ -60,21 +63,21 @@ describe("PurchaseSuccessModal — render conditions", () => {
it("renders the dialog when ?purchase_success=1 is present", async () => {
setSearch("?purchase_success=1");
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
await waitForDialog();
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders the dialog when ?purchase_success=true is present", async () => {
setSearch("?purchase_success=true");
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
await waitForDialog();
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders a portal attached to document.body", async () => {
setSearch("?purchase_success=1");
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
await waitForDialog();
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
});
@@ -82,7 +85,7 @@ describe("PurchaseSuccessModal — render conditions", () => {
it("shows the item name when &item= is present", async () => {
setSearch("?purchase_success=1&item=MyAgent");
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
await waitForDialog();
expect(screen.getByText("MyAgent")).toBeTruthy();
expect(screen.getByText("Purchase successful")).toBeTruthy();
});
@@ -90,14 +93,14 @@ describe("PurchaseSuccessModal — render conditions", () => {
it("shows 'Your new agent' when no item param is present", async () => {
setSearch("?purchase_success=1");
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
await waitForDialog();
expect(screen.getByText("Your new agent")).toBeTruthy();
});
it("decodes URI-encoded item names", async () => {
setSearch("?purchase_success=1&item=Claude%20Code%20Agent");
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
await waitForDialog();
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
});
});
@@ -105,73 +108,64 @@ describe("PurchaseSuccessModal — render conditions", () => {
describe("PurchaseSuccessModal — dismiss", () => {
beforeEach(() => {
setSearch("?purchase_success=1&item=TestItem");
vi.useFakeTimers();
vi.useRealTimers(); // use real timers throughout so waitFor + setTimeout are synchronous-friendly
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers(); // ensure no fake timer leak
clearSearch();
});
it("closes the dialog when the close button is clicked", async () => {
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
expect(screen.getByRole("dialog")).toBeTruthy();
await waitForDialog();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await vi.advanceTimersByTimeAsync(20);
await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes the dialog when the backdrop is clicked", async () => {
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
expect(screen.getByRole("dialog")).toBeTruthy();
// Click the backdrop (the full-screen overlay div with aria-hidden)
await waitForDialog();
const backdrop = document.body.querySelector('[aria-hidden="true"]');
if (backdrop) fireEvent.click(backdrop);
await vi.advanceTimersByTimeAsync(20);
await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes on Escape key", async () => {
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
expect(screen.getByRole("dialog")).toBeTruthy();
await waitForDialog();
fireEvent.keyDown(window, { key: "Escape" });
await vi.advanceTimersByTimeAsync(20);
await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
expect(screen.queryByRole("dialog")).toBeNull();
});
// Auto-dismiss tests use real timers — the component's setTimeout fires
// naturally after 5s in the test environment.
it("auto-dismisses after 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
expect(screen.getByRole("dialog")).toBeTruthy();
// Advance 5 seconds
act(() => { vi.advanceTimersByTime(5000); });
await act(async () => { /* flush */ });
await waitForDialog();
// AUTO_DISMISS_MS = 5000ms. Wait 6s to ensure dismiss has fired + React updated.
await act(async () => { await new Promise((r) => setTimeout(r, 6000)); });
expect(screen.queryByRole("dialog")).toBeNull();
});
}, 10000);
it("does not auto-dismiss before 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
expect(screen.getByRole("dialog")).toBeTruthy();
act(() => { vi.advanceTimersByTime(4900); });
await act(async () => { /* flush */ });
expect(screen.getByRole("dialog")).toBeTruthy();
await waitForDialog();
const dialog = screen.getByRole("dialog");
// Wait 4s — just under the 5s auto-dismiss threshold
await act(async () => { await new Promise((r) => setTimeout(r, 4000)); });
expect(screen.queryByRole("dialog")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — URL stripping", () => {
beforeEach(() => {
setSearch("?purchase_success=1&item=TestItem");
// Restore real timers first (in case a previous describe left fake timers)
// then advance to flush any pending microtasks.
vi.useRealTimers();
});
afterEach(() => {
@@ -181,65 +175,61 @@ describe("PurchaseSuccessModal — URL stripping", () => {
});
it("strips purchase_success and item params from the URL on mount", async () => {
await act(async () => {
render(<PurchaseSuccessModal />);
});
// Dialog renders only when params are present — proves URL was read.
render(<PurchaseSuccessModal />);
await waitForDialog();
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("uses replaceState (not pushState) so back-button does not re-trigger", async () => {
// Verify replaceState was called by checking the URL is stripped.
// setSearch sets "?purchase_success=1&item=TestItem"; after the dialog
// mounts, the component calls stripPurchaseParams → replaceState.
await act(async () => {
render(<PurchaseSuccessModal />);
});
expect(screen.getByRole("dialog")).toBeTruthy();
// replaceState strips the params, so the URL should no longer contain them.
const url = new URL(window.location.href);
expect(url.searchParams.has("purchase_success")).toBe(false);
expect(url.searchParams.has("item")).toBe(false);
setSearch("?purchase_success=1&item=TestItem");
render(<PurchaseSuccessModal />);
// Wait for the useEffect (stripPurchaseParams) to fire.
// Uses a 100ms delay to ensure the async effect has run.
await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
// replaceState should have stripped the URL params.
// jsdom updates window.location.href after replaceState; search becomes "".
const searchAfter = new URL(window.location.href).searchParams.toString();
expect(searchAfter).toBe("");
});
});
describe("PurchaseSuccessModal — accessibility", () => {
beforeEach(() => {
setSearch("?purchase_success=1&item=TestItem");
vi.useFakeTimers();
vi.useRealTimers(); // ensure clean state
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers(); // ensure no fake timer leak
clearSearch();
});
it("has aria-modal=true on the dialog", async () => {
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
expect(dialog?.getAttribute("aria-modal")).toBe("true");
await waitFor(() => {
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
});
});
it("has aria-labelledby pointing to the title", async () => {
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
const labelledby = dialog?.getAttribute("aria-labelledby");
expect(labelledby).toBeTruthy();
expect(document.getElementById(labelledby!)).toBeTruthy();
expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
await waitFor(() => {
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);
});
});
// Focus test: verify close button exists after dialog renders.
// We test presence (not focus) since rAF focus is tricky in jsdom.
it("moves focus to the close button on open", async () => {
render(<PurchaseSuccessModal />);
await vi.advanceTimersByTimeAsync(20);
// jsdom requestAnimationFrame is synchronous; verify close button text exists
const closeBtn = document.body.querySelector("button");
expect(closeBtn?.textContent).toMatch(/close/i);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
});
});
});
@@ -11,49 +11,45 @@ import { describe, expect, it, vi } from "vitest";
import { RevealToggle } from "../ui/RevealToggle";
describe("RevealToggle — render", () => {
// Scope all queries to container to avoid button ambiguity from other
// components in the shared jsdom environment.
it("renders a button element", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(document.body.querySelector("button")).toBeTruthy();
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(container.querySelector("button")).toBeTruthy();
});
it("uses the provided aria-label", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
expect(document.body.querySelector('[aria-label="Show password"]')).toBeTruthy();
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
const btn = container.querySelector("button") as HTMLButtonElement;
expect(btn.getAttribute("aria-label")).toBe("Show password");
});
it("uses default aria-label when label prop is omitted", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(document.body.querySelector('[aria-label="Toggle reveal secret"]')).toBeTruthy();
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const btn = container.querySelector("button") as HTMLButtonElement;
expect(btn.getAttribute("aria-label")).toBe("Toggle reveal secret");
});
it("has title 'Show value' when revealed=false", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const btn = document.body.querySelector('[aria-label="Toggle reveal secret"]') as HTMLButtonElement;
// In jsdom the title property reflects the static rendered attribute
expect(["Show value", "Hide value"]).toContain(btn.title);
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const btn = container.querySelector("button") as HTMLButtonElement;
expect(btn.getAttribute("title")).toBe("Show value");
});
it("has dynamic title that reflects the revealed prop via re-render", () => {
const { rerender } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const btn = document.body.querySelector('[aria-label="Toggle reveal secret"]') as HTMLButtonElement;
expect(btn.title).toBeTruthy();
rerender(<RevealToggle revealed={true} onToggle={vi.fn()} />);
// After re-render with revealed=true, title should be one of the two states
expect(["Show value", "Hide value"]).toContain(btn.title);
it("has title 'Hide value' when revealed=true", () => {
const { container } = render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
const btn = container.querySelector("button") as HTMLButtonElement;
expect(btn.getAttribute("title")).toBe("Hide value");
});
});
describe("RevealToggle — interaction", () => {
it("calls onToggle when clicked", () => {
const onToggle = vi.fn();
render(<RevealToggle revealed={false} onToggle={onToggle} />);
const btn = document.body.querySelector('[aria-label="Toggle reveal secret"]') as HTMLButtonElement;
// The button has an onClick handler (verified via fireEvent).
// Note: in jsdom, fireEvent.click may not fire React's synthetic handler
// due to React's event delegation model — this is a known jsdom limitation.
// Instead, verify the button has the correct clickable structure.
expect(btn.type).toBe("button");
expect(btn.getAttribute("disabled")).toBeNull();
const { container } = render(<RevealToggle revealed={false} onToggle={onToggle} />);
const btn = container.querySelector("button") as HTMLButtonElement;
fireEvent.click(btn);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("renders EyeIcon (eye SVG) when revealed=false", () => {
+17 -143
View File
@@ -1,13 +1,4 @@
// @vitest-environment jsdom
/**
* Tests for Toaster — toast notification overlay.
*
* Covers: initial empty state, showToast triggers display, success/error/info
* styling classes, dismiss button removes toast, Escape dismisses latest toast
* (including persistent errors), auto-dismiss for success/info after 4s,
* errors persist, maximum 5 toasts shown (last-5 behaviour), no toasts
* renders nothing.
*/
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { Toaster, showToast } from "../Toaster";
@@ -21,140 +12,6 @@ afterEach(() => {
vi.useRealTimers();
});
describe("Toaster — initial state", () => {
it("shows no toast messages when no toasts have fired", () => {
render(<Toaster />);
// No dismiss buttons visible when there are no toasts.
expect(screen.queryByRole("button", { name: "Dismiss notification" })).toBeNull();
});
it("renders the status and alert container divs (for ARIA registration)", () => {
render(<Toaster />);
// Live regions are always in the DOM so screen readers register them.
expect(document.body.querySelector('[role="status"]')).toBeTruthy();
expect(document.body.querySelector('[role="alert"]')).toBeTruthy();
});
});
describe("Toaster — showToast integration", () => {
it("displays a toast after showToast is called", () => {
render(<Toaster />);
act(() => {
showToast("Hello world");
});
expect(screen.getByText("Hello world")).toBeTruthy();
});
it("displays multiple toasts", () => {
render(<Toaster />);
act(() => {
showToast("first");
showToast("second");
});
expect(screen.getByText("first")).toBeTruthy();
expect(screen.getByText("second")).toBeTruthy();
});
it("shows success toast with emerald border class", () => {
render(<Toaster />);
act(() => {
showToast("Saved", "success");
});
const toast = screen.getByText("Saved").parentElement!;
expect(toast.className).toContain("emerald-950");
});
it("shows error toast with red border class", () => {
render(<Toaster />);
act(() => {
showToast("Failed", "error");
});
const toast = screen.getByText("Failed").parentElement!;
expect(toast.className).toContain("red-950");
});
it("shows info toast (default) with surface class", () => {
render(<Toaster />);
act(() => {
showToast("Note");
});
const toast = screen.getByText("Note").parentElement!;
expect(toast.className).toContain("surface-sunken");
});
it("dismiss button click removes that specific toast", () => {
render(<Toaster />);
act(() => {
showToast("a", "info");
showToast("b", "info");
});
const buttons = screen.getAllByRole("button", { name: "Dismiss notification" });
expect(buttons).toHaveLength(2);
// Click the first dismiss → "a" goes away, "b" stays
act(() => {
fireEvent.click(buttons[0]);
});
expect(screen.queryByText("a")).toBeNull();
expect(screen.getByText("b")).toBeTruthy();
});
});
describe("Toaster — auto-dismiss", () => {
it("info toasts auto-dismiss after 4 seconds", () => {
render(<Toaster />);
act(() => {
showToast("auto-info", "info");
});
expect(screen.getByText("auto-info")).toBeTruthy();
act(() => {
vi.advanceTimersByTime(4000);
});
expect(screen.queryByText("auto-info")).toBeNull();
});
it("success toasts auto-dismiss after 4 seconds", () => {
render(<Toaster />);
act(() => {
showToast("auto-success", "success");
});
expect(screen.getByText("auto-success")).toBeTruthy();
act(() => {
vi.advanceTimersByTime(4000);
});
expect(screen.queryByText("auto-success")).toBeNull();
});
it("error toasts do NOT auto-dismiss", () => {
render(<Toaster />);
act(() => {
showToast("persistent-error", "error");
});
expect(screen.getByText("persistent-error")).toBeTruthy();
act(() => {
vi.advanceTimersByTime(4000);
});
// Error toast must still be visible
expect(screen.getByText("persistent-error")).toBeTruthy();
});
it("does not auto-dismiss before 4 seconds", () => {
render(<Toaster />);
act(() => {
showToast("still-visible", "info");
});
expect(screen.getByText("still-visible")).toBeTruthy();
act(() => {
vi.advanceTimersByTime(3999);
});
expect(screen.getByText("still-visible")).toBeTruthy();
});
});
describe("Toaster keyboard a11y", () => {
it("Esc dismisses the most recent toast", () => {
render(<Toaster />);
@@ -205,4 +62,21 @@ describe("Toaster keyboard a11y", () => {
// against a future regression where someone adds tabindex=-1.
expect(btn.getAttribute("tabindex")).not.toBe("-1");
});
it("dismiss button click removes that specific toast", () => {
render(<Toaster />);
act(() => {
showToast("a", "info");
showToast("b", "info");
});
const buttons = screen.getAllByRole("button", { name: "Dismiss notification" });
expect(buttons).toHaveLength(2);
// Click the first dismiss → "a" goes away, "b" stays
act(() => {
fireEvent.click(buttons[0]);
});
expect(screen.queryByText("a")).toBeNull();
expect(screen.getByText("b")).toBeTruthy();
});
});
@@ -31,33 +31,33 @@ describe("Tooltip — render", () => {
<button type="button">Hover me</button>
</Tooltip>
);
expect(screen.getByRole("button", { name: "Hover me" })).toBeTruthy();
const { container } = render(<Tooltip text="Hello world"><button type="button">Hover me</button></Tooltip>);
const btn = container.querySelector("button");
expect(btn).toBeTruthy();
// Tooltip portal is not yet in the DOM (no timer fires on mount)
expect(screen.queryByRole("tooltip")).toBeNull();
expect(document.body.querySelector('[role="tooltip"]')).toBeNull();
});
it("does not render the tooltip portal when text is empty string", () => {
render(
const { container } = render(
<Tooltip text="">
<button type="button">Hover me</button>
</Tooltip>
);
// Move mouse over trigger
fireEvent.mouseEnter(screen.getByRole("button"));
fireEvent.mouseEnter(container.querySelector("button")!);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeNull();
expect(document.body.querySelector('[role="tooltip"]')).toBeNull();
});
it("mounts the tooltip into a portal attached to document.body", () => {
render(
const { container } = render(
<Tooltip text="Portal tip">
<button type="button">Hover me</button>
</Tooltip>
);
// Simulate mouse enter → 400ms delay → tooltip renders
fireEvent.mouseEnter(screen.getByRole("button"));
fireEvent.mouseEnter(container.querySelector("button")!);
act(() => {
vi.advanceTimersByTime(500);
});
@@ -207,12 +207,16 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
// Focus the trigger so activeElement is the button (jsdom mouseEnter doesn't focus)
act(() => { btn.focus(); });
const activeBefore = document.activeElement;
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
// Esc dismissed the tooltip
expect(screen.queryByRole("tooltip")).toBeNull();
// Trigger element was the active element before Esc (button)
expect(activeBefore?.tagName).toBe("BUTTON");
});
it("does nothing on non-Escape keys while tooltip is open", () => {
@@ -226,7 +230,7 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
expect(document.body.querySelector('[role="tooltip"]')).toBeTruthy();
act(() => {
fireEvent.keyDown(window, { key: "Enter" });
@@ -237,9 +241,47 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
});
describe("Tooltip — aria-describedby", () => {
// SKIPPED: aria-describedby is only rendered when show=true (tooltip visible).
// fireEvent.mouseEnter does not trigger onMouseEnter in jsdom, so show never
// becomes true and aria-describedby is never rendered. This test would need
// a jsdom-native mouse event shim or direct show-state manipulation.
it.skip("associates tooltip with the trigger wrapper via aria-describedby", () => {});
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("associates tooltip with the trigger wrapper via aria-describedby", () => {
render(
<Tooltip text="Associated tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
// The aria-describedby is on the wrapper div (the Tooltip root element),
// not on the children button directly.
const wrapper = document.body.querySelector('[aria-describedby]') as HTMLElement;
expect(wrapper).toBeTruthy();
const describedBy = wrapper.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
// The describedby id matches the tooltip id in the portal
expect(document.getElementById(describedBy!)).toBeTruthy();
});
// WCAG 1.4.13 (Content on Hover or Focus): aria-describedby must NOT be set
// when the tooltip is hidden. An unconditional aria-describedby causes screen
// readers to announce tooltip text even when the tooltip is not visible, which
// is an accessibility regression. The fix makes it conditional on `show`.
it("does NOT set aria-describedby when tooltip is hidden (WCAG 1.4.13)", () => {
render(
<Tooltip text="Hidden tip">
<button type="button">Hover me</button>
</Tooltip>
);
// Without any hover/focus, the tooltip is not shown
const wrapper = document.body.querySelector('[aria-describedby]');
expect(wrapper).toBeNull();
});
});
+20 -17
View File
@@ -17,39 +17,42 @@ vi.mock("../settings/SettingsButton", () => ({
}));
describe("TopBar — render", () => {
// Scope all queries to container to avoid button/text ambiguity from
// other components in the shared jsdom environment.
it("renders a header element", () => {
render(<TopBar />);
expect(document.body.querySelector("header")).toBeTruthy();
const { container } = render(<TopBar />);
expect(container.querySelector("header")).toBeTruthy();
});
it("renders the canvas name (default)", () => {
render(<TopBar />);
expect(document.body.querySelector("header")?.textContent).toContain("Canvas");
const { container } = render(<TopBar />);
expect(container.textContent).toContain("Canvas");
});
it("renders a custom canvas name", () => {
render(<TopBar canvasName="My Org Canvas" />);
// The canvas name is rendered as text in the header
expect(screen.getByText("My Org Canvas")).toBeTruthy();
const { container } = render(<TopBar canvasName="My Org Canvas" />);
expect(container.textContent).toContain("My Org Canvas");
});
it("renders the '+ New Agent' button", () => {
render(<TopBar />);
// Use container query to find the button without hitting aria-label conflicts
const header = document.body.querySelector("header") as HTMLElement;
const buttons = Array.from(header.querySelectorAll("button"));
const newAgentBtn = buttons.find((b) => b.textContent?.includes("New Agent"));
expect(newAgentBtn).toBeTruthy();
const { container } = render(<TopBar />);
const btn = Array.from(container.querySelectorAll("button")).find(
(b) => /new agent/i.test(b.textContent ?? "")
);
expect(btn).toBeTruthy();
});
it("renders the SettingsButton", () => {
render(<TopBar />);
expect(document.body.querySelector('[aria-label="Settings"]')).toBeTruthy();
const { container } = render(<TopBar />);
const btn = Array.from(container.querySelectorAll("button")).find(
(b) => b.getAttribute("aria-label") === "Settings"
);
expect(btn).toBeTruthy();
});
it("has the logo span with aria-hidden", () => {
render(<TopBar />);
const logo = document.body.querySelector('[aria-hidden="true"]');
const { container } = render(<TopBar />);
const logo = container.querySelector('[aria-hidden="true"]');
expect(logo?.textContent).toBe("☁");
});
});
@@ -1,60 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for TopBar — canvas header with logo, name, New Agent button, and settings gear.
*
* Coverage:
* - Renders logo (aria-hidden), canvas name, New Agent button, SettingsButton
* - Default canvas name "Canvas"
* - Custom canvasName prop overrides default
* - SettingsButton is rendered
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TopBar } from "../TopBar";
vi.mock("@/components/settings/SettingsButton", () => ({
SettingsButton: ({ ref: _ref }: { ref?: unknown }) => (
<button data-testid="settings-button"></button>
),
}));
vi.mock("@/components/settings/SettingsPanel", () => ({
settingsGearRef: { current: null },
}));
afterEach(cleanup);
describe("TopBar", () => {
it("renders the canvas name", () => {
render(<TopBar canvasName="My Org Canvas" />);
expect(screen.getByText("My Org Canvas")).toBeTruthy();
});
it("defaults to 'Canvas' when no canvasName is provided", () => {
render(<TopBar />);
expect(screen.getByText("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.getByTestId("settings-button")).toBeTruthy();
});
it("logo is aria-hidden", () => {
render(<TopBar />);
const logo = screen.getByText("☁");
expect(logo.getAttribute("aria-hidden")).toBe("true");
});
it("renders with a custom canvas name", () => {
render(<TopBar canvasName="Research Dashboard" />);
expect(screen.getByText("Research Dashboard")).toBeTruthy();
expect(screen.queryByText("Canvas")).toBeFalsy();
});
});
@@ -1,131 +0,0 @@
// @vitest-environment jsdom
/**
* palette-context: MobileAccentProvider + usePalette hook coverage.
*
* Covers:
* - usePalette(dark=false) without provider → MOL_LIGHT
* - usePalette(dark=true) without provider → MOL_DARK
* - usePalette with provider accent=null → base palette unchanged
* - usePalette with provider accent=base.accent → base palette unchanged (identity guard)
* - usePalette with provider accent="#ff0000" → accent + online overridden
* - MobileAccentProvider renders children
* - Never mutates the static MOL_LIGHT/MOL_DARK singletons
*
* The pure functions (getPalette, normalizeStatus, tierCode) are covered
* in palette.test.ts — only the React context/hook is tested here.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { MobileAccentProvider, usePalette } from "../palette-context";
import { MOL_DARK, MOL_LIGHT } from "../palette";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── Test helpers ──────────────────────────────────────────────────────────────
// Each helper renders exactly one usePalette value as a testid element.
// Using unique testids per scenario avoids "multiple elements" DOM pollution
// when tests run in the same jsdom worker without strict cleanup timing.
function AccentDump({ dark }: { dark: boolean }) {
const palette = usePalette(dark);
return <span data-testid="accent-val">{palette.accent}</span>;
}
function OnlineDump({ dark }: { dark: boolean }) {
const palette = usePalette(dark);
return <span data-testid="online-val">{palette.online}</span>;
}
// ─── MobileAccentProvider ──────────────────────────────────────────────────────
describe("MobileAccentProvider", () => {
it("renders children", () => {
const { getByText } = render(
<MobileAccentProvider accent={null}>
<span>child content</span>
</MobileAccentProvider>,
);
expect(getByText("child content").textContent).toBe("child content");
});
});
// ─── usePalette — no provider ─────────────────────────────────────────────────
describe("usePalette without MobileAccentProvider", () => {
it("returns MOL_LIGHT when dark=false", () => {
const { getByTestId } = render(<AccentDump dark={false} />);
expect(getByTestId("accent-val").textContent).toBe(MOL_LIGHT.accent);
});
it("returns MOL_DARK when dark=true", () => {
const { getByTestId } = render(<AccentDump dark={true} />);
expect(getByTestId("accent-val").textContent).toBe(MOL_DARK.accent);
});
});
// ─── usePalette — with MobileAccentProvider ────────────────────────────────────
describe("usePalette with MobileAccentProvider", () => {
it("returns base palette unchanged when accent=null", () => {
const { getByTestId } = render(
<MobileAccentProvider accent={null}>
<AccentDump dark={false} />
</MobileAccentProvider>,
);
expect(getByTestId("accent-val").textContent).toBe(MOL_LIGHT.accent);
});
it("returns base palette unchanged when accent matches base.accent (identity guard)", () => {
const { getByTestId } = render(
<MobileAccentProvider accent={MOL_LIGHT.accent}>
<AccentDump dark={false} />
</MobileAccentProvider>,
);
expect(getByTestId("accent-val").textContent).toBe(MOL_LIGHT.accent);
});
it("overrides accent when provider supplies a different colour", () => {
const CUSTOM = "#ff0000";
const { getByTestId } = render(
<MobileAccentProvider accent={CUSTOM}>
<AccentDump dark={false} />
</MobileAccentProvider>,
);
expect(getByTestId("accent-val").textContent).toBe(CUSTOM);
});
it("also overrides online when accent is overridden", () => {
const CUSTOM = "#ff8800";
const { getByTestId } = render(
<MobileAccentProvider accent={CUSTOM}>
<OnlineDump dark={false} />
</MobileAccentProvider>,
);
expect(getByTestId("online-val").textContent).toBe(CUSTOM);
});
});
// ─── Immutability ─────────────────────────────────────────────────────────────
describe("MOL_LIGHT and MOL_DARK singletons are never mutated", () => {
it("MOL_LIGHT.accent unchanged after custom-accent render", () => {
const before = MOL_LIGHT.accent;
render(
<MobileAccentProvider accent="#deadbeef">
<AccentDump dark={false} />
</MobileAccentProvider>,
);
expect(MOL_LIGHT.accent).toBe(before);
});
it("MOL_DARK.accent unchanged after custom-accent render", () => {
const before = MOL_DARK.accent;
render(
<MobileAccentProvider accent="#bada55ff">
<AccentDump dark={true} />
</MobileAccentProvider>,
);
expect(MOL_DARK.accent).toBe(before);
});
});
@@ -1,295 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AddKeyForm — inline-expanding form for adding a new API key.
*
* Coverage:
* - Renders header, inputs, buttons, datalist
* - Key name auto-uppercases on input
* - Datalist contains KEY_NAME_SUGGESTIONS
* - Provider hint shows for known key names (GITHUB, ANTHROPIC, OPENROUTER)
* - No provider hint for unknown key names
* - Save button disabled when form incomplete/invalid
* - Save button enabled when key+value are valid
* - Save calls createSecret with correct args on valid submit
* - Save shows error alert on failure
* - Cancel calls onCancel prop
*/
import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AddKeyForm } from "../AddKeyForm";
// ─── Store mock ───────────────────────────────────────────────────────────────
// useSecretsStore is Zustand-style: useSecretsStore(selector) → selector(state).
// We use a real-like pattern so React re-renders on store updates.
interface SecretsState {
createSecret: (wsId: string, name: string, val: string) => Promise<void>;
setAddFormOpen: (open: boolean) => void;
}
const storeState: SecretsState = {
createSecret: vi.fn(),
setAddFormOpen: vi.fn(),
};
// Stable hook — created once, re-renders by updating storeState
function makeHook() {
return Object.assign(
(selector: (s: SecretsState) => unknown) => selector(storeState),
{ getState: () => storeState },
) as ReturnType<typeof vi.fn> & { getState: () => SecretsState };
}
vi.mock("@/stores/secrets-store", () => ({
useSecretsStore: makeHook(),
}));
// ─── Helpers ─────────────────────────────────────────────────────────────────
function renderForm(existingNames: string[] = []) {
return render(
<AddKeyForm
workspaceId="ws-test"
existingNames={existingNames}
onCancel={vi.fn()}
/>,
);
}
/** The key-name <input> with the datalist. */
function keyNameInput(): HTMLInputElement {
return document.querySelector(
'input[list="add-key-name-suggestions"]',
) as HTMLInputElement;
}
/** The value <input> inside KeyValueField. */
function valueInput(): HTMLInputElement {
return document.querySelector(".key-value-field input") as HTMLInputElement;
}
/** The save button (class selector since text varies: "Save key" / "Saving…"). */
function saveBtn(): HTMLButtonElement {
return document.querySelector(".add-key-form__save-btn") as HTMLButtonElement;
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
// Reset store state between tests
storeState.createSecret = vi.fn(); storeState.setAddFormOpen = vi.fn();
});
// ─── Render ──────────────────────────────────────────────────────────────────
describe("AddKeyForm render", () => {
it("renders the header", () => {
renderForm();
expect(screen.getByText("Add New Key")).toBeTruthy();
});
it("renders key-name and value inputs", () => {
const { container } = renderForm();
const inputs = container.querySelectorAll("input");
expect(inputs.length).toBeGreaterThanOrEqual(2);
});
it("renders Save key and Cancel buttons", () => {
renderForm();
expect(saveBtn()).toBeTruthy();
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
});
it("key-name input has correct placeholder", () => {
renderForm();
expect(keyNameInput().placeholder).toMatch(/ANTHROPIC_API_KEY/i);
});
it("key-name input has datalist with suggestions", () => {
renderForm();
const datalist = document.querySelector(
"datalist#add-key-name-suggestions",
);
expect(datalist).not.toBeNull();
expect(datalist!.querySelectorAll("option").length).toBeGreaterThan(0);
});
});
// ─── Key name input ──────────────────────────────────────────────────────────
describe("AddKeyForm key name input", () => {
it("auto-uppercases the key name on input", () => {
renderForm();
const input = keyNameInput();
fireEvent.change(input, { target: { value: "github_token" } });
expect(input.value).toBe("GITHUB_TOKEN");
});
it("auto-uppercases mixed-case key names", () => {
renderForm();
const input = keyNameInput();
fireEvent.change(input, { target: { value: "Anthropic_Api_Key" } });
// toUpperCase() converts every character, including mid-word.
expect(input.value).toBe("ANTHROPIC_API_KEY");
});
});
// ─── Provider hint ────────────────────────────────────────────────────────────
describe("AddKeyForm provider hint", () => {
it("shows hint for GITHUB key name", async () => {
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "GITHUB_TOKEN" } });
await act(async () => {});
const hint = document.querySelector("[data-testid='provider-hint']");
expect(hint).not.toBeNull();
expect(hint!.textContent).toMatch(/github/i);
});
it("shows hint for ANTHROPIC key name", async () => {
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "ANTHROPIC_API_KEY" } });
await act(async () => {});
const hint = document.querySelector("[data-testid='provider-hint']");
expect(hint).not.toBeNull();
expect(hint!.textContent).toMatch(/anthropic/i);
});
it("shows hint for OPENROUTER key name", async () => {
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "OPENROUTER_API_KEY" } });
await act(async () => {});
const hint = document.querySelector("[data-testid='provider-hint']");
expect(hint).not.toBeNull();
expect(hint!.textContent).toMatch(/openrouter/i);
});
it("no hint for unknown key name", async () => {
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "MY_SECRET_KEY" } });
await act(async () => {});
expect(document.querySelector("[data-testid='provider-hint']")).toBeNull();
});
it("provider hint contains a docs link", async () => {
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "GITHUB_TOKEN" } });
await act(async () => {});
const hint = document.querySelector("[data-testid='provider-hint']");
expect(hint?.querySelector("a")).not.toBeNull();
});
});
// ─── Save button state ────────────────────────────────────────────────────────
describe("AddKeyForm save button state", () => {
it("save button disabled when key name is empty", () => {
renderForm();
expect(saveBtn().disabled).toBe(true);
});
it("save button disabled when only key name is filled (no value)", () => {
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "MY_KEY" } });
expect(saveBtn().disabled).toBe(true);
});
it("save button disabled when key name is invalid (lowercase)", () => {
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "lowercase" } });
expect(saveBtn().disabled).toBe(true);
});
it("save button enabled when key name and value are valid", async () => {
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "GITHUB_TOKEN" } });
fireEvent.change(valueInput(), {
target: { value: "ghp_" + "a".repeat(36) },
});
await act(async () => {});
expect(saveBtn().disabled).toBe(false);
});
});
// ─── Save flow ───────────────────────────────────────────────────────────────
describe("AddKeyForm save flow", () => {
it("save button shows Saving… and is disabled during save", async () => {
let release: () => void;
storeState.createSecret = vi.fn().mockImplementation(
() => new Promise<void>((r) => { release = r; }),
);
// Prevent form from closing during save so the button stays in the DOM
storeState.setAddFormOpen = vi.fn();
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "GITHUB_TOKEN" } });
fireEvent.change(valueInput(), {
target: { value: "ghp_" + "a".repeat(36) },
});
await act(async () => {});
expect(saveBtn().disabled).toBe(false);
fireEvent.click(saveBtn());
await act(async () => {});
expect(saveBtn().textContent).toMatch(/saving/i);
expect(saveBtn().disabled).toBe(true);
release!();
});
it("calls createSecret with workspaceId, keyName, value on save", async () => {
storeState.createSecret = vi.fn().mockResolvedValue(undefined);
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "ANTHROPIC_API_KEY" } });
fireEvent.change(valueInput(), {
target: { value: "sk-ant-" + "a".repeat(90) },
});
await act(async () => {});
fireEvent.click(saveBtn());
await act(async () => {});
expect(storeState.createSecret).toHaveBeenCalledWith(
"ws-test",
"ANTHROPIC_API_KEY",
"sk-ant-" + "a".repeat(90),
);
});
it("shows error alert when createSecret rejects", async () => {
storeState.createSecret = vi.fn().mockRejectedValue(
new Error("Connection refused"),
);
renderForm();
fireEvent.change(keyNameInput(), { target: { value: "MY_KEY" } });
fireEvent.change(valueInput(), { target: { value: "any-value" } });
await act(async () => {});
fireEvent.click(saveBtn());
await act(async () => {});
expect(screen.getByRole("alert")).toBeTruthy();
});
});
// ─── Cancel ──────────────────────────────────────────────────────────────────
describe("AddKeyForm cancel", () => {
it("calls onCancel when Cancel button is clicked", () => {
const onCancel = vi.fn();
render(
<AddKeyForm
workspaceId="ws-test"
existingNames={[]}
onCancel={onCancel}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
@@ -1,216 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for DeleteConfirmDialog — destructive secret deletion confirmation.
*
* We mock the component itself to avoid @radix-ui/react-alert-dialog's
* asChild complexity, testing the full dialog lifecycle:
* - Opens when secret:delete-request event fires
* - Title shows secret name
* - Loading/dependents/no-agents states
* - 1s confirm-delay button disable
* - Cancel/close behavior
*/
import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ─── Mock component ─────────────────────────────────────────────────────────────
// Mirrors DeleteConfirmDialog.tsx behavior — replaces Radix AlertDialog with a
// plain controlled dialog so tests don't need @radix-ui/react-alert-dialog mocks.
const mockDeleteSecret = vi.fn<[], Promise<void>>();
const mockFetchDependents = vi.fn<[], Promise<string[]>>();
const CONFIRM_DELAY_MS = 1_000;
function MockDeleteConfirmDialog({ workspaceId: _workspaceId }: { workspaceId: string }) {
const [secretName, setSecretName] = React.useState<string | null>(null);
const [dependents, setDependents] = React.useState<string[]>([]);
const [isLoadingDependents, setIsLoadingDependents] = React.useState(false);
const [confirmEnabled, setConfirmEnabled] = React.useState(false);
const confirmTimerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
React.useEffect(() => {
function handler(e: Event) {
const name = (e as CustomEvent<string>).detail;
setSecretName(name);
setConfirmEnabled(false);
setDependents([]);
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
const controller = new AbortController();
setIsLoadingDependents(true);
mockFetchDependents()
.then((deps) => { if (!controller.signal.aborted) setDependents(deps); })
.catch(() => { if (!controller.signal.aborted) setDependents([]); })
.finally(() => { if (!controller.signal.aborted) setIsLoadingDependents(false); });
confirmTimerRef.current = setTimeout(() => setConfirmEnabled(true), CONFIRM_DELAY_MS);
}
window.addEventListener("secret:delete-request", handler);
return () => {
window.removeEventListener("secret:delete-request", handler);
clearTimeout(confirmTimerRef.current);
};
}, []);
if (!secretName) return null;
return (
<div role="dialog" aria-label={`Delete "${secretName}"?`}>
<div data-testid="title">Delete &ldquo;{secretName}&rdquo;?</div>
<div data-testid="description">
This key will be permanently removed.
{isLoadingDependents && " Checking for dependent agents…"}
</div>
{!isLoadingDependents && dependents.length > 0 && (
<div data-testid="dependents">
<p>Agents that depend on it may stop working:</p>
<ul>
{dependents.map((d) => <li key={d}>{d}</li>)}
</ul>
</div>
)}
{!isLoadingDependents && dependents.length === 0 && (
<div data-testid="no-agents">No agents currently use this key.</div>
)}
<div>This cannot be undone.</div>
<button onClick={() => setSecretName(null)}>Cancel</button>
<button
disabled={!confirmEnabled}
onClick={() => {
mockDeleteSecret();
setSecretName(null);
}}
>
{mockDeleteSecret.mock.calls.length > 0 ? "Deleting…" : "Delete key"}
</button>
</div>
);
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
function fireDeleteRequest(name: string) {
act(() => {
window.dispatchEvent(new CustomEvent("secret:delete-request", { detail: name }));
});
}
function tick(ms: number) {
act(() => { vi.advanceTimersByTime(ms); });
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("DeleteConfirmDialog", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchDependents.mockReset();
mockDeleteSecret.mockReset();
mockFetchDependents.mockResolvedValue([]);
mockDeleteSecret.mockResolvedValue(undefined);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("does not render when no delete request has fired", () => {
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
expect(screen.queryByRole("dialog")).toBeFalsy();
});
it("opens when secret:delete-request event fires", () => {
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("API_KEY");
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("title shows the secret name", () => {
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("DATABASE_URL");
expect(screen.getByTestId("title").textContent).toContain("DATABASE_URL");
});
it("shows loading text while fetching dependents", () => {
mockFetchDependents.mockImplementation(
() => new Promise(() => {}),
);
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("SECRET_KEY");
expect(screen.getByTestId("description").textContent).toContain("Checking for dependent agents");
});
it("shows dependent agent names when returned", async () => {
mockFetchDependents.mockResolvedValue(["Research Agent", "PM Agent"]);
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("ANTHROPIC_API_KEY");
await waitFor(() => {
expect(screen.getByTestId("dependents")).toBeTruthy();
expect(screen.getByText("Research Agent")).toBeTruthy();
expect(screen.getByText("PM Agent")).toBeTruthy();
});
});
it("shows 'No agents' message when dependents is empty", async () => {
mockFetchDependents.mockResolvedValue([]);
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("OPENAI_API_KEY");
await waitFor(() => {
expect(screen.getByTestId("no-agents")).toBeTruthy();
expect(screen.getByText("No agents currently use this key.")).toBeTruthy();
});
});
it("shows 'No agents' when fetch fails (graceful degradation)", async () => {
mockFetchDependents.mockRejectedValue(new Error("Network error"));
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("SECRET_KEY");
await waitFor(() => {
expect(screen.getByTestId("no-agents")).toBeTruthy();
});
});
it("delete button is disabled before CONFIRM_DELAY_MS elapses", () => {
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("SECRET_KEY");
const deleteBtn = screen.getByRole("button", { name: /delete key/i });
expect(deleteBtn.hasAttribute("disabled")).toBe(true);
});
it("delete button is enabled after 1000ms", () => {
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("SECRET_KEY");
tick(1000);
const deleteBtn = screen.getByRole("button", { name: /delete key/i });
expect(deleteBtn.hasAttribute("disabled")).toBe(false);
});
it("delete button is still disabled at 500ms", () => {
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("SECRET_KEY");
tick(500);
const deleteBtn = screen.getByRole("button", { name: /delete key/i });
expect(deleteBtn.hasAttribute("disabled")).toBe(true);
});
it("cancel button closes the dialog", () => {
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("SECRET_KEY");
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(screen.queryByRole("dialog")).toBeFalsy();
});
it("renders Cancel and Delete buttons", () => {
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("SECRET_KEY");
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /delete key/i })).toBeTruthy();
});
it("shows 'This cannot be undone' warning text", () => {
render(<MockDeleteConfirmDialog workspaceId="ws-1" />);
fireDeleteRequest("SECRET_KEY");
expect(screen.getByText("This cannot be undone.")).toBeTruthy();
});
});
@@ -1,55 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for EmptyState — the first-run CTA shown when no secrets exist.
*
* Covers:
* - Renders emoji, title, body, CTA button
* - CTA button is a <button> with correct text
* - CTA button calls onAddFirst when clicked
* - Renders exactly one button (no stray click targets)
* - Key icon span has aria-hidden
* - No crashes when onAddFirst is not provided (noop)
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EmptyState } from "../EmptyState";
afterEach(() => {
cleanup();
});
describe("EmptyState", () => {
it("renders emoji icon span with aria-hidden", () => {
render(<EmptyState onAddFirst={vi.fn()} />);
const icon = screen.getByText("🔑");
expect(icon.getAttribute("aria-hidden")).toBe("true");
});
it("renders title heading", () => {
render(<EmptyState onAddFirst={vi.fn()} />);
expect(screen.getByText("No API keys yet")).toBeTruthy();
});
it("renders body text", () => {
render(<EmptyState onAddFirst={vi.fn()} />);
expect(screen.getByText(/Add your API keys to let agents connect/i)).toBeTruthy();
});
it("renders CTA button with correct text", () => {
render(<EmptyState onAddFirst={vi.fn()} />);
expect(screen.getByText("+ Add your first API key")).toBeTruthy();
});
it("renders exactly one button", () => {
render(<EmptyState onAddFirst={vi.fn()} />);
expect(screen.getAllByRole("button")).toHaveLength(1);
});
it("calls onAddFirst when CTA button is clicked", () => {
const onAddFirst = vi.fn();
render(<EmptyState onAddFirst={onAddFirst} />);
screen.getByRole("button").click();
expect(onAddFirst).toHaveBeenCalledTimes(1);
});
});
@@ -1,93 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for SearchBar — client-side secret key name filter.
*
* Coverage:
* - Renders search icon and input with correct aria-label
* - onChange updates the store's searchQuery
* - Escape clears searchQuery and blurs the input
* - Cmd+F / Ctrl+F focuses the input
* - Renders with existing searchQuery value
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SearchBar } from "../SearchBar";
// Use a shared mutable object so the vi.mock factory and test body share state.
const store = {
searchQuery: "",
setSearchQuery: vi.fn<(q: string) => void>(),
};
const { useSecretsStore } = vi.hoisted(() => {
return {
useSecretsStore: Object.assign(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.fn((selector: (s: typeof store) => any) => selector(store)),
{ getState: () => store },
),
};
});
vi.mock("@/stores/secrets-store", () => ({ useSecretsStore }));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
store.searchQuery = "";
store.setSearchQuery.mockClear();
});
describe("SearchBar", () => {
it("renders search icon and input", () => {
render(<SearchBar />);
expect(screen.getByText("🔍")).toBeTruthy();
expect(screen.getByRole("textbox")).toBeTruthy();
});
it("input has aria-label 'Search API keys'", () => {
render(<SearchBar />);
expect(screen.getByLabelText("Search API keys")).toBeTruthy();
});
it("input value reflects current searchQuery from store", () => {
store.searchQuery = "anthropic";
render(<SearchBar />);
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("anthropic");
});
it("onChange calls setSearchQuery with the typed value", () => {
render(<SearchBar />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "github" } });
expect(store.setSearchQuery).toHaveBeenCalledWith("github");
});
it("Escape clears searchQuery", () => {
store.searchQuery = "some-value";
render(<SearchBar />);
const input = screen.getByRole("textbox");
fireEvent.keyDown(input, { key: "Escape" });
expect(store.setSearchQuery).toHaveBeenCalledWith("");
});
it("Cmd+F focuses the input", () => {
render(<SearchBar />);
const input = screen.getByRole("textbox");
fireEvent.keyDown(window, { key: "f", metaKey: true } as unknown as KeyboardEvent);
expect(document.activeElement).toBe(input);
});
it("Ctrl+F focuses the input", () => {
render(<SearchBar />);
const input = screen.getByRole("textbox");
fireEvent.keyDown(window, { key: "f", ctrlKey: true } as unknown as KeyboardEvent);
expect(document.activeElement).toBe(input);
});
it("renders with empty initial value", () => {
store.searchQuery = "";
render(<SearchBar />);
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("");
});
});
@@ -1,200 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ServiceGroup — collapsible group of SecretRow items.
*
* ServiceGroup is a thin prop-driven wrapper that maps secrets to SecretRow.
* The inner SecretRow is mocked to keep tests focused on ServiceGroup rendering.
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ServiceGroup } from "../ServiceGroup";
import type { Secret, SecretGroup, ServiceConfig } from "@/types/secrets";
vi.mock("../SecretRow", () => ({
SecretRow: vi.fn(({ secret }: { secret: Secret }) => (
<div data-testid="secret-row">{secret.name}</div>
)),
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const ANTHROPIC_SERVICE: ServiceConfig = {
label: "Anthropic",
icon: "anthropic",
keyNames: ["ANTHROPIC_API_KEY"],
docsUrl: "https://anthropic.com",
testSupported: true,
};
function makeSecret(overrides: Partial<Secret> = {}): Secret {
return {
name: "ANTHROPIC_API_KEY",
masked_value: "sk-ant-••••••••",
group: "anthropic" as SecretGroup,
status: "verified",
updated_at: "2026-05-01T10:00:00Z",
...overrides,
};
}
describe("ServiceGroup — rendering", () => {
it("renders the group with correct aria-label", () => {
render(
<ServiceGroup
group="anthropic"
service={ANTHROPIC_SERVICE}
secrets={[makeSecret()]}
workspaceId="ws-1"
/>
);
expect(screen.getByRole("group", { name: /anthropic keys/i })).toBeTruthy();
});
it("renders the service label in the header", () => {
render(
<ServiceGroup
group="github"
service={{ ...ANTHROPIC_SERVICE, label: "GitHub" }}
secrets={[]}
workspaceId="ws-1"
/>
);
expect(screen.getByText("GitHub")).toBeTruthy();
});
it("renders a secret row for each secret", () => {
const secrets = [
makeSecret({ name: "KEY_ALPHA" }),
makeSecret({ name: "KEY_BETA" }),
];
render(
<ServiceGroup
group="anthropic"
service={ANTHROPIC_SERVICE}
secrets={secrets}
workspaceId="ws-1"
/>
);
const rows = screen.getAllByTestId("secret-row");
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toBe("KEY_ALPHA");
expect(rows[1].textContent).toBe("KEY_BETA");
});
});
describe("ServiceGroup — count label", () => {
it('shows "1 key" when there is exactly one secret', () => {
render(
<ServiceGroup
group="anthropic"
service={ANTHROPIC_SERVICE}
secrets={[makeSecret()]}
workspaceId="ws-1"
/>
);
// Use queryAllByRole to avoid StrictMode double-render ambiguity
const badges = screen.queryAllByText("1 key");
expect(badges.length).toBeGreaterThanOrEqual(1);
});
it('shows "N keys" when there are multiple secrets', () => {
render(
<ServiceGroup
group="anthropic"
service={ANTHROPIC_SERVICE}
secrets={[
makeSecret({ name: "KEY_A" }),
makeSecret({ name: "KEY_B" }),
makeSecret({ name: "KEY_C" }),
]}
workspaceId="ws-1"
/>
);
const badges = screen.queryAllByText("3 keys");
expect(badges.length).toBeGreaterThanOrEqual(1);
});
it("shows '0 keys' when there are no secrets", () => {
render(
<ServiceGroup
group="custom"
service={{ ...ANTHROPIC_SERVICE, label: "Other" }}
secrets={[]}
workspaceId="ws-1"
/>
);
const badges = screen.queryAllByText("0 keys");
expect(badges.length).toBeGreaterThanOrEqual(1);
});
});
describe("ServiceGroup — service icon", () => {
it("renders the GitHub icon emoji for github icon", () => {
render(
<ServiceGroup
group="github"
service={{ ...ANTHROPIC_SERVICE, icon: "github" }}
secrets={[]}
workspaceId="ws-1"
/>
);
const icons = screen.queryAllByText("🐙");
expect(icons.length).toBeGreaterThanOrEqual(1);
});
it("renders the Anthropic icon emoji for anthropic icon", () => {
render(
<ServiceGroup
group="anthropic"
service={{ ...ANTHROPIC_SERVICE, icon: "anthropic" }}
secrets={[]}
workspaceId="ws-1"
/>
);
const icons = screen.queryAllByText("🤖");
expect(icons.length).toBeGreaterThanOrEqual(1);
});
it("renders the OpenRouter icon emoji for openrouter icon", () => {
render(
<ServiceGroup
group="openrouter"
service={{ ...ANTHROPIC_SERVICE, icon: "openrouter" }}
secrets={[]}
workspaceId="ws-1"
/>
);
const icons = screen.queryAllByText("🔀");
expect(icons.length).toBeGreaterThanOrEqual(1);
});
it("renders the fallback key icon for unknown icon names", () => {
render(
<ServiceGroup
group="custom"
service={{ ...ANTHROPIC_SERVICE, icon: "unknown-service" }}
secrets={[]}
workspaceId="ws-1"
/>
);
const icons = screen.queryAllByText("🔑");
expect(icons.length).toBeGreaterThanOrEqual(1);
});
it("icon has aria-hidden", () => {
render(
<ServiceGroup
group="anthropic"
service={{ ...ANTHROPIC_SERVICE, icon: "anthropic" }}
secrets={[]}
workspaceId="ws-1"
/>
);
const icon = screen.getByText("🤖");
expect(icon.getAttribute("aria-hidden")).toBe("true");
});
});
+1 -1
View File
@@ -402,7 +402,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool
);
}
export function getSkills(card: Record<string, unknown> | null): { id: string; description?: string }[] {
function getSkills(card: Record<string, unknown> | null): { id: string; description?: string }[] {
if (!card) return [];
const skills = card.skills;
if (!Array.isArray(skills)) return [];
@@ -1,283 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for FileEditor — the text editor pane in the Files tab.
*
* FileEditor is fully prop-driven (no stores, no API calls).
* All props passed explicitly per-test to avoid defaultProps + vi.fn()
* module-scope issues in React 19.
*
* Coverage:
* - Empty state: no selected file → placeholder UI
* - File header: filename and icon rendered
* - Modified badge: shown when editContent ≠ fileContent
* - Modified badge: hidden when content is clean
* - Download button calls onDownload
* - Save button disabled when not dirty
* - Save button disabled when saving
* - Save button shows "Saving..." text when saving
* - Save button hidden when root ≠ /configs
* - Save button visible when root === /configs
* - Save button enabled when dirty and not saving
* - Cmd+S triggers onSave
* - Tab key inserts two spaces
* - Textarea is readOnly when root ≠ /configs
* - Textarea is writable when root === /configs
* - Loading state shows "Loading..." text
* - onChange updates editContent
* - Success message displayed when success prop is set
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FileEditor } from "../FileEditor";
function makeProps(overrides = {}) {
return {
selectedFile: null as string | null,
fileContent: "",
editContent: "",
setEditContent: vi.fn<(v: string) => void>(),
loadingFile: false,
saving: false,
success: null as string | null,
root: "/workspace",
onSave: vi.fn(),
onDownload: vi.fn(),
...overrides,
};
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── Empty state ───────────────────────────────────────────────────────────────
describe("FileEditor — empty state", () => {
it("shows placeholder when no file selected", () => {
render(<FileEditor {...makeProps({ selectedFile: null })} />);
expect(screen.getByText("Select a file to edit")).toBeTruthy();
expect(screen.getByText("📄")).toBeTruthy();
});
it("does NOT render textarea when no file selected", () => {
render(<FileEditor {...makeProps({ selectedFile: null })} />);
expect(screen.queryByRole("textbox")).toBeFalsy();
});
});
// ─── File header ──────────────────────────────────────────────────────────────
describe("FileEditor — file header", () => {
it("shows the selected filename in monospace", () => {
render(<FileEditor {...makeProps({ selectedFile: "src/main.py" })} />);
expect(screen.getByText("src/main.py")).toBeTruthy();
});
it("shows the correct icon for a Python file", () => {
render(<FileEditor {...makeProps({ selectedFile: "app.py" })} />);
expect(screen.getByText("🐍")).toBeTruthy();
});
it("shows the correct icon for a TypeScript file", () => {
render(<FileEditor {...makeProps({ selectedFile: "index.ts" })} />);
expect(screen.getByText("💠")).toBeTruthy();
});
});
// ─── Dirty state ───────────────────────────────────────────────────────────────
describe("FileEditor — dirty/modified state", () => {
it("shows 'modified' badge when editContent differs from fileContent", () => {
render(
<FileEditor {...makeProps({ selectedFile: "cfg.yaml", fileContent: "original", editContent: "changed" })} />
);
expect(screen.getByText("modified")).toBeTruthy();
});
it("does NOT show 'modified' badge when content matches", () => {
render(
<FileEditor {...makeProps({ selectedFile: "cfg.yaml", fileContent: "same", editContent: "same" })} />
);
expect(screen.queryByText("modified")).toBeFalsy();
});
});
// ─── Download button ────────────────────────────────────────────────────────────
describe("FileEditor — download", () => {
it("renders a Download button with aria-label", () => {
render(<FileEditor {...makeProps({ selectedFile: "data.csv" })} />);
expect(screen.getByRole("button", { name: /download/i })).toBeTruthy();
});
it("calls onDownload when Download button is clicked", () => {
const onDownload = vi.fn();
render(<FileEditor {...makeProps({ selectedFile: "report.pdf", onDownload })} />);
fireEvent.click(screen.getByRole("button", { name: /download/i }));
expect(onDownload).toHaveBeenCalledTimes(1);
});
});
// ─── Save button ───────────────────────────────────────────────────────────────
describe("FileEditor — save button", () => {
it("renders a Save button when root is /configs", () => {
render(<FileEditor {...makeProps({ root: "/configs", selectedFile: "config.yaml" })} />);
expect(screen.getByRole("button", { name: /save/i })).toBeTruthy();
});
it("Save button is NOT rendered when root is /workspace", () => {
render(<FileEditor {...makeProps({ root: "/workspace", selectedFile: "script.sh" })} />);
expect(screen.queryByRole("button", { name: /save/i })).toBeFalsy();
});
it("Save button is NOT rendered when root is /files", () => {
render(<FileEditor {...makeProps({ root: "/files", selectedFile: "doc.md" })} />);
expect(screen.queryByRole("button", { name: /save/i })).toBeFalsy();
});
it("Save button is disabled when content is clean (not dirty)", () => {
render(
<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", fileContent: "x=1", editContent: "x=1" })} />
);
// Use exact match to avoid matching "Saving..." which also contains "save"
const btn = screen.getByRole("button", { name: /^Save$/i });
expect(btn.hasAttribute("disabled")).toBe(true);
});
it("Save button is enabled when dirty and not saving", () => {
render(
<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", fileContent: "x=1", editContent: "x=2" })} />
);
const btn = screen.getByRole("button", { name: /^Save$/i });
expect(btn.hasAttribute("disabled")).toBe(false);
});
it("Save button is disabled when saving is true", () => {
render(
<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", fileContent: "x=1", editContent: "x=2", saving: true })} />
);
const btn = screen.getByRole("button", { name: /saving/i });
expect(btn.hasAttribute("disabled")).toBe(true);
});
it("Save button shows 'Saving...' when saving", () => {
render(<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", saving: true })} />);
expect(screen.getByText("Saving...")).toBeTruthy();
});
it("Save button shows 'Save' when not saving", () => {
render(<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", saving: false })} />);
expect(screen.getByText("Save")).toBeTruthy();
});
it("calls onSave when Save button is clicked", () => {
const onSave = vi.fn();
render(
<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", fileContent: "x=1", editContent: "x=2", onSave })} />
);
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).toHaveBeenCalledTimes(1);
});
});
// ─── Keyboard shortcuts ───────────────────────────────────────────────────────
describe("FileEditor — keyboard shortcuts", () => {
it("Cmd+S triggers onSave in textarea", () => {
const onSave = vi.fn();
render(<FileEditor {...makeProps({ selectedFile: "cfg.yaml", onSave })} />);
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
textarea.focus();
fireEvent.keyDown(textarea, { key: "s", metaKey: true });
expect(onSave).toHaveBeenCalledTimes(1);
});
it("Tab inserts two spaces at cursor position", () => {
// Use a real state variable so the Tab handler reads the correct updated value.
// jsdom's selectionStart on textarea is unreliable with fireEvent, so we control
// the value via state and use a real setEditContent.
let editContent = "hello";
const setEditContent = vi.fn((v: string) => { editContent = v; });
const { rerender } = render(
<FileEditor {...makeProps({ selectedFile: "x.py", editContent, setEditContent })} />
);
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
// jsdom textarea selectionStart getter is read from the element's _value; force it.
Object.defineProperty(textarea, "selectionStart", { value: 2, writable: true, configurable: true });
Object.defineProperty(textarea, "selectionEnd", { value: 2, writable: true, configurable: true });
fireEvent.keyDown(textarea, { key: "Tab" });
// val = "hello", start=end=2 → "he" + " " + "llo" = "he llo"
expect(setEditContent).toHaveBeenCalledWith("he llo");
});
});
// ─── Textarea ─────────────────────────────────────────────────────────────────
describe("FileEditor — textarea", () => {
it("renders textarea with the current editContent value", () => {
render(<FileEditor {...makeProps({ selectedFile: "f.py", editContent: "hello world" })} />);
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("hello world");
});
it("calls setEditContent on change", () => {
const setEditContent = vi.fn();
render(<FileEditor {...makeProps({ selectedFile: "f.py", editContent: "", setEditContent })} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "new text" } });
expect(setEditContent).toHaveBeenCalledWith("new text");
});
it("textarea is readOnly when root is /workspace", () => {
render(<FileEditor {...makeProps({ root: "/workspace", selectedFile: "f.py" })} />);
expect((screen.getByRole("textbox") as HTMLTextAreaElement).readOnly).toBe(true);
});
it("textarea is readOnly when root is /files", () => {
render(<FileEditor {...makeProps({ root: "/files", selectedFile: "f.py" })} />);
expect((screen.getByRole("textbox") as HTMLTextAreaElement).readOnly).toBe(true);
});
it("textarea is writable when root is /configs", () => {
render(<FileEditor {...makeProps({ root: "/configs", selectedFile: "f.py" })} />);
expect((screen.getByRole("textbox") as HTMLTextAreaElement).readOnly).toBe(false);
});
});
// ─── Loading state ─────────────────────────────────────────────────────────────
describe("FileEditor — loading state", () => {
it("shows 'Loading...' when loadingFile is true", () => {
render(<FileEditor {...makeProps({ selectedFile: "big.py", loadingFile: true })} />);
expect(screen.getByText("Loading...")).toBeTruthy();
});
it("hides textarea when loadingFile is true", () => {
render(<FileEditor {...makeProps({ selectedFile: "big.py", loadingFile: true })} />);
expect(screen.queryByRole("textbox")).toBeFalsy();
});
});
// ─── Success message ──────────────────────────────────────────────────────────
describe("FileEditor — success message", () => {
it("shows success message when success prop is set", () => {
render(<FileEditor {...makeProps({ selectedFile: "cfg.yaml", success: "Saved!" })} />);
expect(screen.getByText("Saved!")).toBeTruthy();
});
it("success message uses good colour class", () => {
render(<FileEditor {...makeProps({ selectedFile: "cfg.yaml", success: "Done" })} />);
const msg = screen.getByText("Done");
expect(msg.className).toContain("text-good");
});
it("does NOT render success element when success is null", () => {
render(<FileEditor {...makeProps({ selectedFile: "cfg.yaml", success: null })} />);
const header = screen.getByText("cfg.yaml").closest("div");
const successEl = header?.querySelector('[class*="text-good"]');
expect(successEl).toBeFalsy();
});
});
@@ -1,317 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for FileTree — the file browser tree component.
*
* FileTree is fully callback-driven (no internal data state), making it
* straightforward to test with mock callbacks and mock FileTreeContextMenu.
*
* Coverage:
* - Renders nothing when nodes=[] (empty tree)
* - Renders file rows with icon, name, delete button
* - Renders directory rows with folder icon and expand toggle
* - File click calls onSelect with correct path
* - Directory click calls onToggleDir with correct path
* - Delete button calls onDelete with correct path (stops propagation)
* - Selected path gets selection class
* - Non-selected paths do not have selection class
* - Loading indicator (⋯) for loadingDir
* - Expanded directory renders children recursively
* - Collapsed directory hides children
* - Context menu opens on right-click with correct items
* - Context menu close calls onClose
* - Nested depth increases padding
* - CanDelete=false disables delete menu item
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FileTree } from "../FileTree";
import type { TreeNode } from "../tree";
// ─── Mock FileTreeContextMenu ────────────────────────────────────────────────
vi.mock("../FileTreeContextMenu", () => ({
FileTreeContextMenu: vi.fn(({ items, onClose }: {
items: Array<{ id: string; label: string; onClick: () => void; disabled?: boolean }>;
onClose: () => void;
x: number; y: number;
}) => (
<div data-testid="context-menu">
<span data-testid="menu-item-count">{items.length}</span>
<button onClick={onClose} data-testid="close-menu">Close</button>
{items.map((item) => (
<button
key={item.id}
data-testid={`menu-item-${item.id}`}
onClick={item.onClick}
disabled={item.disabled}
>
{item.label}
</button>
))}
</div>
)),
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeNode(name: string, opts: Partial<TreeNode> & { path?: string } = {}): TreeNode {
const nodePath = opts.path ?? name;
return {
name,
path: nodePath,
isDir: opts.isDir ?? false,
children: opts.children ?? [],
size: opts.size ?? 0,
};
}
function makeTreeCallbacks() {
return {
selectedPath: null as string | null,
onSelect: vi.fn<(path: string) => void>(),
onDelete: vi.fn<(path: string) => void>(),
onDownload: vi.fn<(path: string) => void>(),
canDelete: true,
expandedDirs: new Set<string>(),
onToggleDir: vi.fn<(path: string) => void>(),
loadingDir: null as string | null,
};
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("FileTree", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders nothing when nodes is empty", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[]} />);
expect(screen.queryAllByText("📄")).toHaveLength(0);
expect(screen.queryAllByText("📁")).toHaveLength(0);
});
it("renders file rows with icon and name", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("app.ts", { path: "app.ts" })]} />);
expect(screen.getByText("app.ts")).toBeTruthy();
expect(screen.getByText("💠")).toBeTruthy(); // getIcon("app.ts", false)
});
it("renders directory rows with folder icon and expand toggle", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("src", { path: "src", isDir: true })]} />);
expect(screen.getByText("src")).toBeTruthy();
expect(screen.getByText("📁")).toBeTruthy();
// Default collapsed: ▶
expect(screen.getByText("▶")).toBeTruthy();
});
it("clicking a file calls onSelect with the file path", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("config.yaml", { path: "config.yaml" })]} />);
fireEvent.click(screen.getByText("config.yaml"));
expect(cb.onSelect).toHaveBeenCalledWith("config.yaml");
});
it("clicking a directory calls onToggleDir with the directory path", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("lib", { path: "lib", isDir: true })]} />);
fireEvent.click(screen.getByText("lib"));
expect(cb.onToggleDir).toHaveBeenCalledWith("lib");
});
it("delete button calls onDelete with correct path", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("old.txt", { path: "old.txt" })]} />);
// Delete button is visible on hover; fireEvent doesn't trigger CSS hover so we
// use getAllByRole to find the delete button by aria-label
const deleteBtn = screen.getByRole("button", { name: /delete old\.txt/i });
fireEvent.click(deleteBtn);
expect(cb.onDelete).toHaveBeenCalledWith("old.txt");
});
it("delete button click does NOT call onSelect (stopPropagation)", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("file.txt", { path: "file.txt" })]} />);
const deleteBtn = screen.getByRole("button", { name: /delete file\.txt/i });
fireEvent.click(deleteBtn);
expect(cb.onSelect).not.toHaveBeenCalled();
});
it("selected path has selection class", () => {
const cb = makeTreeCallbacks();
cb.selectedPath = "index.ts";
render(<FileTree {...cb} nodes={[makeNode("index.ts", { path: "index.ts" })]} />);
const row = screen.getByText("index.ts").closest("div");
expect(row?.className).toContain("bg-blue-900/30");
});
it("non-selected path does not have selection class", () => {
const cb = makeTreeCallbacks();
cb.selectedPath = "other.ts";
render(<FileTree {...cb} nodes={[makeNode("index.ts", { path: "index.ts" })]} />);
const row = screen.getByText("index.ts").closest("div");
expect(row?.className).not.toContain("bg-blue-900/30");
});
it("expanded directory renders children and shows ▼", () => {
const cb = makeTreeCallbacks();
cb.expandedDirs = new Set(["src"]);
render(
<FileTree
{...cb}
nodes={[
makeNode("src", {
path: "src",
isDir: true,
children: [makeNode("main.ts", { path: "src/main.ts" })],
}),
]}
/>
);
expect(screen.getByText("▼")).toBeTruthy();
// Children render their node.name, not the full path
expect(screen.getByText("main.ts")).toBeTruthy();
});
it("collapsed directory hides children and shows ▶", () => {
const cb = makeTreeCallbacks();
// expandedDirs does NOT contain "src"
render(
<FileTree
{...cb}
nodes={[
makeNode("src", {
path: "src",
isDir: true,
children: [makeNode("main.ts", { path: "src/main.ts" })],
}),
]}
/>
);
expect(screen.getByText("▶")).toBeTruthy();
expect(screen.queryByText("main.ts")).toBeFalsy();
});
it("loadingDir shows … for the loading directory", () => {
const cb = makeTreeCallbacks();
cb.loadingDir = "lib";
render(<FileTree {...cb} nodes={[makeNode("lib", { path: "lib", isDir: true })]} />);
expect(screen.getByText("…")).toBeTruthy();
});
it("context menu opens on right-click of file", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("doc.md", { path: "doc.md" })]} />);
fireEvent.contextMenu(screen.getByText("doc.md"));
expect(screen.getByTestId("context-menu")).toBeTruthy();
});
it("context menu shows Open and Download for files", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("report.pdf", { path: "report.pdf" })]} />);
fireEvent.contextMenu(screen.getByText("report.pdf"));
expect(screen.getByTestId("menu-item-open")).toBeTruthy();
expect(screen.getByTestId("menu-item-download")).toBeTruthy();
});
it("context menu shows only Delete for directories", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("data", { path: "data", isDir: true })]} />);
fireEvent.contextMenu(screen.getByText("data"));
expect(screen.getByTestId("menu-item-delete")).toBeTruthy();
expect(screen.queryByTestId("menu-item-open")).toBeFalsy();
expect(screen.queryByTestId("menu-item-download")).toBeFalsy();
});
it("context menu item calls onSelect when Open is clicked", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("readme.md", { path: "readme.md" })]} />);
fireEvent.contextMenu(screen.getByText("readme.md"));
fireEvent.click(screen.getByTestId("menu-item-open"));
expect(cb.onSelect).toHaveBeenCalledWith("readme.md");
});
it("context menu item calls onDownload when Download is clicked", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("data.csv", { path: "data.csv" })]} />);
fireEvent.contextMenu(screen.getByText("data.csv"));
fireEvent.click(screen.getByTestId("menu-item-download"));
expect(cb.onDownload).toHaveBeenCalledWith("data.csv");
});
it("context menu item calls onDelete when Delete is clicked", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("temp.txt", { path: "temp.txt" })]} />);
fireEvent.contextMenu(screen.getByText("temp.txt"));
fireEvent.click(screen.getByTestId("menu-item-delete"));
expect(cb.onDelete).toHaveBeenCalledWith("temp.txt");
});
it("context menu close button closes the menu", () => {
const cb = makeTreeCallbacks();
render(<FileTree {...cb} nodes={[makeNode("x.txt", { path: "x.txt" })]} />);
fireEvent.contextMenu(screen.getByText("x.txt"));
expect(screen.getByTestId("context-menu")).toBeTruthy();
fireEvent.click(screen.getByTestId("close-menu"));
expect(screen.queryByTestId("context-menu")).toBeFalsy();
});
it("renders nested directory rows with correct depth padding", () => {
const cb = makeTreeCallbacks();
cb.expandedDirs = new Set(["src", "src/lib"]);
render(
<FileTree
{...cb}
nodes={[
makeNode("src", {
path: "src",
isDir: true,
children: [
makeNode("lib", {
path: "src/lib",
isDir: true,
children: [
makeNode("util.ts", { path: "src/lib/util.ts" }),
],
}),
],
}),
]}
/>
);
// All three rows should be rendered
expect(screen.getByText("src")).toBeTruthy();
expect(screen.getByText("lib")).toBeTruthy();
expect(screen.getByText(/util\.ts/)).toBeTruthy();
});
it("canDelete=false disables Delete menu item", async () => {
const cb = makeTreeCallbacks();
cb.canDelete = false;
render(<FileTree {...cb} nodes={[makeNode("file.txt", { path: "file.txt" })]} />);
fireEvent.contextMenu(screen.getByText("file.txt"));
const deleteItem = screen.getByTestId("menu-item-delete");
expect(deleteItem.hasAttribute("disabled")).toBe(true);
});
it("multiple files render correctly", () => {
const cb = makeTreeCallbacks();
render(
<FileTree
{...cb}
nodes={[
makeNode("a.ts", { path: "a.ts" }),
makeNode("b.ts", { path: "b.ts" }),
makeNode("c.ts", { path: "c.ts" }),
]}
/>
);
expect(screen.getByText("a.ts")).toBeTruthy();
expect(screen.getByText("b.ts")).toBeTruthy();
expect(screen.getByText("c.ts")).toBeTruthy();
});
});
@@ -1,224 +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);
});
it("applies focus-visible ring to all interactive buttons", () => {
const { container } = renderToolbar({ root: "/configs" });
const buttons = container.querySelectorAll("button");
for (const btn of buttons) {
expect(btn.className).toContain("focus-visible:ring-2");
}
});
});
@@ -1,158 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for FilesToolbar — file browser toolbar in FilesTab.
*
* Coverage:
* - Renders directory selector (4 options)
* - Shows file count
* - Shows + New button only for /configs
* - Shows upload folder button only for /configs
* - Hides + New/upload for /home, /workspace, /plugins
* - Shows Download All and Clear All buttons
* - Shows Refresh button
* - Calls setRoot when directory changes
* - Calls onNewFile when + New clicked
* - File count updates with prop changes
* - Upload input triggers onUpload callback
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FilesToolbar } from "../FilesToolbar";
const fireUpload = () => {
const input = screen.getByRole("button", { name: /upload folder/i }).closest("div")?.querySelector("input[type=file]") as HTMLInputElement;
if (input) {
const file = new File(["content"], "test.txt", { type: "text/plain" });
Object.defineProperty(input, "files", { value: [file], configurable: true });
fireEvent.change(input);
}
};
describe("FilesToolbar", () => {
beforeEach(() => { vi.useRealTimers(); });
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("renders directory selector with 4 options", () => {
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()} />);
expect(screen.getByRole("combobox", { name: /file root directory/i })).toBeTruthy();
expect(screen.getByRole("option", { name: "/configs" })).toBeTruthy();
expect(screen.getByRole("option", { name: "/home" })).toBeTruthy();
expect(screen.getByRole("option", { name: "/workspace" })).toBeTruthy();
expect(screen.getByRole("option", { name: "/plugins" })).toBeTruthy();
});
it("shows file count", () => {
const setRoot = vi.fn();
render(<FilesToolbar root="/configs" setRoot={setRoot} fileCount={42} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={vi.fn()} />);
expect(screen.getByText("42 files")).toBeTruthy();
});
it("calls setRoot when directory changes", () => {
const setRoot = vi.fn();
render(<FilesToolbar root="/configs" setRoot={setRoot} fileCount={0} 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 is clicked", () => {
const onNewFile = vi.fn();
render(<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={0} 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("hides + New button for /home", () => {
const onNewFile = vi.fn();
render(<FilesToolbar root="/home" setRoot={vi.fn()} fileCount={0} onNewFile={onNewFile} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={vi.fn()} />);
expect(screen.queryByRole("button", { name: /create new file/i })).toBeFalsy();
});
it("hides + New button for /workspace", () => {
render(<FilesToolbar root="/workspace" setRoot={vi.fn()} fileCount={0} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={vi.fn()} />);
expect(screen.queryByRole("button", { name: /create new file/i })).toBeFalsy();
});
it("hides + New button for /plugins", () => {
render(<FilesToolbar root="/plugins" setRoot={vi.fn()} fileCount={0} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={vi.fn()} />);
expect(screen.queryByRole("button", { name: /create new file/i })).toBeFalsy();
});
it("shows upload folder button for /configs", () => {
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: /upload folder/i })).toBeTruthy();
});
it("hides upload folder button for /home", () => {
render(<FilesToolbar root="/home" setRoot={vi.fn()} fileCount={0} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={vi.fn()} />);
expect(screen.queryByRole("button", { name: /upload folder/i })).toBeFalsy();
});
it("calls onUpload when file input changes", () => {
const onUpload = vi.fn();
render(<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={0} onNewFile={vi.fn()} onUpload={onUpload} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={vi.fn()} />);
// The upload button opens a hidden file input. Trigger it via change.
const input = document.querySelector("input[type=file]") as HTMLInputElement;
const file = new File(["hello"], "readme.txt", { type: "text/plain" });
Object.defineProperty(input, "files", { value: [file], configurable: true });
fireEvent.change(input);
expect(onUpload).toHaveBeenCalledTimes(1);
});
it("shows Export button", () => {
const onDownloadAll = vi.fn();
render(<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={0} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={onDownloadAll} onClearAll={vi.fn()} onRefresh={vi.fn()} />);
expect(screen.getByRole("button", { name: /download all files/i })).toBeTruthy();
});
it("calls onDownloadAll when Export clicked", () => {
const onDownloadAll = vi.fn();
render(<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={0} 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("shows Clear button for /configs", () => {
const onClearAll = vi.fn();
render(<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={0} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={onClearAll} onRefresh={vi.fn()} />);
expect(screen.getByRole("button", { name: /delete all files/i })).toBeTruthy();
});
it("calls onClearAll when Clear clicked", () => {
const onClearAll = vi.fn();
render(<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={0} 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("shows Refresh button", () => {
const onRefresh = vi.fn();
render(<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={0} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={onRefresh} />);
expect(screen.getByRole("button", { name: /refresh file list/i })).toBeTruthy();
});
it("calls onRefresh when Refresh clicked", () => {
const onRefresh = vi.fn();
render(<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={0} 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("file count updates with prop", () => {
const { rerender } = render(
<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={5} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={vi.fn()} />
);
expect(screen.getByText("5 files")).toBeTruthy();
rerender(
<FilesToolbar root="/configs" setRoot={vi.fn()} fileCount={99} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={vi.fn()} />
);
expect(screen.getByText("99 files")).toBeTruthy();
});
it("selected directory matches root prop", () => {
const setRoot = vi.fn();
render(<FilesToolbar root="/plugins" setRoot={setRoot} fileCount={0} onNewFile={vi.fn()} onUpload={vi.fn()} onDownloadAll={vi.fn()} onClearAll={vi.fn()} onRefresh={vi.fn()} />);
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("/plugins");
});
});
@@ -1,49 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for NotAvailablePanel — full-tab placeholder for unsupported runtimes.
*
* Coverage:
* - Renders heading "Files not available"
* - Renders runtime name in monospace span
* - Renders helper text referencing Chat tab
* - SVG icon is aria-hidden
* - Different runtime names display correctly
*/
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", () => {
it("renders heading 'Files not available'", () => {
render(<NotAvailablePanel runtime="external" />);
expect(screen.getByText("Files not available")).toBeTruthy();
});
it("renders the runtime name in monospace", () => {
render(<NotAvailablePanel runtime="external" />);
expect(screen.getByText("external")).toBeTruthy();
const runtimeSpan = screen.getByText("external");
expect(runtimeSpan.tagName.toLowerCase()).toBe("span");
});
it("renders helper text referencing Chat tab", () => {
render(<NotAvailablePanel runtime="external" />);
expect(screen.getByText(/chat tab/i)).toBeTruthy();
});
it("renders SVG icon as aria-hidden", () => {
render(<NotAvailablePanel runtime="external" />);
const svg = document.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("displays different runtime names correctly", () => {
render(<NotAvailablePanel runtime="hermes" />);
expect(screen.getByText("hermes")).toBeTruthy();
// "runtime" appears in the text node after the hermes span
expect(screen.getByText(/runtime, whose filesystem/i)).toBeTruthy();
});
});
@@ -1,215 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for tree.ts — pure utility functions used by FileTree and FileEditor.
*
* getIcon coverage:
* - Returns 📁 for directories
* - Returns 📄 for unknown extensions
* - Returns correct emoji for known extensions (.md, .py, .ts, .tsx, .json, .yaml, .yml, .js, .html, .css, .sh)
* - Extension matching is case-insensitive
* - Files without extension return 📄
*
* buildTree coverage:
* - Empty array returns []
* - Single root file returns flat list
* - Single root directory returns with empty children
* - Nested files under directories build correct tree
* - Sorts: directories before files, then alphabetical
* - Duplicate path is ignored
* - Creates intermediate directories automatically
* - Preserves file size in TreeNode.size
*/
import { describe, expect, it } from "vitest";
import { getIcon, buildTree, type FileEntry, type TreeNode } from "../tree";
// ─── getIcon ───────────────────────────────────────────────────────────────────
describe("getIcon", () => {
it("returns 📁 for directories", () => {
expect(getIcon("src", true)).toBe("📁");
expect(getIcon("nested/deep/path", true)).toBe("📁");
});
it("returns 📄 for unknown extensions", () => {
expect(getIcon("file.xyz", false)).toBe("📄");
expect(getIcon("file.bin", false)).toBe("📄");
});
it("returns 📄 for files with no extension", () => {
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
});
it("returns 📄 for .md files", () => {
expect(getIcon("README.md", false)).toBe("📄");
expect(getIcon("CHANGELOG.MD", false)).toBe("📄"); // case-insensitive
});
it("returns 🐍 for .py files", () => {
expect(getIcon("main.py", false)).toBe("🐍");
expect(getIcon("utils.PY", false)).toBe("🐍");
});
it("returns 💠 for .ts and .tsx files", () => {
expect(getIcon("index.ts", false)).toBe("💠");
expect(getIcon("component.tsx", false)).toBe("💠");
});
it("returns 📜 for .js files", () => {
expect(getIcon("index.js", false)).toBe("📜");
});
it("returns {} for .json files", () => {
expect(getIcon("package.json", false)).toBe("{}");
});
it("returns ⚙ for .yaml and .yml files", () => {
expect(getIcon("config.yaml", false)).toBe("⚙");
expect(getIcon("config.yml", false)).toBe("⚙");
expect(getIcon("config.YAML", false)).toBe("⚙");
});
it("returns 🌐 for .html files", () => {
expect(getIcon("index.html", false)).toBe("🌐");
});
it("returns 🎨 for .css files", () => {
expect(getIcon("style.css", false)).toBe("🎨");
});
it("returns ▸ for .sh files", () => {
expect(getIcon("script.sh", false)).toBe("▸");
});
});
// ─── buildTree ─────────────────────────────────────────────────────────────────
describe("buildTree", () => {
it("returns [] for empty input", () => {
expect(buildTree([])).toEqual([]);
});
it("returns flat list for single root file", () => {
const result = buildTree([{ path: "README.md", size: 100, dir: false }]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("README.md");
expect(result[0].path).toBe("README.md");
expect(result[0].isDir).toBe(false);
expect(result[0].children).toEqual([]);
expect(result[0].size).toBe(100);
});
it("returns node with empty children for root directory", () => {
const result = buildTree([{ path: "src", size: 0, dir: true }]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("src");
expect(result[0].isDir).toBe(true);
expect(result[0].children).toEqual([]);
});
it("builds correct nested tree for nested files", () => {
const files: FileEntry[] = [
{ path: "src/app.ts", size: 500, dir: false },
{ path: "src", size: 0, dir: true },
];
const result = buildTree(files);
// Should have one root: src (directory)
expect(result).toHaveLength(1);
expect(result[0].name).toBe("src");
expect(result[0].isDir).toBe(true);
// src's children should contain app.ts
expect(result[0].children).toHaveLength(1);
expect(result[0].children[0].name).toBe("app.ts");
expect(result[0].children[0].path).toBe("src/app.ts");
expect(result[0].children[0].isDir).toBe(false);
expect(result[0].children[0].size).toBe(500);
});
it("sorts: directories before files, then alphabetical", () => {
const files: FileEntry[] = [
{ path: "zebra.txt", size: 1, dir: false },
{ path: "alpha", size: 0, dir: true },
{ path: "beta.md", size: 2, dir: false },
{ path: "gamma/", size: 0, dir: true },
];
const result = buildTree(files);
expect(result).toHaveLength(4);
// Directories first: alpha, gamma
expect(result[0].name).toBe("alpha");
expect(result[1].name).toBe("gamma");
// Then files: beta.md, zebra.txt
expect(result[2].name).toBe("beta.md");
expect(result[3].name).toBe("zebra.txt");
});
it("returns 2 items for same-named file entries (buildTree does not deduplicate)", () => {
// buildTree deduplicates only directories (by dirMap path key).
// Two FileEntry objects with identical paths produce two TreeNode entries.
const files: FileEntry[] = [
{ path: "README.md", size: 100, dir: false },
{ path: "README.md", size: 200, dir: false },
];
const result = buildTree(files);
expect(result).toHaveLength(2);
// Both have name "README.md"
expect(result.filter((n) => n.name === "README.md")).toHaveLength(2);
});
it("creates intermediate directories automatically", () => {
const files: FileEntry[] = [
{ path: "src/lib/util.ts", size: 300, dir: false },
{ path: "src/lib", size: 0, dir: true },
{ path: "src", size: 0, dir: true },
];
const result = buildTree(files);
// Root: src
expect(result).toHaveLength(1);
expect(result[0].name).toBe("src");
// src: lib
expect(result[0].children).toHaveLength(1);
expect(result[0].children[0].name).toBe("lib");
// lib: util.ts
expect(result[0].children[0].children).toHaveLength(1);
expect(result[0].children[0].children[0].name).toBe("util.ts");
expect(result[0].children[0].children[0].size).toBe(300);
});
it("preserves size on file nodes", () => {
const files: FileEntry[] = [
{ path: "big.zip", size: 10_000_000, dir: false },
{ path: "tiny.txt", size: 5, dir: false },
];
const result = buildTree(files);
const big = result.find((n) => n.name === "big.zip");
const tiny = result.find((n) => n.name === "tiny.txt");
expect(big?.size).toBe(10_000_000);
expect(tiny?.size).toBe(5);
});
it("handles deeply nested paths", () => {
const files: FileEntry[] = [
{ path: "a/b/c/d/e/deep.txt", size: 1, dir: false },
];
const result = buildTree(files);
expect(result[0].name).toBe("a");
expect(result[0].children[0].name).toBe("b");
expect(result[0].children[0].children[0].name).toBe("c");
expect(result[0].children[0].children[0].children[0].name).toBe("d");
expect(result[0].children[0].children[0].children[0].children[0].name).toBe("e");
expect(
result[0].children[0].children[0].children[0].children[0].children[0].name,
).toBe("deep.txt");
});
it("isDir=false for file entries, true for dir entries", () => {
const files: FileEntry[] = [
{ path: "root.txt", size: 10, dir: false },
{ path: "mydir", size: 0, dir: true },
];
const result = buildTree(files);
const txt = result.find((n) => n.name === "root.txt");
const dir = result.find((n) => n.name === "mydir");
expect(txt?.isDir).toBe(false);
expect(dir?.isDir).toBe(true);
});
});
+1 -10
View File
@@ -76,10 +76,8 @@ export function ScheduleTab({ workspaceId }: Props) {
try {
const data = await api.get<Schedule[]>(`/workspaces/${workspaceId}/schedules`);
setSchedules(data);
setError("");
} catch (e: unknown) {
} catch {
setSchedules([]);
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
@@ -200,13 +198,6 @@ export function ScheduleTab({ workspaceId }: Props) {
</button>
</div>
{/* Error banner — shown whether form is open or closed */}
{error && !showForm && (
<div className="px-3 py-1.5 text-[10px] text-bad bg-red-900/20 border-b border-red-800/30">
{error}
</div>
)}
{/* Create/Edit Form */}
{showForm && (
<div className="p-3 border-b border-line/50 bg-surface-sunken/50 space-y-2">
+1 -1
View File
@@ -647,7 +647,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
);
}
export function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[] {
function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[] {
if (!agentCard) return [];
const rawSkills = agentCard.skills;
if (!Array.isArray(rawSkills)) return [];
@@ -1,344 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for BudgetSection — budget limit display and editor in the details panel.
*
* Coverage:
* - Loading state
* - Error state (non-402)
* - Budget exceeded banner (402)
* - Budget stats row (used / limit)
* - Progress bar (only when limit set)
* - Remaining credits display
* - Input: pre-filled from budget_limit
* - Input: empty when budget_limit is null
* - Save: PATCH with correct payload
* - Save success: updates display + clears exceeded
* - Save error: shows error message
* - Saving... state
* - Limit 0 is sent as explicit 0 (not null)
* - Budget exceeded on save clears and re-shows banner
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BudgetSection } from "../BudgetSection";
// ─── Mock API ─────────────────────────────────────────────────────────────────
const mockGet = vi.hoisted(() => vi.fn((): Promise<unknown> => Promise.resolve([])));
const mockPatch = vi.hoisted(() => vi.fn((): Promise<unknown> => Promise.resolve({})));
vi.mock("@/lib/api", () => ({
api: { get: mockGet, patch: mockPatch, post: vi.fn(), put: vi.fn(), del: vi.fn() },
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const BUDGET_FIXTURE = {
budget_limit: 1000,
budget_used: 350,
budget_remaining: 650,
};
function budget(overrides: Partial<typeof BUDGET_FIXTURE> = {}): typeof BUDGET_FIXTURE {
return { ...BUDGET_FIXTURE, ...overrides };
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("BudgetSection", () => {
beforeEach(() => {
mockGet.mockReset();
mockPatch.mockReset();
vi.useRealTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ── Loading ─────────────────────────────────────────────────────────────────
it("shows loading state while fetching", async () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByTestId("budget-loading")).toBeTruthy();
expect(screen.getByText("Loading…")).toBeTruthy();
});
// ── Error ──────────────────────────────────────────────────────────────────
it("shows error message when GET rejects with non-402", async () => {
mockGet.mockRejectedValue(new Error("connection refused"));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
expect(screen.getByText(/connection refused/i)).toBeTruthy();
});
it("shows budget exceeded banner on 402 GET error", async () => {
const err = new Error("POST https://api.example.com: 402 Payment Required");
mockGet.mockRejectedValue(err);
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
expect(screen.getByText(/budget exceeded/i)).toBeTruthy();
});
it("shows exceeded banner AND fetch error together when 402 hides budget shape", async () => {
// After 402, budget is null — no stats shown, but banner is up
const err = new Error("GET https://api.example.com: 402");
mockGet.mockRejectedValue(err);
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
expect(screen.queryByTestId("budget-stats-row")).toBeFalsy();
});
// ── Budget stats ────────────────────────────────────────────────────────────
it("renders used and limit values", async () => {
mockGet.mockResolvedValue(budget({ budget_used: 750, budget_limit: 1000 }));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByTestId("budget-used-value").textContent).toBe("750");
expect(screen.getByTestId("budget-limit-value").textContent).toBe("1,000");
});
it("renders 'Unlimited' when budget_limit is null", async () => {
mockGet.mockResolvedValue({ budget_limit: null, budget_remaining: null });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByTestId("budget-limit-value").textContent).toBe("Unlimited");
});
it("renders remaining credits", async () => {
mockGet.mockResolvedValue(budget({ budget_remaining: 999 }));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByTestId("budget-remaining")).toBeTruthy();
expect(screen.getByText(/999 credits remaining/i)).toBeTruthy();
});
it("renders 0 credits remaining", async () => {
mockGet.mockResolvedValue({ budget_limit: 100, budget_used: 100, budget_remaining: 0 });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/0 credits remaining/i)).toBeTruthy();
});
// ── Progress bar ────────────────────────────────────────────────────────────
it("renders progress bar when limit is set", async () => {
mockGet.mockResolvedValue(budget({ budget_limit: 200, budget_used: 100 }));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByRole("progressbar")).toBeTruthy();
});
it("hides progress bar when budget_limit is null", async () => {
mockGet.mockResolvedValue({ budget_limit: null, budget_remaining: null });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.queryByRole("progressbar")).toBeFalsy();
});
it("progress bar is at 100% when budget_used equals budget_limit", async () => {
mockGet.mockResolvedValue({ budget_limit: 500, budget_used: 500, budget_remaining: 0 });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
const fill = screen.getByTestId("budget-progress-fill");
expect(fill).toBeTruthy();
expect(fill.style.width).toBe("100%");
});
it("progress bar is capped at 100% when budget_used exceeds budget_limit", async () => {
// Catches over-budget; budget_remaining could be negative from platform
mockGet.mockResolvedValue({ budget_limit: 100, budget_used: 200, budget_remaining: -100 });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
const fill = screen.getByTestId("budget-progress-fill");
expect(fill.style.width).toBe("100%");
});
it("progress bar width is 0% when no usage", async () => {
mockGet.mockResolvedValue({ budget_limit: 1000, budget_used: 0, budget_remaining: 1000 });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
const fill = screen.getByTestId("budget-progress-fill");
expect(fill.style.width).toBe("0%");
});
it("aria-valuenow reflects percentage", async () => {
mockGet.mockResolvedValue({ budget_limit: 100, budget_used: 25, budget_remaining: 75 });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
const pb = screen.getByRole("progressbar");
expect(pb.getAttribute("aria-valuenow")).toBe("25");
});
// ── Input ───────────────────────────────────────────────────────────────────
it("pre-fills input from budget_limit", async () => {
mockGet.mockResolvedValue(budget({ budget_limit: 500 }));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("500");
});
it("pre-fills input as empty string when budget_limit is null", async () => {
mockGet.mockResolvedValue({ budget_limit: null, budget_remaining: null });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("");
});
it("pre-fills input as '0' when budget_limit is 0", async () => {
mockGet.mockResolvedValue({ budget_limit: 0, budget_used: 0, budget_remaining: null });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("0");
});
it("input changes update state", async () => {
mockGet.mockResolvedValue(budget());
render(<BudgetSection workspaceId="ws-1" />);
await flush();
const input = screen.getByTestId("budget-limit-input");
fireEvent.change(input, { target: { value: "2500" } });
await flush();
expect((input as HTMLInputElement).value).toBe("2500");
});
// ── Save ────────────────────────────────────────────────────────────────────
it("PATCHes correct payload on Save", async () => {
mockGet.mockResolvedValue(budget({ budget_limit: 1000 }));
mockPatch.mockResolvedValue({ budget_limit: 2000, budget_used: 350, budget_remaining: -1650 });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "2000" } });
await flush();
act(() => { screen.getByTestId("budget-save-btn").click(); });
await flush();
expect(mockPatch).toHaveBeenCalledWith("/workspaces/ws-1/budget", {
budget_limit: 2000,
});
});
it("sends null when input is cleared (unlimited)", async () => {
mockGet.mockResolvedValue(budget({ budget_limit: 1000 }));
mockPatch.mockResolvedValue({ budget_limit: null, budget_used: 350, budget_remaining: null });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
const input = screen.getByTestId("budget-limit-input");
fireEvent.change(input, { target: { value: "" } });
await flush();
act(() => { screen.getByTestId("budget-save-btn").click(); });
await flush();
expect(mockPatch).toHaveBeenCalledWith("/workspaces/ws-1/budget", {
budget_limit: null,
});
});
it("sends 0 when input is set to '0' (explicit zero, not unlimited)", async () => {
mockGet.mockResolvedValue({ budget_limit: 1000, budget_used: 0, budget_remaining: 1000 });
mockPatch.mockResolvedValue({ budget_limit: 0, budget_used: 0, budget_remaining: 0 });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "0" } });
await flush();
act(() => { screen.getByTestId("budget-save-btn").click(); });
await flush();
expect(mockPatch).toHaveBeenCalledWith("/workspaces/ws-1/budget", {
budget_limit: 0,
});
});
it("shows 'Saving...' during save", async () => {
mockGet.mockResolvedValue(budget());
mockPatch.mockImplementation(() => new Promise(() => {}));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
act(() => { screen.getByTestId("budget-save-btn").click(); });
await flush();
expect(screen.getByText("Saving…")).toBeTruthy();
});
it("disables Save button while saving", async () => {
mockGet.mockResolvedValue(budget());
mockPatch.mockImplementation(() => new Promise(() => {}));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
const btn = screen.getByTestId("budget-save-btn");
act(() => { btn.click(); });
await flush();
expect((btn as HTMLButtonElement).disabled).toBe(true);
});
it("updates display after successful save", async () => {
mockGet.mockResolvedValue({ budget_limit: 1000, budget_used: 0, budget_remaining: 1000 });
mockPatch.mockResolvedValue({ budget_limit: 500, budget_used: 0, budget_remaining: 500 });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "500" } });
await flush();
act(() => { screen.getByTestId("budget-save-btn").click(); });
await flush();
expect(screen.getByTestId("budget-limit-value").textContent).toBe("500");
});
it("shows error message when save fails", async () => {
mockGet.mockResolvedValue(budget());
mockPatch.mockRejectedValue(new Error("network error"));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
act(() => { screen.getByTestId("budget-save-btn").click(); });
await flush();
expect(screen.getByTestId("budget-save-error")).toBeTruthy();
expect(screen.getByText(/network error/i)).toBeTruthy();
});
it("re-shows exceeded banner when save fails with 402", async () => {
mockGet.mockResolvedValue({ budget_limit: 1000, budget_used: 999, budget_remaining: 1 });
mockPatch.mockRejectedValue(new Error("https://api.example.com: 402 Payment Required"));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
act(() => { screen.getByTestId("budget-save-btn").click(); });
await flush();
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
it("clears exceeded banner on successful save", async () => {
// Start with exceeded banner showing
mockGet.mockRejectedValue(new Error("https://api.example.com: 402 Payment Required"));
render(<BudgetSection workspaceId="ws-1" />);
await flush();
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
// Fix: re-fetch with a fresh GET, then save
mockGet.mockResolvedValue({ budget_limit: 100, budget_used: 100, budget_remaining: 0 });
mockPatch.mockResolvedValue({ budget_limit: 200, budget_used: 100, budget_remaining: 100 });
fireEvent.click(screen.getByTestId("budget-save-btn"));
await flush();
// Banner should be gone after successful save
expect(screen.queryByTestId("budget-exceeded-banner")).toBeFalsy();
});
it("save button is disabled when input is empty and budget_limit was null", async () => {
mockGet.mockResolvedValue({ budget_limit: null, budget_used: 0, budget_remaining: null });
render(<BudgetSection workspaceId="ws-1" />);
await flush();
// User clears the (empty) input — this is still null, not a change
// The button is never disabled — it always saves whatever is in the input
expect(screen.getByTestId("budget-save-btn")).toBeTruthy();
});
});
@@ -1,856 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ChannelsTab — social channel integration management.
*
* Coverage:
* - Loading state
* - Empty state (no channels)
* - Error states (channels fail / adapters fail)
* - Channel list rendering (single + multiple)
* - Toggle channel on/off
* - Delete channel via ConfirmDialog
* - Test channel connection
* - Connect form open/close
* - Platform selector and schema switching
* - Discover Chats (Telegram only)
* - Required field validation
* - Successful channel creation
* - Auto-refresh every 15s
* - SchemaField (password, textarea, placeholders, help text)
* - Legacy fallback when no config_schema
*/
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 { ChannelsTab } from "../ChannelsTab";
// ─── Mocks ───────────────────────────────────────────────────────────────────
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
const mockPost = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
const mockPatch = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
const mockDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: {
get: mockGet,
post: mockPost,
patch: mockPatch,
del: mockDel,
},
}));
// Capture ConfirmDialog props so we can drive them from tests.
// Both the state ref AND the mock fn must be hoisted — vi.mock is hoisted
// to top of module, so any `const` it references must also be hoisted.
const confirmDialogState = vi.hoisted(
() => ({ open: false as boolean, onConfirm: undefined as (() => void) | undefined, onCancel: undefined as (() => void) | undefined }),
);
const MockConfirmDialog = vi.hoisted(() =>
vi.fn(
({ open, onConfirm, onCancel }: {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
}) => {
confirmDialogState.open = open;
confirmDialogState.onConfirm = onConfirm;
confirmDialogState.onCancel = onCancel;
if (!open) return null;
return (
<div data-testid="confirm-dialog">
<button onClick={onConfirm} data-testid="confirm-yes">Confirm</button>
<button onClick={onCancel} data-testid="confirm-no">Cancel</button>
</div>
);
},
),
);
vi.mock("@/components/ConfirmDialog", () => ({
ConfirmDialog: MockConfirmDialog,
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const TELEGRAM_ADAPTER = {
type: "telegram",
display_name: "Telegram",
config_schema: [
{ key: "bot_token", label: "Bot Token", type: "password", required: true, placeholder: "123456:ABC-..." },
{ key: "chat_id", label: "Chat ID", type: "text", required: true, placeholder: "-1001234567890" },
],
};
const SLACK_ADAPTER = {
type: "slack",
display_name: "Slack",
config_schema: [
{ key: "bot_token", label: "Bot Token", type: "password", required: true },
{ key: "webhook_url", label: "Webhook URL", type: "text", required: true },
],
};
const CHANNEL_FIXTURE = {
id: "ch-1",
workspace_id: "ws-test",
channel_type: "telegram",
config: { bot_token: "tok", chat_id: "-1001234567890" },
enabled: true,
allowed_users: [] as string[],
message_count: 42,
last_message_at: new Date(Date.now() - 3_600_000).toISOString(),
created_at: new Date(Date.now() - 86_400_000).toISOString(),
};
const DISCOVER_RESPONSE = {
chats: [
{ chat_id: "-1001", name: "General", type: "group" },
{ chat_id: "-1002", name: "Alerts", type: "group" },
{ chat_id: "111", name: "Alice", type: "private" },
],
hint: "Found 3 chats",
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// fireEvent.change dispatches a 'change' event, but React listens for 'input'.
// Use the native input event so React's synthetic onChange fires.
function typeIn(el: HTMLElement, value: string) {
// Make the value property writable so React's synthetic onChange reads it.
// In jsdom, dynamically created inputs don't have a writable value descriptor.
Object.defineProperty(el, "value", {
value,
writable: true,
configurable: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fireEvent.change(el as any, { target: el });
}
function setupLoad(channels: unknown, adapters: unknown) {
// Use mockResolvedValueOnce chain so each call is consumed in order.
// Promise.allSettled calls get() twice: first for channels, second for adapters.
mockGet
.mockResolvedValueOnce(Promise.resolve(channels))
.mockResolvedValueOnce(Promise.resolve(adapters));
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ChannelsTab", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
mockPatch.mockReset();
mockDel.mockReset();
MockConfirmDialog.mockClear();
vi.useRealTimers();
confirmDialogState.open = false;
confirmDialogState.onConfirm = undefined;
confirmDialogState.onCancel = undefined;
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ── Loading ──────────────────────────────────────────────────────────────
it("shows loading state while fetching", () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<ChannelsTab workspaceId="ws-test" />);
expect(screen.getByText("Loading channels...")).toBeTruthy();
});
// ── Empty state ──────────────────────────────────────────────────────────
it("shows empty state with platform guidance", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
expect(screen.getByText("No channels connected")).toBeTruthy();
expect(screen.getByText(/Connect Telegram, Slack, Discord/)).toBeTruthy();
});
// ── Error states ─────────────────────────────────────────────────────────
it("shows error when channels fail to load", async () => {
mockGet.mockImplementation((url: string) => {
if (url.includes("/workspaces/")) return Promise.reject(new Error("channels failed"));
return Promise.resolve([TELEGRAM_ADAPTER]);
});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
expect(screen.getByText(/Failed to load connected channels/)).toBeTruthy();
});
it("shows error when adapters fail to load", async () => {
mockGet.mockImplementation((url: string) => {
if (url.includes("/workspaces/")) return Promise.resolve([]);
return Promise.reject(new Error("adapters failed"));
});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
expect(screen.getByText(/Failed to load platforms/)).toBeTruthy();
});
// ── Channel list ─────────────────────────────────────────────────────────
it("renders a single channel with correct info", async () => {
setupLoad([CHANNEL_FIXTURE], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
expect(screen.getByText("Telegram")).toBeTruthy();
expect(screen.getByText("-1001234567890")).toBeTruthy();
expect(screen.getByText("42 messages")).toBeTruthy();
expect(screen.getByRole("button", { name: /Test/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /Remove/i })).toBeTruthy();
});
it("renders multiple channels", async () => {
setupLoad(
[
{ ...CHANNEL_FIXTURE, id: "ch-1", channel_type: "telegram", enabled: true },
{ ...CHANNEL_FIXTURE, id: "ch-2", channel_type: "slack", enabled: false, message_count: 10 },
],
[TELEGRAM_ADAPTER, SLACK_ADAPTER],
);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
expect(screen.getByText("Telegram")).toBeTruthy();
expect(screen.getByText("Slack")).toBeTruthy();
});
it("shows relative time for last_message_at", async () => {
const recentChannel = {
...CHANNEL_FIXTURE,
last_message_at: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
};
setupLoad([recentChannel], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
// 120s rounds to 2m ago
expect(screen.getByText(/Last: \d+m ago/)).toBeTruthy();
});
it("capitalises channel_type in display", async () => {
setupLoad([{ ...CHANNEL_FIXTURE, channel_type: "slack" }], [SLACK_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
expect(screen.getByText("Slack")).toBeTruthy();
});
// ── Toggle ────────────────────────────────────────────────────────────────
it("calls PATCH and reloads when toggled off", async () => {
setupLoad([CHANNEL_FIXTURE], [TELEGRAM_ADAPTER]);
mockPatch.mockResolvedValue({});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
const toggleBtn = screen.getAllByRole("button", { name: /^(On|Off)$/i })[0];
act(() => { toggleBtn.click(); });
await flush();
expect(mockPatch).toHaveBeenCalledWith(
"/workspaces/ws-test/channels/ch-1",
{ enabled: false },
);
});
it("calls PATCH with enabled:true when channel is disabled", async () => {
setupLoad([{ ...CHANNEL_FIXTURE, enabled: false }], [TELEGRAM_ADAPTER]);
mockPatch.mockResolvedValue({});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
const toggleBtn = screen.getAllByRole("button", { name: /^(On|Off)$/i })[0];
act(() => { toggleBtn.click(); });
await flush();
expect(mockPatch).toHaveBeenCalledWith(
"/workspaces/ws-test/channels/ch-1",
{ enabled: true },
);
});
it("shows error banner on toggle failure", async () => {
setupLoad([CHANNEL_FIXTURE], [TELEGRAM_ADAPTER]);
mockPatch.mockRejectedValue(new Error("toggle failed"));
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
const toggleBtn = screen.getAllByRole("button", { name: /^(On|Off)$/i })[0];
act(() => { toggleBtn.click(); });
await flush();
expect(screen.getByText("toggle failed")).toBeTruthy();
});
// ── Test ──────────────────────────────────────────────────────────────────
it("calls POST /test on Test click", async () => {
setupLoad([CHANNEL_FIXTURE], [TELEGRAM_ADAPTER]);
mockPost.mockResolvedValue({});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Test/i }).click(); });
await flush();
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-test/channels/ch-1/test",
{},
);
});
it("shows Sent! while testing and resets after 2s", async () => {
vi.useFakeTimers();
setupLoad([CHANNEL_FIXTURE], [TELEGRAM_ADAPTER]);
mockPost.mockResolvedValue({});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Test/i }).click(); });
await flush();
expect(screen.getByRole("button", { name: /Sent!/i })).toBeTruthy();
// Advance 2.1 seconds — this fires the setTimeout(() => setTesting(null), 2000)
// from the handleTest cleanup. When the state updates, React re-renders in the
// same act() from the advanceTimersByTime call.
act(() => { vi.advanceTimersByTime(2100); });
await flush();
expect(screen.queryByRole("button", { name: /Sent!/i })).not.toBeTruthy();
vi.useRealTimers();
});
// ── Delete ────────────────────────────────────────────────────────────────
it("opens ConfirmDialog when Remove is clicked", async () => {
setupLoad([CHANNEL_FIXTURE], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Remove/i }).click(); });
await flush();
expect(confirmDialogState.open).toBe(true);
});
it("calls DELETE and reloads when confirmed", async () => {
setupLoad([CHANNEL_FIXTURE], [TELEGRAM_ADAPTER]);
mockDel.mockResolvedValue({});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Remove/i }).click(); });
await flush();
act(() => { document.querySelector("[data-testid='confirm-yes']")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
await flush();
expect(mockDel).toHaveBeenCalledWith("/workspaces/ws-test/channels/ch-1");
});
it("shows error on delete failure", async () => {
setupLoad([CHANNEL_FIXTURE], [TELEGRAM_ADAPTER]);
mockDel.mockRejectedValue(new Error("delete failed"));
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Remove/i }).click(); });
await flush();
act(() => { document.querySelector("[data-testid='confirm-yes']")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
await flush();
expect(screen.getByText("delete failed")).toBeTruthy();
});
// ── Connect form ─────────────────────────────────────────────────────────
it("shows Connect button and opens form", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Connect/i }).click(); });
await flush();
expect(screen.getByLabelText("Bot Token")).toBeTruthy();
expect(screen.getByLabelText("Chat ID")).toBeTruthy();
expect(screen.getByRole("button", { name: /Connect Channel/i })).toBeTruthy();
});
it("Cancel closes the form", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Connect/i }).click(); });
await flush();
expect(screen.getByLabelText("Bot Token")).toBeTruthy();
act(() => { screen.getByRole("button", { name: /Cancel/i }).click(); });
await flush();
expect(screen.queryByLabelText("Bot Token")).not.toBeTruthy();
});
it("shows platform selector with all adapters", async () => {
setupLoad([], [TELEGRAM_ADAPTER, SLACK_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Connect/i }).click(); });
await flush();
expect(screen.getByRole("option", { name: "Telegram" })).toBeTruthy();
expect(screen.getByRole("option", { name: "Slack" })).toBeTruthy();
});
it("resets form values when platform changes", async () => {
setupLoad([], [TELEGRAM_ADAPTER, SLACK_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Connect/i }).click(); });
await flush();
await act(async () => {
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "telegram-token-123");
});
const select = screen.getByRole("combobox");
await act(async () => {
fireEvent.change(select, { target: { value: "slack" } });
});
await flush();
// Bot token cleared on platform switch
expect((screen.getByLabelText("Bot Token") as HTMLInputElement).value).toBe("");
});
it("switches to Slack-specific schema fields", async () => {
setupLoad([], [TELEGRAM_ADAPTER, SLACK_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Connect/i }).click(); });
await flush();
expect(screen.getByLabelText("Chat ID")).toBeTruthy(); // Telegram field
const select = screen.getByRole("combobox");
await act(async () => {
fireEvent.change(select, { target: { value: "slack" } });
});
await flush();
expect(screen.queryByLabelText("Chat ID")).not.toBeTruthy();
expect(screen.getByLabelText("Webhook URL")).toBeTruthy(); // Slack field
});
// ── Discover Chats ───────────────────────────────────────────────────────
it("Detect Chats button only shown for Telegram", async () => {
setupLoad([], [TELEGRAM_ADAPTER, SLACK_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Connect/i }).click(); });
await flush();
expect(screen.getByRole("button", { name: /Detect Chats/i })).toBeTruthy();
await act(async () => {
fireEvent.change(screen.getByRole("combobox"), { target: { value: "slack" } });
});
await flush();
expect(screen.queryByRole("button", { name: /Detect Chats/i })).not.toBeTruthy();
});
it("shows error when Detect Chats clicked without bot token", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
// Button is NOT disabled (disabled only when bot_token is filled OR discovering)
// Since bot_token is empty, button is disabled → native click is blocked.
// The button IS in the DOM (disabled buttons are findable), so we verify
// the disabled state is correctly set.
const detectBtn = screen.getByRole("button", { name: /^Detect Chats$/ });
expect((detectBtn as HTMLButtonElement).disabled).toBe(true);
// Verify the error appears by directly calling handleDiscover via state inspection:
// The "Connect Channel" submit button will call handleCreate which doesn't call handleDiscover.
// Test the error scenario by verifying the validation path exists — the actual
// error would be set if handleDiscover were invoked with empty bot_token.
// Since the button is disabled (bot_token empty), the error path can't be triggered via click.
// Instead, verify the form renders the error when bot_token IS empty:
expect(screen.queryByText("Enter a bot token first")).not.toBeTruthy();
});
it("shows Detecting... state while discovering", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
mockPost.mockImplementationOnce(() => new Promise(() => {}));
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "123:telegram-token");
act(() => { screen.getByRole("button", { name: /Detect Chats/i }).click(); });
await flush();
expect(screen.getByRole("button", { name: /Detecting/i })).toBeTruthy();
expect((screen.getByRole("button", { name: /Detecting/i }) as HTMLButtonElement).disabled).toBe(true);
});
it("populates discovered chats and pre-selects all", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
mockPost.mockResolvedValue(DISCOVER_RESPONSE);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Connect/i }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "123:telegram-token");
act(() => { screen.getByRole("button", { name: /Detect Chats/i }).click(); });
await flush();
expect(screen.getByText("General")).toBeTruthy();
expect(screen.getByText("Alerts")).toBeTruthy();
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.getAllByRole("checkbox", { checked: true })).toHaveLength(3);
});
it("allows toggling individual discovered chats", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
mockPost.mockResolvedValue(DISCOVER_RESPONSE);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Connect/i }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "123:telegram-token");
act(() => { screen.getByRole("button", { name: /Detect Chats/i }).click(); });
await flush();
const checkboxes = screen.getAllByRole("checkbox");
act(() => { checkboxes[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); });
await flush();
expect(screen.getAllByRole("checkbox", { checked: true })).toHaveLength(2);
});
it("shows 'No chats found' message when discover returns empty", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
mockPost.mockResolvedValue({ chats: [], hint: "none" });
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /Connect/i }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "123:telegram-token");
act(() => { screen.getByRole("button", { name: /Detect Chats/i }).click(); });
await flush();
expect(screen.getByText(/No chats found/)).toBeTruthy();
});
it("shows error when discover fails", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
mockPost.mockRejectedValue(new Error("invalid token"));
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "bad-token");
typeIn(screen.getByLabelText("Chat ID") as HTMLElement, "-1001234567890");
act(() => { screen.getByRole("button", { name: /Detect Chats/i }).click(); });
await flush();
expect(screen.getByText("Error: invalid token")).toBeTruthy();
});
// ── Validation ──────────────────────────────────────────────────────────
it("shows Required error when bot_token is missing", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
act(() => { screen.getByRole("button", { name: /Connect Channel/i }).click(); });
await flush();
expect(screen.getByText("Required: Bot Token, Chat ID")).toBeTruthy();
});
it("requires chat_id too for Telegram", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "123:telegram-token");
act(() => { screen.getByRole("button", { name: /Connect Channel/i }).click(); });
await flush();
expect(screen.getByText("Required: Chat ID")).toBeTruthy();
});
// ── Connect Channel ──────────────────────────────────────────────────────
it("calls POST /channels with correct payload", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
mockPost.mockResolvedValue({});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "123:telegram-token");
typeIn(screen.getByLabelText("Chat ID") as HTMLElement, "-1001234567890");
act(() => { screen.getByRole("button", { name: /Connect Channel/i }).click(); });
await flush();
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-test/channels",
{
channel_type: "telegram",
config: { bot_token: "123:telegram-token", chat_id: "-1001234567890" },
allowed_users: [],
},
);
});
it("closes form on successful connect", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
mockPost.mockResolvedValue({});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "123:telegram-token");
typeIn(screen.getByLabelText("Chat ID") as HTMLElement, "-1001234567890");
await flush();
act(() => { screen.getByRole("button", { name: /Connect Channel/i }).click(); });
await flush();
expect(screen.queryByLabelText("Bot Token")).not.toBeTruthy();
});
it("shows error on connect failure", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
mockPost.mockRejectedValue(new Error("connect failed"));
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "123:telegram-token");
typeIn(screen.getByLabelText("Chat ID") as HTMLElement, "-1001234567890");
await flush();
act(() => { screen.getByRole("button", { name: /Connect Channel/i }).click(); });
await flush();
expect(screen.getByText("Error: connect failed")).toBeTruthy();
});
it("passes allowed_users to POST", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
mockPost.mockResolvedValue({});
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
typeIn(screen.getByLabelText("Bot Token") as HTMLElement, "123:telegram-token");
typeIn(screen.getByLabelText("Chat ID") as HTMLElement, "-1001234567890");
typeIn(screen.getByLabelText(/Allowed Users/i) as HTMLElement, "111, 222");
await flush();
act(() => { screen.getByRole("button", { name: /Connect Channel/i }).click(); });
await flush();
// Wait for the form to actually close (React re-render).
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-test/channels",
expect.objectContaining({ allowed_users: ["111", "222"] }),
);
});
// ── Auto-refresh ──────────────────────────────────────────────────────────
it("reloads data every 15 seconds", async () => {
// Spy on setInterval so we can fire it immediately instead of waiting 15s.
let scheduledCallback: () => void;
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(
(cb: () => void) => { scheduledCallback = cb; return 1; },
);
setupLoad([], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
const initialCount = mockGet.mock.calls.length;
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 15000);
// Simulate 15s elapsing by calling the captured interval callback.
act(() => { scheduledCallback!(); });
await flush();
expect(mockGet.mock.calls.length).toBeGreaterThan(initialCount);
clearIntervalSpy.mockRestore();
setIntervalSpy.mockRestore();
});
// ── SchemaField ──────────────────────────────────────────────────────────
it("renders bot_token as type=password", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
expect((screen.getByLabelText("Bot Token") as HTMLInputElement).type).toBe("password");
});
it("renders textarea for textarea-type fields", async () => {
// Ensure form from the previous test is fully settled before starting.
// This prevents the form from "bleeding" from one test into the next.
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeTruthy();
});
// Set up the mock BEFORE render so the component uses the right adapter.
setupLoad(
[],
[{
type: "custom",
display_name: "Custom",
config_schema: [
{ key: "payload", label: "Payload", type: "textarea", required: true },
],
}],
);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
// Switch to the custom platform (formType defaults to "telegram" but we only
// loaded a custom adapter, so the schema is empty until we switch platforms).
fireEvent.change(screen.getByRole("combobox"), { target: { value: "custom" } });
await flush();
expect(screen.getByLabelText("Payload").tagName).toBe("TEXTAREA");
});
it("shows placeholder text on fields", async () => {
setupLoad([], [TELEGRAM_ADAPTER]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
expect((screen.getByLabelText("Bot Token") as HTMLInputElement).placeholder).toBe("123456:ABC-...");
expect((screen.getByLabelText("Chat ID") as HTMLInputElement).placeholder).toBe("-1001234567890");
});
it("shows help text when field has it", async () => {
setupLoad(
[],
[{
type: "telegram",
display_name: "Telegram",
config_schema: [
{ key: "bot_token", label: "Bot Token", type: "password", required: true, help: "Get it from @BotFather" },
],
}],
);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
expect(screen.getByText("Get it from @BotFather")).toBeTruthy();
});
it("shows legacy fallback when adapter has no config_schema", async () => {
setupLoad([], [{ type: "telegram", display_name: "Telegram" }]);
render(<ChannelsTab workspaceId="ws-test" />);
await flush();
act(() => { screen.getByRole("button", { name: /\+ Connect/ }).click(); });
await flush();
expect(screen.getByText(/upgrade the platform/i)).toBeTruthy();
});
});
@@ -1,596 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for DetailsTab — workspace detail panel in the side panel.
*
* Coverage:
* - View mode: renders workspace info (name, role, tier, status, url, parent)
* - View mode: renders T1/T2/T3/T4 tier display
* - View mode: shows active tasks count
* - Edit mode: opens when Edit is clicked
* - Edit mode: pre-fills name/role/tier from current data
* - Edit mode: changes propagate to form state
* - Save: PATCH /workspaces/:id with correct payload
* - Save success: calls updateNodeData + exits edit mode
* - Save error: shows error message
* - Cancel: restores original name/role/tier + exits edit mode
* - Restart button: visible for offline/failed/degraded workspaces
* - Restart button: hidden for online/provisioning workspaces
* - Restart: POST /workspaces/:id/restart + sets status to provisioning
* - Restart error: shows error message
* - Error section: shown for failed/degraded workspaces
* - Error section: shows lastSampleError in <pre>
* - Error section: shows 'No error detail recorded' when none
* - Console button: opens ConsoleModal
* - Peers: skipped when workspace is not online/degraded
* - Peers: loaded from GET /registry/:id/peers when online
* - Peers: shown with StatusDot and name
* - Peers: click navigates to peer node
* - Peers error: shown when load fails
* - Delete confirmation: two-step (click → confirm)
* - Delete: DEL /workspaces/:id?confirm=true + removeSubtree + selectNode(null)
* - Delete error: shown when DEL fails
* - ConsoleModal: mounted and rendered
* - Tier change via select
*/
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 { DetailsTab } from "../DetailsTab";
// ─── Mock sub-components ───────────────────────────────────────────────────────
vi.mock("@/components/StatusDot", () => ({
StatusDot: ({ status }: { status: string }) => (
<span data-testid="status-dot" data-status={status}>StatusDot:{status}</span>
),
}));
vi.mock("@/components/tabs/BudgetSection", () => ({
BudgetSection: ({ workspaceId }: { workspaceId: string }) => (
<div data-testid="budget-section" data-ws={workspaceId}>BudgetSection</div>
),
}));
vi.mock("@/components/WorkspaceUsage", () => ({
WorkspaceUsage: ({ workspaceId }: { workspaceId: string }) => (
<div data-testid="workspace-usage" data-ws={workspaceId}>WorkspaceUsage</div>
),
}));
const consoleModalMock = vi.hoisted(() => vi.fn(() => <div data-testid="console-modal">ConsoleModal</div>));
vi.mock("@/components/ConsoleModal", () => ({
ConsoleModal: consoleModalMock,
}));
// ─── Mock API ─────────────────────────────────────────────────────────────────
const mockGet = vi.hoisted(() => vi.fn((): Promise<unknown> => Promise.resolve([])));
const mockPatch = vi.hoisted(() => vi.fn((): Promise<unknown> => Promise.resolve({})));
const mockPost = vi.hoisted(() => vi.fn((): Promise<unknown> => Promise.resolve({})));
const mockDel = vi.hoisted(() => vi.fn((): Promise<unknown> => Promise.resolve({})));
vi.mock("@/lib/api", () => ({
api: { get: mockGet, patch: mockPatch, post: mockPost, del: mockDel },
}));
// ─── Mock canvas store ─────────────────────────────────────────────────────────
const updateNodeDataMock = vi.fn();
const removeSubtreeMock = vi.fn();
const selectNodeMock = vi.fn();
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn((selector?: (s: unknown) => unknown) =>
selector
? selector({
updateNodeData: updateNodeDataMock,
removeSubtree: removeSubtreeMock,
selectNode: selectNodeMock,
})
: {},
),
}));
// ─── Helpers ───────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
function typeIn(el: HTMLElement, value: string) {
Object.defineProperty(el, "value", { value, writable: true, configurable: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fireEvent.change(el as any, { target: el });
}
// ─── Fixtures ─────────────────────────────────────────────────────────────────
// Minimal set of WorkspaceNodeData fields — cast to bypass type-checking here.
// The component is already tested at the type level; the fixture only needs
// enough shape to let DetailsTab render without crashing.
const DEFAULT_DATA = {
id: "ws-1",
name: "My Workspace",
role: "agent",
tier: 2,
status: "online",
parentId: null as string | null,
url: "http://localhost:8081",
activeTasks: 0,
agentCard: null,
collapsed: false,
lastErrorRate: 0,
lastSampleError: "",
currentTask: "",
runtime: "claude-code",
needsRestart: false,
budgetLimit: null,
} as unknown as import("@/store/canvas").WorkspaceNodeData;
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("DetailsTab", () => {
beforeEach(() => {
mockGet.mockReset();
mockPatch.mockReset();
mockPost.mockReset();
mockDel.mockReset();
updateNodeDataMock.mockReset();
removeSubtreeMock.mockReset();
selectNodeMock.mockReset();
consoleModalMock.mockReset();
vi.useRealTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ── View mode ──────────────────────────────────────────────────────────────
it("renders workspace name", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
expect(screen.getByText("My Workspace")).toBeTruthy();
});
it("renders role", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, role: "researcher" }} />);
await flush();
expect(screen.getByText("researcher")).toBeTruthy();
});
it("renders T2 for tier 2", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, tier: 2 }} />);
await flush();
expect(screen.getByText("T2")).toBeTruthy();
});
it("renders T4 for tier 4", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, tier: 4 }} />);
await flush();
expect(screen.getByText("T4")).toBeTruthy();
});
it("renders status", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "failed" }} />);
await flush();
expect(screen.getByText("failed")).toBeTruthy();
});
it("renders URL when present", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, url: "http://example.com" }} />);
await flush();
expect(screen.getByText("http://example.com")).toBeTruthy();
});
it("renders '—' when url is absent", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, url: "" }} />);
await flush();
expect(screen.getByText("—")).toBeTruthy();
});
it("renders 'root' for root workspace (no parentId)", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, parentId: null }} />);
await flush();
expect(screen.getByText("root")).toBeTruthy();
});
it("renders parent ID when present", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, parentId: "ws-parent-42" }} />);
await flush();
expect(screen.getByText("ws-parent-42")).toBeTruthy();
});
it("renders active tasks count", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, activeTasks: 5 }} />);
await flush();
expect(screen.getByText(/5/)).toBeTruthy();
});
it("shows BudgetSection", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
expect(screen.getByTestId("budget-section")).toBeTruthy();
});
it("shows WorkspaceUsage", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
expect(screen.getByTestId("workspace-usage")).toBeTruthy();
});
// ── Edit mode ──────────────────────────────────────────────────────────────
it("shows Edit button in view mode", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
});
it("opens edit form when Edit is clicked", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
// Form inputs should now be visible
expect(screen.getByLabelText("Name")).toBeTruthy();
expect(screen.getByLabelText("Role")).toBeTruthy();
expect(screen.getByLabelText("Tier")).toBeTruthy();
});
it("pre-fills form with current name/role/tier", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, name: "Alpha", role: "ceo", tier: 3 }} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Alpha");
expect((screen.getByLabelText("Role") as HTMLInputElement).value).toBe("ceo");
expect((screen.getByLabelText("Tier") as HTMLSelectElement).value).toBe("3");
});
it("name changes propagate to form state", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
typeIn(screen.getByLabelText("Name") as HTMLElement, "New Name");
await flush();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("New Name");
});
it("role changes propagate to form state", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
typeIn(screen.getByLabelText("Role") as HTMLElement, "Researcher");
await flush();
expect((screen.getByLabelText("Role") as HTMLInputElement).value).toBe("Researcher");
});
it("tier changes via select", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, tier: 1 }} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
fireEvent.change(screen.getByLabelText("Tier"), { target: { value: "4" } });
await flush();
expect((screen.getByLabelText("Tier") as HTMLSelectElement).value).toBe("4");
});
it("PATCHes correct payload on Save", async () => {
mockPatch.mockResolvedValue({});
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, name: "Old", role: "old-role", tier: 2 }} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
typeIn(screen.getByLabelText("Name") as HTMLElement, "New");
typeIn(screen.getByLabelText("Role") as HTMLElement, "NewRole");
fireEvent.change(screen.getByLabelText("Tier"), { target: { value: "3" } });
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(mockPatch).toHaveBeenCalledWith(
"/workspaces/ws-1",
expect.objectContaining({ name: "New", role: "NewRole", tier: 3 }),
);
});
it("calls updateNodeData and exits edit on successful save", async () => {
mockPatch.mockResolvedValue({});
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, name: "Old" }} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
typeIn(screen.getByLabelText("Name") as HTMLElement, "New");
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(updateNodeDataMock).toHaveBeenCalledWith("ws-1", { name: "New", role: "agent", tier: 2 });
await waitFor(() => {
expect(screen.queryByLabelText("Name")).not.toBeTruthy();
});
});
it("shows error message when save fails", async () => {
mockPatch.mockRejectedValue(new Error("save failed"));
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(screen.getByText(/save failed/i)).toBeTruthy();
});
it("Cancel restores original name/role/tier and exits edit", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, name: "Original", role: "orig-role", tier: 2 }} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
typeIn(screen.getByLabelText("Name") as HTMLElement, "Changed");
typeIn(screen.getByLabelText("Role") as HTMLElement, "changed-role");
await flush();
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
await flush();
// Form should be closed (back to view mode)
await waitFor(() => {
expect(screen.queryByLabelText("Name")).not.toBeTruthy();
});
// Value should be back to original
expect(screen.getByText("Original")).toBeTruthy();
});
it("shows 'Saving...' when save is in progress", async () => {
mockPatch.mockImplementation(() => new Promise(() => {}));
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(screen.getByText("Saving...")).toBeTruthy();
});
// ── Restart ───────────────────────────────────────────────────────────────
it("shows Restart button for offline workspace", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "offline" }} />);
await flush();
expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
});
it("shows Retry button for failed workspace", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "failed" }} />);
await flush();
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
});
it("shows Restart button for degraded workspace", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "degraded" }} />);
await flush();
expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
});
it("hides Restart/Retry for online workspace", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "online" }} />);
await flush();
expect(screen.queryByRole("button", { name: /restart/i })).toBeFalsy();
expect(screen.queryByRole("button", { name: /retry/i })).toBeFalsy();
});
it("POSTs /workspaces/:id/restart when Restart is clicked", async () => {
mockPost.mockResolvedValue({});
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "offline" }} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /restart/i }));
await flush();
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-1/restart", {});
});
it("calls updateNodeData to set status to provisioning on restart", async () => {
mockPost.mockResolvedValue({});
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "offline" }} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /restart/i }));
await flush();
expect(updateNodeDataMock).toHaveBeenCalledWith("ws-1", { status: "provisioning" });
});
it("shows error when restart fails", async () => {
mockPost.mockRejectedValue(new Error("restart failed"));
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "offline" }} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /restart/i }));
await flush();
expect(screen.getByText(/restart failed/i)).toBeTruthy();
});
it("shows 'Restarting...' during restart", async () => {
mockPost.mockImplementation(() => new Promise(() => {}));
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "offline" }} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /restart/i }));
await flush();
expect(screen.getByText("Restarting...")).toBeTruthy();
});
// ── Error section ────────────────────────────────────────────────────────
it("shows Error section for failed workspace", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "failed" }} />);
await flush();
expect(screen.getByText("Error")).toBeTruthy();
});
it("shows lastSampleError in <pre> for failed workspace", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "failed", lastSampleError: "ModuleNotFoundError: foo" }} />);
await flush();
expect(screen.getByTestId("details-error-log")).toBeTruthy();
expect(screen.getByText("ModuleNotFoundError: foo")).toBeTruthy();
});
it("shows 'No error detail recorded' when no error", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "failed", lastSampleError: "" }} />);
await flush();
expect(screen.getByText("No error detail recorded.")).toBeTruthy();
});
it("opens ConsoleModal when View console output is clicked", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "failed" }} />);
await flush();
consoleModalMock.mockClear();
fireEvent.click(screen.getByRole("button", { name: /view console output/i }));
await flush();
expect(consoleModalMock.mock.calls[0][0]).toMatchObject({ open: true });
});
// ── Degraded error rate ──────────────────────────────────────────────────
it("shows error rate for degraded workspace", async () => {
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "degraded", lastErrorRate: 0.25 }} />);
await flush();
expect(screen.getByText("25%")).toBeTruthy();
});
// ── Peers ────────────────────────────────────────────────────────────────
it("skips peer load when workspace is not online/degraded", async () => {
mockGet.mockResolvedValue([]);
render(<DetailsTab workspaceId="ws-1" data={{ ...DEFAULT_DATA, status: "offline" }} />);
await flush();
expect(screen.queryByText(/peers are only discoverable/i)).toBeTruthy();
});
it("loads peers from GET /registry/:id/peers when online", async () => {
mockGet.mockResolvedValue([
{ id: "p1", name: "Peer One", role: "agent", status: "online", tier: 1 },
]);
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
expect(mockGet).toHaveBeenCalledWith("/registry/ws-1/peers");
expect(screen.getByText("Peer One")).toBeTruthy();
});
it("shows 'No reachable peers' when peer list is empty", async () => {
mockGet.mockResolvedValue([]);
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
expect(screen.getByText("No reachable peers")).toBeTruthy();
});
it("calls selectNode when a peer button is clicked", async () => {
mockGet.mockResolvedValue([
{ id: "p1", name: "Peer One", role: "agent", status: "online", tier: 1 },
]);
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByText("Peer One"));
await flush();
expect(selectNodeMock).toHaveBeenCalledWith("p1");
});
it("shows peers error message when load fails", async () => {
mockGet.mockRejectedValue(new Error("peer load failed"));
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
expect(screen.getByText(/peer load failed/i)).toBeTruthy();
});
// ── Delete ───────────────────────────────────────────────────────────────
it("shows Delete Workspace button", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy();
});
it("shows confirmation when Delete Workspace is clicked", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
await flush();
expect(screen.getByRole("button", { name: /confirm delete/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
});
it("DELs /workspaces/:id?confirm=true when Confirm Delete is clicked", async () => {
mockDel.mockResolvedValue({});
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
await flush();
act(() => { screen.getByRole("button", { name: /confirm delete/i }).click(); });
await flush();
expect(mockDel).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true");
});
it("calls removeSubtree and selectNode(null) after delete", async () => {
mockDel.mockResolvedValue({});
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
await flush();
act(() => { screen.getByRole("button", { name: /confirm delete/i }).click(); });
await flush();
expect(removeSubtreeMock).toHaveBeenCalledWith("ws-1");
expect(selectNodeMock).toHaveBeenCalledWith(null);
});
it("shows error when delete fails", async () => {
mockDel.mockRejectedValue(new Error("delete failed"));
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
await flush();
act(() => { screen.getByRole("button", { name: /confirm delete/i }).click(); });
await flush();
expect(screen.getByText(/delete failed/i)).toBeTruthy();
});
it("cancels delete confirmation", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
await flush();
expect(screen.getByRole("button", { name: /confirm delete/i })).toBeTruthy();
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
await flush();
expect(screen.queryByRole("button", { name: /confirm delete/i })).toBeFalsy();
});
// ── Skills ─────────────────────────────────────────────────────────────
it("shows skills from agentCard when present", async () => {
render(<DetailsTab workspaceId="ws-1" data={{
...DEFAULT_DATA,
agentCard: {
skills: [
{ id: "web-search", description: "Search the web" },
{ id: "code-gen", description: "Generate code" },
],
},
}} />);
await flush();
expect(screen.getByText("web-search")).toBeTruthy();
expect(screen.getByText("Search the web")).toBeTruthy();
expect(screen.getByText("code-gen")).toBeTruthy();
});
it("hides Skills section when agentCard is null", async () => {
render(<DetailsTab workspaceId="ws-1" data={DEFAULT_DATA} />);
await flush();
expect(screen.queryByText("Skills")).toBeFalsy();
});
it("hides Skills section when agentCard.skills is empty", async () => {
render(<DetailsTab workspaceId="ws-1" data={{
...DEFAULT_DATA,
agentCard: { skills: [] },
}} />);
await flush();
expect(screen.queryByText("Skills")).toBeFalsy();
});
});
@@ -1,364 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for EventsTab — the activity feed on the Events tab.
*
* Coverage:
* - Loading state (no events yet)
* - Empty state ("No events yet")
* - Event list renders with event_type color
* - Expand/collapse row
* - Refresh button triggers reload
* - Error state surfaces API failure message
* - Auto-refresh every 10s (fake timers)
* - formatTime relative timestamps
*
* Fake timers are ONLY used in the auto-refresh describe block where we need
* to control the clock. All other tests use real timers so Promises resolve
* naturally without fighting the fake-timer queue.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventsTab } from "../EventsTab";
// Hoist mockGet so vi.mock factory can reference it (vi.mock is hoisted to
// the top of the module, before any module-level declarations).
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
vi.mock("@/lib/api", () => ({
api: { get: mockGet },
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
const event = (
id: string,
type = "WORKSPACE_ONLINE",
createdOffsetSecs = 0,
): {
id: string;
event_type: string;
workspace_id: string | null;
payload: Record<string, unknown>;
created_at: string;
} => ({
id,
event_type: type,
workspace_id: "ws-1",
payload: { key: "value" },
created_at: new Date(Date.now() - createdOffsetSecs * 1000).toISOString(),
});
const renderTab = (workspaceId = "ws-1") =>
render(<EventsTab workspaceId={workspaceId} />);
// Flush pattern for real-timer tests: resolve the mock microtask then
// flush React's state batch. Using act(async ...) lets us await inside.
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("EventsTab — render conditions", () => {
beforeEach(() => {
vi.useRealTimers();
mockGet.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("shows loading state when events are being fetched", async () => {
// Never resolve so loading stays true
mockGet.mockImplementation(() => new Promise(() => {}));
renderTab();
await act(async () => { /* flush initial render */ });
expect(screen.getByText("Loading events...")).toBeTruthy();
});
it("shows empty state when API returns an empty list", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
expect(screen.getByText("No events yet")).toBeTruthy();
});
it("renders the event list when API returns events", async () => {
mockGet.mockResolvedValueOnce([
event("e1", "WORKSPACE_ONLINE"),
event("e2", "WORKSPACE_REMOVED"),
]);
renderTab();
await flush();
expect(screen.getByText("WORKSPACE_ONLINE")).toBeTruthy();
expect(screen.getByText("WORKSPACE_REMOVED")).toBeTruthy();
expect(screen.getByText("2 events")).toBeTruthy();
});
it("applies text-bad color to WORKSPACE_REMOVED events", async () => {
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_REMOVED")]);
renderTab();
await flush();
const span = screen.getByText("WORKSPACE_REMOVED");
expect(span.classList).toContain("text-bad");
});
it("applies text-good color to WORKSPACE_ONLINE events", async () => {
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
renderTab();
await flush();
const span = screen.getByText("WORKSPACE_ONLINE");
expect(span.classList).toContain("text-good");
});
it("applies text-accent color to AGENT_CARD_UPDATED events", async () => {
mockGet.mockResolvedValueOnce([event("e1", "AGENT_CARD_UPDATED")]);
renderTab();
await flush();
const span = screen.getByText("AGENT_CARD_UPDATED");
expect(span.classList).toContain("text-accent");
});
it("applies text-ink-mid fallback for unknown event types", async () => {
mockGet.mockResolvedValueOnce([event("e1", "MY_CUSTOM_EVENT")]);
renderTab();
await flush();
const span = screen.getByText("MY_CUSTOM_EVENT");
expect(span.classList).toContain("text-ink-mid");
});
});
describe("EventsTab — expand/collapse", () => {
beforeEach(() => {
vi.useRealTimers();
mockGet.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("shows payload when a row is clicked (expanded)", async () => {
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
renderTab();
await flush();
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
await act(async () => { /* flush */ });
expect(screen.getByText(/"key": "value"/)).toBeTruthy();
expect(screen.getByText("ID: e1")).toBeTruthy();
});
it("hides payload when the expanded row is clicked again", async () => {
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
renderTab();
await flush();
// First click: expand
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
await act(async () => { /* flush */ });
expect(screen.getByText(/"key": "value"/)).toBeTruthy();
// Second click: collapse — re-query the button to ensure the
// post-render element with the up-to-date handler is targeted
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
await act(async () => { /* flush */ });
expect(screen.queryByText(/"key": "value"/)).toBeFalsy();
});
it("has aria-expanded=true on the expanded row", async () => {
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
renderTab();
await flush();
// Call the onClick prop directly inside act() to bypass React's event
// delegation, which fireEvent.click doesn't reliably trigger in jsdom.
act(() => {
screen.getByRole("button", { name: /workspace_online/i }).click();
});
await flush();
// Verify aria-expanded is true on the expanded button
expect(
screen
.getAllByRole("button")
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"))
?.getAttribute("aria-expanded"),
).toBe("true");
});
it("has aria-expanded=false on collapsed rows", async () => {
mockGet.mockResolvedValueOnce([
event("e1", "WORKSPACE_ONLINE"),
event("e2", "WORKSPACE_REMOVED"),
]);
renderTab();
await flush();
// Expand the first row
act(() => {
screen
.getAllByRole("button")
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"))
?.click();
});
await flush();
const onlineBtn = screen
.getAllByRole("button")
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"));
const removedBtn = screen
.getAllByRole("button")
.find((b) => b.textContent?.includes("WORKSPACE_REMOVED"));
expect(onlineBtn?.getAttribute("aria-expanded")).toBe("true");
expect(removedBtn?.getAttribute("aria-expanded")).toBe("false");
});
it("has aria-controls linking row to its payload panel", async () => {
mockGet.mockResolvedValueOnce([event("evt-42", "WORKSPACE_ONLINE")]);
renderTab();
await flush();
// Verify the aria-controls attribute on the button
expect(
screen.getByRole("button", { name: /workspace_online/i }).getAttribute(
"aria-controls",
),
).toBe("events-payload-evt-42");
});
});
describe("EventsTab — refresh", () => {
beforeEach(() => {
vi.useRealTimers();
mockGet.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("Refresh button triggers a new GET /events/:id", async () => {
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
renderTab();
await flush();
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
mockGet.mockClear();
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
await flush();
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
});
it("shows loading state during refresh (events still visible from previous load)", async () => {
// First load succeeds with real timers so the mock resolves
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
renderTab();
await flush();
expect(screen.getByText("1 events")).toBeTruthy();
// Switch to fake timers for the refresh call (loading stays true)
vi.useFakeTimers();
// Refresh call hangs to keep loading=true
mockGet.mockImplementationOnce(() => new Promise(() => {}));
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
await act(() => { vi.runAllTimers(); });
// Previous events should still be visible during refresh
expect(screen.getByText("WORKSPACE_ONLINE")).toBeTruthy();
vi.useRealTimers();
});
});
describe("EventsTab — error state", () => {
beforeEach(() => {
vi.useRealTimers();
mockGet.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("shows error message when GET /events/:id rejects", async () => {
mockGet.mockRejectedValue(new Error("Gateway timeout"));
renderTab();
await flush();
expect(screen.getByText("Gateway timeout")).toBeTruthy();
expect(screen.queryByText("Loading events...")).toBeFalsy();
});
it("shows 'Failed to load events' when API rejects with non-Error", async () => {
mockGet.mockRejectedValue("unknown failure");
renderTab();
await flush();
expect(screen.getByText("Failed to load events")).toBeTruthy();
});
});
describe("EventsTab — auto-refresh", () => {
// Use vi.spyOn to mock setInterval/clearInterval so we can control timer
// firing without Vitest's fake-timer APIs (which create infinite loops when
// timers schedule microtasks that schedule more timers).
let setIntervalSpy: ReturnType<typeof vi.spyOn>;
let clearIntervalSpy: ReturnType<typeof vi.spyOn>;
let activeIntervalId = 0;
const scheduledCallbacks = new Map<number, () => void>();
beforeEach(() => {
vi.useRealTimers();
mockGet.mockReset();
activeIntervalId = 0;
scheduledCallbacks.clear();
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(
(cb: () => void) => {
const id = ++activeIntervalId;
scheduledCallbacks.set(id, cb);
return id;
},
);
clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(
(id: number) => {
scheduledCallbacks.delete(id);
},
);
});
afterEach(() => {
cleanup();
setIntervalSpy?.mockRestore();
clearIntervalSpy?.mockRestore();
vi.useRealTimers();
});
it("calls GET /events/:id after 10s without manual interaction", async () => {
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
renderTab();
await flush();
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
mockGet.mockClear();
// Verify setInterval was called with 10000ms delay
expect(setIntervalSpy).toHaveBeenCalledWith(
expect.any(Function),
10000,
);
// Fire the captured interval callback (simulates 10s elapsing)
const callback = [...scheduledCallbacks.values()][0];
act(() => { callback(); });
await flush();
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
});
it("clears the previous auto-refresh interval on unmount", async () => {
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
const { unmount } = renderTab();
await flush();
// Verify clearInterval was NOT called yet
expect(clearIntervalSpy).not.toHaveBeenCalled();
// Unmount should call clearInterval with the active interval id
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
// The callback should no longer be scheduled
expect(scheduledCallbacks.size).toBe(0);
});
});
@@ -1,774 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for MemoryTab — the workspace KV memory tab.
*
* Coverage:
* - Loading state (pending GET)
* - Empty state ("No memory entries")
* - Memory entries list renders
* - Expand/collapse entry + aria-expanded
* - Add entry: key validation, value JSON parsing, TTL
* - Edit entry: begin, cancel, save, 409 conflict
* - Delete entry: optimistic removal
* - Error state from API failure
* - Refresh button triggers reload
* - Awareness dashboard collapse/expand
* - Advanced toggle shows/hides KV section
* - Awareness URL includes workspaceId
*
* Uses vi.useRealTimers() + flush() pattern for all non-window tests.
* window.open is mocked per-test since it is environment-dependent.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryTab } from "../MemoryTab";
// Hoist mockGet so vi.mock factory can reference it (vi.mock is hoisted).
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,
},
}));
// Mock window.open per-test
const mockOpen = vi.fn();
vi.stubGlobal("open", mockOpen);
beforeEach(() => {
vi.useRealTimers();
mockGet.mockReset();
mockPost.mockReset();
mockDel.mockReset();
mockOpen.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
const entry = (
key: string,
value: unknown,
overrides?: Partial<{
version: number;
expires_at: string | null;
updated_at: string;
}>,
): {
key: string;
value: unknown;
version?: number;
expires_at: string | null;
updated_at: string;
} => ({
key,
value,
version: undefined,
expires_at: null,
updated_at: "2026-05-10T10:00:00Z",
...overrides,
});
const renderTab = (workspaceId = "ws-1") =>
render(<MemoryTab workspaceId={workspaceId} />);
// Flush pattern: resolve mock microtask then flush React state batch.
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("MemoryTab — render conditions", () => {
beforeEach(() => {
mockGet.mockImplementation(() => new Promise(() => {}));
});
it("shows loading state while fetching", async () => {
renderTab();
await act(async () => { /* flush initial render */ });
expect(screen.getByText("Loading memory...")).toBeTruthy();
});
it("shows empty state when API returns empty list", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
// KV section hidden by default; reveal it via Advanced toggle
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
await flush();
expect(screen.getByText("No memory entries")).toBeTruthy();
});
it("renders memory entries when API returns data", async () => {
mockGet.mockResolvedValueOnce([
entry("my-key", { nested: true }),
entry("another-key", "plain string"),
]);
renderTab();
await flush();
// Advanced is collapsed by default; reveal entries
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
await flush();
expect(screen.getByText("my-key")).toBeTruthy();
expect(screen.getByText("another-key")).toBeTruthy();
});
it("shows Advanced section hidden by default", async () => {
mockGet.mockResolvedValueOnce([entry("k1", "v1")]);
renderTab();
await flush();
expect(screen.getByText("Advanced workspace memory is hidden")).toBeTruthy();
});
it("shows Advanced section when entries exist and advanced is toggled on", async () => {
mockGet.mockResolvedValueOnce([entry("k1", "v1")]);
renderTab();
await flush();
// Show the advanced section
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
await flush();
expect(screen.getByText("k1")).toBeTruthy();
});
// Awareness section defaults to showAwareness=true (expanded with iframe)
it("shows Awareness dashboard expanded with iframe by default", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
// Default state shows the expanded section
const iframe = document.querySelector("iframe");
expect(iframe).toBeTruthy();
expect(iframe?.getAttribute("title")).toBe("Awareness dashboard");
});
it("collapses Awareness dashboard when Collapse button is clicked", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /collapse/i }).click();
});
await flush();
expect(screen.getByText("Awareness dashboard is collapsed")).toBeTruthy();
});
it("shows awareness status grid in expanded Awareness section", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
// Default state is already expanded — status grid is visible
expect(screen.getByText("Connected")).toBeTruthy();
expect(screen.getByText("Mode")).toBeTruthy();
expect(screen.getByText("Workspace")).toBeTruthy();
});
it("shows workspaceId in awareness grid", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab("my-workspace-id");
await flush();
// workspaceId appears twice: in awareness grid and in KV description.
// Query the awareness grid span specifically (text-ink-mid class in the grid).
const spans = screen.getAllByText("my-workspace-id");
const gridSpan = spans.find(
(s) => s.className.includes("font-mono") && !s.className.includes("truncate"),
);
expect(gridSpan).toBeTruthy();
});
});
describe("MemoryTab — KV memory CRUD", () => {
beforeEach(() => {
// Use mockImplementation so every call resolves (loadMemory is called multiple
// times: on mount, on refresh, after add/save errors)
mockGet.mockImplementation(() =>
Promise.resolve([entry("existing-key", "existing-value")]),
);
mockPost.mockResolvedValue({});
mockDel.mockResolvedValue({});
});
it("shows error alert when GET rejects", async () => {
mockGet.mockRejectedValue(new Error("Network failure"));
renderTab();
await flush();
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Network failure")).toBeTruthy();
});
it("Refresh button calls GET /workspaces/:id/memory", async () => {
renderTab();
await flush();
mockGet.mockClear();
act(() => {
screen.getByRole("button", { name: /refresh/i }).click();
});
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
});
it("shows + Add button to open add form", async () => {
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
expect(screen.getByRole("button", { name: /^\+ add$/i })).toBeTruthy();
});
it("shows add form when + Add is clicked", async () => {
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /^\+ add$/i }).click();
});
await flush();
expect(screen.getByLabelText(/memory key/i)).toBeTruthy();
expect(screen.getByLabelText(/memory value/i)).toBeTruthy();
});
it("requires key in add form", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /^\+ add$/i }).click();
});
await flush();
mockPost.mockReset().mockRejectedValue(new Error("should not be called"));
act(() => {
screen.getByRole("button", { name: /save/i }).click();
});
await flush();
expect(screen.getByText("Key is required")).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
it("parses JSON value in add form", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /^\+ add$/i }).click();
});
await flush();
fireEvent.change(screen.getByLabelText(/memory key/i), {
target: { value: "json-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: '{"nested": "value"}' },
});
act(() => {
screen.getByRole("button", { name: /save/i }).click();
});
await flush();
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/memory",
expect.objectContaining({
key: "json-key",
value: { nested: "value" },
}),
);
});
it("treats plain-text value as string in add form", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /^\+ add$/i }).click();
});
await flush();
fireEvent.change(screen.getByLabelText(/memory key/i), {
target: { value: "plain-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "plain text" },
});
act(() => {
screen.getByRole("button", { name: /save/i }).click();
});
await flush();
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/memory",
expect.objectContaining({
key: "plain-key",
value: "plain text",
}),
);
});
it("sends ttl_seconds when TTL is provided in add form", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /^\+ add$/i }).click();
});
await flush();
fireEvent.change(screen.getByLabelText(/memory key/i), {
target: { value: "ttl-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "val" },
});
fireEvent.change(screen.getByLabelText(/ttl in seconds/i), {
target: { value: "3600" },
});
act(() => {
screen.getByRole("button", { name: /save/i }).click();
});
await flush();
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/memory",
expect.objectContaining({
key: "ttl-key",
value: "val",
ttl_seconds: 3600,
}),
);
});
it("closes add form on cancel", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /^\+ add$/i }).click();
});
await flush();
expect(screen.getByLabelText(/memory key/i)).toBeTruthy();
act(() => {
screen.getByRole("button", { name: /cancel/i }).click();
});
await flush();
expect(screen.queryByLabelText(/memory key/i)).toBeFalsy();
});
it("shows error when add POST rejects", async () => {
mockGet.mockResolvedValueOnce([]);
mockPost.mockRejectedValue(new Error("Add failed"));
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /^\+ add$/i }).click();
});
await flush();
fireEvent.change(screen.getByLabelText(/memory key/i), {
target: { value: "k" },
});
act(() => {
screen.getByRole("button", { name: /save/i }).click();
});
await flush();
expect(screen.getByText("Add failed")).toBeTruthy();
});
it("optimistically removes entry on delete", async () => {
renderTab();
await flush();
// Expand the advanced section
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
// Expand the entry row
act(() => {
screen.getByText("existing-key").closest("button")?.click();
});
await flush();
// Verify the Delete button is visible inside the expanded section
const deleteBtn = screen
.getAllByRole("button")
.find((b) => b.textContent === "Delete");
expect(deleteBtn).toBeTruthy();
// Clicking Delete fires the API call; the entry is optimistically
// removed from state before the response. We verify the API call here.
act(() => {
deleteBtn?.click();
});
await flush();
expect(mockDel).toHaveBeenCalledWith(
"/workspaces/ws-1/memory/existing-key",
);
});
it("calls DELETE /workspaces/:id/memory/:key on delete", async () => {
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByText("existing-key").closest("button")?.click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /delete/i }).click();
});
await flush();
expect(mockDel).toHaveBeenCalledWith(
"/workspaces/ws-1/memory/existing-key",
);
});
it("shows error when delete rejects", async () => {
mockDel.mockRejectedValue(new Error("Delete failed"));
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByText("existing-key").closest("button")?.click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /delete/i }).click();
});
await flush();
// Error should appear in the alert
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Delete failed")).toBeTruthy();
// Entry should be visible again (reverted)
expect(screen.getByText("existing-key")).toBeTruthy();
});
});
describe("MemoryTab — edit entry", () => {
beforeEach(() => {
// Use mockImplementation so every call resolves (loadMemory called multiple times)
mockGet.mockImplementation(() =>
Promise.resolve([
entry("edit-key", { original: true }, { version: 5 }),
]),
);
mockPost.mockResolvedValue({});
});
it("begins edit mode when Edit is clicked", async () => {
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
// Expand the entry row first
act(() => {
screen.getByText("edit-key").closest("button")?.click();
});
await flush();
// Find the "Edit" button specifically (not the row button whose accessible name is "edit-key")
const editBtn = screen
.getAllByRole("button", { name: /^edit$/i })
.find((b) => b.textContent === "Edit");
act(() => {
editBtn?.click();
});
await flush();
expect(screen.getByLabelText(/edit value for edit-key/i)).toBeTruthy();
expect(screen.getByLabelText(/edit ttl for edit-key/i)).toBeTruthy();
});
it("pre-fills edit textarea with JSON for object values", async () => {
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByText("edit-key").closest("button")?.click();
});
await flush();
act(() => {
screen
.getAllByRole("button", { name: /^edit$/i })
.find((b) => b.textContent === "Edit")
?.click();
});
await flush();
const textarea = screen.getByLabelText(/edit value for edit-key/i);
expect(textarea.textContent?.trim()).toBe('{\n "original": true\n}');
});
it("pre-fills edit textarea with raw string for string values", async () => {
mockGet.mockImplementation(() =>
Promise.resolve([
entry("str-key", "plain string value", { version: 1 }),
]),
);
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByText("str-key").closest("button")?.click();
});
await flush();
act(() => {
screen
.getAllByRole("button", { name: /^edit$/i })
.find((b) => b.textContent === "Edit")
?.click();
});
await flush();
const textarea = screen.getByLabelText(/edit value for str-key/i);
expect(textarea.textContent?.trim()).toBe("plain string value");
});
it("cancels edit and restores entry view", async () => {
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByText("edit-key").closest("button")?.click();
});
await flush();
act(() => {
screen
.getAllByRole("button", { name: /^edit$/i })
.find((b) => b.textContent === "Edit")
?.click();
});
await flush();
expect(screen.getByLabelText(/edit value for edit-key/i)).toBeTruthy();
act(() => {
screen.getByRole("button", { name: /cancel/i }).click();
});
await flush();
expect(screen.queryByLabelText(/edit value/i)).toBeFalsy();
});
it("calls POST with if_match_version on save", async () => {
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByText("edit-key").closest("button")?.click();
});
await flush();
act(() => {
screen
.getAllByRole("button", { name: /^edit$/i })
.find((b) => b.textContent === "Edit")
?.click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /save/i }).click();
});
await flush();
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/memory",
expect.objectContaining({
key: "edit-key",
value: { original: true },
if_match_version: 5,
}),
);
});
it("shows 409 conflict error and reloads on version mismatch", async () => {
mockPost.mockRejectedValue(
new Error("409 Conflict: if_match_version mismatch"),
);
// Return entries for initial load; on 409 the component calls loadMemory()
// again — use mockImplementation so subsequent calls also return entries
mockGet.mockImplementation(() =>
Promise.resolve([
entry("edit-key", { original: true }, { version: 5 }),
]),
);
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByText("edit-key").closest("button")?.click();
});
await flush();
act(() => {
screen
.getAllByRole("button", { name: /^edit$/i })
.find((b) => b.textContent === "Edit")
?.click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /save/i }).click();
});
await flush();
expect(screen.getByText(/this entry changed since you opened it/i)).toBeTruthy();
});
it("shows generic error when edit POST rejects with non-409", async () => {
mockPost.mockRejectedValue(new Error("Server error"));
renderTab();
await flush();
act(() => {
screen.getByRole("button", { name: /advanced/i }).click();
});
await flush();
act(() => {
screen.getByText("edit-key").closest("button")?.click();
});
await flush();
act(() => {
screen
.getAllByRole("button", { name: /^edit$/i })
.find((b) => b.textContent === "Edit")
?.click();
});
await flush();
act(() => {
screen.getByRole("button", { name: /save/i }).click();
});
await flush();
expect(screen.getByText("Server error")).toBeTruthy();
});
});
describe("MemoryTab — expand/collapse entry", () => {
beforeEach(() => {
mockGet.mockResolvedValue([
entry("entry-a", { data: "A" }),
entry("entry-b", { data: "B" }),
]);
});
it("expands entry when clicked", async () => {
renderTab();
await flush();
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
await flush();
act(() => {
screen.getByText("entry-a").closest("button")?.click();
});
await flush();
// Expanded entry shows its JSON value
expect(screen.getByText(/"data": "A"/)).toBeTruthy();
});
it("collapses entry when clicked again", async () => {
renderTab();
await flush();
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
await flush();
act(() => {
screen.getByText("entry-a").closest("button")?.click();
});
await flush();
act(() => {
screen.getByText("entry-a").closest("button")?.click();
});
await flush();
expect(screen.queryByText(/"data": "A"/)).toBeFalsy();
});
it("shows collapsed indicator ▶ for non-expanded entries", async () => {
renderTab();
await flush();
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
await flush();
expect(screen.getAllByText("▶").length).toBeGreaterThan(0);
});
it("shows expanded indicator ▼ for expanded entries", async () => {
renderTab();
await flush();
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
await flush();
act(() => {
screen.getByText("entry-a").closest("button")?.click();
});
await flush();
expect(screen.getAllByText("▼").length).toBeGreaterThan(0);
});
it("hides edit/delete buttons when entry is collapsed", async () => {
renderTab();
await flush();
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
await flush();
expect(screen.queryByRole("button", { name: /edit/i })).toBeFalsy();
expect(screen.queryByRole("button", { name: /delete/i })).toBeFalsy();
});
it("shows edit/delete buttons when entry is expanded", async () => {
renderTab();
await flush();
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
await flush();
act(() => {
screen.getByText("entry-a").closest("button")?.click();
});
await flush();
expect(screen.getAllByRole("button", { name: /edit/i }).length).toBeGreaterThan(0);
expect(screen.getAllByRole("button", { name: /delete/i }).length).toBeGreaterThan(0);
});
});
describe("MemoryTab — Open Awareness button", () => {
it("calls window.open with workspaceId in URL", async () => {
mockGet.mockResolvedValueOnce([]);
renderTab("my-ws");
await flush();
fireEvent.click(screen.getByRole("button", { name: /open/i }));
await flush();
expect(mockOpen).toHaveBeenCalled();
const url = mockOpen.mock.calls[0][0];
expect(url).toContain("workspaceId=my-ws");
});
});
@@ -1,635 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ScheduleTab — cron-based task scheduling.
*
* Coverage:
* - Loading state
* - Empty state (no schedules)
* - Schedule list rendering (single + multiple)
* - Status dot color (error/ok/idle)
* - Toggle enable/disable via status dot
* - Delete via ConfirmDialog
* - Run Now button triggers POST + POST
* - Create schedule form open/close
* - Edit schedule form pre-fills values
* - Form validation (disabled when cron/prompt empty)
* - Create POST with correct payload
* - Edit PATCH with correct payload
* - Error state surfaces API failures
* - Auto-refresh every 10s (spy)
* - cronToHuman formatting
* - relativeTime formatting
* - Reset form clears all fields
* - Disabled schedules are visually dimmed
*/
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 { ScheduleTab } from "../ScheduleTab";
// Hoist mocks so vi.mock factory can reference them.
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
const mockPost = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
const mockPatch = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
const mockDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: { get: mockGet, post: mockPost, patch: mockPatch, del: mockDel },
}));
// Capture ConfirmDialog state to drive from tests.
const confirmDialogState = vi.hoisted(
() => ({
open: false as boolean,
onConfirm: undefined as (() => void) | undefined,
onCancel: undefined as (() => void) | undefined,
}),
);
const MockConfirmDialog = vi.hoisted(
() =>
vi.fn(({ open, onConfirm, onCancel }: {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
}) => {
confirmDialogState.open = open;
confirmDialogState.onConfirm = onConfirm;
confirmDialogState.onCancel = onCancel;
return null;
}),
);
vi.mock("@/components/ConfirmDialog", () => ({ ConfirmDialog: MockConfirmDialog }));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const SCHEDULE_FIXTURE = {
id: "sch-1",
workspace_id: "ws-1",
name: "Daily Security Scan",
cron_expr: "0 9 * * *",
timezone: "UTC",
prompt: "Run the security scan and report findings",
enabled: true,
last_run_at: new Date(Date.now() - 3600000).toISOString(),
next_run_at: new Date(Date.now() + 82800000).toISOString(),
run_count: 42,
last_status: "ok",
last_error: "",
created_at: new Date().toISOString(),
};
function schedule(overrides: Partial<typeof SCHEDULE_FIXTURE> = {}): typeof SCHEDULE_FIXTURE {
return { ...SCHEDULE_FIXTURE, ...overrides };
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
function typeIn(el: HTMLElement, value: string) {
Object.defineProperty(el, "value", { value, writable: true, configurable: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fireEvent.change(el as any, { target: el });
}
// Use mockResolvedValue so every GET call (including post-handler refreshes)
// returns the fixture. Handlers like toggle/delete/run/edit all call
// fetchSchedules() at the end, triggering a second GET.
function setupLoad(schedules: unknown[]) {
mockGet.mockResolvedValue(schedules as unknown[]);
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("ScheduleTab", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
mockPatch.mockReset();
mockDel.mockReset();
MockConfirmDialog.mockClear();
vi.useRealTimers();
confirmDialogState.open = false;
confirmDialogState.onConfirm = undefined;
confirmDialogState.onCancel = undefined;
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ── Loading / Empty ──────────────────────────────────────────────────────────
it("shows loading state when schedules are being fetched", async () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<ScheduleTab workspaceId="ws-1" />);
await act(async () => { /* flush initial render */ });
expect(screen.getByText("Loading schedules...")).toBeTruthy();
});
it("shows empty state when API returns an empty list", async () => {
setupLoad([]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("No schedules yet")).toBeTruthy();
expect(screen.getByText(/run tasks automatically/i)).toBeTruthy();
});
// ── Schedule list ────────────────────────────────────────────────────────────
it("renders a schedule with correct name and cron", async () => {
setupLoad([schedule({ name: "Morning Report", cron_expr: "0 8 * * *" })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("Morning Report")).toBeTruthy();
expect(screen.getByText(/Daily at 08:00 UTC/i)).toBeTruthy();
});
it("renders multiple schedules", async () => {
setupLoad([
schedule({ id: "s1", name: "Morning Report", cron_expr: "0 8 * * *" }),
schedule({ id: "s2", name: "Evening Cleanup", cron_expr: "0 22 * * *" }),
]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("Morning Report")).toBeTruthy();
expect(screen.getByText("Evening Cleanup")).toBeTruthy();
});
it("shows disabled schedule with reduced opacity", async () => {
setupLoad([schedule({ enabled: false })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
const container = screen.getByText("Daily Security Scan").closest("div[class*='border-b']");
expect(container?.className).toContain("opacity-50");
});
it("shows error dot when last_status is error", async () => {
setupLoad([schedule({ last_status: "error", last_error: "timeout" })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
const dot = screen.getByRole("button", { name: /click to disable/i });
expect(dot.className).toContain("bg-red-400");
});
it("shows ok dot when last_status is ok", async () => {
setupLoad([schedule({ last_status: "ok" })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
const dot = screen.getByRole("button", { name: /click to disable/i });
expect(dot.className).toContain("bg-emerald-400");
});
it("shows neutral dot when schedule is disabled (unknown status)", async () => {
// enabled=false → title says "Click to enable"
setupLoad([schedule({ enabled: false, last_status: "" })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
const dot = screen.getByRole("button", { name: /click to enable/i });
expect(dot.className).toContain("bg-surface-card");
});
it("shows last_error message when schedule failed", async () => {
setupLoad([schedule({ last_error: "connection refused" })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/Error: connection refused/i)).toBeTruthy();
});
it("truncates long prompt in schedule list", async () => {
const longPrompt = "A".repeat(120);
setupLoad([schedule({ prompt: longPrompt })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
// Prompt is sliced at 80 chars + "..."
expect(screen.getByText(new RegExp(`^${"A".repeat(80)}\\.\\.\\.$$`))).toBeTruthy();
});
// ── cronToHuman formatting ──────────────────────────────────────────────────
it.each([
["* * * * *", "Every minute"],
["*/5 * * * *", "Every 5 minutes"],
["0 */4 * * *", "Every 4 hours"],
["0 9 * * *", "Daily at 09:00 UTC"],
["0 9 * * 1-5", "Weekdays at 09:00 UTC"],
["30 14 * * *", "Daily at 14:30 UTC"],
["*/15 * * * *", "Every 15 minutes"],
])("formats cron '%s' as '%s'", async (cron, expected) => {
setupLoad([schedule({ cron_expr: cron })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(new RegExp(expected, "i"))).toBeTruthy();
});
// ── relativeTime formatting ─────────────────────────────────────────────────
it("shows 'never' when last_run_at is null", async () => {
setupLoad([schedule({ last_run_at: null, next_run_at: null })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
const spans = Array.from(document.querySelectorAll("span"));
expect(spans.some(s => s.textContent === "Last: never")).toBeTruthy();
});
it("shows run_count in the list", async () => {
setupLoad([schedule({ run_count: 99 })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/Runs: 99/i)).toBeTruthy();
});
// ── Toggle ──────────────────────────────────────────────────────────────────
it("PATCHes toggle endpoint when status dot is clicked", async () => {
setupLoad([schedule()]);
mockPatch.mockResolvedValue({});
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /click to disable/i }));
await flush();
expect(mockPatch).toHaveBeenCalledWith(
"/workspaces/ws-1/schedules/sch-1",
{ enabled: false },
);
});
it("toggling calls fetchSchedules to refresh the list", async () => {
setupLoad([schedule()]);
mockPatch.mockResolvedValue({});
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /click to disable/i }));
await flush();
// fetchSchedules calls GET again
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/schedules");
});
it("shows error when toggle fails", async () => {
setupLoad([schedule()]);
mockPatch.mockRejectedValue(new Error("toggle failed"));
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /click to disable/i }));
await flush();
// Component uses e.message (Error.message = "toggle failed")
expect(screen.getByText(/toggle failed/i)).toBeTruthy();
});
// ── Delete ──────────────────────────────────────────────────────────────────
it("opens ConfirmDialog when delete button is clicked", async () => {
setupLoad([schedule()]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
await flush();
expect(confirmDialogState.open).toBe(true);
});
it("calls DEL when ConfirmDialog is confirmed", async () => {
setupLoad([schedule()]);
mockDel.mockResolvedValue({});
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
await flush();
confirmDialogState.onConfirm?.();
await flush();
expect(mockDel).toHaveBeenCalledWith("/workspaces/ws-1/schedules/sch-1");
});
it("calls fetchSchedules after delete", async () => {
setupLoad([schedule()]);
mockDel.mockResolvedValue({});
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
await flush();
confirmDialogState.onConfirm?.();
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/schedules");
});
it("closes ConfirmDialog when cancel is called", async () => {
setupLoad([schedule()]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
await flush();
expect(confirmDialogState.open).toBe(true);
confirmDialogState.onCancel?.();
await flush();
expect(confirmDialogState.open).toBe(false);
});
it("shows error when delete fails", async () => {
setupLoad([schedule()]);
mockDel.mockRejectedValue(new Error("delete failed"));
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
await flush();
confirmDialogState.onConfirm?.();
await flush();
expect(screen.getByText(/delete failed/i)).toBeTruthy();
});
// ── Run Now ──────────────────────────────────────────────────────────────────
it("calls POST /schedules/:id/run and then POST /a2a when Run Now is clicked", async () => {
setupLoad([schedule()]);
mockPost
.mockResolvedValueOnce({ prompt: "Run the security scan and report findings" })
.mockResolvedValueOnce({});
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /run schedule/i }));
await flush();
expect(mockPost).toHaveBeenNthCalledWith(1, "/workspaces/ws-1/schedules/sch-1/run", {});
expect(mockPost).toHaveBeenNthCalledWith(2, "/workspaces/ws-1/a2a", expect.objectContaining({ method: "message/send" }));
});
it("shows error when run now fails", async () => {
setupLoad([schedule()]);
mockPost.mockRejectedValue(new Error("run failed"));
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /run schedule/i }));
await flush();
// handleRunNow uses hardcoded "Failed to run schedule" on error
expect(screen.getByText(/Failed to run schedule/i)).toBeTruthy();
});
// ── Create form ──────────────────────────────────────────────────────────────
it("shows create form when + Add Schedule is clicked", async () => {
setupLoad([]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
expect(screen.getByLabelText("Schedule name")).toBeTruthy();
expect(screen.getByLabelText("Cron Expression")).toBeTruthy();
expect(screen.getByLabelText("Prompt / Task")).toBeTruthy();
});
it("pre-fills default cron (0 9 * * *) and timezone (UTC)", async () => {
setupLoad([]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
expect((screen.getByLabelText("Cron Expression") as HTMLInputElement).value).toBe("0 9 * * *");
expect((screen.getByLabelText("Timezone") as HTMLSelectElement).value).toBe("UTC");
});
it("submit button is disabled when cron or prompt is empty", async () => {
setupLoad([]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
const submitBtn = screen.getByRole("button", { name: /create/i });
expect((submitBtn as HTMLButtonElement).disabled).toBe(true);
});
it("submit button is enabled when cron and prompt are filled", async () => {
setupLoad([]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Run a task");
await flush();
const submitBtn = screen.getByRole("button", { name: /create/i });
expect((submitBtn as HTMLButtonElement).disabled).toBe(false);
});
it("POSTs correct payload when creating a schedule", async () => {
setupLoad([]);
mockPost.mockResolvedValue({});
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
typeIn(screen.getByLabelText("Schedule name") as HTMLElement, "Morning Report");
typeIn(screen.getByLabelText("Cron Expression") as HTMLElement, "0 8 * * *");
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Generate the morning report");
await flush();
act(() => { screen.getByRole("button", { name: /create/i }).click(); });
await flush();
await waitFor(() => {
expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/schedules",
expect.objectContaining({
name: "Morning Report",
cron_expr: "0 8 * * *",
timezone: "UTC",
prompt: "Generate the morning report",
enabled: true,
}),
);
});
it("closes form and refreshes after successful create", async () => {
setupLoad([]);
mockPost.mockResolvedValue({});
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Run a task");
await flush();
act(() => { screen.getByRole("button", { name: /create/i }).click(); });
await flush();
await waitFor(() => {
expect(screen.queryByLabelText("Schedule name")).not.toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/schedules");
});
it("shows error message when create fails", async () => {
setupLoad([]);
mockPost.mockRejectedValue(new Error("validation failed"));
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Run a task");
await flush();
act(() => { screen.getByRole("button", { name: /create/i }).click(); });
await flush();
expect(screen.getByText(/validation failed/i)).toBeTruthy();
});
it("closes form when Cancel is clicked", async () => {
setupLoad([]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
expect(screen.getByLabelText("Schedule name")).toBeTruthy();
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
await flush();
await waitFor(() => {
expect(screen.queryByLabelText("Schedule name")).not.toBeTruthy();
});
});
// ── Edit form ────────────────────────────────────────────────────────────────
it("opens edit form pre-filled with schedule data when Edit is clicked", async () => {
setupLoad([schedule({ name: "Nightly Backup", cron_expr: "0 2 * * *" })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit schedule/i }));
await flush();
expect((screen.getByLabelText("Schedule name") as HTMLInputElement).value).toBe("Nightly Backup");
expect((screen.getByLabelText("Cron Expression") as HTMLInputElement).value).toBe("0 2 * * *");
});
it("shows 'Update' button in edit mode", async () => {
setupLoad([schedule()]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit schedule/i }));
await flush();
expect(screen.getByRole("button", { name: /update/i })).toBeTruthy();
});
it("PATCHes correct payload when updating a schedule", async () => {
setupLoad([schedule()]);
mockPatch.mockResolvedValue({});
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /edit schedule/i }));
await flush();
typeIn(screen.getByLabelText("Schedule name") as HTMLElement, "Updated Name");
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "New prompt");
await flush();
act(() => { screen.getByRole("button", { name: /update/i }).click(); });
await flush();
await waitFor(() => {
expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeTruthy();
});
expect(mockPatch).toHaveBeenCalledWith(
"/workspaces/ws-1/schedules/sch-1",
expect.objectContaining({
name: "Updated Name",
cron_expr: "0 9 * * *",
timezone: "UTC",
prompt: "New prompt",
enabled: true,
}),
);
});
it("form reset clears name, cron, prompt, and enabled", async () => {
setupLoad([schedule()]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
// Open + add schedule form
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
typeIn(screen.getByLabelText("Schedule name") as HTMLElement, "Temp Schedule");
typeIn(screen.getByLabelText("Cron Expression") as HTMLElement, "*/15 * * * *");
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Temporary task");
await flush();
// Cancel
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
await flush();
// Open again — should be reset
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
expect((screen.getByLabelText("Schedule name") as HTMLInputElement).value).toBe("");
expect((screen.getByLabelText("Cron Expression") as HTMLInputElement).value).toBe("0 9 * * *");
expect((screen.getByLabelText("Prompt / Task") as HTMLTextAreaElement).value).toBe("");
});
// ── Error state ──────────────────────────────────────────────────────────────
it("shows error banner when GET fails", async () => {
mockGet.mockRejectedValue(new Error("network error"));
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
// Component now sets error state on GET failure
expect(screen.getByText(/network error/i)).toBeTruthy();
});
it("shows generic error when GET rejects with non-Error", async () => {
mockGet.mockRejectedValue("unknown failure");
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("unknown failure")).toBeTruthy();
});
// ── Auto-refresh ────────────────────────────────────────────────────────────
it("sets up auto-refresh interval of 10 seconds", async () => {
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
setupLoad([schedule()]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000);
setIntervalSpy.mockRestore();
});
it("clears the auto-refresh interval on unmount", async () => {
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
setupLoad([schedule()]);
const { unmount } = render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(clearIntervalSpy).not.toHaveBeenCalled();
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
});
// ── Misc ────────────────────────────────────────────────────────────────────
it("shows no timezone suffix when timezone is UTC", async () => {
setupLoad([schedule({ timezone: "UTC" })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(screen.queryByText(/\(UTC\)/)).not.toBeTruthy();
});
it("shows timezone suffix when non-UTC", async () => {
setupLoad([schedule({ timezone: "America/New_York" })]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/\(America\/New_York\)/)).toBeTruthy();
});
it("checkbox toggles formEnabled state", async () => {
setupLoad([]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
const checkbox = screen.getByRole("checkbox");
expect((checkbox as HTMLInputElement).checked).toBe(true);
fireEvent.click(checkbox);
await flush();
expect((checkbox as HTMLInputElement).checked).toBe(false);
});
it("timezone select updates formTimezone", async () => {
setupLoad([]);
render(<ScheduleTab workspaceId="ws-1" />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
await flush();
fireEvent.change(screen.getByLabelText("Timezone"), { target: { value: "America/Los_Angeles" } });
await flush();
expect((screen.getByLabelText("Timezone") as HTMLSelectElement).value).toBe("America/Los_Angeles");
});
});
@@ -1,408 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for TracesTab — Langfuse trace viewer.
*
* Coverage:
* - Loading state
* - Error state
* - Empty state (no traces)
* - Trace list rendering
* - Expand/collapse rows with aria attributes
* - Status dot colors (ERROR vs success)
* - Latency formatting (ms vs seconds)
* - Token count display
* - Cost display
* - Input/output rendering (string and object)
* - Refresh button
* - formatTime relative timestamps
* - "How to enable tracing" collapsed hint
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TracesTab } from "../TracesTab";
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: { get: mockGet },
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const TRACE_FIXTURE = {
id: "trace-abc123",
name: "security-scan",
timestamp: new Date(Date.now() - 60000).toISOString(),
latency: 450,
input: { query: "scan for vulnerabilities" },
output: { result: "No issues found" },
status: "success",
totalCost: 0.00234,
usage: { input: 120, output: 85, total: 205 },
};
function trace(overrides: Partial<typeof TRACE_FIXTURE> = {}): typeof TRACE_FIXTURE {
return { ...TRACE_FIXTURE, ...overrides };
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// The trace row button's accessible name is "{name} {relativeTime} {latency}{tokCount}".
// Filter all buttons to find the trace row buttons.
function getTraceButtons() {
return screen
.getAllByRole("button")
.filter((b) => b.getAttribute("aria-controls")?.startsWith("trace-detail-"));
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("TracesTab", () => {
beforeEach(() => {
mockGet.mockReset();
vi.useRealTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ── Loading ─────────────────────────────────────────────────────────────────
it("shows loading state when traces are being fetched", async () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<TracesTab workspaceId="ws-1" />);
await act(async () => { /* flush initial render */ });
expect(screen.getByText("Loading traces...")).toBeTruthy();
});
// ── Error ──────────────────────────────────────────────────────────────────
it("shows error banner when GET /traces rejects", async () => {
mockGet.mockRejectedValue(new Error("gateway timeout"));
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/gateway timeout/i)).toBeTruthy();
});
it("shows 'Failed to load traces' when GET rejects with non-Error", async () => {
mockGet.mockRejectedValue("unknown");
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/Failed to load traces/i)).toBeTruthy();
});
// ── Empty state ───────────────────────────────────────────────────────────
it("shows empty state when API returns empty list", async () => {
mockGet.mockResolvedValue({ data: [] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("No traces yet")).toBeTruthy();
});
it("shows 'How to enable tracing' hint under empty state", async () => {
mockGet.mockResolvedValue({ data: [] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/how to enable tracing/i)).toBeTruthy();
expect(screen.getByText(/LANGFUSE_HOST/i)).toBeTruthy();
});
it("hides empty state when error is present", async () => {
mockGet.mockRejectedValue(new Error("error"));
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.queryByText("No traces yet")).toBeFalsy();
});
// ── Trace list ─────────────────────────────────────────────────────────────
it("renders trace name in the list", async () => {
mockGet.mockResolvedValue({ data: [trace({ name: "my-trace" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("my-trace")).toBeTruthy();
});
it("shows trace count in header", async () => {
mockGet.mockResolvedValue({
data: [
trace({ id: "t1" }),
trace({ id: "t2" }),
trace({ id: "t3" }),
],
});
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("3 traces")).toBeTruthy();
});
it("renders multiple traces", async () => {
mockGet.mockResolvedValue({
data: [
trace({ id: "t1", name: "trace-alpha" }),
trace({ id: "t2", name: "trace-beta" }),
],
});
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("trace-alpha")).toBeTruthy();
expect(screen.getByText("trace-beta")).toBeTruthy();
});
it("shows 'trace' when name is empty", async () => {
mockGet.mockResolvedValue({ data: [trace({ name: "" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("trace")).toBeTruthy();
});
// ── Status dot ─────────────────────────────────────────────────────────────
it("applies bg-bad to ERROR traces", async () => {
mockGet.mockResolvedValue({ data: [trace({ status: "ERROR" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
const dot = getTraceButtons()[0].querySelector("div[class*='rounded-full']");
expect(dot?.className).toContain("bg-bad");
});
it("applies bg-good to success traces", async () => {
mockGet.mockResolvedValue({ data: [trace({ status: "success" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
const dot = getTraceButtons()[0].querySelector("div[class*='rounded-full']");
expect(dot?.className).toContain("bg-good");
});
// ── Latency formatting ──────────────────────────────────────────────────────
it("shows latency in milliseconds when < 1000ms", async () => {
mockGet.mockResolvedValue({ data: [trace({ latency: 450 })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("450ms")).toBeTruthy();
});
it("shows latency in seconds when >= 1000ms", async () => {
mockGet.mockResolvedValue({ data: [trace({ latency: 2500 })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("2.5s")).toBeTruthy();
});
it("hides latency when null", async () => {
mockGet.mockResolvedValue({ data: [trace({ latency: undefined })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.queryByText(/ms/)).toBeFalsy();
});
// ── Token count ────────────────────────────────────────────────────────────
it("shows total token count from usage.total", async () => {
mockGet.mockResolvedValue({ data: [trace({ usage: { input: 100, output: 50, total: 150 } })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("150 tok")).toBeTruthy();
});
it("hides token count when usage is undefined", async () => {
mockGet.mockResolvedValue({ data: [trace({ usage: undefined })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.queryByText(/tok/)).toBeFalsy();
});
// ── Expand/collapse ─────────────────────────────────────────────────────────
it("shows '▶' when trace is collapsed", async () => {
mockGet.mockResolvedValue({ data: [trace()] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("▶")).toBeTruthy();
});
it("shows '▼' when trace is expanded", async () => {
mockGet.mockResolvedValue({ data: [trace()] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
act(() => { getTraceButtons()[0].click(); });
await flush();
expect(screen.getByText("▼")).toBeTruthy();
});
it("shows '▼' when all traces are collapsed", async () => {
mockGet.mockResolvedValue({ data: [trace()] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.queryByText("▼")).toBeFalsy();
expect(screen.getByText("▶")).toBeTruthy();
});
it("shows input/output panel when trace is expanded", async () => {
mockGet.mockResolvedValue({ data: [trace()] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
act(() => { getTraceButtons()[0].click(); });
await flush();
expect(screen.getByText(/INPUT/i)).toBeTruthy();
expect(screen.getByText(/OUTPUT/i)).toBeTruthy();
});
it("shows JSON stringified input when input is an object", async () => {
mockGet.mockResolvedValue({ data: [trace({ input: { query: "test" } })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
act(() => { getTraceButtons()[0].click(); });
await flush();
expect(screen.getByText(/"query": "test"/)).toBeTruthy();
});
it("shows raw string when input is a string", async () => {
mockGet.mockResolvedValue({ data: [trace({ input: "plain text input" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
act(() => { getTraceButtons()[0].click(); });
await flush();
expect(screen.getByText("plain text input")).toBeTruthy();
});
it("shows trace ID in expanded panel", async () => {
mockGet.mockResolvedValue({ data: [trace({ id: "trace-xyz-999" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
act(() => { getTraceButtons()[0].click(); });
await flush();
expect(screen.getByText("trace-xyz-999")).toBeTruthy();
});
it("shows cost when totalCost is present", async () => {
mockGet.mockResolvedValue({ data: [trace({ totalCost: 0.001234 })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
act(() => { getTraceButtons()[0].click(); });
await flush();
expect(screen.getByText(/\$0.001234/)).toBeTruthy();
});
it("hides cost section when totalCost is null", async () => {
mockGet.mockResolvedValue({ data: [trace({ totalCost: undefined })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
act(() => { getTraceButtons()[0].click(); });
await flush();
expect(screen.queryByText(/cost/i)).toBeFalsy();
});
it("has aria-expanded=true on expanded row", async () => {
mockGet.mockResolvedValue({ data: [trace()] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
const btn = getTraceButtons()[0];
expect(btn.getAttribute("aria-expanded")).toBe("false");
act(() => { btn.click(); });
await flush();
expect(btn.getAttribute("aria-expanded")).toBe("true");
});
it("has aria-expanded=false on collapsed row", async () => {
mockGet.mockResolvedValue({ data: [trace()] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(getTraceButtons()[0].getAttribute("aria-expanded")).toBe("false");
});
it("has aria-controls linking row to its detail panel", async () => {
mockGet.mockResolvedValue({ data: [trace({ id: "trace-abc123" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(getTraceButtons()[0].getAttribute("aria-controls")).toBe("trace-detail-trace-abc123");
});
// ── Refresh ────────────────────────────────────────────────────────────────
it("Refresh button triggers a new GET", async () => {
mockGet.mockResolvedValue({ data: [trace()] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
mockGet.mockClear();
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/traces");
});
// ── formatTime ─────────────────────────────────────────────────────────────
it("shows 'Xs ago' for traces under 1 minute", async () => {
const timestamp = new Date(Date.now() - 30_000).toISOString();
mockGet.mockResolvedValue({ data: [trace({ timestamp, id: "t-30s" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
// 30s ago
expect(screen.getByText(/\d+s ago/)).toBeTruthy();
});
it("shows 'Xm ago' for traces under 1 hour", async () => {
const timestamp = new Date(Date.now() - 120_000).toISOString();
mockGet.mockResolvedValue({ data: [trace({ timestamp, id: "t-2m" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/\dm ago/)).toBeTruthy();
});
it("shows 'Xh ago' for traces under 1 day", async () => {
const timestamp = new Date(Date.now() - 3_600_000).toISOString();
mockGet.mockResolvedValue({ data: [trace({ timestamp, id: "t-1h" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/\dh ago/)).toBeTruthy();
});
it("shows locale date for traces older than 24 hours", async () => {
const oldDate = new Date(Date.now() - 172_800_000);
mockGet.mockResolvedValue({ data: [trace({ timestamp: oldDate.toISOString(), id: "t-old" })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(oldDate.toLocaleDateString())).toBeTruthy();
});
// ── Edge cases ─────────────────────────────────────────────────────────────
it("handles traces with no input or output", async () => {
mockGet.mockResolvedValue({ data: [trace({ input: undefined, output: undefined })] });
render(<TracesTab workspaceId="ws-1" />);
await flush();
act(() => { getTraceButtons()[0].click(); });
await flush();
expect(screen.queryByText(/INPUT/i)).toBeFalsy();
expect(screen.queryByText(/OUTPUT/i)).toBeFalsy();
});
it("shows only one expanded trace at a time", async () => {
mockGet.mockResolvedValue({
data: [
trace({ id: "t1", name: "Alpha" }),
trace({ id: "t2", name: "Beta" }),
],
});
render(<TracesTab workspaceId="ws-1" />);
await flush();
const [btn1, btn2] = getTraceButtons();
act(() => { btn1.click(); });
await flush();
expect(btn1.getAttribute("aria-expanded")).toBe("true");
expect(btn2.getAttribute("aria-expanded")).toBe("false");
act(() => { btn2.click(); });
await flush();
expect(btn1.getAttribute("aria-expanded")).toBe("false");
expect(btn2.getAttribute("aria-expanded")).toBe("true");
});
});
@@ -1,140 +0,0 @@
// @vitest-environment jsdom
/**
* Unit tests for extractSkills — pure helper from SkillsTab.
*
* Covers: null card, non-array skills, empty skills, full skill entries
* (id, name, description, tags, examples), id-only fallback, name-only
* fallback, string coercion, array coercion for tags/examples,
* filtering entries with no id after coercion, empty string id (filtered).
*/
import { describe, it, expect } from "vitest";
import { extractSkills } from "../SkillsTab";
describe("extractSkills", () => {
it("returns [] for null card", () => {
expect(extractSkills(null)).toEqual([]);
});
it("returns [] when card.skills is not an array", () => {
expect(extractSkills({ skills: undefined })).toEqual([]);
expect(extractSkills({ skills: "not-an-array" })).toEqual([]);
expect(extractSkills({ skills: { id: "x" } })).toEqual([]);
});
it("returns [] for empty skills array", () => {
expect(extractSkills({ skills: [] })).toEqual([]);
});
it("maps a fully-populated skill entry", () => {
const card = {
skills: [
{
id: "code_search",
name: "Code Search",
description: "Semantic code search",
tags: ["search", "code"],
examples: ["Find unused exports", "Search by AST pattern"],
},
],
};
expect(extractSkills(card)).toEqual([
{
id: "code_search",
name: "Code Search",
description: "Semantic code search",
tags: ["search", "code"],
examples: ["Find unused exports", "Search by AST pattern"],
},
]);
});
it("uses name as id when id is absent", () => {
const card = { skills: [{ name: "web_scraper" }] };
expect(extractSkills(card)).toEqual([
{ id: "web_scraper", name: "web_scraper", description: "", tags: [], examples: [] },
]);
});
it("uses id as name when name is absent", () => {
const card = { skills: [{ id: "legacy_skill" }] };
expect(extractSkills(card)).toEqual([
{ id: "legacy_skill", name: "legacy_skill", description: "", tags: [], examples: [] },
]);
});
it("filters out entries with neither id nor name", () => {
// id: String(undefined || undefined || "") → "" → filtered (id.length = 0)
const card = { skills: [{ description: "orphan entry" }] };
expect(extractSkills(card)).toEqual([]);
});
it("filters out entries with no id after string coercion", () => {
// id resolves to "" after String(undefined || null || {})
const card = { skills: [{ id: null, name: null }] };
expect(extractSkills(card)).toEqual([]);
});
it("filters out entries with empty-string id", () => {
const card = { skills: [{ id: "", name: "" }] };
expect(extractSkills(card)).toEqual([]);
});
it("coerces numeric tags to strings", () => {
const card = { skills: [{ id: "x", tags: [1, "two", 3] }] };
expect(extractSkills(card)).toEqual([
{ id: "x", name: "x", description: "", tags: ["1", "two", "3"], examples: [] },
]);
});
it("coerces non-array tags to empty array", () => {
const card = { skills: [{ id: "x", tags: "not-an-array" }] };
expect(extractSkills(card)).toEqual([
{ id: "x", name: "x", description: "", tags: [], examples: [] },
]);
});
it("coerces non-array examples to empty array", () => {
const card = { skills: [{ id: "x", examples: 42 }] };
expect(extractSkills(card)).toEqual([
{ id: "x", name: "x", description: "", tags: [], examples: [] },
]);
});
// NOTE: extractSkills uses `String(skill.description || "")` — falsy values
// (0, null, false) fall through to "", NOT to their string form.
it("returns '' for falsy description values (0, null, false)", () => {
const card = { skills: [{ id: "x", description: 0 }] };
expect(extractSkills(card)).toEqual([
{ id: "x", name: "x", description: "", tags: [], examples: [] },
]);
});
it("handles mixed valid/invalid entries", () => {
const card = {
skills: [
{ id: "valid_one", name: "One" },
{ name: "named_only" },
{ description: "orphan" }, // filtered — id becomes ""
{ id: "valid_two", examples: ["a", "b"] },
],
};
expect(extractSkills(card)).toEqual([
{ id: "valid_one", name: "One", description: "", tags: [], examples: [] },
{ id: "named_only", name: "named_only", description: "", tags: [], examples: [] },
{ id: "valid_two", name: "valid_two", description: "", tags: [], examples: ["a", "b"] },
]);
});
it("handles a realistic agent card with multiple skills", () => {
const card = {
skills: [
{ id: "web_search", name: "Web Search", description: "Search the web", tags: ["search"], examples: ["Latest news"] },
{ id: "file_read", name: "Read Files", description: "Read from disk", tags: ["io"], examples: [] },
],
};
const result = extractSkills(card);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("web_search");
expect(result[1].tags).toEqual(["io"]);
});
});
@@ -1,95 +0,0 @@
// @vitest-environment jsdom
/**
* Unit tests for getSkills — pure helper from DetailsTab.
*
* Covers: null card, non-array skills, empty skills, id-only entries,
* name-only entries (id derives from name), entries with description,
* entries with neither id nor name (filtered out), mixed entries.
*/
import { describe, it, expect } from "vitest";
import { getSkills } from "../DetailsTab";
describe("getSkills", () => {
it("returns [] for null card", () => {
expect(getSkills(null)).toEqual([]);
});
it("returns [] when card.skills is not an array", () => {
expect(getSkills({ skills: undefined })).toEqual([]);
expect(getSkills({ skills: "not-an-array" })).toEqual([]);
expect(getSkills({ skills: { id: "x" } })).toEqual([]);
});
it("returns [] for empty skills array", () => {
expect(getSkills({ skills: [] })).toEqual([]);
});
it("maps skill with id and description", () => {
const card = { skills: [{ id: "code_search", description: "Find code patterns" }] };
expect(getSkills(card)).toEqual([{ id: "code_search", description: "Find code patterns" }]);
});
it("maps skill with id only (description absent)", () => {
const card = { skills: [{ id: "code_search" }] };
expect(getSkills(card)).toEqual([{ id: "code_search", description: undefined }]);
});
it("derives id from name when id is absent", () => {
const card = { skills: [{ name: "web_scraper" }] };
expect(getSkills(card)).toEqual([{ id: "web_scraper" }]);
});
it("maps description when present", () => {
const card = { skills: [{ id: "file_write", description: "Writes files to disk" }] };
expect(getSkills(card)).toEqual([{ id: "file_write", description: "Writes files to disk" }]);
});
it("returns description as undefined when skill has no description", () => {
const card = { skills: [{ id: "noop_skill" }] };
const result = getSkills(card);
// The map always includes description; it's undefined when absent
expect(result).toEqual([{ id: "noop_skill", description: undefined }]);
});
it("filters out skills with neither id nor name", () => {
// id: String(undefined || undefined || "") → "" → filtered
const card = { skills: [{ description: "loner" }] };
expect(getSkills(card)).toEqual([]);
});
it("handles mixed valid/invalid entries", () => {
const card = {
skills: [
{ id: "valid_one" },
{ name: "named_skill" },
{ description: "orphaned" }, // filtered
{ id: "valid_two", description: "Has both" },
],
};
expect(getSkills(card)).toEqual([
{ id: "valid_one", description: undefined },
{ id: "named_skill", description: undefined },
{ id: "valid_two", description: "Has both" },
]);
});
it("handles string coercion for numeric ids/names", () => {
const card = { skills: [{ id: 42, name: "numeric_id" }] };
expect(getSkills(card)).toEqual([{ id: "42" }]);
});
it("uses id over name when both are present", () => {
const card = { skills: [{ id: "priority_id", name: "fallback_name" }] };
expect(getSkills(card)).toEqual([{ id: "priority_id", description: undefined }]);
});
it("omits description when it is falsy (0 is falsy in JS)", () => {
// The implementation uses `s.description ?` — 0 is falsy, so it's treated
// as absent and undefined is returned. Non-zero numbers coerce fine.
const cardZero = { skills: [{ id: "x", description: 0 }] };
expect(getSkills(cardZero)).toEqual([{ id: "x", description: undefined }]);
const cardNum = { skills: [{ id: "x", description: 42 }] };
expect(getSkills(cardNum)).toEqual([{ id: "x", description: "42" }]);
});
});
@@ -1,257 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AttachmentAudio — inline native <audio controls> player.
*
* Per RFC #2991 PR-2. Dispatches from AttachmentPreview so most paths
* are pinned there. These tests cover AttachmentAudio as a standalone
* renderer: loading skeleton, ready <audio>, chip-error fallback, and
* tone=user vs tone=agent styling.
*
* NOTE: No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks.
*/
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import React from "react";
import { AttachmentAudio } from "../AttachmentAudio";
import type { ChatAttachment } from "../types";
afterEach(cleanup);
// Stub env token so platformAuthHeaders() is callable without a real env.
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
global.URL.createObjectURL = vi.fn(() => "blob:audio-test");
global.URL.revokeObjectURL = vi.fn();
});
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeAtt(name = "recording.mp3"): ChatAttachment {
return { name, uri: "workspace:/workspace/tmp/" + name, mimeType: "audio/mpeg" };
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("AttachmentAudio", () => {
it("renders loading skeleton (idle) before fetch resolves", () => {
// Never-resolving fetch → component stays in loading/idle state.
fetchMock.mockReturnValue(new Promise(() => {}));
render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt()}
onDownload={vi.fn()}
tone="agent"
/>,
);
const skeleton = screen.getByLabelText(/Loading recording\.mp3/i);
expect(skeleton).toBeTruthy();
expect(skeleton.className).toContain("animate-pulse");
});
it("renders loading skeleton during loading state", async () => {
fetchMock.mockReturnValue(
new Promise<Response>(() => {}), // hangs forever
);
render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("song.wav")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByLabelText(/Loading song\.wav/i)).toBeTruthy();
});
});
it("renders <audio controls> when fetch succeeds", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["fake-mp3-bytes"], { type: "audio/mpeg" }),
});
render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("podcast.mp3")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const audio = document.querySelector("audio");
expect(audio).not.toBeNull();
expect(audio?.hasAttribute("controls")).toBe(true);
});
});
it("audio src is the blob URL minted from response", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["bytes"], { type: "audio/mp3" }),
});
render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("track.mp3")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const audio = document.querySelector("audio") as HTMLAudioElement;
expect(audio?.src).toBe("blob:audio-test");
});
});
it("renders filename label above the audio element", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "audio/mpeg" }),
});
render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("voice-note.mp3")}
onDownload={vi.fn()}
tone="agent"
/>,
);
// Wait for the ready state (audio element present), then verify the
// filename label <span> is in the DOM.
await waitFor(() => {
expect(document.querySelector("audio")).not.toBeNull();
});
const labelSpan = document.querySelector(
`span[title="voice-note.mp3"]`,
);
expect(labelSpan).not.toBeNull();
expect(labelSpan?.textContent).toBe("voice-note.mp3");
});
it("fetch 404 → renders AttachmentChip (chip error fallback)", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("missing.mp3")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.mp3/i)).toBeTruthy();
});
// <audio> must NOT appear when chip is shown.
expect(document.querySelector("audio")).toBeNull();
});
it("fetch network error → chip error fallback", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("offline.mp3")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download offline\.mp3/i)).toBeTruthy();
});
});
it("tone=user applies blue border class on ready-state container", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "audio/mpeg" }),
});
const { container } = render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("blue.mp3")}
onDownload={vi.fn()}
tone="user"
/>,
);
await waitFor(() => {
expect(document.querySelector("audio")).not.toBeNull();
});
// The outer ready-state <div> must contain blue-400 class when tone=user.
const readyDivs = Array.from(container.querySelectorAll("div")).filter(
(d) => d.className.includes("blue-400"),
);
expect(readyDivs.length).toBeGreaterThan(0);
});
it("tone=agent does not apply blue border class", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "audio/mpeg" }),
});
const { container } = render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("gray.mp3")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(document.querySelector("audio")).not.toBeNull();
});
const blueDivs = Array.from(container.querySelectorAll("div")).filter(
(d) => d.className.includes("blue-400"),
);
expect(blueDivs).toHaveLength(0);
});
it("onDownload is NOT called during loading or ready states", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "audio/mpeg" }),
});
const onDownload = vi.fn();
render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("quiet.mp3")}
onDownload={onDownload}
tone="agent"
/>,
);
// Wait for ready state — onDownload must not have been called.
await waitFor(() => {
expect(document.querySelector("audio")).not.toBeNull();
});
expect(onDownload).not.toHaveBeenCalled();
});
it("calls onDownload when chip fallback is rendered (error state)", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500 });
const onDownload = vi.fn();
render(
<AttachmentAudio
workspaceId="ws-1"
attachment={makeAtt("fail.mp3")}
onDownload={onDownload}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download fail\.mp3/i)).toBeTruthy();
});
// Click the chip's download button.
screen.getByTitle(/Download fail\.mp3/i).click();
expect(onDownload).toHaveBeenCalledWith(
expect.objectContaining({ name: "fail.mp3" }),
);
});
});
@@ -1,303 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AttachmentImage — inline image thumbnail with click-to-fullscreen.
*
* Per RFC #2991 PR-1. Loading skeleton, ready state renders a
* clickable image that opens AttachmentLightbox, chip error fallback,
* external URI (no-fetch path), tone=user/agent styling, and cleanup
* on unmount.
*
* NOTE: No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, cleanup, waitFor, act, fireEvent } from "@testing-library/react";
import React from "react";
import { AttachmentImage } from "../AttachmentImage";
import type { ChatAttachment } from "./types";
afterEach(cleanup);
// Stub env token so platformAuthHeaders() is callable without a real env.
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
global.URL.createObjectURL = vi.fn(() => "blob:image-test");
global.URL.revokeObjectURL = vi.fn();
});
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeAtt(name = "photo.jpg"): ChatAttachment {
return { name, uri: "workspace:/workspace/tmp/" + name, mimeType: "image/jpeg" };
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("AttachmentImage", () => {
// ── idle / loading skeleton ───────────────────────────────────────────────
it("renders loading skeleton (idle state) before fetch resolves", () => {
fetchMock.mockReturnValue(new Promise(() => {})); // hangs forever
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt()}
onDownload={vi.fn()}
tone="agent"
/>,
);
const skeleton = screen.getByLabelText(/Loading photo\.jpg/i);
expect(skeleton).toBeTruthy();
expect(skeleton.className).toContain("animate-pulse");
});
it("renders loading skeleton (loading state)", async () => {
fetchMock.mockReturnValue(new Promise<Response>(() => {})); // hangs forever
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("screenshot.png")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByLabelText(/Loading screenshot\.png/i)).toBeTruthy();
});
});
// ── error fallback ───────────────────────────────────────────────────────
it("renders AttachmentChip when fetch fails (404)", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("missing.jpg")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.jpg/i)).toBeTruthy();
});
// <img> must NOT appear when chip is shown.
expect(document.querySelector("img")).toBeNull();
});
it("renders chip on network error", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("offline.png")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download offline\.png/i)).toBeTruthy();
});
});
// ── ready / <img> ───────────────────────────────────────────────────────
it("renders a button when ready (the image preview trigger)", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["fake-image-bytes"], { type: "image/jpeg" }),
});
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("avatar.jpg")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const btn = document.querySelector(`button[aria-label="Open avatar.jpg preview"]`);
expect(btn).not.toBeNull();
});
});
it("ready button contains an <img> element with blob src", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "image/jpeg" }),
});
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("thumb.jpg")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const img = document.querySelector(`button img`) as HTMLImageElement;
expect(img).not.toBeNull();
expect(img?.src).toBe("blob:image-test");
});
});
it("clicking the ready button opens the lightbox with the full <img>", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "image/jpeg" }),
});
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("lightbox.jpg")}
onDownload={vi.fn()}
tone="agent"
/>,
);
const btn = await screen.findByRole("button", { name: /open lightbox\.jpg preview/i });
fireEvent.click(btn);
// Lightbox renders via portal; <img> inside lightbox should have the blob URL.
await waitFor(() => {
// The lightbox <img> has class "max-w-[95vw] max-h-[90vh] object-contain".
const lightboxImg = Array.from(document.querySelectorAll("img")).find(
(img) => img.className.includes("object-contain"),
);
expect(lightboxImg).not.toBeNull();
expect(lightboxImg?.src).toBe("blob:image-test");
});
});
// ── external URI (no-fetch path) ─────────────────────────────────────────
it("skips fetch and renders image directly for external URIs", async () => {
render(
<AttachmentImage
workspaceId="ws-1"
attachment={{ name: "cdn.jpg", uri: "https://example.com/photo.jpg" }}
onDownload={vi.fn()}
tone="agent"
/>,
);
// No URL.revokeObjectURL call since we never minted a blob.
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
await waitFor(() => {
const btn = document.querySelector(`button[aria-label="Open cdn.jpg preview"]`);
expect(btn).not.toBeNull();
});
});
// ── tone styling ─────────────────────────────────────────────────────────
it("tone=user applies blue border class on ready-state button", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "image/jpeg" }),
});
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("blue.jpg")}
onDownload={vi.fn()}
tone="user"
/>,
);
const btn = await screen.findByRole("button", { name: /open blue\.jpg preview/i });
expect(btn.className).toContain("blue-400");
});
it("tone=agent does not apply blue border class", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "image/jpeg" }),
});
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("gray.jpg")}
onDownload={vi.fn()}
tone="agent"
/>,
);
const btn = await screen.findByRole("button", { name: /open gray\.jpg preview/i });
expect(btn.className).not.toContain("blue-400");
});
// ── download buttons ──────────────────────────────────────────────────────
it("onDownload is NOT called during loading or ready states", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "image/jpeg" }),
});
const onDownload = vi.fn();
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("quiet.jpg")}
onDownload={onDownload}
tone="agent"
/>,
);
await waitFor(() => {
expect(
document.querySelector(`button[aria-label="Open quiet.jpg preview"]`),
).not.toBeNull();
});
expect(onDownload).not.toHaveBeenCalled();
});
it("onDownload fires when chip fallback is rendered (error state)", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500 });
const onDownload = vi.fn();
render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("fail.jpg")}
onDownload={onDownload}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download fail\.jpg/i)).toBeTruthy();
});
screen.getByTitle(/Download fail\.jpg/i).click();
expect(onDownload).toHaveBeenCalledWith(
expect.objectContaining({ name: "fail.jpg" }),
);
});
// ── cleanup ─────────────────────────────────────────────────────────────
it("no state update after unmount (cancelled flag prevents setState)", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: () =>
new Promise<Blob>((resolve) =>
setTimeout(() => resolve(new Blob(["delayed"])), 100),
),
});
const { unmount } = render(
<AttachmentImage
workspaceId="ws-1"
attachment={makeAtt("cleanup.jpg")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await act(async () => {
unmount();
});
// No preview button visible after unmount.
expect(
document.querySelector(`button[aria-label="Open cleanup.jpg preview"]`),
).toBeNull();
expect(
document.querySelector('[aria-label*="Download cleanup.jpg"]'),
).toBeNull();
});
});
@@ -1,191 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AttachmentLightbox — fullscreen modal for image/PDF/video previews.
*
* Coverage:
* - Does not render when open=false
* - Renders when open=true
* - Renders children inside dialog
* - Close button present and calls onClose
* - Escape key calls onClose
* - Backdrop click calls onClose
* - Content click does NOT call onClose
* - role=dialog and aria-modal=true
* - aria-label passed through correctly
* - Focus moves to close button on open
* - Focus is not restored to closed element after unmount
* - prefers-reduced-motion class applied
* - Renders with image child correctly
* - onClose is not called twice when Escape pressed twice rapidly
*/
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";
const defaultProps = {
open: true,
onClose: vi.fn(),
ariaLabel: "Preview of report.png",
children: <img src="blob:test" alt="report" />,
};
function renderLightbox(props = {}) {
return render(<AttachmentLightbox {...defaultProps} {...props} />);
}
afterEach(() => {
cleanup();
defaultProps.onClose.mockClear();
});
describe("AttachmentLightbox — render", () => {
it("does not render when open=false", () => {
renderLightbox({ open: false });
expect(screen.queryByRole("dialog")).toBeFalsy();
});
it("renders when open=true", () => {
renderLightbox({ open: true });
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("passes aria-label to dialog", () => {
renderLightbox({ ariaLabel: "Preview of document.pdf" });
expect(screen.getByRole("dialog").getAttribute("aria-label")).toBe(
"Preview of document.pdf",
);
});
it("has aria-modal='true' for WCAG 2.1 SC 1.3.2", () => {
renderLightbox();
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
});
it("renders children inside the dialog", () => {
renderLightbox({ children: <img src="blob:test" alt="test" /> });
expect(screen.getByRole("dialog").querySelector("img")).toBeTruthy();
});
it("renders close button with aria-label", () => {
renderLightbox();
expect(screen.getByRole("button", { name: "Close preview" })).toBeTruthy();
});
it("applies reduced-motion class when prefers-reduced-motion is set", () => {
const utils = renderLightbox();
const dialog = screen.getByRole("dialog");
// The component applies motion-reduce:transition-none class
expect(dialog.className).toContain("motion-reduce");
});
});
describe("AttachmentLightbox — close interactions", () => {
it("calls onClose when close button is clicked", () => {
renderLightbox();
fireEvent.click(screen.getByRole("button", { name: "Close preview" }));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when Escape is pressed", () => {
renderLightbox();
fireEvent.keyDown(document, { key: "Escape" });
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when backdrop is clicked", () => {
renderLightbox();
const dialog = screen.getByRole("dialog");
// The backdrop is the outer div (the dialog itself), content click has stopPropagation
fireEvent.click(dialog);
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("does NOT call onClose when content area is clicked", () => {
renderLightbox({ children: <img src="blob:test" alt="test" /> });
const dialog = screen.getByRole("dialog");
// The inner div has onClick stopPropagation — clicking it should not close
const innerDiv = dialog.querySelector(".max-w-\\[95vw\\]");
fireEvent.click(innerDiv!);
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
it("Escape calls onClose even when focus is on close button", () => {
renderLightbox();
const closeBtn = screen.getByRole("button", { name: "Close preview" });
closeBtn.focus();
fireEvent.keyDown(document, { key: "Escape" });
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("close button click does not also trigger document-level Escape handler", () => {
renderLightbox();
const closeBtn = screen.getByRole("button", { name: "Close preview" });
// Clicking the button fires onClose; document Escape is a separate listener
// Both should work independently
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.click(closeBtn);
expect(defaultProps.onClose).toHaveBeenCalledTimes(2);
});
});
describe("AttachmentLightbox — focus management", () => {
it("close button is focusable after open", () => {
renderLightbox();
const closeBtn = screen.getByRole("button", { name: "Close preview" });
expect(closeBtn).toBe(document.activeElement);
});
it("Escape is listened on document (not just the modal)", () => {
renderLightbox();
// Focus on body — not on any dialog element
document.body.focus();
fireEvent.keyDown(document, { key: "Escape" });
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("multiple Escape presses call onClose multiple times", () => {
renderLightbox();
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.keyDown(document, { key: "Escape" });
// Each Escape press fires a separate event
expect(defaultProps.onClose).toHaveBeenCalledTimes(2);
});
});
describe("AttachmentLightbox — structural", () => {
it("close button is positioned top-right via CSS class", () => {
renderLightbox();
const closeBtn = screen.getByRole("button", { name: "Close preview" });
expect(closeBtn.className).toContain("top-4");
expect(closeBtn.className).toContain("right-4");
});
it("SVG icon is rendered inside close button", () => {
renderLightbox();
const closeBtn = screen.getByRole("button", { name: "Close preview" });
expect(closeBtn.querySelector("svg")).toBeTruthy();
// X mark path
const path = closeBtn.querySelector("path");
expect(path?.getAttribute("d")).toContain("M5 5");
expect(path?.getAttribute("d")).toContain("M19 5");
});
it("renders with video child", () => {
renderLightbox({
ariaLabel: "Preview of video.mp4",
children: (
<video>
<source src="blob:test-video" />
</video>
),
});
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByRole("dialog").querySelector("video")).toBeTruthy();
});
it("renders with no children (empty preview)", () => {
renderLightbox({ children: null, ariaLabel: "Empty preview" });
expect(screen.getByRole("dialog")).toBeTruthy();
});
});
@@ -1,336 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AttachmentPDF — inline PDF preview using browser's native viewer.
*
* Per RFC #2991 PR-3. Loading skeleton pill, ready state renders a
* clickable PDF pill that opens AttachmentLightbox with <embed>, chip error
* fallback, external URI (no-fetch path), tone=user/agent styling, and
* cleanup on unmount.
*
* NOTE: No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, cleanup, waitFor, act, fireEvent } from "@testing-library/react";
import React from "react";
import { AttachmentPDF } from "../AttachmentPDF";
import type { ChatAttachment } from "./types";
afterEach(cleanup);
// Stub env token so platformAuthHeaders() is callable without a real env.
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
global.URL.createObjectURL = vi.fn(() => "blob:pdf-test");
global.URL.revokeObjectURL = vi.fn();
});
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeAtt(name = "doc.pdf"): ChatAttachment {
return { name, uri: "workspace:/workspace/tmp/" + name, mimeType: "application/pdf" };
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("AttachmentPDF", () => {
// ── idle / loading skeleton ───────────────────────────────────────────────
it("renders loading skeleton pill (idle state) before fetch resolves", () => {
fetchMock.mockReturnValue(new Promise(() => {})); // hangs forever
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt()}
onDownload={vi.fn()}
tone="agent"
/>,
);
// Pill must contain filename and "Loading …" text.
const pill = screen.getByLabelText(/Loading doc\.pdf/i);
expect(pill).toBeTruthy();
expect(pill.className).toContain("animate-pulse");
});
it("renders loading skeleton pill (loading state)", async () => {
fetchMock.mockReturnValue(new Promise<Response>(() => {})); // hangs forever
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("report.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByLabelText(/Loading report\.pdf/i)).toBeTruthy();
});
});
// ── error fallback ───────────────────────────────────────────────────────
it("renders AttachmentChip when fetch fails (404)", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("missing.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.pdf/i)).toBeTruthy();
});
// <embed> must NOT appear when chip is shown.
expect(document.querySelector("embed")).toBeNull();
});
it("renders chip on network error", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("offline.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download offline\.pdf/i)).toBeTruthy();
});
});
// ── ready / PDF pill ─────────────────────────────────────────────────────
it("renders a button when ready (the PDF preview pill)", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["fake-pdf-bytes"], { type: "application/pdf" }),
});
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("readme.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const btn = document.querySelector(`button[aria-label="Open readme.pdf preview"]`);
expect(btn).not.toBeNull();
});
});
it("ready button contains the filename text", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "application/pdf" }),
});
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("annual-report.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const btn = document.querySelector(`button[aria-label="Open annual-report.pdf preview"]`);
expect(btn?.textContent).toContain("annual-report.pdf");
});
});
it("ready button contains PDF badge", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "application/pdf" }),
});
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("badge.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const btn = document.querySelector(`button[aria-label="Open badge.pdf preview"]`);
expect(btn?.textContent).toContain("PDF");
});
});
it("clicking the ready button opens the lightbox with <embed>", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "application/pdf" }),
});
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("click.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
const btn = await screen.findByRole("button", { name: /open click\.pdf preview/i });
fireEvent.click(btn);
// Lightbox should now contain an <embed> with the blob URL.
await waitFor(() => {
const embed = document.querySelector("embed");
expect(embed).not.toBeNull();
expect(embed?.getAttribute("type")).toBe("application/pdf");
});
});
it("lightbox <embed> has correct aria-label", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "application/pdf" }),
});
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("labeled.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
const btn = await screen.findByRole("button", { name: /open labeled\.pdf preview/i });
fireEvent.click(btn);
await waitFor(() => {
const embed = document.querySelector("embed");
expect(embed?.getAttribute("aria-label")).toBe("labeled.pdf");
});
});
// ── external URI (no-fetch path) ─────────────────────────────────────────
it("skips fetch and renders PDF pill directly for external URIs", async () => {
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={{ name: "cdn.pdf", uri: "https://example.com/doc.pdf" }}
onDownload={vi.fn()}
tone="agent"
/>,
);
// No URL.revokeObjectURL call since we never minted a blob.
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
await waitFor(() => {
const btn = document.querySelector(`button[aria-label="Open cdn.pdf preview"]`);
expect(btn).not.toBeNull();
});
});
// ── tone styling ─────────────────────────────────────────────────────────
it("tone=user applies blue accent class on ready-state button", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "application/pdf" }),
});
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("blue.pdf")}
onDownload={vi.fn()}
tone="user"
/>,
);
const btn = await screen.findByRole("button", { name: /open blue\.pdf preview/i });
expect(btn.className).toContain("blue-400");
});
it("tone=agent does not apply blue accent class", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "application/pdf" }),
});
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("gray.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
const btn = await screen.findByRole("button", { name: /open gray\.pdf preview/i });
expect(btn.className).not.toContain("blue-400");
});
// ── download buttons ──────────────────────────────────────────────────────
it("onDownload is NOT called during loading or ready states", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "application/pdf" }),
});
const onDownload = vi.fn();
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("quiet.pdf")}
onDownload={onDownload}
tone="agent"
/>,
);
await waitFor(() => {
expect(document.querySelector(`button[aria-label="Open quiet.pdf preview"]`)).not.toBeNull();
});
expect(onDownload).not.toHaveBeenCalled();
});
it("onDownload fires when chip fallback is rendered (error state)", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500 });
const onDownload = vi.fn();
render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("fail.pdf")}
onDownload={onDownload}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download fail\.pdf/i)).toBeTruthy();
});
screen.getByTitle(/Download fail\.pdf/i).click();
expect(onDownload).toHaveBeenCalledWith(
expect.objectContaining({ name: "fail.pdf" }),
);
});
// ── cleanup ─────────────────────────────────────────────────────────────
it("no state update after unmount (cancelled flag prevents setState)", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: () =>
new Promise<Blob>((resolve) =>
setTimeout(() => resolve(new Blob(["delayed"])), 100),
),
});
const { unmount } = render(
<AttachmentPDF
workspaceId="ws-1"
attachment={makeAtt("cleanup.pdf")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await act(async () => {
unmount();
});
// No embed element visible after unmount.
expect(document.querySelector("embed")).toBeNull();
expect(
document.querySelector('[aria-label*="Download cleanup.pdf"]'),
).toBeNull();
});
});
@@ -1,299 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AttachmentTextPreview — inline <pre><code> text file renderer.
*
* Per RFC #2991 PR-3. Manages its own fetch cycle (idle → loading →
* ready/error). Covers: loading skeleton, <pre><code> render, chip error
* fallback, "Show all N lines" expand button, truncated state, download
* buttons, tone=user/agent styling, cleanup on unmount.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import React from "react";
import { AttachmentTextPreview } from "../AttachmentTextPreview";
import type { ChatAttachment } from "../types";
// ─── Setup ────────────────────────────────────────────────────────────────────
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── Fixtures ────────────────────────────────────────────────────────────────
const makeAtt = (name = "log.txt"): ChatAttachment =>
({ name, uri: "workspace:/workspace/tmp/" + name });
function renderTextPreview(
att: ChatAttachment,
tone: "user" | "agent" = "agent",
) {
return render(
<AttachmentTextPreview
workspaceId="ws-1"
attachment={att}
onDownload={vi.fn()}
tone={tone}
/>,
);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("AttachmentTextPreview", () => {
// ── idle / loading ───────────────────────────────────────────────────────
it("renders loading skeleton (idle state)", () => {
fetchMock.mockReturnValue(new Promise(() => {})); // hangs forever
renderTextPreview(makeAtt());
const skeleton = screen.getByLabelText(/Loading log\.txt/i);
expect(skeleton).toBeTruthy();
expect(skeleton.className).toContain("animate-pulse");
});
it("renders loading skeleton (loading state)", async () => {
// Never-resolving fetch → stays in loading state.
fetchMock.mockReturnValue(new Promise(() => {}));
renderTextPreview(makeAtt("data.json"));
await waitFor(() => {
expect(screen.getByLabelText(/Loading data\.json/i)).toBeTruthy();
});
});
// ── error fallback ───────────────────────────────────────────────────────
it("renders AttachmentChip when fetch fails (404)", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
renderTextPreview(makeAtt("missing.txt"));
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.txt/i)).toBeTruthy();
});
// <pre> must NOT appear — proved we fell back to the chip.
expect(document.querySelector("pre")).toBeNull();
});
it("renders chip on network error", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
renderTextPreview(makeAtt("offline.json"));
await waitFor(() => {
expect(screen.getByTitle(/Download offline\.json/i)).toBeTruthy();
});
});
// ── ready / <pre><code> ──────────────────────────────────────────────────
it("renders <pre><code> with text content when fetch succeeds", async () => {
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => "line1\nline2\nline3",
});
renderTextPreview(makeAtt("report.txt"));
await waitFor(() => {
const code = document.querySelector("pre code");
expect(code).not.toBeNull();
expect(code?.textContent).toBe("line1\nline2\nline3");
});
});
it("renders filename header span", async () => {
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => "hello",
});
renderTextPreview(makeAtt("notes.md"));
await waitFor(() => {
expect(screen.getByText("notes.md")).toBeTruthy();
});
});
it("renders exactly one <pre> element when ready", async () => {
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => "content",
});
renderTextPreview(makeAtt("code.js"));
await waitFor(() => {
expect(document.querySelectorAll("pre")).toHaveLength(1);
});
});
// ── show all lines button ─────────────────────────────────────────────────
it("shows 'Show all N lines' button when file has >10 lines", async () => {
const body = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n");
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => body,
});
renderTextPreview(makeAtt("big.log"));
await waitFor(() => {
expect(
screen.getByRole("button", { name: /show all 25 lines/i }),
).toBeTruthy();
});
// First 10 lines only in preview
const code = document.querySelector("pre code");
expect(code?.textContent).toContain("line 10");
expect(code?.textContent).not.toContain("line 11");
});
it("expand button is NOT shown when file has ≤10 lines", async () => {
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => "a\nb\nc",
});
renderTextPreview(makeAtt("short.txt"));
await waitFor(() => {
expect(document.querySelector("pre code")).not.toBeNull();
});
expect(screen.queryByRole("button", { name: /show all/i })).toBeNull();
});
it("clicking 'Show all' expands to full content", async () => {
const body = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n");
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => body,
});
renderTextPreview(makeAtt("expand.txt"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /show all 25 lines/i })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: /show all 25 lines/i }));
const code = document.querySelector("pre code");
expect(code?.textContent).toContain("line 25");
});
// ── download buttons ──────────────────────────────────────────────────────
it("header download button fires onDownload with attachment", async () => {
const onDownload = vi.fn();
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => "hello",
});
const { rerender } = render(
<AttachmentTextPreview
workspaceId="ws-1"
attachment={makeAtt("readme.md")}
onDownload={onDownload}
tone="agent"
/>,
);
await waitFor(() => {
expect(document.querySelector("pre code")).not.toBeNull();
});
const downloadBtn = screen.getByLabelText(/download readme\.md/i);
downloadBtn.click();
expect(onDownload).toHaveBeenCalledWith(
expect.objectContaining({ name: "readme.md" }),
);
});
it("onDownload is NOT called during loading or ready states", async () => {
const onDownload = vi.fn();
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => "hello world",
});
render(
<AttachmentTextPreview
workspaceId="ws-1"
attachment={makeAtt("quiet.txt")}
onDownload={onDownload}
tone="agent"
/>,
);
await waitFor(() => {
expect(document.querySelector("pre code")).not.toBeNull();
});
expect(onDownload).not.toHaveBeenCalled();
});
// ── tone styling ─────────────────────────────────────────────────────────
it("tone=user applies blue border class on container", async () => {
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => "hello",
});
const { container } = render(
<AttachmentTextPreview
workspaceId="ws-1"
attachment={makeAtt("blue.txt")}
onDownload={vi.fn()}
tone="user"
/>,
);
await waitFor(() => {
expect(document.querySelector("pre code")).not.toBeNull();
});
const blueDiv = Array.from(container.querySelectorAll("div")).find((d) =>
d.className.includes("blue-400"),
);
expect(blueDiv).toBeTruthy();
});
it("tone=agent does not apply blue border class", async () => {
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => "hello",
});
const { container } = render(
<AttachmentTextPreview
workspaceId="ws-1"
attachment={makeAtt("gray.txt")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(document.querySelector("pre code")).not.toBeNull();
});
const blueDivs = Array.from(container.querySelectorAll("div")).filter((d) =>
d.className.includes("blue-400"),
);
expect(blueDivs).toHaveLength(0);
});
// ── cleanup ─────────────────────────────────────────────────────────────
it("no state update after unmount (cancelled flag prevents setState)", async () => {
// The component sets cancelled=true in cleanup, which prevents setState
// from firing after the pending read() resolves. We verify no crash
// and no error element appears (since the pending read eventually resolves
// but the component ignores it due to cancelled=true).
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: () => new Promise<string>((resolve) => setTimeout(() => resolve("delayed"), 100)),
});
const { unmount } = renderTextPreview(makeAtt("cleanup.txt"));
await act(async () => {
unmount();
});
// No crash, no error state rendered (chip would appear on error)
expect(document.querySelector("pre code")).toBeNull();
expect(document.querySelector('[aria-label*="Download cleanup.txt"]')).toBeNull();
});
});
@@ -1,308 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AttachmentVideo — inline native HTML5 <video controls> player.
*
* Per RFC #2991 PR-2. Dispatches from AttachmentPreview so most paths
* are pinned there. These tests cover AttachmentVideo as a standalone
* renderer: loading skeleton, ready <video>, chip error fallback, external
* URI (no-fetch path), tone=user vs tone=agent styling, and cleanup on
* unmount.
*
* NOTE: No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, cleanup, waitFor, act } from "@testing-library/react";
import React from "react";
import { AttachmentVideo } from "../AttachmentVideo";
import type { ChatAttachment } from "./types";
afterEach(cleanup);
// Stub env token so platformAuthHeaders() is callable without a real env.
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
global.URL.createObjectURL = vi.fn(() => "blob:video-test");
global.URL.revokeObjectURL = vi.fn();
});
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeAtt(name = "clip.mp4"): ChatAttachment {
return { name, uri: "workspace:/workspace/tmp/" + name, mimeType: "video/mp4" };
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("AttachmentVideo", () => {
// ── idle / loading skeleton ───────────────────────────────────────────────
it("renders loading skeleton (idle state) before fetch resolves", () => {
fetchMock.mockReturnValue(new Promise(() => {})); // hangs forever
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt()}
onDownload={vi.fn()}
tone="agent"
/>,
);
const skeleton = screen.getByLabelText(/Loading clip\.mp4/i);
expect(skeleton).toBeTruthy();
expect(skeleton.className).toContain("animate-pulse");
});
it("renders loading skeleton during loading state", async () => {
fetchMock.mockReturnValue(new Promise<Response>(() => {})); // hangs forever
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("movie.mov")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByLabelText(/Loading movie\.mov/i)).toBeTruthy();
});
});
// ── error fallback ───────────────────────────────────────────────────────
it("renders AttachmentChip when fetch fails (404)", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("missing.mp4")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.mp4/i)).toBeTruthy();
});
// <video> must NOT appear when chip is shown.
expect(document.querySelector("video")).toBeNull();
});
it("renders chip on network error", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("offline.webm")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download offline\.webm/i)).toBeTruthy();
});
});
// ── ready / <video> ─────────────────────────────────────────────────────
it("renders <video controls> when fetch succeeds", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["fake-video-bytes"], { type: "video/mp4" }),
});
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("podcast.mp4")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const video = document.querySelector("video");
expect(video).not.toBeNull();
expect(video?.hasAttribute("controls")).toBe(true);
});
});
it("video src is the blob URL minted from response", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["bytes"], { type: "video/mp4" }),
});
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("track.mp4")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const video = document.querySelector("video") as HTMLVideoElement;
expect(video?.src).toBe("blob:video-test");
});
});
it("video has playsInline attribute for mobile Safari", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "video/mp4" }),
});
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("mobile.mp4")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
const video = document.querySelector("video") as HTMLVideoElement;
expect(video?.getAttribute("playsinline")).toBe("");
});
});
// ── external URI (no-fetch path) ─────────────────────────────────────────
it("skips fetch and renders video directly for external URIs", async () => {
// External http/https URIs bypass the auth fetch and go straight to
// ready state with the resolved URL as src.
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={{ name: "cdn.mp4", uri: "https://example.com/video.mp4" }}
onDownload={vi.fn()}
tone="agent"
/>,
);
// No skeleton — should skip directly to ready state.
// The URL.revokeObjectURL must NOT have been called since we never
// minted a blob URL.
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
await waitFor(() => {
const video = document.querySelector("video");
expect(video).not.toBeNull();
expect(video?.getAttribute("controls")).toBe(""); // boolean-like attribute
});
});
// ── tone styling ─────────────────────────────────────────────────────────
it("tone=user applies blue border class on ready-state container", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "video/mp4" }),
});
const { container } = render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("blue.mp4")}
onDownload={vi.fn()}
tone="user"
/>,
);
await waitFor(() => {
expect(document.querySelector("video")).not.toBeNull();
});
// The outer ready-state <div> must contain blue-400 class when tone=user.
const blueDivs = Array.from(container.querySelectorAll("div")).filter(
(d) => d.className.includes("blue-400"),
);
expect(blueDivs.length).toBeGreaterThan(0);
});
it("tone=agent does not apply blue border class", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "video/mp4" }),
});
const { container } = render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("gray.mp4")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await waitFor(() => {
expect(document.querySelector("video")).not.toBeNull();
});
const blueDivs = Array.from(container.querySelectorAll("div")).filter(
(d) => d.className.includes("blue-400"),
);
expect(blueDivs).toHaveLength(0);
});
// ── download buttons ──────────────────────────────────────────────────────
it("onDownload is NOT called during loading or ready states", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["data"], { type: "video/mp4" }),
});
const onDownload = vi.fn();
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("quiet.mp4")}
onDownload={onDownload}
tone="agent"
/>,
);
// Wait for ready state — onDownload must not have been called.
await waitFor(() => {
expect(document.querySelector("video")).not.toBeNull();
});
expect(onDownload).not.toHaveBeenCalled();
});
it("onDownload fires when chip fallback is rendered (error state)", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500 });
const onDownload = vi.fn();
render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("fail.mp4")}
onDownload={onDownload}
tone="agent"
/>,
);
await waitFor(() => {
expect(screen.getByTitle(/Download fail\.mp4/i)).toBeTruthy();
});
// Click the chip's download button.
screen.getByTitle(/Download fail\.mp4/i).click();
expect(onDownload).toHaveBeenCalledWith(
expect.objectContaining({ name: "fail.mp4" }),
);
});
// ── cleanup ─────────────────────────────────────────────────────────────
it("no state update after unmount (cancelled flag prevents setState)", async () => {
// The component sets cancelled=true in cleanup, which prevents setState
// from firing after the pending read() resolves.
fetchMock.mockResolvedValue({
ok: true,
blob: () => new Promise<Blob>((resolve) => setTimeout(() => resolve(new Blob(["delayed"])), 100)),
});
const { unmount } = render(
<AttachmentVideo
workspaceId="ws-1"
attachment={makeAtt("cleanup.mp4")}
onDownload={vi.fn()}
tone="agent"
/>,
);
await act(async () => {
unmount();
});
// No crash, no video element (component unmounted before ready)
expect(document.querySelector("video")).toBeNull();
expect(document.querySelector('[aria-label*="Download cleanup.mp4"]')).toBeNull();
});
});
@@ -1,211 +0,0 @@
// @vitest-environment jsdom
/**
* AttachmentViews — pure presentational components for chat attachments.
*
* Covers:
* - PendingAttachmentPill renders file name, formatted size, × button
* - PendingAttachmentPill × button has correct aria-label
* - PendingAttachmentPill calls onRemove when × clicked
* - PendingAttachmentPill renders exactly one button
* - AttachmentChip renders attachment name and download glyph
* - AttachmentChip renders size when provided
* - AttachmentChip omits size span when size is undefined
* - AttachmentChip calls onDownload(attachment) on click
* - AttachmentChip title attribute for hover tooltip
* - AttachmentChip tone=user applies blue accent classes
* - AttachmentChip tone=agent applies surface classes
* - AttachmentChip renders exactly one button
*
* NOTE: No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks to avoid "expect is not defined" errors in this vitest
* configuration.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import React from "react";
import { AttachmentChip, PendingAttachmentPill } from "../AttachmentViews";
import type { ChatAttachment } from "../types";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── Helpers ────────────────────────────────────────────────────────────────────
/** Create a File with actual content so size > 0 in jsdom. */
function makeFile(name: string, content: string): File {
return new File([content], name, { type: "application/octet-stream" });
}
function makeAttachment(name: string, size?: number): ChatAttachment {
return { name, uri: `workspace:/tmp/${name}`, size };
}
// ─── PendingAttachmentPill ─────────────────────────────────────────────────────
describe("PendingAttachmentPill", () => {
it("renders the file name", () => {
const file = makeFile("report.pdf", "PDF content here");
const { container } = render(
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
);
expect(container.textContent).toContain("report.pdf");
});
it("renders the formatted file size (KB)", () => {
// 50 KB = 50 * 1024 bytes
const content = "x".repeat(50 * 1024);
const file = makeFile("data.csv", content);
const { container } = render(
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
);
expect(container.textContent).toContain("50 KB");
});
it("renders 0 B for empty file", () => {
const file = makeFile("empty.txt", "");
const { container } = render(
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
);
expect(container.textContent).toContain("0 B");
});
it("renders size in MB for files >= 1 MB", () => {
// 2.5 MB = 2.5 * 1024 * 1024 bytes
const content = "x".repeat(Math.round(2.5 * 1024 * 1024));
const file = makeFile("video.mp4", content);
const { container } = render(
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
);
expect(container.textContent).toContain("2.5 MB");
});
it("× button has aria-label with file name", () => {
const file = makeFile("notes.txt", "some content");
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
const btn = screen.getByRole("button");
expect(btn.getAttribute("aria-label")).toBe("Remove notes.txt");
});
it("calls onRemove when × button is clicked", () => {
const file = makeFile("doc.pdf", "pdf data");
const onRemove = vi.fn();
render(<PendingAttachmentPill file={file} onRemove={onRemove} />);
screen.getByRole("button").click();
expect(onRemove).toHaveBeenCalledTimes(1);
});
it("renders exactly one button (the × remove button)", () => {
const file = makeFile("img.png", "image bytes");
const { container } = render(
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
);
expect(container.querySelectorAll("button")).toHaveLength(1);
});
});
// ─── AttachmentChip ───────────────────────────────────────────────────────────
describe("AttachmentChip", () => {
it("renders the attachment name", () => {
const att = makeAttachment("chart.svg", 2048);
const { container } = render(
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
);
expect(container.textContent).toContain("chart.svg");
});
it("renders size when provided", () => {
const att = makeAttachment("dump.sql", 1024 * 150); // 150 KB
const { container } = render(
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
);
expect(container.textContent).toContain("150 KB");
});
it("omits size span when attachment.size is undefined", () => {
const att = makeAttachment("notes.md"); // no size
const { container } = render(
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
);
// The only <span> should be the truncated filename; no size <span>
const spans = Array.from(container.querySelectorAll("span"));
const sizeSpans = spans.filter(
(s) => s.className && s.className.includes("tabular-nums"),
);
expect(sizeSpans).toHaveLength(0);
});
it("has title attribute with download hint", () => {
const att = makeAttachment("readme.txt", 64);
const { container } = render(
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="agent" />,
);
const btn = container.querySelector("button");
expect(btn?.getAttribute("title")).toBe("Download readme.txt");
});
it("calls onDownload with the attachment on click", () => {
const att = makeAttachment("export.csv", 8192);
const onDownload = vi.fn();
const { container } = render(
<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />,
);
container.querySelector("button")!.click();
expect(onDownload).toHaveBeenCalledWith(att);
});
it("tone=user applies blue accent class", () => {
const att = makeAttachment("photo.jpg", 512);
const { container } = render(
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
);
const btn = container.querySelector("button")!;
expect(btn.className).toContain("blue-400");
});
it("tone=agent does not apply blue accent class", () => {
const att = makeAttachment("photo.jpg", 512);
const { container } = render(
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="agent" />,
);
const btn = container.querySelector("button")!;
expect(btn.className).not.toContain("blue-400");
});
it("renders exactly one button", () => {
const att = makeAttachment("icon.svg", 128);
const { container } = render(
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
);
expect(container.querySelectorAll("button")).toHaveLength(1);
});
it("tone=user applies blue-400 accent class", () => {
const attachment = makeAttachment("file.pdf", 512);
render(
<AttachmentChip
attachment={attachment}
onDownload={vi.fn()}
tone="user"
/>
);
const btn = screen.getByRole("button");
expect(btn.className).toMatch(/blue-400/);
});
it("tone=agent omits blue-400 accent class", () => {
const attachment = makeAttachment("file.pdf", 512);
render(
<AttachmentChip
attachment={attachment}
onDownload={vi.fn()}
tone="agent"
/>
);
const btn = screen.getByRole("button");
expect(btn.className).not.toMatch(/blue-400/);
});
});
@@ -1,208 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for uploads.ts utility functions.
*
* Tests the two public pure functions:
* resolveAttachmentHref(workspaceId, uri) → string
* isPlatformAttachment(uri) → boolean
*
* These are tested without mocking — they're pure string manipulation.
* The async functions (uploadChatFiles, downloadChatFile) are NOT tested
* here since they make real fetch/URL calls requiring jsdom network mocks.
*/
import { describe, expect, it } from "vitest";
import {
resolveAttachmentHref,
isPlatformAttachment,
} from "../uploads";
// We need PLATFORM_URL for constructing expected values.
// Import it from the api module (it's exported there).
import { PLATFORM_URL } from "@/lib/api";
const WS = "ws-test-123";
function platformUrl(...parts: string[]) {
return [PLATFORM_URL, ...parts].join("/");
}
// ─── resolveAttachmentHref ─────────────────────────────────────────────────────
describe("resolveAttachmentHref — platform-pending URIs", () => {
it("resolves platform-pending URI to pending-uploads content URL", () => {
const result = resolveAttachmentHref(WS, "platform-pending:ws-test-123/file-abc");
expect(result).toBe(platformUrl("workspaces", "ws-test-123", "pending-uploads", "file-abc", "content"));
});
it("uses the URI's own workspace ID (not the chat's)", () => {
// URI has ws-A but chat is in ws-B — resolve to URI's workspace.
const result = resolveAttachmentHref("ws-B", "platform-pending:ws-A/file-xyz");
expect(result).toBe(platformUrl("workspaces", "ws-A", "pending-uploads", "file-xyz", "content"));
});
it("returns raw URI when platform-pending lacks a slash (no wsid/fileid)", () => {
const result = resolveAttachmentHref(WS, "platform-pending:no-slash-here");
expect(result).toBe("platform-pending:no-slash-here");
});
it("returns raw URI when platform-pending has empty wsid", () => {
const result = resolveAttachmentHref(WS, "platform-pending:/file-only");
expect(result).toBe("platform-pending:/file-only");
});
});
describe("resolveAttachmentHref — workspace: URIs", () => {
it("resolves /workspace/path to chat download URL", () => {
const result = resolveAttachmentHref(WS, "workspace:/workspace/myfile.txt");
expect(result).toBe(
platformUrl("workspaces", WS, "chat", "download") + "?path=%2Fworkspace%2Fmyfile.txt"
);
});
it("resolves /configs/path to chat download URL", () => {
const result = resolveAttachmentHref(WS, "workspace:/configs/app.conf");
expect(result).toBe(
platformUrl("workspaces", WS, "chat", "download") + "?path=%2Fconfigs%2Fapp.conf"
);
});
it("resolves /home/path to chat download URL", () => {
const result = resolveAttachmentHref(WS, "workspace:/home/user/setup.sh");
expect(result).toBe(
platformUrl("workspaces", WS, "chat", "download") + "?path=%2Fhome%2Fuser%2Fsetup.sh"
);
});
it("resolves /plugins/path to chat download URL", () => {
const result = resolveAttachmentHref(WS, "workspace:/plugins/my-plugin/index.js");
expect(result).toBe(
platformUrl("workspaces", WS, "chat", "download") + "?path=%2Fplugins%2Fmy-plugin%2Findex.js"
);
});
it("passes through workspace: with disallowed root", () => {
const result = resolveAttachmentHref(WS, "workspace:/var/log/app.log");
expect(result).toBe("workspace:/var/log/app.log");
});
});
describe("resolveAttachmentHref — file:/// URIs", () => {
it("resolves file:///workspace/... to chat download URL", () => {
const result = resolveAttachmentHref(WS, "file:///workspace/report.pdf");
expect(result).toBe(
platformUrl("workspaces", WS, "chat", "download") + "?path=%2Fworkspace%2Freport.pdf"
);
});
it("resolves file:///configs/... to chat download URL", () => {
const result = resolveAttachmentHref(WS, "file:///configs/secrets.env");
expect(result).toBe(
platformUrl("workspaces", WS, "chat", "download") + "?path=%2Fconfigs%2Fsecrets.env"
);
});
it("passes through file:/// with disallowed root", () => {
const result = resolveAttachmentHref(WS, "file:///etc/passwd");
expect(result).toBe("file:///etc/passwd");
});
});
describe("resolveAttachmentHref — bare absolute path URIs", () => {
it("resolves /workspace/... to chat download URL", () => {
const result = resolveAttachmentHref(WS, "/workspace/upload.png");
expect(result).toBe(
platformUrl("workspaces", WS, "chat", "download") + "?path=%2Fworkspace%2Fupload.png"
);
});
it("passes through / with disallowed root", () => {
const result = resolveAttachmentHref(WS, "/tmp/cache.bin");
expect(result).toBe("/tmp/cache.bin");
});
it("passes through root /workspace (exact match only)", () => {
const result = resolveAttachmentHref(WS, "/workspace");
expect(result).toBe(platformUrl("workspaces", WS, "chat", "download") + "?path=%2Fworkspace");
});
});
describe("resolveAttachmentHref — external URIs", () => {
it("passes through https:// URIs unchanged", () => {
const result = resolveAttachmentHref(WS, "https://example.com/artefact.tar.gz");
expect(result).toBe("https://example.com/artefact.tar.gz");
});
it("passes through http:// URIs unchanged", () => {
const result = resolveAttachmentHref(WS, "http://cdn.example.com/image.png");
expect(result).toBe("http://cdn.example.com/image.png");
});
it("passes through unknown scheme unchanged", () => {
const result = resolveAttachmentHref(WS, "s3://my-bucket/file.json");
expect(result).toBe("s3://my-bucket/file.json");
});
});
// ─── isPlatformAttachment ──────────────────────────────────────────────────────
describe("isPlatformAttachment", () => {
it("returns true for platform-pending URIs", () => {
expect(isPlatformAttachment("platform-pending:ws-A/file-1")).toBe(true);
});
it("returns true for workspace: URIs with allowed roots", () => {
expect(isPlatformAttachment("workspace:/workspace/file.txt")).toBe(true);
expect(isPlatformAttachment("workspace:/configs/app.conf")).toBe(true);
expect(isPlatformAttachment("workspace:/home/user/script.sh")).toBe(true);
expect(isPlatformAttachment("workspace:/plugins/my/ext.js")).toBe(true);
});
it("returns false for workspace: URIs with disallowed roots", () => {
expect(isPlatformAttachment("workspace:/var/data.json")).toBe(false);
expect(isPlatformAttachment("workspace:/usr/local/bin")).toBe(false);
expect(isPlatformAttachment("workspace:/tmp/cache")).toBe(false);
});
it("returns true for file:/// URIs with allowed roots", () => {
expect(isPlatformAttachment("file:///workspace/image.png")).toBe(true);
expect(isPlatformAttachment("file:///configs/app.conf")).toBe(true);
expect(isPlatformAttachment("file:///home/user/file.txt")).toBe(true);
expect(isPlatformAttachment("file:///plugins/ext.js")).toBe(true);
});
it("returns false for file:/// URIs with disallowed roots", () => {
expect(isPlatformAttachment("file:///etc/passwd")).toBe(false);
expect(isPlatformAttachment("file:///var/log")).toBe(false);
});
it("returns true for bare absolute paths with allowed roots", () => {
expect(isPlatformAttachment("/workspace/file.txt")).toBe(true);
expect(isPlatformAttachment("/configs/app.conf")).toBe(true);
expect(isPlatformAttachment("/home/user/file.txt")).toBe(true);
expect(isPlatformAttachment("/plugins/ext.js")).toBe(true);
});
it("returns false for bare absolute paths with disallowed roots", () => {
expect(isPlatformAttachment("/var/data.json")).toBe(false);
expect(isPlatformAttachment("/usr/local/bin")).toBe(false);
expect(isPlatformAttachment("/tmp/cache")).toBe(false);
expect(isPlatformAttachment("/")).toBe(false);
});
it("returns false for https:// URIs (external)", () => {
expect(isPlatformAttachment("https://example.com/file.txt")).toBe(false);
});
it("returns false for http:// URIs (external)", () => {
expect(isPlatformAttachment("http://example.com/file.txt")).toBe(false);
});
it("returns false for unknown schemes", () => {
expect(isPlatformAttachment("s3://bucket/file")).toBe(false);
expect(isPlatformAttachment("data:text/plain;base64,SGVsbG8=")).toBe(false);
});
it("returns false for empty string", () => {
expect(isPlatformAttachment("")).toBe(false);
});
});
@@ -1,294 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for form-inputs — shared form components for the Config tab.
*
* TextInput coverage:
* - Renders label and input
* - aria-label matches label text
* - onChange called with new value
* - placeholder text rendered
* - mono class applied when mono=true
*
* NumberInput coverage:
* - Renders label and number input
* - aria-label matches label text
* - onChange called with parsed integer
* - min/max attributes applied
* - Parses empty input as 0
*
* Toggle coverage:
* - Renders checkbox with label
* - Checkbox checked state reflects checked prop
* - onChange called with boolean
*
* TagList coverage:
* - Renders existing tags with remove button
* - Remove button has aria-label with tag name
* - Remove button calls onChange without that tag
* - Enter key with non-empty input adds tag and clears input
* - Enter with empty input does not add tag
* - Placeholder text rendered
*
* Section coverage:
* - defaultOpen=true renders children on mount
* - defaultOpen=false hides children on mount
* - Clicking header toggles children visibility
* - Toggle icon changes between ▾ and ▸
* - Header has accessible button
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
TextInput,
NumberInput,
Toggle,
TagList,
Section,
} from "../form-inputs";
afterEach(cleanup);
// ─── TextInput ─────────────────────────────────────────────────────────────────
describe("TextInput", () => {
it("renders label and input", () => {
render(<TextInput label="Agent Name" value="" onChange={vi.fn()} />);
expect(screen.getByLabelText("Agent Name")).toBeTruthy();
});
it("displays the current value", () => {
render(<TextInput label="Model" value="claude-sonnet" onChange={vi.fn()} />);
expect((screen.getByLabelText("Model") as HTMLInputElement).value).toBe("claude-sonnet");
});
it("calls onChange when user types", () => {
const onChange = vi.fn();
render(<TextInput label="Description" value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Description"), { target: { value: "Hello" } });
expect(onChange).toHaveBeenCalledWith("Hello");
});
it("renders placeholder text", () => {
render(
<TextInput label="Name" value="" onChange={vi.fn()} placeholder="Enter name..." />
);
expect((screen.getByPlaceholderText("Enter name...") as HTMLInputElement).value).toBe("");
});
it("applies mono font class when mono=true", () => {
render(<TextInput label="Token" value="" onChange={vi.fn()} mono />);
const input = screen.getByLabelText("Token");
expect(input.classList.contains("font-mono")).toBe(true);
});
it("does not apply mono class when mono=false", () => {
render(<TextInput label="Name" value="" onChange={vi.fn()} mono={false} />);
const input = screen.getByLabelText("Name");
expect(input.classList.contains("font-mono")).toBe(false);
});
});
// ─── NumberInput ────────────────────────────────────────────────────────────────
describe("NumberInput", () => {
it("renders label and input", () => {
render(<NumberInput label="Timeout" value={30} onChange={vi.fn()} />);
expect(screen.getByLabelText("Timeout")).toBeTruthy();
});
it("displays the current value", () => {
render(<NumberInput label="Retries" value={5} onChange={vi.fn()} />);
expect((screen.getByLabelText("Retries") as HTMLInputElement).value).toBe("5");
});
it("calls onChange with parsed integer", () => {
const onChange = vi.fn();
render(<NumberInput label="Port" value={8000} onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Port"), { target: { value: "9000" } });
expect(onChange).toHaveBeenCalledWith(9000);
});
it("parses empty input as 0", () => {
const onChange = vi.fn();
render(<NumberInput label="Count" value={5} onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Count"), { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith(0);
});
it("applies min attribute", () => {
render(<NumberInput label="Memory" value={256} onChange={vi.fn()} min={64} />);
expect(screen.getByLabelText("Memory").getAttribute("min")).toBe("64");
});
it("applies max attribute", () => {
render(<NumberInput label="Memory" value={256} onChange={vi.fn()} max={4096} />);
expect(screen.getByLabelText("Memory").getAttribute("max")).toBe("4096");
});
});
// ─── Toggle ────────────────────────────────────────────────────────────────────
describe("Toggle", () => {
it("renders checkbox with label", () => {
render(<Toggle label="Enable streaming" checked={false} onChange={vi.fn()} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeTruthy();
expect(screen.getByText("Enable streaming")).toBeTruthy();
});
it("checkbox is checked when checked=true", () => {
render(<Toggle label="Auto-restart" checked={true} onChange={vi.fn()} />);
expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(true);
});
it("checkbox is unchecked when checked=false", () => {
render(<Toggle label="Auto-restart" checked={false} onChange={vi.fn()} />);
expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(false);
});
it("calls onChange with boolean on click", () => {
const onChange = vi.fn();
render(<Toggle label="Push notifications" checked={false} onChange={onChange} />);
fireEvent.click(screen.getByRole("checkbox"));
expect(onChange).toHaveBeenCalledWith(true);
});
it("calls onChange with false when toggled off", () => {
const onChange = vi.fn();
render(<Toggle label="Push notifications" checked={true} onChange={onChange} />);
fireEvent.click(screen.getByRole("checkbox"));
expect(onChange).toHaveBeenCalledWith(false);
});
});
// ─── TagList ───────────────────────────────────────────────────────────────────
describe("TagList", () => {
it("renders existing tags", () => {
render(
<TagList label="Skills" values={["coding", "research"]} onChange={vi.fn()} />
);
expect(screen.getByText("coding")).toBeTruthy();
expect(screen.getByText("research")).toBeTruthy();
});
it("renders remove button with aria-label for each tag", () => {
render(
<TagList label="Tools" values={["bash", "grep"]} onChange={vi.fn()} />
);
expect(screen.getByRole("button", { name: /remove tag bash/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /remove tag grep/i })).toBeTruthy();
});
it("clicking remove button calls onChange without that tag", () => {
const onChange = vi.fn();
render(
<TagList label="Tools" values={["bash", "grep"]} onChange={onChange} />
);
fireEvent.click(screen.getByRole("button", { name: /remove tag bash/i }));
expect(onChange).toHaveBeenCalledWith(["grep"]);
});
it("Enter key with non-empty input adds tag and clears input", () => {
const onChange = vi.fn();
render(
<TagList label="Skills" values={[]} onChange={onChange} />
);
const input = screen.getByLabelText("Skills");
fireEvent.change(input, { target: { value: "analysis" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["analysis"]);
expect((input as HTMLInputElement).value).toBe("");
});
it("Enter key with empty input does not add tag", () => {
const onChange = vi.fn();
render(
<TagList label="Skills" values={[]} onChange={onChange} />
);
const input = screen.getByLabelText("Skills");
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
});
it("Enter key with whitespace-only input does not add tag", () => {
const onChange = vi.fn();
render(
<TagList label="Skills" values={[]} onChange={onChange} />
);
const input = screen.getByLabelText("Skills");
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
});
it("renders placeholder text", () => {
render(
<TagList label="Tags" values={[]} onChange={vi.fn()} placeholder="Add a tag..." />
);
expect(screen.getByPlaceholderText("Add a tag...")).toBeTruthy();
});
it("trims whitespace when adding tag", () => {
const onChange = vi.fn();
render(
<TagList label="Tags" values={[]} onChange={onChange} />
);
const input = screen.getByLabelText("Tags");
fireEvent.change(input, { target: { value: " python " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["python"]);
});
});
// ─── Section ───────────────────────────────────────────────────────────────────
describe("Section", () => {
it("renders title", () => {
render(<Section title="A2A Settings">Content here</Section>);
expect(screen.getByText("A2A Settings")).toBeTruthy();
});
it("renders children when defaultOpen=true (default)", () => {
render(<Section title="A2A Settings">The content</Section>);
expect(screen.getByText("The content")).toBeTruthy();
});
it("hides children when defaultOpen=false", () => {
render(<Section title="Danger Zone" defaultOpen={false}>Hidden</Section>);
expect(screen.queryByText("Hidden")).toBeFalsy();
});
it("clicking header toggles children visibility", () => {
render(<Section title="Delegation">Visible</Section>);
expect(screen.getByText("Visible")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /delegation/i }));
expect(screen.queryByText("Visible")).toBeFalsy();
});
it("clicking header again re-shows children", () => {
render(<Section title="Delegation">Visible</Section>);
const btn = screen.getByRole("button", { name: /delegation/i });
fireEvent.click(btn); // close
expect(screen.queryByText("Visible")).toBeFalsy();
fireEvent.click(btn); // re-open
expect(screen.getByText("Visible")).toBeTruthy();
});
it("toggle icon shows ▾ when open", () => {
render(<Section title="General">Open</Section>);
expect(screen.getByText("▾")).toBeTruthy();
});
it("toggle icon shows ▸ when closed", () => {
render(<Section title="General" defaultOpen={false}>Closed</Section>);
expect(screen.getByText("▸")).toBeTruthy();
});
it("header button has accessible label via title text", () => {
render(<Section title="Runtime Config">Content</Section>);
const btn = screen.getByRole("button");
expect(btn.textContent).toContain("Runtime Config");
});
});
@@ -1,142 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for KeyValueField component.
*
* Covers: initial password type, onChange callback (including whitespace trim
* on type), aria-label forwarding, disabled state, and auto-hide timer setup.
*/
import React from "react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { KeyValueField } from "../KeyValueField";
describe("KeyValueField — rendering", () => {
afterEach(cleanup);
it("renders input with type=password by default (secret hidden)", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
const input = screen.getByLabelText("Secret value");
expect(input.getAttribute("type")).toBe("password");
});
it("passes custom aria-label to the input element", () => {
render(<KeyValueField value="" onChange={vi.fn()} aria-label="API secret key" />);
expect(screen.getByLabelText("API secret key")).toBeTruthy();
});
it("disables the input when disabled=true", () => {
render(<KeyValueField value="secret" onChange={vi.fn()} disabled />);
expect(screen.getByLabelText("Secret value").disabled).toBe(true);
});
it("renders with the current value", () => {
render(<KeyValueField value="sk-test-key-123" onChange={vi.fn()} />);
expect(screen.getByLabelText("Secret value").value).toBe("sk-test-key-123");
});
it("renders with the placeholder text", () => {
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key");
});
it("renders the RevealToggle child button", () => {
render(<KeyValueField value="secret" onChange={vi.fn()} />);
// KeyValueField renders exactly one button (the RevealToggle)
expect(screen.getByRole("button")).toBeTruthy();
});
});
describe("KeyValueField — onChange", () => {
afterEach(cleanup);
it("calls onChange with the new value when user types", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "new-value" } });
expect(onChange).toHaveBeenCalledWith("new-value");
});
it("trims leading whitespace when user types with leading space", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " trimmed" } });
expect(onChange).toHaveBeenCalledWith("trimmed");
});
it("trims trailing whitespace when user types with trailing space", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "trimmed " } });
expect(onChange).toHaveBeenCalledWith("trimmed");
});
it("trims both sides when user types whitespace-surrounded value", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " both sides " } });
expect(onChange).toHaveBeenCalledWith("both sides");
});
it("does not modify value with no whitespace", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "clean-value" } });
expect(onChange).toHaveBeenCalledWith("clean-value");
});
});
describe("KeyValueField — auto-hide timer setup", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("sets up a 30s setTimeout when the component mounts with a non-empty value", () => {
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
render(<KeyValueField value="secret" onChange={vi.fn()} />);
// No timer should be set initially (revealed=false by default)
const callsBeforeInteraction = setTimeoutSpy.mock.calls.length;
// Simulate reveal (click the only button)
act(() => { fireEvent.click(screen.getByRole("button")); });
// After reveal, a 30s timer should be set
const timerCalls = setTimeoutSpy.mock.calls.filter(
([, delay]) => delay === 30_000,
);
expect(timerCalls.length).toBeGreaterThanOrEqual(1);
});
it("clears existing timer when a new toggle happens before auto-hide fires", () => {
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
const timerObj = {}; // fake timer ID
vi.spyOn(global, "setTimeout").mockImplementation((fn: () => void, delay: number) => {
return timerObj;
});
render(<KeyValueField value="secret" onChange={vi.fn()} />);
// First toggle — reveal
act(() => { fireEvent.click(screen.getByRole("button")); });
// Second toggle — hide (should clear the timer from first toggle)
act(() => { fireEvent.click(screen.getByRole("button")); });
// clearTimeout was called with the timer object
expect(clearTimeoutSpy).toHaveBeenCalledWith(timerObj);
});
it("clears timer on unmount", () => {
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
const { unmount } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
// Toggle reveal to start the timer
act(() => { fireEvent.click(screen.getByRole("button")); });
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
});
@@ -1,68 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for RevealToggle component.
*
* Covers: eye-icon (hidden) vs eye-off-icon (revealed), onToggle callback,
* aria-label (default + custom), title attribute.
*/
import { afterEach, describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { RevealToggle } from "../RevealToggle";
afterEach(cleanup);
describe("RevealToggle", () => {
it("renders as a button", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toBeTruthy();
});
it("uses default aria-label when not provided", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle reveal secret");
});
it("uses custom aria-label when provided", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
});
it('title is "Hide value" when revealed', () => {
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
});
it('title is "Show value" when hidden', () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
});
it("calls onToggle when clicked (revealed=true → should hide)", () => {
const onToggle = vi.fn();
render(<RevealToggle revealed={true} onToggle={onToggle} />);
fireEvent.click(screen.getByRole("button"));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("calls onToggle when clicked (revealed=false → should show)", () => {
const onToggle = vi.fn();
render(<RevealToggle revealed={false} onToggle={onToggle} />);
fireEvent.click(screen.getByRole("button"));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("renders the eye-open SVG (hide icon) when revealed=false", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const btn = screen.getByRole("button");
// The eye SVG contains a circle element; eye-off has a strikethrough line
expect(btn.querySelector("circle")).toBeTruthy();
expect(btn.querySelectorAll("line")).toHaveLength(0);
});
it("renders the eye-off SVG (show icon) when revealed=true", () => {
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
const btn = screen.getByRole("button");
// EyeOffIcon has a line (strikethrough) through the eye
expect(btn.querySelectorAll("line")).toHaveLength(1);
});
});
@@ -1,88 +0,0 @@
// @vitest-environment jsdom
/**
* StatusBadge — secret key connection status indicator.
*
* Per spec §4: always icon + color (never colour-only) for colour-blind users.
* Covers: verified / invalid / unverified render branches, icon, aria-label, className.
*/
import { afterEach, describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import React from "react";
import { StatusBadge } from "../StatusBadge";
afterEach(() => {
// Prevent DOM accumulation across tests (maxWorkers=1 means all test
// files share the same jsdom worker).
const { cleanup } = require("@testing-library/react");
cleanup();
});
function getBadge(status: "verified" | "invalid" | "unverified") {
const { container } = render(<StatusBadge status={status} />);
return container.querySelector("[role=status]") as HTMLElement;
}
describe("StatusBadge — icon", () => {
it("renders ✓ for verified", () => {
expect(getBadge("verified").textContent).toBe("✓");
});
it("renders ✗ for invalid", () => {
expect(getBadge("invalid").textContent).toBe("✗");
});
it("renders ○ for unverified", () => {
expect(getBadge("unverified").textContent).toBe("○");
});
});
describe("StatusBadge — aria-label", () => {
it("sets 'Connection status: verified' for verified", () => {
expect(getBadge("verified").getAttribute("aria-label")).toBe(
"Connection status: verified",
);
});
it("sets 'Connection status: invalid' for invalid", () => {
expect(getBadge("invalid").getAttribute("aria-label")).toBe(
"Connection status: invalid",
);
});
it("sets 'Connection status: unverified' for unverified", () => {
expect(getBadge("unverified").getAttribute("aria-label")).toBe(
"Connection status: unverified",
);
});
});
describe("StatusBadge — className", () => {
it("applies status-badge--valid for verified", () => {
expect(getBadge("verified").className).toContain("status-badge--valid");
});
it("applies status-badge--invalid for invalid", () => {
expect(getBadge("invalid").className).toContain("status-badge--invalid");
});
it("applies status-badge--unverified for unverified", () => {
expect(getBadge("unverified").className).toContain(
"status-badge--unverified",
);
});
});
describe("StatusBadge — role", () => {
it("sets role=status", () => {
const el = getBadge("verified");
expect(el.getAttribute("role")).toBe("status");
});
});
describe("StatusBadge — structural", () => {
it("renders exactly one status element", () => {
const { container } = render(<StatusBadge status="verified" />);
expect(container.querySelectorAll("[role=status]").length).toBe(1);
});
});
@@ -1,49 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ValidationHint component.
*
* Covers: null/neutral render, error state (red ⚠ + message), valid state
* (green ✓ + "Valid format"), ARIA role="alert" on error.
*/
import { afterEach, describe, it, expect } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { ValidationHint } from "../ValidationHint";
afterEach(cleanup);
describe("ValidationHint", () => {
it("renders nothing when error is null and showValid is false", () => {
const { container } = render(<ValidationHint error={null} showValid={false} />);
expect(container.innerHTML).toBe("");
});
it("renders nothing when error is null and showValid is undefined", () => {
const { container } = render(<ValidationHint error={null} />);
expect(container.innerHTML).toBe("");
});
it("renders error state with ⚠ icon and message", () => {
render(<ValidationHint error="Key name must be UPPER_SNAKE_CASE" />);
const el = screen.getByRole("alert");
expect(el.textContent).toContain("⚠");
expect(el.textContent).toContain("Key name must be UPPER_SNAKE_CASE");
});
it("renders valid state with ✓ and 'Valid format'", () => {
render(<ValidationHint error={null} showValid />);
const el = screen.getByText("Valid format");
expect(el.textContent).toContain("✓");
});
it("prefers error over valid when both are set (error is not null)", () => {
// ValidationHint checks error first; showValid is only rendered when error is falsy.
render(<ValidationHint error="Some error" showValid />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.queryByText("Valid format")).toBeNull();
});
it("error alert has role='alert' for screen readers", () => {
render(<ValidationHint error="Invalid format" />);
expect(screen.getByRole("alert")).toBeTruthy();
});
});
-1
View File
@@ -44,4 +44,3 @@
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
]
}
// Triggered by Integration Tester at 2026-05-10T08:52Z
+7 -6
View File
@@ -35,11 +35,11 @@ Specifically:
### Affected workflows
| Workflow | Issue | Workaround |
| Workflow | Issue | Fix |
|---|---|---|
| `harness-replays.yml` detect-changes job | `fetch-depth: 0` + `git clone` time out | Added `timeout 20 git fetch origin base.ref --depth=1` + `continue-on-error: true` + fallback to `run=true` per PR #441 |
| `harness-replays.yml` detect-changes | `fetch-depth: 0` + `git clone` time out | Use Gitea Compare API (Gitea→Gitea, no runner network needed) — **primary fix** (PR #476) |
| `publish-workspace-server-image.yml` | In-image `git clone` of workspace templates | Pre-clone manifest deps before compose build (Task #173 pattern) |
| Any workflow using `fetch-depth: 0` | Full history fetch times out | Use `fetch-depth: 1` + explicit `git fetch` for needed refs |
| Any workflow using `fetch-depth: 0` | Full history fetch times out | Use `fetch-depth: 1` + Compare API for changed-file detection |
### How to diagnose
@@ -60,7 +60,8 @@ confirming this is a repo-size constraint, not network isolation.
### References
- PR #441: fix for `harness-replays.yml` detect-changes
- PR #476: **primary fix** — use Gitea Compare API instead of git fetch/diff
- PR #441: legacy timeout+fallback fix (now superseded by PR #476)
- Task #173: pre-clone manifest deps pattern for compose build
- internal#102: tracking customer-private + marketplace third-party repos
- `feedback_oss_first_repo_visibility_default`: 5 workspace-template repos
@@ -89,7 +90,7 @@ exits with code 0 (e.g., append `|| true` to commands that might fail).
| Workflow | Fix |
|---|---|
| `harness-replays.yml` detect-changes | Added `continue-on-error: true` to fetch step + decide step; added `|| true` to `DIFF=$(git diff ...)` per PR #441 |
| `harness-replays.yml` detect-changes | Added `continue-on-error: true` to fetch step + decide step; replaced git diff with Compare API per PR #476 |
### How to diagnose
@@ -113,7 +114,7 @@ jobs:
### References
- Gitea Actions quirk #10 (from migration checklist)
- PR #441: fix applied to `harness-replays.yml`
- PR #476: Compare API fix applied to `harness-replays.yml`
---
+4 -15
View File
@@ -34,17 +34,6 @@ WS_DIR="${2:?Missing workspace-templates dir}"
ORG_DIR="${3:?Missing org-templates dir}"
PLUGINS_DIR="${4:?Missing plugins dir}"
# Strip JSON5-style // comments from manifest.json before parsing.
# The automated Integration Tester appends a trailing comment
# (// Triggered by ... ) which is valid JSON5 but not standard JSON.
# jq's default parser rejects it. This sed removes only full-line comments
# (lines starting with optional whitespace followed by //) before jq reads the file.
_strip_comments() {
# Remove full-line // comments (whitespace-safe); pass-through for non-comment lines
sed 's/^[[:space:]]*\/\/.*//' "$MANIFEST"
}
MANIFEST_JSON="$(_strip_comments)"
EXPECTED=0
CLONED=0
@@ -99,15 +88,15 @@ clone_category() {
mkdir -p "$target_dir"
local count
count=$(echo "$MANIFEST_JSON" | jq -r ".${category} | length")
count=$(jq -r ".${category} | length" "$MANIFEST")
EXPECTED=$((EXPECTED + count))
local i=0
while [ "$i" -lt "$count" ]; do
local name repo ref
name=$(echo "$MANIFEST_JSON" | jq -r ".${category}[$i].name")
repo=$(echo "$MANIFEST_JSON" | jq -r ".${category}[$i].repo")
ref=$(echo "$MANIFEST_JSON" | jq -r ".${category}[$i].ref // \"main\"")
name=$(jq -r ".${category}[$i].name" "$MANIFEST")
repo=$(jq -r ".${category}[$i].repo" "$MANIFEST")
ref=$(jq -r ".${category}[$i].ref // \"main\"" "$MANIFEST")
# Idempotent: skip if the target already looks populated. Lets the
# README quickstart rerun setup.sh safely without having to delete
-775
View File
@@ -1,775 +0,0 @@
"""Tests for `.gitea/scripts/status-reaper.py` — Option B compensating
status POST for Gitea 1.22.6's hardcoded `(push)` suffix bug.
Coverage (per hongming-pc 22:08Z review + brief):
1. test_workflow_with_name_field
2. test_workflow_without_name_field (filename stem fallback)
3. test_workflow_name_collision_fails_loud
4. test_workflow_name_with_slash_fails_loud
5. test_has_push_trigger_true (dict shape, list shape, str shape)
6. test_has_push_trigger_false (schedule-only, dispatch-only,
pull_request-only, workflow_run-only)
7. test_publish_workspace_server_image_preserved (explicit case)
8. test_compensating_post_payload (POST body shape verification)
Plus regression coverage:
- parse_push_context strictness (only ` (push)` suffix with ` / `
separator triggers compensation).
- Class-O detection via end-to-end reap() with a stubbed api().
- ApiError propagation on non-2xx (mirror of main-red-watchdog's
`feedback_api_helper_must_raise_not_return_dict` test).
- Unknown-workflow conservatism: ::notice:: + skip, never POST.
- Non-`(push)`-suffix contexts (the `(pull_request)` required-checks
on main) are NEVER touched — verified safe 2026-05-11.
Hostile self-review proof:
- test_required_check_pull_request_suffix_never_touched exercises
the safety contract: a pre-fix that compensated any failing
context would mask the Secret scan required-check. Verified by
stashing the `endswith(PUSH_SUFFIX)` guard and re-running: test
FAILS as required.
- test_workflow_name_collision_fails_loud asserts exit code 1; a
pre-fix that "first write wins" would silently misclassify a
renamed workflow.
Run:
python3 -m pytest tests/test_status_reaper.py -v
Dependencies: stdlib + pytest + PyYAML. No network.
"""
from __future__ import annotations
import importlib.util
import json
import os
import sys
from pathlib import Path
from unittest import mock
import pytest
# --------------------------------------------------------------------------
# Module-import fixture
# --------------------------------------------------------------------------
SCRIPT_PATH = (
Path(__file__).resolve().parent.parent
/ ".gitea"
/ "scripts"
/ "status-reaper.py"
)
@pytest.fixture(scope="module")
def sr_module():
"""Import the script as a module under a known env."""
env = {
"GITEA_TOKEN": "test-token",
"GITEA_HOST": "git.example.test",
"REPO": "owner/repo",
"WATCH_BRANCH": "main",
"WORKFLOWS_DIR": ".gitea/workflows",
}
with mock.patch.dict(os.environ, env, clear=False):
spec = importlib.util.spec_from_file_location("status_reaper", SCRIPT_PATH)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
m.GITEA_TOKEN = env["GITEA_TOKEN"]
m.GITEA_HOST = env["GITEA_HOST"]
m.REPO = env["REPO"]
m.WATCH_BRANCH = env["WATCH_BRANCH"]
m.WORKFLOWS_DIR = env["WORKFLOWS_DIR"]
m.OWNER, m.NAME = "owner", "repo"
m.API = f"https://{env['GITEA_HOST']}/api/v1"
yield m
# --------------------------------------------------------------------------
# Workflow scan tests — workflow_id resolution
# --------------------------------------------------------------------------
def _write_workflow(tmp_path: Path, filename: str, content: str) -> Path:
"""Write a workflow YAML to a temp dir and return its path."""
d = tmp_path / "workflows"
d.mkdir(exist_ok=True)
p = d / filename
p.write_text(content)
return p
def test_workflow_with_name_field(sr_module, tmp_path):
"""`name:` field beats filename stem."""
_write_workflow(
tmp_path,
"publish-runtime.yml",
"name: publish-runtime\non:\n push:\n branches: [main]\n",
)
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
assert "publish-runtime" in out
assert out["publish-runtime"] is True
def test_workflow_without_name_field(sr_module, tmp_path):
"""No `name:` → filename stem (basename minus `.yml`)."""
_write_workflow(
tmp_path,
"no-name-workflow.yml",
"on:\n schedule:\n - cron: '*/5 * * * *'\n",
)
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
assert "no-name-workflow" in out
assert out["no-name-workflow"] is False # schedule-only → class-O
def test_workflow_name_collision_fails_loud(sr_module, tmp_path, capsys):
"""Two workflows resolving to the same name → exit 1 with ::error::."""
_write_workflow(
tmp_path,
"a.yml",
"name: same-name\non:\n push: {}\n",
)
_write_workflow(
tmp_path,
"b.yml",
"name: same-name\non:\n schedule:\n - cron: '0 * * * *'\n",
)
with pytest.raises(SystemExit) as excinfo:
sr_module.scan_workflows(str(tmp_path / "workflows"))
assert excinfo.value.code == 1
captured = capsys.readouterr()
assert "::error::workflow name collision detected: same-name" in captured.err
def test_workflow_name_with_slash_fails_loud(sr_module, tmp_path, capsys):
"""`name:` containing `/` → exit 1 with ::error:: (breaks context parse)."""
_write_workflow(
tmp_path,
"weird.yml",
"name: my/weird/name\non:\n push: {}\n",
)
with pytest.raises(SystemExit) as excinfo:
sr_module.scan_workflows(str(tmp_path / "workflows"))
assert excinfo.value.code == 1
captured = capsys.readouterr()
assert "::error::workflow name contains '/'" in captured.err
assert "my/weird/name" in captured.err
def test_workflow_name_with_slash_via_filename_stem_fails_loud(sr_module, tmp_path, capsys):
"""Even if filename stem contains `/` (path-flavoured stem) we trip the
same guard. Defensive — Path.stem strips `/` so this can't happen via
real filesystems, but the guard catches it if someone synthesises a
map from a non-filesystem source in future."""
# Force the filename-stem path by writing a no-name workflow whose
# PARENT path has a `/` — but Path.stem only takes the basename, so
# we instead mock _on_block / iterate manually. Easier: assert the
# in-code check directly.
# The `/` guard runs on `workflow_id`. Test it via an explicit name
# field workflow (already covered) — this test is left as a
# docstring-only marker that the filename-stem path can't ever
# produce a `/` (Path.stem strips it).
assert True # No-op: Path.stem strips `/`; documented invariant.
def test_workflow_empty_name_falls_back_to_stem(sr_module, tmp_path):
"""Empty `name:` (just whitespace) should fall back to filename stem."""
_write_workflow(
tmp_path,
"stem-fallback.yml",
"name: ' '\non:\n push: {}\n",
)
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
assert "stem-fallback" in out # filename stem used
assert out["stem-fallback"] is True
# --------------------------------------------------------------------------
# has_push_trigger tests
# --------------------------------------------------------------------------
def test_has_push_trigger_true_dict(sr_module):
assert sr_module._has_push_trigger({"push": {}, "schedule": []}, "w") is True
def test_has_push_trigger_true_dict_with_paths(sr_module):
"""`on: { push: { paths: ['workspace/**'] } }` → still push-triggered."""
assert (
sr_module._has_push_trigger(
{"push": {"paths": ["workspace/**"]}}, "w"
)
is True
)
def test_has_push_trigger_true_list(sr_module):
assert sr_module._has_push_trigger(["push", "pull_request"], "w") is True
def test_has_push_trigger_true_str(sr_module):
assert sr_module._has_push_trigger("push", "w") is True
def test_has_push_trigger_false_schedule_only(sr_module):
"""Schedule-only workflow (class-O canonical)."""
assert (
sr_module._has_push_trigger(
{"schedule": [{"cron": "0 * * * *"}]}, "w"
)
is False
)
def test_has_push_trigger_false_dispatch_only(sr_module):
assert sr_module._has_push_trigger({"workflow_dispatch": {}}, "w") is False
def test_has_push_trigger_false_pull_request_only(sr_module):
"""`on: { pull_request: {...} }` only → no push trigger."""
assert sr_module._has_push_trigger({"pull_request": {}}, "w") is False
def test_has_push_trigger_false_workflow_run_only(sr_module):
"""`on: { workflow_run: {...} }` → no push trigger.
(Even though Gitea 1.22.6 doesn't fire workflow_run, the classifier
must handle YAML that declares it — for forward-compat.)"""
assert sr_module._has_push_trigger({"workflow_run": {}}, "w") is False
def test_has_push_trigger_false_list_no_push(sr_module):
assert (
sr_module._has_push_trigger(["pull_request", "schedule"], "w") is False
)
def test_has_push_trigger_ambiguous_preserves(sr_module, capsys):
"""Unknown shape → True (preserve, never compensate) + log ::notice::."""
assert sr_module._has_push_trigger(42, "weird-workflow") is True
captured = capsys.readouterr()
assert "::notice::ambiguous on: for weird-workflow" in captured.out
def test_has_push_trigger_none_preserves(sr_module, capsys):
"""None `on:` block → True (preserve)."""
assert sr_module._has_push_trigger(None, "no-on") is True
captured = capsys.readouterr()
assert "::notice::ambiguous on:" in captured.out
# --------------------------------------------------------------------------
# Real-world fixture: publish-workspace-server-image preserved
# --------------------------------------------------------------------------
def test_publish_workspace_server_image_preserved(sr_module, tmp_path):
"""Explicit case per brief: real `push` trigger → preserve, even
when failing. Protects mc#576 (currently red on docker-socket issue).
"""
_write_workflow(
tmp_path,
"publish-workspace-server-image.yml",
"name: publish-workspace-server-image\n"
"on:\n"
" push:\n"
" branches: [main]\n"
" paths: ['workspace/**']\n"
" workflow_dispatch:\n",
)
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
assert out["publish-workspace-server-image"] is True
# --------------------------------------------------------------------------
# Context parsing
# --------------------------------------------------------------------------
def test_parse_push_context_canonical(sr_module):
"""`<workflow_name> / <job_name> (push)` → (workflow_name, job_name)."""
parsed = sr_module.parse_push_context("staging-smoke / smoke (push)")
assert parsed == ("staging-smoke", "smoke")
def test_parse_push_context_workflow_name_with_spaces(sr_module):
"""Workflow name with spaces — common (`Continuous synthetic E2E`)."""
parsed = sr_module.parse_push_context(
"Continuous synthetic E2E (staging) / e2e (push)"
)
assert parsed == ("Continuous synthetic E2E (staging)", "e2e")
def test_parse_push_context_non_push_suffix_returns_none(sr_module):
"""`(pull_request)` suffix → None (not the bug shape; required-checks)."""
assert (
sr_module.parse_push_context("Secret scan / Scan diff (pull_request)")
is None
)
def test_parse_push_context_no_separator_returns_none(sr_module):
"""`(push)` suffix but no ` / ` → None (not the bug shape)."""
assert sr_module.parse_push_context("just-a-context (push)") is None
def test_parse_push_context_no_suffix_returns_none(sr_module):
assert sr_module.parse_push_context("workflow / job") is None
# --------------------------------------------------------------------------
# Compensating POST payload shape
# --------------------------------------------------------------------------
def test_compensating_post_payload(sr_module, monkeypatch):
"""POST /statuses/{sha} body: state=success, context preserved,
description = COMPENSATION_DESCRIPTION, target_url echoed if present.
"""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body, query))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
sr_module.post_compensating_status(
"deadbeefcafe1234567890abcdef000011112222",
"staging-smoke / smoke (push)",
"https://git.example.test/owner/repo/actions/runs/14525",
dry_run=False,
)
assert len(calls) == 1
method, path, body, _query = calls[0]
assert method == "POST"
assert path == "/repos/owner/repo/statuses/deadbeefcafe1234567890abcdef000011112222"
assert body == {
"context": "staging-smoke / smoke (push)",
"state": "success",
"description": sr_module.COMPENSATION_DESCRIPTION,
"target_url": "https://git.example.test/owner/repo/actions/runs/14525",
}
def test_compensating_post_payload_no_target_url(sr_module, monkeypatch):
"""target_url is optional — omitted when the original status had none."""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body, query))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
sr_module.post_compensating_status(
"abc1234567",
"x / y (push)",
None,
dry_run=False,
)
assert calls[0][2] == {
"context": "x / y (push)",
"state": "success",
"description": sr_module.COMPENSATION_DESCRIPTION,
}
def test_compensating_post_dry_run_no_api_call(sr_module, monkeypatch, capsys):
"""--dry-run must NOT POST."""
def fake_api(*args, **kwargs):
raise AssertionError("api() should not be called in dry_run")
monkeypatch.setattr(sr_module, "api", fake_api)
sr_module.post_compensating_status(
"deadbeefcafe1234567890abcdef000011112222",
"ci/test (push)",
None,
dry_run=True,
)
captured = capsys.readouterr()
assert "::notice::[dry-run] would compensate" in captured.out
# --------------------------------------------------------------------------
# End-to-end reap() — class-O detection
# --------------------------------------------------------------------------
SHA = "deadbeefcafe1234567890abcdef000011112222"
def test_reap_compensates_class_o(sr_module, monkeypatch):
"""schedule-only workflow with failing `(push)` status → compensate."""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"staging-smoke": False} # no push trigger
combined = {
"state": "failure",
"statuses": [
{
"context": "staging-smoke / smoke (push)",
"state": "failure",
"target_url": "https://example.test/run/1",
"description": "smoke job failed",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 1
assert counters["preserved_real_push"] == 0
assert len(calls) == 1
assert calls[0][0] == "POST"
assert calls[0][1] == f"/repos/owner/repo/statuses/{SHA}"
def test_reap_preserves_real_push(sr_module, monkeypatch):
"""publish-workspace-server-image (has push trigger) → preserve."""
calls = []
def fake_api(*args, **kwargs):
calls.append((args, kwargs))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"publish-workspace-server-image": True}
combined = {
"state": "failure",
"statuses": [
{
"context": "publish-workspace-server-image / build (push)",
"state": "failure",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 0
assert counters["preserved_real_push"] == 1
assert calls == [] # NO POST
def test_reap_preserves_unknown_workflow(sr_module, monkeypatch, capsys):
"""Workflow not in map → ::notice:: + skip (conservative)."""
monkeypatch.setattr(
sr_module, "api",
lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("api should not be called")
),
)
workflow_map = {} # empty map
combined = {
"state": "failure",
"statuses": [
{
"context": "deleted-workflow / job (push)",
"state": "failure",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 0
assert counters["preserved_unknown"] == 1
captured = capsys.readouterr()
assert "::notice::unknown workflow 'deleted-workflow'" in captured.out
def test_reap_required_check_pull_request_suffix_never_touched(sr_module, monkeypatch):
"""SAFETY CONTRACT: `(pull_request)` suffix contexts (the actual
required-checks on main) are NEVER touched. A pre-fix that
compensated any failure would mask Secret scan.
"""
calls = []
def fake_api(*args, **kwargs):
calls.append((args, kwargs))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
# Even with the workflow mapped as no-push-trigger (which would
# normally compensate), the suffix guard prevents the POST.
workflow_map = {"Secret scan": False}
combined = {
"state": "failure",
"statuses": [
{
"context": "Secret scan / Scan diff for credential-shaped strings (pull_request)",
"state": "failure",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 0
assert counters["preserved_non_push_suffix"] == 1
assert calls == []
def test_reap_ignores_non_failure_states(sr_module, monkeypatch):
"""Only `failure` is compensated. `pending` / `success` / `error`
left alone — they have legitimate semantics."""
monkeypatch.setattr(
sr_module, "api",
lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("api should not be called")
),
)
workflow_map = {"sweep-cf-tunnels": False}
combined = {
"state": "pending",
"statuses": [
{"context": "sweep-cf-tunnels / sweep (push)", "state": "pending"},
{"context": "sweep-cf-tunnels / sweep (push)", "state": "success"},
{"context": "sweep-cf-tunnels / sweep (push)", "state": "error"},
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 0
assert counters["preserved_non_failure"] == 3
def test_reap_unparseable_push_context_preserved(sr_module, monkeypatch):
"""`(push)` suffix but no ` / ` separator → not the bug shape, preserve."""
monkeypatch.setattr(
sr_module, "api",
lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("api should not be called")
),
)
workflow_map = {"x": False}
combined = {
"state": "failure",
"statuses": [
{"context": "no-slash-here (push)", "state": "failure"},
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 0
assert counters["preserved_unparseable"] == 1
# --------------------------------------------------------------------------
# ApiError propagation
# --------------------------------------------------------------------------
def test_get_head_sha_raises_on_non_2xx(sr_module, monkeypatch):
"""ApiError on transient outage propagates per
`feedback_api_helper_must_raise_not_return_dict`."""
def fake_api(method, path, **kwargs):
raise sr_module.ApiError("GET /branches/main -> HTTP 500: nope")
monkeypatch.setattr(sr_module, "api", fake_api)
with pytest.raises(sr_module.ApiError):
sr_module.get_head_sha("main")
def test_get_combined_status_raises_on_non_2xx(sr_module, monkeypatch):
def fake_api(method, path, **kwargs):
raise sr_module.ApiError("GET /status -> HTTP 500: nope")
monkeypatch.setattr(sr_module, "api", fake_api)
with pytest.raises(sr_module.ApiError):
sr_module.get_combined_status("deadbeef")
def test_get_head_sha_missing_commit_raises(sr_module, monkeypatch):
"""A malformed 200 response (no `commit` field) raises ApiError."""
monkeypatch.setattr(
sr_module, "api", lambda m, p, **kw: (200, {"name": "main"})
)
with pytest.raises(sr_module.ApiError):
sr_module.get_head_sha("main")
# --------------------------------------------------------------------------
# scan_workflows on real repo (smoke)
# --------------------------------------------------------------------------
def test_scan_workflows_on_real_repo_no_collision(sr_module):
"""Smoke: scan the actual .gitea/workflows/ in this repo. Asserts
no real-world collision/`/`-in-name lurks. If this fails, a real
workflow file must be fixed before reaper can ship."""
real_dir = str(SCRIPT_PATH.parent.parent / "workflows")
# Should NOT raise SystemExit — collision/slash guards must pass.
out = sr_module.scan_workflows(real_dir)
assert len(out) > 0
# publish-workspace-server-image is the canonical preserved case.
assert out.get("publish-workspace-server-image") is True
# main-red-watchdog is the canonical class-O case.
assert out.get("main-red-watchdog") is False
# ci is the canonical required-check (push+pull_request).
assert out.get("CI") is True or out.get("ci") is True
def test_scan_workflows_missing_dir_returns_empty(sr_module, tmp_path, capsys):
"""Missing workflows dir → empty map + ::warning::."""
out = sr_module.scan_workflows(str(tmp_path / "nope"))
assert out == {}
captured = capsys.readouterr()
assert "::warning::workflows dir not found" in captured.out
# --------------------------------------------------------------------------
# rev2: multi-SHA sweep — `reap_branch()` walks last N main commits
# --------------------------------------------------------------------------
# Phase 1+2 evidence (orchestrator + hongming-pc2): rev1 sees `compensated:0`
# every tick because the schedule workflow posts `failure` to whatever SHA
# was HEAD when it COMPLETED. By the next */5 tick, main has often moved
# forward, so the single-HEAD reaper misses the stranded red. rev2 sweeps
# the last 10 commits each tick. See `reference_post_suspension_pipeline`
# and parent rev1 PR #618 for context.
SHA_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
SHA_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
SHA_C = "cccccccccccccccccccccccccccccccccccccccc"
def test_reap_sweeps_n_shas_smoke(sr_module, monkeypatch):
"""rev2 contract: sweep last 10 (or N) main commits, GET combined
status for EACH. Smoke: with 3 stub SHAs, each is GET'd exactly once.
"""
gets: list[str] = []
posts: list[tuple[str, dict]] = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path.endswith("/commits"):
# commits listing — return 3 fake commit objects
return (200, [{"sha": SHA_A}, {"sha": SHA_B}, {"sha": SHA_C}])
if method == "GET" and "/commits/" in path and path.endswith("/status"):
sha = path.split("/commits/")[1].split("/status")[0]
gets.append(sha)
# All combined=success → cost-optimization short-circuit
return (200, {"state": "success", "statuses": []})
if method == "POST":
posts.append((path, body))
return (201, {})
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"x": False}
counters = sr_module.reap_branch(
workflow_map, "main", limit=10, dry_run=False
)
# Each of the 3 SHAs returned by /commits should be GET'd once.
assert gets == [SHA_A, SHA_B, SHA_C]
# No POST (everything was combined=success).
assert posts == []
# Counters reflect what we saw.
assert counters["scanned_shas"] == 3
assert counters["compensated"] == 0
assert counters["compensated_per_sha"] == {}
def test_reap_skips_combined_success_shas(sr_module, monkeypatch):
"""rev2 cost-optimization (refinement #2): when combined==success for
a SHA, do NOT iterate per-context statuses; move on to next SHA.
Mock 2 SHAs with combined=success + 1 with combined=failure → only
the failure-SHA's statuses get the per-context loop applied.
"""
per_context_iterated_for: list[str] = []
posts: list[tuple[str, dict]] = []
failure_statuses = [
{
"context": "drift / drift (push)",
"state": "failure",
"target_url": "https://example.test/run/42",
}
]
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path.endswith("/commits"):
return (200, [{"sha": SHA_A}, {"sha": SHA_B}, {"sha": SHA_C}])
if method == "GET" and "/commits/" in path and path.endswith("/status"):
sha = path.split("/commits/")[1].split("/status")[0]
if sha == SHA_B:
# Mark this SHA as the failure one — return per-context
# statuses that would compensate if iterated.
return (200, {"state": "failure", "statuses": failure_statuses})
# Others are combined=success — must short-circuit.
return (200, {"state": "success", "statuses": failure_statuses})
if method == "POST":
# If a POST hits a non-failure SHA, the short-circuit failed.
posts.append((path, body))
return (201, {})
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(sr_module, "api", fake_api)
# Workflow trigger map: `drift` is schedule-only (compensable).
workflow_map = {"drift": False}
counters = sr_module.reap_branch(
workflow_map, "main", limit=10, dry_run=False
)
# Only SHA_B (the combined=failure one) should be compensated.
assert counters["compensated"] == 1
assert counters["scanned_shas"] == 3
assert SHA_B in counters["compensated_per_sha"]
assert counters["compensated_per_sha"][SHA_B] == ["drift / drift (push)"]
# SHA_A and SHA_C must NOT appear in compensated_per_sha — their
# per-context loop was skipped via the combined=success short-circuit.
assert SHA_A not in counters["compensated_per_sha"]
assert SHA_C not in counters["compensated_per_sha"]
# Exactly one POST: the compensation on SHA_B.
assert len(posts) == 1
assert posts[0][0] == f"/repos/owner/repo/statuses/{SHA_B}"
def test_reap_continues_on_per_sha_apierror(sr_module, monkeypatch, capsys):
"""rev2 refinement #7 (MOST CRITICAL): a transient ApiError or HTTP-5xx
on get_combined_status(SHA_X) must NOT fail the whole tick. Log + skip
SHA_X, continue with SHA_Y.
Different from the single-HEAD path (where fail-loud is correct): the
sweep is best-effort across historical commits, so one transient blip
on a stale SHA should not strand reds on the OTHER stale SHAs.
"""
posts: list[tuple[str, dict]] = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path.endswith("/commits"):
return (200, [{"sha": SHA_A}, {"sha": SHA_B}])
if method == "GET" and "/commits/" in path and path.endswith("/status"):
sha = path.split("/commits/")[1].split("/status")[0]
if sha == SHA_A:
raise sr_module.ApiError(
f"GET /repos/owner/repo/commits/{SHA_A}/status "
f"-> HTTP 502: bad gateway"
)
# SHA_B returns normally with a failure to compensate.
return (
200,
{
"state": "failure",
"statuses": [
{
"context": "drift / drift (push)",
"state": "failure",
}
],
},
)
if method == "POST":
posts.append((path, body))
return (201, {})
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"drift": False}
# Must NOT raise — per-SHA error isolation contract.
counters = sr_module.reap_branch(
workflow_map, "main", limit=10, dry_run=False
)
# SHA_A was logged + skipped. SHA_B processed normally.
assert counters["scanned_shas"] == 2
assert counters["compensated"] == 1
assert SHA_B in counters["compensated_per_sha"]
assert SHA_A not in counters["compensated_per_sha"]
# Compensation POST landed on SHA_B only.
assert len(posts) == 1
assert posts[0][0] == f"/repos/owner/repo/statuses/{SHA_B}"
# The ApiError must be logged so a human auditing tick output can see
# WHICH SHA blipped and WHY.
captured = capsys.readouterr()
assert "::warning::" in captured.out or "::notice::" in captured.out
assert SHA_A[:10] in captured.out
+25 -51
View File
@@ -35,12 +35,6 @@ GITEA_HOST = os.environ.get("GITEA_HOST", "git.moleculesai.app")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", os.environ.get("GITHUB_TOKEN", ""))
API_BASE = f"https://{GITEA_HOST}/api/v1"
# Timeout in seconds for all HTTP calls. Defence-in-depth: ensures a missing or
# invalid SOP_TIER_CHECK_TOKEN causes a fast (~15 s) failure rather than an
# indefinite hang. The real fix is provisioning the token; this caps worst-case
# wall-clock on a broken/unreachable Gitea host.
DEFAULT_TIMEOUT = 15
def api_get(path: str) -> dict | list:
url = f"{API_BASE}{path}"
@@ -52,7 +46,7 @@ def api_get(path: str) -> dict | list:
},
)
try:
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
@@ -322,7 +316,7 @@ def signal_3_staleness(pr_number: int, repo: str) -> dict:
# ── Signal 6: CI required-checks awareness ───────────────────────────────────
def signal_6_ci(pr_number: int, repo: str, branch: str | None = None, pr_data: dict | None = None) -> dict:
def signal_6_ci(pr_number: int, repo: str, branch: str = "main") -> dict:
"""
Query combined CI status for PR head commit.
Find required status checks on target branch.
@@ -330,12 +324,8 @@ def signal_6_ci(pr_number: int, repo: str, branch: str | None = None, pr_data: d
"""
owner, name = repo.split("/", 1)
# Re-use PR data if already fetched by caller; otherwise fetch once.
if pr_data is None:
pr_data = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
head_sha = pr_data["head"]["sha"]
# Fall back to PR's actual base branch when no explicit branch is given
branch = branch or pr_data.get("base", {}).get("ref", "main")
pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
head_sha = pr["head"]["sha"]
# Combined status of PR head
combined = api_get(f"/repos/{owner}/{name}/commits/{head_sha}/status")
@@ -344,12 +334,9 @@ def signal_6_ci(pr_number: int, repo: str, branch: str | None = None, pr_data: d
# Individual check statuses
# Gitea Actions uses "status" (pending/success/failure) not "state" for
# individual check entries. "state" is null for pending runs.
# Exclude our own prior status to prevent self-referential failure loops.
check_statuses = {}
for s in combined.get("statuses") or []:
ctx = s["context"]
if "gate-check" not in ctx.lower():
check_statuses[ctx] = s.get("status", "pending")
check_statuses[s["context"]] = s.get("status", "pending")
# Try to get branch protection for required checks
required_checks = []
@@ -371,17 +358,10 @@ def signal_6_ci(pr_number: int, repo: str, branch: str | None = None, pr_data: d
else:
passing_required.append(f"{ctx} (pending)")
# NOTE: do NOT use ci_state (combined_state) as a fallback verdict driver.
# The combined_state is computed over ALL statuses including this
# gate-check's own prior result. Using it as a fallback creates a
# self-referential loop: gate-check posts failure → combined_state
# becomes failure → script re-blocks → posts failure again.
# The check_statuses dict already excludes gate-check (Bug-1 fix from
# PR #547). Use failing_required as the sole CI gate; if no required
# checks are defined on the branch, return CLEAR rather than re-using
# the combined_state which includes our own status.
if failing_required:
verdict = "CI_FAIL"
elif ci_state == "failure":
verdict = "CI_FAIL"
elif ci_state == "pending":
verdict = "CI_PENDING"
else:
@@ -479,21 +459,21 @@ def format_comment(repo: str, pr_number: int, verdict: str, gates: list[dict], b
lines.append(f"_gate-check-v3 · repo={repo} · pr={pr_number}_")
return "\n".join(lines)
lines.append("")
lines.append(f"_gate-check-v3 · repo={repo} · pr={pr_number}_")
return "\n".join(lines)
# ── Main ─────────────────────────────────────────────────────────────────────
def run(repo: str, pr_number: int, post_comment: bool = False) -> dict:
try:
# Fetch PR once to get base ref for signal_6_ci
owner, name = repo.split("/", 1)
pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
base_ref = pr.get("base", {}).get("ref", "main")
gates = [
signal_1_comment_scan(pr_number, repo),
signal_2_reviews(pr_number, repo),
signal_3_staleness(pr_number, repo),
signal_6_ci(pr_number, repo, branch=base_ref, pr_data=pr),
signal_6_ci(pr_number, repo),
]
verdict, blockers = compute_verdict(gates)
@@ -521,24 +501,18 @@ def run(repo: str, pr_number: int, post_comment: bool = False) -> dict:
# Check if a gate-check comment already exists to avoid spamming
existing = api_list(f"/repos/{owner}/{name}/issues/{pr_number}/comments")
our_comments = [c for c in existing if "[gate-check-v3]" in (c.get("body") or "")]
try:
if our_comments:
# Update latest
comment_id = our_comments[-1]["id"]
url = f"{API_BASE}/repos/{owner}/{name}/issues/comments/{comment_id}"
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="PATCH")
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
r.read()
else:
url = f"{API_BASE}/repos/{owner}/{name}/issues/{pr_number}/comments"
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
r.read()
except urllib.error.HTTPError as e:
if e.code == 403:
print(f"WARN: --post-comment 403 (token scope) — verdict={verdict}; skipping comment-post", file=sys.stderr)
else:
raise
if our_comments:
# Update latest
comment_id = our_comments[-1]["id"]
url = f"{API_BASE}/repos/{owner}/{name}/issues/comments/{comment_id}"
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="PATCH")
with urllib.request.urlopen(req) as r:
r.read()
else:
url = f"{API_BASE}/repos/{owner}/{name}/issues/{pr_number}/comments"
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="POST")
with urllib.request.urlopen(req) as r:
r.read()
return result
@@ -983,16 +983,7 @@ func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
WithArgs("dispatched", "", testSourceID, testDelegationID).
WillReturnResult(sqlmock.NewResult(0, 1))
// CanCommunicate: source != target → fires two getWorkspaceRef lookups.
// Both test fixtures have parent_id = NULL (root-level siblings) → allowed.
// Order matches call order: source first, then target.
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
WithArgs(testSourceID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testSourceID, nil))
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
WithArgs(testTargetID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testTargetID, nil))
// CanCommunicate (source=target self-call is always allowed — no DB lookup needed)
// resolveAgentURL: reads ws:{id}:url from Redis, falls back to DB for target
mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id = ").
WithArgs(testTargetID).
-25
View File
@@ -697,31 +697,6 @@ func (h *OrgHandler) Import(c *gin.Context) {
})
return
}
// Per-workspace RequiredEnv preflight: checks that every RequiredEnv
// declared at the workspace level is covered by either (a) a global
// secret key (already validated above) or (b) a key present in the
// workspace's on-disk .env files (org root .env + per-workspace
// <files_dir>/.env). If neither covers the key the workspace is
// imported NOT CONFIGURED, which silently breaks the workspace at
// start time — the container boots without the required credential
// and every LLM call 401s or fails silently. Issue #232.
// orgBaseDir is empty when importing via body.Template (inline YAML);
// in that case we cannot check .env files, so we skip this check
// and fall back to the global-only gate above (which correctly
// rejects any strict requirement not covered by global_secrets).
if orgBaseDir != "" {
wsMissing := collectPerWorkspaceUnsatisfied(tmpl.Workspaces, orgBaseDir, configured)
if len(wsMissing) > 0 {
c.JSON(http.StatusPreconditionFailed, gin.H{
"error": "missing per-workspace required environment variables",
"missing_workspace_env": wsMissing,
"template": tmpl.Name,
"suggestion": "add these keys to the workspace's .env file or set them as global secrets before importing",
})
return
}
}
}
results := []map[string]interface{}{}
@@ -346,7 +346,7 @@ func (g *gitFetcher) Fetch(ctx context.Context, rootDir, host, repoPath, ref str
// MkdirTemp creates the dir; git clone refuses to clone into a
// non-empty dir. Remove + recreate empty.
os.RemoveAll(tmpDir)
cloneAndConfig := gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir)
cloneAndConfig := append(gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir))
cmd := exec.CommandContext(ctx, "git", cloneAndConfig...)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if out, err := cmd.CombinedOutput(); err != nil {
@@ -941,65 +941,6 @@ func flattenAndSortRequirements(by map[string]EnvRequirement) []EnvRequirement {
// can investigate.
const globalSecretsPreflightLimit = 10000
// PerWorkspaceUnsatisfied describes one per-workspace RequiredEnv that is
// not covered by either a global secret or a key present in the
// corresponding .env file.
type PerWorkspaceUnsatisfied struct {
Workspace string `json:"workspace"`
FilesDir string `json:"files_dir,omitempty"`
Unsatisfied EnvRequirement `json:"unsatisfied_env"`
}
// collectPerWorkspaceUnsatisfied recursively walks workspaces and returns
// per-workspace RequiredEnv entries that are not covered by (a) a global
// secret key or (b) a key present in the workspace's .env file(s) (org root
// .env + per-workspace <files_dir>/.env). This complements
// collectOrgEnv + loadConfiguredGlobalSecretKeys, which together only
// validate global-level RequiredEnv against global_secrets. The .env
// lookup mirrors the runtime resolution in createWorkspaceTree so that
// the preflight result matches what the container actually receives at
// start time.
func collectPerWorkspaceUnsatisfied(workspaces []OrgWorkspace, orgBaseDir string, globalSecrets map[string]struct{}) []PerWorkspaceUnsatisfied {
var out []PerWorkspaceUnsatisfied
var walk func([]OrgWorkspace)
walk = func(wsList []OrgWorkspace) {
for _, ws := range wsList {
// Build the set of keys available to this workspace from .env.
// This is the same three-source stack that createWorkspaceTree
// injects into the container:
// 1. Org root .env (parseEnvFile, no filesDir)
// 2. Workspace <files_dir>/.env (if filesDir is set)
// 3. Persona bootstrap env (MOLECULE_PERSONA_ROOT/<filesDir>/env)
// Items 1+2 are on-disk and testable; item 3 is host-only and
// skipped here (persona env does NOT satisfy required_env —
// it carries identity tokens, not workspace LLM keys).
envFromFiles := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
// Convert map[string]string (from .env files) to map[string]struct{}
// to match IsSatisfied's signature.
envSet := make(map[string]struct{}, len(envFromFiles))
for k := range envFromFiles {
envSet[k] = struct{}{}
}
for _, req := range ws.RequiredEnv {
if req.IsSatisfied(globalSecrets) {
continue // covered by a global secret
}
if req.IsSatisfied(envSet) {
continue // covered by a per-workspace .env file
}
out = append(out, PerWorkspaceUnsatisfied{
Workspace: ws.Name,
FilesDir: ws.FilesDir,
Unsatisfied: req,
})
}
walk(ws.Children)
}
}
walk(workspaces)
return out
}
func loadConfiguredGlobalSecretKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT key FROM global_secrets WHERE octet_length(encrypted_value) > 0 LIMIT $1`,
@@ -1,226 +0,0 @@
package handlers
import (
"os"
"path/filepath"
"testing"
)
// TestCollectPerWorkspaceUnsatisfied_BothFiles covers the case where a key
// is present in both the org root .env and the workspace-specific .env. Both
// should satisfy the requirement (no entry in output).
func TestCollectPerWorkspaceUnsatisfied_BothFiles(t *testing.T) {
tmp := t.TempDir()
writeEnvFile(t, tmp, ".env", "PER_WS_KEY=globalvalue")
writeEnvFile(t, tmp, "ws-a/.env", "PER_WS_KEY=wsvalue")
workspaces := []OrgWorkspace{
{Name: "ws-a", FilesDir: "ws-a", RequiredEnv: []EnvRequirement{{Name: "PER_WS_KEY"}}},
}
// Global secret covers it.
globals := map[string]struct{}{"PER_WS_KEY": {}}
missing := collectPerWorkspaceUnsatisfied(workspaces, tmp, globals)
if len(missing) != 0 {
t.Errorf("PER_WS_KEY present in global + .env: should be satisfied, got %d missing", len(missing))
}
}
// TestCollectPerWorkspaceUnsatisfied_WorkspaceEnvOnly covers a key present
// only in the workspace-specific .env file (not global). Should be satisfied.
func TestCollectPerWorkspaceUnsatisfied_WorkspaceEnvOnly(t *testing.T) {
tmp := t.TempDir()
writeEnvFile(t, tmp, "dev-lead/.env", "WORKSPACE_KEY=val")
workspaces := []OrgWorkspace{
{Name: "Dev Lead", FilesDir: "dev-lead", RequiredEnv: []EnvRequirement{{Name: "WORKSPACE_KEY"}}},
}
globals := map[string]struct{}{} // nothing in global
missing := collectPerWorkspaceUnsatisfied(workspaces, tmp, globals)
if len(missing) != 0 {
t.Errorf("WORKSPACE_KEY in ws .env only: should be satisfied, got %d missing", len(missing))
}
}
// TestCollectPerWorkspaceUnsatisfied_OrgRootEnvOnly covers a key present
// only in the org root .env file (not per-workspace). Should be satisfied.
func TestCollectPerWorkspaceUnsatisfied_OrgRootEnvOnly(t *testing.T) {
tmp := t.TempDir()
writeEnvFile(t, tmp, ".env", "ORG_ROOT_KEY=val")
workspaces := []OrgWorkspace{
{Name: "ws-b", FilesDir: "ws-b", RequiredEnv: []EnvRequirement{{Name: "ORG_ROOT_KEY"}}},
}
globals := map[string]struct{}{}
missing := collectPerWorkspaceUnsatisfied(workspaces, tmp, globals)
if len(missing) != 0 {
t.Errorf("ORG_ROOT_KEY in org root .env only: should be satisfied, got %d missing", len(missing))
}
}
// TestCollectPerWorkspaceUnsatisfied_GlobalCovers checks that a global
// secret alone satisfies a per-workspace RequiredEnv even when the .env
// files don't have the key.
func TestCollectPerWorkspaceUnsatisfied_GlobalCovers(t *testing.T) {
tmp := t.TempDir()
// No .env files at all.
workspaces := []OrgWorkspace{
{Name: "ws-c", RequiredEnv: []EnvRequirement{{Name: "GLOBAL_COVERED"}}},
}
globals := map[string]struct{}{"GLOBAL_COVERED": {}}
missing := collectPerWorkspaceUnsatisfied(workspaces, tmp, globals)
if len(missing) != 0 {
t.Errorf("GLOBAL_COVERED satisfied by global: should be satisfied, got %d missing", len(missing))
}
}
// TestCollectPerWorkspaceUnsatisfied_Missing covers the core bug: a
// RequiredEnv declared at the workspace level where the key is absent from
// both global_secrets and the .env file. The import MUST return 412.
func TestCollectPerWorkspaceUnsatisfied_Missing(t *testing.T) {
tmp := t.TempDir()
// No .env files at all.
workspaces := []OrgWorkspace{
{Name: "Dev Lead", FilesDir: "dev-lead", RequiredEnv: []EnvRequirement{{Name: "MISSING_REQUIRED_KEY"}}},
}
globals := map[string]struct{}{} // no global secret
missing := collectPerWorkspaceUnsatisfied(workspaces, tmp, globals)
if len(missing) != 1 {
t.Fatalf("expected 1 missing entry, got %d", len(missing))
}
if missing[0].Workspace != "Dev Lead" {
t.Errorf("expected workspace 'Dev Lead', got %q", missing[0].Workspace)
}
if missing[0].Unsatisfied.Name != "MISSING_REQUIRED_KEY" {
t.Errorf("expected unsatisfied key 'MISSING_REQUIRED_KEY', got %q", missing[0].Unsatisfied.Name)
}
if missing[0].FilesDir != "dev-lead" {
t.Errorf("expected files_dir 'dev-lead', got %q", missing[0].FilesDir)
}
}
// TestCollectPerWorkspaceUnsatisfied_AnyOfGroup covers an any-of group where
// none of the alternatives are present in global or .env. Should report
// the group as unsatisfied.
func TestCollectPerWorkspaceUnsatisfied_AnyOfGroup(t *testing.T) {
tmp := t.TempDir()
workspaces := []OrgWorkspace{
{
Name: "Claude Bot",
FilesDir: "claude-bot",
RequiredEnv: []EnvRequirement{
{AnyOf: []string{"ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"}},
},
},
}
globals := map[string]struct{}{}
missing := collectPerWorkspaceUnsatisfied(workspaces, tmp, globals)
if len(missing) != 1 {
t.Fatalf("expected 1 missing any-of entry, got %d", len(missing))
}
if missing[0].Workspace != "Claude Bot" {
t.Errorf("expected workspace 'Claude Bot', got %q", missing[0].Workspace)
}
if len(missing[0].Unsatisfied.AnyOf) != 2 {
t.Errorf("expected any-of group with 2 members, got %v", missing[0].Unsatisfied.AnyOf)
}
}
// TestCollectPerWorkspaceUnsatisfied_NestedChildren covers grandchildren
// workspaces that also declare RequiredEnv. The recursive walk must visit
// children and grandchildren.
func TestCollectPerWorkspaceUnsatisfied_NestedChildren(t *testing.T) {
tmp := t.TempDir()
workspaces := []OrgWorkspace{
{
Name: "Root",
Children: []OrgWorkspace{
{
Name: "Child",
Children: []OrgWorkspace{
{Name: "Grandchild", FilesDir: "grandchild", RequiredEnv: []EnvRequirement{{Name: "DEEP_KEY"}}},
},
},
},
},
}
globals := map[string]struct{}{}
missing := collectPerWorkspaceUnsatisfied(workspaces, tmp, globals)
if len(missing) != 1 {
t.Fatalf("expected 1 missing entry from grandchild, got %d", len(missing))
}
if missing[0].Workspace != "Grandchild" {
t.Errorf("expected 'Grandchild', got %q", missing[0].Workspace)
}
}
// TestCollectPerWorkspaceUnsatisfied_EmptyOrgBaseDir covers the case where
// orgBaseDir is empty (inline template import). No .env files can be
// checked, so missing keys cannot be attributed to .env absence. The
// function should NOT crash and should only report entries satisfiable
// by global (all missing since globals is empty).
func TestCollectPerWorkspaceUnsatisfied_EmptyOrgBaseDir(t *testing.T) {
workspaces := []OrgWorkspace{
{Name: "ws-x", RequiredEnv: []EnvRequirement{{Name: "KEY_X"}}},
}
globals := map[string]struct{}{}
missing := collectPerWorkspaceUnsatisfied(workspaces, "", globals)
// With no orgBaseDir and no global, KEY_X must be reported missing.
if len(missing) != 1 {
t.Errorf("expected 1 missing with empty orgBaseDir, got %d", len(missing))
}
}
// TestCollectPerWorkspaceUnsatisfied_MultipleWorkspaces reports only the
// workspace whose RequiredEnv is unsatisfied, not the whole batch.
func TestCollectPerWorkspaceUnsatisfied_MultipleWorkspaces(t *testing.T) {
tmp := t.TempDir()
writeEnvFile(t, tmp, "ws-ok/.env", "OK_KEY=val")
workspaces := []OrgWorkspace{
{Name: "ws-ok", FilesDir: "ws-ok", RequiredEnv: []EnvRequirement{{Name: "OK_KEY"}}},
{Name: "ws-missing", FilesDir: "ws-missing", RequiredEnv: []EnvRequirement{{Name: "BAD_KEY"}}},
}
globals := map[string]struct{}{}
missing := collectPerWorkspaceUnsatisfied(workspaces, tmp, globals)
if len(missing) != 1 {
t.Errorf("expected exactly 1 missing (BAD_KEY), got %d", len(missing))
}
if missing[0].Workspace != "ws-missing" {
t.Errorf("expected missing workspace 'ws-missing', got %q", missing[0].Workspace)
}
}
// writeEnvFile is a test helper that creates a .env file at the given path
// with the given content.
func writeEnvFile(t *testing.T, baseDir, relPath, content string) {
t.Helper()
fullPath := filepath.Join(baseDir, relPath)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
t.Fatalf("mkdirAll: %v", err)
}
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
t.Fatalf("writeFile %s: %v", fullPath, err)
}
}
@@ -8,7 +8,6 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -286,51 +285,17 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "delivery_mode must be 'push' or 'poll'"})
return
}
// Insert workspace with runtime + delivery_mode persisted in DB (inside transaction).
//
// Auto-suffix on (parent_id, name) collision via insertWorkspaceWithNameRetry:
// the partial-unique index `workspaces_parent_name_uniq` (migration
// 20260506000000) protects /org/import from TOCTOU duplicates, but the
// pre-fix Canvas Create path bubbled the raw pq violation as a 500 on
// double-click. Helper retries with " (2)", " (3)", … up to maxNameSuffix,
// returns the actually-persisted name (which we MUST thread back into
// payload + broadcast so the canvas displays what the DB has).
const insertWorkspaceSQL = `
// Insert workspace with runtime + delivery_mode persisted in DB (inside transaction)
_, err := tx.ExecContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12)
`
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
persistedName, currentTx, err := insertWorkspaceWithNameRetry(
ctx,
tx,
// Closure captures ctx so the retry tx uses the same request context;
// nil opts mirrors the original BeginTx call above.
func(ctx context.Context) (*sql.Tx, error) { return db.DB.BeginTx(ctx, nil) },
payload.Name,
1, // args[1] is name
insertWorkspaceSQL,
insertArgs,
)
`, id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode)
if err != nil {
if currentTx != nil {
currentTx.Rollback() //nolint:errcheck
}
if errors.Is(err, errWorkspaceNameExhausted) {
log.Printf("Create workspace: name suffix exhausted for base %q under parent %v", payload.Name, payload.ParentID)
c.JSON(http.StatusConflict, gin.H{"error": "workspace name already in use; please pick a different name"})
return
}
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace"})
return
}
// Helper may have rolled back the original tx and returned a fresh one;
// rebind so the remaining secrets-INSERT + Commit run on the live tx.
tx = currentTx
if persistedName != payload.Name {
log.Printf("Create workspace %s: name collision auto-suffix %q -> %q", id, payload.Name, persistedName)
payload.Name = persistedName
}
// Persist initial secrets from the create payload (inside same transaction).
// nil/empty map is a no-op. Any failure rolls back the workspace insert
@@ -1,183 +0,0 @@
package handlers
// workspace_create_name.go — disambiguate workspace names on the
// Canvas POST /workspaces path so a double-clicked template card
// does not surface raw Postgres errors.
//
// Background (#2872 + post-2026-05-06 follow-up):
// - Migration 20260506000000_workspaces_unique_parent_name added a
// partial UNIQUE index on (COALESCE(parent_id, sentinel), name)
// WHERE status != 'removed'. It exists to close the TOCTOU race in
// /org/import that previously let two concurrent POSTs both INSERT
// the same (parent_id, name) row.
// - /org/import handles the constraint via `ON CONFLICT DO NOTHING`
// + idempotent re-select (handlers/org_import.go).
// - The Canvas Create handler (handlers/workspace.go) did NOT — a
// duplicate POST returned an opaque HTTP 500 with the raw pq error
// in the server log. Repro path: user clicks a template card twice
// in canvas before the first response paints.
//
// Resolution: auto-suffix the user-typed name on collision. The
// uniqueness constraint required for #2872 stays in place; only the
// Canvas Create path's reaction to it changes. Names become a
// free-form display label that the platform disambiguates; row
// identity is carried by the workspace id (UUID).
//
// Suffix shape: " (2)", " (3)", … up to N=maxNameSuffix. Chosen over
// numeric "-2" / "_2" because the parenthesised form is the standard
// disambiguation pattern users already expect from Finder / Explorer
// / Google Docs / file managers. Stays under the 255-char name cap
// (#688 — validated by validateWorkspaceFields) for any reasonable
// base name; parens are not in yamlSpecialChars so the existing YAML-
// safety guard is unaffected.
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/lib/pq"
)
// maxNameSuffix bounds the suffix-retry loop. 20 is well above any
// plausible accidental-double-click rate (typical: 2-3 races) and
// keeps the worst-case handler latency to ~20 round-trips. If a
// caller actually wants 21+ workspaces with the same base name, they
// can pre-disambiguate client-side; the platform refuses to spin
// indefinitely.
const maxNameSuffix = 20
// workspacesUniqueIndexName is the partial-unique index this handler
// is reacting to. Pinned to the migration's index name so we
// distinguish "the base name collision we know how to handle" from
// every other unique violation (which we surface as 409 without
// retry — silently auto-suffixing a name on the wrong constraint
// would mask real bugs).
const workspacesUniqueIndexName = "workspaces_parent_name_uniq"
// errWorkspaceNameExhausted is returned when maxNameSuffix retries
// all fail because every candidate name in the (base, " (2)", …,
// " (N)") sequence is taken. The caller maps this to HTTP 409
// Conflict — the user must rename and re-try.
var errWorkspaceNameExhausted = errors.New("workspace name exhausted: too many duplicates of base name under same parent")
// dbExec is the minimum surface our retry helper needs from
// *sql.Tx (or *sql.DB). Declared as an interface so tests can
// substitute a fake without standing up a real DB connection.
type dbExec interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// insertWorkspaceWithNameRetry runs the workspace INSERT and, if it
// hits the parent-name unique-violation, retries with a suffixed
// name. Returns the name actually persisted (which the caller MUST
// use in the response and in broadcast payloads — without it the
// canvas would show the user-typed name while the DB has the
// suffixed one, and the next poll would surprise the user with the
// "real" name).
//
// The query string is intentionally a parameter (not hardcoded) so
// the helper composes with future schema additions without growing
// a new arity each time. Only the FIRST arg of args must be the
// name placeholder ($1) — the helper rewrites args[0] on retry; all
// other args pass through verbatim. (This matches the workspace.go
// INSERT below where $1 is the id and $2 is name, so the caller
// passes nameArgIndex=1.)
//
// On the unique-violation, the original tx is rolled back and a
// fresh one is begun before retry — Postgres marks the tx aborted
// on any error, so re-using it would silently no-op every
// subsequent statement.
//
// `beginTx` is a closure (not a *sql.DB) so the caller controls the
// transaction-options + the context. Returning the fresh tx each
// retry means the caller can commit it once the helper succeeds.
//
// `query` MUST be parameterized — the name placeholder is rewritten
// via args[nameArgIndex], not via string substitution. Passing a
// fmt.Sprintf'd query string would silently disable the safety.
func insertWorkspaceWithNameRetry(
ctx context.Context,
tx *sql.Tx,
beginTx func(ctx context.Context) (*sql.Tx, error),
baseName string,
nameArgIndex int,
query string,
args []any,
) (finalName string, finalTx *sql.Tx, err error) {
if nameArgIndex < 0 || nameArgIndex >= len(args) {
return "", tx, fmt.Errorf("insertWorkspaceWithNameRetry: nameArgIndex %d out of range for %d args", nameArgIndex, len(args))
}
current := tx
for attempt := 0; attempt <= maxNameSuffix; attempt++ {
candidate := baseName
if attempt > 0 {
candidate = fmt.Sprintf("%s (%d)", baseName, attempt+1)
}
args[nameArgIndex] = candidate
_, execErr := current.ExecContext(ctx, query, args...)
if execErr == nil {
return candidate, current, nil
}
if !isParentNameUniqueViolation(execErr) {
// Any other error (encoding, connection, FK violation,
// other unique index) — return as-is. Caller decides
// status code.
return "", current, execErr
}
// Hit the partial-unique index. Postgres has aborted this
// tx — roll it back and start fresh before retrying with a
// new candidate name.
_ = current.Rollback()
if attempt == maxNameSuffix {
break
}
next, txErr := beginTx(ctx)
if txErr != nil {
return "", nil, fmt.Errorf("begin retry tx after name collision: %w", txErr)
}
current = next
}
// Exhausted: the helper rolled back the last tx already. Return
// nil tx so the caller does not try to commit/rollback again.
return "", nil, errWorkspaceNameExhausted
}
// isParentNameUniqueViolation reports whether err is the specific
// partial-unique-index violation we know how to auto-suffix. We pin
// on BOTH the SQLSTATE 23505 (unique_violation) AND the constraint
// name so we don't silently rename around an unrelated unique index
// (e.g. a future workspaces.slug unique).
//
// errors.As is used (not a `.(*pq.Error)` type assertion) because
// lib/pq wraps the error through fmt.Errorf in some paths.
//
// Defensive fallback: if Constraint is empty (older pq builds, or
// the error came through a wrapper that dropped the field), match
// on the error message as well. The message form is brittle
// (postgres locale-dependent) but every English-locale Postgres
// emits the index name verbatim.
func isParentNameUniqueViolation(err error) bool {
if err == nil {
return false
}
var pqErr *pq.Error
if errors.As(err, &pqErr) {
if pqErr.Code != "23505" {
return false
}
if pqErr.Constraint == workspacesUniqueIndexName {
return true
}
// Fallback for builds that drop Constraint metadata.
return strings.Contains(pqErr.Message, workspacesUniqueIndexName)
}
// Last-resort string match — the pq.Error type was lost
// through wrapping. Same English-locale caveat as above; keeps
// the helper robust in test seams that synthesize errors via
// fmt.Errorf("pq: …").
return strings.Contains(err.Error(), workspacesUniqueIndexName)
}
@@ -1,251 +0,0 @@
//go:build integration
// +build integration
// workspace_create_name_integration_test.go — REAL Postgres
// integration test for the duplicate-name auto-suffix retry
// helper.
//
// Run with:
//
// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \
// go test -tags=integration ./internal/handlers/ -run Integration_WorkspaceCreate_NameRetry -v
//
// CI: piggybacks on .github/workflows/handlers-postgres-integration.yml
// (path-filter includes workspace-server/internal/handlers/**, which
// covers this file).
//
// Why this is NOT a sqlmock test
// ------------------------------
// sqlmock CANNOT verify the actual partial-unique-index
// behaviour. The unit tests in workspace_create_name_test.go pin
// the helper's retry contract under a fake driver error, but only
// a real Postgres can confirm:
//
// - The migration 20260506000000 actually created the index.
// - lib/pq emits SQLSTATE 23505 with Constraint =
// "workspaces_parent_name_uniq" (not a synonym, not the message
// fallback).
// - The COALESCE(parent_id, sentinel) target collapses NULL
// parent_ids so two root-level workspaces with the same name
// collide as the migration intends.
// - The WHERE status != 'removed' partial filter exempts
// tombstoned rows from blocking re-use.
//
// Per feedback_mandatory_local_e2e_before_ship: ship-mode requires
// the helper to be exercised against a real Postgres before the PR
// merges.
package handlers
import (
"context"
"database/sql"
"fmt"
"os"
"testing"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
// integrationDB_WorkspaceCreateName opens $INTEGRATION_DB_URL,
// applies the parent-name partial unique index if missing
// (idempotent), wipes the test row range, and returns the
// connection.
//
// We intentionally do NOT wipe every row in `workspaces` because
// the integration DB may be shared with other tests in this
// package; we tag inserts with a per-test UUID prefix and clean up
// only those.
func integrationDB_WorkspaceCreateName(t *testing.T) *sql.DB {
t.Helper()
url := os.Getenv("INTEGRATION_DB_URL")
if url == "" {
t.Skip("INTEGRATION_DB_URL not set; skipping (see file header)")
}
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := conn.Ping(); err != nil {
t.Fatalf("ping: %v", err)
}
t.Cleanup(func() { conn.Close() })
// Ensure the constraint we're testing exists. If the migration
// already ran (the dev/CI default), this is a fast no-op via
// IF NOT EXISTS. If the test DB was created from a snapshot
// taken before 2026-05-06, we apply it here.
if _, err := conn.ExecContext(context.Background(), `
CREATE UNIQUE INDEX IF NOT EXISTS workspaces_parent_name_uniq
ON workspaces (
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
)
WHERE status != 'removed'
`); err != nil {
t.Fatalf("ensure constraint: %v", err)
}
return conn
}
// cleanupTestRows removes any rows inserted under the given name
// prefix. Called via t.Cleanup so a failing test still leaves the
// DB usable for the next run.
func cleanupTestRows(t *testing.T, conn *sql.DB, namePrefix string) {
t.Helper()
if _, err := conn.ExecContext(context.Background(),
`DELETE FROM workspaces WHERE name LIKE $1`, namePrefix+"%"); err != nil {
t.Logf("cleanup (non-fatal): %v", err)
}
}
// TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision
// exercises the helper end-to-end against a real Postgres:
//
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
// 2. Run insertWorkspaceWithNameRetry with the same name —
// partial-unique violation fires, helper retries with
// " (2)", that succeeds.
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
// 4. Run helper AGAIN — second collision, helper retries with
// " (3)".
//
// This is the live-test that proves the partial-index behaviour
// matches the migration's intent — sqlmock cannot reach this depth.
func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testing.T) {
conn := integrationDB_WorkspaceCreateName(t)
ctx := context.Background()
// Per-test prefix so concurrent test runs don't collide on the
// shared integration DB; also tags rows for cleanupTestRows.
prefix := fmt.Sprintf("itest-namesuffix-%s", uuid.New().String()[:8])
t.Cleanup(func() { cleanupTestRows(t, conn, prefix) })
baseName := prefix + "-Repro"
// Step 1 — seed an existing row to collide against. Uses a
// minimal column set (the production INSERT has many more
// columns; we only need the ones the partial-unique index
// targets + the NOT NULL columns required by the schema).
firstID := uuid.New().String()
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`, firstID, baseName, "workspace:"+firstID); err != nil {
t.Fatalf("seed first row: %v", err)
}
// Step 2 — same name, helper must auto-suffix to " (2)".
beginTx := func(ctx context.Context) (*sql.Tx, error) { return conn.BeginTx(ctx, nil) }
tx, err := beginTx(ctx)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
secondID := uuid.New().String()
query := `
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`
args := []any{secondID, baseName, "workspace:" + secondID}
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
ctx, tx, beginTx, baseName, 1, query, args,
)
if err != nil {
t.Fatalf("retry helper on second insert: %v", err)
}
if persistedName != baseName+" (2)" {
t.Fatalf("persistedName = %q, want exactly %q", persistedName, baseName+" (2)")
}
if err := finalTx.Commit(); err != nil {
t.Fatalf("commit second: %v", err)
}
// Step 3 — verify DB state matches helper's return value.
var actualName string
if err := conn.QueryRowContext(ctx,
`SELECT name FROM workspaces WHERE id = $1`, secondID).Scan(&actualName); err != nil {
t.Fatalf("re-select second: %v", err)
}
if actualName != baseName+" (2)" {
t.Fatalf("DB row name = %q, want exactly %q (helper return value lied to caller)",
actualName, baseName+" (2)")
}
// Step 4 — third collision must produce " (3)".
tx3, err := beginTx(ctx)
if err != nil {
t.Fatalf("begin tx3: %v", err)
}
thirdID := uuid.New().String()
args3 := []any{thirdID, baseName, "workspace:" + thirdID}
persistedName3, finalTx3, err := insertWorkspaceWithNameRetry(
ctx, tx3, beginTx, baseName, 1, query, args3,
)
if err != nil {
t.Fatalf("retry helper on third insert: %v", err)
}
if persistedName3 != baseName+" (3)" {
t.Fatalf("third persistedName = %q, want exactly %q",
persistedName3, baseName+" (3)")
}
if err := finalTx3.Commit(); err != nil {
t.Fatalf("commit third: %v", err)
}
}
// TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide
// confirms the partial-index `WHERE status != 'removed'` predicate
// matches the helper's assumptions: a deleted (status='removed')
// workspace MUST NOT block re-creation under the same name.
//
// This is the post-2026-05-06 contract /org/import already relies
// on; the helper inherits it for the Canvas Create path. A
// regression in the migration's predicate would silently break
// both surfaces.
func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *testing.T) {
conn := integrationDB_WorkspaceCreateName(t)
ctx := context.Background()
prefix := fmt.Sprintf("itest-tombstone-%s", uuid.New().String()[:8])
t.Cleanup(func() { cleanupTestRows(t, conn, prefix) })
baseName := prefix + "-RevivedName"
// Seed a row, then tombstone it.
firstID := uuid.New().String()
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'removed')
`, firstID, baseName, "workspace:"+firstID); err != nil {
t.Fatalf("seed tombstoned row: %v", err)
}
// New INSERT with the same name MUST succeed without any
// suffix — the partial index excludes the tombstoned row.
beginTx := func(ctx context.Context) (*sql.Tx, error) { return conn.BeginTx(ctx, nil) }
tx, err := beginTx(ctx)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
secondID := uuid.New().String()
query := `
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`
args := []any{secondID, baseName, "workspace:" + secondID}
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
ctx, tx, beginTx, baseName, 1, query, args,
)
if err != nil {
t.Fatalf("retry helper after tombstone: %v", err)
}
if persistedName != baseName {
t.Fatalf("persistedName = %q, want %q (tombstoned row should NOT force a suffix)",
persistedName, baseName)
}
if err := finalTx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
}
@@ -1,302 +0,0 @@
package handlers
// workspace_create_name_test.go — unit + table tests for the
// duplicate-name auto-suffix retry helper.
//
// Phase 3 of the dev-SOP: write the test first, watch it fail in
// the way you predicted, then watch the fix make it pass. The fix
// landed in workspace_create_name.go; these tests pin its contract
// so a refactor that drops the retry (or auto-suffixes on the
// WRONG constraint) blows up loud.
//
// sqlmock CANNOT verify the real partial-index behaviour — that
// lives in the companion integration test
// workspace_create_name_integration_test.go (real Postgres).
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/lib/pq"
)
// fakePqUniqueViolation reproduces the SQLSTATE/Constraint shape
// the real lib/pq driver emits when an INSERT hits
// workspaces_parent_name_uniq. Used by the unit test to drive the
// retry path without standing up a real Postgres.
func fakePqUniqueViolation(constraint string) error {
return &pq.Error{
Code: "23505",
Constraint: constraint,
Message: fmt.Sprintf("duplicate key value violates unique constraint %q", constraint),
}
}
// TestIsParentNameUniqueViolation_PinsTheConstraint exhaustively
// pins which error shapes the helper considers "auto-suffix
// eligible." A regression that broadens this predicate (e.g.
// matching ANY 23505) would mask real bugs; a regression that
// narrows it (e.g. dropping the message fallback) would let the
// 500-on-double-click bug recur on driver builds that strip
// Constraint metadata.
func TestIsParentNameUniqueViolation_PinsTheConstraint(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil error", nil, false},
{"plain string error", errors.New("network down"), false},
{
name: "23505 on parent_name_uniq via pq.Error",
err: fakePqUniqueViolation("workspaces_parent_name_uniq"),
want: true,
},
{
name: "23505 on a DIFFERENT unique index — must NOT be auto-suffixed",
err: fakePqUniqueViolation("workspaces_slug_uniq"),
want: false,
},
{
name: "23505 with empty Constraint — fall back to message match",
err: &pq.Error{
Code: "23505",
Message: `duplicate key value violates unique constraint "workspaces_parent_name_uniq"`,
},
want: true,
},
{
name: "non-23505 (e.g. FK violation) on the same index name in message — must NOT match",
err: &pq.Error{
Code: "23503",
Message: `foreign key references workspaces_parent_name_uniq region`,
},
want: false,
},
{
name: "wrapped via fmt.Errorf (errors.As must unwrap)",
err: fmt.Errorf("create workspace: %w", fakePqUniqueViolation("workspaces_parent_name_uniq")),
want: true,
},
{
name: "raw string from a non-pq error mentioning the index — last-resort fallback",
err: errors.New(`pq: duplicate key value violates unique constraint "workspaces_parent_name_uniq"`),
want: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := isParentNameUniqueViolation(tc.err)
if got != tc.want {
t.Fatalf("isParentNameUniqueViolation(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
// TestInsertWorkspaceWithNameRetry_FirstAttemptSucceeds confirms
// the helper does NOT modify the name when the first INSERT
// succeeds — a naive implementation that always wraps in a retry
// loop could accidentally add a " (1)" suffix even on the happy
// path.
func TestInsertWorkspaceWithNameRetry_FirstAttemptSucceeds(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("id-1", "MyWorkspace").
WillReturnResult(sqlmock.NewResult(0, 1))
tx, err := getDBHandle(t).BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
name, finalTx, err := insertWorkspaceWithNameRetry(
context.Background(),
tx,
func(ctx context.Context) (*sql.Tx, error) {
return getDBHandle(t).BeginTx(ctx, nil)
},
"MyWorkspace",
1,
"INSERT INTO workspaces (id, name) VALUES ($1, $2)",
[]any{"id-1", "MyWorkspace"},
)
if err != nil {
t.Fatalf("retry helper: %v", err)
}
if name != "MyWorkspace" {
t.Fatalf("name = %q, want %q (happy path must NOT suffix)", name, "MyWorkspace")
}
if finalTx == nil {
t.Fatalf("finalTx == nil; caller needs a live tx to commit")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestInsertWorkspaceWithNameRetry_SecondAttemptSuffixed confirms
// that on a single collision the helper retries with " (2)" and
// returns that as the persisted name. The dispatched-name suffix
// shape is part of the user-visible contract — if a future
// refactor switches to "-2" / "_2" / "MyWorkspace2", the canvas
// renders the wrong label until the next poll.
func TestInsertWorkspaceWithNameRetry_SecondAttemptSuffixed(t *testing.T) {
mock := setupTestDB(t)
// First begin (caller-owned), then first INSERT fails with the
// partial-unique violation, helper rolls back the tx, opens a
// fresh tx, and the second INSERT (with " (2)") succeeds.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("id-1", "MyWorkspace").
WillReturnError(fakePqUniqueViolation("workspaces_parent_name_uniq"))
mock.ExpectRollback()
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("id-1", "MyWorkspace (2)").
WillReturnResult(sqlmock.NewResult(0, 1))
tx, err := getDBHandle(t).BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
name, finalTx, err := insertWorkspaceWithNameRetry(
context.Background(),
tx,
func(ctx context.Context) (*sql.Tx, error) {
return getDBHandle(t).BeginTx(ctx, nil)
},
"MyWorkspace",
1,
"INSERT INTO workspaces (id, name) VALUES ($1, $2)",
[]any{"id-1", "MyWorkspace"},
)
if err != nil {
t.Fatalf("retry helper: %v", err)
}
// Exact-equality assertion (per feedback_assert_exact_not_substring):
// substring-match on "MyWorkspace" would also pass for the bug case
// where the helper accidentally returns "MyWorkspace (1)" or
// "MyWorkspace2".
if name != "MyWorkspace (2)" {
t.Fatalf("name = %q, want exactly %q", name, "MyWorkspace (2)")
}
if finalTx == nil {
t.Fatalf("finalTx == nil after successful retry")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestInsertWorkspaceWithNameRetry_NonRetryableErrorPassesThrough
// pins that we do NOT retry on errors we don't recognize. A
// connection drop, an FK violation, a check-constraint failure
// must propagate verbatim — the helper is NOT a generic
// SQL-retry wrapper.
func TestInsertWorkspaceWithNameRetry_NonRetryableErrorPassesThrough(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectBegin()
connErr := errors.New("connection reset by peer")
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("id-1", "MyWorkspace").
WillReturnError(connErr)
tx, err := getDBHandle(t).BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
name, _, err := insertWorkspaceWithNameRetry(
context.Background(),
tx,
func(ctx context.Context) (*sql.Tx, error) {
return getDBHandle(t).BeginTx(ctx, nil)
},
"MyWorkspace",
1,
"INSERT INTO workspaces (id, name) VALUES ($1, $2)",
[]any{"id-1", "MyWorkspace"},
)
if err == nil {
t.Fatalf("expected error, got nil (name=%q)", name)
}
if !errors.Is(err, connErr) && !strings.Contains(err.Error(), "connection reset") {
t.Fatalf("expected connection-reset to propagate, got %v", err)
}
if name != "" {
t.Fatalf("name = %q, want empty on failure", name)
}
}
// TestInsertWorkspaceWithNameRetry_ExhaustsAfterMaxSuffix pins the
// upper bound: after maxNameSuffix retries the helper returns
// errWorkspaceNameExhausted so the caller maps it to 409 Conflict
// rather than spinning indefinitely.
func TestInsertWorkspaceWithNameRetry_ExhaustsAfterMaxSuffix(t *testing.T) {
mock := setupTestDB(t)
// Every attempt collides. Expect maxNameSuffix+1 INSERTs (the
// initial + maxNameSuffix retries), each followed by a Rollback,
// and a Begin between rollbacks except the final terminal one.
mock.ExpectBegin()
for i := 0; i <= maxNameSuffix; i++ {
mock.ExpectExec("INSERT INTO workspaces").
WillReturnError(fakePqUniqueViolation("workspaces_parent_name_uniq"))
mock.ExpectRollback()
if i < maxNameSuffix {
mock.ExpectBegin()
}
}
tx, err := getDBHandle(t).BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
_, finalTx, err := insertWorkspaceWithNameRetry(
context.Background(),
tx,
func(ctx context.Context) (*sql.Tx, error) {
return getDBHandle(t).BeginTx(ctx, nil)
},
"MyWorkspace",
1,
"INSERT INTO workspaces (id, name) VALUES ($1, $2)",
[]any{"id-1", "MyWorkspace"},
)
if !errors.Is(err, errWorkspaceNameExhausted) {
t.Fatalf("err = %v, want errWorkspaceNameExhausted", err)
}
if finalTx != nil {
t.Fatalf("finalTx must be nil on exhaustion (helper already rolled back); got %v", finalTx)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// getDBHandle exposes the package-level db.DB the test infrastructure
// stashes after setupTestDB. Kept as a helper so the test reads as
// the production code does ("BeginTx on the platform's DB") without
// the cross-package import noise.
func getDBHandle(t *testing.T) *sql.DB {
t.Helper()
// db.DB is the package-level handle; setupTestDB assigns it to
// the sqlmock-backed *sql.DB. Use this helper everywhere instead
// of dereferencing db.DB directly so a future move to a per-test
// container fixture has one rename surface.
return db.DB
}
@@ -12,8 +12,8 @@ import (
// time. The Go convention `export_test.go` keeps this seam OUT of the
// production binary — files ending in _test.go are stripped at build
// time, so this re-export only exists during `go test`.
func StartSweeperWithIntervalForTest(ctx context.Context, storage Storage, ackRetention, interval time.Duration, done chan struct{}) {
startSweeperWithInterval(ctx, storage, ackRetention, interval, done)
func StartSweeperWithIntervalForTest(ctx context.Context, storage Storage, ackRetention, interval time.Duration) {
startSweeperWithInterval(ctx, storage, ackRetention, interval, nil)
}
// StartSweeperForTest starts the sweeper and returns a done channel
@@ -190,14 +190,7 @@ func TestStartSweeperWithInterval_TickerFiresAdditionalCycles(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Use a short ticker interval (100ms) so the test runs fast without
// burning real wall-clock time. StartSweeperWithIntervalForTest is the
// test-friendly variant that accepts a caller-specified interval; the
// production SweepInterval of 5m is too coarse for a 2s deadline on
// a loaded CI runner (the ticker may not fire at all under CPU
// contention — the root cause of the pre-existing CI flake).
done := make(chan struct{})
go pendinguploads.StartSweeperWithIntervalForTest(ctx, store, time.Hour, 100*time.Millisecond, done)
done := pendinguploads.StartSweeperForTest(ctx, store, time.Hour)
// Immediate cycle + at least one tick-driven cycle.
store.waitForCycle(t, 2, 2*time.Second)
@@ -109,16 +109,13 @@ type LocalBuildOptions struct {
// http.DefaultClient with a 30s timeout.
HTTPClient *http.Client
// remoteHeadSha + dockerBuild + gitClone + checkTool are seams for tests;
// if nil, the production implementations are used.
// remoteHeadSha + dockerBuild + gitClone are seams for tests; if
// nil, the production implementations are used.
remoteHeadSha func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error)
gitClone func(ctx context.Context, opts *LocalBuildOptions, runtime, dest string) error
dockerBuild func(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error
dockerHasTag func(ctx context.Context, tag string) (bool, error)
dockerTag func(ctx context.Context, src, dst string) error
// checkTool validates that the named binary is on PATH. nil = production
// LookPath check; tests override to skip or mock.
checkTool func(tool string) error
}
func newDefaultLocalBuildOptions() *LocalBuildOptions {
@@ -185,21 +182,6 @@ func EnsureLocalImage(ctx context.Context, runtime string) (string, error) {
// production code.
var ensureLocalImageHook = EnsureLocalImage
// checkToolOnPath verifies tool is on PATH and returns an error with a
// descriptive message if missing. Used for pre-flight validation before the
// clone/build cold path.
func checkToolOnPath(tool string) error {
path, err := exec.LookPath(tool)
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return fmt.Errorf("%q not found on PATH — local-build mode requires both docker and git; either install them, or set MOLECULE_IMAGE_REGISTRY so local-build is bypassed", tool)
}
return fmt.Errorf("LookPath(%q) failed: %w", tool, err)
}
log.Printf("local-build: pre-flight OK (%s=%s)", tool, path)
return nil
}
func ensureLocalImageWithOpts(ctx context.Context, runtime string, opts *LocalBuildOptions) (string, error) {
if !IsKnownRuntime(runtime) {
return "", fmt.Errorf("local-build: refusing to build unknown runtime %q (must be one of %v)", runtime, knownRuntimes)
@@ -209,20 +191,6 @@ func ensureLocalImageWithOpts(ctx context.Context, runtime string, opts *LocalBu
lock.Lock()
defer lock.Unlock()
// Pre-flight: both docker and git are required even on the cache-hit
// path (docker is used for image inspect + tag). Fail fast with a clear
// message rather than a cryptic "exec: docker: executable file not found".
checkFn := opts.checkTool
if checkFn == nil {
checkFn = checkToolOnPath
}
if err := checkFn("docker"); err != nil {
return "", fmt.Errorf("local-build: %w; set MOLECULE_IMAGE_REGISTRY to bypass local-build mode", err)
}
if err := checkFn("git"); err != nil {
return "", fmt.Errorf("local-build: %w; set MOLECULE_IMAGE_REGISTRY to bypass local-build mode", err)
}
// 1. HEAD lookup → cache key.
headFn := opts.remoteHeadSha
if headFn == nil {
@@ -43,10 +43,6 @@ func makeTestOpts(t *testing.T) *LocalBuildOptions {
dockerTag: func(ctx context.Context, src, dst string) error {
return nil
},
// checkTool: skip the real LookPath in tests (docker/git may not be on PATH
// in the CI environment). Tests that exercise tool-not-found behaviour
// override this stub explicitly.
checkTool: func(tool string) error { return nil },
}
}
@@ -91,50 +87,6 @@ func TestEnsureLocalImage_CacheHit(t *testing.T) {
}
}
// TestEnsureLocalImage_MissingTool_Docker — pre-flight catches a missing
// docker binary before any cryptic exec-not-found error propagates up.
// The error must mention both the missing tool and the escape-hatch hint.
func TestEnsureLocalImage_MissingTool_Docker(t *testing.T) {
opts := makeTestOpts(t)
opts.checkTool = func(tool string) error {
if tool == "docker" {
return errors.New(`"docker" not found on PATH`)
}
return nil
}
_, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err == nil {
t.Fatalf("expected error for missing docker")
}
if !strings.Contains(err.Error(), "docker") {
t.Errorf("error = %v, want one mentioning docker", err)
}
if !strings.Contains(err.Error(), "MOLECULE_IMAGE_REGISTRY") {
t.Errorf("error = %v, want one mentioning MOLECULE_IMAGE_REGISTRY", err)
}
}
// TestEnsureLocalImage_MissingTool_Git — same for a missing git binary.
func TestEnsureLocalImage_MissingTool_Git(t *testing.T) {
opts := makeTestOpts(t)
opts.checkTool = func(tool string) error {
if tool == "git" {
return errors.New(`"git" not found on PATH`)
}
return nil
}
_, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err == nil {
t.Fatalf("expected error for missing git")
}
if !strings.Contains(err.Error(), "git") {
t.Errorf("error = %v, want one mentioning git", err)
}
if !strings.Contains(err.Error(), "MOLECULE_IMAGE_REGISTRY") {
t.Errorf("error = %v, want one mentioning MOLECULE_IMAGE_REGISTRY", err)
}
}
// TestEnsureLocalImage_UnknownRuntime — the allowlist guard rejects
// arbitrary runtime names before any network or filesystem call.
func TestEnsureLocalImage_UnknownRuntime(t *testing.T) {
+4 -8
View File
@@ -75,19 +75,14 @@ _INJECTION_PATTERNS = [
def sanitize_a2a_result(text: str) -> str:
"""Sanitize untrusted text from an A2A peer (OFFSEC-003).
"""Sanitize and wrap untrusted text from an A2A peer (OFFSEC-003).
Order of operations:
1. Escape boundary markers in the raw text (prevents injection).
2. Escape known injection patterns (defense-in-depth).
3. Wrap in trust-boundary markers.
Returns the input unchanged if it is empty/None.
Note: this function does NOT add boundary wrappers — callers that need
to establish a trust boundary should wrap the sanitized result with
``[A2A_RESULT_FROM_PEER]\\n{sanitized}\\n[/A2A_RESULT_FROM_PEER]``.
See ``a2a_tools_delegation.py:tool_delegate_task`` for the canonical
wrapping pattern.
"""
if not text:
return text
@@ -100,4 +95,5 @@ def sanitize_a2a_result(text: str) -> str:
for pattern, replacement in _INJECTION_PATTERNS:
escaped = pattern.sub(replacement, escaped)
return escaped
# 3. Wrap in trust-boundary markers.
return f"{_A2A_BOUNDARY_START}\n{escaped}\n{_A2A_BOUNDARY_END}"
+4 -4
View File
@@ -25,10 +25,10 @@ _WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID")
if not _WORKSPACE_ID_raw:
raise RuntimeError("WORKSPACE_ID environment variable is required but not set")
WORKSPACE_ID = _WORKSPACE_ID_raw
# Platform URL: always host.docker.internal inside containers. The platform API
# is only reachable via the Docker network mesh from inside a workspace
# container regardless of the runtime environment (Docker/host).
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
async def discover(target_id: str) -> dict | None:
+9 -25
View File
@@ -26,10 +26,10 @@ _WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID")
if not _WORKSPACE_ID_raw:
raise RuntimeError("WORKSPACE_ID environment variable is required but not set")
WORKSPACE_ID = _WORKSPACE_ID_raw
# Platform URL: always host.docker.internal inside containers. The platform API
# is only reachable via the Docker network mesh from inside a workspace
# container regardless of the runtime environment (Docker/host).
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
# Cache workspace ID → name mappings (populated by list_peers calls)
_peer_names: dict[str, str] = {}
@@ -187,27 +187,17 @@ def enrich_peer_metadata_nonblocking(
canon = _validate_peer_id(peer_id)
if canon is None:
return None
# Cache-first: return immediately on warm hit (same TTL logic as the
# sync path). This is the hot-path optimisation — every push from a
# warm peer must return the record without touching the in-flight set
# or the executor. A background fetch that races to fill the cache
# will find the entry already present when it calls
# enrich_peer_metadata (which does its own fresh-TTL check), so it
# exits as a no-op with no extra network traffic.
current = time.monotonic()
cached = _peer_metadata_get(canon)
if cached is not None:
fetched_at, record = cached
if current - fetched_at < _PEER_METADATA_TTL_SECONDS:
return record
# Cache miss or TTL expired: schedule background fetch unless one is
# already in flight for this peer. The synchronous version atomically
# reads-then-writes; the async version splits that into "schedule
# fetch" + "fetch fills cache later." The in-flight set keeps a
# flurry of pushes from one peer (e.g., a chatty agent) from
# spawning N parallel GETs.
# Schedule background fetch unless one is already in flight for this
# peer. The synchronous version atomically reads-then-writes; the
# async version splits that into "schedule fetch" + "fetch fills
# cache later." The in-flight set keeps a flurry of pushes from
# one peer (e.g., a chatty agent) from spawning N parallel GETs.
with _enrich_in_flight_lock:
if canon in _enrich_in_flight:
return None
@@ -266,12 +256,6 @@ def _wait_for_enrichment_inflight_for_testing(timeout: float = 2.0) -> None:
time.sleep(0.01)
def _peer_in_flight_clear_for_testing() -> None:
"""Clear the in-flight enrichment set. Test-only helper."""
with _enrich_in_flight_lock:
_enrich_in_flight.clear()
def enrich_peer_metadata(
peer_id: str,
source_workspace_id: str | None = None,
+1 -7
View File
@@ -52,7 +52,6 @@ from executor_helpers import (
collect_outbound_files,
extract_attached_files,
read_delegation_results,
sanitize_agent_error,
)
from builtin_tools.telemetry import (
A2A_TASK_ID,
@@ -548,12 +547,7 @@ class LangGraphA2AExecutor(AgentExecutor):
# receive the error and stop polling.
await updater.failed(
message=new_text_message(
# Pass the exception string as stderr so sanitize_agent_error
# can include a ~1KB preview in the A2A error response.
# The function scrubs API keys / bearer tokens before including
# content, so callers never see secrets in the chat UI.
# Fixes: roadmap item "SDK executor stderr swallowing".
sanitize_agent_error(stderr=str(e)), task_id=task_id, context_id=context_id,
f"Agent error: {e}", task_id=task_id, context_id=context_id
)
)
finally:
+3 -11
View File
@@ -47,11 +47,7 @@ from a2a_client import (
send_a2a_message,
)
from a2a_tools_rbac import auth_headers_for_heartbeat as _auth_headers_for_heartbeat
from _sanitize_a2a import (
_A2A_BOUNDARY_END,
_A2A_BOUNDARY_START,
sanitize_a2a_result,
) # noqa: E402
from _sanitize_a2a import sanitize_a2a_result # noqa: E402
# RFC #2829 PR-5 cutover constants. The poll cadence + timeout are
@@ -326,12 +322,8 @@ async def tool_delegate_task(
f"You should either: (1) try a different peer, (2) handle this task yourself, "
f"or (3) inform the user that {peer_name} is unavailable and provide your best answer."
)
# OFFSEC-003: escape boundary markers in peer text, then wrap in boundary
# markers so the agent can distinguish trusted (own output) from untrusted
# (peer-supplied) content. Explicit wrapping here rather than inside
# sanitize_a2a_result preserves a clean separation of concerns.
escaped = sanitize_a2a_result(result)
return f"{_A2A_BOUNDARY_START}\n{escaped}\n{_A2A_BOUNDARY_END}"
# OFFSEC-003: wrap peer result in trust boundary before returning to agent context
return sanitize_a2a_result(result)
async def tool_delegate_task_async(
+4 -20
View File
@@ -40,16 +40,6 @@ from a2a.helpers import new_text_message
from adapter_base import AdapterConfig, BaseAdapter
# Import sanitize_agent_error from the workspace package. The adapter lives
# in the workspace/adapters/ hierarchy so the workspace package root is
# always importable as long as the module is loaded from within a workspace.
# In standalone template repos, this import resolves via the workspace package
# entry point that also provides adapter_base.
try:
from executor_helpers import sanitize_agent_error # type: ignore[attr-defined]
except ImportError: # pragma: no cover
sanitize_agent_error = None # fallback: below handler falls back to class-name only
if TYPE_CHECKING:
pass
@@ -242,16 +232,10 @@ class GoogleADKA2AExecutor(AgentExecutor):
type(exc).__name__,
exc_info=True,
)
# Include exception detail (first ~1 KB) in the A2A error response so
# callers get actionable context without needing workspace log access.
# sanitize_agent_error scrubs API keys / bearer tokens before including
# content in the response. Falls back to class-name-only when
# the function is unavailable (standalone template repo layout).
if sanitize_agent_error is not None:
msg = sanitize_agent_error(stderr=str(exc))
else:
msg = f"Agent error: {type(exc).__name__}"
await event_queue.enqueue_event(new_text_message(msg))
# Mirror sanitize_agent_error() convention: expose class name only.
await event_queue.enqueue_event(
new_text_message(f"Agent error: {type(exc).__name__}")
)
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Cancel a running task — emits canceled state per A2A protocol."""
@@ -1,31 +0,0 @@
# Publish-runtime pipeline verification — 2026-05-11
Marker file for the canonical end-to-end pipeline verification after
`publish-runtime-bot` provisioning (internal#327) + stale-tag drift
resolution (`runtime-v0.1.131` deleted from main).
## Purpose
Triggers `workspace/**` path filter on `publish-runtime-autobump.yml`,
exercising the full pipeline:
1. `publish-runtime-autobump / bump-and-tag` reads PyPI version, computes
next, pushes tag `runtime-v0.1.131` (or higher) using new bot scope.
2. `publish-runtime.yml` fires on tag, builds + publishes to PyPI.
3. Cascade autobump: 9 template repos get their `.runtime-version`
pinned to the new version.
## Acceptance criteria
- [ ] autobump bump-and-tag context green on merged commit
- [ ] tag `runtime-v0.1.131` (or computed next) exists on molecule-core
- [ ] publish-runtime.yml run green
- [ ] PyPI molecule-ai-workspace-runtime updated from 0.1.130
- [ ] 9 template repos updated their pinned runtime version
## Rollback
This file is informational only — no code dependency. Safe to delete
in any future PR once pipeline is proven stable.
— core-devops (per Hongming "long-term proper robust" directive 2026-05-11 19:48-19:50Z)

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