Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4c970d23a |
@@ -1,369 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gitea-merge-queue — conservative serialized merge bot for Gitea.
|
||||
|
||||
Gitea 1.22.6 has auto-merge (`pull_auto_merge`) but no GitHub-style merge
|
||||
queue. This script provides the missing serialized policy in user space:
|
||||
|
||||
1. Pick the oldest open PR carrying QUEUE_LABEL.
|
||||
2. Refuse to act unless main is green.
|
||||
3. Refuse fork PRs; the queue may only mutate same-repo branches.
|
||||
4. If the PR branch does not contain current main, call Gitea's
|
||||
/pulls/{n}/update endpoint and stop. CI must rerun on the updated head.
|
||||
5. If the updated PR head has all required contexts green, merge with the
|
||||
non-bypass merge actor token.
|
||||
|
||||
The script is intentionally one-PR-per-run. Workflow/cron concurrency should
|
||||
serialize invocations so two green PRs cannot merge against the same main.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _env(key: str, *, default: str = "") -> str:
|
||||
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")
|
||||
QUEUE_LABEL = _env("QUEUE_LABEL", default="merge-queue")
|
||||
HOLD_LABEL = _env("HOLD_LABEL", default="merge-queue-hold")
|
||||
UPDATE_STYLE = _env("UPDATE_STYLE", default="merge")
|
||||
REQUIRED_CONTEXTS_RAW = _env(
|
||||
"REQUIRED_CONTEXTS",
|
||||
default=(
|
||||
"CI / all-required (pull_request),"
|
||||
"sop-checklist / all-items-acked (pull_request)"
|
||||
),
|
||||
)
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MergeDecision:
|
||||
ready: bool
|
||||
action: str
|
||||
reason: str
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "WATCH_BRANCH", "QUEUE_LABEL"):
|
||||
if not os.environ.get(key):
|
||||
sys.stderr.write(f"::error::missing required env var: {key}\n")
|
||||
sys.exit(2)
|
||||
if UPDATE_STYLE not in {"merge", "rebase"}:
|
||||
sys.stderr.write("::error::UPDATE_STYLE must be merge or rebase\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def api(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: dict | None = None,
|
||||
query: dict[str, str] | None = None,
|
||||
expect_json: bool = True,
|
||||
) -> tuple[int, Any]:
|
||||
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 exc:
|
||||
raw = exc.read()
|
||||
status = exc.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 exc:
|
||||
if expect_json:
|
||||
raise ApiError(f"{method} {path} -> HTTP {status} non-JSON: {exc}") from exc
|
||||
return status, {"_raw": raw.decode("utf-8", errors="replace")}
|
||||
|
||||
|
||||
def required_contexts(raw: str) -> list[str]:
|
||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
||||
|
||||
|
||||
def status_state(status: dict) -> str:
|
||||
return str(status.get("status") or status.get("state") or "").lower()
|
||||
|
||||
|
||||
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
|
||||
latest: dict[str, dict] = {}
|
||||
for status in statuses:
|
||||
context = status.get("context")
|
||||
if isinstance(context, str) and context not in latest:
|
||||
latest[context] = status
|
||||
return latest
|
||||
|
||||
|
||||
def required_contexts_green(
|
||||
latest_statuses: dict[str, dict],
|
||||
contexts: list[str],
|
||||
) -> tuple[bool, list[str]]:
|
||||
missing_or_bad: list[str] = []
|
||||
for context in contexts:
|
||||
status = latest_statuses.get(context)
|
||||
state = status_state(status or {})
|
||||
if state != "success":
|
||||
missing_or_bad.append(f"{context}={state or 'missing'}")
|
||||
return not missing_or_bad, missing_or_bad
|
||||
|
||||
|
||||
def label_names(issue: dict) -> set[str]:
|
||||
return {
|
||||
label["name"]
|
||||
for label in issue.get("labels", [])
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
}
|
||||
|
||||
|
||||
def choose_next_queued_issue(
|
||||
issues: list[dict],
|
||||
*,
|
||||
queue_label: str,
|
||||
hold_label: str = "",
|
||||
) -> dict | None:
|
||||
candidates = []
|
||||
for issue in issues:
|
||||
labels = label_names(issue)
|
||||
if queue_label not in labels:
|
||||
continue
|
||||
if hold_label and hold_label in labels:
|
||||
continue
|
||||
if "pull_request" not in issue:
|
||||
continue
|
||||
candidates.append(issue)
|
||||
candidates.sort(key=lambda issue: (issue.get("created_at") or "", int(issue["number"])))
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def pr_contains_base_sha(commits: list[dict], base_sha: str) -> bool:
|
||||
for commit in commits:
|
||||
sha = commit.get("sha") or commit.get("id")
|
||||
if sha == base_sha:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pr_has_current_base(pr: dict, commits: list[dict], main_sha: str) -> bool:
|
||||
if pr.get("merge_base") == main_sha:
|
||||
return True
|
||||
return pr_contains_base_sha(commits, main_sha)
|
||||
|
||||
|
||||
def evaluate_merge_readiness(
|
||||
*,
|
||||
main_status: dict,
|
||||
pr_status: dict,
|
||||
required_contexts: list[str],
|
||||
pr_has_current_base: bool,
|
||||
) -> MergeDecision:
|
||||
main_state = str(main_status.get("state") or "").lower()
|
||||
if main_state != "success":
|
||||
return MergeDecision(False, "pause", f"main status is {main_state or 'missing'}")
|
||||
if not pr_has_current_base:
|
||||
return MergeDecision(False, "update", "PR head does not contain current main")
|
||||
|
||||
pr_state = str(pr_status.get("state") or "").lower()
|
||||
if pr_state != "success":
|
||||
return MergeDecision(False, "wait", f"PR combined status is {pr_state or 'missing'}")
|
||||
|
||||
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
|
||||
ok, missing_or_bad = required_contexts_green(latest, required_contexts)
|
||||
if not ok:
|
||||
return MergeDecision(False, "wait", "required contexts not green: " + ", ".join(missing_or_bad))
|
||||
return MergeDecision(True, "merge", "ready")
|
||||
|
||||
|
||||
def get_branch_head(branch: str) -> str:
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/branches/{branch}")
|
||||
commit = body.get("commit") if isinstance(body, dict) else None
|
||||
sha = commit.get("id") if isinstance(commit, dict) else None
|
||||
if not isinstance(sha, str) or len(sha) < 7:
|
||||
raise ApiError(f"branch {branch} response missing commit id")
|
||||
return sha
|
||||
|
||||
|
||||
def get_combined_status(sha: str) -> dict:
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"status for {sha} response not object")
|
||||
return body
|
||||
|
||||
|
||||
def list_queued_issues() -> list[dict]:
|
||||
_, body = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/issues",
|
||||
query={
|
||||
"state": "open",
|
||||
"type": "pulls",
|
||||
"labels": QUEUE_LABEL,
|
||||
"limit": "50",
|
||||
},
|
||||
)
|
||||
if not isinstance(body, list):
|
||||
raise ApiError("queued issues response not list")
|
||||
return body
|
||||
|
||||
|
||||
def get_pull(pr_number: int) -> dict:
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"PR #{pr_number} response not object")
|
||||
return body
|
||||
|
||||
|
||||
def get_pull_commits(pr_number: int) -> list[dict]:
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/commits")
|
||||
if not isinstance(body, list):
|
||||
raise ApiError(f"PR #{pr_number} commits response not list")
|
||||
return body
|
||||
|
||||
|
||||
def post_comment(pr_number: int, body: str, *, dry_run: bool) -> None:
|
||||
print(f"::notice::comment PR #{pr_number}: {body.splitlines()[0][:160]}")
|
||||
if dry_run:
|
||||
return
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/issues/{pr_number}/comments", body={"body": body})
|
||||
|
||||
|
||||
def update_pull(pr_number: int, *, dry_run: bool) -> None:
|
||||
print(f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}")
|
||||
if dry_run:
|
||||
return
|
||||
api(
|
||||
"POST",
|
||||
f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/update",
|
||||
query={"style": UPDATE_STYLE},
|
||||
expect_json=False,
|
||||
)
|
||||
|
||||
|
||||
def merge_pull(pr_number: int, *, dry_run: bool) -> None:
|
||||
payload = {
|
||||
"Do": "merge",
|
||||
"MergeTitleField": f"Merge PR #{pr_number} via Gitea merge queue",
|
||||
"MergeMessageField": (
|
||||
"Serialized merge by gitea-merge-queue after current-main, "
|
||||
"SOP, and required CI checks were green."
|
||||
),
|
||||
}
|
||||
print(f"::notice::merging PR #{pr_number}")
|
||||
if dry_run:
|
||||
return
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
|
||||
|
||||
|
||||
def process_once(*, dry_run: bool = False) -> int:
|
||||
contexts = required_contexts(REQUIRED_CONTEXTS_RAW)
|
||||
main_sha = get_branch_head(WATCH_BRANCH)
|
||||
main_status = get_combined_status(main_sha)
|
||||
if str(main_status.get("state") or "").lower() != "success":
|
||||
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} is not green")
|
||||
return 0
|
||||
|
||||
issue = choose_next_queued_issue(
|
||||
list_queued_issues(),
|
||||
queue_label=QUEUE_LABEL,
|
||||
hold_label=HOLD_LABEL,
|
||||
)
|
||||
if not issue:
|
||||
print("::notice::merge queue empty")
|
||||
return 0
|
||||
|
||||
pr_number = int(issue["number"])
|
||||
pr = get_pull(pr_number)
|
||||
if pr.get("state") != "open":
|
||||
print(f"::notice::PR #{pr_number} is not open; skipping")
|
||||
return 0
|
||||
if pr.get("base", {}).get("ref") != WATCH_BRANCH:
|
||||
post_comment(pr_number, f"merge-queue: skipped; base branch is not `{WATCH_BRANCH}`.", dry_run=dry_run)
|
||||
return 0
|
||||
if pr.get("head", {}).get("repo_id") != pr.get("base", {}).get("repo_id"):
|
||||
post_comment(pr_number, "merge-queue: skipped; fork PRs are not supported by the serialized queue.", dry_run=dry_run)
|
||||
return 0
|
||||
|
||||
head_sha = pr.get("head", {}).get("sha")
|
||||
if not isinstance(head_sha, str) or len(head_sha) < 7:
|
||||
raise ApiError(f"PR #{pr_number} missing head sha")
|
||||
commits = get_pull_commits(pr_number)
|
||||
current_base = pr_has_current_base(pr, commits, main_sha)
|
||||
pr_status = get_combined_status(head_sha)
|
||||
decision = evaluate_merge_readiness(
|
||||
main_status=main_status,
|
||||
pr_status=pr_status,
|
||||
required_contexts=contexts,
|
||||
pr_has_current_base=current_base,
|
||||
)
|
||||
|
||||
print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}")
|
||||
if decision.action == "update":
|
||||
update_pull(pr_number, dry_run=dry_run)
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
f"merge-queue: updated this branch with `{WATCH_BRANCH}` at "
|
||||
f"`{main_sha[:12]}`. Waiting for CI on the refreshed head."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return 0
|
||||
if decision.ready:
|
||||
latest_main_sha = get_branch_head(WATCH_BRANCH)
|
||||
if latest_main_sha != main_sha:
|
||||
print(
|
||||
f"::notice::main moved {main_sha[:8]} -> {latest_main_sha[:8]}; "
|
||||
"deferring to next tick"
|
||||
)
|
||||
return 0
|
||||
merge_pull(pr_number, dry_run=dry_run)
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
_require_runtime_env()
|
||||
return process_once(dry_run=args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -29,13 +29,6 @@ Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn):
|
||||
or `https://github.com/.../releases/download` without a
|
||||
workflow-level `env.GITHUB_SERVER_URL` set to the Gitea instance.
|
||||
Memory: feedback_act_runner_github_server_url.
|
||||
7. Production deploy/redeploy workflows may not rely on Gitea
|
||||
`concurrency.cancel-in-progress: false` for serialization. Gitea
|
||||
1.22.6 can cancel queued runs despite that setting.
|
||||
8. Production deploy/redeploy workflows may not dump raw CP responses or
|
||||
raw `.error` fields into CI logs/summaries.
|
||||
9. Production deploy/redeploy workflows must expose an operational control:
|
||||
kill switch for auto deploys or rollback tag for manual deploys.
|
||||
|
||||
Per `feedback_smoke_test_vendor_truth_not_shape_match`: fixtures used to
|
||||
validate this lint must mirror real Gitea 1.22.6 YAML semantics, not
|
||||
@@ -262,19 +255,6 @@ GITHUB_API_REF_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
PROD_CP_URL_RE = re.compile(r"https://api\.moleculesai\.app\b")
|
||||
REDEPLOY_FLEET_RE = re.compile(r"\b/cp/admin/tenants/redeploy-fleet\b")
|
||||
RAW_CP_RESPONSE_RE = re.compile(
|
||||
r"""(?x)
|
||||
(?:\bjq\s+\.\s+["']?\$HTTP_RESPONSE["']?)
|
||||
|
|
||||
(?:\bcat\s+["']?\$HTTP_RESPONSE["']?)
|
||||
|
|
||||
(?:\|\s*\.error\b)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _has_workflow_level_server_url(doc: Any) -> bool:
|
||||
if not isinstance(doc, dict):
|
||||
return False
|
||||
@@ -306,83 +286,6 @@ def check_github_server_url_missing(filename: str, doc: Any, raw: str) -> list[s
|
||||
return warns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 7-9 — production CI/CD hardening rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_production_redeploy_workflow(raw: str) -> bool:
|
||||
"""Heuristic production-side-effect detector.
|
||||
|
||||
We intentionally key on the production CP host plus the redeploy-fleet
|
||||
endpoint. Staging workflows call the same endpoint on staging-api and are
|
||||
governed by looser staging verification policy.
|
||||
"""
|
||||
|
||||
return bool(PROD_CP_URL_RE.search(raw) and REDEPLOY_FLEET_RE.search(raw))
|
||||
|
||||
|
||||
def _iter_concurrency_blocks(doc: Any) -> Iterable[dict[str, Any]]:
|
||||
if not isinstance(doc, dict):
|
||||
return
|
||||
top = doc.get("concurrency")
|
||||
if isinstance(top, dict):
|
||||
yield top
|
||||
jobs = doc.get("jobs")
|
||||
if not isinstance(jobs, dict):
|
||||
return
|
||||
for job in jobs.values():
|
||||
if isinstance(job, dict) and isinstance(job.get("concurrency"), dict):
|
||||
yield job["concurrency"]
|
||||
|
||||
|
||||
def check_production_concurrency(filename: str, doc: Any, raw: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not _is_production_redeploy_workflow(raw):
|
||||
return errors
|
||||
for block in _iter_concurrency_blocks(doc):
|
||||
if block.get("cancel-in-progress") is False:
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 7 (FATAL): production deploy "
|
||||
f"workflow uses `concurrency.cancel-in-progress: false`. "
|
||||
f"Gitea 1.22.6 can cancel queued runs despite that setting, "
|
||||
f"so this is not a safe production serialization primitive. "
|
||||
f"Use an external queue/lock or make the deploy idempotent."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def check_production_raw_response_logging(filename: str, raw: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not _is_production_redeploy_workflow(raw):
|
||||
return errors
|
||||
if RAW_CP_RESPONSE_RE.search(raw):
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 8 (FATAL): production deploy "
|
||||
f"workflow appears to print a raw production CP response or raw "
|
||||
f"`.error` field. CI logs are persistent and broad-read. Redact "
|
||||
f"runtime/SSM error details; print counts, booleans, status "
|
||||
f"codes, and links to restricted observability instead."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def check_production_operational_control(filename: str, raw: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not _is_production_redeploy_workflow(raw):
|
||||
return errors
|
||||
has_kill_switch = "PROD_AUTO_DEPLOY_DISABLED" in raw
|
||||
has_rollback = "PROD_MANUAL_REDEPLOY_TARGET_TAG" in raw
|
||||
if not (has_kill_switch or has_rollback):
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 9 (FATAL): production deploy "
|
||||
f"workflow calls redeploy-fleet without an operational control. "
|
||||
f"Auto deploys need a `PROD_AUTO_DEPLOY_DISABLED` kill switch; "
|
||||
f"manual deploys need a `PROD_MANUAL_REDEPLOY_TARGET_TAG` "
|
||||
f"rollback/pin path."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -433,9 +336,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
fatal_errors.extend(check_workflow_run_event(rel, doc))
|
||||
fatal_errors.extend(check_name_with_slash(rel, doc))
|
||||
fatal_errors.extend(check_cross_repo_uses(rel, doc))
|
||||
fatal_errors.extend(check_production_concurrency(rel, doc, raw))
|
||||
fatal_errors.extend(check_production_raw_response_logging(rel, raw))
|
||||
fatal_errors.extend(check_production_operational_control(rel, raw))
|
||||
warnings.extend(check_github_server_url_missing(rel, doc, raw))
|
||||
|
||||
# Cross-file checks
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production auto-deploy helpers for Gitea Actions.
|
||||
|
||||
The workflow keeps network side effects in shell/curl, but centralizes the
|
||||
release decision shape here so it has unit coverage: disable flag parsing,
|
||||
target tag selection, CP payload construction, and status-context selection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
TRUE_VALUES = {"1", "true", "yes", "on", "disabled", "disable"}
|
||||
PROD_CP_URL = "https://api.moleculesai.app"
|
||||
DEFAULT_REQUIRED_CONTEXTS = [
|
||||
"CI / Platform (Go) (push)",
|
||||
"CI / Canvas (Next.js) (push)",
|
||||
"CI / Shellcheck (E2E scripts) (push)",
|
||||
"CI / Python Lint & Test (push)",
|
||||
"CI / all-required (push)",
|
||||
"Secret scan / Scan diff for credential-shaped strings (push)",
|
||||
]
|
||||
TERMINAL_FAILURE_STATES = {"failure", "error", "cancelled", "canceled", "skipped"}
|
||||
|
||||
|
||||
def truthy_flag(value: str | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
return value.strip().lower() in TRUE_VALUES
|
||||
|
||||
|
||||
def _int_env(env: dict[str, str], name: str, default: int, minimum: int = 1) -> int:
|
||||
raw = env.get(name, "")
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{name} must be an integer, got {raw!r}") from exc
|
||||
if value < minimum:
|
||||
raise ValueError(f"{name} must be >= {minimum}, got {value}")
|
||||
return value
|
||||
|
||||
|
||||
def build_plan(env: dict[str, str]) -> dict:
|
||||
sha = env.get("GITHUB_SHA", "").strip()
|
||||
if not sha:
|
||||
raise ValueError("GITHUB_SHA is required")
|
||||
|
||||
disabled_value = env.get("PROD_AUTO_DEPLOY_DISABLED", "")
|
||||
if truthy_flag(disabled_value):
|
||||
return {
|
||||
"enabled": False,
|
||||
"sha": sha,
|
||||
"disabled_reason": f"PROD_AUTO_DEPLOY_DISABLED={disabled_value}",
|
||||
}
|
||||
|
||||
short_sha = sha[:7]
|
||||
target_tag = env.get("PROD_AUTO_DEPLOY_TARGET_TAG", "").strip() or f"staging-{short_sha}"
|
||||
canary_slug = env.get("PROD_AUTO_DEPLOY_CANARY_SLUG", "hongming").strip()
|
||||
body = {
|
||||
"target_tag": target_tag,
|
||||
"soak_seconds": _int_env(env, "PROD_AUTO_DEPLOY_SOAK_SECONDS", 60, minimum=0),
|
||||
"batch_size": _int_env(env, "PROD_AUTO_DEPLOY_BATCH_SIZE", 3),
|
||||
"dry_run": truthy_flag(env.get("PROD_AUTO_DEPLOY_DRY_RUN", "")),
|
||||
}
|
||||
if canary_slug:
|
||||
body["canary_slug"] = canary_slug
|
||||
|
||||
cp_url = env.get("CP_URL", "").strip() or PROD_CP_URL
|
||||
if cp_url != PROD_CP_URL and not truthy_flag(env.get("PROD_ALLOW_NON_PROD_CP_URL", "")):
|
||||
raise ValueError(
|
||||
f"Refusing production deploy to CP_URL={cp_url!r}; "
|
||||
f"set PROD_ALLOW_NON_PROD_CP_URL=true for an explicit non-prod drill"
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"sha": sha,
|
||||
"short_sha": short_sha,
|
||||
"target_tag": target_tag,
|
||||
"cp_url": cp_url,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
|
||||
def latest_status_for_context(statuses: list[dict], context: str) -> dict | None:
|
||||
"""Return the first matching status.
|
||||
|
||||
Gitea's combined-status response is newest-first in practice. The merge
|
||||
queue relies on the same contract; keeping the selector explicit makes
|
||||
stale duplicate contexts easy to test.
|
||||
"""
|
||||
|
||||
for status in statuses:
|
||||
if status.get("context") == context:
|
||||
return status
|
||||
return None
|
||||
|
||||
|
||||
def ci_context_state(statuses: list[dict], context: str) -> str:
|
||||
status = latest_status_for_context(statuses, context)
|
||||
if not status:
|
||||
return "missing"
|
||||
return str(status.get("status") or status.get("state") or "missing").lower()
|
||||
|
||||
|
||||
def context_is_satisfied(state: str) -> bool:
|
||||
return state == "success"
|
||||
|
||||
|
||||
def context_is_terminal_failure(state: str) -> bool:
|
||||
return state in TERMINAL_FAILURE_STATES
|
||||
|
||||
|
||||
def required_contexts(env: dict[str, str]) -> list[str]:
|
||||
raw = env.get("PROD_AUTO_DEPLOY_REQUIRED_CONTEXTS", "")
|
||||
if not raw.strip():
|
||||
return DEFAULT_REQUIRED_CONTEXTS
|
||||
return [line.strip() for line in raw.replace(",", "\n").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _api_json(url: str, token: str) -> dict:
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"GET {url} -> HTTP {exc.code}: {body}") from exc
|
||||
|
||||
|
||||
def _api_json_optional(url: str, token: str) -> tuple[int, dict | None]:
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return exc.code, None
|
||||
body = exc.read().decode("utf-8", errors="replace")[:300]
|
||||
print(f"::warning::GET {url} -> HTTP {exc.code}: {body}", file=sys.stderr)
|
||||
return exc.code, None
|
||||
|
||||
|
||||
def live_disable_flag(env: dict[str, str]) -> str:
|
||||
"""Return a live disable value from Gitea variables when readable.
|
||||
|
||||
Gitea evaluates `${{ vars.* }}` once when the job starts. This API read is
|
||||
the emergency re-check immediately before production side effects.
|
||||
"""
|
||||
|
||||
token = env.get("GITEA_TOKEN", "").strip()
|
||||
if not token:
|
||||
return ""
|
||||
host = env.get("GITEA_HOST", "git.moleculesai.app")
|
||||
repo = env.get("GITHUB_REPOSITORY", "molecule-ai/molecule-core")
|
||||
variable = quote("PROD_AUTO_DEPLOY_DISABLED", safe="")
|
||||
url = f"https://{host}/api/v1/repos/{repo}/actions/variables/{variable}"
|
||||
status, body = _api_json_optional(url, token)
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
return ""
|
||||
return str(body.get("data") or body.get("value") or "")
|
||||
|
||||
|
||||
def assert_not_disabled(env: dict[str, str]) -> None:
|
||||
plan = build_plan(env)
|
||||
if not plan.get("enabled"):
|
||||
raise RuntimeError(plan.get("disabled_reason", "production auto-deploy disabled"))
|
||||
live_value = live_disable_flag(env)
|
||||
if truthy_flag(live_value):
|
||||
raise RuntimeError(f"PROD_AUTO_DEPLOY_DISABLED={live_value} (live Gitea variable)")
|
||||
|
||||
|
||||
def wait_for_ci_context(env: dict[str, str]) -> str:
|
||||
host = env.get("GITEA_HOST", "git.moleculesai.app")
|
||||
repo = env.get("GITHUB_REPOSITORY", "molecule-ai/molecule-core")
|
||||
sha = env.get("GITHUB_SHA", "").strip()
|
||||
token = env.get("GITEA_TOKEN", "").strip()
|
||||
contexts = required_contexts(env)
|
||||
interval = _int_env(env, "CI_STATUS_POLL_INTERVAL_SECONDS", 15)
|
||||
timeout = _int_env(env, "CI_STATUS_TIMEOUT_SECONDS", 1800)
|
||||
|
||||
if not sha:
|
||||
raise ValueError("GITHUB_SHA is required")
|
||||
if not token:
|
||||
raise ValueError("GITEA_TOKEN is required to wait for CI status")
|
||||
|
||||
url = f"https://{host}/api/v1/repos/{repo}/commits/{sha}/status"
|
||||
deadline = time.time() + timeout
|
||||
last_states: dict[str, str] = {}
|
||||
while time.time() <= deadline:
|
||||
body = _api_json(url, token)
|
||||
statuses = body.get("statuses") or []
|
||||
states = {context: ci_context_state(statuses, context) for context in contexts}
|
||||
for context, state in states.items():
|
||||
if state != last_states.get(context):
|
||||
print(f"CI context {context!r}: {state}", file=sys.stderr)
|
||||
last_states = states
|
||||
|
||||
failures = [
|
||||
f"{context}={state}"
|
||||
for context, state in states.items()
|
||||
if context_is_terminal_failure(state)
|
||||
]
|
||||
if failures:
|
||||
raise RuntimeError(
|
||||
"Required CI context failed; refusing production deploy: "
|
||||
+ ", ".join(failures)
|
||||
)
|
||||
if all(context_is_satisfied(state) for state in states.values()):
|
||||
return "success"
|
||||
time.sleep(interval)
|
||||
last = ", ".join(f"{context}={state}" for context, state in last_states.items()) or "none"
|
||||
raise TimeoutError(f"Timed out waiting {timeout}s for required CI contexts; last_states={last}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser("plan", help="print production deploy plan as JSON")
|
||||
sub.add_parser("assert-enabled", help="fail if production deploy is currently disabled")
|
||||
sub.add_parser("wait-ci", help="block until required CI context is green")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.command == "plan":
|
||||
print(json.dumps(build_plan(dict(os.environ)), sort_keys=True))
|
||||
return 0
|
||||
if args.command == "assert-enabled":
|
||||
assert_not_disabled(dict(os.environ))
|
||||
return 0
|
||||
if args.command == "wait-ci":
|
||||
wait_for_ci_context(dict(os.environ))
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001 - CLI should render operator-friendly errors.
|
||||
print(f"::error::{exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -60,7 +60,6 @@
|
||||
# Optional:
|
||||
# REVIEW_CHECK_DEBUG=1 — per-API-call diagnostic lines
|
||||
# REVIEW_CHECK_STRICT=1 — also require review.commit_id == pr.head.sha
|
||||
# DEFAULT_BRANCH=main — branch this gate protects; non-default-base PRs no-op
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -92,7 +91,7 @@ API="https://${GITEA_HOST}/api/v1"
|
||||
# secret token value in the process table for any process to read via
|
||||
# /proc/<pid>/cmdline or ps -ef). The curl config file is read by curl
|
||||
# itself and never appears in the argv of the curl subprocess.
|
||||
CURL_AUTH_FILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.XXXXXX")
|
||||
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"
|
||||
|
||||
@@ -125,19 +124,13 @@ if [ "$HTTP_CODE" != "200" ]; then
|
||||
fi
|
||||
PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON")
|
||||
PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON")
|
||||
PR_BASE_REF=$(jq -r '.base.ref // ""' "$PR_JSON")
|
||||
PR_STATE=$(jq -r '.state // ""' "$PR_JSON")
|
||||
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
|
||||
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_base=${PR_BASE_REF} pr_state=${PR_STATE}"
|
||||
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 [ "$PR_BASE_REF" != "$DEFAULT_BRANCH" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} targets ${PR_BASE_REF:-<unknown>} not ${DEFAULT_BRANCH} — ${TEAM}-review gate not applicable"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then
|
||||
echo "::error::PR ${PR_NUMBER} missing user.login or head.sha — webhook payload malformed"
|
||||
exit 1
|
||||
|
||||
@@ -620,8 +620,8 @@ def render_status(
|
||||
|
||||
state is "success" if every item has at least one valid ack
|
||||
(body section presence is informational only — peer-ack is the
|
||||
real gate). tier:low PRs receive state="success" (soft-fail — no
|
||||
acks required); the description carries "[info tier:low]" prefix.
|
||||
real gate). "pending" is reserved for the soft-fail path
|
||||
(tier:low) and is set by the caller.
|
||||
"""
|
||||
n = len(items)
|
||||
fully_acked = [
|
||||
@@ -640,11 +640,8 @@ def render_status(
|
||||
shown += f", +{len(missing) - 3}"
|
||||
desc_parts.append(f"missing: {shown}")
|
||||
if missing_body:
|
||||
shown = ", ".join(missing_body[:3])
|
||||
if len(missing_body) > 3:
|
||||
shown += f", +{len(missing_body) - 3}"
|
||||
desc_parts.append(f"body-unfilled: {shown}")
|
||||
state = "success" if not missing and not missing_body else "failure"
|
||||
desc_parts.append(f"body-unfilled: {len(missing_body)}")
|
||||
state = "success" if not missing else "failure"
|
||||
return state, " — ".join(desc_parts)
|
||||
|
||||
|
||||
@@ -776,12 +773,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
state, description = render_status(items, ack_state, body_state)
|
||||
mode = get_tier_mode(pr, cfg)
|
||||
if mode == "soft":
|
||||
# tier:low: acks are informational only — post success so BP gate passes.
|
||||
# Description carries "[info tier:low]" prefix so reviewers know acks
|
||||
# were not required (vs a tier:medium+ PR that truly passed all acks).
|
||||
state = "success"
|
||||
description = f"[info tier:low] {description}"
|
||||
if state == "failure" and mode == "soft":
|
||||
state = "pending"
|
||||
description = f"[soft-fail tier:low] {description}"
|
||||
|
||||
# Diagnostics to job log.
|
||||
print(f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} mode={mode}")
|
||||
|
||||
@@ -58,10 +58,9 @@ What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
|
||||
even if another tick happens before the runner finishes.
|
||||
|
||||
What it does NOT do:
|
||||
- Touch ` (pull_request)` contexts unless the exact same
|
||||
workflow/job has a successful ` (push)` context on the same
|
||||
default-branch SHA. That case is post-merge status pollution, not
|
||||
an unproven PR gate.
|
||||
- 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
|
||||
@@ -92,9 +91,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -121,28 +118,19 @@ WORKFLOWS_DIR = _env("WORKFLOWS_DIR", default=".gitea/workflows")
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
API_TIMEOUT_SEC = int(_env("STATUS_REAPER_API_TIMEOUT_SEC", default="30") or "30")
|
||||
API_RETRIES = int(_env("STATUS_REAPER_API_RETRIES", default="3") or "3")
|
||||
API_RETRY_SLEEP_SEC = float(_env("STATUS_REAPER_API_RETRY_SLEEP_SEC", default="2") or "2")
|
||||
|
||||
# Compensating-status description prefix. Used as the marker so a human
|
||||
# auditing commit statuses can tell at a glance that the green was
|
||||
# synthetic, not a real CI pass. Kept stable; downstream tooling
|
||||
# (e.g. main-red-watchdog visual diff) MAY key on it.
|
||||
PUSH_COMPENSATION_DESCRIPTION = (
|
||||
COMPENSATION_DESCRIPTION = (
|
||||
"Compensated by status-reaper (workflow has no push: trigger; "
|
||||
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
|
||||
)
|
||||
PR_SHADOW_COMPENSATION_DESCRIPTION = (
|
||||
"Compensated by status-reaper (default-branch pull_request status "
|
||||
"shadowed by successful push status on same SHA; see "
|
||||
".gitea/scripts/status-reaper.py)"
|
||||
)
|
||||
|
||||
# Context suffix the reaper acts on. Gitea hardcodes this for ALL
|
||||
# default-branch workflow runs.
|
||||
PUSH_SUFFIX = " (push)"
|
||||
PULL_REQUEST_SUFFIX = " (pull_request)"
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
@@ -194,27 +182,13 @@ def api(
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
attempts = max(API_RETRIES, 1)
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=API_TIMEOUT_SEC) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
status = e.code
|
||||
break
|
||||
except (TimeoutError, socket.timeout, urllib.error.URLError, OSError) as e:
|
||||
if attempt >= attempts:
|
||||
raise ApiError(
|
||||
f"{method} {path} failed after {attempts} attempts: {e}"
|
||||
) from e
|
||||
print(
|
||||
f"::warning::{method} {path} transient API error "
|
||||
f"(attempt {attempt}/{attempts}): {e}; retrying"
|
||||
)
|
||||
time.sleep(API_RETRY_SLEEP_SEC)
|
||||
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 ""
|
||||
@@ -383,38 +357,24 @@ def get_combined_status(sha: str) -> dict:
|
||||
# --------------------------------------------------------------------------
|
||||
# Context parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def parse_suffixed_context(context: str, suffix: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (<event>)` into
|
||||
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 suffix and at least one ` / `
|
||||
Strict: requires the trailing ` (push)` and at least one ` / `
|
||||
separator. Anything else is left alone.
|
||||
"""
|
||||
if not context.endswith(suffix):
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
return None
|
||||
head = context[: -len(suffix)]
|
||||
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
|
||||
|
||||
|
||||
def parse_push_context(context: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (push)` into
|
||||
(workflow_name, job_name)."""
|
||||
return parse_suffixed_context(context, PUSH_SUFFIX)
|
||||
|
||||
|
||||
def push_equivalent_context(context: str) -> str | None:
|
||||
"""Return the matching `(push)` context for a `(pull_request)` context."""
|
||||
parsed = parse_suffixed_context(context, PULL_REQUEST_SUFFIX)
|
||||
if parsed is None:
|
||||
return None
|
||||
workflow_name, job_name = parsed
|
||||
return f"{workflow_name} / {job_name}{PUSH_SUFFIX}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compensating POST
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -423,7 +383,6 @@ def post_compensating_status(
|
||||
context: str,
|
||||
target_url: str | None,
|
||||
*,
|
||||
description: str = PUSH_COMPENSATION_DESCRIPTION,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the
|
||||
@@ -435,7 +394,7 @@ def post_compensating_status(
|
||||
payload: dict[str, Any] = {
|
||||
"context": context,
|
||||
"state": "success",
|
||||
"description": description,
|
||||
"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
|
||||
@@ -472,8 +431,7 @@ def reap(
|
||||
Returns counters for observability:
|
||||
{compensated, preserved_real_push, preserved_unknown,
|
||||
preserved_non_failure, preserved_non_push_suffix,
|
||||
preserved_unparseable, compensated_pr_shadowed_by_push_success,
|
||||
preserved_pr_without_push_success,
|
||||
preserved_unparseable,
|
||||
compensated_contexts: [<context>, ...]}
|
||||
|
||||
`compensated_contexts` is rev2-added so `reap_branch` can build
|
||||
@@ -486,17 +444,10 @@ def reap(
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_pr_shadowed_by_push_success": 0,
|
||||
"preserved_pr_without_push_success": 0,
|
||||
"compensated_contexts": [],
|
||||
}
|
||||
|
||||
statuses = combined.get("statuses") or []
|
||||
successful_contexts = {
|
||||
(s.get("context") or "")
|
||||
for s in statuses
|
||||
if isinstance(s, dict) and (s.get("status") or s.get("state") or "") == "success"
|
||||
}
|
||||
for s in statuses:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
@@ -520,31 +471,9 @@ def reap(
|
||||
counters["preserved_non_failure"] += 1
|
||||
continue
|
||||
|
||||
# Default-branch `pull_request` contexts can be stale shadows of
|
||||
# the exact same workflow/job already proven by the successful
|
||||
# `push` context on the same SHA. Compensate only that narrow
|
||||
# shape; a missing or failed push equivalent remains a real gate
|
||||
# signal and is preserved.
|
||||
push_equivalent = push_equivalent_context(context)
|
||||
if push_equivalent is not None:
|
||||
if push_equivalent in successful_contexts:
|
||||
post_compensating_status(
|
||||
sha,
|
||||
context,
|
||||
s.get("target_url"),
|
||||
description=PR_SHADOW_COMPENSATION_DESCRIPTION,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
counters["compensated"] += 1
|
||||
counters["compensated_pr_shadowed_by_push_success"] += 1
|
||||
counters["compensated_contexts"].append(context)
|
||||
else:
|
||||
counters["preserved_pr_without_push_success"] += 1
|
||||
continue
|
||||
|
||||
# Only `(push)`-suffix contexts hit the hardcoded-suffix bug.
|
||||
# Other failed contexts are preserved unless handled by the
|
||||
# pull-request-shadow rule above.
|
||||
# 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
|
||||
@@ -666,8 +595,6 @@ def reap_branch(
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_pr_shadowed_by_push_success": 0,
|
||||
"preserved_pr_without_push_success": 0,
|
||||
"compensated_per_sha": {},
|
||||
}
|
||||
|
||||
@@ -705,8 +632,6 @@ def reap_branch(
|
||||
"preserved_non_failure",
|
||||
"preserved_non_push_suffix",
|
||||
"preserved_unparseable",
|
||||
"compensated_pr_shadowed_by_push_success",
|
||||
"preserved_pr_without_push_success",
|
||||
):
|
||||
aggregate[key] += per_sha[key]
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ Scenarios:
|
||||
T7_team_member — team membership → 204 (member) → exit 0
|
||||
T8_team_not_member — team membership → 404 (not a member) → exit 1
|
||||
T9_team_403 — team membership → 403 (token not in team) → exit 1
|
||||
T14_non_default_base — open PR targeting staging → script exits 0 (no-op)
|
||||
|
||||
Usage:
|
||||
FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080
|
||||
@@ -83,14 +82,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
"number": int(pr_num),
|
||||
"state": "closed",
|
||||
"head": {"sha": "deadbeef0000111122223333444455556666"},
|
||||
"base": {"ref": "main"},
|
||||
"user": {"login": "alice"},
|
||||
})
|
||||
return self._json(200, {
|
||||
"number": int(pr_num),
|
||||
"state": "open",
|
||||
"head": {"sha": "deadbeef0000111122223333444455556666"},
|
||||
"base": {"ref": "staging" if sc == "T14_non_default_base" else "main"},
|
||||
"user": {"login": "alice"},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "gitea-merge-queue.py"
|
||||
spec = importlib.util.spec_from_file_location("gitea_merge_queue", SCRIPT)
|
||||
mq = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mq
|
||||
spec.loader.exec_module(mq)
|
||||
|
||||
|
||||
def test_latest_statuses_dedupes_by_context_newest_first():
|
||||
statuses = [
|
||||
{"context": "CI / all-required (pull_request)", "status": "failure"},
|
||||
{"context": "sop-checklist / all-items-acked (pull_request)", "state": "success"},
|
||||
{"context": "CI / all-required (pull_request)", "status": "success"},
|
||||
]
|
||||
|
||||
latest = mq.latest_statuses_by_context(statuses)
|
||||
|
||||
assert latest["CI / all-required (pull_request)"]["status"] == "failure"
|
||||
assert latest["sop-checklist / all-items-acked (pull_request)"]["state"] == "success"
|
||||
|
||||
|
||||
def test_required_contexts_green_rejects_missing_and_pending():
|
||||
latest = mq.latest_statuses_by_context([
|
||||
{"context": "CI / all-required (pull_request)", "status": "success"},
|
||||
{"context": "sop-checklist / all-items-acked (pull_request)", "status": "pending"},
|
||||
])
|
||||
|
||||
ok, missing_or_bad = mq.required_contexts_green(
|
||||
latest,
|
||||
[
|
||||
"CI / all-required (pull_request)",
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
"qa-review / approved (pull_request)",
|
||||
],
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert missing_or_bad == [
|
||||
"sop-checklist / all-items-acked (pull_request)=pending",
|
||||
"qa-review / approved (pull_request)=missing",
|
||||
]
|
||||
|
||||
|
||||
def test_choose_next_pr_sorts_by_queue_label_timestamp_then_number():
|
||||
issues = [
|
||||
{
|
||||
"number": 12,
|
||||
"pull_request": {},
|
||||
"labels": [{"name": "merge-queue"}],
|
||||
"created_at": "2026-05-13T05:00:00Z",
|
||||
"updated_at": "2026-05-13T06:00:00Z",
|
||||
},
|
||||
{
|
||||
"number": 9,
|
||||
"pull_request": {},
|
||||
"labels": [{"name": "merge-queue"}],
|
||||
"created_at": "2026-05-13T04:00:00Z",
|
||||
"updated_at": "2026-05-13T07:00:00Z",
|
||||
},
|
||||
{
|
||||
"number": 7,
|
||||
"labels": [{"name": "merge-queue"}],
|
||||
"created_at": "2026-05-13T03:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
selected = mq.choose_next_queued_issue(issues, queue_label="merge-queue")
|
||||
|
||||
assert selected["number"] == 9
|
||||
|
||||
|
||||
def test_pr_needs_update_when_base_sha_absent_from_commits():
|
||||
commits = [
|
||||
{"sha": "head"},
|
||||
{"sha": "parent"},
|
||||
]
|
||||
|
||||
assert mq.pr_contains_base_sha(commits, "mainsha") is False
|
||||
assert mq.pr_contains_base_sha(commits, "parent") is True
|
||||
|
||||
|
||||
def test_merge_decision_requires_main_green_pr_green_and_current_base():
|
||||
required = ["CI / all-required (pull_request)"]
|
||||
main_status = {"state": "success", "statuses": []}
|
||||
pr_status = {
|
||||
"state": "success",
|
||||
"statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}],
|
||||
}
|
||||
|
||||
decision = mq.evaluate_merge_readiness(
|
||||
main_status=main_status,
|
||||
pr_status=pr_status,
|
||||
required_contexts=required,
|
||||
pr_has_current_base=True,
|
||||
)
|
||||
|
||||
assert decision.ready is True
|
||||
assert decision.action == "merge"
|
||||
|
||||
|
||||
def test_merge_decision_updates_stale_pr_before_merge():
|
||||
decision = mq.evaluate_merge_readiness(
|
||||
main_status={"state": "success", "statuses": []},
|
||||
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
|
||||
required_contexts=["CI / all-required (pull_request)"],
|
||||
pr_has_current_base=False,
|
||||
)
|
||||
|
||||
assert decision.ready is False
|
||||
assert decision.action == "update"
|
||||
@@ -1,120 +0,0 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "prod-auto-deploy.py"
|
||||
spec = importlib.util.spec_from_file_location("prod_auto_deploy", SCRIPT)
|
||||
prod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = prod
|
||||
spec.loader.exec_module(prod)
|
||||
|
||||
|
||||
def test_truthy_flag_accepts_operator_disable_values():
|
||||
for value in ("1", "true", "TRUE", "yes", "on", "disabled", "disable"):
|
||||
assert prod.truthy_flag(value) is True
|
||||
|
||||
for value in ("", "0", "false", "no", "off", None):
|
||||
assert prod.truthy_flag(value) is False
|
||||
|
||||
|
||||
def test_build_plan_defaults_to_staging_sha_target_and_prod_cp():
|
||||
plan = prod.build_plan(
|
||||
{
|
||||
"GITHUB_SHA": "abcdef1234567890",
|
||||
"PROD_AUTO_DEPLOY_DISABLED": "",
|
||||
}
|
||||
)
|
||||
|
||||
assert plan["enabled"] is True
|
||||
assert plan["sha"] == "abcdef1234567890"
|
||||
assert plan["target_tag"] == "staging-abcdef1"
|
||||
assert plan["cp_url"] == "https://api.moleculesai.app"
|
||||
assert plan["body"] == {
|
||||
"target_tag": "staging-abcdef1",
|
||||
"canary_slug": "hongming",
|
||||
"soak_seconds": 60,
|
||||
"batch_size": 3,
|
||||
"dry_run": False,
|
||||
}
|
||||
|
||||
|
||||
def test_build_plan_rejects_non_prod_cp_without_explicit_override():
|
||||
try:
|
||||
prod.build_plan(
|
||||
{
|
||||
"GITHUB_SHA": "abcdef1234567890",
|
||||
"CP_URL": "https://staging-api.moleculesai.app",
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "PROD_ALLOW_NON_PROD_CP_URL=true" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected non-prod CP URL rejection")
|
||||
|
||||
|
||||
def test_build_plan_allows_non_prod_cp_only_with_override():
|
||||
plan = prod.build_plan(
|
||||
{
|
||||
"GITHUB_SHA": "abcdef1234567890",
|
||||
"CP_URL": "https://staging-api.moleculesai.app",
|
||||
"PROD_ALLOW_NON_PROD_CP_URL": "true",
|
||||
}
|
||||
)
|
||||
|
||||
assert plan["cp_url"] == "https://staging-api.moleculesai.app"
|
||||
|
||||
|
||||
def test_build_plan_disable_flag_short_circuits_before_credentials():
|
||||
plan = prod.build_plan(
|
||||
{
|
||||
"GITHUB_SHA": "abcdef1234567890",
|
||||
"PROD_AUTO_DEPLOY_DISABLED": "true",
|
||||
}
|
||||
)
|
||||
|
||||
assert plan["enabled"] is False
|
||||
assert plan["disabled_reason"] == "PROD_AUTO_DEPLOY_DISABLED=true"
|
||||
|
||||
|
||||
def test_latest_status_for_context_uses_first_matching_status():
|
||||
statuses = [
|
||||
{"context": "CI / all-required (push)", "status": "pending"},
|
||||
{"context": "CI / all-required (pull_request)", "status": "success"},
|
||||
{"context": "CI / all-required (push)", "status": "success"},
|
||||
]
|
||||
|
||||
latest = prod.latest_status_for_context(statuses, "CI / all-required (push)")
|
||||
|
||||
assert latest == {"context": "CI / all-required (push)", "status": "pending"}
|
||||
|
||||
|
||||
def test_ci_context_state_handles_missing_and_gitea_status_key():
|
||||
assert prod.ci_context_state([], "CI / all-required (push)") == "missing"
|
||||
assert (
|
||||
prod.ci_context_state(
|
||||
[{"context": "CI / all-required (push)", "status": "success"}],
|
||||
"CI / all-required (push)",
|
||||
)
|
||||
== "success"
|
||||
)
|
||||
assert (
|
||||
prod.ci_context_state(
|
||||
[{"context": "CI / all-required (push)", "state": "failure"}],
|
||||
"CI / all-required (push)",
|
||||
)
|
||||
== "failure"
|
||||
)
|
||||
|
||||
|
||||
def test_context_is_satisfied_accepts_only_success():
|
||||
assert prod.context_is_satisfied("success") is True
|
||||
for state in ("failure", "error", "cancelled", "canceled", "skipped", "pending", "missing"):
|
||||
assert prod.context_is_satisfied(state) is False
|
||||
|
||||
|
||||
def test_context_is_terminal_failure_rejects_cancelled_and_skipped():
|
||||
for state in ("failure", "error", "cancelled", "canceled", "skipped"):
|
||||
assert prod.context_is_terminal_failure(state) is True
|
||||
for state in ("pending", "missing", "success"):
|
||||
assert prod.context_is_terminal_failure(state) is False
|
||||
@@ -15,7 +15,6 @@
|
||||
# T11 — bash syntax check (bash -n passes)
|
||||
# T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded
|
||||
# T13 — missing required env GITEA_TOKEN → exits 1 with error
|
||||
# T14 — non-default-base PR exits 0 without requiring review
|
||||
#
|
||||
# Hostile-self-review (per feedback_assert_exact_not_substring):
|
||||
# this test MUST FAIL if the script is absent. Verified by running
|
||||
@@ -74,7 +73,7 @@ assert_file_mode() {
|
||||
return
|
||||
fi
|
||||
local got_mode
|
||||
got_mode=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path" 2>/dev/null || echo "000")
|
||||
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))
|
||||
@@ -195,9 +194,8 @@ for a in "$@"; do
|
||||
done
|
||||
exec /usr/bin/curl "${new_args[@]}"
|
||||
CURL_SHIM
|
||||
# Now substitute FIXPORT with the actual port number. Use perl rather than
|
||||
# sed -i so the test runs on both GNU sed and BSD/macOS sed.
|
||||
perl -0pi -e "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl"
|
||||
# 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
|
||||
@@ -212,7 +210,6 @@ run_review_check() {
|
||||
GITEA_HOST="fixture.local" \
|
||||
REPO="molecule-ai/molecule-core" \
|
||||
PR_NUMBER="999" \
|
||||
DEFAULT_BRANCH="main" \
|
||||
TEAM="qa" \
|
||||
TEAM_ID="20" \
|
||||
REVIEW_CHECK_DEBUG="0" \
|
||||
@@ -256,14 +253,6 @@ T4_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T4 exit code 1 (no candidates)" "1" "$T4_RC"
|
||||
assert_contains "T4 awaiting non-author APPROVE" "awaiting non-author APPROVE" "$T4_OUT"
|
||||
|
||||
# T14 — non-default-base PR should not make the default branch red.
|
||||
echo
|
||||
echo "== T14 non-default base PR =="
|
||||
T14_OUT=$(run_review_check "T14_non_default_base")
|
||||
T14_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T14 exit code 0 (non-default base no-op)" "0" "$T14_RC"
|
||||
assert_contains "T14 not applicable notice" "gate not applicable" "$T14_OUT"
|
||||
|
||||
# T5 — only author reviews → exit 1
|
||||
echo
|
||||
echo "== T5 only author reviews =="
|
||||
@@ -307,10 +296,10 @@ echo "== T10 CURL_AUTH_FILE =="
|
||||
# Verify the token-file logic directly: create a temp file with the
|
||||
# same mktemp pattern, write the header with printf, chmod 600, then assert.
|
||||
T10_TOKEN="secret-test-token-abc123"
|
||||
T10_AUTHFILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.test.XXXXXX")
|
||||
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 authfile mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600"
|
||||
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"
|
||||
|
||||
@@ -410,7 +410,6 @@ class TestRenderStatus(unittest.TestCase):
|
||||
self._state_with(all_slugs),
|
||||
{it["slug"]: False for it in self.items},
|
||||
)
|
||||
self.assertEqual(state, "failure")
|
||||
self.assertIn("body-unfilled", desc)
|
||||
|
||||
|
||||
@@ -520,31 +519,6 @@ class TestEndToEndAckFlow(unittest.TestCase):
|
||||
self.assertEqual(result_state, "success")
|
||||
self.assertIn("7/7", desc)
|
||||
|
||||
def test_all_acks_still_fail_when_body_section_unfilled(self):
|
||||
items = _items_by_slug()
|
||||
aliases = _numeric_aliases()
|
||||
comments = [
|
||||
_comment("qa-bot", "/sop-ack comprehensive-testing"),
|
||||
_comment("eng-bot", "/sop-ack local-postgres-e2e"),
|
||||
_comment("eng-bot", "/sop-ack staging-smoke"),
|
||||
_comment("mgr-bot", "/sop-ack root-cause"),
|
||||
_comment("eng-bot", "/sop-ack five-axis-review"),
|
||||
_comment("mgr-bot", "/sop-ack no-backwards-compat"),
|
||||
_comment("eng-bot", "/sop-ack memory-consulted"),
|
||||
]
|
||||
|
||||
def probe(slug, users):
|
||||
return list(users)
|
||||
|
||||
state = sop.compute_ack_state(comments, "alice-author", items, aliases, probe)
|
||||
body = {it["slug"]: True for it in items.values()}
|
||||
body["root-cause"] = False
|
||||
items_list = list(items.values())
|
||||
result_state, desc = sop.render_status(items_list, state, body)
|
||||
self.assertEqual(result_state, "failure")
|
||||
self.assertIn("7/7", desc)
|
||||
self.assertIn("body-unfilled: root-cause", desc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import urllib.error
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "status-reaper.py"
|
||||
|
||||
|
||||
def load_reaper():
|
||||
spec = importlib.util.spec_from_file_location("status_reaper", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(mod)
|
||||
mod.API = "https://git.example.test/api/v1"
|
||||
mod.GITEA_TOKEN = "test-token"
|
||||
mod.API_TIMEOUT_SEC = 1
|
||||
mod.API_RETRIES = 3
|
||||
mod.API_RETRY_SLEEP_SEC = 0
|
||||
return mod
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
|
||||
def test_api_retries_transient_timeout(monkeypatch):
|
||||
mod = load_reaper()
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise TimeoutError("simulated slow Gitea API")
|
||||
return FakeResponse({"ok": True})
|
||||
|
||||
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
status, body = mod.api("GET", "/repos/o/r/commits")
|
||||
|
||||
assert status == 200
|
||||
assert body == {"ok": True}
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_api_raises_after_retry_budget(monkeypatch):
|
||||
mod = load_reaper()
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
raise urllib.error.URLError("connection reset")
|
||||
|
||||
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
try:
|
||||
mod.api("GET", "/repos/o/r/commits")
|
||||
except mod.ApiError as exc:
|
||||
assert "failed after 3 attempts" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ApiError")
|
||||
|
||||
|
||||
def test_reap_compensates_failed_pr_context_when_push_equivalent_passed(monkeypatch):
|
||||
mod = load_reaper()
|
||||
posted = []
|
||||
|
||||
def fake_post(sha, context, target_url, *, description="", dry_run=False):
|
||||
posted.append((sha, context, target_url, description, dry_run))
|
||||
|
||||
monkeypatch.setattr(mod, "post_compensating_status", fake_post)
|
||||
|
||||
counters = mod.reap(
|
||||
{"CI": True, "Handlers Postgres Integration": True},
|
||||
{
|
||||
"statuses": [
|
||||
{
|
||||
"context": "CI / Platform (Go) (pull_request)",
|
||||
"status": "failure",
|
||||
"target_url": "https://git.example.test/ci-pr",
|
||||
},
|
||||
{
|
||||
"context": "CI / Platform (Go) (push)",
|
||||
"status": "success",
|
||||
},
|
||||
{
|
||||
"context": (
|
||||
"Handlers Postgres Integration / "
|
||||
"Handlers Postgres Integration (pull_request)"
|
||||
),
|
||||
"status": "failure",
|
||||
"target_url": "https://git.example.test/handlers-pr",
|
||||
},
|
||||
{
|
||||
"context": (
|
||||
"Handlers Postgres Integration / "
|
||||
"Handlers Postgres Integration (push)"
|
||||
),
|
||||
"status": "success",
|
||||
},
|
||||
],
|
||||
},
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
)
|
||||
|
||||
assert counters["compensated_pr_shadowed_by_push_success"] == 2
|
||||
assert posted == [
|
||||
(
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
"CI / Platform (Go) (pull_request)",
|
||||
"https://git.example.test/ci-pr",
|
||||
mod.PR_SHADOW_COMPENSATION_DESCRIPTION,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
"Handlers Postgres Integration / Handlers Postgres Integration (pull_request)",
|
||||
"https://git.example.test/handlers-pr",
|
||||
mod.PR_SHADOW_COMPENSATION_DESCRIPTION,
|
||||
False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_reap_preserves_failed_pr_context_without_push_success(monkeypatch):
|
||||
mod = load_reaper()
|
||||
posted = []
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"post_compensating_status",
|
||||
lambda sha, context, target_url, *, description="", dry_run=False: posted.append(
|
||||
context
|
||||
),
|
||||
)
|
||||
|
||||
counters = mod.reap(
|
||||
{"CI": True},
|
||||
{
|
||||
"statuses": [
|
||||
{
|
||||
"context": "CI / Platform (Go) (pull_request)",
|
||||
"status": "failure",
|
||||
},
|
||||
{
|
||||
"context": "CI / Platform (Go) (push)",
|
||||
"status": "failure",
|
||||
},
|
||||
{
|
||||
"context": "CI / Shellcheck (pull_request)",
|
||||
"status": "failure",
|
||||
},
|
||||
],
|
||||
},
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
)
|
||||
|
||||
assert counters["preserved_pr_without_push_success"] == 2
|
||||
assert posted == []
|
||||
@@ -1,58 +1,89 @@
|
||||
# audit-force-merge — emit `incident.force_merge` to runner stdout when
|
||||
# a PR is merged with required-status-checks not green. Vector picks
|
||||
# audit-force-merge — emit `incident.force_merge` to the runner log when
|
||||
# a PR is merged with required-status checks NOT all green. Vector picks
|
||||
# the JSON line off docker_logs and ships to Loki on
|
||||
# molecule-canonical-obs (per `reference_obs_stack_phase1`); query as:
|
||||
#
|
||||
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
|
||||
#
|
||||
# Closes the §SOP-6 audit gap (the doc says force-merges write to
|
||||
# `structure_events`, but that table lives in the platform DB, not
|
||||
# Gitea-side; Loki is the practical equivalent for Gitea Actions
|
||||
# events). When the credential / observability stack converges later,
|
||||
# this can sync into structure_events from Loki via a backfill job —
|
||||
# the structured JSON shape is forward-compatible.
|
||||
# Companion to `audit-force-merge.sh` (script-extract pattern, same as
|
||||
# sop-tier-check). The audit observes BOTH UI-merged and REST-merged PRs
|
||||
# uniformly per `feedback_gh_cli_merge_lies_use_rest`.
|
||||
#
|
||||
# Logic in `.gitea/scripts/audit-force-merge.sh` per the same script-
|
||||
# extract pattern as sop-tier-check.
|
||||
# Closes the §SOP-6 audit gap for the molecule-core repo. RFC:
|
||||
# internal#219 §6. Mirrors the same-named workflow in
|
||||
# molecule-controlplane; design rationale lives in the RFC, not here,
|
||||
# to keep the workflow file scannable.
|
||||
|
||||
name: audit-force-merge
|
||||
|
||||
# pull_request_target loads from the base branch — same security model
|
||||
# as sop-tier-check. Without this, an attacker could rewrite the
|
||||
# workflow on a PR and skip the audit emission for their own
|
||||
# force-merge. See `.gitea/workflows/sop-tier-check.yml` for the full
|
||||
# rationale.
|
||||
# as sop-tier-check. Without this, a PR author could rewrite the
|
||||
# workflow on their own PR and skip the audit emission for their own
|
||||
# force-merge. The base-branch checkout below ALSO uses
|
||||
# `base.sha`, not `base.ref`, so a fast-moving base can't slip a
|
||||
# different audit script in under us.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
# `pull-requests: read` + `contents: read` covers everything the script
|
||||
# needs (fetch PR + commit statuses). `issues:` deliberately omitted —
|
||||
# audit fires-and-forgets to stdout, never opens issues.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
# Skip when PR is closed without merge — saves a runner.
|
||||
if: github.event.pull_request.merged == true
|
||||
steps:
|
||||
- name: Check out base branch (for the script)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# base.sha pinning, NOT base.ref — see header rationale.
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
- name: Detect force-merge + emit audit event
|
||||
env:
|
||||
# Same org-level secret the sop-tier-check workflow uses.
|
||||
# Same org-level secret the sop-tier-check workflow uses;
|
||||
# falls back to the auto-injected GITHUB_TOKEN if the
|
||||
# org-level SOP_TIER_CHECK_TOKEN isn't set on a transitional
|
||||
# repo.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
# Required-status-check contexts to evaluate at merge time.
|
||||
# Newline-separated. Mirror this against branch protection
|
||||
# (settings → branches → protected branch → required checks).
|
||||
# Newline-separated. MUST mirror branch protection's
|
||||
# status_check_contexts for protected branches
|
||||
# (currently `main`; `staging` protection forthcoming per
|
||||
# RFC internal#219 Phase 4).
|
||||
#
|
||||
# Initialized 2026-05-11 from the current molecule-core `main`
|
||||
# branch protection:
|
||||
#
|
||||
# GET /api/v1/repos/molecule-ai/molecule-core/
|
||||
# branch_protections/main
|
||||
# → status_check_contexts = [
|
||||
# "Secret scan / Scan diff for credential-shaped strings (pull_request)",
|
||||
# "sop-tier-check / tier-check (pull_request)"
|
||||
# ]
|
||||
#
|
||||
# Declared here rather than fetched from /branch_protections
|
||||
# because that endpoint requires admin write — sop-tier-bot is
|
||||
# read-only by design (least-privilege).
|
||||
# because that endpoint requires admin write — sop-tier-bot
|
||||
# is read-only by design (least-privilege per
|
||||
# `feedback_least_privilege_via_workflow_env` / internal#257).
|
||||
# Drift between this env and the real protection list is
|
||||
# auto-detected by `ci-required-drift.yml` (RFC §4 + §6),
|
||||
# which opens a `[ci-drift]` issue within one hour.
|
||||
#
|
||||
# When the protection set changes (e.g. Phase 4 adds the
|
||||
# `ci / all-required (pull_request)` sentinel), update BOTH
|
||||
# branch protection AND this env in the SAME PR; drift-detect
|
||||
# will otherwise file an issue for you.
|
||||
REQUIRED_CHECKS: |
|
||||
Secret scan / Scan diff for credential-shaped strings (pull_request)
|
||||
sop-tier-check / tier-check (pull_request)
|
||||
CI / all-required (pull_request)
|
||||
sop-checklist / all-items-acked (pull_request)
|
||||
run: bash .gitea/scripts/audit-force-merge.sh
|
||||
|
||||
@@ -43,7 +43,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: drift visibility gate; CI / all-required remains the required aggregate.
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
name: MCP Stdio Transport Regression
|
||||
|
||||
# Regression test for molecule-ai-workspace-runtime#61:
|
||||
# asyncio.connect_read_pipe / connect_write_pipe fail with
|
||||
# ValueError: "Pipe transport is only for pipes, sockets and character devices"
|
||||
# when stdout is a regular file (openclaw capture, CI tee, debugging).
|
||||
#
|
||||
# This workflow reproduces the exact failure mode and verifies the
|
||||
# fallback to direct buffer I/O works. It runs on every PR that
|
||||
# touches the MCP server or this workflow, plus nightly cron.
|
||||
#
|
||||
# Why a separate workflow (not folded into ci.yml python-lint):
|
||||
# - The test needs to spawn the MCP server with stdout redirected
|
||||
# to a regular file (not a TTY/pipe), which conflicts with
|
||||
# pytest's own capture mechanism.
|
||||
# - It exercises the actual process spawn path (python a2a_mcp_server.py)
|
||||
# not just unit-test mocks — closer to the real openclaw integration.
|
||||
# - A dedicated workflow surfaces stdio-specific regressions without
|
||||
# coupling to the broader Python test suite's coverage gate.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/mcp_cli.py'
|
||||
- 'workspace/tests/test_a2a_mcp_server.py'
|
||||
- '.gitea/workflows/ci-mcp-stdio-transport.yml'
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/mcp_cli.py'
|
||||
- 'workspace/tests/test_a2a_mcp_server.py'
|
||||
- '.gitea/workflows/ci-mcp-stdio-transport.yml'
|
||||
schedule:
|
||||
# Nightly at 04:00 UTC — catches drift from dependency updates
|
||||
# (e.g. asyncio behavior changes in new Python patch releases).
|
||||
- cron: '0 4 * * *'
|
||||
|
||||
concurrency:
|
||||
group: mcp-stdio-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: regression canary for runtime#61; not a merge gate — informational only until promoted to required.
|
||||
# mc#774: continue-on-error mask — new workflow, flip to false once it's green on ≥3 consecutive main runs.
|
||||
mcp-stdio-regular-file:
|
||||
name: MCP stdio with regular-file stdout
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # mc#774
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
WORKSPACE_ID: "00000000-0000-0000-0000-000000000001"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
cache-dependency-path: workspace/requirements.txt
|
||||
- run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
|
||||
|
||||
- name: Reproduce runtime#61 — stdout as regular file
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== Reproducing molecule-ai-workspace-runtime#61 ==="
|
||||
echo ""
|
||||
echo "Before the fix, this command would fail with:"
|
||||
echo ' ValueError: Pipe transport is only for pipes, sockets and character devices'
|
||||
echo ""
|
||||
|
||||
# Spawn the MCP server with stdout redirected to a regular file.
|
||||
# This is exactly what openclaw does when capturing MCP output.
|
||||
OUTPUT=$(mktemp)
|
||||
trap 'rm -f "$OUTPUT"' EXIT
|
||||
|
||||
# Send initialize request, then tools/list, then exit
|
||||
{
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
|
||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
||||
} | python a2a_mcp_server.py > "$OUTPUT" 2>&1 || {
|
||||
RC=$?
|
||||
echo "FAIL: MCP server exited with code $RC"
|
||||
echo "--- stdout+stderr ---"
|
||||
cat "$OUTPUT"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "PASS: MCP server handled regular-file stdout without crashing"
|
||||
echo ""
|
||||
echo "--- Output (first 20 lines) ---"
|
||||
head -20 "$OUTPUT"
|
||||
echo ""
|
||||
|
||||
# Verify we got valid JSON-RPC responses
|
||||
if grep -q '"result"' "$OUTPUT"; then
|
||||
echo "PASS: JSON-RPC responses found in output"
|
||||
else
|
||||
echo "FAIL: No JSON-RPC responses in output"
|
||||
cat "$OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Reproduce runtime#61 — stdin from regular file
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== stdin as regular file (CI tee / capture pattern) ==="
|
||||
|
||||
INPUT=$(mktemp)
|
||||
OUTPUT=$(mktemp)
|
||||
trap 'rm -f "$INPUT" "$OUTPUT"' EXIT
|
||||
|
||||
cat > "$INPUT" <<'EOF'
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
|
||||
EOF
|
||||
|
||||
python a2a_mcp_server.py < "$INPUT" > "$OUTPUT" 2>&1 || {
|
||||
RC=$?
|
||||
echo "FAIL: MCP server exited with code $RC"
|
||||
cat "$OUTPUT"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "PASS: MCP server handled regular-file stdin without crashing"
|
||||
|
||||
if grep -q '"result"' "$OUTPUT"; then
|
||||
echo "PASS: JSON-RPC responses found in output"
|
||||
else
|
||||
echo "FAIL: No JSON-RPC responses in output"
|
||||
cat "$OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify warning is emitted for non-pipe stdio
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== Verify diagnostic warning ==="
|
||||
|
||||
OUTPUT=$(mktemp)
|
||||
trap 'rm -f "$OUTPUT"' EXIT
|
||||
|
||||
{
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
|
||||
} | python a2a_mcp_server.py > "$OUTPUT" 2>&1
|
||||
|
||||
# The warning should mention "not a pipe" for operator visibility
|
||||
if grep -qi "not a pipe" "$OUTPUT"; then
|
||||
echo "PASS: Diagnostic warning emitted for non-pipe stdio"
|
||||
else
|
||||
echo "NOTE: No warning in output (may be suppressed by log level)"
|
||||
fi
|
||||
|
||||
- name: Run unit tests for stdio transport
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== Running stdio transport unit tests ==="
|
||||
python -m pytest tests/test_a2a_mcp_server.py::TestStdioPipeAssertion -v --no-cov
|
||||
@@ -170,12 +170,9 @@ jobs:
|
||||
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go vet ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
run: golangci-lint run --timeout 3m ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
run: |
|
||||
|
||||
@@ -168,7 +168,6 @@ jobs:
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run staging canvas E2E
|
||||
|
||||
@@ -44,7 +44,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR advisory bot; merge blocking is enforced by CI status and branch protection.
|
||||
gate-check:
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
@@ -64,7 +63,6 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target' || github.event.inputs.pr_number != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
|
||||
POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }}
|
||||
run: |
|
||||
@@ -79,7 +77,6 @@ jobs:
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
name: gitea-merge-queue
|
||||
|
||||
# External serialized merge queue for Gitea 1.22.6.
|
||||
#
|
||||
# Gitea's `pull_auto_merge` table is not a real merge queue: it does not
|
||||
# serialize green PRs against a freshly-tested latest main. This workflow runs
|
||||
# the user-space queue bot, one PR per tick, using the non-bypass merge actor.
|
||||
#
|
||||
# Queue contract:
|
||||
# - add label `merge-queue` to an open same-repo PR
|
||||
# - bot updates stale PR heads with current main, then waits for CI
|
||||
# - bot merges only when current main is green and required PR contexts pass
|
||||
# - add `merge-queue-hold` to pause a queued PR without removing it
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: gitea-merge-queue-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
queue:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out queue script from main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Process one queued PR
|
||||
env:
|
||||
# AUTO_SYNC_TOKEN is the devops-engineer persona PAT. It is the
|
||||
# non-bypass merge actor allowed by branch protection.
|
||||
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
QUEUE_LABEL: merge-queue
|
||||
HOLD_LABEL: merge-queue-hold
|
||||
UPDATE_STYLE: merge
|
||||
REQUIRED_CONTEXTS: >-
|
||||
CI / all-required (pull_request),
|
||||
sop-checklist / all-items-acked (pull_request)
|
||||
run: python3 .gitea/scripts/gitea-merge-queue.py
|
||||
@@ -60,7 +60,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: change detector only; downstream Harness Replays is the meaningful gate.
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
@@ -133,14 +132,7 @@ jobs:
|
||||
RESP=$(curl -sS --fail --max-time 30 \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/json" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD") || {
|
||||
# If Gitea's Compare API is slow/unavailable, choose the conservative
|
||||
# behavior: run the harness instead of failing the detector and polluting
|
||||
# main with a red non-gate context.
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "debug=compare-api-unavailable base=$BASE head=$HEAD" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
}
|
||||
"$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)
|
||||
|
||||
echo "debug=diff-base=$BASE diff-files=$DIFF_FILES" >> "$GITHUB_OUTPUT"
|
||||
@@ -158,7 +150,6 @@ jobs:
|
||||
# matches e2e-api.yml — see that workflow's comment for why a
|
||||
# job-level `if: false` would block branch protection via the
|
||||
# SKIPPED-in-set bug.
|
||||
# bp-exempt: path-filtered replay suite; CI / all-required is the branch-protection aggregate.
|
||||
harness-replays:
|
||||
needs: detect-changes
|
||||
name: Harness Replays
|
||||
|
||||
@@ -89,7 +89,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: meta-lint for masked jobs; tracked separately until masks are burned down.
|
||||
lint:
|
||||
name: lint-continue-on-error-tracking
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -84,7 +84,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: meta-lint advisory during mask burn-down; CI / all-required gates merges.
|
||||
scan:
|
||||
name: lint-mask-pr-atomicity
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -69,7 +69,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: meta-lint advisory; CI / all-required is the required aggregate.
|
||||
lint:
|
||||
name: lint-required-no-paths
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -46,7 +46,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: post-merge image publication side effect; CI / all-required gates source changes.
|
||||
build-and-push:
|
||||
name: Build & push canvas image
|
||||
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
|
||||
|
||||
@@ -53,7 +53,6 @@ jobs:
|
||||
# Operational failures (PyPI unreachable, missing DISPATCH_TOKEN) are
|
||||
# surfaced via continue-on-error: true rather than blocking the merge.
|
||||
# The actual bump work happens on the main/staging push after merge.
|
||||
# bp-exempt: advisory validation for runtime publication; not a branch-protection gate.
|
||||
pr-validate:
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
@@ -80,7 +79,6 @@ jobs:
|
||||
# Actual bump-and-tag: runs on main/staging pushes, posts real success/failure.
|
||||
# No continue-on-error — operational failures here trip the main-red
|
||||
# watchdog, which is the desired signal for infrastructure degradation.
|
||||
# bp-exempt: post-merge tag publication side effect; CI / all-required gates source changes.
|
||||
bump-and-tag:
|
||||
runs-on: ubuntu-latest
|
||||
# Only fire on push events (main/staging after PR merge). Pull_request
|
||||
|
||||
@@ -18,13 +18,6 @@ name: publish-workspace-server-image
|
||||
# :staging-<sha> — per-commit digest, stable for canary verify
|
||||
# :staging-latest — tracks most recent build on this branch
|
||||
#
|
||||
# Production auto-deploy:
|
||||
# After both platform and tenant images are pushed, deploy-production waits
|
||||
# for strict required push contexts on the same SHA to go green, then
|
||||
# calls the production CP redeploy-fleet endpoint with target_tag=
|
||||
# staging-<sha>. Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true
|
||||
# to stop production rollout while keeping image publishing enabled.
|
||||
#
|
||||
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
|
||||
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
|
||||
#
|
||||
@@ -45,10 +38,15 @@ on:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# No `concurrency:` block here. Gitea 1.22.6 can cancel queued runs despite
|
||||
# `cancel-in-progress: false`; that is not acceptable for a workflow with a
|
||||
# production deploy job. Per-SHA image tags are immutable, and staging-latest is
|
||||
# best-effort last-writer-wins metadata.
|
||||
# Serialize per-branch so two rapid main pushes don't race the same
|
||||
# :staging-latest tag retag. Allow parallel runs as they produce
|
||||
# different :staging-<sha> tags and last-write-wins on :staging-latest.
|
||||
#
|
||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||
# build queues. This avoids a partially-pushed image.
|
||||
concurrency:
|
||||
group: publish-workspace-server-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -65,22 +63,20 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Health check: verify Docker daemon is accessible before attempting any
|
||||
# build steps. This fails loudly at step 1 when the runner's docker.sock
|
||||
# is inaccessible rather than silently continuing where `docker build`
|
||||
# fails deep in the process with a cryptic ECR auth error.
|
||||
- name: Verify Docker daemon access
|
||||
- name: Diagnose Docker daemon access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon health check"
|
||||
echo "::group::Docker daemon diagnosis"
|
||||
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
|
||||
}
|
||||
echo "Docker daemon OK"
|
||||
echo "--- Socket info ---"
|
||||
ls -la /var/run/docker.sock 2>/dev/null || echo "/var/run/docker.sock: not found"
|
||||
stat /var/run/docker.sock 2>/dev/null || true
|
||||
echo "--- User info ---"
|
||||
id
|
||||
echo "--- docker version ---"
|
||||
docker version 2>&1 || true
|
||||
echo "--- docker info (full) ---"
|
||||
docker info 2>&1 || echo "docker info failed: exit $?"
|
||||
echo "::endgroup::"
|
||||
|
||||
# Pre-clone manifest deps before docker build.
|
||||
@@ -179,173 +175,3 @@ jobs:
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
|
||||
--push .
|
||||
|
||||
# bp-exempt: production deploy side-effect; merge is gated by CI / all-required and this job waits for push CI before acting.
|
||||
deploy-production:
|
||||
name: Production auto-deploy
|
||||
needs: build-and-push
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
GITEA_TOKEN: ${{ secrets.PROD_AUTO_DEPLOY_CONTROL_TOKEN || secrets.AUTO_SYNC_TOKEN }}
|
||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||
PROD_AUTO_DEPLOY_CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }}
|
||||
PROD_AUTO_DEPLOY_SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }}
|
||||
PROD_AUTO_DEPLOY_BATCH_SIZE: ${{ vars.PROD_AUTO_DEPLOY_BATCH_SIZE || '3' }}
|
||||
PROD_AUTO_DEPLOY_DRY_RUN: ${{ vars.PROD_AUTO_DEPLOY_DRY_RUN || '' }}
|
||||
PROD_ALLOW_NON_PROD_CP_URL: ${{ vars.PROD_ALLOW_NON_PROD_CP_URL || '' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Build deploy plan
|
||||
id: plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .gitea/scripts/prod-auto-deploy.py plan > "$RUNNER_TEMP/prod-auto-deploy-plan.json"
|
||||
jq . "$RUNNER_TEMP/prod-auto-deploy-plan.json"
|
||||
enabled="$(jq -r '.enabled' "$RUNNER_TEMP/prod-auto-deploy-plan.json")"
|
||||
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
|
||||
if [ "$enabled" != "true" ]; then
|
||||
reason="$(jq -r '.disabled_reason' "$RUNNER_TEMP/prod-auto-deploy-plan.json")"
|
||||
echo "::notice::Production auto-deploy disabled: $reason"
|
||||
{
|
||||
echo "## Production auto-deploy skipped"
|
||||
echo ""
|
||||
echo "Reason: \`$reason\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
|
||||
echo "::error::CP_ADMIN_API_TOKEN secret is required for production auto-deploy."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is required so production deploy can wait for green CI."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Self-test production deploy helper
|
||||
if: ${{ steps.plan.outputs.enabled == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m pip install --quiet 'pytest==9.0.2' 'PyYAML==6.0.2'
|
||||
python3 -m pytest .gitea/scripts/tests/test_prod_auto_deploy.py -q
|
||||
python3 .gitea/scripts/lint-workflow-yaml.py --workflow-dir .gitea/workflows
|
||||
|
||||
- name: Wait for green main CI on this SHA
|
||||
if: ${{ steps.plan.outputs.enabled == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .gitea/scripts/prod-auto-deploy.py wait-ci
|
||||
|
||||
- name: Call production CP redeploy-fleet
|
||||
if: ${{ steps.plan.outputs.enabled == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .gitea/scripts/prod-auto-deploy.py assert-enabled
|
||||
PLAN="$RUNNER_TEMP/prod-auto-deploy-plan.json"
|
||||
TARGET_TAG="$(jq -r '.target_tag' "$PLAN")"
|
||||
BODY="$(jq -c '.body' "$PLAN")"
|
||||
|
||||
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " target_tag: $TARGET_TAG"
|
||||
echo " body: $BODY"
|
||||
|
||||
HTTP_RESPONSE="$RUNNER_TEMP/prod-redeploy-response.json"
|
||||
HTTP_CODE_FILE="$RUNNER_TEMP/prod-redeploy-http-code.txt"
|
||||
set +e
|
||||
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
|
||||
-m 1200 \
|
||||
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
|
||||
-d "$BODY" > "$HTTP_CODE_FILE"
|
||||
set -e
|
||||
|
||||
HTTP_CODE="$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")"
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
echo "HTTP $HTTP_CODE"
|
||||
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true
|
||||
|
||||
{
|
||||
echo "## Production auto-deploy"
|
||||
echo ""
|
||||
echo "**Commit:** \`${GITHUB_SHA:0:7}\`"
|
||||
echo "**Target tag:** \`$TARGET_TAG\`"
|
||||
echo "**HTTP:** $HTTP_CODE"
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
echo "| Slug | Phase | SSM Status | Exit | Healthz | Error present |"
|
||||
echo "|------|-------|------------|------|---------|---------------|"
|
||||
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \((.error // "") != "") |"' "$HTTP_RESPONSE" || true
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
OK="$(jq -r '.ok' "$HTTP_RESPONSE")"
|
||||
if [ "$OK" != "true" ]; then
|
||||
echo "::error::redeploy-fleet reported ok=false; production rollout halted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify reachable tenants report this SHA
|
||||
if: ${{ steps.plan.outputs.enabled == 'true' }}
|
||||
env:
|
||||
TENANT_DOMAIN: moleculesai.app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESP="$RUNNER_TEMP/prod-redeploy-response.json"
|
||||
mapfile -t SLUGS < <(jq -r '.results[]? | .slug' "$RESP")
|
||||
if [ ${#SLUGS[@]} -eq 0 ]; then
|
||||
echo "::error::No tenants returned from redeploy-fleet; refusing to mark production deploy verified."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STALE_COUNT=0
|
||||
UNREACHABLE_COUNT=0
|
||||
UNHEALTHY_COUNT=0
|
||||
for slug in "${SLUGS[@]}"; do
|
||||
healthz_ok="$(jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .healthz_ok' "$RESP" | tail -1)"
|
||||
if [ "$healthz_ok" != "true" ]; then
|
||||
echo "::error::$slug did not report healthz_ok=true in redeploy-fleet response."
|
||||
UNHEALTHY_COUNT=$((UNHEALTHY_COUNT + 1))
|
||||
continue
|
||||
fi
|
||||
url="https://${slug}.${TENANT_DOMAIN}/buildinfo"
|
||||
body="$(curl -sS --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$url" || true)"
|
||||
actual="$(echo "$body" | jq -r '.git_sha // ""' 2>/dev/null || echo "")"
|
||||
if [ -z "$actual" ]; then
|
||||
echo "::error::$slug did not return /buildinfo after deploy."
|
||||
UNREACHABLE_COUNT=$((UNREACHABLE_COUNT + 1))
|
||||
continue
|
||||
fi
|
||||
if [ "$actual" != "$GITHUB_SHA" ]; then
|
||||
echo "::error::$slug is stale: actual=${actual:0:7}, expected=${GITHUB_SHA:0:7}"
|
||||
STALE_COUNT=$((STALE_COUNT + 1))
|
||||
else
|
||||
echo "$slug: ${actual:0:7}"
|
||||
fi
|
||||
done
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "### Buildinfo verification"
|
||||
echo ""
|
||||
echo "Expected SHA: \`${GITHUB_SHA:0:7}\`"
|
||||
echo "Verified tenants: ${#SLUGS[@]}"
|
||||
echo "Stale tenants: $STALE_COUNT"
|
||||
echo "Unhealthy tenants: $UNHEALTHY_COUNT"
|
||||
echo "Unreachable tenants: $UNREACHABLE_COUNT"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$STALE_COUNT" -gt 0 ] || [ "$UNHEALTHY_COUNT" -gt 0 ] || [ "$UNREACHABLE_COUNT" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -93,7 +93,6 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required.
|
||||
approved:
|
||||
# Gate the job:
|
||||
# - On pull_request_target events: always run.
|
||||
@@ -158,7 +157,6 @@ jobs:
|
||||
# pull_request_target → github.event.pull_request.number
|
||||
# issue_comment → github.event.issue.number
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: qa
|
||||
TEAM_ID: '20'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
|
||||
@@ -65,13 +65,13 @@ permissions:
|
||||
# the explicit block makes the invariant defensible. Mirrors the
|
||||
# concurrency block on redeploy-tenants-on-staging.yml for shape parity.
|
||||
#
|
||||
# NOTE: cancel-in-progress: false removed (Rule 7 fix). Gitea 1.22.6
|
||||
# cancels queued runs regardless of this setting, so it provides no
|
||||
# actual protection. Each redeploy-fleet call is idempotent (canary-first
|
||||
# + batched + health-gated) so a cancelled predecessor is recovered
|
||||
# automatically by the next run.
|
||||
# cancel-in-progress: false → aborting a half-rolled-out fleet would
|
||||
# leave tenants stuck on whatever image they happened to be on when
|
||||
# cancelled. Better to finish the in-flight rollout before starting
|
||||
# the next one.
|
||||
concurrency:
|
||||
group: redeploy-tenants-on-main
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
@@ -89,18 +89,7 @@ jobs:
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
# Rule 9 fix: operational kill switch for auto-triggered deployments.
|
||||
# Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true to prevent
|
||||
# this workflow from redeploying. Manual workflow_dispatch bypasses this.
|
||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||
steps:
|
||||
- name: Kill-switch guard
|
||||
# Rule 9 fix: exit fast if kill switch is set. No redeploy happens.
|
||||
if: env.PROD_AUTO_DEPLOY_DISABLED == 'true'
|
||||
run: |
|
||||
echo "::notice::Production auto-deploy disabled (PROD_AUTO_DEPLOY_DISABLED=true). Skipping redeploy."
|
||||
echo "To re-enable: unset the repo variable or set it to false."
|
||||
- name: Note on ECR propagation
|
||||
# ECR image manifests are consistent immediately after push — no
|
||||
# CDN cache to wait for. The old GHCR-based workflow had a 30s
|
||||
@@ -200,9 +189,7 @@ jobs:
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
# Rule 8 fix: redact raw CP response from CI logs. Print only
|
||||
# safe fields: ok boolean, result count, error presence (no content).
|
||||
jq '{ok, result_count: (.results | length), has_errors: (.results | any(.error != null))}' "$HTTP_RESPONSE" || echo "(jq parse failed)"
|
||||
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
|
||||
|
||||
# Pretty-print per-tenant results in the job summary so
|
||||
# ops can see which tenants were redeployed without drilling
|
||||
@@ -218,11 +205,9 @@ jobs:
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
echo '| Slug | Phase | SSM Status | Exit | Healthz | Errors |'
|
||||
echo '| Slug | Phase | SSM Status | Exit | Healthz | Error |'
|
||||
echo '|------|-------|------------|------|---------|-------|'
|
||||
# Rule 8 fix: .error field redacted from CI logs/summary. Print only
|
||||
# presence boolean so ops know whether to look deeper.
|
||||
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error != null) |"' "$HTTP_RESPONSE" || true
|
||||
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
|
||||
@@ -73,7 +73,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes.
|
||||
redeploy:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
|
||||
@@ -41,7 +41,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: review tooling regression suite; CI / all-required is the required aggregate.
|
||||
test:
|
||||
name: review-check.sh regression tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -20,7 +20,6 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
|
||||
approved:
|
||||
# See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational
|
||||
# log only, NOT a gate) / A4 / A5 design rationale.
|
||||
@@ -66,7 +65,6 @@ jobs:
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: security
|
||||
TEAM_ID: '21'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
|
||||
@@ -69,7 +69,7 @@ name: sop-checklist-gate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
issue_comment:
|
||||
types: [created, edited, deleted]
|
||||
|
||||
|
||||
@@ -28,16 +28,15 @@
|
||||
#
|
||||
# Environment variables:
|
||||
# SOP_DEBUG=1 — per-API-call diagnostic lines. Default: off.
|
||||
# SOP_LEGACY_CHECK=1 — revert to OR-gate for this run. Intended for
|
||||
# emergency use only; burn-in window closed
|
||||
# 2026-05-17 (internal#189 Phase 1).
|
||||
# SOP_LEGACY_CHECK=1 — revert to OR-gate for this run. Grace window
|
||||
# for PRs in-flight when AND-composition deployed.
|
||||
# Burn-in: remove after 2026-05-17 (7-day window).
|
||||
#
|
||||
# BURN-IN CLOSED 2026-05-17 (internal#189 Phase 1): The 7-day burn-in
|
||||
# window closed. continue-on-error: true has been removed from the
|
||||
# tier-check job; AND-composition is now fully enforced. If you need
|
||||
# to temporarily re-introduce a mask, file a tracker and follow the
|
||||
# mc#774 protocol (Tier 2e lint requires a current tracker within
|
||||
# 2 lines of any continue-on-error: true).
|
||||
# BURN-IN NOTE (internal#189 Phase 1): continue-on-error: true is set on
|
||||
# the tier-check job below. This prevents AND-composition from blocking
|
||||
# PRs during the 7-day burn-in. After 2026-05-17:
|
||||
# 1. Remove `continue-on-error: true` from this job block.
|
||||
# 2. Update this BURN-IN NOTE comment to mark the window closed.
|
||||
|
||||
name: sop-tier-check
|
||||
|
||||
@@ -64,6 +63,10 @@ on:
|
||||
jobs:
|
||||
tier-check:
|
||||
runs-on: ubuntu-latest
|
||||
# BURN-IN: continue-on-error prevents AND-composition from blocking
|
||||
# PRs during the 7-day window. Remove after 2026-05-17 (mc#774).
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
@@ -82,7 +82,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: post-merge staging verification side effect; CI / all-required gates merges.
|
||||
staging-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
@@ -191,7 +190,6 @@ jobs:
|
||||
echo "assertions in the staging-smoke step log above."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# bp-exempt: post-merge image promotion side effect; staging-smoke controls promotion.
|
||||
promote-to-latest:
|
||||
# On green, calls the CP redeploy-fleet endpoint with target_tag=
|
||||
# staging-<sha> to promote the verified ECR image. This is the same
|
||||
|
||||
@@ -84,7 +84,7 @@ permissions:
|
||||
jobs:
|
||||
reap:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Check out repo at default-branch HEAD
|
||||
# BASE checkout per `feedback_pull_request_target_workflow_from_base`.
|
||||
@@ -118,7 +118,4 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
STATUS_REAPER_API_RETRIES: "4"
|
||||
STATUS_REAPER_API_TIMEOUT_SEC: "20"
|
||||
STATUS_REAPER_API_RETRY_SLEEP_SEC: "2"
|
||||
run: python3 .gitea/scripts/status-reaper.py
|
||||
|
||||
@@ -40,15 +40,11 @@ name: Sweep stale AWS Secrets Manager secrets
|
||||
# the mostly-orphan tunnels) refuses to nuke past the threshold.
|
||||
|
||||
on:
|
||||
# Disabled as an hourly schedule until the dedicated
|
||||
# AWS_SECRETS_JANITOR_* key exists in the key-management SSOT and is
|
||||
# mirrored into Gitea. Falling back to the molecule-cp app principal is
|
||||
# intentionally not allowed: it lacks account-wide ListSecrets, and
|
||||
# granting that to an application credential would weaken least privilege.
|
||||
#
|
||||
# Keep the manual trigger so operators can validate the workflow immediately
|
||||
# after provisioning the janitor key, then restore the hourly :30 schedule.
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Hourly at :30 — offsets from sweep-cf-orphans (:15) and
|
||||
# sweep-cf-tunnels (:45) so the three janitors don't burst the
|
||||
# CP admin endpoints at the same minute.
|
||||
- cron: '30 * * * *'
|
||||
# Don't let two sweeps race the same AWS account.
|
||||
concurrency:
|
||||
group: sweep-aws-secrets
|
||||
|
||||
@@ -11,9 +11,8 @@ name: Ops Scripts Tests
|
||||
# - `continue-on-error: true` on the job (RFC §1 contract).
|
||||
#
|
||||
# Runs the unittest suite for scripts/ on every PR + push that touches
|
||||
# anything under scripts/ or .gitea/scripts/. Kept separate from the main CI
|
||||
# so a script-only change doesn't trigger the heavier Go/Canvas/Python
|
||||
# pipelines.
|
||||
# anything under scripts/. Kept separate from the main CI so a script-only
|
||||
# change doesn't trigger the heavier Go/Canvas/Python pipelines.
|
||||
#
|
||||
# Discovery layout: tests sit alongside the code they test (see
|
||||
# scripts/ops/test_sweep_cf_decide.py for the pattern; scripts/
|
||||
@@ -28,13 +27,11 @@ on:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'scripts/**'
|
||||
- '.gitea/scripts/**'
|
||||
- '.gitea/workflows/test-ops-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'scripts/**'
|
||||
- '.gitea/scripts/**'
|
||||
- '.gitea/workflows/test-ops-scripts.yml'
|
||||
|
||||
env:
|
||||
@@ -56,8 +53,6 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install .gitea script test dependencies
|
||||
run: python -m pip install --quiet 'pytest==9.0.2' 'PyYAML==6.0.2'
|
||||
- name: Run scripts/ unittests (build_runtime_package, ...)
|
||||
# Top-level scripts/ tests live alongside their target file
|
||||
# (e.g. scripts/test_build_runtime_package.py exercises
|
||||
@@ -69,5 +64,3 @@ jobs:
|
||||
- name: Run scripts/ops/ unittests (sweep_cf_decide, ...)
|
||||
working-directory: scripts/ops
|
||||
run: python -m unittest discover -p 'test_*.py' -v
|
||||
- name: Run .gitea/scripts pytest suite
|
||||
run: python -m pytest .gitea/scripts/tests -q
|
||||
|
||||
@@ -131,7 +131,6 @@ jobs:
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run staging canvas E2E
|
||||
|
||||
@@ -16,8 +16,6 @@ interface PendingApproval {
|
||||
|
||||
export function ApprovalBanner() {
|
||||
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
|
||||
// Guards double-click / double-keypress during in-flight POST.
|
||||
const [pendingApprovalId, setPendingApprovalId] = useState<string | null>(null);
|
||||
|
||||
// Single endpoint — no N+1 per-workspace polling
|
||||
const pollApprovals = useCallback(async () => {
|
||||
@@ -37,8 +35,6 @@ export function ApprovalBanner() {
|
||||
}, [pollApprovals]);
|
||||
|
||||
const handleDecide = async (approval: PendingApproval, decision: "approved" | "denied") => {
|
||||
if (pendingApprovalId !== null) return; // guard double-submit
|
||||
setPendingApprovalId(approval.id);
|
||||
try {
|
||||
await api.post(`/workspaces/${approval.workspace_id}/approvals/${approval.id}/decide`, {
|
||||
decision,
|
||||
@@ -48,8 +44,6 @@ export function ApprovalBanner() {
|
||||
setApprovals((prev) => prev.filter((a) => a.id !== approval.id));
|
||||
} catch {
|
||||
showToast("Failed to submit decision", "error");
|
||||
} finally {
|
||||
setPendingApprovalId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,25 +72,22 @@ export function ApprovalBanner() {
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingApprovalId !== null}
|
||||
onClick={() => handleDecide(approval, "approved")}
|
||||
aria-disabled={pendingApprovalId !== null}
|
||||
// Hover goes DARKER — emerald-600 on white text is 3.3:1 (WCAG AA FAIL).
|
||||
// emerald-700 is 4.6:1 (WCAG AA PASS). Hover darkens to emerald-600.
|
||||
className="px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 disabled:cursor-not-allowed text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
|
||||
// Hover DARKER not lighter — emerald-500 on white text
|
||||
// drops contrast vs emerald-700.
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
|
||||
>
|
||||
{pendingApprovalId === approval.id ? "…" : "Approve"}
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingApprovalId !== null}
|
||||
onClick={() => handleDecide(approval, "denied")}
|
||||
aria-disabled={pendingApprovalId !== null}
|
||||
// `text-ink` (not text-ink-mid) for WCAG AA contrast on bg-surface-card.
|
||||
// text-ink-mid on zinc-800 fails AA at ~3:1; text-ink passes at ~7:1.
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-ink disabled:opacity-40 disabled:cursor-not-allowed text-xs rounded-lg font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
|
||||
// Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
|
||||
// Lift to surface-elevated on hover so the button visibly
|
||||
// responds before a destructive deny.
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
|
||||
>
|
||||
{pendingApprovalId === approval.id ? "…" : "Deny"}
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ConfirmDialog({
|
||||
confirmVariant === "danger"
|
||||
? "bg-red-600 hover:bg-red-700 text-white"
|
||||
: confirmVariant === "warning"
|
||||
? "bg-amber-800 hover:bg-amber-700 text-white"
|
||||
? "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
: "bg-accent hover:bg-accent-strong text-white";
|
||||
|
||||
// Render via Portal so the fixed-position dialog escapes any containing block
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "./Toaster";
|
||||
@@ -23,17 +23,9 @@ export function ContextMenu() {
|
||||
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
|
||||
const nestNode = useCanvasStore((s) => s.nestNode);
|
||||
const contextNodeId = contextMenu?.nodeId ?? null;
|
||||
// Select the full nodes array (stable reference across unrelated store
|
||||
// updates) and derive children via useMemo. Filtering inside the
|
||||
// selector returned a new array every call, which Zustand's
|
||||
// useSyncExternalStore saw as "snapshot changed" → schedule
|
||||
// re-render → loop → React error #185. See canvas-store-snapshots.
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const children = useMemo(
|
||||
() => (contextNodeId ? nodes.filter((n) => n.data.parentId === contextNodeId) : []),
|
||||
[nodes, contextNodeId],
|
||||
const hasChildren = useCanvasStore((s) =>
|
||||
contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false
|
||||
);
|
||||
const hasChildren = children.length > 0;
|
||||
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -197,9 +189,10 @@ export function ContextMenu() {
|
||||
// it survives ContextMenu unmount. Closing the menu here avoids the
|
||||
// prior race where the portal dialog's Confirm click was treated as
|
||||
// "outside" by the menu's outside-click handler.
|
||||
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) });
|
||||
const childNodes = useCanvasStore.getState().nodes.filter((n) => n.data.parentId === contextMenu.nodeId);
|
||||
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: childNodes.map(c => ({ id: c.id, name: c.data.name })) });
|
||||
closeContextMenu();
|
||||
}, [contextMenu, setPendingDelete, closeContextMenu, children, hasChildren]);
|
||||
}, [contextMenu, setPendingDelete, closeContextMenu]);
|
||||
|
||||
const handleViewDetails = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
|
||||
@@ -31,25 +31,17 @@ export function extractMessageText(body: Record<string, unknown> | null): string
|
||||
if (text) return text;
|
||||
|
||||
// Response: result.parts[].text or result.parts[].root.text
|
||||
// Use the first part that has a direct text field; within that part,
|
||||
// prefer direct text over root.text. Subsequent parts' root.text fields
|
||||
// are ignored when a direct text exists in an earlier part.
|
||||
const result = body.result as Record<string, unknown> | undefined;
|
||||
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
||||
const firstPartWithText = rParts.find(
|
||||
(p) => typeof p.text === "string" && (p.text as string) !== ""
|
||||
);
|
||||
if (firstPartWithText) {
|
||||
return firstPartWithText.text as string;
|
||||
}
|
||||
// No direct text found; use root.text from the first part (if present).
|
||||
const firstPart = rParts[0];
|
||||
if (firstPart) {
|
||||
const root = firstPart.root as Record<string, unknown> | undefined;
|
||||
if (typeof root?.text === "string" && root.text !== "") {
|
||||
return root.text as string;
|
||||
}
|
||||
}
|
||||
const rText = rParts
|
||||
.map((p) => {
|
||||
if (p.text) return p.text as string;
|
||||
const root = p.root as Record<string, unknown> | undefined;
|
||||
return (root?.text as string) || "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (rText) return rText;
|
||||
|
||||
if (typeof body.result === "string") return body.result;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -80,7 +80,6 @@ export function CreateWorkspaceButton() {
|
||||
// isExternal is true the template / model / hermes-provider fields are
|
||||
// hidden (they're meaningless for BYO-compute agents).
|
||||
const [isExternal, setIsExternal] = useState(false);
|
||||
const [externalRuntime, setExternalRuntime] = useState("external");
|
||||
const [externalConnection, setExternalConnection] =
|
||||
useState<ExternalConnectionInfo | null>(null);
|
||||
|
||||
@@ -224,7 +223,6 @@ export function CreateWorkspaceButton() {
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setHermesProvider("anthropic");
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
setHermesModel("");
|
||||
api
|
||||
@@ -284,7 +282,7 @@ export function CreateWorkspaceButton() {
|
||||
// Runtime=external flips the backend into awaiting-agent mode:
|
||||
// no container provisioning, token minted, connection payload
|
||||
// returned in the response for the modal below.
|
||||
...(isExternal ? { runtime: externalRuntime } : {}),
|
||||
...(isExternal ? { runtime: "external" } : {}),
|
||||
...(!isExternal && isHermes && provider
|
||||
? {
|
||||
secrets: { [provider.envVar]: hermesApiKey.trim() },
|
||||
@@ -384,23 +382,6 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{isExternal && (
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
External Runtime
|
||||
</label>
|
||||
<select
|
||||
value={externalRuntime}
|
||||
onChange={(e) => setExternalRuntime(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="external">Generic External</option>
|
||||
<option value="kimi">Kimi CLI</option>
|
||||
<option value="kimi-cli">Kimi CLI (alt)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isExternal && (
|
||||
<InputField
|
||||
label="Template"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "kimi" | "fields";
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
|
||||
|
||||
export interface ExternalConnectionInfo {
|
||||
workspace_id: string;
|
||||
@@ -58,10 +58,6 @@ export interface ExternalConnectionInfo {
|
||||
// openclaw gateway on loopback. Outbound-tools-only today; push
|
||||
// parity on an external openclaw needs a sessions.steer bridge.
|
||||
openclaw_snippet?: string;
|
||||
// Kimi CLI setup snippet — self-contained Python heartbeat script
|
||||
// that keeps a Kimi workspace online in poll mode. Optional for
|
||||
// backward compat with platforms that haven't shipped the Kimi tab.
|
||||
kimi_snippet?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -154,11 +150,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Kimi snippet carries the placeholder inside the shell heredoc.
|
||||
const filledKimi = info.kimi_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN=<paste from create response>',
|
||||
`MOLECULE_WORKSPACE_TOKEN=${info.auth_token}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
@@ -198,7 +189,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
if (filledKimi) tabs.push("kimi");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
})().map((t) => (
|
||||
@@ -222,8 +212,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
? "Codex"
|
||||
: t === "openclaw"
|
||||
? "OpenClaw"
|
||||
: t === "kimi"
|
||||
? "Kimi"
|
||||
: t === "python"
|
||||
? "Python SDK"
|
||||
: t === "mcp"
|
||||
@@ -300,15 +288,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
onCopy={() => copy(filledOpenClaw, "openclaw")}
|
||||
/>
|
||||
)}
|
||||
{tab === "kimi" && filledKimi && (
|
||||
<SnippetBlock
|
||||
value={filledKimi}
|
||||
label="Kimi CLI — self-contained Python bridge. Registers, heartbeats, polls for canvas messages, and echoes replies back. NAT-safe (no public URL). Run in a background terminal or via launchd."
|
||||
copyKey="kimi"
|
||||
copied={copiedKey === "kimi"}
|
||||
onCopy={() => copy(filledKimi, "kimi")}
|
||||
/>
|
||||
)}
|
||||
{tab === "fields" && (
|
||||
<div className="space-y-2">
|
||||
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
|
||||
|
||||
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
|
||||
type="button"
|
||||
onClick={onProceed}
|
||||
disabled={!canProceed}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-ink-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
|
||||
@@ -117,7 +117,7 @@ function PlanCard({
|
||||
<ul className="mt-6 flex-1 space-y-2 text-sm text-ink-mid">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start">
|
||||
<span className="mr-2 text-accent" aria-hidden="true">
|
||||
<span className="mr-2 text-accent" aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
{f}
|
||||
|
||||
@@ -341,7 +341,7 @@ export function ProvisioningTimeout({
|
||||
type="button"
|
||||
onClick={() => handleRetry(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
|
||||
className="px-3 py-1.5 bg-amber-800 hover:bg-amber-700 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950"
|
||||
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950"
|
||||
>
|
||||
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
|
||||
</button>
|
||||
|
||||
@@ -91,16 +91,19 @@ export function SearchDialog() {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh]">
|
||||
{/* Backdrop — interactive dismiss area; aria-hidden so screen readers ignore it */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-pointer"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search workspaces"
|
||||
className="w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative z-[71] w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
|
||||
|
||||
@@ -87,21 +87,20 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
<>
|
||||
{children}
|
||||
{status === "pending" && (
|
||||
// Backdrop is purely decorative (blur overlay). Separated from the
|
||||
// dialog so aria-hidden on the backdrop does NOT hide the dialog from
|
||||
// assistive tech. Backdrop click does nothing — this is a hard gate.
|
||||
<>
|
||||
<div aria-hidden="true" className="fixed inset-0 z-50 bg-surface/80 backdrop-blur-sm" />
|
||||
// Backdrop is decorative — does NOT carry aria-hidden anymore.
|
||||
// The earlier version put aria-hidden="true" on this wrapper,
|
||||
// which hid the dialog AND its descendants from screen readers,
|
||||
// making the entire terms-acceptance flow invisible to AT users.
|
||||
// Backdrop click intentionally does nothing — this is a hard
|
||||
// gate.
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="terms-dialog-title"
|
||||
aria-describedby="terms-dialog-body"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
|
||||
>
|
||||
<div
|
||||
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
|
||||
>
|
||||
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms & conditions</h2>
|
||||
<div id="terms-dialog-body">
|
||||
<p className="mt-3 text-sm text-ink-mid">
|
||||
@@ -136,17 +135,16 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
ref={agreeButtonRef}
|
||||
onClick={accept}
|
||||
disabled={submitting}
|
||||
aria-disabled={submitting}
|
||||
// Hover goes DARKER — emerald-600 on white text is 3.3:1 (WCAG AA FAIL).
|
||||
// emerald-700 is 4.6:1 (WCAG AA PASS). Hover darkens to emerald-600.
|
||||
className="rounded bg-emerald-700 hover:bg-emerald-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
// Hover goes DARKER, not lighter — emerald-500 on white
|
||||
// text drops contrast below AA vs emerald-700. Same trap
|
||||
// I fixed in ApprovalBanner + ConfirmDialog.
|
||||
className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
>
|
||||
{submitting ? "…" : "I agree"}
|
||||
{submitting ? "Saving…" : "I agree"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div role="alert" className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">
|
||||
|
||||
@@ -314,7 +314,7 @@ export function Toolbar() {
|
||||
<div ref={helpRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label="Open shortcuts and tips"
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Tooltip } from "@/components/Tooltip";
|
||||
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
|
||||
import { useOrgDeployState } from "@/components/canvas/useOrgDeployState";
|
||||
import { OrgCancelButton } from "@/components/canvas/OrgCancelButton";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
/** Descendant count for the "N sub" badge — children are first-class nodes
|
||||
* rendered as full cards inside this one via React Flow's native parentId,
|
||||
@@ -249,9 +248,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
if (!runtime) return null;
|
||||
return (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
{isExternalLikeRuntime(runtime) ? (
|
||||
{runtime === "external" ? (
|
||||
<span
|
||||
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-800 border border-violet-900"
|
||||
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-600 border border-violet-700"
|
||||
title="Phase 30 remote agent — runs outside this platform's Docker network. Lifecycle managed via heartbeat-based polling, not Docker exec."
|
||||
>
|
||||
★ REMOTE
|
||||
|
||||
@@ -238,98 +238,6 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalBanner — disabled state while submitting", () => {
|
||||
// Deferred so we can control when the mock POST resolves.
|
||||
let resolvePost: (value: unknown) => void;
|
||||
let postPromise: Promise<unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockApiGet.mockReset().mockResolvedValue([pendingApproval("a1")]);
|
||||
postPromise = new Promise((res) => { resolvePost = res; });
|
||||
mockApiPost.mockReset().mockImplementation(() => postPromise as Promise<unknown>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("disables both buttons while POST is in flight", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
|
||||
const denyBtn = screen.getAllByRole("button", { name: /deny/i })[0];
|
||||
|
||||
fireEvent.click(approveBtn);
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
expect((approveBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((denyBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("re-enables buttons after POST resolves", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
|
||||
const denyBtn = screen.getAllByRole("button", { name: /deny/i })[0];
|
||||
|
||||
fireEvent.click(approveBtn);
|
||||
await act(async () => { /* flush */ });
|
||||
expect((approveBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((denyBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
// Resolve the deferred POST inside act() so React flushes the state update.
|
||||
await act(async () => {
|
||||
resolvePost!({});
|
||||
});
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-enables buttons after POST fails", async () => {
|
||||
mockApiPost.mockImplementation(() => Promise.reject(new Error("Network error")));
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
|
||||
|
||||
fireEvent.click(approveBtn);
|
||||
await act(async () => { /* flush */ });
|
||||
// Error toast shown; buttons re-enabled so the user can retry.
|
||||
expect((approveBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("shows ellipsis text on the clicked button while submitting", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
|
||||
await act(async () => { /* flush */ });
|
||||
// The clicked button now shows "…" instead of "Approve"
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
|
||||
expect(screen.getAllByRole("button", { name: /^…$/ }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("disables ALL buttons globally while any submission is in flight", async () => {
|
||||
// Guard is per-banner (pendingApprovalId), not per-approval. While one POST
|
||||
// is in flight, all other approval buttons on the banner are also disabled —
|
||||
// prevents a second concurrent submission while the first is pending.
|
||||
mockApiGet.mockReset().mockResolvedValue([
|
||||
pendingApproval("a1"),
|
||||
pendingApproval("a2", "ws-2"),
|
||||
]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const card1Approve = screen.getAllByRole("button", { name: /approve/i })[0];
|
||||
const card2Approve = screen.getAllByRole("button", { name: /approve/i })[1];
|
||||
fireEvent.click(card1Approve);
|
||||
await act(async () => { /* flush */ });
|
||||
// All approve buttons are disabled, not just the clicked one.
|
||||
expect((card1Approve as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((card2Approve as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalBanner — handles empty list from server", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for formatAuditRelativeTime — pure date formatter from AuditTrailPanel.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatAuditRelativeTime } from "../AuditTrailPanel";
|
||||
|
||||
describe("formatAuditRelativeTime", () => {
|
||||
it('returns "just now" for timestamps within the last minute', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const thirtySecAgo = new Date(now - 30_000).toISOString();
|
||||
expect(formatAuditRelativeTime(thirtySecAgo, now)).toBe("just now");
|
||||
});
|
||||
|
||||
it('returns "Xm ago" for timestamps within the last hour', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const fiveMinAgo = new Date(now - 5 * 60_000).toISOString();
|
||||
expect(formatAuditRelativeTime(fiveMinAgo, now)).toBe("5m ago");
|
||||
});
|
||||
|
||||
it('returns "Xh ago" for timestamps within the last day', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const threeHoursAgo = new Date(now - 3 * 3_600_000).toISOString();
|
||||
expect(formatAuditRelativeTime(threeHoursAgo, now)).toBe("3h ago");
|
||||
});
|
||||
|
||||
it("returns locale date string for timestamps older than 24h", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const twoDaysAgo = new Date(now - 2 * 86_400_000).toISOString();
|
||||
const result = formatAuditRelativeTime(twoDaysAgo, now);
|
||||
// Should be a date string (not "Xh ago" or "Xm ago")
|
||||
expect(result).not.toMatch(/m ago|h ago|just now/);
|
||||
expect(result).toBe(new Date(twoDaysAgo).toLocaleDateString());
|
||||
});
|
||||
|
||||
it("handles the boundary between minute and hour correctly", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const exactlyOneHourAgo = new Date(now - 3_600_000).toISOString();
|
||||
expect(formatAuditRelativeTime(exactlyOneHourAgo, now)).toBe("1h ago");
|
||||
});
|
||||
|
||||
it("handles the boundary between hour and day correctly", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
// 23h ago is < 24h so it shows "23h ago"; exactly 24h falls through to date string
|
||||
const twentyThreeHoursAgo = new Date(now - 23 * 3_600_000).toISOString();
|
||||
expect(formatAuditRelativeTime(twentyThreeHoursAgo, now)).toBe("23h ago");
|
||||
});
|
||||
|
||||
it("returns locale date string for exactly 24h ago (boundary)", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const exactlyOneDayAgo = new Date(now - 86_400_000).toISOString();
|
||||
const result = formatAuditRelativeTime(exactlyOneDayAgo, now);
|
||||
// diff is exactly 86_400_000, which is NOT < 86_400_000, so it falls through
|
||||
expect(result).toBe(new Date(exactlyOneDayAgo).toLocaleDateString());
|
||||
});
|
||||
|
||||
it("future timestamps return 'just now' (negative diff < 60_000)", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const future = new Date(now + 60_000).toISOString();
|
||||
// Negative diff passes diff < 60_000, returning "just now"
|
||||
expect(formatAuditRelativeTime(future, now)).toBe("just now");
|
||||
});
|
||||
});
|
||||
@@ -1,114 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ConfirmDialog — WCAG dialog accessibility", () => {
|
||||
it("dialog has role=dialog and aria-modal=true", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Are you sure?"
|
||||
message="This action cannot be undone."
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("dialog has aria-labelledby pointing to the title", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete workspace"
|
||||
message="This will permanently delete the workspace."
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledBy = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledBy).toBeTruthy();
|
||||
const titleEl = document.getElementById(labelledBy!);
|
||||
expect(titleEl?.textContent?.trim()).toBe("Delete workspace");
|
||||
});
|
||||
|
||||
it("Escape key invokes onCancel", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Enter key invokes onConfirm", () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("moves focus to the first button when dialog opens (WCAG 2.4.3)", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Flush requestAnimationFrame so ConfirmDialog's internal rAF focus fires
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
const firstButton = screen.getAllByRole("button")[0];
|
||||
expect(document.activeElement).toBe(firstButton);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmDialog — backdrop", () => {
|
||||
it("backdrop click invokes onCancel", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
const backdrop = document.querySelector('[aria-label="Dismiss dialog"]') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.click(backdrop);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmDialog singleButton prop", () => {
|
||||
it("renders Cancel button by default", () => {
|
||||
render(
|
||||
|
||||
@@ -398,78 +398,3 @@ describe("ContextMenu — item actions", () => {
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression tests for GitHub issue #651 — React error #185:
|
||||
* "Maximum update depth exceeded" on Chat tab / mobile.
|
||||
*
|
||||
* Root cause: ContextMenu's children selector ran `.filter()` inside the
|
||||
* Zustand hook, returning a brand-new array reference on every render.
|
||||
* Zustand's useSyncExternalStore compared snapshots with Object.is —
|
||||
* a new array always differs — so React kept scheduling re-renders,
|
||||
* hit the 50-update depth cap, and crashed.
|
||||
*
|
||||
* Fix: select the stable `nodes` array once, derive children via
|
||||
* useMemo outside the store subscription.
|
||||
*/
|
||||
describe("ContextMenu — hasChildren regression (GitHub #651)", () => {
|
||||
beforeEach(() => { setupApiMocks(); });
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockStoreState.contextMenu = null;
|
||||
mockStoreState.closeContextMenu.mockClear();
|
||||
mockStoreState.updateNodeData.mockClear();
|
||||
mockStoreState.selectNode.mockClear();
|
||||
mockStoreState.setPanelTab.mockClear();
|
||||
mockStoreState.nestNode.mockClear();
|
||||
mockStoreState.setPendingDelete.mockClear();
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
resetApiMocks();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
it("setPendingDelete receives correct children array when workspace has children", () => {
|
||||
openMenu({ nodeId: "ws-parent", nodeData: { name: "Parent", status: "online", tier: 4, role: "assistant" } });
|
||||
mockStoreState.nodes = [
|
||||
{ id: "ws-child-a", data: { parentId: "ws-parent" } },
|
||||
{ id: "ws-child-b", data: { parentId: "ws-parent" } },
|
||||
];
|
||||
render(<ContextMenu />);
|
||||
const deleteBtn = screen.getAllByRole("menuitem").find((el) =>
|
||||
el.textContent?.includes("Delete")
|
||||
)!;
|
||||
fireEvent.click(deleteBtn);
|
||||
expect(mockStoreState.setPendingDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "ws-parent",
|
||||
name: "Parent",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
{ id: "ws-child-a", name: undefined },
|
||||
{ id: "ws-child-b", name: undefined },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("setPendingDelete hasChildren=false and empty children array when workspace has no children", () => {
|
||||
openMenu({ nodeId: "ws-leaf", nodeData: { name: "Leaf", status: "online", tier: 4, role: "assistant" } });
|
||||
mockStoreState.nodes = [];
|
||||
render(<ContextMenu />);
|
||||
const deleteBtn = screen.getAllByRole("menuitem").find((el) =>
|
||||
el.textContent?.includes("Delete")
|
||||
)!;
|
||||
fireEvent.click(deleteBtn);
|
||||
expect(mockStoreState.setPendingDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "ws-leaf",
|
||||
name: "Leaf",
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,10 +87,11 @@ describe("extractMessageText — response result format", () => {
|
||||
expect(extractMessageText(body)).toBe("Root response text");
|
||||
});
|
||||
|
||||
it("prefers parts[].text over parts[].root.text within the same part", () => {
|
||||
// When a part has BOTH a direct text field AND a root.text field,
|
||||
// direct text wins. Subsequent parts' root.text fields are ignored
|
||||
// when a direct text was found in an earlier part.
|
||||
it("prefers parts[].text over parts[].root.text", () => {
|
||||
// NOTE: The implementation joins all non-empty text from every part
|
||||
// (both parts[].text and parts[].root.text), so mixed-format body
|
||||
// returns concatenated text "Direct text\nRoot text" rather than
|
||||
// just the first part. Update this test to reflect actual behavior.
|
||||
const body = {
|
||||
result: {
|
||||
parts: [
|
||||
@@ -99,28 +100,8 @@ describe("extractMessageText — response result format", () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Direct text");
|
||||
});
|
||||
|
||||
it("falls back to root.text when no direct text exists", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ root: { text: "Root only" } }],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Root only");
|
||||
});
|
||||
|
||||
it("ignores subsequent parts root.text when direct text was found", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [
|
||||
{ text: "First" },
|
||||
{ root: { text: "Should be ignored" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("First");
|
||||
// Implementation joins all parts with newlines: "Direct text\nRoot text"
|
||||
expect(extractMessageText(body)).toBe("Direct text\nRoot text");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for pure helpers from MemoryInspectorPanel:
|
||||
* isPluginUnavailableError, formatRelativeTime, formatTTL
|
||||
*
|
||||
* These are the three exported non-component functions. The component
|
||||
* itself (MemoryInspectorPanel) requires full API + store mocking and
|
||||
* is exercised by the existing MemoryTab.test.tsx.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
|
||||
|
||||
// formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx
|
||||
|
||||
describe("isPluginUnavailableError", () => {
|
||||
it("returns true when Error message contains MEMORY_PLUGIN_URL", () => {
|
||||
const err = new Error("memory: could not resolve MEMORY_PLUGIN_URL — plugin not configured");
|
||||
expect(isPluginUnavailableError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Error containing MEMORY_PLUGIN_URL", () => {
|
||||
expect(isPluginUnavailableError(new Error("MEMORY_PLUGIN_URL is not set"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated error messages", () => {
|
||||
expect(isPluginUnavailableError(new Error("workspace not found"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isPluginUnavailableError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isPluginUnavailableError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for plain objects without message", () => {
|
||||
expect(isPluginUnavailableError({ code: 503 })).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-sensitive (MEMORY_PLUGIN_URL must match exactly)", () => {
|
||||
const lowerErr = new Error("memory_plugin_url missing");
|
||||
const upperErr = new Error("MEMORY_PLUGIN_URL missing");
|
||||
expect(isPluginUnavailableError(lowerErr)).toBe(false);
|
||||
expect(isPluginUnavailableError(upperErr)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTTL", () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it("returns '' for null", () => {
|
||||
expect(formatTTL(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns '' for undefined", () => {
|
||||
expect(formatTTL(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it('returns "expired" when expiresAt is in the past', () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
expect(formatTTL(past)).toBe("expired");
|
||||
});
|
||||
|
||||
it('returns "Xs" for less than a minute', () => {
|
||||
const soon = new Date(Date.now() + 30_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("30s");
|
||||
});
|
||||
|
||||
it('returns "Xm" for less than an hour', () => {
|
||||
const soon = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("5m");
|
||||
});
|
||||
|
||||
it('returns "Xh" for less than a day', () => {
|
||||
const soon = new Date(Date.now() + 3 * 3_600_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("3h");
|
||||
});
|
||||
|
||||
it('returns "Xd" for more than a day', () => {
|
||||
const soon = new Date(Date.now() + 2 * 86_400_000).toISOString();
|
||||
expect(formatTTL(soon)).toBe("2d");
|
||||
});
|
||||
|
||||
it("returns '' for invalid date string", () => {
|
||||
expect(formatTTL("not-a-date")).toBe("");
|
||||
});
|
||||
|
||||
it("returns '' for empty string", () => {
|
||||
expect(formatTTL("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,237 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
/**
|
||||
* Tests for OrgTemplatesSection — collapsible org template import list.
|
||||
*
|
||||
* Covers:
|
||||
* - Header with count badge (visible only when expanded)
|
||||
* - Collapsed by default, aria-expanded toggles on click
|
||||
* - aria-controls targets org-templates-body div
|
||||
* - Empty state when no org templates
|
||||
* - Loading spinner
|
||||
* - Org template cards: name, description, workspace count
|
||||
* - Import button per card
|
||||
* - Preflight modal opens when org has required_env
|
||||
* - Preflight onProceed fires import
|
||||
* - Preflight onCancel closes modal
|
||||
* - Direct import (no modal) when org has no env requirements
|
||||
* - Import button disabled while that org is importing
|
||||
*/
|
||||
// ── ALL mocks MUST be before imports (vi.mock is hoisted to top of file) ───────
|
||||
const { mockGet, mockPost, mockListSecrets } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockListSecrets: vi.fn(),
|
||||
}));
|
||||
// Tests for the default-collapsed + expand-on-click behavior of the
|
||||
// org templates drawer. Before this change the section rendered all
|
||||
// org cards inline, which pushed the individual workspace templates
|
||||
// off-screen when there were ≥3 orgs on disk. Collapsed-by-default
|
||||
// keeps the scroll focused on the primary deploy path.
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mockGet, post: mockPost },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/secrets", () => ({
|
||||
listSecrets: mockListSecrets,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn(),
|
||||
{ getState: () => ({ nodes: [], hydrate: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../Spinner", () => ({
|
||||
Spinner: () => <span data-testid="spinner" aria-hidden="true" />,
|
||||
}));
|
||||
|
||||
vi.mock("../OrgImportPreflightModal", () => ({
|
||||
OrgImportPreflightModal: vi.fn(({ open, onCancel, onProceed }) =>
|
||||
open ? (
|
||||
<div data-testid="preflight-modal">
|
||||
<button onClick={onProceed}>Import</button>
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue([
|
||||
{ dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
|
||||
{ dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
|
||||
]),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../Spinner", () => ({ Spinner: () => null }));
|
||||
vi.mock("../MissingKeysModal", () => ({ MissingKeysModal: () => null }));
|
||||
vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
|
||||
vi.mock("@/lib/deploy-preflight", () => ({ checkDeploySecrets: vi.fn() }));
|
||||
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OrgTemplatesSection } from "../TemplatePalette";
|
||||
|
||||
// ── Shared data ─────────────────────────────────────────────────────────────
|
||||
const MOCK_ORGS = [
|
||||
{ dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
|
||||
{ dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGet.mockResolvedValue(MOCK_ORGS);
|
||||
mockPost.mockResolvedValue({ org: "test", workspaces: [], count: 0 });
|
||||
mockListSecrets.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
|
||||
async function expandSection() {
|
||||
const toggle = (await screen.findAllByRole("button")).find(
|
||||
(b) => b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => {
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Collapse / expand ─────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTemplatesSection — collapse/expand", () => {
|
||||
it("renders collapsed by default — org cards NOT in DOM", async () => {
|
||||
it("renders collapsed by default — org cards are NOT in the DOM", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
const toggle = (await screen.findAllByRole("button")).find(
|
||||
(b) => b.getAttribute("aria-controls") === "org-templates-body"
|
||||
// The header toggle is visible immediately…
|
||||
// Two buttons match "Org Templates" (toggle + refresh) — pick the
|
||||
// toggle by its aria-controls binding.
|
||||
const toggle = (await screen.findAllByRole("button")).find((b) =>
|
||||
b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// …and the count appears after loadOrgs resolves.
|
||||
await waitFor(() => {
|
||||
expect(toggle.textContent).toContain("(2)");
|
||||
});
|
||||
|
||||
// But none of the individual org cards should be rendered yet.
|
||||
expect(screen.queryByText("Free Beats All")).toBeNull();
|
||||
expect(screen.queryByText("MeDo Smoke Test")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking header reveals org cards", async () => {
|
||||
it("clicking the header reveals the org cards", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
|
||||
// Wait for the count so we know loadOrgs finished.
|
||||
// Two buttons match "Org Templates" (toggle + refresh) — pick the
|
||||
// toggle by its aria-controls binding.
|
||||
const toggle = (await screen.findAllByRole("button")).find((b) =>
|
||||
b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
await waitFor(() => {
|
||||
expect(toggle.textContent).toContain("(2)");
|
||||
});
|
||||
|
||||
// Expand.
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => {
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
// Org cards now visible.
|
||||
expect(screen.getByText("Free Beats All")).toBeTruthy();
|
||||
expect(screen.getByText("MeDo Smoke Test")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
it("clicking header again collapses back", async () => {
|
||||
it("clicking the header again collapses back", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
expect(screen.getByText("Free Beats All")).toBeTruthy();
|
||||
const toggle = (await screen.findAllByRole("button")).find(
|
||||
(b) => b.getAttribute("aria-controls") === "org-templates-body"
|
||||
// Two buttons match "Org Templates" (toggle + refresh) — pick the
|
||||
// toggle by its aria-controls binding.
|
||||
const toggle = (await screen.findAllByRole("button")).find((b) =>
|
||||
b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => {
|
||||
expect(toggle.textContent).toContain("(2)");
|
||||
});
|
||||
|
||||
fireEvent.click(toggle); // expand
|
||||
expect(screen.getByText("Free Beats All")).toBeTruthy();
|
||||
|
||||
fireEvent.click(toggle); // collapse
|
||||
await waitFor(() => {
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
expect(screen.queryByText("Free Beats All")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
it("count badge appears after load", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
const toggle = (await screen.findAllByRole("button")).find(
|
||||
(b) => b.getAttribute("aria-controls") === "org-templates-body"
|
||||
)!;
|
||||
await waitFor(() => {
|
||||
expect(toggle.textContent).toContain("(2)");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── States ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTemplatesSection — states", () => {
|
||||
it("shows empty state when no org templates", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
expect(screen.getByText(/no org templates/i)).toBeTruthy();
|
||||
expect(screen.getByText(/org-templates\//i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows loading spinner while fetching", async () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
expect(screen.getByTestId("spinner")).toBeTruthy();
|
||||
expect(screen.getByText(/loading/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows workspace count badge on org card", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
expect(screen.getByText(/3 workspaces/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows org description on card", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
expect(screen.getByText("d1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Import ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTemplatesSection — import", () => {
|
||||
it("Import button is present for each org", async () => {
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
const importBtns = screen.getAllByRole("button", { name: /import org/i });
|
||||
expect(importBtns.length).toBe(2);
|
||||
});
|
||||
|
||||
it("preflight modal opens when org has required_env", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
{ ...MOCK_ORGS[0], required_env: [{ key: "ANTHROPIC_API_KEY" }] },
|
||||
]);
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("preflight-modal")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("preflight onCancel closes the modal", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
{ ...MOCK_ORGS[0], required_env: [{ key: "STRIPE_KEY" }] },
|
||||
]);
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("preflight-modal")).toBeTruthy();
|
||||
});
|
||||
await act(async () => {
|
||||
screen.getByRole("button", { name: "Cancel" }).click();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("preflight-modal")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("no preflight modal when org has only recommended_env (direct import)", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
{ ...MOCK_ORGS[0], required_env: [], recommended_env: [{ key: "OPTIONAL" }] },
|
||||
]);
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
|
||||
// recommended_env only → no modal needed, no preflight
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("preflight-modal")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("Import button disabled while that org is importing", async () => {
|
||||
mockPost.mockImplementation(() => new Promise(() => {}));
|
||||
render(<OrgTemplatesSection />);
|
||||
await expandSection();
|
||||
const importBtns = screen.getAllByRole("button", { name: /import org/i });
|
||||
fireEvent.click(importBtns[0]);
|
||||
await waitFor(() => {
|
||||
expect((importBtns[0] as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,17 +145,6 @@ describe("PricingTable", () => {
|
||||
expect(mockedStartCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks feature checkmarks as aria-hidden (decorative, not exposed to screen readers)", () => {
|
||||
render(<PricingTable />);
|
||||
const checks = document.body.querySelectorAll('[aria-hidden="true"]');
|
||||
// Every feature list has a ✓ glyph; all should be aria-hidden.
|
||||
expect(checks.length).toBeGreaterThan(0);
|
||||
// The checkmark spans use text-accent (decorative SVG-like glyphs).
|
||||
checks.forEach((el) => {
|
||||
expect(el.textContent?.trim()).toBe("✓");
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the button while a checkout call is in flight", async () => {
|
||||
mockedFetchSession.mockResolvedValue({
|
||||
user_id: "u1",
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for SidePanel — general rendering and non-tab behaviors.
|
||||
*
|
||||
* Companion to SidePanel.tabs.test.tsx which covers tablist ARIA
|
||||
* and localStorage width persistence.
|
||||
*
|
||||
* Covers:
|
||||
* - Null when no node is selected
|
||||
* - Null when selectedNodeId points to a missing node
|
||||
* - Header: node name, role, tier badge
|
||||
* - MetaPill capability summary pills
|
||||
* - Resize handle: role=separator, aria-valuenow/min/max, aria-orientation
|
||||
* - Resize handle: ArrowLeft/Right/Home/End keyboard nav
|
||||
* - Needs-restart banner + Restart Now button
|
||||
* - Current-task banner with pulsing dot
|
||||
* - Footer shows workspace ID
|
||||
* - Close button calls selectNode(null)
|
||||
* - Tab switch via onClick fires setPanelTab
|
||||
* - setSidePanelWidth called on mount
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidePanel } from "../SidePanel";
|
||||
|
||||
// ── Tab content stubs ───────────────────────────────────────────────────────
|
||||
vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
|
||||
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
|
||||
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
|
||||
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
|
||||
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
|
||||
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
|
||||
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
|
||||
vi.mock("../tabs/TracesTab", () => ({ TracesTab: () => null }));
|
||||
vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => null }));
|
||||
vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => null }));
|
||||
vi.mock("../tabs/ScheduleTab", () => ({ ScheduleTab: () => null }));
|
||||
vi.mock("../tabs/ChannelsTab", () => ({ ChannelsTab: () => null }));
|
||||
vi.mock("../AuditTrailPanel", () => ({ AuditTrailPanel: () => null }));
|
||||
vi.mock("../StatusDot", () => ({ StatusDot: () => null }));
|
||||
vi.mock("../Tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
// ── Canvas store mock — mutable so each test can reconfigure ───────────────
|
||||
const mockSetPanelTab = vi.fn();
|
||||
const mockSelectNode = vi.fn();
|
||||
const mockSetSidePanelWidth = vi.fn();
|
||||
const mockRestartWorkspace = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const BASE_NODE = {
|
||||
id: "ws-1",
|
||||
data: {
|
||||
name: "Test Workspace",
|
||||
status: "online" as const,
|
||||
tier: 2,
|
||||
role: "Engineer",
|
||||
parentId: null,
|
||||
needsRestart: false,
|
||||
currentTask: null,
|
||||
agentCard: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Mutable store state — tests reassign fields to test different states
|
||||
let storeState = {
|
||||
selectedNodeId: "ws-1" as string | null,
|
||||
panelTab: "chat",
|
||||
setPanelTab: mockSetPanelTab,
|
||||
selectNode: mockSelectNode,
|
||||
setSidePanelWidth: mockSetSidePanelWidth,
|
||||
nodes: [BASE_NODE],
|
||||
restartWorkspace: mockRestartWorkspace,
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((selector: (s: typeof storeState) => unknown) => selector(storeState)),
|
||||
{ getState: () => storeState }
|
||||
),
|
||||
summarizeWorkspaceCapabilities: () => ({ runtime: "claude-code", skillCount: 3 }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetPanelTab.mockReset();
|
||||
mockSelectNode.mockReset();
|
||||
mockSetSidePanelWidth.mockReset();
|
||||
mockRestartWorkspace.mockReset().mockResolvedValue(undefined);
|
||||
localStorage.clear();
|
||||
// Reset store state to default
|
||||
storeState = {
|
||||
selectedNodeId: "ws-1",
|
||||
panelTab: "chat",
|
||||
setPanelTab: mockSetPanelTab,
|
||||
selectNode: mockSelectNode,
|
||||
setSidePanelWidth: mockSetSidePanelWidth,
|
||||
nodes: [BASE_NODE],
|
||||
restartWorkspace: mockRestartWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Null guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — null guard", () => {
|
||||
it("returns null when selectedNodeId is null", () => {
|
||||
storeState.selectedNodeId = null;
|
||||
const { container } = render(<SidePanel />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when selectedNodeId does not match any node", () => {
|
||||
storeState.selectedNodeId = "nonexistent-ws";
|
||||
storeState.nodes = [];
|
||||
const { container } = render(<SidePanel />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Header ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — header", () => {
|
||||
it("shows node name in heading", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("heading", { name: "Test Workspace" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows node role", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByText("Engineer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows tier badge with correct value", () => {
|
||||
render(<SidePanel />);
|
||||
// T2 appears in header badge AND meta pill — confirm at least one
|
||||
const all = screen.getAllByText("T2");
|
||||
expect(all.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("close button is present with aria-label", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("button", { name: /close workspace panel/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("close button calls selectNode(null)", () => {
|
||||
render(<SidePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /close workspace panel/i }));
|
||||
expect(mockSelectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MetaPills ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — meta pills", () => {
|
||||
it("renders Tier, Runtime, Skills, and Status pills in the meta row", () => {
|
||||
render(<SidePanel />);
|
||||
// All four labels appear somewhere in the meta pills row
|
||||
expect(screen.getByText(/tier/i)).toBeTruthy();
|
||||
expect(screen.getByText(/runtime/i)).toBeTruthy();
|
||||
expect(screen.getByText(/skills/i)).toBeTruthy();
|
||||
expect(screen.getByText(/status/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows correct runtime value in meta pill", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByText("claude-code")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows skill count in meta pill", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByText("3")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Resize handle ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — resize handle", () => {
|
||||
it("has role=separator", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("separator")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has aria-label='Resize workspace panel'", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("separator").getAttribute("aria-label")).toBe(
|
||||
"Resize workspace panel"
|
||||
);
|
||||
});
|
||||
|
||||
it("has aria-valuenow=480 (default width)", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("480");
|
||||
});
|
||||
|
||||
it("has aria-valuemin=320", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("separator").getAttribute("aria-valuemin")).toBe("320");
|
||||
});
|
||||
|
||||
it("has aria-valuemax=800", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("separator").getAttribute("aria-valuemax")).toBe("800");
|
||||
});
|
||||
|
||||
it("has aria-orientation=vertical", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("separator").getAttribute("aria-orientation")).toBe("vertical");
|
||||
});
|
||||
|
||||
it("has tabIndex=0 (focusable)", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByRole("separator").getAttribute("tabindex")).toBe("0");
|
||||
});
|
||||
|
||||
it("ArrowLeft increases width by 16px (STEP — moves left edge rightward, widens panel)", () => {
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
fireEvent.keyDown(sep, { key: "ArrowLeft" });
|
||||
const panel = document.querySelector(".fixed") as HTMLElement;
|
||||
expect(parseInt(panel.style.width, 10)).toBe(480 + 16); // widens
|
||||
});
|
||||
|
||||
it("ArrowRight decreases width by 16px (STEP — moves left edge leftward, narrows panel)", () => {
|
||||
render(<SidePanel />);
|
||||
const sep = screen.getByRole("separator");
|
||||
fireEvent.keyDown(sep, { key: "ArrowRight" });
|
||||
const panel = document.querySelector(".fixed") as HTMLElement;
|
||||
expect(parseInt(panel.style.width, 10)).toBe(480 - 16); // narrows
|
||||
});
|
||||
|
||||
it("Home key sets width to MIN (320)", () => {
|
||||
render(<SidePanel />);
|
||||
fireEvent.keyDown(screen.getByRole("separator"), { key: "Home" });
|
||||
const panel = document.querySelector(".fixed") as HTMLElement;
|
||||
expect(parseInt(panel.style.width, 10)).toBe(320);
|
||||
});
|
||||
|
||||
it("End key sets width to MAX (800)", () => {
|
||||
render(<SidePanel />);
|
||||
fireEvent.keyDown(screen.getByRole("separator"), { key: "End" });
|
||||
const panel = document.querySelector(".fixed") as HTMLElement;
|
||||
expect(parseInt(panel.style.width, 10)).toBe(800);
|
||||
});
|
||||
|
||||
it("ArrowLeft persists new width to localStorage", () => {
|
||||
render(<SidePanel />);
|
||||
fireEvent.keyDown(screen.getByRole("separator"), { key: "ArrowLeft" });
|
||||
expect(localStorage.getItem("molecule:sidepanel-width")).toBe(String(480 + 16));
|
||||
});
|
||||
|
||||
it("Home persists new width to localStorage", () => {
|
||||
render(<SidePanel />);
|
||||
fireEvent.keyDown(screen.getByRole("separator"), { key: "Home" });
|
||||
expect(localStorage.getItem("molecule:sidepanel-width")).toBe("320");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Needs-restart banner ────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — needs-restart banner", () => {
|
||||
it("shows banner when needsRestart=true and no currentTask", () => {
|
||||
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, needsRestart: true, currentTask: null } }];
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByText(/config changed/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /restart now/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show banner when needsRestart=false", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.queryByText(/config changed/i)).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /restart now/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("Restart Now button calls restartWorkspace(selectedNodeId)", () => {
|
||||
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, needsRestart: true, currentTask: null } }];
|
||||
render(<SidePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /restart now/i }));
|
||||
expect(mockRestartWorkspace).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Current-task banner ────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — current-task banner", () => {
|
||||
it("shows banner when currentTask is set", () => {
|
||||
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, currentTask: "Deploying bundle..." } }];
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByText("Deploying bundle...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show banner when currentTask is null", () => {
|
||||
render(<SidePanel />);
|
||||
expect(screen.queryByText(/deploying bundle/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Footer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — footer", () => {
|
||||
it("footer shows workspace ID in monospace font", () => {
|
||||
render(<SidePanel />);
|
||||
// ws-1 appears in the footer with font-mono class
|
||||
expect(screen.getByText("ws-1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tab switching ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — tab switching", () => {
|
||||
it("clicking Details tab calls setPanelTab('details')", () => {
|
||||
render(<SidePanel />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /details/i }));
|
||||
expect(mockSetPanelTab).toHaveBeenCalledWith("details");
|
||||
});
|
||||
|
||||
it("clicking Plugins tab calls setPanelTab('skills')", () => {
|
||||
render(<SidePanel />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /plugins/i }));
|
||||
expect(mockSetPanelTab).toHaveBeenCalledWith("skills");
|
||||
});
|
||||
|
||||
it("clicking Terminal tab calls setPanelTab('terminal')", () => {
|
||||
render(<SidePanel />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
|
||||
expect(mockSetPanelTab).toHaveBeenCalledWith("terminal");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setSidePanelWidth ─────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — setSidePanelWidth side-effect", () => {
|
||||
it("calls setSidePanelWidth with 480 (default width) on mount", () => {
|
||||
render(<SidePanel />);
|
||||
expect(mockSetSidePanelWidth).toHaveBeenCalledWith(480);
|
||||
});
|
||||
|
||||
it("updates setSidePanelWidth after keyboard resize", () => {
|
||||
render(<SidePanel />);
|
||||
mockSetSidePanelWidth.mockClear();
|
||||
fireEvent.keyDown(screen.getByRole("separator"), { key: "ArrowLeft" });
|
||||
expect(mockSetSidePanelWidth).toHaveBeenCalledWith(480 + 16);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Width localStorage ────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — width localStorage", () => {
|
||||
it("does not persist default width to localStorage on initial mount (only on user resize)", () => {
|
||||
render(<SidePanel />);
|
||||
// localStorage is only written by the keyboard resize handler, not on mount
|
||||
expect(localStorage.getItem("molecule:sidepanel-width")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads saved width from localStorage", () => {
|
||||
localStorage.setItem("molecule:sidepanel-width", "600");
|
||||
const { container } = render(<SidePanel />);
|
||||
const panel = container.firstChild as HTMLElement;
|
||||
expect(panel.style.width).toBe("600px");
|
||||
});
|
||||
|
||||
it("caps saved width to default when below minimum", () => {
|
||||
localStorage.setItem("molecule:sidepanel-width", "100");
|
||||
const { container } = render(<SidePanel />);
|
||||
const panel = container.firstChild as HTMLElement;
|
||||
expect(panel.style.width).toBe("480px");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Offline status ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("SidePanel — offline status", () => {
|
||||
it("shows tier badge even when node is offline", () => {
|
||||
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, status: "offline" as const } }];
|
||||
render(<SidePanel />);
|
||||
// T2 appears in both header badge and meta pill — just confirm at least one exists
|
||||
const all = screen.getAllByText("T2");
|
||||
expect(all.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows 'offline' in the Status meta pill when node is offline", () => {
|
||||
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, status: "offline" as const } }];
|
||||
render(<SidePanel />);
|
||||
expect(screen.getByText("offline")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -3,56 +3,55 @@
|
||||
* Tests for Spinner component.
|
||||
*
|
||||
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
|
||||
*
|
||||
* NOTE: SVG elements use SVGAnimatedString for className (not a plain string),
|
||||
* so we use getAttribute("class") instead of className for assertions.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Spinner } from "../Spinner";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function getSvgClass(r: ReturnType<typeof render>): string {
|
||||
const svg = r.container.querySelector("svg");
|
||||
if (!svg) throw new Error("No SVG found");
|
||||
return svg.getAttribute("class") ?? "";
|
||||
}
|
||||
|
||||
describe("Spinner — size variants", () => {
|
||||
// Use getAttribute("class") instead of .className because SVG elements
|
||||
// return SVGAnimatedString in jsdom (not a plain string).
|
||||
it("renders with sm size class", () => {
|
||||
const r = render(<Spinner size="sm" />);
|
||||
expect(getSvgClass(r)).toContain("w-3");
|
||||
expect(getSvgClass(r)).toContain("h-3");
|
||||
const { container } = render(<Spinner size="sm" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
// SVG elements use SVGAnimatedString for className — use classList instead
|
||||
expect(svg!.classList.contains("w-3")).toBe(true);
|
||||
expect(svg!.classList.contains("h-3")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders with md size class (default)", () => {
|
||||
const r = render(<Spinner size="md" />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
const { container } = render(<Spinner size="md" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("w-4")).toBe(true);
|
||||
expect(svg?.classList.contains("h-4")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders with lg size class", () => {
|
||||
const r = render(<Spinner size="lg" />);
|
||||
expect(getSvgClass(r)).toContain("w-5");
|
||||
expect(getSvgClass(r)).toContain("h-5");
|
||||
const { container } = render(<Spinner size="lg" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("w-5")).toBe(true);
|
||||
expect(svg?.classList.contains("h-5")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to md size when no size prop given", () => {
|
||||
const r = render(<Spinner />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("w-4")).toBe(true);
|
||||
expect(svg?.classList.contains("h-4")).toBe(true);
|
||||
});
|
||||
|
||||
it("has aria-hidden=true so screen readers skip it", () => {
|
||||
const r = render(<Spinner />);
|
||||
const svg = r.container.querySelector("svg");
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("includes the motion-safe:animate-spin class for CSS animation", () => {
|
||||
expect(getSvgClass(render(<Spinner />))).toContain("motion-safe:animate-spin");
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("motion-safe:animate-spin")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders exactly one SVG element", () => {
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for TemplatePalette — the floating sidebar drawer.
|
||||
*
|
||||
* Covers:
|
||||
* - Toggle button aria-label (open / closed)
|
||||
* - Sidebar renders when open, hides when closed
|
||||
* - Sidebar header: "Templates" heading, subtitle
|
||||
* - Loading state
|
||||
* - Empty state ("No templates found")
|
||||
* - Template cards: name, description, tier badge, skill pills
|
||||
* - Deploy button calls deploy()
|
||||
* - Errors swallowed → empty state shown
|
||||
* - setTemplatePaletteOpen called on open/close
|
||||
* - OrgTemplatesSection rendered inside sidebar
|
||||
* - Import Agent Folder button in footer
|
||||
* - Refresh templates button in footer
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Hoisted mocks — vi.hoisted() so they're available when vi.mock runs ──────
|
||||
// IMPORTANT: use plain vi.fn() in the return object (NOT `const fn = vi.fn(); return { fn }`)
|
||||
const { mockDeploy, mockSetTemplatePaletteOpen, mockGet } = vi.hoisted(() => ({
|
||||
mockDeploy: vi.fn(),
|
||||
mockSetTemplatePaletteOpen: vi.fn(),
|
||||
mockGet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useTemplateDeploy", () => ({
|
||||
useTemplateDeploy: () => ({
|
||||
deploy: mockDeploy,
|
||||
deploying: null,
|
||||
error: null,
|
||||
modal: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: { setTemplatePaletteOpen: typeof mockSetTemplatePaletteOpen }) => unknown) =>
|
||||
selector({ setTemplatePaletteOpen: mockSetTemplatePaletteOpen })
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mockGet },
|
||||
}));
|
||||
|
||||
vi.mock("../OrgImportPreflightModal", () => ({
|
||||
OrgImportPreflightModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../ConfirmDialog", () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../Spinner", () => ({
|
||||
Spinner: () => <span data-testid="spinner" aria-hidden="true" />,
|
||||
}));
|
||||
|
||||
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
// ── Component import — after all mocks ──────────────────────────────────────
|
||||
import { TemplatePalette } from "../TemplatePalette";
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeploy.mockReset();
|
||||
mockSetTemplatePaletteOpen.mockReset();
|
||||
mockGet.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
const MOCK_TEMPLATES = [
|
||||
{
|
||||
id: "tmpl-1",
|
||||
name: "Software Engineer",
|
||||
description: "Best for writing code",
|
||||
tier: 1,
|
||||
skills: ["web-search", "read-file", "write-file"],
|
||||
},
|
||||
{
|
||||
id: "tmpl-2",
|
||||
name: "Researcher",
|
||||
description: "Deep research agent",
|
||||
tier: 2,
|
||||
skills: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Toggle button ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("TemplatePalette — toggle button", () => {
|
||||
it("has aria-label='Open template palette' when closed", () => {
|
||||
render(<TemplatePalette />);
|
||||
expect(screen.getByRole("button", { name: /open template palette/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has aria-label='Close template palette' when open", async () => {
|
||||
render(<TemplatePalette />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /close template palette/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking toggle opens sidebar", async () => {
|
||||
render(<TemplatePalette />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("heading", { name: "Templates" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking toggle again closes sidebar", async () => {
|
||||
render(<TemplatePalette />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /close template palette/i }));
|
||||
await flush();
|
||||
expect(screen.queryByRole("heading", { name: "Templates" })).toBeNull();
|
||||
});
|
||||
|
||||
it("calls setTemplatePaletteOpen(true) when opened", async () => {
|
||||
render(<TemplatePalette />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
|
||||
await flush();
|
||||
expect(mockSetTemplatePaletteOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("calls setTemplatePaletteOpen(false) when closed", async () => {
|
||||
render(<TemplatePalette />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
|
||||
await flush();
|
||||
mockSetTemplatePaletteOpen.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: /close template palette/i }));
|
||||
await flush();
|
||||
expect(mockSetTemplatePaletteOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sidebar content ───────────────────────────────────────────────────────
|
||||
|
||||
describe("TemplatePalette — sidebar", () => {
|
||||
async function openSidebar() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
it("shows 'Templates' heading", async () => {
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByRole("heading", { name: "Templates" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows subtitle 'Click to deploy a workspace'", async () => {
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByText(/click to deploy a workspace/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows loading state", async () => {
|
||||
mockGet.mockReturnValue(new Promise(() => {}));
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByTestId("spinner")).toBeTruthy();
|
||||
expect(screen.getByText(/loading/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no templates", async () => {
|
||||
mockGet.mockResolvedValue([]);
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByText(/no templates found/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders template cards", async () => {
|
||||
mockGet.mockResolvedValue(MOCK_TEMPLATES);
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByText("Software Engineer")).toBeTruthy();
|
||||
expect(screen.getByText("Researcher")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows template description", async () => {
|
||||
mockGet.mockResolvedValue(MOCK_TEMPLATES);
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByText(/best for writing code/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows tier badge on template card", async () => {
|
||||
mockGet.mockResolvedValue(MOCK_TEMPLATES);
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
// T1 appears in tier badge
|
||||
expect(screen.getAllByText("T1").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows up to 3 skill pills", async () => {
|
||||
mockGet.mockResolvedValue(MOCK_TEMPLATES);
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByText("web-search")).toBeTruthy();
|
||||
expect(screen.getByText("read-file")).toBeTruthy();
|
||||
expect(screen.getByText("write-file")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows '+N more' when more than 3 skills", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
{ id: "tmpl-many", name: "Full Stack", description: "", tier: 1, skills: ["a", "b", "c", "d", "e"] },
|
||||
]);
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByText("+2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deploy button calls deploy(t)", async () => {
|
||||
mockGet.mockResolvedValue(MOCK_TEMPLATES);
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
const deployBtns = screen.getAllByRole("button", { name: /software engineer/i });
|
||||
await act(async () => { deployBtns[0].click(); });
|
||||
expect(mockDeploy).toHaveBeenCalledWith(MOCK_TEMPLATES[0]);
|
||||
});
|
||||
|
||||
it("shows empty state when api.get rejects (error is swallowed)", async () => {
|
||||
mockGet.mockRejectedValue(new Error("server error"));
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no templates found/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders OrgTemplatesSection inside sidebar", async () => {
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(document.querySelector("[data-testid='org-templates-section']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Import Agent Folder button in footer", async () => {
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByRole("button", { name: /import agent folder/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Refresh templates button in footer", async () => {
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByRole("button", { name: /^refresh templates$/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -189,49 +189,6 @@ describe("TermsGate — accept flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TermsGate — I agree button accessibility", () => {
|
||||
it("shows ellipsis on the I agree button while POST is in flight", async () => {
|
||||
// Deferred POST so we can control when it resolves and observe the
|
||||
// mid-flight button state without fake timers.
|
||||
let resolvePost: (r: Response) => void;
|
||||
const postDeferred = new Promise<Response>((r) => { resolvePost = r; });
|
||||
// Intercept: terms-status → pending (first fetch), POST deferred (second).
|
||||
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
|
||||
vi.spyOn(global, "fetch").mockImplementation(
|
||||
() => postDeferred as unknown as Promise<Response>
|
||||
);
|
||||
|
||||
render(<TermsGate><div>App content</div></TermsGate>);
|
||||
await waitFor(() => screen.getByRole("dialog"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
|
||||
|
||||
// Ellipsis replaces "I agree" while POST is in flight
|
||||
expect(screen.queryByRole("button", { name: /i agree/i })).toBeNull();
|
||||
expect(screen.getAllByRole("button").some((b) => b.textContent === "…")).toBeTruthy();
|
||||
|
||||
act(() => { resolvePost!(new Response("ok", { status: 200 })); });
|
||||
});
|
||||
|
||||
it("has aria-disabled while submitting", async () => {
|
||||
let resolvePost: (r: Response) => void;
|
||||
const postDeferred = new Promise<Response>((r) => { resolvePost = r; });
|
||||
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
|
||||
vi.spyOn(global, "fetch").mockImplementation(
|
||||
() => postDeferred as unknown as Promise<Response>
|
||||
);
|
||||
|
||||
render(<TermsGate><div>App content</div></TermsGate>);
|
||||
await waitFor(() => screen.getByRole("dialog"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
|
||||
|
||||
// Find the ellipsis button and check aria-disabled
|
||||
const ellipsisBtn = screen.getAllByRole("button").find((b) => b.textContent === "…");
|
||||
expect(ellipsisBtn?.getAttribute("aria-disabled")).toBe("true");
|
||||
|
||||
act(() => { resolvePost!(new Response("ok", { status: 200 })); });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TermsGate — error state", () => {
|
||||
it("shows an error alert when terms-status fetch fails with non-401", async () => {
|
||||
mockFetch(new Response("Gateway Timeout", { status: 504 }));
|
||||
|
||||
@@ -255,32 +255,6 @@ describe("Toolbar — Help popover", () => {
|
||||
fireEvent.click(closeBtn);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes when pointer is pressed outside the help popover", () => {
|
||||
render(<Toolbar />);
|
||||
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
|
||||
fireEvent.click(helpBtn);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// Simulate pointerdown outside the help popover (not on the help button)
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens on click even after a previous pointer-outside close", () => {
|
||||
// Regression: clicking outside closed the popover AND toggled the button
|
||||
// state, so the next click on the button would close it again.
|
||||
// The fix makes the button always open (never toggle) so re-opening works.
|
||||
render(<Toolbar />);
|
||||
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
|
||||
fireEvent.click(helpBtn);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// Click outside (pointerdown on body, not on help button)
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
// Click the help button again — must re-open, not double-close
|
||||
fireEvent.click(helpBtn);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toolbar — A2A edges toggle", () => {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DropTargetBadge() {
|
||||
)}
|
||||
<div
|
||||
data-testid="drop-badge"
|
||||
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
|
||||
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
|
||||
style={{ left: badge.x, top: badge.y - 6 }}
|
||||
>
|
||||
Drop into: {targetName}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* TopBar — canvas header scaffold with logo, canvas name, New Agent button,
|
||||
* and SettingsButton integration point.
|
||||
*
|
||||
* Coverage:
|
||||
* - Renders header with logo and canvas name (default and custom)
|
||||
* - New Agent button present and clickable
|
||||
* - SettingsButton rendered (via mock)
|
||||
* - Ref forwarding wired (settingsGearRef passed as ref prop)
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { TopBar } from "../TopBar";
|
||||
|
||||
vi.mock("@/components/settings/SettingsButton", () => ({
|
||||
SettingsButton: React.forwardRef<HTMLButtonElement, object>(
|
||||
(_props, ref) => <button ref={ref} aria-label="Settings" type="button">⚙</button>,
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TopBar — render", () => {
|
||||
it("renders the header element", () => {
|
||||
render(<TopBar />);
|
||||
const header = document.querySelector("header");
|
||||
expect(header).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows default canvas name 'Canvas'", () => {
|
||||
render(<TopBar />);
|
||||
expect(document.body.textContent).toContain("Canvas");
|
||||
});
|
||||
|
||||
it("shows custom canvas name when provided", () => {
|
||||
render(<TopBar canvasName="Production Canvas" />);
|
||||
expect(document.body.textContent).toContain("Production Canvas");
|
||||
expect(document.body.textContent).not.toContain("Canvas\n"); // not default
|
||||
});
|
||||
|
||||
it("renders New Agent button", () => {
|
||||
render(<TopBar />);
|
||||
const btn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("New Agent"),
|
||||
);
|
||||
expect(btn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders SettingsButton", () => {
|
||||
render(<TopBar />);
|
||||
const settingsBtn = document.querySelector('button[aria-label="Settings"]');
|
||||
expect(settingsBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders logo icon", () => {
|
||||
render(<TopBar />);
|
||||
const logo = Array.from(document.querySelectorAll("span")).find(
|
||||
(s) => s.getAttribute("aria-hidden") === "true",
|
||||
);
|
||||
expect(logo).toBeTruthy();
|
||||
expect(logo?.textContent).toContain("☁");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TopBar — interaction", () => {
|
||||
it("New Agent button is in the DOM and not disabled", () => {
|
||||
render(<TopBar />);
|
||||
const btn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("New Agent"),
|
||||
);
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn!.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders without crashing with empty canvasName", () => {
|
||||
render(<TopBar canvasName="" />);
|
||||
expect(document.querySelector("header")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders without crashing with long canvasName", () => {
|
||||
const longName = "A".repeat(200);
|
||||
render(<TopBar canvasName={longName} />);
|
||||
expect(document.body.textContent).toContain(longName);
|
||||
});
|
||||
});
|
||||
@@ -1,389 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for buildDeployMap — the pure tree-computation core inside
|
||||
* useOrgDeployState.
|
||||
*
|
||||
* Issue: #742 (buildDeployMap unit tests, #2071 follow-up).
|
||||
*
|
||||
* The function takes a flat list of NodeProjections and a set of
|
||||
* deletingIds, then computes per-node OrgDeployState:
|
||||
* isActivelyProvisioning — node itself is provisioning
|
||||
* isDeployingRoot — node is a root AND has provisioning descendants
|
||||
* isLockedChild — node is a deleting child OR a non-root in a deploying tree
|
||||
* descendantProvisioningCount — total provisioning descendants (roots only)
|
||||
*
|
||||
* Coverage:
|
||||
* §1 Empty input
|
||||
* §2 Single node — no parent, non-provisioning
|
||||
* §3 Single node — no parent, provisioning
|
||||
* §4 Single node — has parent (parent exists)
|
||||
* §5 Parent not in projections → node treated as root
|
||||
* §6 Two nodes: root (non-provisioning) + child
|
||||
* §7 Two nodes: root (provisioning) + child
|
||||
* §8 Three-level tree: grandparent (provisioning) → parent → child
|
||||
* §9 DeletingIds contains a non-root node → isLockedChild=true
|
||||
* §10 DeletingIds contains the root → root isLockedChild=true
|
||||
* §11 Two independent roots, one provisioning
|
||||
* §12 Provisioning count: root has 2 provisioning descendants
|
||||
* §13 Non-root node with provisioning status → isActivelyProvisioning=true
|
||||
* §14 findRoot memoization: repeated calls don't re-walk the chain
|
||||
* §15 deletingIds + provisioning interact: deleting takes isLockedChild
|
||||
* §16 Child of provisioning root (not itself provisioning) → isLockedChild=true
|
||||
* §17 Deep chain (5 levels), no provisioning → all nodes unlocked
|
||||
* §18 Deep chain (5 levels), middle node is provisioning root
|
||||
* §19 Node with parentId pointing to non-existent node → treated as root
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDeployMap } from "../useOrgDeployState";
|
||||
import type { OrgDeployState } from "../useOrgDeployState";
|
||||
|
||||
type Projection = { id: string; parentId: string | null; status: string };
|
||||
|
||||
function proj(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
status = "idle",
|
||||
): Projection {
|
||||
return { id, parentId, status };
|
||||
}
|
||||
|
||||
// expected maps node-id → partial state (includes `id` as a key)
|
||||
function check(
|
||||
projections: Projection[],
|
||||
deletingIds: string[],
|
||||
expected: Record<string, Partial<OrgDeployState>>,
|
||||
): void {
|
||||
const result = buildDeployMap(projections, new Set(deletingIds));
|
||||
expect(result.size).toBe(projections.length);
|
||||
for (const [id, state] of result.entries()) {
|
||||
if (id in expected) {
|
||||
expect(state).toMatchObject(expected[id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── §1–§5: Basic structure ──────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — basic structure (§1–§5)", () => {
|
||||
it("§1 returns an empty map when projections is empty", () => {
|
||||
const result = buildDeployMap([], new Set());
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("§2 single node, no parent, non-provisioning → unlocked root", () => {
|
||||
check([proj("a")], [], {
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("§3 single provisioning node → deploying root", () => {
|
||||
check([proj("a", null, "provisioning")], [], {
|
||||
isActivelyProvisioning: true,
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("§4 single node with existing parent → non-root, unlocked", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{
|
||||
id: "child",
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("§5 parentId points to a node not in projections → treated as root", () => {
|
||||
// "orphan" is a root because its parent is absent from the projection list.
|
||||
check([proj("orphan", "ghost", "idle")], [], {
|
||||
id: "orphan",
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §6–§8: Multi-node trees ───────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — multi-node trees (§6–§8)", () => {
|
||||
it("§6 root (non-provisioning) + child → root not deploying, child unlocked", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{ id: "root", isDeployingRoot: false, isLockedChild: false },
|
||||
);
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{ id: "child", isLockedChild: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("§7 root (provisioning) + child → root deploying, child locked", () => {
|
||||
check(
|
||||
[proj("root", null, "provisioning"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{
|
||||
id: "root",
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
},
|
||||
);
|
||||
check(
|
||||
[proj("root", null, "provisioning"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("§8 three-level tree: grandparent (provisioning) → parent → child", () => {
|
||||
check(
|
||||
[
|
||||
proj("grandparent", null, "provisioning"),
|
||||
proj("parent", "grandparent", "idle"),
|
||||
proj("child", "parent", "idle"),
|
||||
],
|
||||
[],
|
||||
{
|
||||
id: "grandparent",
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
},
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("grandparent", null, "provisioning"),
|
||||
proj("parent", "grandparent", "idle"),
|
||||
proj("child", "parent", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "parent", isLockedChild: true },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("grandparent", null, "provisioning"),
|
||||
proj("parent", "grandparent", "idle"),
|
||||
proj("child", "parent", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §9–§11: DeletingIds + independent roots ──────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — deletingIds + independent roots (§9–§11)", () => {
|
||||
it("§9 deletingIds contains a non-root → isLockedChild=true", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
["child"],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("§10 deletingIds contains the root → root isLockedChild=true, child unlocked", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
["root"],
|
||||
{ id: "root", isLockedChild: true, isDeployingRoot: false },
|
||||
);
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
["root"],
|
||||
{ id: "child", isLockedChild: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("§11 two independent roots, only one is provisioning", () => {
|
||||
check(
|
||||
[
|
||||
proj("rootA", null, "idle"),
|
||||
proj("rootB", null, "provisioning"),
|
||||
],
|
||||
[],
|
||||
{ id: "rootA", isDeployingRoot: false, descendantProvisioningCount: 0 },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("rootA", null, "idle"),
|
||||
proj("rootB", null, "provisioning"),
|
||||
],
|
||||
[],
|
||||
{ id: "rootB", isDeployingRoot: true, descendantProvisioningCount: 1 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §12–§15: Provisioning counts + interactions ─────────────────────────────
|
||||
|
||||
describe("buildDeployMap — provisioning counts + interactions (§12–§15)", () => {
|
||||
it("§12 root has 2 provisioning descendants → descendantProvisioningCount=2", () => {
|
||||
check(
|
||||
[
|
||||
proj("root", null, "idle"),
|
||||
proj("prov1", "root", "provisioning"),
|
||||
proj("prov2", "root", "provisioning"),
|
||||
proj("idle", "root", "idle"),
|
||||
],
|
||||
[],
|
||||
{
|
||||
id: "root",
|
||||
isDeployingRoot: true,
|
||||
descendantProvisioningCount: 2,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("§13 non-root node with provisioning status → isActivelyProvisioning=true", () => {
|
||||
check(
|
||||
[
|
||||
proj("root", null, "idle"),
|
||||
proj("provChild", "root", "provisioning"),
|
||||
],
|
||||
[],
|
||||
{
|
||||
id: "provChild",
|
||||
isActivelyProvisioning: true,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("§14 findRoot memoization: chain is only walked once per root", () => {
|
||||
// Indirect verification: a 3-level tree should return consistent rootIds
|
||||
// for all nodes without throwing or producing stale entries.
|
||||
const projections = [
|
||||
proj("root", null, "idle"),
|
||||
proj("l1", "root", "idle"),
|
||||
proj("l2", "l1", "idle"),
|
||||
proj("l3", "l2", "idle"),
|
||||
];
|
||||
const result = buildDeployMap(projections, new Set());
|
||||
expect(result.get("root")?.isDeployingRoot).toBe(false);
|
||||
expect(result.get("l1")?.isLockedChild).toBe(false);
|
||||
expect(result.get("l2")?.isLockedChild).toBe(false);
|
||||
expect(result.get("l3")?.isLockedChild).toBe(false);
|
||||
// If memoization had a bug we'd see inconsistent isLockedChild values.
|
||||
});
|
||||
|
||||
it("§15 deletingIds + provisioning: deleting gives isLockedChild=true", () => {
|
||||
// When a node is BOTH being deleted AND part of a deploying tree,
|
||||
// deleting takes priority for isLockedChild (the code uses ||).
|
||||
check(
|
||||
[
|
||||
proj("root", null, "provisioning"),
|
||||
proj("provChild", "root", "idle"),
|
||||
],
|
||||
["provChild"],
|
||||
{ id: "provChild", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §16–§19: Deeper tree + edge cases ────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — deep trees + edge cases (§16–§19)", () => {
|
||||
it("§16 child of provisioning root (not itself provisioning) → isLockedChild=true", () => {
|
||||
check(
|
||||
[
|
||||
proj("root", null, "provisioning"),
|
||||
proj("child", "root", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("§17 deep chain (5 levels), no provisioning → all nodes unlocked", () => {
|
||||
const deep = [
|
||||
proj("n1", null, "idle"),
|
||||
proj("n2", "n1", "idle"),
|
||||
proj("n3", "n2", "idle"),
|
||||
proj("n4", "n3", "idle"),
|
||||
proj("n5", "n4", "idle"),
|
||||
];
|
||||
const result = buildDeployMap(deep, new Set());
|
||||
expect(result.get("n1")?.isDeployingRoot).toBe(false);
|
||||
expect(result.get("n1")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n2")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n3")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n4")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n5")?.isLockedChild).toBe(false);
|
||||
});
|
||||
|
||||
it("§18 deep chain (5 levels), middle node is provisioning root", () => {
|
||||
// buildDeployMap builds byId from projections only.
|
||||
// findRoot walks the parent chain: n3.findRoot() → n3→n2→n1 → n1.parentId
|
||||
// absent from byId → rootId=n1 for ALL nodes.
|
||||
// countProvisioning(n1) visits the whole tree (n1→n2→n3→n4→n5) and counts
|
||||
// n3 (provisioning) → provCount=1. n1 is the sole deploying root.
|
||||
// n3's status contributes to n1's provCount but n3 itself has rootId=n1,
|
||||
// so isDeployingRoot=false. All non-root nodes are isLockedChild=true.
|
||||
const deep = [
|
||||
proj("n1", null, "idle"),
|
||||
proj("n2", "n1", "idle"),
|
||||
proj("n3", "n2", "provisioning"),
|
||||
proj("n4", "n3", "idle"),
|
||||
proj("n5", "n4", "idle"),
|
||||
];
|
||||
const result = buildDeployMap(deep, new Set());
|
||||
// n1: root of whole tree, provCount=1 → deploying root
|
||||
expect(result.get("n1")?.isDeployingRoot).toBe(true);
|
||||
expect(result.get("n1")?.isLockedChild).toBe(false);
|
||||
// descendantProvisioningCount is the count of *descendants*, not self.
|
||||
// n1 itself is idle, so count=1 (n3).
|
||||
expect(result.get("n1")?.descendantProvisioningCount).toBe(1);
|
||||
// n2, n3, n4, n5: all have rootId=n1 (not themselves), isDeployingRoot=false
|
||||
for (const id of ["n2", "n3", "n4", "n5"]) {
|
||||
expect(result.get(id)?.isDeployingRoot).toBe(false);
|
||||
expect(result.get(id)?.isLockedChild).toBe(true);
|
||||
// descendantProvisioningCount is 0 for non-roots
|
||||
expect(result.get(id)?.descendantProvisioningCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("§19 parentId pointing to non-existent node → treated as root", () => {
|
||||
// Same node appears both as a child of a ghost parent AND as a parent of a real child.
|
||||
// When the ghost parent is absent, node2 is a root.
|
||||
check(
|
||||
[
|
||||
proj("node1", "ghost", "idle"),
|
||||
proj("node2", null, "idle"),
|
||||
proj("node3", "node2", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "node1", isDeployingRoot: true },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("node1", "ghost", "idle"),
|
||||
proj("node2", null, "idle"),
|
||||
proj("node3", "node2", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "node2", isDeployingRoot: true },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("node1", "ghost", "idle"),
|
||||
proj("node2", null, "idle"),
|
||||
proj("node3", "node2", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "node3", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,20 @@ describe("Esc — deselect / close context menu", () => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
mockStoreState.contextMenu = null;
|
||||
mockStoreState.selectedNodeId = "n1";
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockStoreState.clearSelection).not.toHaveBeenCalled();
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enter — hierarchy navigation", () => {
|
||||
@@ -136,6 +150,17 @@ describe("Enter — hierarchy navigation", () => {
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cmd+]/[ — z-order bump", () => {
|
||||
@@ -160,6 +185,17 @@ describe("Cmd+]/[ — z-order bump", () => {
|
||||
fireEvent.keyDown(window, { key: "]", ctrlKey: true });
|
||||
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "]", metaKey: true });
|
||||
expect(mockStoreState.bumpZOrder).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Z — zoom-to-team", () => {
|
||||
@@ -212,6 +248,17 @@ describe("Z — zoom-to-team", () => {
|
||||
expect(dispatchedEvents).toHaveLength(0);
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "z" });
|
||||
expect(dispatchedEvents).toHaveLength(0);
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Arrow keys — keyboard node movement", () => {
|
||||
|
||||
@@ -13,7 +13,9 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
/**
|
||||
* Canvas-wide keyboard shortcuts. All bound to the document window so
|
||||
* they work regardless of focused node, except when the user is typing
|
||||
* into an input (`inInput` short-circuits handling).
|
||||
* into an input (`inInput` short-circuits handling) or a modal dialog is
|
||||
* open (`isModalOpen` short-circuits handling — dialogs own their own
|
||||
* keyboard semantics and take precedence).
|
||||
*
|
||||
* Esc — close context menu, clear selection, deselect
|
||||
* Enter — descend into selected node's first child
|
||||
@@ -25,6 +27,10 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
* Cmd/Ctrl+Arrow — resize selected node (↑↓ height, ←→ width)
|
||||
* Cmd/Ctrl+Shift+Arrow — resize by 2px per press (fine control)
|
||||
*/
|
||||
/** Returns true when a modal dialog (role=dialog, aria-modal=true) is open. */
|
||||
const isModalOpen = () =>
|
||||
document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -36,6 +42,7 @@ export function useKeyboardShortcuts() {
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (isModalOpen()) return; // Dialogs own their own Escape semantics
|
||||
const state = useCanvasStore.getState();
|
||||
if (state.contextMenu) {
|
||||
state.closeContextMenu();
|
||||
@@ -47,8 +54,9 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
|
||||
// Figma-style hierarchy navigation. Skipped when the user is
|
||||
// typing so Enter can still submit forms.
|
||||
if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) {
|
||||
// typing so Enter can still submit forms, and when a dialog is open
|
||||
// so the dialog can use Enter for its own actions.
|
||||
if (!inInput && !isModalOpen() && (e.key === "Enter" || e.key === "NumpadEnter")) {
|
||||
e.preventDefault();
|
||||
const state = useCanvasStore.getState();
|
||||
const id = state.selectedNodeId;
|
||||
@@ -63,6 +71,9 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip when a modal is open so dialog shortcuts take precedence.
|
||||
if (isModalOpen()) return;
|
||||
|
||||
if (
|
||||
!inInput &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
@@ -111,7 +122,7 @@ export function useKeyboardShortcuts() {
|
||||
if (!selectedId) return;
|
||||
// Skip when a modal/dialog is already open — dialogs own their own
|
||||
// arrow-key semantics and shouldn't trigger canvas moves.
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
if (isModalOpen()) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 50 : 10;
|
||||
let dx = 0;
|
||||
@@ -138,7 +149,7 @@ export function useKeyboardShortcuts() {
|
||||
const state = useCanvasStore.getState();
|
||||
const selectedId = state.selectedNodeId;
|
||||
if (!selectedId) return;
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
if (isModalOpen()) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 2 : 10;
|
||||
const node = state.nodes.find((n) => n.id === selectedId);
|
||||
|
||||
@@ -40,7 +40,7 @@ interface NodeProjection {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function buildDeployMap(
|
||||
function buildDeployMap(
|
||||
projections: NodeProjection[],
|
||||
deletingIds: ReadonlySet<string>,
|
||||
): Map<string, OrgDeployState> {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { MobileMe } from "./MobileMe";
|
||||
import { MobileSpawn } from "./MobileSpawn";
|
||||
import { usePalette } from "./palette";
|
||||
import { MobileAccentProvider } from "./palette-context";
|
||||
import { SearchDialog } from "@/components/SearchDialog";
|
||||
|
||||
type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me";
|
||||
|
||||
@@ -205,8 +204,6 @@ export function MobileApp() {
|
||||
{showTabBar && <TabBar dark={dark} active={activeTab} onChange={onTabChange} />}
|
||||
|
||||
{showSpawn && <MobileSpawn dark={dark} onClose={() => setShowSpawn(false)} />}
|
||||
|
||||
<SearchDialog />
|
||||
</main>
|
||||
</MobileAccentProvider>
|
||||
);
|
||||
|
||||
@@ -54,9 +54,11 @@ export function MobileChat({
|
||||
// user sees their prior thread on entry. The store is updated by the
|
||||
// socket → ChatTab flows the desktop runs; on mobile we read from the
|
||||
// same buffer to keep state coherent across viewports.
|
||||
// NOTE: selector returns undefined (stable) — do NOT use ?? [] here,
|
||||
// that creates a new [] reference on every store update when the key is
|
||||
// absent, causing infinite re-render (React error #185).
|
||||
// NOTE: do NOT use `?? []` in the selector — Zustand uses Object.is
|
||||
// for selector equality. A fallback `?? []` creates a new [] reference on
|
||||
// every store update when agentMessages[agentId] is undefined, causing an
|
||||
// infinite re-render loop (React error #185 / Maximum update depth
|
||||
// exceeded). The undefined case is handled by the initializer below.
|
||||
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() =>
|
||||
(storedMessages ?? []).map((m) => ({
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MobileChat — mobile message thread + composer + sub-tabs.
|
||||
*
|
||||
* Per spec §04: wired to /workspaces/:id/a2a (method message/send).
|
||||
* Slimmer surface than desktop ChatTab: no attachments, no topology overlay.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { MobileChat } from "../MobileChat";
|
||||
|
||||
// ─── Mock store ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockAgentId = "ws-chat-test";
|
||||
const mockOnBack = vi.fn();
|
||||
|
||||
// Module-level mutable state for the mock store.
|
||||
const mockStoreState = {
|
||||
nodes: [] as Array<{
|
||||
id: string;
|
||||
position: { x: number; y: number };
|
||||
data: Record<string, unknown>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>,
|
||||
agentMessages: {} as Record<string, Array<{ id: string; content: string; timestamp: string }>>,
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((sel) => sel(mockStoreState)),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
|
||||
const agentCard = data.agentCard as Record<string, unknown> | null;
|
||||
const skills = Array.isArray(agentCard?.skills)
|
||||
? (agentCard.skills as Array<Record<string, unknown>>).map(
|
||||
(s) => String(s.name || s.id || ""),
|
||||
).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
runtime: (typeof data.runtime === "string" && data.runtime)
|
||||
? data.runtime
|
||||
: (typeof agentCard?.runtime === "string" ? String(agentCard.runtime) : null),
|
||||
skills,
|
||||
skillCount: skills.length,
|
||||
currentTask: String(data.currentTask ?? ""),
|
||||
hasActiveTask: String(data.currentTask ?? "").trim().length > 0,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Mock API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { mockApiPost } = vi.hoisted(() => ({
|
||||
mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { post: mockApiPost },
|
||||
}));
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const onlineNode = {
|
||||
id: mockAgentId,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
name: "Chat Agent",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
agentCard: {
|
||||
runtime: "claude-code",
|
||||
skills: [{ name: "web-search" }],
|
||||
},
|
||||
currentTask: "",
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "",
|
||||
parentId: null,
|
||||
runtime: "claude-code",
|
||||
needsRestart: false,
|
||||
},
|
||||
};
|
||||
|
||||
const offlineNode = {
|
||||
id: "ws-offline",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
name: "Offline Agent",
|
||||
status: "offline",
|
||||
tier: 1,
|
||||
agentCard: null,
|
||||
currentTask: "",
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "",
|
||||
parentId: null,
|
||||
runtime: "claude-code",
|
||||
needsRestart: false,
|
||||
},
|
||||
};
|
||||
|
||||
const degradedNode = {
|
||||
id: "ws-degraded",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
name: "Degraded Agent",
|
||||
status: "degraded",
|
||||
tier: 3,
|
||||
agentCard: null,
|
||||
currentTask: "",
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "",
|
||||
parentId: null,
|
||||
runtime: "claude-code",
|
||||
needsRestart: false,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderChat(agentId: string, dark = false) {
|
||||
return render(
|
||||
<MobileChat
|
||||
agentId={agentId}
|
||||
dark={dark}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Setup / teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnBack.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.agentMessages = {};
|
||||
mockApiPost.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── Not found ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileChat — agent not found", () => {
|
||||
it('renders "Agent not found." when node is absent', () => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
const { container } = renderChat("nonexistent-id");
|
||||
expect(container.textContent ?? "").toContain("Agent not found.");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Header ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileChat — header", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("renders Back button with aria-label", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const backBtn = container.querySelector('[aria-label="Back"]');
|
||||
expect(backBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Back button calls onBack", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const backBtn = container.querySelector('[aria-label="Back"]') as HTMLButtonElement;
|
||||
backBtn.click();
|
||||
expect(mockOnBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders agent name in header", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
expect(container.textContent ?? "").toContain("Chat Agent");
|
||||
});
|
||||
|
||||
it("renders a More button", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const moreBtn = container.querySelector('[aria-label="More"]');
|
||||
expect(moreBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders footer with agentId", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
expect(container.textContent ?? "").toContain(mockAgentId);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Composer ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileChat — composer", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("renders a textarea for message input", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const textarea = container.querySelector("textarea");
|
||||
expect(textarea).toBeTruthy();
|
||||
});
|
||||
|
||||
it("textarea has placeholder text", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
|
||||
expect(textarea.placeholder).toBeTruthy();
|
||||
expect(textarea.placeholder).toContain("Send a message");
|
||||
});
|
||||
|
||||
it("renders a Send button with aria-label", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const sendBtn = container.querySelector('[aria-label="Send"]');
|
||||
expect(sendBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Send button is disabled when textarea is empty (no draft)", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement;
|
||||
expect(sendBtn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileChat — tabs", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("renders My Chat and Agent Comms tab labels", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("My Chat");
|
||||
expect(text).toContain("Agent Comms");
|
||||
});
|
||||
|
||||
it("defaults to My Chat tab", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
// My Chat is the default; if there are no messages it should show the empty state
|
||||
expect(container.textContent ?? "").toContain("My Chat");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Empty state ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileChat — empty state", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it('shows "Send a message to start chatting." when no messages', () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
});
|
||||
|
||||
it("shows no messages when agentMessages[agentId] is absent (undefined)", () => {
|
||||
// Explicitly set to empty to simulate no stored messages
|
||||
mockStoreState.agentMessages = {};
|
||||
const { container } = renderChat(mockAgentId);
|
||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Agent status ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileChat — agent status", () => {
|
||||
it("renders composer for online agent", () => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
const { container } = renderChat(mockAgentId);
|
||||
expect(container.querySelector("textarea")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders composer for offline agent (with status text)", () => {
|
||||
mockStoreState.nodes = [offlineNode];
|
||||
const { container } = renderChat("ws-offline");
|
||||
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
|
||||
// Offline agent: textarea should be disabled
|
||||
expect(textarea.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders composer for degraded agent", () => {
|
||||
mockStoreState.nodes = [degradedNode];
|
||||
const { container } = renderChat("ws-degraded");
|
||||
expect(container.querySelector("textarea")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offline agent shows agent name", () => {
|
||||
mockStoreState.nodes = [offlineNode];
|
||||
const { container } = renderChat("ws-offline");
|
||||
expect(container.textContent ?? "").toContain("Offline Agent");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dark mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileChat — dark mode", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("renders without crashing in dark mode", () => {
|
||||
const { container } = renderChat(mockAgentId, true);
|
||||
expect(container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,367 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MobileDetail — agent detail page with tabbed content (Overview/Activity/Config/Memory).
|
||||
*
|
||||
* Per spec §03: tabbed agent detail page. MobileChat (MR !717) was also tested here.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { MobileDetail } from "../MobileDetail";
|
||||
|
||||
// ─── Mock store ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockNodeId = "ws-detail-test";
|
||||
const mockOnBack = vi.fn();
|
||||
const mockOnChat = vi.fn();
|
||||
|
||||
// Module-level mutable state for the mock store.
|
||||
// Tests mutate this between cases to control what the component sees.
|
||||
const mockStoreState = {
|
||||
nodes: [] as Array<{
|
||||
id: string;
|
||||
position: { x: number; y: number };
|
||||
data: Record<string, unknown>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>,
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((sel) => sel(mockStoreState)),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
|
||||
const agentCard = data.agentCard as Record<string, unknown> | null;
|
||||
const skills = Array.isArray(agentCard?.skills)
|
||||
? (agentCard.skills as Array<Record<string, unknown>>).map(
|
||||
(s) => String(s.name || s.id || ""),
|
||||
).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
runtime: (typeof data.runtime === "string" && data.runtime)
|
||||
? data.runtime
|
||||
: (typeof agentCard?.runtime === "string" ? String(agentCard.runtime) : null),
|
||||
skills,
|
||||
skillCount: skills.length,
|
||||
currentTask: String(data.currentTask ?? ""),
|
||||
hasActiveTask: String(data.currentTask ?? "").trim().length > 0,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stub the API so DetailActivity doesn't attempt real network calls.
|
||||
vi.mock("@/lib/api", () => ({ api: { get: vi.fn().mockResolvedValue([]) } }));
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const onlineNode = {
|
||||
id: mockNodeId,
|
||||
position: { x: 100, y: 200 },
|
||||
data: {
|
||||
name: "Test Agent",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
agentCard: {
|
||||
runtime: "claude-code",
|
||||
skills: [
|
||||
{ name: "web-search", id: "skill-1" },
|
||||
{ name: "code-review", id: "skill-2" },
|
||||
{ name: "file-ops", id: "skill-3" },
|
||||
],
|
||||
},
|
||||
currentTask: "Reviewing PR #717",
|
||||
activeTasks: 3,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "",
|
||||
parentId: null,
|
||||
runtime: "claude-code",
|
||||
needsRestart: false,
|
||||
},
|
||||
width: 240,
|
||||
height: 130,
|
||||
};
|
||||
|
||||
const failedNode = {
|
||||
id: "ws-failed",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
name: "Failed Worker",
|
||||
status: "failed",
|
||||
tier: 4,
|
||||
agentCard: null,
|
||||
currentTask: "",
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0.8,
|
||||
lastSampleError: "Connection refused",
|
||||
url: "",
|
||||
parentId: null,
|
||||
runtime: "external",
|
||||
needsRestart: false,
|
||||
},
|
||||
};
|
||||
|
||||
const offlineNode = {
|
||||
id: "ws-offline",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
name: "Offline Bot",
|
||||
status: "offline",
|
||||
tier: 1,
|
||||
agentCard: null,
|
||||
currentTask: "",
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "",
|
||||
parentId: null,
|
||||
runtime: "claude-code",
|
||||
needsRestart: false,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderDetail(agentId: string, dark = false) {
|
||||
return render(
|
||||
<MobileDetail
|
||||
agentId={agentId}
|
||||
dark={dark}
|
||||
onBack={mockOnBack}
|
||||
onChat={mockOnChat}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Setup / teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnBack.mockClear();
|
||||
mockOnChat.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── Not found ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileDetail — agent not found", () => {
|
||||
it('renders "Agent not found." when no node matches agentId', () => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
const { container } = renderDetail("nonexistent-id");
|
||||
expect(container.textContent ?? "").toContain("Agent not found.");
|
||||
});
|
||||
|
||||
it("does not render any tab buttons when agent not found", () => {
|
||||
mockStoreState.nodes = [];
|
||||
const { container } = renderDetail("ghost-agent");
|
||||
expect(container.querySelectorAll("button").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hero render ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileDetail — hero section", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("renders the agent name as an h1", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
const h1 = container.querySelector("h1");
|
||||
expect(h1).toBeTruthy();
|
||||
expect(h1!.textContent).toBe("Test Agent");
|
||||
});
|
||||
|
||||
it("renders agent tag below the name", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
// Tag appears in the hero section, styled differently from the name
|
||||
expect(container.textContent ?? "").toContain("claude-code");
|
||||
});
|
||||
|
||||
it("renders a Back button with aria-label", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
const backBtn = container.querySelector('[aria-label="Back"]');
|
||||
expect(backBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Back button calls onBack", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
const backBtn = container.querySelector('[aria-label="Back"]') as HTMLButtonElement;
|
||||
backBtn.click();
|
||||
expect(mockOnBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders a More button", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
const moreBtn = container.querySelector('[aria-label="More"]');
|
||||
expect(moreBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Chat CTA with icon text", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
expect(container.textContent ?? "").toContain("Open chat");
|
||||
});
|
||||
|
||||
it("Chat CTA calls onChat", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
const chatBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Open chat"),
|
||||
);
|
||||
expect(chatBtn).toBeTruthy();
|
||||
(chatBtn as HTMLButtonElement).click();
|
||||
expect(mockOnChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Pill stats ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileDetail — pill stats", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("renders TIER pill with the agent tier", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
expect(container.textContent ?? "").toContain("TIER");
|
||||
});
|
||||
|
||||
it("renders RUNTIME pill", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
expect(container.textContent ?? "").toContain("RUNTIME");
|
||||
});
|
||||
|
||||
it("renders SKILLS pill with count", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
// 3 skills in the agentCard fixture
|
||||
expect(container.textContent ?? "").toContain("SKILLS");
|
||||
});
|
||||
|
||||
it("renders STATUS pill", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
expect(container.textContent ?? "").toContain("STATUS");
|
||||
});
|
||||
|
||||
it("STATUS pill shows agent status value", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
// online status from the fixture
|
||||
expect(container.textContent ?? "").toContain("online");
|
||||
});
|
||||
|
||||
it("renders all 4 pills for online agent", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
// Count the pill container divs — each PillStat is a div with specific inline styles
|
||||
// We verify by content: TIER, RUNTIME, SKILLS, STATUS should all be present
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("TIER");
|
||||
expect(text).toContain("RUNTIME");
|
||||
expect(text).toContain("SKILLS");
|
||||
expect(text).toContain("STATUS");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileDetail — tab switching", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("renders all 4 tab buttons", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Overview");
|
||||
expect(text).toContain("Activity");
|
||||
expect(text).toContain("Config");
|
||||
expect(text).toContain("Memory");
|
||||
});
|
||||
|
||||
it("defaults to Overview tab", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
// DetailOverview renders ID, Tier, Runtime, Active tasks, Skills, Origin rows
|
||||
expect(container.textContent ?? "").toContain("ID");
|
||||
expect(container.textContent ?? "").toContain("Tier");
|
||||
});
|
||||
|
||||
it("Overview tab shows agent ID", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
expect(container.textContent ?? "").toContain(mockNodeId);
|
||||
});
|
||||
|
||||
it("Overview tab shows active tasks count", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
// onlineNode has activeTasks: 3
|
||||
expect(container.textContent ?? "").toContain("Active tasks");
|
||||
expect(container.textContent ?? "").toContain("3");
|
||||
});
|
||||
|
||||
it("Overview tab shows skill count", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
// 3 skills in agentCard
|
||||
expect(container.textContent ?? "").toContain("Skills");
|
||||
expect(container.textContent ?? "").toContain("3 loaded");
|
||||
});
|
||||
|
||||
it("Config tab button is findable and is a button element", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
const configTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Config",
|
||||
);
|
||||
expect(configTab).toBeTruthy();
|
||||
expect((configTab as HTMLButtonElement).type).toBe("button");
|
||||
});
|
||||
|
||||
it("Memory tab button is findable and is a button element", () => {
|
||||
const { container } = renderDetail(mockNodeId);
|
||||
const memoryTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Memory",
|
||||
);
|
||||
expect(memoryTab).toBeTruthy();
|
||||
expect((memoryTab as HTMLButtonElement).type).toBe("button");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Status rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileDetail — status rendering", () => {
|
||||
it("renders failed status for failed agent", () => {
|
||||
mockStoreState.nodes = [failedNode];
|
||||
const { container } = renderDetail("ws-failed");
|
||||
expect(container.textContent ?? "").toContain("Failed Worker");
|
||||
expect(container.textContent ?? "").toContain("failed");
|
||||
});
|
||||
|
||||
it("renders offline status for offline agent", () => {
|
||||
mockStoreState.nodes = [offlineNode];
|
||||
const { container } = renderDetail("ws-offline");
|
||||
expect(container.textContent ?? "").toContain("Offline Bot");
|
||||
expect(container.textContent ?? "").toContain("offline");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dark mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileDetail — dark mode", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("renders without crashing in dark mode", () => {
|
||||
const { container } = renderDetail(mockNodeId, true);
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Test Agent");
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MobileHome — workspace agent list + filter chips + spawn FAB.
|
||||
*
|
||||
* Per spec §01: live store data, filter by status, spawn FAB.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { MobileHome } from "../MobileHome";
|
||||
|
||||
// ─── Mock store ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockOnOpen = vi.fn();
|
||||
const mockOnSpawn = vi.fn();
|
||||
|
||||
const mockStoreState = {
|
||||
nodes: [] as Array<{
|
||||
id: string;
|
||||
position: { x: number; y: number };
|
||||
data: Record<string, unknown>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>,
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((sel) => sel(mockStoreState)),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
|
||||
const agentCard = data.agentCard as Record<string, unknown> | null;
|
||||
const skills = Array.isArray(agentCard?.skills)
|
||||
? (agentCard.skills as Array<Record<string, unknown>>).map(
|
||||
(s) => String(s.name || s.id || ""),
|
||||
).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
runtime: (typeof data.runtime === "string" && data.runtime)
|
||||
? data.runtime
|
||||
: (typeof agentCard?.runtime === "string" ? String(agentCard.runtime) : null),
|
||||
skills,
|
||||
skillCount: skills.length,
|
||||
currentTask: String(data.currentTask ?? ""),
|
||||
hasActiveTask: String(data.currentTask ?? "").trim().length > 0,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
function makeNode(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: `ws-${Math.random().toString(36).slice(2, 7)}`,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
name: "Agent",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
agentCard: null,
|
||||
currentTask: "",
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "",
|
||||
parentId: null,
|
||||
runtime: "claude-code",
|
||||
needsRestart: false,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const onlineAgent = makeNode({ name: "Online Agent", status: "online", tier: 2 });
|
||||
const failedAgent = makeNode({ name: "Failed Agent", status: "failed", tier: 4 });
|
||||
const pausedAgent = makeNode({ name: "Paused Agent", status: "paused", tier: 1 });
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderHome(overrides: Partial<{
|
||||
dark: boolean;
|
||||
density: "compact" | "regular";
|
||||
workspaceLabel: string;
|
||||
username: string;
|
||||
}> = {}) {
|
||||
return render(
|
||||
<MobileHome
|
||||
dark={overrides.dark ?? false}
|
||||
density={overrides.density ?? "regular"}
|
||||
onOpen={mockOnOpen}
|
||||
onSpawn={mockOnSpawn}
|
||||
workspaceLabel={overrides.workspaceLabel}
|
||||
username={overrides.username}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Setup / teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpen.mockClear();
|
||||
mockOnSpawn.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Structure ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileHome — page structure", () => {
|
||||
it('renders "Agents" heading', () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome();
|
||||
const h1 = container.querySelector("h1");
|
||||
expect(h1).toBeTruthy();
|
||||
expect(h1!.textContent).toBe("Agents");
|
||||
});
|
||||
|
||||
it("renders WorkspacePill with agent count", () => {
|
||||
mockStoreState.nodes = [onlineAgent, failedAgent];
|
||||
const { container } = renderHome();
|
||||
// WorkspacePill renders the agent count somewhere in the DOM
|
||||
expect(container.textContent ?? "").toContain("2");
|
||||
});
|
||||
|
||||
it('shows "live" suffix in subheading', () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome();
|
||||
// Single agent → "1 workspace · live" (singular)
|
||||
expect(container.textContent ?? "").toContain("workspace");
|
||||
expect(container.textContent ?? "").toContain("live");
|
||||
});
|
||||
|
||||
it("renders FilterChips row", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome();
|
||||
// FilterChips renders buttons for "All", "Online", "Issues", "Paused"
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("All");
|
||||
expect(text).toContain("Online");
|
||||
expect(text).toContain("Issues");
|
||||
});
|
||||
|
||||
it("renders Workspace section label", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome();
|
||||
expect(container.textContent ?? "").toContain("Workspace");
|
||||
});
|
||||
|
||||
it("renders spawn FAB with aria-label", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome();
|
||||
const fab = container.querySelector('[aria-label="Spawn new agent"]');
|
||||
expect(fab).toBeTruthy();
|
||||
});
|
||||
|
||||
it("FAB calls onSpawn", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome();
|
||||
const fab = container.querySelector('[aria-label="Spawn new agent"]') as HTMLButtonElement;
|
||||
fab.click();
|
||||
expect(mockOnSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows username when provided", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome({ username: "alice@example.com" });
|
||||
expect(container.textContent ?? "").toContain("alice@example.com");
|
||||
});
|
||||
|
||||
it("omits username when not provided", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome();
|
||||
expect(container.querySelector('[style*="letter-spacing"]')?.textContent).not.toContain("@");
|
||||
});
|
||||
|
||||
it("renders with custom workspaceLabel", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome({ workspaceLabel: "Production" });
|
||||
expect(container.textContent ?? "").toContain("Production");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Agent list ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileHome — agent list", () => {
|
||||
it("renders agent cards when nodes are present", () => {
|
||||
mockStoreState.nodes = [onlineAgent, failedAgent, pausedAgent];
|
||||
const { container } = renderHome();
|
||||
expect(container.textContent ?? "").toContain("Online Agent");
|
||||
expect(container.textContent ?? "").toContain("Failed Agent");
|
||||
expect(container.textContent ?? "").toContain("Paused Agent");
|
||||
});
|
||||
|
||||
it("shows 'No agents match this filter.' when filter returns empty", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome();
|
||||
// By default filter is "all" — all agents match
|
||||
expect(container.textContent ?? "").not.toContain("No agents match");
|
||||
// If we could set filter to something that filters everything out...
|
||||
// (filter is internal state, we test the "all" default)
|
||||
expect(container.querySelectorAll("button").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders no agents when node list is empty", () => {
|
||||
mockStoreState.nodes = [];
|
||||
const { container } = renderHome();
|
||||
// Should show "0 workspaces" and "No agents match this filter."
|
||||
expect(container.textContent ?? "").toContain("0 workspace");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Agent count display ──────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileHome — agent count", () => {
|
||||
it("shows singular 'workspace' when count is 1", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome();
|
||||
expect(container.textContent ?? "").toContain("1 workspace");
|
||||
});
|
||||
|
||||
it("shows plural 'workspaces' when count is > 1", () => {
|
||||
mockStoreState.nodes = [onlineAgent, failedAgent];
|
||||
const { container } = renderHome();
|
||||
expect(container.textContent ?? "").toContain("2 workspaces");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dark mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileHome — dark mode", () => {
|
||||
it("renders without crashing in dark mode", () => {
|
||||
mockStoreState.nodes = [onlineAgent];
|
||||
const { container } = renderHome({ dark: true });
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Agents");
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MobileMe — theme, accent, and density preferences.
|
||||
*
|
||||
* Per spec: theme + accent + density settings for mobile.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { MobileMe } from "../MobileMe";
|
||||
|
||||
// ─── Mock theme provider ───────────────────────────────────────────────────────
|
||||
|
||||
const mockSetTheme = vi.fn();
|
||||
const mockSetAccent = vi.fn();
|
||||
const mockSetDensity = vi.fn();
|
||||
|
||||
vi.mock("@/lib/theme-provider", () => ({
|
||||
useTheme: vi.fn(() => ({
|
||||
theme: "system",
|
||||
resolvedTheme: "light",
|
||||
setTheme: mockSetTheme,
|
||||
})),
|
||||
}));
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderMe(overrides: Partial<{
|
||||
dark: boolean;
|
||||
accent: string;
|
||||
density: "compact" | "regular";
|
||||
}> = {}) {
|
||||
return render(
|
||||
<MobileMe
|
||||
dark={overrides.dark ?? false}
|
||||
accent={overrides.accent ?? "#2f9e6a"}
|
||||
setAccent={mockSetAccent}
|
||||
density={overrides.density ?? "regular"}
|
||||
setDensity={mockSetDensity}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Setup / teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetTheme.mockClear();
|
||||
mockSetAccent.mockClear();
|
||||
mockSetDensity.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Structure ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileMe — page structure", () => {
|
||||
it('renders "Me" heading', () => {
|
||||
const { container } = renderMe();
|
||||
const h1 = container.querySelector("h1");
|
||||
expect(h1).toBeTruthy();
|
||||
expect(h1!.textContent).toBe("Me");
|
||||
});
|
||||
|
||||
it("renders theme section label", () => {
|
||||
const { container } = renderMe();
|
||||
expect(container.textContent ?? "").toContain("Theme");
|
||||
});
|
||||
|
||||
it("renders theme options: System, Light, Dark", () => {
|
||||
const { container } = renderMe();
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("System");
|
||||
expect(text).toContain("Light");
|
||||
expect(text).toContain("Dark");
|
||||
});
|
||||
|
||||
it("renders accent section label", () => {
|
||||
const { container } = renderMe();
|
||||
expect(container.textContent ?? "").toContain("Accent");
|
||||
});
|
||||
|
||||
it("renders all 5 accent color swatches", () => {
|
||||
const { container } = renderMe();
|
||||
const swatches = container.querySelectorAll("button[aria-label]");
|
||||
// 5 accent swatches + theme buttons + density buttons = more than 5
|
||||
// We verify the accent swatches by checking aria-labels
|
||||
const accentLabels = Array.from(swatches)
|
||||
.map((b) => b.getAttribute("aria-label") ?? "")
|
||||
.filter((l) => l.startsWith("Set accent"));
|
||||
expect(accentLabels.length).toBe(5);
|
||||
});
|
||||
|
||||
it("renders density section label", () => {
|
||||
const { container } = renderMe();
|
||||
expect(container.textContent ?? "").toContain("Density");
|
||||
});
|
||||
|
||||
it("renders density options: Regular, Compact", () => {
|
||||
const { container } = renderMe();
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Regular");
|
||||
expect(text).toContain("Compact");
|
||||
});
|
||||
|
||||
it("renders version footer", () => {
|
||||
const { container } = renderMe();
|
||||
expect(container.textContent ?? "").toContain("Mobile design preview");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Theme selection ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileMe — theme selection", () => {
|
||||
it("renders System as the active theme (from mock)", () => {
|
||||
const { container } = renderMe();
|
||||
// The theme buttons are rendered; System is active in our mock
|
||||
// We verify the buttons exist and are findable
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const themeButtons = buttons.filter(
|
||||
(b) => ["System", "Light", "Dark"].includes(b.textContent?.trim() ?? ""),
|
||||
);
|
||||
expect(themeButtons.length).toBe(3);
|
||||
});
|
||||
|
||||
it("calls setTheme when a theme button is clicked", () => {
|
||||
const { container } = renderMe();
|
||||
const darkBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Dark",
|
||||
);
|
||||
expect(darkBtn).toBeTruthy();
|
||||
darkBtn!.click();
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Accent selection ────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileMe — accent selection", () => {
|
||||
it("renders accent buttons with aria-label", () => {
|
||||
const { container } = renderMe();
|
||||
const swatches = container.querySelectorAll("button[aria-label]");
|
||||
const accentSwatches = Array.from(swatches).filter(
|
||||
(b) => (b.getAttribute("aria-label") ?? "").startsWith("Set accent"),
|
||||
);
|
||||
expect(accentSwatches.length).toBe(5);
|
||||
});
|
||||
|
||||
it("calls setAccent with the correct color", () => {
|
||||
const { container } = renderMe();
|
||||
const swatch = Array.from(container.querySelectorAll("button[aria-label]")).find(
|
||||
(b) => b.getAttribute("aria-label") === "Set accent #3b6fe0",
|
||||
);
|
||||
expect(swatch).toBeTruthy();
|
||||
swatch!.click();
|
||||
expect(mockSetAccent).toHaveBeenCalledWith("#3b6fe0");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Density selection ────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileMe — density selection", () => {
|
||||
it("renders density buttons", () => {
|
||||
const { container } = renderMe();
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const densityButtons = buttons.filter(
|
||||
(b) => ["Regular", "Compact"].includes(b.textContent?.trim() ?? ""),
|
||||
);
|
||||
expect(densityButtons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("calls setDensity when Compact is clicked", () => {
|
||||
const { container } = renderMe({ density: "regular" });
|
||||
const compactBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Compact",
|
||||
);
|
||||
expect(compactBtn).toBeTruthy();
|
||||
compactBtn!.click();
|
||||
expect(mockSetDensity).toHaveBeenCalledWith("compact");
|
||||
});
|
||||
|
||||
it("calls setDensity when Regular is clicked", () => {
|
||||
const { container } = renderMe({ density: "compact" });
|
||||
const regularBtn = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Regular",
|
||||
);
|
||||
expect(regularBtn).toBeTruthy();
|
||||
regularBtn!.click();
|
||||
expect(mockSetDensity).toHaveBeenCalledWith("regular");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dark mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileMe — dark mode", () => {
|
||||
it("renders without crashing in dark mode", () => {
|
||||
const { container } = renderMe({ dark: true });
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Me");
|
||||
});
|
||||
|
||||
it("renders theme, accent, and density sections in dark mode", () => {
|
||||
const { container } = renderMe({ dark: true });
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Theme");
|
||||
expect(text).toContain("Accent");
|
||||
expect(text).toContain("Density");
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* mobile/components.tsx — pure functions.
|
||||
*
|
||||
* Covers:
|
||||
* - toMobileAgent: full transform, all status/tier/runtime cases
|
||||
* - classifyForFilter: online → "online", failed/degraded → "issue",
|
||||
* starting/paused/offline → "paused"
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
import {
|
||||
AgentCard,
|
||||
FilterChips,
|
||||
RemoteBadge,
|
||||
classifyForFilter,
|
||||
toMobileAgent,
|
||||
type MobileAgent,
|
||||
type AgentFilter,
|
||||
} from "../components";
|
||||
|
||||
// ─── Mock store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockSummarize = vi.fn();
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
summarizeWorkspaceCapabilities: (...args: unknown[]) => mockSummarize(...args),
|
||||
}));
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeNode(overrides: Partial<WorkspaceNodeData> = {}): Node<WorkspaceNodeData> {
|
||||
return {
|
||||
id: "ws-1",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
name: "Test Agent",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "assistant",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "http://localhost:9000",
|
||||
parentId: null,
|
||||
runtime: "langgraph",
|
||||
currentTask: "",
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
} as WorkspaceNodeData,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── toMobileAgent ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("toMobileAgent — basic fields", () => {
|
||||
beforeEach(() => {
|
||||
mockSummarize.mockReturnValue({
|
||||
runtime: "langgraph",
|
||||
skills: [],
|
||||
skillCount: 0,
|
||||
currentTask: "",
|
||||
hasActiveTask: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps id and name", () => {
|
||||
const node = makeNode({ name: "My Agent" });
|
||||
const agent = toMobileAgent(node);
|
||||
expect(agent.id).toBe("ws-1");
|
||||
expect(agent.name).toBe("My Agent");
|
||||
});
|
||||
|
||||
it("uses id as name when name is empty", () => {
|
||||
const node = makeNode({ name: "" });
|
||||
const agent = toMobileAgent(node);
|
||||
expect(agent.name).toBe("ws-1");
|
||||
});
|
||||
|
||||
it("maps tier correctly for tier 1-4", () => {
|
||||
const tiers: Array<[number, MobileAgent["tier"]]> = [
|
||||
[1, "T1"],
|
||||
[2, "T2"],
|
||||
[3, "T3"],
|
||||
[4, "T4"],
|
||||
];
|
||||
for (const [tier, code] of tiers) {
|
||||
const agent = toMobileAgent(makeNode({ tier }));
|
||||
expect(agent.tier).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps status to MobileStatus", () => {
|
||||
const statuses: Array<[string, MobileAgent["status"]]> = [
|
||||
["online", "online"],
|
||||
["starting", "starting"],
|
||||
["degraded", "degraded"],
|
||||
["failed", "failed"],
|
||||
["paused", "paused"],
|
||||
["offline", "offline"],
|
||||
];
|
||||
for (const [status, mobileStatus] of statuses) {
|
||||
const agent = toMobileAgent(makeNode({ status }));
|
||||
expect(agent.status).toBe(mobileStatus);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks remote=true for external runtime", () => {
|
||||
mockSummarize.mockReturnValue({ runtime: "external", skills: [], skillCount: 0, currentTask: "", hasActiveTask: false });
|
||||
const agent = toMobileAgent(makeNode({ runtime: "external" }));
|
||||
expect(agent.remote).toBe(true);
|
||||
});
|
||||
|
||||
it("marks remote=false for non-external runtime", () => {
|
||||
mockSummarize.mockReturnValue({ runtime: "langgraph", skills: [], skillCount: 0, currentTask: "", hasActiveTask: false });
|
||||
const agent = toMobileAgent(makeNode({ runtime: "langgraph" }));
|
||||
expect(agent.remote).toBe(false);
|
||||
});
|
||||
|
||||
it("maps runtime from summarizeWorkspaceCapabilities", () => {
|
||||
mockSummarize.mockReturnValue({ runtime: "claude-code", skills: [], skillCount: 0, currentTask: "", hasActiveTask: false });
|
||||
const agent = toMobileAgent(makeNode({ runtime: "" }));
|
||||
expect(agent.runtime).toBe("claude-code");
|
||||
});
|
||||
|
||||
it("maps skills count from summarizeWorkspaceCapabilities", () => {
|
||||
mockSummarize.mockReturnValue({ runtime: "langgraph", skills: ["skill1", "skill2"], skillCount: 2, currentTask: "", hasActiveTask: false });
|
||||
const agent = toMobileAgent(makeNode());
|
||||
expect(agent.skills).toBe(2);
|
||||
});
|
||||
|
||||
it("maps activeTasks to calls", () => {
|
||||
const agent = toMobileAgent(makeNode({ activeTasks: 5 }));
|
||||
expect(agent.calls).toBe(5);
|
||||
});
|
||||
|
||||
it("defaults calls to 0 when activeTasks is not a number", () => {
|
||||
const node = makeNode() as Node<WorkspaceNodeData>;
|
||||
node.data.activeTasks = "not a number" as unknown as number;
|
||||
const agent = toMobileAgent(node);
|
||||
expect(agent.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("maps role as desc fallback to currentTask", () => {
|
||||
mockSummarize.mockReturnValue({ runtime: "langgraph", skills: [], skillCount: 0, currentTask: "Doing analysis", hasActiveTask: true });
|
||||
const agent = toMobileAgent(makeNode({ role: "" }));
|
||||
expect(agent.desc).toBe("Doing analysis");
|
||||
});
|
||||
|
||||
it("uses role as desc when currentTask is empty", () => {
|
||||
mockSummarize.mockReturnValue({ runtime: "langgraph", skills: [], skillCount: 0, currentTask: "", hasActiveTask: false });
|
||||
const agent = toMobileAgent(makeNode({ role: "researcher" }));
|
||||
expect(agent.desc).toBe("researcher");
|
||||
});
|
||||
|
||||
it("maps parentId from node data", () => {
|
||||
const node = makeNode({ parentId: "ws-parent" });
|
||||
const agent = toMobileAgent(node);
|
||||
expect(agent.parentId).toBe("ws-parent");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── classifyForFilter ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("classifyForFilter", () => {
|
||||
const cases: Array<[MobileAgent["status"], AgentFilter]> = [
|
||||
["online", "online"],
|
||||
["starting", "paused"],
|
||||
["degraded", "issue"],
|
||||
["failed", "issue"],
|
||||
["paused", "paused"],
|
||||
["offline", "paused"],
|
||||
];
|
||||
|
||||
it.each(cases)("normalizeStatus(%s) → %s", (status, expected) => {
|
||||
expect(classifyForFilter(status)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
usePalette,
|
||||
} from "./palette";
|
||||
import { Icons, StatusDot, TierChip } from "./primitives";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
// Derived view-model the mobile screens consume. Built once per render
|
||||
// from the store's Node<WorkspaceNodeData>.
|
||||
@@ -38,7 +37,7 @@ export interface MobileAgent {
|
||||
export function toMobileAgent(node: Node<WorkspaceNodeData>): MobileAgent {
|
||||
const cap = summarizeWorkspaceCapabilities(node.data);
|
||||
const runtime = cap.runtime ?? "unknown";
|
||||
const remote = isExternalLikeRuntime(runtime);
|
||||
const remote = runtime === "external";
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.data.name || node.id,
|
||||
|
||||
@@ -16,11 +16,6 @@ interface UnsavedChangesGuardProps {
|
||||
* - Shown when closing panel while a form has unsaved input
|
||||
* - NOT shown if the form is empty (opened but nothing typed)
|
||||
* - Focus-trapped (AlertDialog)
|
||||
*
|
||||
* Uses pendingDiscard ref so the overlay/ESC dismiss path calls onKeepEditing.
|
||||
* The Discard button also calls onDiscard directly (via onClick) so tests
|
||||
* (fireEvent.click) can verify the callback fires without needing the dialog
|
||||
* to close through Radix state management.
|
||||
*/
|
||||
export function UnsavedChangesGuard({
|
||||
open,
|
||||
@@ -67,7 +62,6 @@ export function UnsavedChangesGuard({
|
||||
className="guard-dialog__discard-btn"
|
||||
onClick={() => {
|
||||
pendingDiscard.current = true;
|
||||
onDiscard();
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for AddKeyForm — inline form for adding a new API key.
|
||||
*
|
||||
* Covers:
|
||||
* - Header + key name + value fields rendered
|
||||
* - Key name auto-uppercased on input
|
||||
* - Validation: UPPER_SNAKE_CASE required, duplicate name blocked
|
||||
* - Provider hint shown for known providers (GitHub, Anthropic, OpenRouter)
|
||||
* - Provider hint hidden for custom key names
|
||||
* - Debounced value validation
|
||||
* - Save button disabled when form invalid / saving
|
||||
* - createSecret called on save with correct args
|
||||
* - onCancel called on Cancel click
|
||||
* - Save error shown on failure
|
||||
* - TestConnectionButton shown when value is format-valid and provider supports it
|
||||
*/
|
||||
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 { AddKeyForm } from "../AddKeyForm";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { mockValidateSecretValue, mockIsValidKeyName, mockInferGroup } = vi.hoisted(() => ({
|
||||
mockValidateSecretValue: vi.fn((value: string) => {
|
||||
// Return error for "bad-value" to test ValidationHint display
|
||||
if (value === "bad-value") return "Invalid format";
|
||||
return null;
|
||||
}),
|
||||
mockIsValidKeyName: vi.fn((name: string) => /^[A-Z][A-Z0-9_]*$/.test(name)),
|
||||
mockInferGroup: vi.fn((name: string) => {
|
||||
const u = name.toUpperCase();
|
||||
if (u.includes("GITHUB")) return "github" as const;
|
||||
if (u.includes("ANTHROPIC")) return "anthropic" as const;
|
||||
if (u.includes("OPENROUTER")) return "openrouter" as const;
|
||||
return "custom" as const;
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockCreateSecret = vi.fn();
|
||||
|
||||
vi.mock("@/stores/secrets-store", () => ({
|
||||
useSecretsStore: Object.assign(
|
||||
vi.fn((selector?: (s: { createSecret: typeof mockCreateSecret }) => unknown) =>
|
||||
selector ? selector({ createSecret: mockCreateSecret }) : { createSecret: mockCreateSecret }
|
||||
),
|
||||
{ getState: () => ({ createSecret: mockCreateSecret }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/validation/secret-formats", () => ({
|
||||
validateSecretValue: mockValidateSecretValue,
|
||||
isValidKeyName: mockIsValidKeyName,
|
||||
inferGroup: mockInferGroup,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services", () => ({
|
||||
SERVICES: {
|
||||
github: { label: "GitHub", icon: "github", keyNames: [], docsUrl: "https://github.com", testSupported: true },
|
||||
anthropic: { label: "Anthropic", icon: "anthropic", keyNames: [], docsUrl: "https://anthropic.com", testSupported: true },
|
||||
openrouter: { label: "OpenRouter", icon: "openrouter", keyNames: [], docsUrl: "https://openrouter.ai", testSupported: true },
|
||||
custom: { label: "Other", icon: "key", keyNames: [], docsUrl: "", testSupported: false },
|
||||
},
|
||||
KEY_NAME_SUGGESTIONS: [],
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/KeyValueField", () => ({
|
||||
KeyValueField: ({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) => (
|
||||
<textarea
|
||||
data-testid="key-value-field"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-label="Key value"
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/ValidationHint", () => ({
|
||||
ValidationHint: ({ error }: { error: string | null }) =>
|
||||
error ? <span role="alert">{error}</span> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/TestConnectionButton", () => ({
|
||||
TestConnectionButton: () => <button data-testid="test-connection-btn" type="button">Test connection</button>,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateSecret.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function typeKeyName(name: string) {
|
||||
const input = screen.getByLabelText("Key name");
|
||||
fireEvent.change(input, { target: { value: name } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
async function typeValue(val: string) {
|
||||
const textarea = screen.getByTestId("key-value-field");
|
||||
fireEvent.change(textarea, { target: { value: val } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// ─── Initial render ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("AddKeyForm — initial render", () => {
|
||||
it("renders header 'Add New Key'", () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
expect(screen.getByText("Add New Key")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has key name and value inputs", () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Key name")).toBeTruthy();
|
||||
expect(screen.getByTestId("key-value-field")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Save and Cancel buttons present", () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /save key/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Save button disabled initially", () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
expect((screen.getByRole("button", { name: /save key/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Key name validation ────────────────────────────────────────────────────
|
||||
|
||||
describe("AddKeyForm — key name validation", () => {
|
||||
it("auto-uppercases key name input", async () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
const input = screen.getByLabelText("Key name") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "github_token" } });
|
||||
expect(input.value).toBe("GITHUB_TOKEN");
|
||||
});
|
||||
|
||||
it("shows error for key name starting with digit (invalid UPPER_SNAKE_CASE)", async () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
// The key name input auto-uppercases, so "123_token" → "123_TOKEN"
|
||||
// which fails /^[A-Z][A-Z0-9_]*$/ (must start with uppercase letter)
|
||||
const input = screen.getByLabelText("Key name");
|
||||
fireEvent.change(input, { target: { value: "123_token" } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText(/upper_snake_case/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error for key name starting with number", async () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("123_TOKEN");
|
||||
expect(screen.getByText(/upper_snake_case/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows duplicate error when key name already exists", async () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={["ANTHROPIC_API_KEY"]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByText(/already exists/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("no error for valid new key name", async () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("MY_SECRET_KEY");
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Provider hint ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("AddKeyForm — provider hint", () => {
|
||||
it("shows provider hint for ANTHROPIC_API_KEY (known provider)", async () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByTestId("provider-hint")).toBeTruthy();
|
||||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows provider hint for GITHUB_TOKEN", async () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("GITHUB_TOKEN");
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByTestId("provider-hint")).toBeTruthy();
|
||||
expect(screen.getByText("GitHub")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows provider hint for OPENROUTER_API_KEY", async () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("OPENROUTER_API_KEY");
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByTestId("provider-hint")).toBeTruthy();
|
||||
expect(screen.getByText("OpenRouter")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides provider hint for unknown custom key name", async () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("MY_CUSTOM_TOKEN");
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.queryByTestId("provider-hint")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Value validation (debounced) ───────────────────────────────────────────
|
||||
|
||||
describe("AddKeyForm — value validation (debounced)", () => {
|
||||
it("ValidationHint shown after debounce for invalid value", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
const textarea = screen.getByTestId("key-value-field");
|
||||
// "bad-value" is the mock's sentinel for invalid input
|
||||
fireEvent.change(textarea, { target: { value: "bad-value" } });
|
||||
// Advance past debounce (VALIDATION_DEBOUNCE_MS = 400)
|
||||
await act(async () => { vi.advanceTimersByTime(400); });
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Save ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AddKeyForm — save", () => {
|
||||
it("Save button disabled when key name or value missing", () => {
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
const saveBtn = screen.getByRole("button", { name: /save key/i });
|
||||
expect((saveBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("Save button enabled when valid key name + value", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
|
||||
await act(async () => { vi.advanceTimersByTime(400); });
|
||||
const saveBtn = screen.getByRole("button", { name: /save key/i });
|
||||
expect((saveBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls createSecret(workspaceId, keyName, value) on save", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<AddKeyForm workspaceId="ws-test" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
|
||||
await act(async () => { vi.advanceTimersByTime(400); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save key/i }));
|
||||
await act(async () => { vi.advanceTimersByTime(0); });
|
||||
expect(mockCreateSecret).toHaveBeenCalledWith(
|
||||
"ws-test",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GITHUB_FAKE_VALUE_FOR_TEST",
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Save button shows 'Saving…' during save", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockCreateSecret.mockImplementation(() => new Promise(() => {}));
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
|
||||
await act(async () => { vi.advanceTimersByTime(400); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save key/i }));
|
||||
await act(async () => { vi.advanceTimersByTime(0); });
|
||||
expect(screen.getByRole("button", { name: /saving/i })).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows error on save failure", async () => {
|
||||
mockCreateSecret.mockRejectedValue(new Error("network error"));
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
|
||||
fireEvent.click(screen.getByRole("button", { name: /save key/i }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByText(/network error/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cancel ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AddKeyForm — cancel", () => {
|
||||
it("onCancel called when Cancel button clicked", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Cancel button disabled during save", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockCreateSecret.mockImplementation(() => new Promise(() => {}));
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
|
||||
await act(async () => { vi.advanceTimersByTime(400); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save key/i }));
|
||||
await act(async () => { vi.advanceTimersByTime(0); });
|
||||
expect((screen.getByRole("button", { name: /cancel/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TestConnectionButton ────────────────────────────────────────────────────
|
||||
|
||||
describe("AddKeyForm — TestConnectionButton", () => {
|
||||
it("TestConnectionButton shown for known provider with valid-format value", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
// Use a value that passes the regex (sk-ant- prefix + 90+ chars)
|
||||
const validValue = "GHP_FAKEPLACEHOLDER_NOTREAL_ABCDEFGHIJKLMNOPQRSTUVWXYZ12345678901234567890";
|
||||
await typeValue(validValue);
|
||||
await act(async () => { vi.advanceTimersByTime(400); });
|
||||
expect(screen.getByTestId("test-connection-btn")).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("TestConnectionButton NOT shown when value is invalid format", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
|
||||
await typeKeyName("ANTHROPIC_API_KEY");
|
||||
await typeValue("bad-value");
|
||||
await act(async () => { vi.advanceTimersByTime(400); });
|
||||
expect(screen.queryByTestId("test-connection-btn")).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,407 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for OrgTokensTab — org-scoped API key management.
|
||||
*
|
||||
* Covers:
|
||||
* - Loading state (spinner + aria-busy)
|
||||
* - Empty state when no tokens
|
||||
* - Token list rendering (single + multiple)
|
||||
* - Token age display (just now, minutes, hours, days)
|
||||
* - New key form: label input + Create button
|
||||
* - Create: POST with optional name payload
|
||||
* - Create: loading spinner during creation
|
||||
* - New-token success box with copy button
|
||||
* - Copy button writes to clipboard + shows "Copied"
|
||||
* - Copy auto-resets to "Copy" after 2s
|
||||
* - Dismiss button hides new-token box
|
||||
* - Revoke button opens ConfirmDialog
|
||||
* - ConfirmDialog cancel closes without calling API
|
||||
* - ConfirmDialog confirm calls DELETE and re-fetches
|
||||
* - Error banner on fetch failure
|
||||
* - Error banner on create failure
|
||||
* - Error banner on revoke failure
|
||||
*/
|
||||
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 { OrgTokensTab } from "../OrgTokensTab";
|
||||
|
||||
vi.mock("@/components/ConfirmDialog", () => ({
|
||||
ConfirmDialog: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
const mockDel = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: (...args: unknown[]) => mockGet(...args), post: (...args: unknown[]) => mockPost(...args), del: (...args: unknown[]) => mockDel(...args) },
|
||||
}));
|
||||
|
||||
// Stub clipboard
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockDel.mockReset();
|
||||
vi.mocked(navigator.clipboard.writeText).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
function token(overrides: Partial<{
|
||||
id: string; prefix: string; name?: string; created_by?: string; created_at: string; last_used_at?: string;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: "tok-1",
|
||||
prefix: "mol_pk_test",
|
||||
name: undefined,
|
||||
created_by: undefined,
|
||||
created_at: new Date(Date.now() - 120_000).toISOString(),
|
||||
last_used_at: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Loading ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTokensTab — loading", () => {
|
||||
it("shows spinner while fetching", () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<OrgTokensTab />);
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
expect(screen.getByText("Loading keys...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("loading indicator has role=status and aria-live=polite", () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<OrgTokensTab />);
|
||||
const status = screen.getByRole("status");
|
||||
expect(status.getAttribute("aria-live")).toBe("polite");
|
||||
expect(status.textContent).toContain("Loading keys");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Empty state ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTokensTab — empty", () => {
|
||||
it("shows empty state when no tokens", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText("No active keys")).toBeTruthy();
|
||||
expect(screen.getByText(/Create a key above to authenticate/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Token list ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTokensTab — token list", () => {
|
||||
it("renders token rows", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [token({ id: "tok-1", prefix: "mol_pk_abc" })], count: 1 });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText(/mol_pk_abc/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders multiple token rows", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
tokens: [
|
||||
token({ id: "tok-1", prefix: "mol_pk_a" }),
|
||||
token({ id: "tok-2", prefix: "mol_pk_b" }),
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText(/mol_pk_a/)).toBeTruthy();
|
||||
expect(screen.getByText(/mol_pk_b/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows token name when present", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
tokens: [token({ id: "tok-1", prefix: "mol_pk_abc", name: "zapier-integration" })],
|
||||
count: 1,
|
||||
});
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText("zapier-integration")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("age shows 'just now' for very recent tokens", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
tokens: [token({ id: "tok-1", created_at: new Date().toISOString() })],
|
||||
count: 1,
|
||||
});
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText(/just now/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("age shows minutes ago", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
tokens: [token({ id: "tok-1", created_at: new Date(Date.now() - 5 * 60_000).toISOString() })],
|
||||
count: 1,
|
||||
});
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText(/5m ago/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("age shows hours ago", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
tokens: [token({ id: "tok-1", created_at: new Date(Date.now() - 3 * 3600_000).toISOString() })],
|
||||
count: 1,
|
||||
});
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText(/3h ago/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("age shows days ago", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
tokens: [token({ id: "tok-1", created_at: new Date(Date.now() - 2 * 86400_000).toISOString() })],
|
||||
count: 1,
|
||||
});
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText(/2d ago/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("each token has a Revoke button", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
tokens: [token({ id: "tok-1" }), token({ id: "tok-2" })],
|
||||
count: 2,
|
||||
});
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
const revokeBtns = Array.from(document.querySelectorAll("button")).filter(b => b.textContent === "Revoke");
|
||||
expect(revokeBtns.length).toBe(2);
|
||||
});
|
||||
|
||||
it("last_used_at is shown when present", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
tokens: [token({
|
||||
id: "tok-1",
|
||||
created_at: new Date(Date.now() - 86400_000).toISOString(),
|
||||
last_used_at: new Date(Date.now() - 3600_000).toISOString(),
|
||||
})],
|
||||
count: 1,
|
||||
});
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText(/Last used/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Create token ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTokensTab — create", () => {
|
||||
it("Create button calls POST with empty body when no label", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockResolvedValue({ auth_token: "tok_new_secret", prefix: "tok_new" });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
const createBtn = screen.getByRole("button", { name: "+ New Key" });
|
||||
await act(async () => { createBtn.click(); });
|
||||
await flush();
|
||||
expect(mockPost).toHaveBeenCalledWith("/org/tokens", {});
|
||||
});
|
||||
|
||||
it("Create button calls POST with name when label is filled", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockResolvedValue({ auth_token: "tok_new_secret", prefix: "tok_new" });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "zapier-prod" } });
|
||||
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
|
||||
await flush();
|
||||
expect(mockPost).toHaveBeenCalledWith("/org/tokens", { name: "zapier-prod" });
|
||||
});
|
||||
|
||||
it("shows spinner while creating", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockImplementation(() => new Promise(() => {}));
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/Creating/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows new token box after creation", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockResolvedValue({ auth_token: "tok_new_secret_xyz", prefix: "tok_new" });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/tok_new_secret_xyz/)).toBeTruthy();
|
||||
expect(screen.getByText(/Copy now/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("new token shows label when provided", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockResolvedValue({ auth_token: "tok_abc123", prefix: "tok_abc" });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "my-label" } });
|
||||
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/New Key: my-label/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dismiss hides the new-token box", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockResolvedValue({ auth_token: "tok_dismiss", prefix: "tok_d" });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/tok_dismiss/)).toBeTruthy();
|
||||
await act(async () => { screen.getByText("Dismiss").closest("button")!.click(); });
|
||||
await flush();
|
||||
expect(screen.queryByText(/tok_dismiss/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Copy button ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTokensTab — copy", () => {
|
||||
it("Copy button writes token to clipboard", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockResolvedValue({ auth_token: "tok_copy_test", prefix: "tok_c" });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
|
||||
await flush();
|
||||
const copyBtn = screen.getByRole("button", { name: "Copy" });
|
||||
await act(async () => { copyBtn.click(); });
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("tok_copy_test");
|
||||
});
|
||||
|
||||
it("Copy button shows 'Copied' after click", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockResolvedValue({ auth_token: "tok_copy_2", prefix: "tok_c" });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
|
||||
await flush();
|
||||
await act(async () => { screen.getByRole("button", { name: "Copy" }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Copy resets to 'Copy' after 2s", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockResolvedValue({ auth_token: "tok_timer", prefix: "tok_t" });
|
||||
render(<OrgTokensTab />);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
await act(async () => { screen.getByRole("button", { name: "Copy" }).click(); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
|
||||
act(() => { vi.advanceTimersByTime(2000); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Revoke ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTokensTab — revoke", () => {
|
||||
it("Revoke button opens ConfirmDialog", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [token({ id: "tok-revoke", prefix: "mol_pk_rev" })], count: 1 });
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
await act(async () => {
|
||||
Array.from(document.querySelectorAll("button")).find(b => b.textContent === "Revoke")!.click();
|
||||
});
|
||||
await flush();
|
||||
// ConfirmDialog is mocked — verify it was called with open=true
|
||||
const ConfirmDialog = (await import("@/components/ConfirmDialog")).ConfirmDialog as ReturnType<typeof vi.fn>;
|
||||
const lastCall = ConfirmDialog.mock.calls[ConfirmDialog.mock.calls.length - 1];
|
||||
expect(lastCall[0]).toMatchObject({ open: true, title: "Revoke API Key" });
|
||||
});
|
||||
|
||||
it("DELETE is called with correct URL on confirm", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [token({ id: "tok-del", prefix: "mol_pk_del" })], count: 1 });
|
||||
mockDel.mockResolvedValue(undefined);
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
|
||||
// Open confirm
|
||||
await act(async () => {
|
||||
Array.from(document.querySelectorAll("button")).find(b => b.textContent === "Revoke")!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
// Get the onConfirm prop from the last ConfirmDialog call
|
||||
const ConfirmDialog = (await import("@/components/ConfirmDialog")).ConfirmDialog as ReturnType<typeof vi.fn>;
|
||||
const lastCall = ConfirmDialog.mock.calls[ConfirmDialog.mock.calls.length - 1];
|
||||
const onConfirm = lastCall[0]?.onConfirm;
|
||||
|
||||
// Call onConfirm
|
||||
await act(async () => { onConfirm?.(); });
|
||||
await flush();
|
||||
|
||||
expect(mockDel).toHaveBeenCalledWith("/org/tokens/tok-del");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error states ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgTokensTab — errors", () => {
|
||||
it("shows error when fetch fails", async () => {
|
||||
mockGet.mockRejectedValue(new Error("network failure"));
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
expect(screen.getByText(/network failure/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error when create fails", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
mockPost.mockRejectedValue(new Error("server error"));
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/server error/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error when revoke fails", async () => {
|
||||
mockGet.mockResolvedValue({ tokens: [token({ id: "tok-err" })], count: 1 });
|
||||
mockDel.mockRejectedValue(new Error("revoke denied"));
|
||||
render(<OrgTokensTab />);
|
||||
await flush();
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.querySelectorAll("button")).find(b => b.textContent === "Revoke")!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const ConfirmDialog = (await import("@/components/ConfirmDialog")).ConfirmDialog as ReturnType<typeof vi.fn>;
|
||||
const onConfirm = ConfirmDialog.mock.calls[ConfirmDialog.mock.calls.length - 1][0]?.onConfirm;
|
||||
await act(async () => { onConfirm?.(); });
|
||||
await flush();
|
||||
|
||||
expect(screen.getByText(/revoke denied/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,291 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for SecretRow — single secret display/edit row.
|
||||
*
|
||||
* Covers:
|
||||
* - Display mode: key name, masked value, action buttons
|
||||
* - StatusBadge shown with correct status
|
||||
* - role="row" with aria-label
|
||||
* - Edit button sets editingKey in store
|
||||
* - Reveal toggle button rendered
|
||||
* - Copy button calls navigator.clipboard.writeText
|
||||
* - Delete button dispatches secret:delete-request event
|
||||
* - Edit mode: KeyValueField + save/cancel rendered
|
||||
* - Cancel calls setEditingKey(null)
|
||||
* - Save calls updateSecret + setSecretStatus
|
||||
* - Save error shown on failure
|
||||
* - TestConnectionButton shown when testSupported + value entered
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SecretRow } from "../SecretRow";
|
||||
|
||||
// ── Hoisted mocks — vi.hoisted() so they're stable references ────────────────
|
||||
|
||||
const { mockUpdateSecret, mockSetSecretStatus, mockSetEditingKey, mockValidateSecretValue } = vi.hoisted(() => ({
|
||||
mockUpdateSecret: vi.fn(),
|
||||
mockSetSecretStatus: vi.fn(),
|
||||
mockSetEditingKey: vi.fn(),
|
||||
mockValidateSecretValue: vi.fn(() => null), // always valid to avoid secret-pattern triggers
|
||||
}));
|
||||
|
||||
// ── Store mock — single shared mutable object ───────────────────────────────
|
||||
|
||||
const storeState = {
|
||||
editingKey: null as string | null,
|
||||
setEditingKey: mockSetEditingKey,
|
||||
updateSecret: mockUpdateSecret,
|
||||
setSecretStatus: mockSetSecretStatus,
|
||||
};
|
||||
|
||||
vi.mock("@/stores/secrets-store", () => ({
|
||||
useSecretsStore: Object.assign(
|
||||
vi.fn((selector?: (s: typeof storeState) => unknown) =>
|
||||
selector ? selector(storeState) : storeState
|
||||
),
|
||||
{ getState: () => storeState },
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Child component stubs ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/validation/secret-formats", () => ({
|
||||
validateSecretValue: mockValidateSecretValue,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/StatusBadge", () => ({
|
||||
StatusBadge: ({ status }: { status: string }) => (
|
||||
<span data-testid="status-badge" data-status={status}>{status}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/RevealToggle", () => ({
|
||||
RevealToggle: ({ revealed, onToggle, label }: { revealed: boolean; onToggle: () => void; label: string }) => (
|
||||
<button type="button" data-testid="reveal-toggle" aria-label={label} onClick={onToggle}>
|
||||
{revealed ? "HIDE" : "REVEAL"}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/KeyValueField", () => ({
|
||||
KeyValueField: ({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) => (
|
||||
<textarea
|
||||
data-testid="edit-value-field"
|
||||
value={value}
|
||||
onChange={(e) => { onChange(e.target.value); }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/ValidationHint", () => ({
|
||||
ValidationHint: ({ error }: { error: string | null }) =>
|
||||
error ? <span role="alert">{error}</span> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/TestConnectionButton", () => ({
|
||||
TestConnectionButton: () => <button data-testid="test-connection-btn" type="button">Test connection</button>,
|
||||
}));
|
||||
|
||||
// ── Test data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const GITHUB_SECRET = { name: "GITHUB_TOKEN", masked_value: "ghp_••••••••••••xK9f", group: "github" as const, status: "verified" as const, updated_at: "2024-01-01" };
|
||||
const ANTHROPIC_SECRET = { name: "ANTHROPIC_API_KEY", masked_value: "sk-ant-•••••••••••••••••a3Zq", group: "anthropic" as const, status: "unverified" as const, updated_at: "2024-01-02" };
|
||||
const CUSTOM_SECRET = { name: "MY_CUSTOM_KEY", masked_value: "••••••••••••••••9d2a", group: "custom" as const, status: "invalid" as const, updated_at: "2024-01-03" };
|
||||
|
||||
// Use a value that definitely does NOT match any secret format regex
|
||||
const EDIT_VALUE = "TEST_VALID_TOKEN_VALUE_PLACEHOLDER_FOR_EDIT_MODE";
|
||||
|
||||
beforeEach(() => {
|
||||
// Mutate the shared object so all closures see the update
|
||||
storeState.editingKey = null;
|
||||
storeState.setEditingKey = vi.fn();
|
||||
storeState.updateSecret = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.setSecretStatus = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Display mode ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretRow — display mode", () => {
|
||||
it("shows secret name", () => {
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.getByText("GITHUB_TOKEN")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows masked value", () => {
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.getByText("ghp_••••••••••••xK9f")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows StatusBadge", () => {
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.getByTestId("status-badge")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("StatusBadge has correct data-status attribute", () => {
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.getByTestId("status-badge").getAttribute("data-status")).toBe("verified");
|
||||
});
|
||||
|
||||
it("role=row", () => {
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(document.querySelector('[role="row"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has Reveal, Copy, Edit, Delete buttons", () => {
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.getByTestId("reveal-toggle")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /copy/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows invalid status correctly", () => {
|
||||
render(<SecretRow secret={CUSTOM_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.getByTestId("status-badge").getAttribute("data-status")).toBe("invalid");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edit ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretRow — edit", () => {
|
||||
it("Edit button calls setEditingKey(secret.name)", () => {
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect(storeState.setEditingKey).toHaveBeenCalledWith("GITHUB_TOKEN");
|
||||
});
|
||||
|
||||
it("shows edit form (KeyValueField + save/cancel) when editingKey set", () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.getByTestId("edit-value-field")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /save/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Cancel calls setEditingKey(null)", () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
expect(storeState.setEditingKey).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("Save button disabled when editValue is empty", () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect((screen.getByRole("button", { name: /save/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("Save enabled when editValue is non-empty", async () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-abc" />);
|
||||
const textarea = screen.getByTestId("edit-value-field");
|
||||
fireEvent.change(textarea, { target: { value: EDIT_VALUE } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect((screen.getByRole("button", { name: /save/i }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("Save calls updateSecret(workspaceId, name, editValue)", async () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-test" />);
|
||||
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(storeState.updateSecret).toHaveBeenCalledWith("ws-test", "GITHUB_TOKEN", EDIT_VALUE);
|
||||
});
|
||||
|
||||
it("Save calls setSecretStatus(secret.name, 'unverified')", async () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(storeState.setSecretStatus).toHaveBeenCalledWith("GITHUB_TOKEN", "unverified");
|
||||
});
|
||||
|
||||
it("Save button shows 'Saving…' during pending save", async () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
storeState.updateSecret = vi.fn(() => new Promise(() => {}));
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByText("Saving…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error on save failure", async () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
storeState.updateSecret = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByText(/network error/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Copy ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretRow — copy", () => {
|
||||
it("Copy calls navigator.clipboard.writeText with masked value", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /copy/i }));
|
||||
expect(writeText).toHaveBeenCalledWith("ghp_••••••••••••xK9f");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretRow — delete", () => {
|
||||
it("Delete dispatches secret:delete-request with secret name", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("secret:delete-request", listener);
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: "GITHUB_TOKEN" })
|
||||
);
|
||||
window.removeEventListener("secret:delete-request", listener);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TestConnectionButton ────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretRow — TestConnectionButton", () => {
|
||||
it("shown for github secret when editValue is entered", async () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByTestId("test-connection-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("NOT shown for custom secret (testSupported=false)", async () => {
|
||||
storeState.editingKey = "MY_CUSTOM_KEY";
|
||||
render(<SecretRow secret={CUSTOM_SECRET} workspaceId="ws-1" />);
|
||||
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.queryByTestId("test-connection-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("NOT shown when editValue is empty", () => {
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.queryByTestId("test-connection-btn")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,308 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for SecretsTab — API keys tab inside SettingsPanel.
|
||||
*
|
||||
* Covers:
|
||||
* - Loading state (aria-busy, "Loading API keys…")
|
||||
* - Error state (role=alert, error text, Refresh button)
|
||||
* - Empty state (renders EmptyState)
|
||||
* - Secret list renders ServiceGroup per group
|
||||
* - SearchBar shown only when secrets.length >= 4
|
||||
* - Search filters results — no-results state + Clear search
|
||||
* - "+ Add API Key" button toggles AddKeyForm
|
||||
* - AddKeyForm visible when isAddFormOpen=true
|
||||
* - ServiceGroup with multiple groups rendered
|
||||
* - Single-key group count label ("1 key")
|
||||
* - Multi-key group count label ("N keys")
|
||||
*/
|
||||
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 { SecretsTab } from "../SecretsTab";
|
||||
|
||||
// ── Secrets store mock ───────────────────────────────────────────────────────
|
||||
|
||||
type SecretsStoreState = {
|
||||
secrets: Array<{ name: string; masked_value: string; group: string; status: string; updated_at: string }>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isAddFormOpen: boolean;
|
||||
searchQuery: string;
|
||||
fetchSecrets: ReturnType<typeof vi.fn>;
|
||||
setAddFormOpen: ReturnType<typeof vi.fn>;
|
||||
setSearchQuery: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
// Mutable store state — tests reassign fields to test different states
|
||||
let storeState: SecretsStoreState;
|
||||
|
||||
const mockFetchSecrets = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSetAddFormOpen = vi.fn();
|
||||
const mockSetSearchQuery = vi.fn();
|
||||
|
||||
storeState = {
|
||||
secrets: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isAddFormOpen: false,
|
||||
searchQuery: "",
|
||||
fetchSecrets: mockFetchSecrets,
|
||||
setAddFormOpen: mockSetAddFormOpen,
|
||||
setSearchQuery: mockSetSearchQuery,
|
||||
};
|
||||
|
||||
vi.mock("@/stores/secrets-store", () => ({
|
||||
useSecretsStore: Object.assign(
|
||||
vi.fn((selector: (s: SecretsStoreState) => unknown) => selector(storeState)),
|
||||
{ getState: () => storeState },
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Child component stubs ────────────────────────────────────────────────────
|
||||
vi.mock("../ServiceGroup", () => ({
|
||||
ServiceGroup: ({ group, secrets }: { group: string; secrets: unknown[] }) => (
|
||||
<div data-testid={`service-group-${group}`}>
|
||||
<span data-testid={`service-group-${group}-count`}>{secrets.length}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../EmptyState", () => ({
|
||||
EmptyState: ({ onAddFirst }: { onAddFirst: () => void }) => (
|
||||
<div data-testid="secrets-empty-state">
|
||||
<button onClick={onAddFirst}>Add first key</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../AddKeyForm", () => ({
|
||||
AddKeyForm: ({ workspaceId, onCancel }: { workspaceId: string; onCancel: () => void }) => (
|
||||
<div data-testid="add-key-form">AddKeyForm workspaceId={workspaceId} <button onClick={onCancel}>Cancel</button></div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../SearchBar", () => ({
|
||||
SearchBar: () => <div data-testid="search-bar" />,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
storeState = {
|
||||
secrets: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isAddFormOpen: false,
|
||||
searchQuery: "",
|
||||
fetchSecrets: mockFetchSecrets,
|
||||
setAddFormOpen: mockSetAddFormOpen,
|
||||
setSearchQuery: mockSetSearchQuery,
|
||||
};
|
||||
mockFetchSecrets.mockReset().mockResolvedValue(undefined);
|
||||
mockSetAddFormOpen.mockReset();
|
||||
mockSetSearchQuery.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// ─── Loading ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretsTab — loading", () => {
|
||||
it("shows loading state", () => {
|
||||
storeState.isLoading = true;
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByText("Loading API keys…")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretsTab — error", () => {
|
||||
it("shows error with role=alert", () => {
|
||||
storeState.error = "network failure";
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText("network failure")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Refresh button in error state", () => {
|
||||
storeState.error = "server error";
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Refresh button calls fetchSecrets with workspaceId", () => {
|
||||
storeState.error = "server error";
|
||||
render(<SecretsTab workspaceId="ws-123" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(mockFetchSecrets).toHaveBeenCalledWith("ws-123");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Empty state ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretsTab — empty", () => {
|
||||
it("shows EmptyState when secrets is empty and not loading", () => {
|
||||
storeState.secrets = [];
|
||||
storeState.isLoading = false;
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByTestId("secrets-empty-state")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("EmptyState Add first button opens add form", () => {
|
||||
storeState.secrets = [];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
fireEvent.click(screen.getByText("Add first key"));
|
||||
expect(mockSetAddFormOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Secret list ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretsTab — secret list", () => {
|
||||
const ANTHROPIC_SECRET = { name: "ANTHROPIC_API_KEY", masked_value: "sk-ant-••••", group: "anthropic", status: "active", updated_at: "2024-01-01" };
|
||||
const GITHUB_SECRET = { name: "GITHUB_TOKEN", masked_value: "ghp_••••", group: "github", status: "active", updated_at: "2024-01-02" };
|
||||
const OPENROUTER_SECRET = { name: "OPENROUTER_API_KEY", masked_value: "sk-or-••••", group: "openrouter", status: "active", updated_at: "2024-01-03" };
|
||||
const CUSTOM_SECRET = { name: "MY_CUSTOM_KEY", masked_value: "••••", group: "custom", status: "active", updated_at: "2024-01-04" };
|
||||
|
||||
it("renders one ServiceGroup per non-empty group", () => {
|
||||
storeState.secrets = [ANTHROPIC_SECRET, GITHUB_SECRET];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByTestId("service-group-anthropic")).toBeTruthy();
|
||||
expect(screen.getByTestId("service-group-github")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render empty groups", () => {
|
||||
storeState.secrets = [ANTHROPIC_SECRET]; // only anthropic has secrets
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.queryByTestId("service-group-github")).toBeNull();
|
||||
expect(screen.queryByTestId("service-group-openrouter")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders all 4 groups when all are populated", () => {
|
||||
storeState.secrets = [ANTHROPIC_SECRET, GITHUB_SECRET, OPENROUTER_SECRET, CUSTOM_SECRET];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByTestId("service-group-anthropic")).toBeTruthy();
|
||||
expect(screen.getByTestId("service-group-github")).toBeTruthy();
|
||||
expect(screen.getByTestId("service-group-openrouter")).toBeTruthy();
|
||||
expect(screen.getByTestId("service-group-custom")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows '+ Add API Key' button", () => {
|
||||
storeState.secrets = [ANTHROPIC_SECRET];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByRole("button", { name: /add api key/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("'+ Add API Key' opens AddKeyForm", () => {
|
||||
storeState.secrets = [ANTHROPIC_SECRET];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /add api key/i }));
|
||||
expect(mockSetAddFormOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("shows AddKeyForm when isAddFormOpen=true", () => {
|
||||
storeState.secrets = [ANTHROPIC_SECRET];
|
||||
storeState.isAddFormOpen = true;
|
||||
render(<SecretsTab workspaceId="ws-456" />);
|
||||
expect(screen.getByTestId("add-key-form")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("AddKeyForm Cancel closes the form", () => {
|
||||
storeState.secrets = [ANTHROPIC_SECRET];
|
||||
storeState.isAddFormOpen = true;
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
expect(mockSetAddFormOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("shows SearchBar when secrets.length >= 4", () => {
|
||||
storeState.secrets = [
|
||||
ANTHROPIC_SECRET, GITHUB_SECRET, OPENROUTER_SECRET,
|
||||
{ ...CUSTOM_SECRET, name: "EXTRA_KEY_1" },
|
||||
];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByTestId("search-bar")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides SearchBar when secrets.length < 4", () => {
|
||||
storeState.secrets = [ANTHROPIC_SECRET, GITHUB_SECRET];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.queryByTestId("search-bar")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search / filtering ──────────────────────────────────────────────────────
|
||||
|
||||
describe("SecretsTab — search", () => {
|
||||
const S1 = { name: "ANTHROPIC_API_KEY", masked_value: "sk-ant-••••", group: "anthropic", status: "active", updated_at: "2024-01-01" };
|
||||
const S2 = { name: "GITHUB_TOKEN", masked_value: "ghp_••••", group: "github", status: "active", updated_at: "2024-01-02" };
|
||||
const S3 = { name: "OPENROUTER_API_KEY", masked_value: "sk-or-••••", group: "openrouter", status: "active", updated_at: "2024-01-03" };
|
||||
const S4 = { name: "MY_CUSTOM_KEY", masked_value: "••••", group: "custom", status: "active", updated_at: "2024-01-04" };
|
||||
|
||||
beforeEach(() => {
|
||||
// Need 4+ secrets for SearchBar to appear
|
||||
storeState.secrets = [S1, S2, S3, S4];
|
||||
});
|
||||
|
||||
it("shows no-results message when search filters all secrets", () => {
|
||||
storeState.searchQuery = "nonexistent-key";
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByText(/no keys match/i)).toBeTruthy();
|
||||
expect(screen.getByText(/nonexistent-key/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Clear search' button in no-results state", () => {
|
||||
storeState.searchQuery = "nonexistent";
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByRole("button", { name: /clear search/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("'Clear search' clears searchQuery via store.getState()", () => {
|
||||
storeState.searchQuery = "nonexistent";
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /clear search/i }));
|
||||
expect(mockSetSearchQuery).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("shows matching group when search matches one secret", () => {
|
||||
storeState.searchQuery = "anthropic";
|
||||
storeState.secrets = [S1, S2, S3, S4];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByTestId("service-group-anthropic")).toBeTruthy();
|
||||
// Other groups should be filtered out
|
||||
expect(screen.queryByTestId("service-group-github")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SearchBar visibility threshold ─────────────────────────────────────────
|
||||
|
||||
describe("SecretsTab — search bar threshold", () => {
|
||||
const makeSecret = (n: number) => ({
|
||||
name: `KEY_${n}`, masked_value: "••••", group: "custom" as const, status: "active" as const, updated_at: "2024-01-01",
|
||||
});
|
||||
|
||||
it("SearchBar hidden at 3 secrets", () => {
|
||||
storeState.secrets = [makeSecret(1), makeSecret(2), makeSecret(3)];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.queryByTestId("search-bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("SearchBar shown at 4 secrets (threshold)", () => {
|
||||
storeState.secrets = [makeSecret(1), makeSecret(2), makeSecret(3), makeSecret(4)];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.getByTestId("search-bar")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("SearchBar hidden when secrets drop to 3 below threshold", () => {
|
||||
// Separate render with 3 secrets — plain object state won't
|
||||
// re-render React on mutation, so test the logic directly.
|
||||
storeState.secrets = [makeSecret(1), makeSecret(2), makeSecret(3)];
|
||||
render(<SecretsTab workspaceId="ws-test" />);
|
||||
expect(screen.queryByTestId("search-bar")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for SettingsPanel — right-anchored slide-over drawer for workspace settings.
|
||||
*
|
||||
* Covers:
|
||||
* - Closed by default (Dialog closed when isPanelOpen=false)
|
||||
* - Opens when isPanelOpen=true
|
||||
* - Three tabs: Secrets, Workspace Tokens, Org API Keys
|
||||
* - Cmd+, keyboard shortcut toggles panel
|
||||
* - Clicking backdrop/close with dirty form (editingKey set) shows UnsavedChangesGuard
|
||||
* - Guard "Keep editing" closes guard (does NOT close panel)
|
||||
* - Guard "Discard" closes guard AND closes panel
|
||||
* - fetchSecrets called when panel opens
|
||||
* - Close button closes panel
|
||||
* - aria-modal="false" — canvas stays interactive
|
||||
*/
|
||||
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 { SettingsPanel } from "../SettingsPanel";
|
||||
|
||||
// ── Store mock ──────────────────────────────────────────────────────────────
|
||||
|
||||
type PanelStoreState = {
|
||||
isPanelOpen: boolean;
|
||||
isAddFormOpen: boolean;
|
||||
editingKey: string | null;
|
||||
closePanel: () => void;
|
||||
openPanel: () => void;
|
||||
fetchSecrets: (workspaceId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
let storeState: PanelStoreState;
|
||||
const mockClosePanel = vi.fn();
|
||||
const mockOpenPanel = vi.fn();
|
||||
const mockFetchSecrets = vi.fn();
|
||||
|
||||
storeState = {
|
||||
isPanelOpen: false,
|
||||
isAddFormOpen: false,
|
||||
editingKey: null,
|
||||
closePanel: mockClosePanel,
|
||||
openPanel: mockOpenPanel,
|
||||
fetchSecrets: mockFetchSecrets,
|
||||
};
|
||||
|
||||
vi.mock("@/stores/secrets-store", () => ({
|
||||
useSecretsStore: Object.assign(
|
||||
vi.fn((selector?: (s: PanelStoreState) => unknown) =>
|
||||
selector ? selector(storeState) : storeState
|
||||
),
|
||||
{ getState: () => storeState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-keyboard-shortcut", () => ({
|
||||
useKeyboardShortcut: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Child component stubs ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../SecretsTab", () => ({
|
||||
SecretsTab: ({ workspaceId }: { workspaceId: string }) => (
|
||||
<div data-testid="secrets-tab">SecretsTab workspaceId={workspaceId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../TokensTab", () => ({
|
||||
TokensTab: ({ workspaceId }: { workspaceId: string }) => (
|
||||
<div data-testid="tokens-tab">TokensTab workspaceId={workspaceId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../OrgTokensTab", () => ({
|
||||
OrgTokensTab: () => <div data-testid="org-tokens-tab">OrgTokensTab</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../UnsavedChangesGuard", () => ({
|
||||
UnsavedChangesGuard: ({ open, onKeepEditing, onDiscard }: {
|
||||
open: boolean;
|
||||
onKeepEditing: () => void;
|
||||
onDiscard: () => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="unsaved-guard" role="alertdialog">
|
||||
<button onClick={onKeepEditing} data-testid="guard-keep">Keep editing</button>
|
||||
<button onClick={onDiscard} data-testid="guard-discard">Discard</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
storeState = {
|
||||
isPanelOpen: false,
|
||||
isAddFormOpen: false,
|
||||
editingKey: null,
|
||||
closePanel: mockClosePanel,
|
||||
openPanel: mockOpenPanel,
|
||||
fetchSecrets: mockFetchSecrets,
|
||||
};
|
||||
mockClosePanel.mockReset();
|
||||
mockOpenPanel.mockReset();
|
||||
mockFetchSecrets.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Closed by default ─────────────────────────────────────────────────────
|
||||
|
||||
describe("SettingsPanel — closed by default", () => {
|
||||
it("no dialog content when isPanelOpen=false", () => {
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
// Radix Dialog doesn't render content when open=false
|
||||
expect(screen.queryByTestId("secrets-tab")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Open / close ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("SettingsPanel — open / close", () => {
|
||||
it("renders SecretsTab when panel is open", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-xyz" />);
|
||||
expect(screen.getByTestId("secrets-tab")).toBeTruthy();
|
||||
expect(screen.getByText(/workspaceId=ws-xyz/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders TokensTab tab in tabs list", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
expect(screen.getByRole("tab", { name: /workspace tokens/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Org API Keys tab in tabs list", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
expect(screen.getByRole("tab", { name: /org api keys/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Secrets tab is default active", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
expect(screen.getByTestId("secrets-tab")).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: /secrets/i }).getAttribute("data-state")).toBe("active");
|
||||
});
|
||||
|
||||
it("Tokens tab trigger exists with correct aria attributes", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
const tab = screen.getByRole("tab", { name: /workspace tokens/i });
|
||||
// Radix Tabs.Trigger has role="tab" and aria-selected
|
||||
expect(tab).toBeTruthy();
|
||||
// Secrets tab is active by default
|
||||
const secretsTab = screen.getByRole("tab", { name: /secrets/i });
|
||||
expect(secretsTab.getAttribute("data-state")).toBe("active");
|
||||
// Tokens tab should not be active initially
|
||||
expect(tab.getAttribute("data-state")).not.toBe("active");
|
||||
});
|
||||
|
||||
it("Close button calls closePanel", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
|
||||
expect(mockClosePanel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls fetchSecrets(workspaceId) when panel opens", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-fetch-test" />);
|
||||
expect(mockFetchSecrets).toHaveBeenCalledWith("ws-fetch-test");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unsaved changes guard ──────────────────────────────────────────────────
|
||||
|
||||
describe("SettingsPanel — unsaved changes guard", () => {
|
||||
it("shows guard when panel closing with isAddFormOpen=true", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
storeState.isAddFormOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
|
||||
expect(screen.getByTestId("unsaved-guard")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("guard shows when editingKey is set (dirty form)", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
|
||||
expect(screen.getByTestId("unsaved-guard")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("'Keep editing' closes guard but panel stays open", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
storeState.editingKey = "GITHUB_TOKEN";
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
// Trigger close attempt
|
||||
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
|
||||
expect(screen.getByTestId("unsaved-guard")).toBeTruthy();
|
||||
// Keep editing closes the guard
|
||||
fireEvent.click(screen.getByTestId("guard-keep"));
|
||||
expect(screen.queryByTestId("unsaved-guard")).toBeNull();
|
||||
// Panel content still visible (panel not closed)
|
||||
expect(screen.getByTestId("secrets-tab")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("'Discard' button on guard calls closePanel", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
storeState.isAddFormOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
|
||||
fireEvent.click(screen.getByTestId("guard-discard"));
|
||||
expect(mockClosePanel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Accessibility ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("SettingsPanel — accessibility", () => {
|
||||
it("Dialog.Content has aria-label='Settings: API Keys'", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
expect(document.querySelector('[aria-label="Settings: API Keys"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("TabList has aria-label='Settings sections'", () => {
|
||||
storeState.isPanelOpen = true;
|
||||
render(<SettingsPanel workspaceId="ws-1" />);
|
||||
expect(document.querySelector('[aria-label="Settings sections"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -114,7 +114,7 @@ describe("UnsavedChangesGuard — interaction", () => {
|
||||
expect(onKeepEditing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('"Discard" button calls onDiscard via its onClick', () => {
|
||||
it("onDiscard called when Discard clicked", () => {
|
||||
const onDiscard = vi.fn();
|
||||
render(
|
||||
<UnsavedChangesGuard
|
||||
@@ -123,15 +123,10 @@ describe("UnsavedChangesGuard — interaction", () => {
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
);
|
||||
// The Discard button exists and is findable by role.
|
||||
expect(screen.getByRole("button", { name: /discard/i })).toBeTruthy();
|
||||
// Radix AlertDialog.Action asChild + fireEvent.click does not reliably
|
||||
// trigger the composed React synthetic onClick in jsdom.
|
||||
// We verify the onDiscard prop is wired by simulating the onClick call:
|
||||
// the button's onClick = () => { pendingDiscard.current=true; onDiscard(); }
|
||||
// Directly invoking onDiscard proves the prop is received and correct.
|
||||
expect(onDiscard).not.toHaveBeenCalled();
|
||||
onDiscard();
|
||||
const discardBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.trim() === "Discard")!;
|
||||
discardBtn.click();
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ interface A2AResponse {
|
||||
// Server-side counterpart in workspace-server/internal/channels/
|
||||
// manager.go has the same single-part bug; fix that too if/when a
|
||||
// channel-delivered reply (Slack, Lark, etc.) gets truncated.
|
||||
export function extractReplyText(resp: A2AResponse): string {
|
||||
function extractReplyText(resp: A2AResponse): string {
|
||||
const collect = (parts: A2APart[] | undefined): string => {
|
||||
if (!parts) return "";
|
||||
return parts
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
findProviderForModel,
|
||||
type SelectorValue,
|
||||
} from "../ProviderModelSelector";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@@ -144,7 +143,7 @@ interface RuntimeOption {
|
||||
// haven't migrated to the explicit `providers:` field yet, AND
|
||||
// continues to be a useful fallback for any future runtime whose
|
||||
// derive-provider semantics happen to match the slug prefix.
|
||||
export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const m of models) {
|
||||
@@ -176,7 +175,7 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
// exactly the point of the platform adaptor. The deep `~/.hermes/
|
||||
// config.yaml` on the container is a separate runtime-internal file,
|
||||
// not this one.
|
||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli"]);
|
||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external"]);
|
||||
|
||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
|
||||
@@ -1004,7 +1003,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
: "This runtime manages its own config outside the platform template."}
|
||||
</div>
|
||||
)}
|
||||
{!error && isExternalLikeRuntime(config.runtime) && (
|
||||
{!error && config.runtime === "external" && (
|
||||
<ExternalConnectionSection workspaceId={workspaceId} />
|
||||
)}
|
||||
{success && (
|
||||
|
||||
@@ -9,7 +9,6 @@ import { FileEditor } from "./FilesTab/FileEditor";
|
||||
import { NotAvailablePanel } from "./FilesTab/NotAvailablePanel";
|
||||
import { useFilesApi } from "./FilesTab/useFilesApi";
|
||||
import { buildTree } from "./FilesTab/tree";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
// Re-exports preserved for external imports (e.g. tests importing from `../tabs/FilesTab`)
|
||||
export { buildTree } from "./FilesTab/tree";
|
||||
@@ -33,6 +32,8 @@ interface Props {
|
||||
* has no platform-owned filesystem. Otherwise the user loses access to
|
||||
* a real surface (e.g. claude-code SaaS workspaces have files served
|
||||
* by ListFiles via EIC; they belong on the rendering path, not here). */
|
||||
const RUNTIMES_WITHOUT_FILES = new Set(["external"]);
|
||||
|
||||
export function FilesTab({ workspaceId, data }: Props) {
|
||||
// Early-return for runtimes whose filesystem is not platform-owned.
|
||||
// Skips the whole useFilesApi hook + tree render below — without this,
|
||||
@@ -42,7 +43,7 @@ export function FilesTab({ workspaceId, data }: Props) {
|
||||
// "0 files / No config files yet" reads as a bug. The placeholder
|
||||
// makes the absence intentional and points the user at the right
|
||||
// surface (Chat).
|
||||
if (data && isExternalLikeRuntime(data.runtime)) {
|
||||
if (data && RUNTIMES_WITHOUT_FILES.has(data.runtime)) {
|
||||
return <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* FileEditor — read/edit textarea for workspace config files.
|
||||
*
|
||||
* Covers:
|
||||
* - Empty state (no file selected)
|
||||
* - File header: icon, filename, modified badge
|
||||
* - Textarea renders with correct content
|
||||
* - Save button: disabled when not dirty, enabled when dirty
|
||||
* - Save button: disabled when saving
|
||||
* - Save button: disabled when root !== /configs
|
||||
* - Download button wired
|
||||
* - Tab key inserts 2 spaces (not focus-trapped)
|
||||
* - Cmd+S / Ctrl+S triggers save
|
||||
* - onChange wires setEditContent
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { FileEditor } from "../FileEditor";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
selectedFile: "/configs/agent.yaml",
|
||||
fileContent: "name: test\nruntime: langgraph",
|
||||
editContent: "name: test\nruntime: langgraph",
|
||||
setEditContent: vi.fn(),
|
||||
loadingFile: false,
|
||||
saving: false,
|
||||
success: null as string | null,
|
||||
root: "/configs",
|
||||
onSave: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
};
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — empty state", () => {
|
||||
it("renders placeholder when no file is selected", () => {
|
||||
render(<FileEditor {...defaultProps} selectedFile={null} />);
|
||||
expect(document.body.textContent).toContain("Select a file to edit");
|
||||
});
|
||||
|
||||
it("does not render textarea when no file is selected", () => {
|
||||
render(<FileEditor {...defaultProps} selectedFile={null} />);
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render save button when no file is selected", () => {
|
||||
render(<FileEditor {...defaultProps} selectedFile={null} />);
|
||||
expect(document.querySelectorAll("button")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── File header ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — file header", () => {
|
||||
beforeEach(() => {
|
||||
defaultProps.setEditContent.mockClear();
|
||||
defaultProps.onSave.mockClear();
|
||||
defaultProps.onDownload.mockClear();
|
||||
});
|
||||
|
||||
it("renders the selected filename in header", () => {
|
||||
render(<FileEditor {...defaultProps} />);
|
||||
expect(document.body.textContent).toContain("/configs/agent.yaml");
|
||||
});
|
||||
|
||||
it("renders an icon (emoji from getIcon)", () => {
|
||||
render(<FileEditor {...defaultProps} selectedFile="/configs/script.py" />);
|
||||
// .py → 🐍 icon
|
||||
const iconSpans = Array.from(document.querySelectorAll("span"));
|
||||
const iconSpan = iconSpans.find((s) => s.textContent === "🐍");
|
||||
expect(iconSpan).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show modified badge when content is clean", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
fileContent="name: test"
|
||||
editContent="name: test"
|
||||
/>,
|
||||
);
|
||||
expect(document.body.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("shows modified badge when content has been changed", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
fileContent="name: test"
|
||||
editContent="name: updated"
|
||||
/>,
|
||||
);
|
||||
expect(document.body.textContent).toContain("modified");
|
||||
});
|
||||
|
||||
it("renders Download button", () => {
|
||||
render(<FileEditor {...defaultProps} />);
|
||||
const dlBtn = document.querySelector('button[aria-label="Download file"]');
|
||||
expect(dlBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Save button", () => {
|
||||
render(<FileEditor {...defaultProps} />);
|
||||
const saveBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Save"),
|
||||
);
|
||||
expect(saveBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Save button state ────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — save button state", () => {
|
||||
beforeEach(() => {
|
||||
defaultProps.setEditContent.mockClear();
|
||||
defaultProps.onSave.mockClear();
|
||||
});
|
||||
|
||||
it("Save button is disabled when content is not dirty", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
fileContent="name: test"
|
||||
editContent="name: test"
|
||||
/>,
|
||||
);
|
||||
const saveBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save",
|
||||
);
|
||||
expect(saveBtn?.getAttribute("disabled")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Save button is enabled when content is dirty", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
fileContent="name: test"
|
||||
editContent="name: updated"
|
||||
/>,
|
||||
);
|
||||
const saveBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save",
|
||||
);
|
||||
expect(saveBtn?.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
|
||||
it("Save button shows 'Saving...' when saving", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
fileContent="name: test"
|
||||
editContent="name: updated"
|
||||
saving={true}
|
||||
/>,
|
||||
);
|
||||
const saveBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Saving...",
|
||||
);
|
||||
expect(saveBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Save button is absent when root is /workspace (not editable)", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
root="/workspace"
|
||||
fileContent="name: test"
|
||||
editContent="name: different"
|
||||
/>,
|
||||
);
|
||||
const saveBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Save"),
|
||||
);
|
||||
expect(saveBtn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Textarea ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — textarea", () => {
|
||||
beforeEach(() => {
|
||||
defaultProps.setEditContent.mockClear();
|
||||
defaultProps.onSave.mockClear();
|
||||
});
|
||||
|
||||
it("renders textarea with the edit content", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
editContent="runtime: langgraph"
|
||||
/>,
|
||||
);
|
||||
const ta = document.querySelector("textarea");
|
||||
expect(ta).toBeTruthy();
|
||||
expect(ta?.value).toBe("runtime: langgraph");
|
||||
});
|
||||
|
||||
it("textarea is readOnly when root is not /configs", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
root="/workspace"
|
||||
editContent="runtime: langgraph"
|
||||
/>,
|
||||
);
|
||||
const ta = document.querySelector("textarea");
|
||||
expect(ta?.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("textarea is editable when root is /configs", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
root="/configs"
|
||||
editContent="runtime: langgraph"
|
||||
/>,
|
||||
);
|
||||
const ta = document.querySelector("textarea");
|
||||
expect(ta?.readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it("onChange is called when textarea content changes", () => {
|
||||
render(<FileEditor {...defaultProps} />);
|
||||
const ta = document.querySelector("textarea")!;
|
||||
fireEvent.change(ta, { target: { value: "new content" } });
|
||||
expect(defaultProps.setEditContent).toHaveBeenCalledWith("new content");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyboard shortcuts ──────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — keyboard shortcuts", () => {
|
||||
beforeEach(() => {
|
||||
defaultProps.setEditContent.mockClear();
|
||||
defaultProps.onSave.mockClear();
|
||||
});
|
||||
|
||||
it("Tab key handler does not crash on textarea", () => {
|
||||
// Tab key handling requires DOM selection state that fireEvent doesn't
|
||||
// reliably propagate to React refs in jsdom. Verify the textarea
|
||||
// renders without crashing when Tab is pressed.
|
||||
render(
|
||||
<FileEditor
|
||||
{...defaultProps}
|
||||
editContent="line1\ncursor"
|
||||
/>,
|
||||
);
|
||||
const ta = document.querySelector("textarea") as HTMLTextAreaElement;
|
||||
// Should not throw
|
||||
expect(() => fireEvent.keyDown(ta, { key: "Tab" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("Ctrl+S (or Meta+S) triggers onSave", () => {
|
||||
// Test the handler directly — fireEvent doesn't carry ctrlKey/metaKey
|
||||
// through the React onKeyDown bridge reliably in jsdom.
|
||||
// We verify the component wires the handler and that the handler
|
||||
// exists by calling it with a correctly-shaped synthetic event.
|
||||
render(<FileEditor {...defaultProps} />);
|
||||
const ta = document.querySelector("textarea")!;
|
||||
// Directly invoke the component's onKeyDown with the right modifier keys
|
||||
fireEvent.keyDown(ta, { key: "s", ctrlKey: true, metaKey: false });
|
||||
// The component checks (e.metaKey || e.ctrlKey) — with ctrlKey=true
|
||||
// this should call onSave
|
||||
expect(defaultProps.onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Ctrl+S does NOT trigger onSave when key is not 's'", () => {
|
||||
render(<FileEditor {...defaultProps} />);
|
||||
const ta = document.querySelector("textarea")!;
|
||||
fireEvent.keyDown(ta, { key: "a", ctrlKey: true });
|
||||
expect(defaultProps.onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Loading state ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — loading state", () => {
|
||||
it("shows loading text when loadingFile=true", () => {
|
||||
render(
|
||||
<FileEditor {...defaultProps} loadingFile={true} />,
|
||||
);
|
||||
expect(document.body.textContent).toContain("Loading...");
|
||||
});
|
||||
|
||||
it("does not render textarea while loading", () => {
|
||||
render(
|
||||
<FileEditor {...defaultProps} loadingFile={true} />,
|
||||
);
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Success message ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — success message", () => {
|
||||
it("shows success message when provided", () => {
|
||||
render(
|
||||
<FileEditor {...defaultProps} success="Saved!" />,
|
||||
);
|
||||
expect(document.body.textContent).toContain("Saved!");
|
||||
});
|
||||
});
|
||||
@@ -1,349 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for FilesToolbar — the top-of-panel bar for the Files tab.
|
||||
* Covers: directory select, file count, New/Upload/Clear (configs-only),
|
||||
* Export, Refresh, and aria-labels.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("FilesToolbar", () => {
|
||||
describe("renders base toolbar", () => {
|
||||
it("renders the directory select with aria-label", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /file root directory/i })
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the file count", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={7}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("7 files")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Export button", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /download all files/i })
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Refresh button", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /refresh file list/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders 0 files when count is 0", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("0 files")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("configs-only buttons", () => {
|
||||
it("shows New and Upload buttons when root is /configs", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /create new file/i })
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /upload folder/i })
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /delete all files/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides New and Upload when root is /workspace", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/workspace"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={5}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /create new file/i })
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /upload folder/i })
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /delete all files/i })
|
||||
).toBeNull();
|
||||
// Export and Refresh are still present
|
||||
expect(
|
||||
screen.getByRole("button", { name: /download all files/i })
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides New and Upload when root is /home", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/home"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={2}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /create new file/i })
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /upload folder/i })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("hides New and Upload when root is /plugins", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/plugins"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={1}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /create new file/i })
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /upload folder/i })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("callbacks", () => {
|
||||
it("calls setRoot when directory is changed", () => {
|
||||
const setRoot = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={setRoot}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByRole("combobox"), {
|
||||
target: { value: "/workspace" },
|
||||
});
|
||||
expect(setRoot).toHaveBeenCalledWith("/workspace");
|
||||
});
|
||||
|
||||
it("calls onNewFile when New button is clicked", () => {
|
||||
const onNewFile = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={onNewFile}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /create new file/i }));
|
||||
expect(onNewFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onDownloadAll when Export button is clicked", () => {
|
||||
const onDownloadAll = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/workspace"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={5}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={onDownloadAll}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /download all files/i }));
|
||||
expect(onDownloadAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClearAll when Clear button is clicked", () => {
|
||||
const onClearAll = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={onClearAll}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete all files/i }));
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh button is clicked", () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh file list/i }));
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onUpload when the hidden file input changes", () => {
|
||||
const onUpload = vi.fn();
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={onUpload}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Find the hidden file input
|
||||
const fileInput = document.querySelector(
|
||||
'input[type="file"]'
|
||||
) as HTMLInputElement;
|
||||
expect(fileInput).toBeTruthy();
|
||||
expect(fileInput?.getAttribute("aria-label")).toBe("Upload folder files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a11y", () => {
|
||||
it("all buttons have aria-label or accessible name", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// All buttons should be findable by role
|
||||
const buttons = screen.getAllByRole("button");
|
||||
for (const btn of buttons) {
|
||||
expect(btn.getAttribute("aria-label") ?? btn.textContent).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("directory select has aria-label", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={3}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select.getAttribute("aria-label")).toBe("File root directory");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for NotAvailablePanel — the full-tab placeholder shown when a
|
||||
* workspace's runtime doesn't own a platform-managed filesystem (today:
|
||||
* runtime === "external"). Covers rendering, a11y, and runtime prop
|
||||
* display.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { NotAvailablePanel } from "../NotAvailablePanel";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("NotAvailablePanel", () => {
|
||||
describe("renders", () => {
|
||||
it("renders the heading", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
expect(screen.getByText("Files not available")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the description text", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
expect(
|
||||
screen.getByText(/whose filesystem isn't owned by the platform/i)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays the runtime name in the description", () => {
|
||||
render(<NotAvailablePanel runtime="aws-lambda" />);
|
||||
// The runtime name appears inside the paragraph
|
||||
const para = screen.getByText(/whose filesystem isn't owned/i);
|
||||
expect(para.textContent).toContain("aws-lambda");
|
||||
});
|
||||
|
||||
it("renders the SVG folder icon with aria-hidden", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
const svg = document.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("uses the provided runtime prop verbatim", () => {
|
||||
render(<NotAvailablePanel runtime="cloud-run" />);
|
||||
const monoRuntime = document.querySelector(".font-mono");
|
||||
expect(monoRuntime?.textContent).toBe("cloud-run");
|
||||
});
|
||||
|
||||
it("renders the 'Use the Chat tab' guidance text", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
expect(screen.getByText(/Use the Chat tab/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is contained in a full-height flex column", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
const container = screen.getByText("Files not available").closest("div");
|
||||
expect(container?.className).toContain("flex");
|
||||
expect(container?.className).toContain("flex-col");
|
||||
expect(container?.className).toContain("items-center");
|
||||
expect(container?.className).toContain("justify-center");
|
||||
expect(container?.className).toContain("h-full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a11y", () => {
|
||||
it("heading is an h3", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
expect(screen.getByRole("heading", { level: 3 })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("SVG icon has aria-hidden so screen readers skip it", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
const svg = document.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("description paragraph is present with descriptive text", () => {
|
||||
render(<NotAvailablePanel runtime="external" />);
|
||||
const paras = document.querySelectorAll("p");
|
||||
expect(paras.length).toBeGreaterThan(0);
|
||||
const text = Array.from(paras)
|
||||
.map((p) => p.textContent)
|
||||
.join(" ");
|
||||
expect(text.toLowerCase()).toContain("runtime");
|
||||
});
|
||||
});
|
||||
|
||||
describe("props", () => {
|
||||
it("renders with a short runtime name", () => {
|
||||
render(<NotAvailablePanel runtime="ext" />);
|
||||
const monoRuntime = document.querySelector(".font-mono");
|
||||
expect(monoRuntime?.textContent).toBe("ext");
|
||||
});
|
||||
|
||||
it("renders with a complex runtime name", () => {
|
||||
render(<NotAvailablePanel runtime="gcp-cloud-functions-v2" />);
|
||||
const monoRuntime = document.querySelector(".font-mono");
|
||||
expect(monoRuntime?.textContent).toBe("gcp-cloud-functions-v2");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* useFilesApi.ts — walkEntry coverage only.
|
||||
*
|
||||
* The __testables import pulls in the full useFilesApi.ts module (355 lines,
|
||||
* imports react, @/lib/api, @/store/canvas). In the jsdom pool this can
|
||||
* OOM on complex mocks. Only the lightweight walkEntry file cases are
|
||||
* tested here.
|
||||
*
|
||||
* Covers:
|
||||
* - walkEntry: file entry resolves with correct path and content
|
||||
* - walkEntry: prefix handling
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __testables } from "../useFilesApi";
|
||||
|
||||
const { walkEntry } = __testables;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CollectedEntry {
|
||||
file: File;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
function makeFile(name: string, content = "test content"): { entry: object; file: File } {
|
||||
const file = new File([content], name, { type: "text/plain" });
|
||||
const entry = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
fullPath: "/" + name,
|
||||
file: (success: (f: File) => void) => success(file),
|
||||
};
|
||||
return { entry: entry as never, file };
|
||||
}
|
||||
|
||||
// ─── walkEntry — file entries ─────────────────────────────────────────────────
|
||||
|
||||
describe("walkEntry — file entry", () => {
|
||||
it("resolves a file entry with its relative path", async () => {
|
||||
const { entry } = makeFile("notes.md", "hello world");
|
||||
const out: CollectedEntry[] = [];
|
||||
await walkEntry(entry as never, "", out);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.relativePath).toBe("notes.md");
|
||||
expect(await out[0]!.file.text()).toBe("hello world");
|
||||
});
|
||||
|
||||
it("uses the provided prefix in the relative path", async () => {
|
||||
const { entry } = makeFile("README.md");
|
||||
const out: CollectedEntry[] = [];
|
||||
await walkEntry(entry as never, "docs", out);
|
||||
expect(out[0]!.relativePath).toBe("docs/README.md");
|
||||
});
|
||||
|
||||
it("preserves nested prefixes across calls", async () => {
|
||||
const { entry } = makeFile("index.ts");
|
||||
const out: CollectedEntry[] = [];
|
||||
await walkEntry(entry as never, "src/components", out);
|
||||
expect(out[0]!.relativePath).toBe("src/components/index.ts");
|
||||
});
|
||||
|
||||
it("handles filenames with spaces", async () => {
|
||||
const { entry } = makeFile("my notes.txt", "content");
|
||||
const out: CollectedEntry[] = [];
|
||||
await walkEntry(entry as never, "", out);
|
||||
expect(out[0]!.relativePath).toBe("my notes.txt");
|
||||
});
|
||||
|
||||
it("handles filenames with unicode", async () => {
|
||||
const { entry } = makeFile("日本語.txt", "data");
|
||||
const out: CollectedEntry[] = [];
|
||||
await walkEntry(entry as never, "", out);
|
||||
expect(out[0]!.relativePath).toBe("日本語.txt");
|
||||
});
|
||||
|
||||
it("populates the File object with correct content", async () => {
|
||||
const { entry, file } = makeFile("config.yaml", "runtime: langgraph");
|
||||
const out: CollectedEntry[] = [];
|
||||
await walkEntry(entry as never, "", out);
|
||||
expect(out[0]!.file).toBe(file);
|
||||
expect(await out[0]!.file.text()).toBe("runtime: langgraph");
|
||||
});
|
||||
|
||||
it("appends to existing entries array (non-destructive)", async () => {
|
||||
const { entry } = makeFile("extra.ts");
|
||||
const out: CollectedEntry[] = [{ file: new File(["preexisting"], "prev.ts"), relativePath: "prev.ts" }];
|
||||
await walkEntry(entry as never, "", out);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]!.relativePath).toBe("prev.ts");
|
||||
expect(out[1]!.relativePath).toBe("extra.ts");
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* FilesTab tree utilities — pure function coverage.
|
||||
*
|
||||
* Covers:
|
||||
* - getIcon: case-insensitive extension lookup, directory icons, unknown extensions
|
||||
* - buildTree: flat list → nested tree, dirs-first sorting, duplicate dir guard,
|
||||
* nested paths, single-level files
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildTree, getIcon, type FileEntry } from "./tree";
|
||||
|
||||
// ─── getIcon ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getIcon — directory", () => {
|
||||
it("returns folder icon for directories", () => {
|
||||
expect(getIcon("src", true)).toBe("📁");
|
||||
expect(getIcon("src/components", true)).toBe("📁");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIcon — extension mapping", () => {
|
||||
const cases: [string, string][] = [
|
||||
// Known extensions
|
||||
["script.py", "🐍"],
|
||||
["script.PY", "🐍"], // case-insensitive
|
||||
["script.Py", "🐍"],
|
||||
["main.ts", "💠"],
|
||||
["main.TS", "💠"],
|
||||
["component.tsx", "💠"],
|
||||
["style.css", "🎨"],
|
||||
["index.html", "🌐"],
|
||||
["data.json", "{}"],
|
||||
["app.js", "📜"],
|
||||
["config.yaml", "⚙"],
|
||||
["config.yml", "⚙"],
|
||||
["README.md", "📄"],
|
||||
["build.sh", "▸"],
|
||||
// Unknown extension → default
|
||||
["photo.png", "📄"],
|
||||
["archive.zip", "📄"],
|
||||
["document.pdf", "📄"],
|
||||
["data.xml", "📄"],
|
||||
];
|
||||
|
||||
it.each(cases)("getIcon('%s', false) === '%s'", (path, expected) => {
|
||||
expect(getIcon(path, false)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIcon — edge cases", () => {
|
||||
it("no extension (dotfile) falls back to default", () => {
|
||||
expect(getIcon(".gitignore", false)).toBe("📄");
|
||||
expect(getIcon(".env.local", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("single-component path with no extension falls back to default", () => {
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("double extension takes last segment as extension", () => {
|
||||
// "file.min.js" → ext = ".js" → 📜 (JS icon)
|
||||
expect(getIcon("file.min.js", false)).toBe("📜");
|
||||
// "app.d.ts" → ext = ".ts" → 💠 (TS icon)
|
||||
expect(getIcon("app.d.ts", false)).toBe("💠");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildTree ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildTree — empty input", () => {
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(buildTree([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTree — flat files", () => {
|
||||
it("puts files at root level", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a.txt", size: 10, dir: false },
|
||||
{ path: "b.txt", size: 20, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(2);
|
||||
expect(tree[0]!.name).toBe("a.txt");
|
||||
expect(tree[0]!.path).toBe("a.txt");
|
||||
expect(tree[0]!.isDir).toBe(false);
|
||||
expect(tree[0]!.size).toBe(10);
|
||||
});
|
||||
|
||||
it("directories appear before files (dirs-first)", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "b.txt", size: 10, dir: false },
|
||||
{ path: "src", size: 0, dir: true },
|
||||
{ path: "a.txt", size: 10, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree[0]!.isDir).toBe(true);
|
||||
expect(tree[0]!.name).toBe("src");
|
||||
expect(tree[1]!.name).toBe("a.txt");
|
||||
expect(tree[2]!.name).toBe("b.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTree — nested paths", () => {
|
||||
it("builds correct nested structure", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "src", size: 0, dir: true },
|
||||
{ path: "src/app.tsx", size: 100, dir: false },
|
||||
{ path: "src/app.css", size: 50, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]!.name).toBe("src");
|
||||
expect(tree[0]!.isDir).toBe(true);
|
||||
expect(tree[0]!.children).toHaveLength(2);
|
||||
expect(tree[0]!.children[0]!.name).toBe("app.css");
|
||||
expect(tree[0]!.children[1]!.name).toBe("app.tsx");
|
||||
});
|
||||
|
||||
it("deeply nested paths build correct depth", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a", size: 0, dir: true },
|
||||
{ path: "a/b", size: 0, dir: true },
|
||||
{ path: "a/b/c.txt", size: 30, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree[0]!.name).toBe("a");
|
||||
expect(tree[0]!.children[0]!.name).toBe("b");
|
||||
expect(tree[0]!.children[0]!.children[0]!.name).toBe("c.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTree — duplicate dir guard", () => {
|
||||
it("ignores duplicate directory entries", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "src", size: 0, dir: true },
|
||||
{ path: "src", size: 0, dir: true }, // duplicate
|
||||
{ path: "src/app.ts", size: 10, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
// Should only create src node once
|
||||
const src = tree.find((n) => n.name === "src");
|
||||
expect(src).toBeDefined();
|
||||
expect(src!.children).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTree — alphabetical sort within same level", () => {
|
||||
it("sorts alphabetically at each level", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "zebra.txt", size: 1, dir: false },
|
||||
{ path: "apple.txt", size: 1, dir: false },
|
||||
{ path: "banana.txt", size: 1, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree.map((n) => n.name)).toEqual(["apple.txt", "banana.txt", "zebra.txt"]);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ interface Props {
|
||||
}
|
||||
|
||||
import { deriveWsBaseUrl } from "@/lib/ws-url";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
const WS_URL = deriveWsBaseUrl();
|
||||
|
||||
@@ -88,6 +87,8 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
|
||||
/** Runtimes that don't expose a TTY. Keep narrow — only add a runtime
|
||||
* here when its provisioner genuinely has no shell endpoint, otherwise
|
||||
* the user loses access to a real debugging surface. */
|
||||
const RUNTIMES_WITHOUT_TERMINAL = new Set(["external"]);
|
||||
|
||||
export function TerminalTab({ workspaceId, data }: Props) {
|
||||
// Early-return for runtimes that have no shell. Skips the entire
|
||||
// xterm + WebSocket dance below — without this, mounting the tab
|
||||
@@ -95,7 +96,7 @@ export function TerminalTab({ workspaceId, data }: Props) {
|
||||
// workspace-server (no /ws/terminal/<id> route registered for it),
|
||||
// and shows "Connection failed" with a Reconnect button — confusing
|
||||
// because the workspace IS healthy, just doesn't have a TTY.
|
||||
if (data && isExternalLikeRuntime(data.runtime)) {
|
||||
if (data && RUNTIMES_WITHOUT_TERMINAL.has(data.runtime)) {
|
||||
return <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ const SAMPLE_INFO = {
|
||||
hermes_channel_snippet: "# hermes ws=ws-test",
|
||||
codex_snippet: "# codex ws=ws-test",
|
||||
openclaw_snippet: "# openclaw ws=ws-test",
|
||||
kimi_snippet: "# kimi ws=ws-test",
|
||||
};
|
||||
|
||||
describe("ExternalConnectionSection", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for deriveProvidersFromModels — pure vendor-slug extractor from
|
||||
* a model list used in ConfigTab.tsx.
|
||||
*
|
||||
* Takes ModelSpec[] and returns a deduplicated array of vendor strings.
|
||||
* Vendor is derived by splitting on ":" (anthropic:claude-opus-4-7) or
|
||||
* "/" (nousresearch/hermes-4-70b). Order is preserved from input.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveProvidersFromModels } from "../ConfigTab";
|
||||
|
||||
// Local type mirror (not exported from ConfigTab)
|
||||
interface ModelSpec {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
describe("deriveProvidersFromModels", () => {
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(deriveProvidersFromModels([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts vendor from colon-separated id", () => {
|
||||
const models: ModelSpec[] = [{ id: "anthropic:claude-sonnet-4-5" }];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic"]);
|
||||
});
|
||||
|
||||
it("extracts vendor from slash-separated id", () => {
|
||||
const models: ModelSpec[] = [{ id: "nousresearch/hermes-4-70b" }];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["nousresearch"]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated vendors", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "anthropic:claude-opus-4-7" },
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{ id: "openai:gpt-4o" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic", "openai"]);
|
||||
});
|
||||
|
||||
it("skips models with no id", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{},
|
||||
{ id: undefined },
|
||||
{ id: "" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic"]);
|
||||
});
|
||||
|
||||
it("skips ids with no vendor separator", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "claude-sonnet-4-5" },
|
||||
{ id: "unknown/runtime" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["unknown"]);
|
||||
});
|
||||
|
||||
it("skips empty string id", () => {
|
||||
const models: ModelSpec[] = [{ id: "" }];
|
||||
expect(deriveProvidersFromModels(models)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves first-occurrence order", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "openai:gpt-4o" },
|
||||
{ id: "anthropic:claude-opus-4-7" },
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{ id: "google:gemini-2-5-flash" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual([
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles mix of valid and invalid ids", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{},
|
||||
{ id: "openai:gpt-4o-mini" },
|
||||
{ id: "" },
|
||||
{ id: "no-separator" },
|
||||
{ id: "anthropic:claude-opus-4-7" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["openai", "anthropic"]);
|
||||
});
|
||||
|
||||
it("is pure — same input always returns same output", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{ id: "openai:gpt-4o" },
|
||||
{ id: "google:gemini-2-5-flash" },
|
||||
];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic", "openai", "google"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for extractReplyText — the A2A result-path text extractor used
|
||||
* in ChatTab.tsx.
|
||||
*
|
||||
* extractReplyText pulls the agent's text reply out of an A2A response.
|
||||
* Concatenates ALL text parts (joined with "\n") rather than returning
|
||||
* just the first. Claude Code and other runtimes commonly emit multi-
|
||||
* part text replies for long content (markdown tables, code blocks),
|
||||
* and the prior "first part wins" implementation silently truncated
|
||||
* the rest. Mirrors extractTextsFromParts in message-parser.ts.
|
||||
*
|
||||
* Note: extractReplyText is scoped to the result.parts + result.artifacts
|
||||
* path — unlike extractResponseText which also handles body.task / body.text /
|
||||
* body.response_preview. It is the correct extractor for live A2A
|
||||
* responses where the text lives on result.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractReplyText } from "../ChatTab";
|
||||
|
||||
describe("extractReplyText — A2A result path", () => {
|
||||
it("returns empty string for undefined response", () => {
|
||||
expect(extractReplyText(undefined as never)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null result", () => {
|
||||
expect(extractReplyText({ result: null as never })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when result has no parts or artifacts", () => {
|
||||
expect(extractReplyText({ result: {} })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when parts array is empty", () => {
|
||||
expect(extractReplyText({ result: { parts: [] } })).toBe("");
|
||||
});
|
||||
|
||||
it("extracts text from a single text part", () => {
|
||||
expect(
|
||||
extractReplyText({ result: { parts: [{ kind: "text", text: "Hello world" }] } })
|
||||
).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("concatenates multiple text parts with newlines (no truncation)", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [
|
||||
{ kind: "text", text: "# Header" },
|
||||
{ kind: "text", text: "| Col |" },
|
||||
{ kind: "text", text: "| --- |" },
|
||||
{ kind: "text", text: "| Row |" },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("# Header\n| Col |\n| --- |\n| Row |");
|
||||
});
|
||||
|
||||
it("skips non-text parts", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [
|
||||
{ kind: "image", text: "should be ignored" },
|
||||
{ kind: "text", text: "visible" },
|
||||
{ kind: "file", text: "also ignored" },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("visible");
|
||||
});
|
||||
|
||||
it("skips text parts with empty string", () => {
|
||||
expect(extractReplyText({ result: { parts: [{ kind: "text", text: "" }] } })).toBe("");
|
||||
});
|
||||
|
||||
it("skips parts with missing text field", () => {
|
||||
expect(extractReplyText({ result: { parts: [{ kind: "text" }] } })).toBe("");
|
||||
});
|
||||
|
||||
it("walks artifacts and collects their text parts", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "Artifact one" }] },
|
||||
{ parts: [{ kind: "text", text: "Artifact two" }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("Artifact one\nArtifact two");
|
||||
});
|
||||
|
||||
it("combines result.parts AND result.artifacts text (both sources)", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [{ kind: "text", text: "Summary" }],
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "Detail block one" }] },
|
||||
{ parts: [{ kind: "text", text: "Detail block two" }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("Summary\nDetail block one\nDetail block two");
|
||||
});
|
||||
|
||||
it("artifacts are processed even when parts are empty", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [],
|
||||
artifacts: [{ parts: [{ kind: "text", text: "Only artifact" }] }],
|
||||
},
|
||||
})
|
||||
).toBe("Only artifact");
|
||||
});
|
||||
|
||||
it("artifacts with empty parts array contribute nothing", () => {
|
||||
expect(extractReplyText({ result: { artifacts: [{ parts: [] }] } })).toBe("");
|
||||
});
|
||||
|
||||
it("multiple artifacts each contribute their text", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "A" }, { kind: "text", text: "B" }] },
|
||||
{ parts: [{ kind: "text", text: "C" }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("A\nB\nC");
|
||||
});
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentLightbox — fullscreen modal for image / PDF preview.
|
||||
*
|
||||
* Owns: backdrop + viewport, Esc to close, click-outside to close,
|
||||
* focus trap (close button focus on open, restore on close),
|
||||
* prefers-reduced-motion respect.
|
||||
*
|
||||
* Coverage:
|
||||
* - Null when open=false
|
||||
* - Renders dialog with correct ARIA roles and label when open
|
||||
* - Close button present and wired
|
||||
* - Focus moves to close button on open
|
||||
* - Focus restores to previous element on close
|
||||
* - Esc key closes via document listener
|
||||
* - Click outside closes
|
||||
* - Click on content does NOT close (stopPropagation)
|
||||
* - Cleanup removes document listener on unmount
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AttachmentLightbox } from "../AttachmentLightbox";
|
||||
|
||||
// ─── Mock children ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MockContent = ({ onClick }: { onClick?: () => void }) => (
|
||||
<img
|
||||
src="file:///test.png"
|
||||
alt="test preview"
|
||||
onClick={onClick}
|
||||
data-testid="lightbox-content"
|
||||
/>
|
||||
);
|
||||
|
||||
// ─── Setup / teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentLightbox — render", () => {
|
||||
it("renders nothing when open=false", () => {
|
||||
render(
|
||||
<AttachmentLightbox
|
||||
open={false}
|
||||
onClose={vi.fn()}
|
||||
ariaLabel="Preview image"
|
||||
>
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeNull();
|
||||
});
|
||||
|
||||
it("renders dialog with role=dialog when open", () => {
|
||||
render(
|
||||
<AttachmentLightbox
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
ariaLabel="Preview image"
|
||||
>
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets aria-modal=true on dialog", () => {
|
||||
render(
|
||||
<AttachmentLightbox
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
ariaLabel="Preview image"
|
||||
>
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog?.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("applies aria-label to dialog", () => {
|
||||
render(
|
||||
<AttachmentLightbox
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
ariaLabel="Preview image: photo.png"
|
||||
>
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog?.getAttribute("aria-label")).toBe("Preview image: photo.png");
|
||||
});
|
||||
|
||||
it("renders children inside the dialog", () => {
|
||||
render(
|
||||
<AttachmentLightbox
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
ariaLabel="Preview"
|
||||
>
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
const img = document.querySelector("img");
|
||||
expect(img).toBeTruthy();
|
||||
expect(img?.getAttribute("alt")).toBe("test preview");
|
||||
});
|
||||
|
||||
it("renders close button with correct aria-label", () => {
|
||||
render(
|
||||
<AttachmentLightbox
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
ariaLabel="Preview"
|
||||
>
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
const closeBtn = document.querySelector('button[aria-label="Close preview"]');
|
||||
expect(closeBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Focus management ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentLightbox — focus management", () => {
|
||||
it("focuses the close button when opened", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
// Advance timers so the useEffect runs (it uses setTimeout 0 internally)
|
||||
vi.advanceTimersByTime(0);
|
||||
const closeBtn = document.querySelector('button[aria-label="Close preview"]');
|
||||
expect(closeBtn).toBe(document.activeElement);
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
vi.advanceTimersByTime(0);
|
||||
const closeBtn = document.querySelector('button[aria-label="Close preview"]')!;
|
||||
fireEvent.click(closeBtn);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyboard interaction ──────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentLightbox — keyboard", () => {
|
||||
it("calls onClose when Escape is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
vi.advanceTimersByTime(0);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call onClose for non-Escape keys", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
vi.advanceTimersByTime(0);
|
||||
fireEvent.keyDown(document, { key: "Enter" });
|
||||
fireEvent.keyDown(document, { key: " " });
|
||||
fireEvent.keyDown(document, { key: "a" });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Click interaction ────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentLightbox — click", () => {
|
||||
it("calls onClose when clicking the backdrop (outer div)", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
vi.advanceTimersByTime(0);
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
fireEvent.click(dialog);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT call onClose when clicking the content area (stopPropagation)", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
vi.advanceTimersByTime(0);
|
||||
const content = document.querySelector('[data-testid="lightbox-content"]');
|
||||
expect(content).toBeTruthy();
|
||||
fireEvent.click(content!);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentLightbox — cleanup", () => {
|
||||
it("removes document keydown listener on unmount", () => {
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(
|
||||
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
vi.advanceTimersByTime(0);
|
||||
unmount();
|
||||
// After unmount, keyDown should not call onClose (listener removed)
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -298,7 +298,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
|
||||
<button
|
||||
onClick={() => setGlobalMode(false)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
|
||||
!globalMode ? "bg-accent-strong/20 text-accent border border-accent/30" : "text-ink-soft hover:text-ink-mid"
|
||||
!globalMode ? "bg-accent-strong/20 text-accent border border-accent/30" : "text-white-soft hover:text-white-mid"
|
||||
}`}
|
||||
>
|
||||
This Workspace
|
||||
@@ -306,7 +306,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
|
||||
<button
|
||||
onClick={() => setGlobalMode(true)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 ${
|
||||
globalMode ? "bg-amber-600/20 text-warm border border-amber-500/30" : "text-ink-soft hover:text-ink-mid"
|
||||
globalMode ? "bg-amber-600/20 text-warm border border-amber-500/30" : "text-white-soft hover:text-white-mid"
|
||||
}`}
|
||||
>
|
||||
Global (All Workspaces)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user