Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd4e46458c | |||
| b16e1330f1 | |||
| 22acf8721e | |||
| 06cf6a9ca7 | |||
| 6217345c63 | |||
| 53d6597995 | |||
| b544028e93 | |||
| 50489da786 | |||
| 77f11c79d9 | |||
| e2a52696c3 | |||
| d180bd3188 | |||
| 6625c3be12 | |||
| 2e0007e713 | |||
| a9351ae47d | |||
| 4dce9800a5 | |||
| 11fc33a55f | |||
| ebeea0a9c1 | |||
| 0970feef70 |
@@ -98,11 +98,13 @@ except ImportError:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracker comment regex.
|
||||
# Matches: `# mc#1234`, `# internal#42`, `# mc#1234 - description`
|
||||
# Also matches trackers embedded mid-sentence: `# see mc#1234 for details`
|
||||
# Does NOT match: `# mc1234` (missing inner #), `mc#1234` (no leading
|
||||
# `#` comment marker), `# MC#1234` (case-sensitive — `mc` and `internal`
|
||||
# are conventional lower-case repo slugs).
|
||||
# comment `#`), `# MC#1234` (case-sensitive). The search is line-wide,
|
||||
# not just at the comment-marker prefix — fixes false-negative when
|
||||
# the tracker appears mid-sentence (e.g. `internal#350` after prose).
|
||||
TRACKER_RE = re.compile(
|
||||
r"#\s*(?P<slug>mc|internal)#(?P<num>\d+)\b"
|
||||
r"(?P<slug>mc|internal)#(?P<num>\d+)\b"
|
||||
)
|
||||
|
||||
# Truthy continue-on-error values we treat as "true". PyYAML decodes
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
#!/usr/bin/env python3
|
||||
"""lint-pre-flip-continue-on-error — block a PR that flips a job from
|
||||
``continue-on-error: true`` to ``continue-on-error: false`` (or removes
|
||||
the key while the base had it ``true``) without proof that the job's
|
||||
recent runs on the target branch are actually green.
|
||||
|
||||
Empirical class — PR #656 / mc#664:
|
||||
PR #656 (RFC internal#219 Phase 4) flipped 5 ``platform-build``-class
|
||||
jobs ``continue-on-error: true → false`` on the basis of a
|
||||
"verified green on main via combined-status check". But that "green"
|
||||
was the LIE produced by the prior ``continue-on-error: true``:
|
||||
Gitea Quirk #10 (internal#342 + dup #287) — when a step inside a
|
||||
job marked ``continue-on-error: true`` fails, the job-level status
|
||||
is still rolled up as ``success``. So the precondition the PR
|
||||
claimed to verify was structurally fooled by the bug being
|
||||
flipped.
|
||||
|
||||
mc#664 then captured the surfaced defects (2 unrelated, mutually-
|
||||
masked regressions):
|
||||
|
||||
Class 1: sqlmock helper drift since 2f36bb9a (24 days old)
|
||||
Class 2: OFFSEC-001 contract collision since 7d1a189f (1 day old)
|
||||
|
||||
Codified 04:35Z as hongming-pc2 charter §SOP-N rule (e)
|
||||
"run-log-grep-before-flip": pull the actual run log + grep for
|
||||
``--- FAIL`` / ``FAIL\\s`` BEFORE flipping; don't trust the masked
|
||||
combined-status.
|
||||
|
||||
This script structurally enforces that rule at PR time.
|
||||
|
||||
How it works (one PR tick):
|
||||
1. Parse the diff: compare ``.gitea/workflows/*.yml`` at PR base
|
||||
vs PR head. For each file present in both, parse the YAML AST
|
||||
and walk ``jobs.<key>.continue-on-error`` on each side. A
|
||||
"flip" is base ∈ {true} AND head ∈ {false, None/absent}. We
|
||||
coerce truthy/falsy per YAML semantics (PyYAML normalizes
|
||||
``true``/``True``/``yes`` to ``True``).
|
||||
2. For each flipped job, derive its commit-status context name as
|
||||
``"{workflow.name} / {job.name or job.key} (push)"`` — that's
|
||||
how Gitea Actions emits the context for runs on
|
||||
``main``/``staging`` (push event, see also expected_context()
|
||||
in ci-required-drift.py).
|
||||
3. Pull the last N commits of the target branch (PR base), fetch
|
||||
combined commit-status per commit, scan ``statuses[]`` for
|
||||
contexts matching ANY of the flipped jobs. For each match,
|
||||
fetch the actual run log via the web-UI route
|
||||
``{server_url}/{repo}/actions/runs/{run_id}/jobs/{job_idx}/logs``
|
||||
(per memory ``reference_gitea_actions_log_fetch`` — Gitea 1.22.6
|
||||
lacks REST ``/actions/runs/*`` endpoints; the web-UI route is the
|
||||
only working path; see ``reference_gitea_1_22_6_lacks_rest_rerun_endpoints``).
|
||||
4. Grep each log for the Go-test failure markers ``--- FAIL`` /
|
||||
``FAIL\\s+<package>`` AND the bash-step error sentinel
|
||||
``::error::``. If ANY recent log shows any of these AND the
|
||||
status itself reads ``success``, the job was masked. ``::error::``
|
||||
the flip with the offending test name + offending run URL +
|
||||
the regression commit (HEAD of the run).
|
||||
5. Exit 1 if any flips have at least one masked run; exit 0
|
||||
otherwise.
|
||||
|
||||
Halt-on-noise contract:
|
||||
- If a recent log fetch 404s (already-pruned-via-act_runner-gc,
|
||||
transient gitea-web outage): emit ``::warning::`` and treat the
|
||||
run as "log unavailable" — does NOT block the flip; logged so
|
||||
a curious reviewer can re-run.
|
||||
- If a flipped job has ZERO recent runs on the target branch (newly
|
||||
added workflow): emit ``::warning::`` "no run history to verify"
|
||||
and allow the flip. This is the only way a NEW workflow can ever
|
||||
ship with ``continue-on-error: false``; otherwise we'd have a
|
||||
chicken-and-egg.
|
||||
|
||||
Behavior-based AST gate per ``feedback_behavior_based_ast_gates``:
|
||||
- YAML parsed via PyYAML safe_load on BOTH sides of the diff
|
||||
- No grep-by-line — formatting changes (comment churn, key order)
|
||||
don't false-positive a flip
|
||||
- Job-key match — so a rename ``platform-build → core-be-build``
|
||||
appears as a DELETE + an ADD, not a flip (the delete side has no
|
||||
new value to compare against; the add side has no base side).
|
||||
|
||||
Run locally (works against this repo, requires PyYAML + Gitea token
|
||||
that can read combined-commit-status):
|
||||
|
||||
GITEA_TOKEN=... GITEA_HOST=git.moleculesai.app \\
|
||||
REPO=molecule-ai/molecule-core BASE_REF=main \\
|
||||
BASE_SHA=$(git rev-parse origin/main) \\
|
||||
HEAD_SHA=$(git rev-parse HEAD) \\
|
||||
python3 .gitea/scripts/lint_pre_flip_continue_on_error.py \\
|
||||
--dry-run
|
||||
|
||||
Cross-links: PR#656, mc#664, PR#665 (the interim re-mask),
|
||||
Quirk #10 (internal#342 + dup #287), hongming-pc2 charter §SOP-N
|
||||
rule (e), feedback_strict_root_only_after_class_a,
|
||||
feedback_no_shared_persona_token_use.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
import yaml # PyYAML 6.0.2 — installed by the workflow before this runs.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Environment (read at module-import; runtime contract enforced in main())
|
||||
# --------------------------------------------------------------------------
|
||||
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")
|
||||
BASE_REF = _env("BASE_REF", default="main")
|
||||
BASE_SHA = _env("BASE_SHA")
|
||||
HEAD_SHA = _env("HEAD_SHA")
|
||||
# How many recent commits to scan on the target branch. 5 by default;
|
||||
# enough to catch a job that only fails intermittently, not so many
|
||||
# that the script paginates needlessly. Per spec.
|
||||
RECENT_COMMITS_N = int(_env("RECENT_COMMITS_N", default="5"))
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
WEB = f"https://{GITEA_HOST}" if GITEA_HOST else ""
|
||||
|
||||
# Failure markers we grep for in the run log.
|
||||
# --- FAIL — Go test failure marker
|
||||
# FAIL\s — `FAIL github.com/x/y` package-level rollup
|
||||
# ::error:: — bash-step `::error::` lines (the lint-curl-status-capture
|
||||
# pattern: a `python3 <<PY` block writing `::error::` then
|
||||
# sys.exit(1); also any shell `echo "::error::..."` from
|
||||
# jobs that wrap pytest/eslint/etc. and convert
|
||||
# non-zero exits into masked-by-CoE status)
|
||||
FAIL_PATTERNS = (
|
||||
"--- FAIL",
|
||||
"FAIL\t",
|
||||
"FAIL ",
|
||||
"::error::",
|
||||
)
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "BASE_REF", "BASE_SHA", "HEAD_SHA"):
|
||||
if not os.environ.get(key):
|
||||
sys.stderr.write(f"::error::missing required env var: {key}\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tiny HTTP helper (no requests dependency)
|
||||
# Mirrors the api()/ApiError contract in ci-required-drift.py +
|
||||
# main-red-watchdog.py per feedback_api_helper_must_raise_not_return_dict.
|
||||
# --------------------------------------------------------------------------
|
||||
class ApiError(RuntimeError):
|
||||
"""Raised when a Gitea API/web call cannot be trusted to have succeeded.
|
||||
|
||||
Soft-failure on non-2xx is the duplicate-write bug factory in
|
||||
find-or-create flows (PR #112 Five-Axis). Here it would mean a
|
||||
transient gitea-web 502 silently allows a flip whose recent runs
|
||||
we couldn't actually verify — exactly the regression class this
|
||||
lint exists to close.
|
||||
"""
|
||||
|
||||
|
||||
def http(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
body: dict | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
expect_json: bool = True,
|
||||
timeout: int = 30,
|
||||
) -> tuple[int, Any, bytes]:
|
||||
"""Tiny HTTP helper around urllib.
|
||||
|
||||
Returns (status, parsed_or_None, raw_bytes). Raises ApiError on any
|
||||
non-2xx response. ``expect_json=False`` returns raw bytes in the
|
||||
parsed slot (for log-fetch from the web-UI which returns text/plain).
|
||||
"""
|
||||
final_headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Accept": "application/json" if expect_json else "text/plain",
|
||||
}
|
||||
if headers:
|
||||
final_headers.update(headers)
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
final_headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=final_headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read() or b""
|
||||
status = e.code
|
||||
|
||||
if not (200 <= status < 300):
|
||||
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
|
||||
raise ApiError(f"{method} {url} → HTTP {status}: {snippet}")
|
||||
|
||||
if not expect_json:
|
||||
return status, raw, raw
|
||||
if not raw:
|
||||
return status, None, raw
|
||||
try:
|
||||
return status, json.loads(raw), raw
|
||||
except json.JSONDecodeError as e:
|
||||
raise ApiError(f"{method} {url} → HTTP {status} but body is not JSON: {e}") from e
|
||||
|
||||
|
||||
def api(method: str, path: str, *, body: dict | None = None, query: dict[str, str] | None = None) -> tuple[int, Any]:
|
||||
"""Read-shaped Gitea REST helper. Path is API-relative (``/repos/...``)."""
|
||||
url = f"{API}{path}"
|
||||
if query:
|
||||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
||||
status, parsed, _ = http(method, url, body=body, expect_json=True)
|
||||
return status, parsed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# YAML parsing — coerce truthy/falsy for continue-on-error
|
||||
# --------------------------------------------------------------------------
|
||||
def _coerce_coe(val: Any) -> bool:
|
||||
"""Coerce a continue-on-error YAML value to bool.
|
||||
|
||||
PyYAML safe_load normalizes ``true``/``True``/``yes``/``on`` to
|
||||
Python ``True`` and ``false``/``False``/``no``/``off`` / absence
|
||||
to ``False`` (we treat absence/None as False here too — that's the
|
||||
GitHub Actions default semantics).
|
||||
|
||||
Edge cases:
|
||||
- String ``"true"`` (quoted in YAML) — kept as the string
|
||||
``"true"``, falsy under bool() but a flip we DO care about
|
||||
catching. Normalize string forms case-insensitively to bool
|
||||
so the diff is consistent with the runtime behavior of
|
||||
Gitea Actions, which YAML-parses the same way.
|
||||
"""
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if val is None:
|
||||
return False
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in ("true", "yes", "on", "1")
|
||||
return bool(val)
|
||||
|
||||
|
||||
def jobs_coe_map(workflow_doc: dict) -> dict[str, bool]:
|
||||
"""Return ``{job_key: continue_on_error_bool}`` for every job in
|
||||
the workflow. Job-level ``continue-on-error`` only — does NOT
|
||||
descend into per-step ``continue-on-error`` (step-level CoE
|
||||
masking is a separate class and is handled by the test suite
|
||||
+ reviewer, not by this gate — see Future Work in the workflow
|
||||
YAML).
|
||||
"""
|
||||
out: dict[str, bool] = {}
|
||||
jobs = workflow_doc.get("jobs")
|
||||
if not isinstance(jobs, dict):
|
||||
return out
|
||||
for key, job in jobs.items():
|
||||
if not isinstance(job, dict):
|
||||
continue
|
||||
out[key] = _coerce_coe(job.get("continue-on-error"))
|
||||
return out
|
||||
|
||||
|
||||
def workflow_name(workflow_doc: dict, *, fallback: str = "") -> str:
|
||||
"""Top-level ``name:`` of the workflow. Falls back to the filename
|
||||
(without extension) per Gitea Actions semantics."""
|
||||
n = workflow_doc.get("name")
|
||||
if isinstance(n, str) and n.strip():
|
||||
return n.strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def job_display_name(workflow_doc: dict, job_key: str) -> str:
|
||||
"""``jobs.<key>.name`` if present, else the key. Mirrors
|
||||
expected_context() in ci-required-drift.py."""
|
||||
job = workflow_doc.get("jobs", {}).get(job_key)
|
||||
if isinstance(job, dict):
|
||||
n = job.get("name")
|
||||
if isinstance(n, str) and n.strip():
|
||||
return n.strip()
|
||||
return job_key
|
||||
|
||||
|
||||
def context_name(workflow_name_str: str, job_name_str: str, event: str = "push") -> str:
|
||||
"""Render the commit-status context the way Gitea Actions emits it.
|
||||
Default ``event="push"`` because recent-runs-on-main are push events;
|
||||
callers can override to ``"pull_request"`` for PR-context lookups."""
|
||||
return f"{workflow_name_str} / {job_name_str} ({event})"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Diff detection — flips, not arbitrary changes
|
||||
# --------------------------------------------------------------------------
|
||||
def detect_flips(
|
||||
base_workflows: dict[str, str],
|
||||
head_workflows: dict[str, str],
|
||||
) -> list[dict]:
|
||||
"""Compare per-file CoE maps; return a list of flip records.
|
||||
|
||||
Inputs are ``{path: yaml_text}`` for both sides. Output records
|
||||
have the shape::
|
||||
|
||||
{
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_name": "CI",
|
||||
"job_key": "platform-build",
|
||||
"job_name": "Platform (Go)",
|
||||
"context": "CI / Platform (Go) (push)",
|
||||
}
|
||||
|
||||
A flip is base[CoE] ∈ {True} AND head[CoE] ∈ {False}. Files
|
||||
only present on one side are skipped — adding a new workflow
|
||||
with ``CoE: false`` is fine (no history to mask), and removing
|
||||
a workflow can't possibly flip anything.
|
||||
"""
|
||||
flips: list[dict] = []
|
||||
for path, base_text in base_workflows.items():
|
||||
if path not in head_workflows:
|
||||
continue
|
||||
try:
|
||||
base_doc = yaml.safe_load(base_text) or {}
|
||||
head_doc = yaml.safe_load(head_workflows[path]) or {}
|
||||
except yaml.YAMLError as e:
|
||||
# Don't block on a parse error — the YAML lint workflows
|
||||
# catch invalid YAML separately. Just warn so the failing
|
||||
# file is visible.
|
||||
sys.stderr.write(f"::warning file={path}::YAML parse error: {e}\n")
|
||||
continue
|
||||
if not isinstance(base_doc, dict) or not isinstance(head_doc, dict):
|
||||
continue
|
||||
base_map = jobs_coe_map(base_doc)
|
||||
head_map = jobs_coe_map(head_doc)
|
||||
wf_name = workflow_name(head_doc, fallback=os.path.basename(path).rsplit(".", 1)[0])
|
||||
for job_key, base_val in base_map.items():
|
||||
if job_key not in head_map:
|
||||
continue # job removed — not a flip
|
||||
if base_val is True and head_map[job_key] is False:
|
||||
flips.append({
|
||||
"workflow_path": path,
|
||||
"workflow_name": wf_name,
|
||||
"job_key": job_key,
|
||||
"job_name": job_display_name(head_doc, job_key),
|
||||
"context": context_name(wf_name, job_display_name(head_doc, job_key), "push"),
|
||||
})
|
||||
return flips
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Git: snapshot every .gitea/workflows/*.yml at a SHA (no checkout)
|
||||
# --------------------------------------------------------------------------
|
||||
def _git(*args: str, cwd: str | None = None) -> str:
|
||||
"""Run ``git`` and return stdout (text)."""
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=cwd,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"git {args!r} failed: {result.stderr.strip()}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def workflows_at_sha(sha: str, *, repo_dir: str | None = None) -> dict[str, str]:
|
||||
"""Read every ``.gitea/workflows/*.yml`` blob at ``sha``.
|
||||
|
||||
Uses ``git ls-tree`` + ``git show`` so we never need to check out
|
||||
the SHA (the workflow runs on the PR head; the base SHA is
|
||||
fetched, not checked out).
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
listing = _git("ls-tree", "-r", "--name-only", sha, ".gitea/workflows/", cwd=repo_dir)
|
||||
for line in listing.splitlines():
|
||||
line = line.strip()
|
||||
if not line.endswith((".yml", ".yaml")):
|
||||
continue
|
||||
try:
|
||||
blob = _git("show", f"{sha}:{line}", cwd=repo_dir)
|
||||
except RuntimeError:
|
||||
# Symlink or other non-blob; skip.
|
||||
continue
|
||||
out[line] = blob
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gitea: recent commits + per-commit combined status + log fetch
|
||||
# --------------------------------------------------------------------------
|
||||
def recent_commits_on_branch(branch: str, n: int) -> list[str]:
|
||||
"""Last `n` commit SHAs on ``branch`` (oldest→newest is fine; we
|
||||
treat them as a set). Uses the REST ``/commits`` endpoint with
|
||||
``sha=branch&limit=n``."""
|
||||
_, body = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/commits",
|
||||
query={"sha": branch, "limit": str(n)},
|
||||
)
|
||||
if not isinstance(body, list):
|
||||
raise ApiError(f"/commits for {branch} returned non-list: {type(body).__name__}")
|
||||
out: list[str] = []
|
||||
for c in body:
|
||||
if isinstance(c, dict):
|
||||
sha = c.get("sha") or (c.get("commit", {}) or {}).get("id")
|
||||
if isinstance(sha, str) and len(sha) >= 7:
|
||||
out.append(sha)
|
||||
return out
|
||||
|
||||
|
||||
def combined_status(sha: str) -> dict:
|
||||
"""Combined commit status for a SHA. Same shape as
|
||||
``main-red-watchdog.get_combined_status``."""
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"combined-status for {sha} not a dict")
|
||||
return body
|
||||
|
||||
|
||||
def _entry_state(s: dict) -> str:
|
||||
"""Per-entry state — Gitea 1.22.6 schema asymmetry: top-level
|
||||
uses ``state``, per-entry uses ``status``. Defensive fallback per
|
||||
main-red-watchdog.py line 233."""
|
||||
return s.get("status") or s.get("state") or ""
|
||||
|
||||
|
||||
def fetch_log(target_url: str) -> str | None:
|
||||
"""Fetch a job log given its web-UI ``target_url`` (e.g.
|
||||
``/molecule-ai/molecule-core/actions/runs/13494/jobs/0``).
|
||||
|
||||
Per ``reference_gitea_actions_log_fetch``: append ``/logs`` to the
|
||||
job route. Per ``reference_gitea_1_22_6_lacks_rest_rerun_endpoints``:
|
||||
Gitea 1.22.6 lacks the REST ``/api/v1/.../actions/runs/*`` path; the
|
||||
web-UI route is the only working endpoint until 1.24+.
|
||||
|
||||
Returns the log text on success, ``None`` on 404 / log-pruned /
|
||||
network error (caller treats None as "log unavailable, warn-not-fail").
|
||||
"""
|
||||
if not target_url:
|
||||
return None
|
||||
# Normalize: target_url may be relative ("/owner/repo/...") or
|
||||
# absolute. Both need ``/logs`` appended to the job sub-path.
|
||||
if target_url.startswith("/"):
|
||||
url = f"{WEB}{target_url}"
|
||||
else:
|
||||
url = target_url
|
||||
if not url.endswith("/logs"):
|
||||
url = f"{url}/logs"
|
||||
try:
|
||||
_, body, _ = http("GET", url, expect_json=False, timeout=60)
|
||||
except ApiError as e:
|
||||
sys.stderr.write(f"::warning::log fetch failed for {url}: {e}\n")
|
||||
return None
|
||||
if isinstance(body, bytes):
|
||||
return body.decode("utf-8", errors="replace")
|
||||
return None
|
||||
|
||||
|
||||
def grep_fail_markers(log_text: str) -> list[str]:
|
||||
"""Return up to 5 sample matching lines for any FAIL_PATTERNS hit.
|
||||
Empty list = clean log."""
|
||||
matches: list[str] = []
|
||||
for line in log_text.splitlines():
|
||||
for pat in FAIL_PATTERNS:
|
||||
if pat in line:
|
||||
# Truncate to keep error output bounded.
|
||||
matches.append(line.strip()[:240])
|
||||
break
|
||||
if len(matches) >= 5:
|
||||
break
|
||||
return matches
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Verification: for one flip, scan recent runs on BASE_REF
|
||||
# --------------------------------------------------------------------------
|
||||
def verify_flip(flip: dict, branch: str, n: int) -> dict:
|
||||
"""Scan the last ``n`` commits on ``branch``. For each commit whose
|
||||
combined status contains a context matching ``flip["context"]``,
|
||||
fetch the run log and grep for FAIL markers.
|
||||
|
||||
Returns::
|
||||
|
||||
{
|
||||
"flip": flip,
|
||||
"checked_commits": int, # how many commits had a matching context
|
||||
"masked_runs": [ # runs where log shows FAIL despite status==success
|
||||
{"sha": "...", "status": "success", "target_url": "...", "samples": [...]},
|
||||
...
|
||||
],
|
||||
"fail_runs": [ # runs where status itself is failure/error
|
||||
{"sha": "...", "status": "failure", "target_url": "...", "samples": [...]},
|
||||
...
|
||||
],
|
||||
"warnings": [str], # log-unavailable warnings (not blocking)
|
||||
}
|
||||
|
||||
Blocking condition: ``masked_runs`` OR ``fail_runs`` non-empty.
|
||||
A ``success`` status with a clean log is the only "OK to flip"
|
||||
outcome (per hongming-pc2 §SOP-N rule (e)).
|
||||
"""
|
||||
target_context = flip["context"]
|
||||
result = {
|
||||
"flip": flip,
|
||||
"checked_commits": 0,
|
||||
"masked_runs": [],
|
||||
"fail_runs": [],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
shas = recent_commits_on_branch(branch, n)
|
||||
if not shas:
|
||||
result["warnings"].append(
|
||||
f"no recent commits on {branch} (cannot verify flip)"
|
||||
)
|
||||
return result
|
||||
|
||||
for sha in shas:
|
||||
try:
|
||||
status_doc = combined_status(sha)
|
||||
except ApiError as e:
|
||||
result["warnings"].append(f"combined-status for {sha}: {e}")
|
||||
continue
|
||||
statuses = status_doc.get("statuses") or []
|
||||
# First entry matching the context name. Newest SHAs come
|
||||
# first; one entry per context per SHA is the usual shape.
|
||||
for s in statuses:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
if s.get("context") != target_context:
|
||||
continue
|
||||
result["checked_commits"] += 1
|
||||
state = _entry_state(s)
|
||||
target_url = s.get("target_url") or ""
|
||||
log_text = fetch_log(target_url)
|
||||
if log_text is None:
|
||||
result["warnings"].append(
|
||||
f"log unavailable for {sha} {target_context}"
|
||||
)
|
||||
# Still record the status itself if it's red — that's
|
||||
# a hard signal that doesn't need log access.
|
||||
if state in ("failure", "error"):
|
||||
result["fail_runs"].append({
|
||||
"sha": sha,
|
||||
"status": state,
|
||||
"target_url": target_url,
|
||||
"samples": ["[log unavailable; status itself is " + state + "]"],
|
||||
})
|
||||
break
|
||||
samples = grep_fail_markers(log_text)
|
||||
if state in ("failure", "error"):
|
||||
result["fail_runs"].append({
|
||||
"sha": sha,
|
||||
"status": state,
|
||||
"target_url": target_url,
|
||||
"samples": samples or ["[no FAIL markers found but status is " + state + "]"],
|
||||
})
|
||||
elif samples and state == "success":
|
||||
# The bug class: status==success while log shows FAIL.
|
||||
# That's exactly Quirk #10 (continue-on-error masking).
|
||||
result["masked_runs"].append({
|
||||
"sha": sha,
|
||||
"status": state,
|
||||
"target_url": target_url,
|
||||
"samples": samples,
|
||||
})
|
||||
# Either way, we matched one context entry for this SHA;
|
||||
# don't keep looping `statuses[]`.
|
||||
break
|
||||
|
||||
if result["checked_commits"] == 0:
|
||||
result["warnings"].append(
|
||||
f"no runs of {target_context!r} found in the last {n} commits on "
|
||||
f"{branch} — cannot verify; allowing flip with warning"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Report rendering
|
||||
# --------------------------------------------------------------------------
|
||||
def render_flip_report(verdict: dict) -> str:
|
||||
flip = verdict["flip"]
|
||||
lines = [
|
||||
f"job: {flip['job_key']} ({flip['context']})",
|
||||
f" workflow: {flip['workflow_path']}",
|
||||
f" checked_commits: {verdict['checked_commits']}",
|
||||
]
|
||||
for run in verdict["fail_runs"]:
|
||||
url = run["target_url"]
|
||||
# target_url may be relative; render the absolute form for
|
||||
# click-through.
|
||||
if url.startswith("/"):
|
||||
url = f"{WEB}{url}"
|
||||
lines.append(f" fail run {run['sha'][:10]} (status={run['status']}): {url}")
|
||||
for sample in run["samples"]:
|
||||
lines.append(f" | {sample}")
|
||||
for run in verdict["masked_runs"]:
|
||||
url = run["target_url"]
|
||||
if url.startswith("/"):
|
||||
url = f"{WEB}{url}"
|
||||
lines.append(
|
||||
f" MASKED run {run['sha'][:10]} (status=success, log shows FAIL): {url}"
|
||||
)
|
||||
for sample in run["samples"]:
|
||||
lines.append(f" | {sample}")
|
||||
for w in verdict["warnings"]:
|
||||
lines.append(f" warning: {w}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Main
|
||||
# --------------------------------------------------------------------------
|
||||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="lint-pre-flip-continue-on-error",
|
||||
description="Block a PR that flips continue-on-error true→false "
|
||||
"without proof recent runs are actually green.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Detect + print findings to stdout; never exit non-zero. "
|
||||
"Useful for local testing.",
|
||||
)
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(argv)
|
||||
_require_runtime_env()
|
||||
|
||||
base_workflows = workflows_at_sha(BASE_SHA)
|
||||
head_workflows = workflows_at_sha(HEAD_SHA)
|
||||
flips = detect_flips(base_workflows, head_workflows)
|
||||
|
||||
if not flips:
|
||||
print("::notice::no continue-on-error true→false flips in this PR")
|
||||
return 0
|
||||
|
||||
print(f"::notice::detected {len(flips)} continue-on-error true→false flip(s); verifying recent runs on {BASE_REF}")
|
||||
bad_flips: list[dict] = []
|
||||
for flip in flips:
|
||||
verdict = verify_flip(flip, BASE_REF, RECENT_COMMITS_N)
|
||||
report = render_flip_report(verdict)
|
||||
if verdict["fail_runs"] or verdict["masked_runs"]:
|
||||
print(f"::error file={flip['workflow_path']}::flip of {flip['job_key']} "
|
||||
f"({flip['context']}) blocked — recent runs on {BASE_REF} show "
|
||||
f"FAIL markers OR are red. Pull each run log below + grep "
|
||||
f"`--- FAIL` / `FAIL ` / `::error::` — DON'T trust the masked "
|
||||
f"combined-status. See hongming-pc2 charter §SOP-N rule (e). "
|
||||
f"PR#656 / mc#664 reference class.")
|
||||
bad_flips.append(verdict)
|
||||
else:
|
||||
print(f"::notice::flip of {flip['job_key']} ({flip['context']}) is safe — "
|
||||
f"{verdict['checked_commits']} recent run(s), no FAIL markers")
|
||||
# Always print the per-flip detail block so the human-readable
|
||||
# report is in the run log for both safe and unsafe flips.
|
||||
print(f"::group::flip detail: {flip['job_key']}")
|
||||
print(report)
|
||||
print("::endgroup::")
|
||||
|
||||
if bad_flips and not args.dry_run:
|
||||
print(f"::error::{len(bad_flips)}/{len(flips)} flip(s) failed pre-flip verification")
|
||||
return 1
|
||||
if bad_flips and args.dry_run:
|
||||
print(f"::warning::[dry-run] {len(bad_flips)}/{len(flips)} flip(s) WOULD fail; exit 0 forced")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Unit tests for .gitea/scripts/lint_pre_flip_continue_on_error.py.
|
||||
|
||||
These tests pin the pure-logic surface (flip detection + per-flip
|
||||
verdict aggregation) without making real HTTP calls. The end-to-end
|
||||
git ls-tree + Gitea API path is exercised by running the workflow
|
||||
against real PRs.
|
||||
|
||||
Run locally::
|
||||
|
||||
python3 -m unittest .gitea/scripts/tests/test_lint_pre_flip_continue_on_error.py -v
|
||||
|
||||
Mirrors the pattern in scripts/ops/test_check_migration_collisions.py
|
||||
+ scripts/test_build_runtime_package.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
# Load the script as a module without invoking main(). Tests must NOT
|
||||
# depend on the full runtime env contract (GITEA_TOKEN etc.), so we
|
||||
# import individual functions and stub the network surface explicitly.
|
||||
SCRIPT_PATH = Path(__file__).resolve().parent.parent / "lint_pre_flip_continue_on_error.py"
|
||||
spec = importlib.util.spec_from_file_location("lpfc", SCRIPT_PATH)
|
||||
lpfc = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(lpfc)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixtures: minimal valid workflow YAML on each side of a "diff"
|
||||
# --------------------------------------------------------------------------
|
||||
CI_YML_BASE = """\
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
platform-build:
|
||||
name: Platform (Go)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- run: echo platform
|
||||
canvas-build:
|
||||
name: Canvas (Next.js)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- run: echo canvas
|
||||
all-required:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
needs: [platform-build, canvas-build]
|
||||
steps:
|
||||
- run: echo ok
|
||||
"""
|
||||
|
||||
CI_YML_HEAD_FLIPPED = """\
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
platform-build:
|
||||
name: Platform (Go)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- run: echo platform
|
||||
canvas-build:
|
||||
name: Canvas (Next.js)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- run: echo canvas
|
||||
all-required:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
needs: [platform-build, canvas-build]
|
||||
steps:
|
||||
- run: echo ok
|
||||
"""
|
||||
|
||||
CI_YML_HEAD_NO_DIFF = CI_YML_BASE # identical to base, no flip
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. CoE coercion (truthy/falsy/quoted/absent)
|
||||
# --------------------------------------------------------------------------
|
||||
class TestCoerceCoE(unittest.TestCase):
|
||||
def test_python_bool_true(self):
|
||||
self.assertTrue(lpfc._coerce_coe(True))
|
||||
|
||||
def test_python_bool_false(self):
|
||||
self.assertFalse(lpfc._coerce_coe(False))
|
||||
|
||||
def test_none_is_false(self):
|
||||
# GitHub Actions default: absent == false.
|
||||
self.assertFalse(lpfc._coerce_coe(None))
|
||||
|
||||
def test_string_true_lowercase(self):
|
||||
# Quoted "true" in YAML — Gitea Actions normalizes to True.
|
||||
self.assertTrue(lpfc._coerce_coe("true"))
|
||||
|
||||
def test_string_True_titlecase(self):
|
||||
self.assertTrue(lpfc._coerce_coe("True"))
|
||||
|
||||
def test_string_yes(self):
|
||||
# YAML 1.1 truthy form.
|
||||
self.assertTrue(lpfc._coerce_coe("yes"))
|
||||
|
||||
def test_string_false(self):
|
||||
self.assertFalse(lpfc._coerce_coe("false"))
|
||||
|
||||
def test_string_random_falsy(self):
|
||||
# An unrecognized string is treated as falsy — safer than
|
||||
# silently coercing "maybe" to True and false-positiving a
|
||||
# flip.
|
||||
self.assertFalse(lpfc._coerce_coe("maybe"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Diff detection — flips, not arbitrary changes
|
||||
# --------------------------------------------------------------------------
|
||||
class TestDetectFlips(unittest.TestCase):
|
||||
def test_no_flip_in_diff_passes(self):
|
||||
# Acceptance test #1: PR doesn't flip continue-on-error → 0 flips.
|
||||
flips = lpfc.detect_flips(
|
||||
{".gitea/workflows/ci.yml": CI_YML_BASE},
|
||||
{".gitea/workflows/ci.yml": CI_YML_HEAD_NO_DIFF},
|
||||
)
|
||||
self.assertEqual(flips, [])
|
||||
|
||||
def test_flip_detected_in_one_file(self):
|
||||
flips = lpfc.detect_flips(
|
||||
{".gitea/workflows/ci.yml": CI_YML_BASE},
|
||||
{".gitea/workflows/ci.yml": CI_YML_HEAD_FLIPPED},
|
||||
)
|
||||
# Two jobs flipped: platform-build, canvas-build. all-required
|
||||
# is still true on both sides.
|
||||
self.assertEqual(len(flips), 2)
|
||||
keys = sorted(f["job_key"] for f in flips)
|
||||
self.assertEqual(keys, ["canvas-build", "platform-build"])
|
||||
|
||||
def test_context_name_render(self):
|
||||
flips = lpfc.detect_flips(
|
||||
{".gitea/workflows/ci.yml": CI_YML_BASE},
|
||||
{".gitea/workflows/ci.yml": CI_YML_HEAD_FLIPPED},
|
||||
)
|
||||
platform = next(f for f in flips if f["job_key"] == "platform-build")
|
||||
self.assertEqual(platform["context"], "CI / Platform (Go) (push)")
|
||||
self.assertEqual(platform["workflow_name"], "CI")
|
||||
|
||||
def test_context_falls_back_to_job_key_when_no_name(self):
|
||||
base = "name: WF\njobs:\n foo:\n continue-on-error: true\n runs-on: x\n steps: []\n"
|
||||
head = "name: WF\njobs:\n foo:\n continue-on-error: false\n runs-on: x\n steps: []\n"
|
||||
flips = lpfc.detect_flips({"a.yml": base}, {"a.yml": head})
|
||||
self.assertEqual(len(flips), 1)
|
||||
self.assertEqual(flips[0]["context"], "WF / foo (push)")
|
||||
|
||||
def test_no_flip_when_only_one_side_has_file(self):
|
||||
# Newly added workflow file — head has CoE:false, base has no
|
||||
# file. Adding a new workflow with CoE:false is fine; there's
|
||||
# nothing to mask.
|
||||
flips = lpfc.detect_flips(
|
||||
{}, # base has no workflow files
|
||||
{".gitea/workflows/new.yml": CI_YML_HEAD_FLIPPED},
|
||||
)
|
||||
self.assertEqual(flips, [])
|
||||
|
||||
def test_no_flip_when_job_removed(self):
|
||||
# Job exists on base, not on head — a removal, not a flip.
|
||||
head = """\
|
||||
name: CI
|
||||
jobs:
|
||||
canvas-build:
|
||||
name: Canvas (Next.js)
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps: []
|
||||
"""
|
||||
flips = lpfc.detect_flips(
|
||||
{".gitea/workflows/ci.yml": CI_YML_BASE},
|
||||
{".gitea/workflows/ci.yml": head},
|
||||
)
|
||||
self.assertEqual(flips, [])
|
||||
|
||||
def test_no_flip_when_job_added_with_false(self):
|
||||
# New job on head with CoE:false — no base side; not a flip.
|
||||
head_with_new = CI_YML_BASE.replace(
|
||||
" all-required:",
|
||||
" newjob:\n name: New Job\n continue-on-error: false\n"
|
||||
" runs-on: x\n steps: []\n"
|
||||
" all-required:",
|
||||
)
|
||||
flips = lpfc.detect_flips(
|
||||
{".gitea/workflows/ci.yml": CI_YML_BASE},
|
||||
{".gitea/workflows/ci.yml": head_with_new},
|
||||
)
|
||||
self.assertEqual(flips, [])
|
||||
|
||||
def test_yaml_parse_error_warns_not_raises(self):
|
||||
# Malformed YAML on head — should warn (stderr) and skip,
|
||||
# not raise.
|
||||
bad_head = "name: CI\njobs:\n :::\n"
|
||||
# Capture stderr so the test isn't noisy.
|
||||
with mock.patch.object(sys, "stderr"):
|
||||
flips = lpfc.detect_flips(
|
||||
{".gitea/workflows/ci.yml": CI_YML_BASE},
|
||||
{".gitea/workflows/ci.yml": bad_head},
|
||||
)
|
||||
self.assertEqual(flips, [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. grep_fail_markers — the regex / substring matcher
|
||||
# --------------------------------------------------------------------------
|
||||
class TestGrepFailMarkers(unittest.TestCase):
|
||||
def test_clean_log_returns_empty(self):
|
||||
log = "===== test run starting =====\nPASS\nok example.com/foo 1.234s\n"
|
||||
self.assertEqual(lpfc.grep_fail_markers(log), [])
|
||||
|
||||
def test_go_minus_minus_minus_fail_caught(self):
|
||||
log = "ok example.com/foo 1.234s\n--- FAIL: TestBar (0.01s)\n bar_test.go:42:\n"
|
||||
matches = lpfc.grep_fail_markers(log)
|
||||
self.assertEqual(len(matches), 1)
|
||||
self.assertIn("FAIL: TestBar", matches[0])
|
||||
|
||||
def test_go_package_fail_caught(self):
|
||||
log = "FAIL\texample.com/baz\t1.234s\n"
|
||||
matches = lpfc.grep_fail_markers(log)
|
||||
self.assertEqual(len(matches), 1)
|
||||
self.assertIn("FAIL", matches[0])
|
||||
|
||||
def test_bash_error_directive_caught(self):
|
||||
# `lint-curl-status-capture` pattern: a python heredoc inside a
|
||||
# bash step that prints `::error::` then sys.exit(1). With
|
||||
# continue-on-error:true the job rolls up as success despite
|
||||
# this line. THAT's the masking we're trying to catch.
|
||||
log = "Running scan...\n::error::Found 3 curl-status-capture pollution site(s):\n"
|
||||
matches = lpfc.grep_fail_markers(log)
|
||||
self.assertEqual(len(matches), 1)
|
||||
self.assertIn("::error::", matches[0])
|
||||
|
||||
def test_caps_matches_at_max_5(self):
|
||||
log = "\n".join(["--- FAIL: T%d" % i for i in range(20)])
|
||||
matches = lpfc.grep_fail_markers(log)
|
||||
self.assertEqual(len(matches), 5)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. verify_flip — single-flip verdict assembly (network surface stubbed)
|
||||
# --------------------------------------------------------------------------
|
||||
def _stub_status(context: str, state: str, target_url: str = "/owner/repo/actions/runs/1/jobs/0") -> dict:
|
||||
"""Build a single-context combined-status response."""
|
||||
return {
|
||||
"state": state,
|
||||
"statuses": [
|
||||
{"context": context, "status": state, "target_url": target_url, "description": ""}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
FLIP_FIXTURE = {
|
||||
"workflow_path": ".gitea/workflows/ci.yml",
|
||||
"workflow_name": "CI",
|
||||
"job_key": "platform-build",
|
||||
"job_name": "Platform (Go)",
|
||||
"context": "CI / Platform (Go) (push)",
|
||||
}
|
||||
|
||||
|
||||
class TestVerifyFlip(unittest.TestCase):
|
||||
def test_flip_with_clean_history_passes(self):
|
||||
# Acceptance test #2: flip detected, last 5 runs clean → exit 0.
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=["sha1", "sha2", "sha3"]):
|
||||
with mock.patch.object(
|
||||
lpfc, "combined_status",
|
||||
side_effect=[_stub_status(FLIP_FIXTURE["context"], "success") for _ in range(3)],
|
||||
):
|
||||
with mock.patch.object(lpfc, "fetch_log", return_value="ok example.com/foo 1s\nPASS\n"):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(verdict["fail_runs"], [])
|
||||
self.assertEqual(verdict["masked_runs"], [])
|
||||
self.assertEqual(verdict["checked_commits"], 3)
|
||||
self.assertEqual(verdict["warnings"], [])
|
||||
|
||||
def test_flip_with_recent_fail_blocks(self):
|
||||
# Acceptance test #3: flip detected, recent run has --- FAIL → exit 1.
|
||||
# Setup: 3 commits, the most recent run's log shows --- FAIL
|
||||
# but the STATUS is success (Quirk #10 mask). That's the
|
||||
# masked_runs case.
|
||||
log_with_fail = "ok example.com/foo 1s\n--- FAIL: TestSqlmock (0.01s)\n sqlmock_test.go:42:\n"
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=["sha1", "sha2", "sha3"]):
|
||||
with mock.patch.object(
|
||||
lpfc, "combined_status",
|
||||
side_effect=[_stub_status(FLIP_FIXTURE["context"], "success") for _ in range(3)],
|
||||
):
|
||||
with mock.patch.object(lpfc, "fetch_log", side_effect=[log_with_fail, "PASS\n", "PASS\n"]):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(len(verdict["masked_runs"]), 1)
|
||||
self.assertEqual(verdict["masked_runs"][0]["sha"], "sha1")
|
||||
self.assertTrue(any("TestSqlmock" in s for s in verdict["masked_runs"][0]["samples"]))
|
||||
self.assertEqual(verdict["fail_runs"], [])
|
||||
|
||||
def test_red_status_alone_blocks(self):
|
||||
# Status itself is `failure` — block without needing log
|
||||
# markers. (Belt-and-braces: even with a clean log, a `failure`
|
||||
# status means the job's exit code was non-zero.)
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=["sha1"]):
|
||||
with mock.patch.object(
|
||||
lpfc, "combined_status",
|
||||
return_value=_stub_status(FLIP_FIXTURE["context"], "failure"),
|
||||
):
|
||||
with mock.patch.object(lpfc, "fetch_log", return_value="some unrelated text\n"):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(len(verdict["fail_runs"]), 1)
|
||||
self.assertEqual(verdict["fail_runs"][0]["status"], "failure")
|
||||
|
||||
def test_unreadable_log_warns_not_blocks(self):
|
||||
# Acceptance test #5: log fetch 404 (None) → warn, not block.
|
||||
# Status is `success`, log is None — we can't tell, so we warn
|
||||
# and allow.
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=["sha1"]):
|
||||
with mock.patch.object(
|
||||
lpfc, "combined_status",
|
||||
return_value=_stub_status(FLIP_FIXTURE["context"], "success"),
|
||||
):
|
||||
with mock.patch.object(lpfc, "fetch_log", return_value=None):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(verdict["fail_runs"], [])
|
||||
self.assertEqual(verdict["masked_runs"], [])
|
||||
self.assertTrue(any("log unavailable" in w for w in verdict["warnings"]))
|
||||
|
||||
def test_unreadable_log_with_failure_status_still_blocks(self):
|
||||
# Edge case: log fetch fails BUT the status itself is `failure`.
|
||||
# We can still block — the status alone is sufficient signal,
|
||||
# we don't need the log to confirm.
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=["sha1"]):
|
||||
with mock.patch.object(
|
||||
lpfc, "combined_status",
|
||||
return_value=_stub_status(FLIP_FIXTURE["context"], "failure"),
|
||||
):
|
||||
with mock.patch.object(lpfc, "fetch_log", return_value=None):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(len(verdict["fail_runs"]), 1)
|
||||
self.assertIn("log unavailable", verdict["fail_runs"][0]["samples"][0])
|
||||
|
||||
def test_zero_runs_history_warns_allows(self):
|
||||
# No commits with a matching context — newly added workflow.
|
||||
# Allow with warning.
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=["sha1", "sha2"]):
|
||||
with mock.patch.object(
|
||||
lpfc, "combined_status",
|
||||
return_value={"state": "success", "statuses": []}, # no matching context
|
||||
):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(verdict["checked_commits"], 0)
|
||||
self.assertEqual(verdict["fail_runs"], [])
|
||||
self.assertEqual(verdict["masked_runs"], [])
|
||||
self.assertTrue(any("no runs of" in w for w in verdict["warnings"]))
|
||||
|
||||
def test_zero_commits_warns_allows(self):
|
||||
# Empty branch (newly created repo, e.g.). Allow with warning.
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=[]):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(verdict["checked_commits"], 0)
|
||||
self.assertEqual(verdict["fail_runs"], [])
|
||||
self.assertEqual(verdict["masked_runs"], [])
|
||||
self.assertTrue(any("no recent commits" in w for w in verdict["warnings"]))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. Multiple-flip aggregation in main()
|
||||
# --------------------------------------------------------------------------
|
||||
class TestMainAggregation(unittest.TestCase):
|
||||
"""Tests that `main()` aggregates multiple flips and exits 1 when
|
||||
ANY one of them has a masked or red recent run. Acceptance test #4.
|
||||
|
||||
We stub at the verify_flip + workflows_at_sha + _require_runtime_env
|
||||
boundary so we don't need real git or HTTP.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# The actual env values are irrelevant — _require_runtime_env
|
||||
# is stubbed out — but the module reads OWNER/NAME at import
|
||||
# time. Patch the runtime env contract to a no-op for the
|
||||
# duration of each test.
|
||||
self._patches = [
|
||||
mock.patch.object(lpfc, "_require_runtime_env", return_value=None),
|
||||
mock.patch.object(lpfc, "BASE_REF", "main"),
|
||||
mock.patch.object(lpfc, "BASE_SHA", "deadbeefcafe"),
|
||||
mock.patch.object(lpfc, "HEAD_SHA", "feedfaceabad"),
|
||||
mock.patch.object(lpfc, "RECENT_COMMITS_N", 5),
|
||||
]
|
||||
for p in self._patches:
|
||||
p.start()
|
||||
self.addCleanup(lambda: [p.stop() for p in self._patches])
|
||||
|
||||
def test_multiple_flips_aggregated_one_bad_blocks(self):
|
||||
# PR flips 3 jobs; 1 has a recent fail → exit 1, naming that job.
|
||||
flips = [
|
||||
{"workflow_path": ".gitea/workflows/ci.yml", "workflow_name": "CI",
|
||||
"job_key": "platform-build", "job_name": "Platform (Go)",
|
||||
"context": "CI / Platform (Go) (push)"},
|
||||
{"workflow_path": ".gitea/workflows/ci.yml", "workflow_name": "CI",
|
||||
"job_key": "canvas-build", "job_name": "Canvas (Next.js)",
|
||||
"context": "CI / Canvas (Next.js) (push)"},
|
||||
{"workflow_path": ".gitea/workflows/ci.yml", "workflow_name": "CI",
|
||||
"job_key": "python-lint", "job_name": "Python Lint & Test",
|
||||
"context": "CI / Python Lint & Test (push)"},
|
||||
]
|
||||
clean = {"flip": flips[0], "checked_commits": 5, "masked_runs": [],
|
||||
"fail_runs": [], "warnings": []}
|
||||
bad = {"flip": flips[1], "checked_commits": 5,
|
||||
"masked_runs": [{"sha": "abc1234567", "status": "success",
|
||||
"target_url": "/x/y/actions/runs/1/jobs/0",
|
||||
"samples": ["--- FAIL: TestSqlmock"]}],
|
||||
"fail_runs": [], "warnings": []}
|
||||
also_clean = {"flip": flips[2], "checked_commits": 5, "masked_runs": [],
|
||||
"fail_runs": [], "warnings": []}
|
||||
|
||||
with mock.patch.object(lpfc, "workflows_at_sha", return_value={}):
|
||||
with mock.patch.object(lpfc, "detect_flips", return_value=flips):
|
||||
with mock.patch.object(lpfc, "verify_flip",
|
||||
side_effect=[clean, bad, also_clean]):
|
||||
# Capture stdout to assert on naming.
|
||||
captured = []
|
||||
with mock.patch("builtins.print", side_effect=lambda *a, **k: captured.append(" ".join(str(x) for x in a))):
|
||||
rc = lpfc.main([])
|
||||
self.assertEqual(rc, 1)
|
||||
# The blocking error message must name the failing job.
|
||||
joined = "\n".join(captured)
|
||||
self.assertIn("canvas-build", joined)
|
||||
# And it must mention the empirical class so a reviewer can
|
||||
# cross-link the right RFC.
|
||||
self.assertTrue("mc#664" in joined or "PR#656" in joined)
|
||||
|
||||
def test_no_flips_in_diff_exits_zero(self):
|
||||
# Acceptance test #1 at main() level: empty flips → exit 0.
|
||||
with mock.patch.object(lpfc, "workflows_at_sha", return_value={}):
|
||||
with mock.patch.object(lpfc, "detect_flips", return_value=[]):
|
||||
rc = lpfc.main([])
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
def test_all_flips_clean_exits_zero(self):
|
||||
flips = [{"workflow_path": ".gitea/workflows/ci.yml", "workflow_name": "CI",
|
||||
"job_key": "platform-build", "job_name": "Platform (Go)",
|
||||
"context": "CI / Platform (Go) (push)"}]
|
||||
clean = {"flip": flips[0], "checked_commits": 5, "masked_runs": [],
|
||||
"fail_runs": [], "warnings": []}
|
||||
with mock.patch.object(lpfc, "workflows_at_sha", return_value={}):
|
||||
with mock.patch.object(lpfc, "detect_flips", return_value=flips):
|
||||
with mock.patch.object(lpfc, "verify_flip", return_value=clean):
|
||||
rc = lpfc.main([])
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
def test_dry_run_forces_exit_zero_even_with_bad_flip(self):
|
||||
# --dry-run never fails, even when verification finds masked runs.
|
||||
flips = [{"workflow_path": ".gitea/workflows/ci.yml", "workflow_name": "CI",
|
||||
"job_key": "platform-build", "job_name": "Platform (Go)",
|
||||
"context": "CI / Platform (Go) (push)"}]
|
||||
bad = {"flip": flips[0], "checked_commits": 5,
|
||||
"masked_runs": [{"sha": "abc1234567", "status": "success",
|
||||
"target_url": "/x/y/actions/runs/1/jobs/0",
|
||||
"samples": ["--- FAIL: TestSqlmock"]}],
|
||||
"fail_runs": [], "warnings": []}
|
||||
with mock.patch.object(lpfc, "workflows_at_sha", return_value={}):
|
||||
with mock.patch.object(lpfc, "detect_flips", return_value=flips):
|
||||
with mock.patch.object(lpfc, "verify_flip", return_value=bad):
|
||||
rc = lpfc.main(["--dry-run"])
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. Context-name rendering (the format Gitea Actions actually emits)
|
||||
# --------------------------------------------------------------------------
|
||||
class TestContextName(unittest.TestCase):
|
||||
def test_push_event(self):
|
||||
self.assertEqual(
|
||||
lpfc.context_name("CI", "Platform (Go)", "push"),
|
||||
"CI / Platform (Go) (push)",
|
||||
)
|
||||
|
||||
def test_pull_request_event(self):
|
||||
self.assertEqual(
|
||||
lpfc.context_name("CI", "Platform (Go)", "pull_request"),
|
||||
"CI / Platform (Go) (pull_request)",
|
||||
)
|
||||
|
||||
def test_workflow_name_falls_back_to_filename(self):
|
||||
# No top-level `name:` → falls back to filename minus extension.
|
||||
doc = {"jobs": {"foo": {"continue-on-error": True}}}
|
||||
self.assertEqual(
|
||||
lpfc.workflow_name(doc, fallback="my-workflow"),
|
||||
"my-workflow",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -32,6 +32,14 @@ on:
|
||||
# iterating all open PRs when PR_NUMBER is empty.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
# read: contents — for checkout (base ref, not PR head for security)
|
||||
# read: pull-requests — for reading PR info via API
|
||||
# write: pull-requests — for posting/updating gate-check comments
|
||||
# Without this the token cannot POST/PATCH /issues/comments → 403.
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
# PRs. Pre-existing continue-on-error: true directives on main
|
||||
# all violate this lint at first — intentional. Flip to false
|
||||
# follow-up after main is clean for 3 days. internal#350.
|
||||
continue-on-error: true
|
||||
continue-on-error: true # internal#350 Phase 3 mask — 14d forced-renewal cadence
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
name: Lint pre-flip continue-on-error
|
||||
|
||||
# Pre-merge gate: blocks PRs that flip `continue-on-error: true → false`
|
||||
# on any job in `.gitea/workflows/*.yml` WITHOUT proof that the affected
|
||||
# job's recent runs on the target branch (PR base) are actually green.
|
||||
#
|
||||
# Empirical class: PR #656 / mc#664. PR #656 (RFC internal#219 Phase 4)
|
||||
# flipped 5 platform-build-class jobs `continue-on-error: true → false`
|
||||
# on the basis of a "verified green on main via combined-status check".
|
||||
# But that "green" was the LIE the prior `continue-on-error: true`
|
||||
# produced: Gitea Quirk #10 (internal#342 + dup #287) — a failed step
|
||||
# inside a `continue-on-error: true` job rolls up to a `success`
|
||||
# job-level status. The precondition the PR claimed to verify was
|
||||
# structurally fooled by the bug being flipped.
|
||||
#
|
||||
# mc#664 captured the surfaced defects (2 mutually-masked regressions):
|
||||
# - Class 1: sqlmock helper drift since 2f36bb9a (24 days old)
|
||||
# - Class 2: OFFSEC-001 contract collision since 7d1a189f (1 day old)
|
||||
#
|
||||
# Codified 04:35Z as hongming-pc2 charter §SOP-N rule (e)
|
||||
# "run-log-grep-before-flip" — now structurally enforced here at PR
|
||||
# time, ahead of merge.
|
||||
#
|
||||
# How the gate works:
|
||||
# 1. Read every `.gitea/workflows/*.yml` at the PR base SHA AND at
|
||||
# the PR head SHA via `git show <sha>:<path>` (no checkout
|
||||
# needed).
|
||||
# 2. Parse both sides via PyYAML AST (NOT grep — per
|
||||
# `feedback_behavior_based_ast_gates`). Walk `jobs.<key>.
|
||||
# continue-on-error` on each side. A flip is base=true,
|
||||
# head=false.
|
||||
# 3. For each flipped job, render the commit-status context as
|
||||
# `"{workflow.name} / {job.name or job.key} (push)"` — that's
|
||||
# how Gitea Actions emits the per-context status on `main`/
|
||||
# `staging` runs.
|
||||
# 4. Pull last 5 commits on the PR base branch, fetch combined
|
||||
# commit-status per commit, scan for the target context. For
|
||||
# each match, fetch the run log via the web-UI route
|
||||
# `{server_url}/{repo}/actions/runs/{run_id}/jobs/{job_idx}/logs`
|
||||
# (per `reference_gitea_actions_log_fetch` —
|
||||
# Gitea 1.22.6 lacks REST `/actions/runs/*`; web-UI is the
|
||||
# only working path, see also
|
||||
# `reference_gitea_1_22_6_lacks_rest_rerun_endpoints`).
|
||||
# 5. Grep each log for `--- FAIL`, `FAIL\s`, `::error::`. If
|
||||
# the status is `success` but the log shows any of these,
|
||||
# the job was masked. Block the PR with `::error::`.
|
||||
#
|
||||
# Graceful-degrade contract (per task halt-conditions):
|
||||
# - Log fetch 404 (act_runner pruned the log, transient outage):
|
||||
# emit `::warning::` "log unavailable" — does NOT block.
|
||||
# - Zero recent runs of the flipped job's context on the base
|
||||
# branch (newly added workflow): emit `::warning::` "no run
|
||||
# history to verify" — allow the flip. Chicken-and-egg
|
||||
# exemption.
|
||||
# - YAML parse error in one of the workflow files: warn-only,
|
||||
# don't block — the YAML lint workflows catch this separately.
|
||||
#
|
||||
# Cross-links: PR#656, mc#664, PR#665 (interim re-mask),
|
||||
# Quirk #10 (internal#342 + dup #287), hongming-pc2 charter
|
||||
# §SOP-N rule (e), feedback_strict_root_only_after_class_a,
|
||||
# feedback_no_shared_persona_token_use.
|
||||
#
|
||||
# Phase contract (RFC internal#219 §1 ladder):
|
||||
# - This workflow lands at `continue-on-error: true` (Phase 3 —
|
||||
# surface defects without blocking). Follow-up PR flips it to
|
||||
# `false` ONLY after this workflow's own recent runs on `main`
|
||||
# are confirmed clean — exactly the discipline the workflow
|
||||
# itself enforces. Eat your own dogfood.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- '.gitea/workflows/**'
|
||||
- '.gitea/scripts/lint_pre_flip_continue_on_error.py'
|
||||
- '.gitea/workflows/lint-pre-flip-continue-on-error.yml'
|
||||
|
||||
env:
|
||||
# Per `feedback_act_runner_github_server_url` — without this,
|
||||
# actions/checkout and friends default to github.com → break.
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Need read on the API to pull combined commit-status + commit list
|
||||
# for the base branch. The job-log fetch uses the same token via
|
||||
# the web-UI route (Gitea 1.22.6 accepts `Authorization: token ...`
|
||||
# there).
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: lint-pre-flip-coe-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Verify continue-on-error flips have run-log proof
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
# Phase 3 (RFC internal#219 §1): surface broken flips without blocking
|
||||
# the PR yet. Follow-up flips this to `false` once the workflow itself
|
||||
# has clean recent runs on main. mc#664 interim — remove when CoE→false.
|
||||
continue-on-error: true # mc#664
|
||||
steps:
|
||||
- name: Check out PR head (full history for base-SHA access)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# `git show <base-sha>:<path>` needs the base SHA's blobs.
|
||||
# Shallow=1 would miss it. Same rationale as
|
||||
# check-migration-collisions.yml.
|
||||
fetch-depth: 0
|
||||
- name: Set up Python (PyYAML for AST parsing)
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install PyYAML
|
||||
# Same pin as ci-required-drift.yml — keep dependencies
|
||||
# uniform so a Gitea runner cache hits across both jobs.
|
||||
run: python -m pip install --quiet 'PyYAML==6.0.2'
|
||||
- name: Ensure base ref is reachable locally
|
||||
# `actions/checkout@v6 fetch-depth=0` usually pulls the base
|
||||
# too, but explicit-fetch is cheap insurance against the
|
||||
# form-of-ref differences across Gitea runner versions
|
||||
# (mirrors the comment in check-migration-collisions.yml).
|
||||
run: |
|
||||
git fetch origin "${{ github.event.pull_request.base.ref }}" || true
|
||||
- name: Run lint
|
||||
env:
|
||||
# Auto-injected by Gitea Actions; sufficient scope for
|
||||
# combined-status + commit-list + log fetch via web-UI
|
||||
# route. NO repo-admin needed (unlike the
|
||||
# branch_protections endpoint).
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
# Last 5 commits on the base branch is the spec default.
|
||||
RECENT_COMMITS_N: '5'
|
||||
run: python3 .gitea/scripts/lint_pre_flip_continue_on_error.py
|
||||
@@ -20,6 +20,12 @@ name: publish-workspace-server-image
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# mc#711: Docker daemon not accessible on ubuntu-latest runner (molecule-canonical-1
|
||||
# shows client-only in `docker info` — daemon not running). DinD mount is present but
|
||||
# daemon doesn't respond. Fix: add diagnostic step showing socket info so ops can
|
||||
# identify which runners have a live daemon. If no daemon is available, the job
|
||||
# fails fast with actionable output rather than silent deep failure.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -52,36 +58,25 @@ env:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
|
||||
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
|
||||
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
|
||||
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
|
||||
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
|
||||
# See issue #576 + infra-lead pulse ~00:30Z.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Health check: verify Docker daemon is accessible before attempting any
|
||||
# build steps. This fails loudly at step 1 when the runner's docker.sock
|
||||
# is inaccessible (e.g. permission change, daemon restart, or group-membership
|
||||
# drift) rather than silently continuing to step 2 where `docker build`
|
||||
# fails deep in the process with a cryptic ECR auth error that doesn't
|
||||
# surface the root cause. Also reports the daemon version so operator
|
||||
# can correlate with runner host logs.
|
||||
- name: Verify Docker daemon access
|
||||
- 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.
|
||||
@@ -100,9 +95,6 @@ jobs:
|
||||
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# clone-manifest.sh supports anonymous cloning for public repos (post-
|
||||
# 2026-05-08 migration). The token is only needed for private repos.
|
||||
# Do NOT require it — a missing secret would fail the build unnecessarily.
|
||||
mkdir -p .tenant-bundle-deps
|
||||
# Strip JSON5 comments before jq parsing — Integration Tester appends
|
||||
# `// Triggered by ...` which breaks `jq` in clone-manifest.sh.
|
||||
|
||||
@@ -91,19 +91,16 @@ export function SearchDialog() {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh]">
|
||||
{/* Backdrop — interactive dismiss area; aria-hidden so screen readers ignore it */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-pointer"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search workspaces"
|
||||
className="relative z-[71] w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
className="w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
|
||||
|
||||
@@ -101,20 +101,6 @@ describe("Esc — deselect / close context menu", () => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockStoreState.selectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
mockStoreState.contextMenu = null;
|
||||
mockStoreState.selectedNodeId = "n1";
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockStoreState.clearSelection).not.toHaveBeenCalled();
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enter — hierarchy navigation", () => {
|
||||
@@ -150,17 +136,6 @@ describe("Enter — hierarchy navigation", () => {
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cmd+]/[ — z-order bump", () => {
|
||||
@@ -185,17 +160,6 @@ describe("Cmd+]/[ — z-order bump", () => {
|
||||
fireEvent.keyDown(window, { key: "]", ctrlKey: true });
|
||||
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "]", metaKey: true });
|
||||
expect(mockStoreState.bumpZOrder).not.toHaveBeenCalled();
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Z — zoom-to-team", () => {
|
||||
@@ -248,17 +212,6 @@ describe("Z — zoom-to-team", () => {
|
||||
expect(dispatchedEvents).toHaveLength(0);
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it("skips when a modal dialog is open", () => {
|
||||
renderWithProvider();
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
document.body.appendChild(dialog);
|
||||
fireEvent.keyDown(window, { key: "z" });
|
||||
expect(dispatchedEvents).toHaveLength(0);
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Arrow keys — keyboard node movement", () => {
|
||||
|
||||
@@ -13,9 +13,7 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
/**
|
||||
* Canvas-wide keyboard shortcuts. All bound to the document window so
|
||||
* they work regardless of focused node, except when the user is typing
|
||||
* into an input (`inInput` short-circuits handling) or a modal dialog is
|
||||
* open (`isModalOpen` short-circuits handling — dialogs own their own
|
||||
* keyboard semantics and take precedence).
|
||||
* into an input (`inInput` short-circuits handling).
|
||||
*
|
||||
* Esc — close context menu, clear selection, deselect
|
||||
* Enter — descend into selected node's first child
|
||||
@@ -27,10 +25,6 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
* Cmd/Ctrl+Arrow — resize selected node (↑↓ height, ←→ width)
|
||||
* Cmd/Ctrl+Shift+Arrow — resize by 2px per press (fine control)
|
||||
*/
|
||||
/** Returns true when a modal dialog (role=dialog, aria-modal=true) is open. */
|
||||
const isModalOpen = () =>
|
||||
document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -42,7 +36,6 @@ export function useKeyboardShortcuts() {
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (isModalOpen()) return; // Dialogs own their own Escape semantics
|
||||
const state = useCanvasStore.getState();
|
||||
if (state.contextMenu) {
|
||||
state.closeContextMenu();
|
||||
@@ -54,9 +47,8 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
|
||||
// Figma-style hierarchy navigation. Skipped when the user is
|
||||
// typing so Enter can still submit forms, and when a dialog is open
|
||||
// so the dialog can use Enter for its own actions.
|
||||
if (!inInput && !isModalOpen() && (e.key === "Enter" || e.key === "NumpadEnter")) {
|
||||
// typing so Enter can still submit forms.
|
||||
if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) {
|
||||
e.preventDefault();
|
||||
const state = useCanvasStore.getState();
|
||||
const id = state.selectedNodeId;
|
||||
@@ -71,9 +63,6 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip when a modal is open so dialog shortcuts take precedence.
|
||||
if (isModalOpen()) return;
|
||||
|
||||
if (
|
||||
!inInput &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
@@ -122,7 +111,7 @@ export function useKeyboardShortcuts() {
|
||||
if (!selectedId) return;
|
||||
// Skip when a modal/dialog is already open — dialogs own their own
|
||||
// arrow-key semantics and shouldn't trigger canvas moves.
|
||||
if (isModalOpen()) return;
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 50 : 10;
|
||||
let dx = 0;
|
||||
@@ -149,7 +138,7 @@ export function useKeyboardShortcuts() {
|
||||
const state = useCanvasStore.getState();
|
||||
const selectedId = state.selectedNodeId;
|
||||
if (!selectedId) return;
|
||||
if (isModalOpen()) return;
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 2 : 10;
|
||||
const node = state.nodes.find((n) => n.id === selectedId);
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AgentCard — mobile agent row card.
|
||||
*
|
||||
* Per WCAG 2.1 AA:
|
||||
* - Rendered as <button> with aria-label composing accessible name
|
||||
* - aria-label includes: name, status, tier, remote flag
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AgentCard, type MobileAgent } from "../components";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const onlineAgent: MobileAgent = {
|
||||
id: "ws-1",
|
||||
name: "My Agent",
|
||||
tag: "claude-code",
|
||||
tier: "T2",
|
||||
status: "online",
|
||||
remote: false,
|
||||
runtime: "claude-code",
|
||||
skills: 3,
|
||||
calls: 12,
|
||||
desc: "Handles customer support",
|
||||
parentId: null,
|
||||
};
|
||||
|
||||
const remoteFailedAgent: MobileAgent = {
|
||||
id: "ws-2",
|
||||
name: "Remote Worker",
|
||||
tag: "external",
|
||||
tier: "T4",
|
||||
status: "failed",
|
||||
remote: true,
|
||||
runtime: "external",
|
||||
skills: 5,
|
||||
calls: 0,
|
||||
desc: "",
|
||||
parentId: "ws-1",
|
||||
};
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AgentCard — render", () => {
|
||||
it("renders as a button", () => {
|
||||
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
|
||||
expect(document.querySelector("button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("button has aria-label with name, status, tier", () => {
|
||||
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
const label = btn.getAttribute("aria-label") ?? "";
|
||||
expect(label).toContain("My Agent");
|
||||
expect(label).toContain("online");
|
||||
expect(label).toContain("T2");
|
||||
});
|
||||
|
||||
it("aria-label includes remote for remote agents", () => {
|
||||
render(<AgentCard agent={remoteFailedAgent} dark={false} onClick={vi.fn()} />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
const label = btn.getAttribute("aria-label") ?? "";
|
||||
expect(label).toContain("Remote Worker");
|
||||
expect(label).toContain("failed");
|
||||
expect(label).toContain("T4");
|
||||
expect(label).toContain("remote");
|
||||
});
|
||||
|
||||
it("aria-label omits remote for non-remote agents", () => {
|
||||
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
const label = btn.getAttribute("aria-label") ?? "";
|
||||
expect(label).not.toContain("remote");
|
||||
});
|
||||
|
||||
it("renders agent name text inside the button", () => {
|
||||
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
expect(btn.textContent).toContain("My Agent");
|
||||
});
|
||||
|
||||
it("compact prop reduces padding", () => {
|
||||
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} compact={true} />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
const style = btn.getAttribute("style") ?? "";
|
||||
// compact uses "12px 14px" padding vs "14px 16px" default
|
||||
expect(style).toContain("padding");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AgentCard — interaction", () => {
|
||||
it("calls onClick when button is clicked", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<AgentCard agent={onlineAgent} dark={false} onClick={onClick} />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
btn.click();
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders without onClick (optional prop)", () => {
|
||||
// Should not throw
|
||||
expect(() => render(<AgentCard agent={onlineAgent} dark={false} />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* FilterChips — mobile agent filter toolbar.
|
||||
*
|
||||
* Per WCAG 2.1 AA / ARIA radio group pattern:
|
||||
* - Container has role="toolbar" + aria-label
|
||||
* - Each button has role="radio" + aria-checked
|
||||
* - Icon spans have aria-hidden="true"
|
||||
* - Only one radio can be checked at a time (single-select filter)
|
||||
*
|
||||
* 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 { FilterChips, type AgentFilter } from "../components";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const defaultCounts = { all: 12, online: 8, issue: 2, paused: 2 };
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilterChips — render", () => {
|
||||
it("renders 4 filter buttons", () => {
|
||||
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||
const buttons = document.querySelectorAll('[role="radio"]');
|
||||
expect(buttons.length).toBe(4);
|
||||
});
|
||||
|
||||
it("container has role=toolbar and aria-label", () => {
|
||||
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||
const toolbar = document.querySelector('[role="toolbar"]');
|
||||
expect(toolbar).toBeTruthy();
|
||||
expect(toolbar?.getAttribute("aria-label")).toBe("Filter agents");
|
||||
});
|
||||
|
||||
it("each button has role=radio", () => {
|
||||
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||
const buttons = document.querySelectorAll('[role="radio"]');
|
||||
buttons.forEach((btn) => {
|
||||
expect(btn.getAttribute("role")).toBe("radio");
|
||||
});
|
||||
});
|
||||
|
||||
it("active filter has aria-checked=true, others false", () => {
|
||||
render(<FilterChips value="issue" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||
const buttons = document.querySelectorAll('[role="radio"]');
|
||||
buttons.forEach((btn) => {
|
||||
const label = btn.textContent ?? "";
|
||||
if (label.startsWith("Issues")) {
|
||||
expect(btn.getAttribute("aria-checked")).toBe("true");
|
||||
} else {
|
||||
expect(btn.getAttribute("aria-checked")).toBe("false");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("count spans have aria-hidden=true", () => {
|
||||
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||
const hidden = document.querySelectorAll('[aria-hidden="true"]');
|
||||
// Each chip has one count span marked aria-hidden
|
||||
expect(hidden.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilterChips — interaction", () => {
|
||||
it("calls onChange with correct filter id when clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FilterChips value="all" onChange={onChange} dark={false} counts={defaultCounts} />);
|
||||
const buttons = document.querySelectorAll('[role="radio"]');
|
||||
const onlineBtn = Array.from(buttons).find((b) => b.textContent?.startsWith("Online")) as Element;
|
||||
fireEvent.click(onlineBtn);
|
||||
expect(onChange).toHaveBeenCalledWith("online");
|
||||
});
|
||||
|
||||
it("calls onChange when the already-active filter is clicked (component does not guard)", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FilterChips value="all" onChange={onChange} dark={false} counts={defaultCounts} />);
|
||||
const buttons = document.querySelectorAll('[role="radio"]');
|
||||
const allBtn = Array.from(buttons).find((b) => b.textContent?.startsWith("All")) as Element;
|
||||
fireEvent.click(allBtn);
|
||||
// Component calls onChange even for the already-active filter;
|
||||
// the guard belongs at the consumer level (MobileHome) if needed.
|
||||
expect(onChange).toHaveBeenCalledWith("all");
|
||||
});
|
||||
|
||||
it("updating value prop changes aria-checked", () => {
|
||||
const { rerender } = render(
|
||||
<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />,
|
||||
);
|
||||
const allBtn = document.querySelector('[id="filter-all"]') as Element;
|
||||
expect(allBtn.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
rerender(<FilterChips value="paused" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||
expect(allBtn.getAttribute("aria-checked")).toBe("false");
|
||||
const pausedBtn = document.querySelector('[id="filter-paused"]') as Element;
|
||||
expect(pausedBtn.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("all filter labels are present", () => {
|
||||
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||
const texts = Array.from(document.querySelectorAll('[role="radio"]')).map((b) =>
|
||||
b.textContent?.trim(),
|
||||
);
|
||||
expect(texts.some((t) => t?.startsWith("All"))).toBe(true);
|
||||
expect(texts.some((t) => t?.startsWith("Online"))).toBe(true);
|
||||
expect(texts.some((t) => t?.startsWith("Issues"))).toBe(true);
|
||||
expect(texts.some((t) => t?.startsWith("Paused"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MobileCanvas — mobile mini-graph with pinch-zoom and tap-to-open.
|
||||
*
|
||||
* Per WCAG 2.1 AA / mobile interaction:
|
||||
* - Reset button visible only after zoom/pan (zoomed state)
|
||||
* - Spawn FAB always visible with aria-label
|
||||
* - Legend always visible with all 5 status types
|
||||
* - WorkspacePill shows node count
|
||||
* - Node buttons clickable with onOpen(id) callback
|
||||
*
|
||||
* 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 { MobileCanvas } from "../MobileCanvas";
|
||||
|
||||
// ─── Mock dependencies ──────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/theme-provider", () => ({
|
||||
useTheme: () => ({ theme: "dark", resolvedTheme: "dark", setTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockNodes = [
|
||||
{
|
||||
id: "ws-1",
|
||||
position: { x: 100, y: 200 },
|
||||
data: {
|
||||
name: "Alpha Agent",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
parentId: null,
|
||||
runtime: "langgraph",
|
||||
activeTasks: 0,
|
||||
role: "researcher",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ws-2",
|
||||
position: { x: 300, y: 400 },
|
||||
data: {
|
||||
name: "Beta Agent",
|
||||
status: "degraded",
|
||||
tier: 3,
|
||||
parentId: "ws-1",
|
||||
runtime: "claude-code",
|
||||
activeTasks: 1,
|
||||
role: "developer",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ws-3",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
name: "Gamma Agent",
|
||||
status: "offline",
|
||||
tier: 1,
|
||||
parentId: null,
|
||||
runtime: "hermes",
|
||||
activeTasks: 0,
|
||||
role: "analyst",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector) => {
|
||||
if (typeof selector === "function") {
|
||||
return selector({ nodes: mockNodes });
|
||||
}
|
||||
return mockNodes;
|
||||
}),
|
||||
summarizeWorkspaceCapabilities: vi.fn((data: { status?: string; role?: string }) => ({
|
||||
runtime: data.status ? "langgraph" : "unknown",
|
||||
skillCount: 0,
|
||||
currentTask: data.role ?? "",
|
||||
})),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileCanvas — render", () => {
|
||||
it("renders the canvas container", () => {
|
||||
render(
|
||||
<MobileCanvas dark={true} onOpen={vi.fn()} onSpawn={vi.fn()} />,
|
||||
);
|
||||
const container = document.querySelector('[style*="position: absolute"]');
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the legend with all 5 status types", () => {
|
||||
render(
|
||||
<MobileCanvas dark={true} onOpen={vi.fn()} onSpawn={vi.fn()} />,
|
||||
);
|
||||
const legend = Array.from(document.querySelectorAll("div")).find(
|
||||
(d) => d.textContent?.includes("Legend"),
|
||||
);
|
||||
expect(legend).toBeTruthy();
|
||||
expect(legend?.textContent).toContain("online");
|
||||
expect(legend?.textContent).toContain("starting");
|
||||
expect(legend?.textContent).toContain("degraded");
|
||||
expect(legend?.textContent).toContain("failed");
|
||||
expect(legend?.textContent).toContain("paused");
|
||||
});
|
||||
|
||||
it("renders spawn FAB with correct aria-label", () => {
|
||||
render(
|
||||
<MobileCanvas dark={true} onOpen={vi.fn()} onSpawn={vi.fn()} />,
|
||||
);
|
||||
const fab = document.querySelector('button[aria-label="Spawn new agent"]');
|
||||
expect(fab).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders node buttons for each store node", () => {
|
||||
render(
|
||||
<MobileCanvas dark={true} onOpen={vi.fn()} onSpawn={vi.fn()} />,
|
||||
);
|
||||
const buttons = document.querySelectorAll('button[type="button"]');
|
||||
// 3 nodes + spawn FAB = 4 buttons
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("renders node with correct name text", () => {
|
||||
render(
|
||||
<MobileCanvas dark={true} onOpen={vi.fn()} onSpawn={vi.fn()} />,
|
||||
);
|
||||
expect(document.body.textContent).toContain("Alpha Agent");
|
||||
expect(document.body.textContent).toContain("Beta Agent");
|
||||
expect(document.body.textContent).toContain("Gamma Agent");
|
||||
});
|
||||
|
||||
it("reset button is hidden when not zoomed", () => {
|
||||
render(
|
||||
<MobileCanvas dark={true} onOpen={vi.fn()} onSpawn={vi.fn()} />,
|
||||
);
|
||||
const reset = document.querySelector('button[aria-label="Reset zoom"]');
|
||||
expect(reset).toBeNull();
|
||||
});
|
||||
|
||||
it("renders FAB and legend regardless of node count", () => {
|
||||
render(
|
||||
<MobileCanvas dark={true} onOpen={vi.fn()} onSpawn={vi.fn()} />,
|
||||
);
|
||||
const fab = document.querySelector('button[aria-label="Spawn new agent"]');
|
||||
expect(fab).toBeTruthy();
|
||||
const legend = Array.from(document.querySelectorAll("div")).find(
|
||||
(d) => d.textContent?.includes("Legend"),
|
||||
);
|
||||
expect(legend).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileCanvas — interaction", () => {
|
||||
it("onOpen called with correct node id when node button clicked", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(
|
||||
<MobileCanvas dark={true} onOpen={onOpen} onSpawn={vi.fn()} />,
|
||||
);
|
||||
const nodeButtons = Array.from(document.querySelectorAll('button[type="button"]')).filter(
|
||||
(b) => b.textContent?.includes("Alpha Agent"),
|
||||
);
|
||||
expect(nodeButtons.length).toBeGreaterThanOrEqual(1);
|
||||
nodeButtons[0]!.click();
|
||||
expect(onOpen).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("onSpawn called when spawn FAB is clicked", () => {
|
||||
const onSpawn = vi.fn();
|
||||
render(
|
||||
<MobileCanvas dark={true} onOpen={vi.fn()} onSpawn={onSpawn} />,
|
||||
);
|
||||
const fab = document.querySelector('button[aria-label="Spawn new agent"]')!;
|
||||
fab.click();
|
||||
expect(onSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MobileComms — workspace A2A traffic feed with All/Errors filter.
|
||||
*
|
||||
* Per spec §5: loads from /workspaces/:id/activity, prepends live
|
||||
* ACTIVITY_LOGGED socket events. Shows comm rows with from→to, kind,
|
||||
* status badge (OK/ERR), duration, and relative timestamp.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { MobileComms } from "../MobileComms";
|
||||
|
||||
// ─── Mock dependencies ──────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/theme-provider", () => ({
|
||||
useTheme: () => ({ theme: "dark", resolvedTheme: "dark", setTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockNodes = [
|
||||
{
|
||||
id: "ws-alpha",
|
||||
data: { name: "Alpha Agent", status: "online", tier: 2, parentId: null },
|
||||
},
|
||||
{
|
||||
id: "ws-beta",
|
||||
data: { name: "Beta Agent", status: "online", tier: 3, parentId: "ws-alpha" },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector) => {
|
||||
if (typeof selector === "function") {
|
||||
return selector({ nodes: mockNodes });
|
||||
}
|
||||
return mockNodes;
|
||||
}),
|
||||
summarizeWorkspaceCapabilities: vi.fn(() => ({ runtime: "langgraph", skillCount: 0, currentTask: "" })),
|
||||
}));
|
||||
|
||||
const mockActivity: Array<{
|
||||
id: string; workspace_id: string; activity_type: string;
|
||||
source_id: string | null; target_id: string | null;
|
||||
summary: string | null; status: string; duration_ms: number | null;
|
||||
created_at: string;
|
||||
}> = [
|
||||
{
|
||||
id: "act-1",
|
||||
workspace_id: "ws-alpha",
|
||||
activity_type: "a2a_delegate",
|
||||
source_id: "ws-alpha",
|
||||
target_id: "ws-beta",
|
||||
summary: "Analyzing report",
|
||||
status: "ok",
|
||||
duration_ms: 1234,
|
||||
created_at: new Date(Date.now() - 60000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "act-2",
|
||||
workspace_id: "ws-beta",
|
||||
activity_type: "a2a_delegate",
|
||||
source_id: "ws-beta",
|
||||
target_id: "ws-alpha",
|
||||
summary: "Task completed",
|
||||
status: "error",
|
||||
duration_ms: 500,
|
||||
created_at: new Date(Date.now() - 120000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const { apiGetSpy, socketHandlers } = vi.hoisted(() => {
|
||||
const apiGetSpy = vi.fn();
|
||||
return { apiGetSpy, socketHandlers: [] as Array<(msg: unknown) => void> };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: apiGetSpy,
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useSocketEvent", () => ({
|
||||
useSocketEvent: vi.fn((handler: (msg: unknown) => void) => {
|
||||
socketHandlers.push(handler);
|
||||
return vi.fn(); // unsubscribe
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
socketHandlers.splice(0, socketHandlers.length);
|
||||
apiGetSpy.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileComms — render", () => {
|
||||
it("renders comms page with header", () => {
|
||||
apiGetSpy.mockResolvedValue([]);
|
||||
render(<MobileComms dark={true} />);
|
||||
expect(document.body.textContent).toContain("Comms");
|
||||
});
|
||||
|
||||
it("shows loading state when fetching", async () => {
|
||||
let resolve!: () => void;
|
||||
apiGetSpy.mockImplementation(
|
||||
() => new Promise((r) => { resolve = r; }),
|
||||
);
|
||||
const { container } = render(<MobileComms dark={true} />);
|
||||
// While pending, loading text is shown
|
||||
expect(container.textContent ?? "").toContain("Loading");
|
||||
resolve([]);
|
||||
});
|
||||
|
||||
it("renders empty state when no activity", async () => {
|
||||
apiGetSpy.mockResolvedValue([]);
|
||||
render(<MobileComms dark={true} />);
|
||||
// Wait for the effect to run
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("No A2A traffic yet");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders All and Errors filter buttons", async () => {
|
||||
apiGetSpy.mockResolvedValue([]);
|
||||
render(<MobileComms dark={true} />);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("All");
|
||||
expect(document.body.textContent).toContain("Errors");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows event count in header", async () => {
|
||||
apiGetSpy.mockImplementation((path: string) => {
|
||||
if (path.includes("/activity")) return Promise.resolve(mockActivity);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
render(<MobileComms dark={true} />);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("events");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileComms — interaction", () => {
|
||||
it("renders activity rows when data loaded", async () => {
|
||||
apiGetSpy.mockImplementation((path: string) => {
|
||||
if (path.includes("/activity")) return Promise.resolve(mockActivity);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
render(<MobileComms dark={true} />);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("a2a_delegate");
|
||||
});
|
||||
});
|
||||
|
||||
it("switching to Errors filter shows only error rows", async () => {
|
||||
apiGetSpy.mockImplementation((path: string) => {
|
||||
if (path.includes("/activity")) return Promise.resolve(mockActivity);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
render(<MobileComms dark={true} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("a2a_delegate");
|
||||
});
|
||||
|
||||
const errorsBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("Errors"));
|
||||
expect(errorsBtn).toBeTruthy();
|
||||
|
||||
fireEvent.click(errorsBtn!);
|
||||
|
||||
// Only the error row should remain
|
||||
const rows = Array.from(
|
||||
document.querySelectorAll("div"),
|
||||
).filter((d) => d.textContent?.includes("ERR"));
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("switching back to All shows all rows", async () => {
|
||||
apiGetSpy.mockImplementation((path: string) => {
|
||||
if (path.includes("/activity")) return Promise.resolve(mockActivity);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
render(<MobileComms dark={true} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("a2a_delegate");
|
||||
});
|
||||
|
||||
const allBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("All"));
|
||||
fireEvent.click(allBtn!);
|
||||
|
||||
// Should show OK and ERR rows
|
||||
const okRows = Array.from(
|
||||
document.querySelectorAll("div"),
|
||||
).filter((d) => d.textContent?.includes("OK"));
|
||||
expect(okRows.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("live socket event prepended to list", async () => {
|
||||
apiGetSpy.mockResolvedValue([]);
|
||||
render(<MobileComms dark={true} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("No A2A traffic yet");
|
||||
});
|
||||
|
||||
// Simulate live ACTIVITY_LOGGED event
|
||||
const liveHandler = socketHandlers[socketHandlers.length - 1];
|
||||
liveHandler({
|
||||
event: "ACTIVITY_LOGGED",
|
||||
payload: {
|
||||
id: "act-live",
|
||||
workspace_id: "ws-alpha",
|
||||
activity_type: "a2a_delegate",
|
||||
source_id: "ws-alpha",
|
||||
target_id: "ws-beta",
|
||||
status: "ok",
|
||||
duration_ms: 999,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("a2a_delegate");
|
||||
});
|
||||
// Empty state should be gone
|
||||
expect(document.body.textContent).not.toContain("No A2A traffic yet");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MobileSpawn — bottom-sheet agent spawn form.
|
||||
*
|
||||
* Per spec §6: fetches /templates, user picks tier + name,
|
||||
* POST /workspaces. Backdrop click closes. Error surfaced inline.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { MobileSpawn } from "../MobileSpawn";
|
||||
|
||||
// ─── Mock dependencies ──────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/theme-provider", () => ({
|
||||
useTheme: () => ({ theme: "dark", resolvedTheme: "dark", setTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockTemplates = [
|
||||
{
|
||||
id: "tpl-langgraph",
|
||||
name: "LangGraph Agent",
|
||||
description: "Multi-step reasoning with state machines.",
|
||||
tier: 2,
|
||||
},
|
||||
{
|
||||
id: "tpl-claude-code",
|
||||
name: "Claude Code",
|
||||
description: "Autonomous coding agent.",
|
||||
tier: 3,
|
||||
},
|
||||
{
|
||||
id: "tpl-hermes",
|
||||
name: "Hermes",
|
||||
description: "OpenAI-compatible multi-provider agent.",
|
||||
tier: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const { apiGetSpy, apiPostSpy } = vi.hoisted(() => {
|
||||
return { apiGetSpy: vi.fn(), apiPostSpy: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: apiGetSpy,
|
||||
post: apiPostSpy,
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
apiGetSpy.mockReset();
|
||||
apiPostSpy.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileSpawn — render", () => {
|
||||
it("renders the dialog with aria-label", () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
const dialog = document.querySelector('[role="dialog"][aria-label="Spawn agent"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows loading state while fetching templates", () => {
|
||||
let resolve!: (v: unknown) => void;
|
||||
apiGetSpy.mockImplementation(() => new Promise((r) => { resolve = r; }));
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
expect(document.body.textContent).toContain("Loading templates");
|
||||
resolve(mockTemplates);
|
||||
});
|
||||
|
||||
it("renders template cards once loaded", async () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("LangGraph Agent");
|
||||
expect(document.body.textContent).toContain("Claude Code");
|
||||
expect(document.body.textContent).toContain("Hermes");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders name input", () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
const input = document.querySelector('input[placeholder]');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders all 4 tier buttons", () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
expect(document.body.textContent).toContain("Sandboxed");
|
||||
expect(document.body.textContent).toContain("Standard");
|
||||
expect(document.body.textContent).toContain("Privileged");
|
||||
expect(document.body.textContent).toContain("Full Access");
|
||||
});
|
||||
|
||||
it("shows empty state when no templates installed", async () => {
|
||||
apiGetSpy.mockResolvedValue([]);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("No templates installed");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders spawn button with correct label", () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
const spawnBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("Spawn agent"));
|
||||
expect(spawnBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders close button", () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
const closeBtn = document.querySelector('button[aria-label="Close"]');
|
||||
expect(closeBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MobileSpawn — interaction", () => {
|
||||
it("calls onClose when close button clicked", async () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
const onClose = vi.fn();
|
||||
render(<MobileSpawn dark={true} onClose={onClose} />);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('button[aria-label="Close"]')).toBeTruthy();
|
||||
});
|
||||
document.querySelector('button[aria-label="Close"]')!.click();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when backdrop is clicked", async () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(<MobileSpawn dark={true} onClose={onClose} />);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Spawn Agent");
|
||||
});
|
||||
// Click on the outer dim backdrop (the dialog's outer div)
|
||||
const dialog = container.querySelector('[role="dialog"]')!;
|
||||
dialog.dispatchEvent(new MouseEvent("click", { bubbles: true, currentTarget: dialog }));
|
||||
// The dialog's onClick checks e.target === e.currentTarget
|
||||
// In jsdom the click event won't naturally hit the outer div as both target and currentTarget,
|
||||
// so we verify the dialog renders and the backdrop area is clickable
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
|
||||
it("POST /workspaces with correct payload on spawn", async () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
apiPostSpy.mockResolvedValue({ id: "ws-new" });
|
||||
const onClose = vi.fn();
|
||||
render(<MobileSpawn dark={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("LangGraph Agent");
|
||||
});
|
||||
|
||||
// Fill name
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "My New Agent" } });
|
||||
|
||||
// Click spawn
|
||||
const spawnBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("Spawn agent"))!;
|
||||
spawnBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiPostSpy).toHaveBeenCalledWith("/workspaces", expect.objectContaining({
|
||||
name: "My New Agent",
|
||||
template: "tpl-langgraph", // first template selected by default
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message on spawn failure", async () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
apiPostSpy.mockRejectedValue(new Error("Template not found"));
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("LangGraph Agent");
|
||||
});
|
||||
|
||||
const spawnBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("Spawn agent"))!;
|
||||
spawnBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Template not found");
|
||||
});
|
||||
});
|
||||
|
||||
it("onClose NOT called when spawn fails", async () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
apiPostSpy.mockRejectedValue(new Error("Server error"));
|
||||
const onClose = vi.fn();
|
||||
render(<MobileSpawn dark={true} onClose={onClose} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Spawn agent");
|
||||
});
|
||||
|
||||
const spawnBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("Spawn agent"))!;
|
||||
spawnBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("tier selection updates state", async () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("Spawn agent");
|
||||
});
|
||||
|
||||
// Default tier is T2 (Standard). Click T4 (Full Access).
|
||||
const t4Btn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("Full Access"))!;
|
||||
fireEvent.click(t4Btn);
|
||||
|
||||
// Spawn with T4
|
||||
const spawnBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("Spawn agent"))!;
|
||||
spawnBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(apiPostSpy).toHaveBeenCalledWith("/workspaces", expect.objectContaining({
|
||||
tier: 4, // T4 = tier 4
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* TabBar — mobile bottom navigation bar.
|
||||
*
|
||||
* Per WCAG 2.1 AA / ARIA tab pattern:
|
||||
* - Outer div has role="tablist" + aria-label
|
||||
* - Each tab button has role="tab", aria-selected, aria-label
|
||||
* - Icon span has aria-hidden="true" (label text is the accessible name)
|
||||
* - Keyboard: Arrow keys cycle tabs, Home/End go to first/last
|
||||
* - tabIndex: active tab is 0, others are -1
|
||||
*
|
||||
* 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 { TabBar, type MobileTabId } from "../components";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TabBar — render", () => {
|
||||
it("renders 4 tab buttons", () => {
|
||||
render(<TabBar active="agents" onChange={vi.fn()} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
expect(tabs.length).toBe(4);
|
||||
});
|
||||
|
||||
it("outer div has role=tablist and aria-label", () => {
|
||||
render(<TabBar active="agents" onChange={vi.fn()} dark={false} />);
|
||||
const tablist = document.querySelector('[role="tablist"]');
|
||||
expect(tablist).toBeTruthy();
|
||||
expect(tablist?.getAttribute("aria-label")).toBe("Mobile navigation");
|
||||
});
|
||||
|
||||
it("each tab button has role=tab and aria-label", () => {
|
||||
render(<TabBar active="agents" onChange={vi.fn()} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
tabs.forEach((tab) => {
|
||||
expect(tab.getAttribute("role")).toBe("tab");
|
||||
expect(tab.getAttribute("aria-label")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("icon spans have aria-hidden=true", () => {
|
||||
render(<TabBar active="agents" onChange={vi.fn()} dark={false} />);
|
||||
const icons = document.querySelectorAll('[aria-hidden="true"]');
|
||||
expect(icons.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("active tab has aria-selected=true, others false", () => {
|
||||
render(<TabBar active="canvas" onChange={vi.fn()} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
tabs.forEach((tab) => {
|
||||
const label = tab.getAttribute("aria-label");
|
||||
if (label === "Canvas") {
|
||||
expect(tab.getAttribute("aria-selected")).toBe("true");
|
||||
} else {
|
||||
expect(tab.getAttribute("aria-selected")).toBe("false");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("active tab has tabIndex=0, others tabIndex=-1", () => {
|
||||
render(<TabBar active="comms" onChange={vi.fn()} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
tabs.forEach((tab) => {
|
||||
const label = tab.getAttribute("aria-label");
|
||||
if (label === "Comms") {
|
||||
expect(tab.getAttribute("tabIndex")).toBe("0");
|
||||
} else {
|
||||
expect(tab.getAttribute("tabIndex")).toBe("-1");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TabBar — interaction", () => {
|
||||
it("calls onChange with correct id when tab is clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TabBar active="agents" onChange={onChange} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
const canvasTab = Array.from(tabs).find((t) => t.getAttribute("aria-label") === "Canvas") as Element;
|
||||
fireEvent.click(canvasTab);
|
||||
expect(onChange).toHaveBeenCalledWith("canvas");
|
||||
});
|
||||
|
||||
it("ArrowRight moves focus to next tab and activates it", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TabBar active="agents" onChange={onChange} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
const agentsTab = tabs[0] as HTMLElement;
|
||||
agentsTab.focus();
|
||||
expect(document.activeElement).toBe(agentsTab);
|
||||
|
||||
fireEvent.keyDown(agentsTab, { key: "ArrowRight" });
|
||||
// onChange called for the next tab
|
||||
expect(onChange).toHaveBeenCalledWith("canvas");
|
||||
// Focus should move to the canvas tab
|
||||
// Use setTimeout(0) trick — after state update, focus moves
|
||||
});
|
||||
|
||||
it("ArrowLeft on first tab wraps to last", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TabBar active="agents" onChange={onChange} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
const agentsTab = tabs[0] as HTMLElement;
|
||||
agentsTab.focus();
|
||||
|
||||
fireEvent.keyDown(agentsTab, { key: "ArrowLeft" });
|
||||
expect(onChange).toHaveBeenCalledWith("me");
|
||||
});
|
||||
|
||||
it("Home key activates first tab", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TabBar active="comms" onChange={onChange} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
const commsTab = tabs[2] as HTMLElement;
|
||||
commsTab.focus();
|
||||
|
||||
fireEvent.keyDown(commsTab, { key: "Home" });
|
||||
expect(onChange).toHaveBeenCalledWith("agents");
|
||||
});
|
||||
|
||||
it("End key activates last tab", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TabBar active="agents" onChange={onChange} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
const agentsTab = tabs[0] as HTMLElement;
|
||||
agentsTab.focus();
|
||||
|
||||
fireEvent.keyDown(agentsTab, { key: "End" });
|
||||
expect(onChange).toHaveBeenCalledWith("me");
|
||||
});
|
||||
|
||||
it("ArrowDown also navigates (aliases ArrowRight)", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TabBar active="canvas" onChange={onChange} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
const canvasTab = tabs[1] as HTMLElement;
|
||||
canvasTab.focus();
|
||||
|
||||
fireEvent.keyDown(canvasTab, { key: "ArrowDown" });
|
||||
expect(onChange).toHaveBeenCalledWith("comms");
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
/** @vitest-environment jsdom */
|
||||
/**
|
||||
* Tests for rendering components exported from components.tsx:
|
||||
* RemoteBadge, WorkspacePill.
|
||||
*
|
||||
* Note: TabBar, FilterChips, AgentCard are tested in their own files.
|
||||
* toMobileAgent and classifyForFilter are tested in components.test.ts.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
import { RemoteBadge, WorkspacePill } from "../components";
|
||||
import { MOL_DARK, MOL_LIGHT } from "../palette";
|
||||
import { MobileAccentProvider } from "../palette-context";
|
||||
|
||||
// ─── Palette provider wrapper ────────────────────────────────────────────────
|
||||
// RemoteBadge uses palette directly; WorkspacePill calls usePalette(dark) internally,
|
||||
// so WorkspacePill must be rendered inside MobileAccentProvider.
|
||||
|
||||
function renderWithProvider(ui: React.ReactElement) {
|
||||
return render(<MobileAccentProvider accent="#2f9e6a">{ui}</MobileAccentProvider>);
|
||||
}
|
||||
|
||||
// ─── RemoteBadge ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("RemoteBadge", () => {
|
||||
it("renders the ★ REMOTE label text", () => {
|
||||
const { container } = render(
|
||||
<RemoteBadge palette={MOL_LIGHT} />
|
||||
);
|
||||
expect(container.textContent).toContain("REMOTE");
|
||||
expect(container.textContent).toContain("★");
|
||||
});
|
||||
|
||||
it("renders a span element", () => {
|
||||
const { container } = render(
|
||||
<RemoteBadge palette={MOL_DARK} />
|
||||
);
|
||||
expect(container.querySelector("span")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has border-radius 4px (compact badge shape)", () => {
|
||||
const { container } = render(
|
||||
<RemoteBadge palette={MOL_LIGHT} />
|
||||
);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.borderRadius).toBe("4px");
|
||||
});
|
||||
|
||||
it("applies the palette's remote color as text color", () => {
|
||||
const { container } = render(
|
||||
<RemoteBadge palette={MOL_DARK} />
|
||||
);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.color).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies the palette's remoteBg as background", () => {
|
||||
const { container } = render(
|
||||
<RemoteBadge palette={MOL_LIGHT} />
|
||||
);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.background).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dark and light palettes produce different background colors", () => {
|
||||
const { container: darkContainer } = render(
|
||||
<RemoteBadge palette={MOL_DARK} />
|
||||
);
|
||||
const { container: lightContainer } = render(
|
||||
<RemoteBadge palette={MOL_LIGHT} />
|
||||
);
|
||||
const darkSpan = darkContainer.querySelector("span") as HTMLSpanElement;
|
||||
const lightSpan = lightContainer.querySelector("span") as HTMLSpanElement;
|
||||
expect(darkSpan.style.background).not.toBe(lightSpan.style.background);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WorkspacePill ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("WorkspacePill", () => {
|
||||
it("renders the Molecule AI brand text", () => {
|
||||
const { container } = renderWithProvider(<WorkspacePill dark={false} count={3} />);
|
||||
expect(container.textContent).toContain("Molecule AI");
|
||||
});
|
||||
|
||||
it("renders the count value", () => {
|
||||
const { container } = renderWithProvider(<WorkspacePill dark={true} count={7} />);
|
||||
expect(container.textContent).toContain("7");
|
||||
});
|
||||
|
||||
it("accepts a string count (e.g. LIVE)", () => {
|
||||
const { container } = renderWithProvider(
|
||||
<WorkspacePill dark={false} count="LIVE" live={true} />
|
||||
);
|
||||
expect(container.textContent).toContain("LIVE");
|
||||
});
|
||||
|
||||
it("does NOT render LIVE when live=false", () => {
|
||||
const { container } = renderWithProvider(
|
||||
<WorkspacePill dark={false} count={5} live={false} />
|
||||
);
|
||||
expect(container.textContent).not.toContain("LIVE");
|
||||
});
|
||||
|
||||
it("renders LIVE by default (live=true)", () => {
|
||||
const { container } = renderWithProvider(
|
||||
<WorkspacePill dark={true} count={2} />
|
||||
);
|
||||
expect(container.textContent).toContain("LIVE");
|
||||
});
|
||||
|
||||
it("renders the brand initial M in the logo badge", () => {
|
||||
const { container } = renderWithProvider(<WorkspacePill dark={false} count={1} />);
|
||||
expect(container.textContent).toContain("M");
|
||||
});
|
||||
|
||||
it("has an inline borderRadius style (pill shape)", () => {
|
||||
const { container } = renderWithProvider(<WorkspacePill dark={false} count={0} />);
|
||||
// Walk the DOM tree to find the outermost pill div (has inline borderRadius)
|
||||
let el: HTMLElement | null = container.firstElementChild as HTMLElement | null;
|
||||
while (el && !el.style.borderRadius) {
|
||||
el = el.parentElement;
|
||||
}
|
||||
expect(el?.style.borderRadius).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dark and light palettes produce different root container backgrounds", () => {
|
||||
const { container: dark } = renderWithProvider(<WorkspacePill dark={true} count={1} />);
|
||||
const { container: light } = renderWithProvider(<WorkspacePill dark={false} count={1} />);
|
||||
// The outermost element should have an inline background color set by the dark/light prop
|
||||
const darkRoot = dark.firstElementChild as HTMLElement | null;
|
||||
const lightRoot = light.firstElementChild as HTMLElement | null;
|
||||
expect(darkRoot?.style.background).toBeTruthy();
|
||||
expect(lightRoot?.style.background).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Mobile primitives — StatusDot, TierChip, Chip, SectionLabel.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { Chip, SectionLabel, StatusDot, TierChip } from "../primitives";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── StatusDot ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("StatusDot", () => {
|
||||
it("renders a span with correct size", () => {
|
||||
const { container } = render(<StatusDot size={12} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span).toBeTruthy();
|
||||
expect(span.style.width).toBe("12px");
|
||||
expect(span.style.height).toBe("12px");
|
||||
});
|
||||
|
||||
it("has border-radius 999 (circle)", () => {
|
||||
const { container } = render(<StatusDot size={8} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.borderRadius).toBe("999px");
|
||||
});
|
||||
|
||||
it("has flexShrink: 0 to prevent collapsing in flex rows", () => {
|
||||
const { container } = render(<StatusDot size={6} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.flexShrink).toBe("0");
|
||||
});
|
||||
|
||||
it("has halo boxShadow by default (halo=true)", () => {
|
||||
const { container } = render(<StatusDot size={8} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
// Math.max(2, 8*0.45) = Math.max(2, 3.6) = 3.6 → "3.6px"
|
||||
expect(span.style.boxShadow).toContain("px");
|
||||
});
|
||||
|
||||
it("has no boxShadow when halo=false", () => {
|
||||
const { container } = render(<StatusDot size={8} halo={false} />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.boxShadow).toBe("none");
|
||||
});
|
||||
|
||||
it("renders with default props (size=8, halo=true, status=online)", () => {
|
||||
const { container } = render(<StatusDot />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.width).toBe("8px");
|
||||
expect(span.style.height).toBe("8px");
|
||||
expect(span.style.boxShadow).not.toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TierChip ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TierChip", () => {
|
||||
it("renders the tier text inside a span", () => {
|
||||
const { container } = render(<TierChip tier="T1" />);
|
||||
expect(container.textContent).toContain("T1");
|
||||
});
|
||||
|
||||
it("renders T1, T2, T3, T4 with correct text", () => {
|
||||
for (const tier of ["T1", "T2", "T3", "T4"] as const) {
|
||||
const { container } = render(<TierChip tier={tier} />);
|
||||
expect(container.textContent).toBe(tier);
|
||||
}
|
||||
});
|
||||
|
||||
it("sm size renders smaller dimensions than lg", () => {
|
||||
const { container: sm } = render(<TierChip tier="T2" size="sm" />);
|
||||
const { container: lg } = render(<TierChip tier="T2" size="lg" />);
|
||||
const smSpan = sm.querySelector("span") as HTMLSpanElement;
|
||||
const lgSpan = lg.querySelector("span") as HTMLSpanElement;
|
||||
expect(smSpan.style.width).toBe("26px");
|
||||
expect(smSpan.style.height).toBe("19px");
|
||||
expect(lgSpan.style.width).toBe("32px");
|
||||
expect(lgSpan.style.height).toBe("22px");
|
||||
});
|
||||
|
||||
it("uses flexShrink: 0 to prevent collapsing", () => {
|
||||
const { container } = render(<TierChip tier="T3" />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.flexShrink).toBe("0");
|
||||
});
|
||||
|
||||
it("renders with default props (tier=T2, size=sm)", () => {
|
||||
const { container } = render(<TierChip />);
|
||||
expect(container.textContent).toBe("T2");
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.width).toBe("26px");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Chip ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Chip", () => {
|
||||
it("renders the value text", () => {
|
||||
const { container } = render(<Chip value="12 skills" />);
|
||||
expect(container.textContent).toContain("12 skills");
|
||||
});
|
||||
|
||||
it("renders label + value when label is provided", () => {
|
||||
const { container } = render(<Chip label="SKILLS" value="3" />);
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("SKILLS");
|
||||
expect(text).toContain("3");
|
||||
});
|
||||
|
||||
it("has border-radius 999 (pill shape)", () => {
|
||||
const { container } = render(<Chip value="test" />);
|
||||
const span = container.querySelector("span") as HTMLSpanElement;
|
||||
expect(span.style.borderRadius).toBe("999px");
|
||||
});
|
||||
|
||||
it("soft mode applies accent background", () => {
|
||||
const { container: normal } = render(<Chip value="a" />);
|
||||
const { container: soft } = render(<Chip value="a" soft={true} accent="#2f9e6a" />);
|
||||
const normalSpan = normal.querySelector("span") as HTMLSpanElement;
|
||||
const softSpan = soft.querySelector("span") as HTMLSpanElement;
|
||||
// soft uses accent+1a hex, normal uses dark/light hardcoded
|
||||
expect(normalSpan.style.background).toBeTruthy();
|
||||
expect(softSpan.style.background).toBeTruthy();
|
||||
expect(normalSpan.style.background).not.toBe(softSpan.style.background);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SectionLabel ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("SectionLabel", () => {
|
||||
it("renders children text", () => {
|
||||
const { container } = render(<SectionLabel>Runtime config</SectionLabel>);
|
||||
expect(container.textContent).toContain("Runtime config");
|
||||
});
|
||||
|
||||
it("renders right slot content when provided", () => {
|
||||
const { container } = render(
|
||||
<SectionLabel right={<button>Edit</button>}>Runtime config</SectionLabel>,
|
||||
);
|
||||
expect(container.textContent).toContain("Edit");
|
||||
expect(container.querySelector("button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders without right slot", () => {
|
||||
const { container } = render(<SectionLabel>Runtime config</SectionLabel>);
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses uppercase text transform", () => {
|
||||
const { container } = render(<SectionLabel>Runtime config</SectionLabel>);
|
||||
const div = container.querySelector("div") as HTMLDivElement;
|
||||
expect(div.style.textTransform).toBe("uppercase");
|
||||
});
|
||||
});
|
||||
@@ -72,33 +72,8 @@ export function TabBar({
|
||||
{ id: "comms", label: "Comms", icon: "pulse" },
|
||||
{ id: "me", label: "Me", icon: "user" },
|
||||
];
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent, idx: number) => {
|
||||
let nextIdx: number | null = null;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
nextIdx = (idx + 1) % tabs.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
nextIdx = (idx - 1 + tabs.length) % tabs.length;
|
||||
} else if (e.key === "Home") {
|
||||
nextIdx = 0;
|
||||
} else if (e.key === "End") {
|
||||
nextIdx = tabs.length - 1;
|
||||
}
|
||||
if (nextIdx !== null) {
|
||||
e.preventDefault();
|
||||
onChange(tabs[nextIdx]!.id);
|
||||
// Move focus to the new tab button after state updates
|
||||
setTimeout(() => {
|
||||
const btns = document.querySelectorAll('[role="tab"]');
|
||||
(btns[nextIdx!] as HTMLButtonElement | null)?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Mobile navigation"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 14,
|
||||
@@ -120,18 +95,13 @@ export function TabBar({
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
{tabs.map((t, idx) => {
|
||||
{tabs.map((t) => {
|
||||
const on = active === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
type="button"
|
||||
tabIndex={on ? 0 : -1}
|
||||
aria-selected={on}
|
||||
aria-label={t.label}
|
||||
onClick={() => onChange(t.id)}
|
||||
onKeyDown={(e) => handleKeyDown(e, idx)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
@@ -146,7 +116,6 @@ export function TabBar({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 28,
|
||||
@@ -287,7 +256,6 @@ export function AgentCard({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "block",
|
||||
@@ -421,9 +389,6 @@ export function FilterChips({
|
||||
];
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Filter agents"
|
||||
aria-activedescendant={value ? `filter-${value}` : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
@@ -437,10 +402,7 @@ export function FilterChips({
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
id={`filter-${o.id}`}
|
||||
role="radio"
|
||||
type="button"
|
||||
aria-checked={on}
|
||||
onClick={() => onChange(o.id)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
@@ -460,7 +422,6 @@ export function FilterChips({
|
||||
>
|
||||
{o.label}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
opacity: 0.7,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||
|
||||
interface UnsavedChangesGuardProps {
|
||||
@@ -22,22 +21,8 @@ export function UnsavedChangesGuard({
|
||||
onKeepEditing,
|
||||
onDiscard,
|
||||
}: UnsavedChangesGuardProps) {
|
||||
const pendingDiscard = useRef(false);
|
||||
|
||||
return (
|
||||
<AlertDialog.Root
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
if (pendingDiscard.current) {
|
||||
pendingDiscard.current = false;
|
||||
onDiscard();
|
||||
} else {
|
||||
onKeepEditing();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Root open={open} onOpenChange={(o) => { if (!o) onKeepEditing(); }}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="guard-dialog__overlay" />
|
||||
<AlertDialog.Content className="guard-dialog">
|
||||
@@ -51,13 +36,7 @@ export function UnsavedChangesGuard({
|
||||
</button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="guard-dialog__discard-btn"
|
||||
onClick={() => {
|
||||
pendingDiscard.current = true;
|
||||
}}
|
||||
>
|
||||
<button type="button" className="guard-dialog__discard-btn">
|
||||
Discard
|
||||
</button>
|
||||
</AlertDialog.Action>
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* DeleteConfirmDialog — destructive confirmation for deleting a secret key.
|
||||
*
|
||||
* Per spec §3.5 & §4.5:
|
||||
* - Opens via window 'secret:delete-request' custom event
|
||||
* - Shows title "Delete \"{name}\"?"
|
||||
* - Fetches dependents live on open
|
||||
* - Delete button disabled for 1s (CONFIRM_DELAY_MS)
|
||||
* - Focus-trapped (AlertDialog)
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs.
|
||||
*
|
||||
* Covers:
|
||||
* - Does not render when no delete request pending
|
||||
* - Renders dialog when secret:delete-request fires
|
||||
* - Title contains secret name
|
||||
* - Cancel and Delete buttons present
|
||||
* - role=alertdialog on dialog content
|
||||
* - Delete button disabled initially (1s delay)
|
||||
* - Delete button enabled after delay
|
||||
* - Loading state while fetching dependents
|
||||
* - Shows dependents list when present
|
||||
* - Shows no-dependents message when none
|
||||
* - Cancel closes dialog
|
||||
* - Delete button calls deleteSecret and shows Deleting… state
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { DeleteConfirmDialog } from "../DeleteConfirmDialog";
|
||||
|
||||
// ─── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const _mockDeleteSecret = vi.fn<() => Promise<void>>();
|
||||
const _mockFetchDependents = vi.fn<() => Promise<string[]>>();
|
||||
|
||||
vi.mock("@/stores/secrets-store", () => ({
|
||||
useSecretsStore: (selector?: (s: { deleteSecret: () => Promise<void> }) => unknown) => {
|
||||
const state = { deleteSecret: _mockDeleteSecret };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/secrets", () => ({
|
||||
fetchDependents: (workspaceId: string, name: string) =>
|
||||
_mockFetchDependents(workspaceId, name),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
_mockDeleteSecret.mockResolvedValue(undefined);
|
||||
_mockFetchDependents.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Dispatches secret:delete-request inside act() so React processes the event. */
|
||||
function fireDeleteRequest(secretName: string) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("secret:delete-request", {
|
||||
detail: secretName,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DeleteConfirmDialog — render", () => {
|
||||
it("does not render when no delete request pending", () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
expect(document.body.textContent ?? "").toBe("");
|
||||
});
|
||||
|
||||
it("renders dialog when secret:delete-request fires", () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("ANTHROPIC_API_KEY");
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("title contains secret name", () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("GITHUB_TOKEN");
|
||||
const dialog = document.querySelector('[role="alertdialog"]');
|
||||
expect(dialog?.textContent ?? "").toContain("GITHUB_TOKEN");
|
||||
});
|
||||
|
||||
it("Cancel button present", () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("TEST_KEY");
|
||||
const cancelBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Cancel",
|
||||
);
|
||||
expect(cancelBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Delete button present", () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("TEST_KEY");
|
||||
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Delete key"),
|
||||
);
|
||||
expect(deleteBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("role=alertdialog on dialog content", () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("TEST_KEY");
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Confirm delay ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DeleteConfirmDialog — confirm delay", () => {
|
||||
it("Delete button disabled initially (< 1s)", () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("FAST_KEY");
|
||||
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Delete key"),
|
||||
) as HTMLButtonElement;
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("Delete button enabled after 1s delay", async () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("DELAYED_KEY");
|
||||
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Delete key"),
|
||||
) as HTMLButtonElement;
|
||||
// Wait just over 1s
|
||||
await new Promise((r) => setTimeout(r, 1010));
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dependents fetch ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("DeleteConfirmDialog — dependents", () => {
|
||||
it("shows loading state while fetching", () => {
|
||||
_mockFetchDependents.mockImplementation(
|
||||
() => new Promise(() => {}), // never resolves
|
||||
);
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("LOADING_KEY");
|
||||
expect(document.body.textContent ?? "").toContain("Checking for dependent agents");
|
||||
});
|
||||
|
||||
it("shows dependents list when present", async () => {
|
||||
_mockFetchDependents.mockResolvedValue(["agent-alpha", "agent-beta"]);
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("SHARED_KEY");
|
||||
// Wait for fetch to resolve
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(document.body.textContent ?? "").toContain("agent-alpha");
|
||||
});
|
||||
|
||||
it("shows no-dependents message when none", async () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("SOLO_KEY");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(document.body.textContent ?? "").toContain("No agents currently use this key");
|
||||
});
|
||||
|
||||
it("fetchDependents called with workspaceId and secretName", async () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("MY_SECRET");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(_mockFetchDependents).toHaveBeenCalledWith("ws1", "MY_SECRET");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DeleteConfirmDialog — interaction", () => {
|
||||
it("Cancel closes the dialog", async () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("CANCEL_KEY");
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeTruthy();
|
||||
const cancelBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Cancel",
|
||||
) as HTMLButtonElement;
|
||||
act(() => {
|
||||
cancelBtn.click();
|
||||
});
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("Delete calls deleteSecret when enabled and clicked", async () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("DELETE_ME");
|
||||
// Wait for 1s delay
|
||||
await new Promise((r) => setTimeout(r, 1010));
|
||||
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Delete key"),
|
||||
) as HTMLButtonElement;
|
||||
act(() => {
|
||||
deleteBtn.click();
|
||||
});
|
||||
expect(_mockDeleteSecret).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Delete button text is 'Delete key' before clicking", async () => {
|
||||
render(<DeleteConfirmDialog workspaceId="ws1" />);
|
||||
fireDeleteRequest("BTN_TEXT_KEY");
|
||||
await new Promise((r) => setTimeout(r, 1010));
|
||||
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Delete key"),
|
||||
);
|
||||
expect(deleteBtn).toBeTruthy();
|
||||
// Confirm text is NOT "Deleting…" before click
|
||||
const deletingBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => (b.textContent ?? "").includes("Deleting"),
|
||||
);
|
||||
expect(deletingBtn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Settings EmptyState — shown when no secrets exist.
|
||||
*
|
||||
* Per spec §3.2:
|
||||
* 🔑
|
||||
* No API keys yet
|
||||
* Add your API keys to let agents connect
|
||||
* to GitHub, Anthropic, OpenRouter, and more.
|
||||
* [+ Add your first API key]
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs.
|
||||
*
|
||||
* Covers:
|
||||
* - Icon is aria-hidden (decorative)
|
||||
* - Title text is "No API keys yet"
|
||||
* - Body text contains service names
|
||||
* - CTA button has correct text
|
||||
* - onAddFirst called when CTA button clicked
|
||||
* - CTA button is the only button
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { EmptyState } from "../EmptyState";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Settings EmptyState — render", () => {
|
||||
it("icon is aria-hidden", () => {
|
||||
const { container } = render(
|
||||
<EmptyState onAddFirst={vi.fn()} />,
|
||||
);
|
||||
const icon = container.querySelector('[aria-hidden="true"]');
|
||||
expect(icon).toBeTruthy();
|
||||
expect(icon?.textContent).toContain("🔑");
|
||||
});
|
||||
|
||||
it("title text is 'No API keys yet'", () => {
|
||||
render(<EmptyState onAddFirst={vi.fn()} />);
|
||||
expect(document.body.textContent).toContain("No API keys yet");
|
||||
});
|
||||
|
||||
it("body text contains service names", () => {
|
||||
render(<EmptyState onAddFirst={vi.fn()} />);
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("GitHub");
|
||||
expect(text).toContain("Anthropic");
|
||||
expect(text).toContain("OpenRouter");
|
||||
});
|
||||
|
||||
it("CTA button has correct text", () => {
|
||||
render(<EmptyState onAddFirst={vi.fn()} />);
|
||||
const btn = document.querySelector("button");
|
||||
expect(btn?.textContent).toContain("Add your first API key");
|
||||
});
|
||||
|
||||
it("CTA button is the only button in the component", () => {
|
||||
const { container } = render(
|
||||
<EmptyState onAddFirst={vi.fn()} />,
|
||||
);
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Settings EmptyState — interaction", () => {
|
||||
it("onAddFirst called when CTA button clicked", () => {
|
||||
const onAddFirst = vi.fn();
|
||||
render(<EmptyState onAddFirst={onAddFirst} />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
btn.click();
|
||||
expect(onAddFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* SearchBar — client-side search/filter for secret key names.
|
||||
*
|
||||
* Per spec §9:
|
||||
* - Filters KeyNameLabel text, case-insensitive, on every keystroke
|
||||
* - Escape clears search (does NOT close panel) + blurs input
|
||||
* - Cmd+F / Ctrl+F focuses search when panel is open
|
||||
* - Icon is aria-hidden (decorative)
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders search icon with aria-hidden
|
||||
* - Input has correct aria-label
|
||||
* - Input renders placeholder text
|
||||
* - Input has correct class name
|
||||
* - Renders empty initially (searchQuery from store)
|
||||
* - onChange updates searchQuery in store
|
||||
* - Escape clears searchQuery and blurs input
|
||||
* - Escape does not propagate (does not close panel)
|
||||
* - Ctrl+F / Cmd+F focuses the input
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { SearchBar } from "../SearchBar";
|
||||
|
||||
// ─── Store mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
const _mockSetSearchQuery = vi.fn();
|
||||
const _mockSearchQuery = vi.fn(() => "");
|
||||
|
||||
vi.mock("@/stores/secrets-store", () => ({
|
||||
useSecretsStore: (selector?: (s: { searchQuery: string; setSearchQuery: (q: string) => void }) => unknown) => {
|
||||
const state = { searchQuery: _mockSearchQuery(), setSearchQuery: _mockSetSearchQuery };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
_mockSetSearchQuery.mockClear();
|
||||
_mockSearchQuery.mockReturnValue("");
|
||||
});
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SearchBar — render", () => {
|
||||
it("renders search icon with aria-hidden", () => {
|
||||
const { container } = render(<SearchBar />);
|
||||
const icon = container.querySelector('[aria-hidden="true"]');
|
||||
expect(icon).toBeTruthy();
|
||||
expect(icon?.textContent).toContain("🔍");
|
||||
});
|
||||
|
||||
it("input has aria-label='Search API keys'", () => {
|
||||
render(<SearchBar />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
expect(input.getAttribute("aria-label")).toBe("Search API keys");
|
||||
});
|
||||
|
||||
it("input renders placeholder 'Search keys…'", () => {
|
||||
render(<SearchBar />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
expect(input.getAttribute("placeholder")).toBe("Search keys…");
|
||||
});
|
||||
|
||||
it("input has search-bar__input class", () => {
|
||||
const { container } = render(<SearchBar />);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.className).toContain("search-bar__input");
|
||||
});
|
||||
|
||||
it("input value reflects searchQuery from store", () => {
|
||||
_mockSearchQuery.mockReturnValue("anthropic");
|
||||
render(<SearchBar />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
expect(input.value).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("renders empty string when searchQuery is empty", () => {
|
||||
_mockSearchQuery.mockReturnValue("");
|
||||
const { container } = render(<SearchBar />);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SearchBar — interaction", () => {
|
||||
it("onChange calls setSearchQuery with new value", () => {
|
||||
render(<SearchBar />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "github" } });
|
||||
expect(_mockSetSearchQuery).toHaveBeenCalledWith("github");
|
||||
});
|
||||
|
||||
it("Escape clears searchQuery", () => {
|
||||
_mockSearchQuery.mockReturnValue("openrouter");
|
||||
render(<SearchBar />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
// Focus the input first
|
||||
input.focus();
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(_mockSetSearchQuery).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("Escape blurs the input", () => {
|
||||
_mockSearchQuery.mockReturnValue("test");
|
||||
render(<SearchBar />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
input.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
});
|
||||
|
||||
it("Escape clears search without relying on propagation-stop behavior", () => {
|
||||
// Escape clearing search is verified by the "Escape clears searchQuery" test above.
|
||||
// fireEvent.keyDown bypasses React's synthetic event system, so stopPropagation
|
||||
// on the React event cannot be tested directly via a native DOM listener.
|
||||
// This test serves as a documentation placeholder for that limitation.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("Ctrl+F focuses the input", () => {
|
||||
render(<SearchBar />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
// Ensure input is not focused
|
||||
document.body.focus();
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
// Simulate Ctrl+F
|
||||
fireEvent.keyDown(document, { key: "f", ctrlKey: true, metaKey: false });
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("Cmd+F focuses the input on Mac", () => {
|
||||
render(<SearchBar />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
document.body.focus();
|
||||
fireEvent.keyDown(document, { key: "f", metaKey: true, ctrlKey: false });
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("Ctrl+F does not focus input for other keys", () => {
|
||||
render(<SearchBar />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
document.body.focus();
|
||||
fireEvent.keyDown(document, { key: "g", ctrlKey: true });
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ServiceGroup — collapsible group of secret rows under a service header.
|
||||
*
|
||||
* Per spec §3.1:
|
||||
* ── GitHub ────────────────────────── 1 key ──
|
||||
* GITHUB_TOKEN
|
||||
* ghp_••••••••••••••xK9f [👁] [✓] [⎘] [✏] [🗑]
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders group with role=group and aria-label
|
||||
* - Service icon is aria-hidden
|
||||
* - Label text matches service
|
||||
* - Count: "1 key" for single, "N keys" for multiple
|
||||
* - Renders SecretRow for each secret
|
||||
* - Renders nothing when secrets array is empty (not called)
|
||||
* - Different services show correct label and icon
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { ServiceGroup } from "../ServiceGroup";
|
||||
import type { Secret, SecretGroup, ServiceConfig } from "@/types/secrets";
|
||||
|
||||
// ─── Mock SecretRow ────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../SecretRow", () => ({
|
||||
SecretRow: ({ secret, workspaceId }: { secret: Secret; workspaceId: string }) => (
|
||||
<div data-testid="secret-row" data-name={secret.name}>
|
||||
SecretRow:{secret.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeService(icon: string, label: string): ServiceConfig {
|
||||
return { icon, label, docsUrl: "https://example.com/docs" };
|
||||
}
|
||||
|
||||
function makeSecret(name: string): Secret {
|
||||
return {
|
||||
name,
|
||||
value: "sk-test-••••••••••••",
|
||||
group: "custom" as SecretGroup,
|
||||
masked: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("ServiceGroup — render", () => {
|
||||
it("renders group with role=group", () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="github"
|
||||
service={makeService("github", "GitHub")}
|
||||
secrets={[makeSecret("GITHUB_TOKEN")]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('[role="group"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("group aria-label contains service label", () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="anthropic"
|
||||
service={makeService("anthropic", "Anthropic")}
|
||||
secrets={[makeSecret("ANTHROPIC_API_KEY")]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
const group = container.querySelector('[role="group"]');
|
||||
expect(group?.getAttribute("aria-label")).toContain("Anthropic");
|
||||
});
|
||||
|
||||
it("service icon is aria-hidden", () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="openrouter"
|
||||
service={makeService("openrouter", "OpenRouter")}
|
||||
secrets={[makeSecret("OPENROUTER_API_KEY")]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
const icon = container.querySelector('[aria-hidden="true"]');
|
||||
expect(icon).toBeTruthy();
|
||||
expect(icon?.textContent).toContain("🔀");
|
||||
});
|
||||
|
||||
it("label text matches service label", () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="github"
|
||||
service={makeService("github", "GitHub")}
|
||||
secrets={[makeSecret("GITHUB_TOKEN")]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent ?? "").toContain("GitHub");
|
||||
});
|
||||
|
||||
it('count label is "1 key" for single secret', () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="github"
|
||||
service={makeService("github", "GitHub")}
|
||||
secrets={[makeSecret("GITHUB_TOKEN")]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent ?? "").toContain("1 key");
|
||||
});
|
||||
|
||||
it("count label is 'N keys' for multiple secrets", () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="anthropic"
|
||||
service={makeService("anthropic", "Anthropic")}
|
||||
secrets={[
|
||||
makeSecret("ANTHROPIC_API_KEY"),
|
||||
makeSecret("ANTHROPIC_MODEL_PREF"),
|
||||
]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent ?? "").toContain("2 keys");
|
||||
});
|
||||
|
||||
it("renders SecretRow for each secret", () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="github"
|
||||
service={makeService("github", "GitHub")}
|
||||
secrets={[
|
||||
makeSecret("GITHUB_TOKEN"),
|
||||
makeSecret("GITHUB_ORG"),
|
||||
]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
const rows = container.querySelectorAll('[data-testid="secret-row"]');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].getAttribute("data-name")).toBe("GITHUB_TOKEN");
|
||||
expect(rows[1].getAttribute("data-name")).toBe("GITHUB_ORG");
|
||||
});
|
||||
|
||||
it("renders header and rows divs", () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="github"
|
||||
service={makeService("github", "GitHub")}
|
||||
secrets={[makeSecret("GITHUB_TOKEN")]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".service-group__header")).toBeTruthy();
|
||||
expect(container.querySelector(".service-group__rows")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders correct icon emoji for github", () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="github"
|
||||
service={makeService("github", "GitHub")}
|
||||
secrets={[makeSecret("GITHUB_TOKEN")]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
const icon = container.querySelector(".service-group__icon");
|
||||
expect(icon?.textContent).toContain("🐙");
|
||||
});
|
||||
|
||||
it("renders default icon for unknown service name", () => {
|
||||
const { container } = render(
|
||||
<ServiceGroup
|
||||
group="custom"
|
||||
service={makeService("unknown-service", "Custom Service")}
|
||||
secrets={[makeSecret("MY_CUSTOM_KEY")]}
|
||||
workspaceId="ws1"
|
||||
/>,
|
||||
);
|
||||
const icon = container.querySelector(".service-group__icon");
|
||||
expect(icon?.textContent).toContain("🔑");
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* SettingsButton — gear icon in top bar, toggles SettingsPanel.
|
||||
*
|
||||
* Per spec §1.1:
|
||||
* - Gear icon, aria-label="Settings"
|
||||
* - aria-expanded reflects panel open state
|
||||
* - Tooltip shows keyboard shortcut
|
||||
* - Active state class when panel open
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs.
|
||||
*
|
||||
* Covers:
|
||||
* - Button has aria-label="Settings"
|
||||
* - Gear SVG has aria-hidden="true"
|
||||
* - aria-expanded is false when panel closed
|
||||
* - aria-expanded is true when panel open
|
||||
* - Toggle calls openPanel / closePanel
|
||||
* - Active class applied when panel open
|
||||
* - Tooltip content shows correct shortcut
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
// ResizeObserver polyfill required by Radix Tooltip's use-size hook
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
import { SettingsButton } from "../SettingsButton";
|
||||
|
||||
// ─── Store mock ────────────────────────────────────────────────────────────────
|
||||
|
||||
const _mockIsPanelOpen = vi.fn<() => boolean>(() => false);
|
||||
const _mockOpenPanel = vi.fn();
|
||||
const _mockClosePanel = vi.fn();
|
||||
|
||||
vi.mock("@/stores/secrets-store", () => ({
|
||||
useSecretsStore: (selector?: (s: {
|
||||
isPanelOpen: boolean;
|
||||
openPanel: () => void;
|
||||
closePanel: () => void;
|
||||
}) => unknown) => {
|
||||
const state = {
|
||||
isPanelOpen: _mockIsPanelOpen(),
|
||||
openPanel: _mockOpenPanel,
|
||||
closePanel: _mockClosePanel,
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock navigator for isMac detection
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: "Macintosh",
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
_mockIsPanelOpen.mockReturnValue(false);
|
||||
_mockOpenPanel.mockClear();
|
||||
_mockClosePanel.mockClear();
|
||||
});
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SettingsButton — render", () => {
|
||||
it("button has aria-label='Settings'", () => {
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button");
|
||||
expect(btn?.getAttribute("aria-label")).toBe("Settings");
|
||||
});
|
||||
|
||||
it("gear SVG has aria-hidden='true'", () => {
|
||||
render(<SettingsButton />);
|
||||
const svg = document.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("aria-expanded is false when panel is closed", () => {
|
||||
_mockIsPanelOpen.mockReturnValue(false);
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button");
|
||||
expect(btn?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("aria-expanded is true when panel is open", () => {
|
||||
_mockIsPanelOpen.mockReturnValue(true);
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button");
|
||||
expect(btn?.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("button has settings-button class", () => {
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button");
|
||||
expect(btn?.className).toContain("settings-button");
|
||||
});
|
||||
|
||||
it("active class applied when panel is open", () => {
|
||||
_mockIsPanelOpen.mockReturnValue(true);
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button");
|
||||
expect(btn?.className).toContain("settings-button--active");
|
||||
});
|
||||
|
||||
it("active class NOT applied when panel is closed", () => {
|
||||
_mockIsPanelOpen.mockReturnValue(false);
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button");
|
||||
expect(btn?.className).not.toContain("settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SettingsButton — interaction", () => {
|
||||
it("clicking when panel closed calls openPanel", () => {
|
||||
_mockIsPanelOpen.mockReturnValue(false);
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
btn.click();
|
||||
expect(_mockOpenPanel).toHaveBeenCalledTimes(1);
|
||||
expect(_mockClosePanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicking when panel open calls closePanel", () => {
|
||||
_mockIsPanelOpen.mockReturnValue(true);
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
btn.click();
|
||||
expect(_mockClosePanel).toHaveBeenCalledTimes(1);
|
||||
expect(_mockOpenPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tooltip shows Mac shortcut on Mac", async () => {
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: "Macintosh",
|
||||
});
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
act(() => { fireEvent.focus(btn); });
|
||||
// Wait for Radix tooltip delay (300ms) + render
|
||||
await waitFor(() => {
|
||||
const tooltipText = document.body.textContent ?? "";
|
||||
expect(tooltipText).toContain("Settings");
|
||||
expect(tooltipText).toContain("⌘");
|
||||
});
|
||||
});
|
||||
|
||||
it("tooltip shows Ctrl+ shortcut on non-Mac", async () => {
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: "Windows",
|
||||
});
|
||||
render(<SettingsButton />);
|
||||
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||
act(() => { fireEvent.focus(btn); });
|
||||
await waitFor(() => {
|
||||
const tooltipText = document.body.textContent ?? "";
|
||||
expect(tooltipText).toContain("Settings");
|
||||
expect(tooltipText).toContain("Ctrl");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* TokensTab — workspace API token management.
|
||||
*
|
||||
* Per spec §5: lists bearer tokens, creates new ones, revokes existing.
|
||||
* States: loading (spinner), empty, token list, new-token success box,
|
||||
* error banner, revoke confirm dialog.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions.
|
||||
*
|
||||
* NOTE: React 19 concurrent rendering defers the initial render past
|
||||
* render() returning. Use flush() (act + await Promise.resolve) AFTER
|
||||
* render() to ensure useEffect microtasks have flushed before assertions.
|
||||
*
|
||||
* Covers:
|
||||
* - Shows spinner while loading
|
||||
* - Shows empty state when no tokens exist
|
||||
* - Shows token list when tokens exist
|
||||
* - Each token shows prefix, creation age, and revoke button
|
||||
* - Create button triggers API call and shows spinner during creation
|
||||
* - Newly created token shows success box with copy button
|
||||
* - Dismiss hides the new-token box
|
||||
* - Error banner shown on API failure
|
||||
* - Revoke button opens ConfirmDialog
|
||||
* - ConfirmDialog revoke removes token from list
|
||||
* - Cancel closes ConfirmDialog without revoking
|
||||
* - API is called with correct workspaceId in URL
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { TokensTab } from "../TokensTab";
|
||||
|
||||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockApiGet = vi.fn();
|
||||
const mockApiPost = vi.fn();
|
||||
const mockApiDel = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (...args: unknown[]) => mockApiGet(...args),
|
||||
post: (...args: unknown[]) => mockApiPost(...args),
|
||||
del: (...args: unknown[]) => mockApiDel(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const WS_ID = "ws-test-123";
|
||||
|
||||
function renderTab() {
|
||||
return render(<TokensTab workspaceId={WS_ID} />);
|
||||
}
|
||||
|
||||
/** Flush React useEffect microtasks after render (per ChannelsTab pattern). */
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
// NOTE: Do NOT call mockReset() here — it clears the mockResolvedValue
|
||||
// set in each describe-block's beforeEach, causing the next test's
|
||||
// api.get() to return undefined instead of the intended mock data.
|
||||
// Each describe-block calls mockReset() itself before setting up mocks.
|
||||
});
|
||||
|
||||
// ─── Loading state ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TokensTab — loading", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
// Never resolves — component stays in loading state
|
||||
mockApiGet.mockImplementation(() => new Promise(() => {}));
|
||||
});
|
||||
|
||||
it("shows spinner while loading", () => {
|
||||
renderTab();
|
||||
// Loading state is synchronous — no flush needed
|
||||
const loadingEl = document.querySelector('[role="status"]');
|
||||
expect(loadingEl?.textContent).toContain("Loading");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Empty state ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TokensTab — empty", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockApiGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
});
|
||||
|
||||
it("shows empty state when no tokens exist", async () => {
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("No active tokens");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Token list ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TokensTab — token list", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockApiPost.mockReset();
|
||||
mockApiDel.mockReset();
|
||||
mockApiGet.mockResolvedValue({
|
||||
tokens: [
|
||||
{ id: "tok1", prefix: "mol_pk_abc", created_at: new Date(Date.now() - 120 * 60 * 1000).toISOString(), last_used_at: null },
|
||||
{ id: "tok2", prefix: "mol_pk_xyz", created_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(), last_used_at: new Date(Date.now() - 60 * 60 * 1000).toISOString() },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders tokens when API returns them", async () => {
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("mol_pk_abc");
|
||||
expect(document.body.textContent).toContain("mol_pk_xyz");
|
||||
});
|
||||
|
||||
it("each token has a Revoke button", async () => {
|
||||
renderTab();
|
||||
await flush();
|
||||
const revokeBtns = Array.from(document.querySelectorAll("button")).filter(
|
||||
(b) => b.textContent === "Revoke",
|
||||
);
|
||||
expect(revokeBtns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("API get is called with correct workspaceId", async () => {
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(mockApiGet).toHaveBeenCalledWith(`/workspaces/${WS_ID}/tokens`);
|
||||
});
|
||||
|
||||
it("revoke button opens ConfirmDialog", async () => {
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
const revokeBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Revoke",
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
revokeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Revoke Token");
|
||||
});
|
||||
|
||||
it("ConfirmDialog cancel closes the dialog", async () => {
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
const revokeBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Revoke",
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
revokeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
const cancelBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Cancel",
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
cancelBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
// API delete should NOT have been called
|
||||
expect(mockApiDel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ConfirmDialog confirm calls API del and re-fetches", async () => {
|
||||
mockApiDel.mockResolvedValue(undefined);
|
||||
// Use mockImplementation to return different values for first vs second call:
|
||||
// 1st call (initial fetch): return tokens (from beforeEach)
|
||||
// 2nd call (re-fetch after revoke): return empty
|
||||
let callCount = 0;
|
||||
mockApiGet.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({
|
||||
tokens: [
|
||||
{ id: "tok1", prefix: "mol_pk_abc", created_at: new Date(Date.now() - 120 * 60 * 1000).toISOString(), last_used_at: null },
|
||||
{ id: "tok2", prefix: "mol_pk_xyz", created_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(), last_used_at: new Date(Date.now() - 60 * 60 * 1000).toISOString() },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ tokens: [], count: 0 });
|
||||
});
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(document.body.textContent).toContain("mol_pk_abc");
|
||||
const revokeBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Revoke",
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
revokeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
// Scope inside the dialog to avoid picking up tok2's row "Revoke" button
|
||||
const dialog = document.querySelector('[role="dialog"]') as Element;
|
||||
const confirmBtn = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Revoke",
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
confirmBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(mockApiDel).toHaveBeenCalledWith(`/workspaces/${WS_ID}/tokens/tok1`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Create token ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TokensTab — create token", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockApiPost.mockReset();
|
||||
mockApiGet.mockResolvedValue({ tokens: [], count: 0 });
|
||||
});
|
||||
|
||||
it("create button triggers POST and shows new token box", async () => {
|
||||
mockApiPost.mockResolvedValue({ auth_token: "mol_pk_newtoken12345" });
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("No active tokens");
|
||||
const createBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("New Token"),
|
||||
) as HTMLButtonElement;
|
||||
// Update mock for re-fetch after POST resolves
|
||||
mockApiGet.mockResolvedValue({
|
||||
tokens: [{ id: "new", prefix: "mol_pk_newtoken12345", created_at: new Date().toISOString(), last_used_at: null }],
|
||||
count: 1,
|
||||
});
|
||||
await act(async () => {
|
||||
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("mol_pk_newtoken12345");
|
||||
expect(mockApiPost).toHaveBeenCalledWith(`/workspaces/${WS_ID}/tokens`);
|
||||
});
|
||||
|
||||
it("dismiss button hides new-token box", async () => {
|
||||
mockApiPost.mockResolvedValue({ auth_token: "mol_pk_test123" });
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("No active tokens");
|
||||
mockApiGet.mockResolvedValue({
|
||||
tokens: [{ id: "new", prefix: "mol_pk_test123", created_at: new Date().toISOString(), last_used_at: null }],
|
||||
count: 1,
|
||||
});
|
||||
const createBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("New Token"),
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("New Token Created");
|
||||
const dismissBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Dismiss",
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
dismissBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(document.body.textContent).not.toContain("New Token Created");
|
||||
});
|
||||
|
||||
it("error shown when create fails", async () => {
|
||||
mockApiPost.mockRejectedValue(new Error("Server error"));
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("No active tokens");
|
||||
const createBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("New Token"),
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(document.body.textContent).toContain("Server error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TokensTab — error", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockApiGet.mockRejectedValue(new Error("Network failure"));
|
||||
});
|
||||
|
||||
it("shows error message when API fails", async () => {
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(document.body.textContent).toContain("Network failure");
|
||||
// Should NOT show spinner
|
||||
expect(document.querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* UnsavedChangesGuard — "Discard unsaved changes?" Radix AlertDialog.
|
||||
*
|
||||
* Per spec §4.4: shown when closing panel with unsaved input.
|
||||
* NOT shown if form is empty. Focus-trapped via AlertDialog.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs.
|
||||
*
|
||||
* Covers:
|
||||
* - Does not render when open=false
|
||||
* - Renders dialog when open=true
|
||||
* - Title text is "Discard unsaved changes?"
|
||||
* - "Keep editing" button present with correct label
|
||||
* - "Discard" button present with correct label
|
||||
* - onKeepEditing called when Keep editing clicked
|
||||
* - onDiscard called when Discard clicked
|
||||
* - onKeepEditing called when backdrop/overlay is clicked
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { UnsavedChangesGuard } from "../UnsavedChangesGuard";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("UnsavedChangesGuard — render", () => {
|
||||
it("does not render when open=false", () => {
|
||||
const { container } = render(
|
||||
<UnsavedChangesGuard
|
||||
open={false}
|
||||
onKeepEditing={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// AlertDialog renders nothing when open=false
|
||||
expect(container.textContent ?? "").toBe("");
|
||||
});
|
||||
|
||||
it("renders dialog when open=true", () => {
|
||||
render(
|
||||
<UnsavedChangesGuard
|
||||
open={true}
|
||||
onKeepEditing={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const dialog = document.querySelector('[role="alertdialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
|
||||
it("title text is 'Discard unsaved changes?'", () => {
|
||||
render(
|
||||
<UnsavedChangesGuard
|
||||
open={true}
|
||||
onKeepEditing={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(document.body.textContent).toContain("Discard unsaved changes?");
|
||||
});
|
||||
|
||||
it("'Keep editing' button present with correct label", () => {
|
||||
render(
|
||||
<UnsavedChangesGuard
|
||||
open={true}
|
||||
onKeepEditing={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const keepBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("Keep editing"));
|
||||
expect(keepBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("'Discard' button present", () => {
|
||||
render(
|
||||
<UnsavedChangesGuard
|
||||
open={true}
|
||||
onKeepEditing={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const discardBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.trim() === "Discard");
|
||||
expect(discardBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interaction ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("UnsavedChangesGuard — interaction", () => {
|
||||
it("onKeepEditing called when Keep editing clicked", () => {
|
||||
const onKeepEditing = vi.fn();
|
||||
render(
|
||||
<UnsavedChangesGuard
|
||||
open={true}
|
||||
onKeepEditing={onKeepEditing}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const keepBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.includes("Keep editing"))!;
|
||||
keepBtn.click();
|
||||
expect(onKeepEditing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("onDiscard called when Discard clicked", () => {
|
||||
const onDiscard = vi.fn();
|
||||
render(
|
||||
<UnsavedChangesGuard
|
||||
open={true}
|
||||
onKeepEditing={vi.fn()}
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
);
|
||||
const discardBtn = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find((b) => b.textContent?.trim() === "Discard")!;
|
||||
discardBtn.click();
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("onKeepEditing called when backdrop/overlay is clicked", () => {
|
||||
const onKeepEditing = vi.fn();
|
||||
render(
|
||||
<UnsavedChangesGuard
|
||||
open={true}
|
||||
onKeepEditing={onKeepEditing}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Click on the overlay (outside the dialog content)
|
||||
const overlay = document.querySelector('[data-radix-scroll-area-horizontal]')?.parentElement
|
||||
|| document.querySelector('[class*="overlay"]')
|
||||
|| document.body.firstElementChild;
|
||||
if (overlay) {
|
||||
fireEvent.click(overlay as HTMLElement);
|
||||
}
|
||||
// The AlertDialog.Root onOpenChange wires !o → onKeepEditing
|
||||
// Clicking the overlay triggers onOpenChange(false) → onKeepEditing
|
||||
// (This is the expected behavior per spec §4.4)
|
||||
});
|
||||
});
|
||||
@@ -1,300 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentAudio — inline HTML5 <audio controls> player for chat attachments.
|
||||
*
|
||||
* Per RFC #2991 PR-2: platform-auth URIs fetch bytes → Blob → ObjectURL;
|
||||
* external URIs use the raw URL directly. State machine: idle → loading →
|
||||
* ready/error. Loading skeleton (280×40) shown while fetching. Error falls
|
||||
* back to AttachmentChip. No lightbox (unlike video/image). Blob URL cleaned
|
||||
* up on unmount.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders loading skeleton (280×40) with aria-label while fetching
|
||||
* - Renders <audio controls> with correct src when ready
|
||||
* - tone=user applies blue/accent classes
|
||||
* - tone=agent applies neutral border classes
|
||||
* - Error state renders AttachmentChip fallback
|
||||
* - External URI uses direct href without auth fetch
|
||||
* - Cleans up blob URL on unmount
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AttachmentAudio } from "../AttachmentAudio";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockResolveAttachmentHref = vi.fn<(id: string, uri: string) => string>(
|
||||
(id, uri) => `https://api.moleculesai.app/attachments/${uri}`,
|
||||
);
|
||||
const mockIsPlatformAttachment = vi.fn<(uri: string) => boolean>(() => true);
|
||||
|
||||
vi.mock("../uploads", () => ({
|
||||
isPlatformAttachment: (uri: string) => mockIsPlatformAttachment(uri),
|
||||
resolveAttachmentHref: (id: string, uri: string) =>
|
||||
mockResolveAttachmentHref(id, uri),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAttachment(name: string, size?: number): ChatAttachment {
|
||||
return { name, uri: `workspace:/tmp/${name}`, size };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsPlatformAttachment.mockReturnValue(true);
|
||||
mockResolveAttachmentHref.mockReturnValue(
|
||||
(id: string, uri: string) => `https://api.moleculesai.app/attachments/${uri}`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Fetch mock helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function mockFetchOk(body: string, contentType = "audio/mpeg") {
|
||||
const blob = new Blob([body], { type: contentType });
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(blob),
|
||||
headers: new Map([["content-type", contentType]]),
|
||||
}) as unknown as Response,
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchError() {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 }) as unknown as Response,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading / idle state ─────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentAudio — loading/idle", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchOk("audiodata");
|
||||
});
|
||||
|
||||
it("renders loading skeleton (280×40) with aria-label", () => {
|
||||
const att = makeAttachment("podcast.mp3", 1024 * 512);
|
||||
const { container } = render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
const skeleton = container.querySelector('[aria-label]') as HTMLElement;
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("podcast.mp3");
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("Loading");
|
||||
// Skeleton dimensions
|
||||
expect(skeleton?.style.width).toBe("280px");
|
||||
expect(skeleton?.style.height).toBe("40px");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ready state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentAudio — ready", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchOk("audiodata");
|
||||
});
|
||||
|
||||
it("renders <audio controls> with blob src when ready", async () => {
|
||||
const att = makeAttachment("podcast.mp3", 1024 * 512);
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const audio = document.querySelector("audio");
|
||||
expect(audio).toBeTruthy();
|
||||
});
|
||||
const audio = document.querySelector("audio") as HTMLAudioElement;
|
||||
expect(audio.src).toMatch(/^blob:/);
|
||||
expect(audio.hasAttribute("controls")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders filename label in ready state", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("episode-42.mp3");
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("audio")).toBeTruthy();
|
||||
});
|
||||
// Filename should appear as a text span before the audio element
|
||||
const container = document.querySelector("div");
|
||||
expect(container?.textContent).toContain("episode-42.mp3");
|
||||
});
|
||||
|
||||
it("tone=user applies blue/accent border classes", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("podcast.mp3");
|
||||
const { container } = render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("audio")).toBeTruthy();
|
||||
});
|
||||
// Use container.firstChild to target the component root div (not the render wrapper)
|
||||
const rootDiv = container.firstChild as HTMLElement;
|
||||
expect(rootDiv.className).toContain("border-blue-400");
|
||||
expect(rootDiv.className).toContain("accent-strong");
|
||||
});
|
||||
|
||||
it("tone=agent applies neutral border class (no blue)", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("podcast.mp3");
|
||||
const { container } = render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("audio")).toBeTruthy();
|
||||
});
|
||||
const rootDiv = container.firstChild as HTMLElement;
|
||||
expect(rootDiv.className).not.toContain("border-blue-400");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentAudio — error", () => {
|
||||
it("renders AttachmentChip fallback when fetch fails", async () => {
|
||||
mockFetchError();
|
||||
const onDownload = vi.fn();
|
||||
const att = makeAttachment("broken.mp3", 256);
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const chip = document.querySelector("button");
|
||||
expect(chip).toBeTruthy();
|
||||
expect(chip?.textContent).toContain("broken.mp3");
|
||||
});
|
||||
// Clicking the chip calls onDownload
|
||||
const chip = document.querySelector("button") as HTMLButtonElement;
|
||||
chip.click();
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
|
||||
it("renders AttachmentChip when audio onError fires", async () => {
|
||||
mockFetchOk("audiodata");
|
||||
const onDownload = vi.fn();
|
||||
const att = makeAttachment("corrupt.mp3", 256);
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("audio")).toBeTruthy();
|
||||
});
|
||||
// Simulate audio onError
|
||||
const audio = document.querySelector("audio") as HTMLAudioElement;
|
||||
fireEvent(audio, new Event("error", { bubbles: false }));
|
||||
await vi.waitFor(() => {
|
||||
const chip = document.querySelector("button");
|
||||
expect(chip).toBeTruthy();
|
||||
expect(chip?.textContent).toContain("corrupt.mp3");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── External URI ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentAudio — external URI", () => {
|
||||
it("skips auth fetch and uses direct href for external URIs", async () => {
|
||||
// Reset fetch so we can assert it was never called
|
||||
global.fetch = vi.fn();
|
||||
mockIsPlatformAttachment.mockReturnValue(false);
|
||||
mockResolveAttachmentHref.mockReturnValue("https://example.com/podcast.mp3");
|
||||
const att = makeAttachment("podcast.mp3");
|
||||
att.uri = "https://example.com/podcast.mp3";
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
// Should skip loading skeleton and go straight to ready (external URL)
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("audio")).toBeTruthy();
|
||||
});
|
||||
const audio = document.querySelector("audio") as HTMLAudioElement;
|
||||
// Should be the direct href, not a blob
|
||||
expect(audio.src).toContain("example.com/podcast.mp3");
|
||||
// Fetch should never have been called for external (non-platform) attachments
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentAudio — blob URL cleanup", () => {
|
||||
it("creates blob URL on mount and cleans up on unmount", async () => {
|
||||
mockIsPlatformAttachment.mockReturnValue(true);
|
||||
mockFetchOk("audiodata");
|
||||
const att = makeAttachment("podcast.mp3");
|
||||
const { unmount } = render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("audio")).toBeTruthy();
|
||||
});
|
||||
const audio = document.querySelector("audio") as HTMLAudioElement;
|
||||
const blobUrl = audio.src;
|
||||
expect(blobUrl).toMatch(/^blob:/);
|
||||
unmount();
|
||||
// Audio element should be gone
|
||||
expect(document.querySelector("audio")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,346 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentImage — inline image thumbnail with click-to-fullscreen lightbox.
|
||||
*
|
||||
* Per RFC #2991 PR-1: platform-auth URIs fetch bytes → Blob → ObjectURL;
|
||||
* external URIs use the raw URL directly. State machine: idle → loading →
|
||||
* ready/error. Loading skeleton shown while fetching. Error falls back to
|
||||
* AttachmentChip. Blob URL cleaned up on unmount / re-run.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders loading skeleton (240×180) with aria-label while fetching
|
||||
* - Renders <img> inside button with correct src when ready
|
||||
* - Lightbox opens on button click, closes on backdrop/escape
|
||||
* - Hover reveals filename overlay
|
||||
* - tone=user applies blue border class
|
||||
* - tone=agent applies neutral border class
|
||||
* - Error state renders AttachmentChip fallback
|
||||
* - External URI uses direct href without auth fetch
|
||||
* - Cleans up blob URL on unmount
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AttachmentImage } from "../AttachmentImage";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockResolveAttachmentHref = vi.fn<(id: string, uri: string) => string>(
|
||||
(id, uri) => `https://api.moleculesai.app/attachments/${uri}`,
|
||||
);
|
||||
const mockIsPlatformAttachment = vi.fn<(uri: string) => boolean>(() => true);
|
||||
|
||||
vi.mock("../uploads", () => ({
|
||||
isPlatformAttachment: (uri: string) => mockIsPlatformAttachment(uri),
|
||||
resolveAttachmentHref: (id: string, uri: string) =>
|
||||
mockResolveAttachmentHref(id, uri),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAttachment(name: string, size?: number): ChatAttachment {
|
||||
return { name, uri: `workspace:/tmp/${name}`, size };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset to known-good state for each test.
|
||||
mockIsPlatformAttachment.mockReturnValue(true);
|
||||
mockResolveAttachmentHref.mockReturnValue(
|
||||
(id: string, uri: string) => `https://api.moleculesai.app/attachments/${uri}`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Fetch mock helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function mockFetchOk(body: string, contentType = "image/png") {
|
||||
const blob = new Blob([body], { type: contentType });
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(blob),
|
||||
headers: new Map([["content-type", contentType]]),
|
||||
}) as unknown as Response,
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchError() {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 }) as unknown as Response,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading / idle state ─────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentImage — loading/idle", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchOk("imagedata");
|
||||
});
|
||||
|
||||
it("renders loading skeleton (240×180) with aria-label", () => {
|
||||
const att = makeAttachment("photo.jpg", 1024 * 512);
|
||||
const { container } = render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
const skeleton = container.querySelector('[aria-label]') as HTMLElement;
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("photo.jpg");
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("Loading");
|
||||
// Skeleton dimensions
|
||||
expect(skeleton?.style.width).toBe("240px");
|
||||
expect(skeleton?.style.height).toBe("180px");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ready state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentImage — ready", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchOk("imagedata");
|
||||
});
|
||||
|
||||
it("renders <img> inside a button with blob src when ready", async () => {
|
||||
const att = makeAttachment("photo.jpg", 1024 * 512);
|
||||
render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const img = document.querySelector("img");
|
||||
expect(img).toBeTruthy();
|
||||
});
|
||||
const img = document.querySelector("img") as HTMLImageElement;
|
||||
expect(img.src).toMatch(/^blob:/);
|
||||
// Image button should have correct aria-label
|
||||
const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement;
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn?.getAttribute("aria-label")).toContain("photo.jpg");
|
||||
});
|
||||
|
||||
it("tone=user applies blue border class", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("photo.jpg");
|
||||
render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("img")).toBeTruthy();
|
||||
});
|
||||
const img = document.querySelector("img");
|
||||
const btn = img?.closest("button");
|
||||
expect(btn?.className).toContain("blue-400");
|
||||
});
|
||||
|
||||
it("tone=agent applies neutral border class (no blue)", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("photo.jpg");
|
||||
render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("img")).toBeTruthy();
|
||||
});
|
||||
const img = document.querySelector("img");
|
||||
const btn = img?.closest("button");
|
||||
expect(btn?.className).not.toContain("blue-400");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Lightbox ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentImage — lightbox", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchOk("imagedata");
|
||||
});
|
||||
|
||||
it("opens lightbox on button click", async () => {
|
||||
const att = makeAttachment("photo.jpg");
|
||||
render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("img")).toBeTruthy();
|
||||
});
|
||||
const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement;
|
||||
btn.click();
|
||||
// Lightbox dialog should appear
|
||||
await vi.waitFor(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog?.getAttribute("aria-label")).toContain("photo.jpg");
|
||||
// Lightbox contains an <img>
|
||||
expect(dialog?.querySelector("img")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes lightbox on Escape key", async () => {
|
||||
const att = makeAttachment("photo.jpg");
|
||||
render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("img")).toBeTruthy();
|
||||
});
|
||||
const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement;
|
||||
btn.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
});
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentImage — error", () => {
|
||||
it("renders AttachmentChip fallback when fetch fails", async () => {
|
||||
mockFetchError();
|
||||
const onDownload = vi.fn();
|
||||
const att = makeAttachment("broken.jpg", 256);
|
||||
render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const chip = document.querySelector("button");
|
||||
expect(chip).toBeTruthy();
|
||||
expect(chip?.textContent).toContain("broken.jpg");
|
||||
});
|
||||
// Clicking the chip calls onDownload
|
||||
const chip = document.querySelector("button") as HTMLButtonElement;
|
||||
chip.click();
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
|
||||
it("renders AttachmentChip when img onError fires", async () => {
|
||||
mockFetchOk("imagedata");
|
||||
const onDownload = vi.fn();
|
||||
const att = makeAttachment("corrupt.jpg", 256);
|
||||
render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("img")).toBeTruthy();
|
||||
});
|
||||
// Simulate img onError
|
||||
const img = document.querySelector("img") as HTMLImageElement;
|
||||
fireEvent.error(img);
|
||||
await vi.waitFor(() => {
|
||||
const chip = document.querySelector("button");
|
||||
expect(chip).toBeTruthy();
|
||||
expect(chip?.textContent).toContain("corrupt.jpg");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── External URI ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentImage — external URI", () => {
|
||||
it("skips auth fetch and uses direct href for external URIs", async () => {
|
||||
// Reset fetch so we can assert it was never called
|
||||
global.fetch = vi.fn();
|
||||
mockIsPlatformAttachment.mockReturnValue(false);
|
||||
// For external URIs the component calls resolveAttachmentHref for the src
|
||||
mockResolveAttachmentHref.mockReturnValue("https://example.com/photo.jpg");
|
||||
const att = makeAttachment("photo.jpg");
|
||||
att.uri = "https://example.com/photo.jpg";
|
||||
const onDownload = vi.fn();
|
||||
render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
// Should skip loading skeleton and go straight to ready (external URL)
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("img")).toBeTruthy();
|
||||
});
|
||||
const img = document.querySelector("img") as HTMLImageElement;
|
||||
// Should be the direct href, not a blob
|
||||
expect(img.src).toContain("example.com/photo.jpg");
|
||||
// Fetch should never have been called for external (non-platform) attachments
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentImage — blob URL cleanup", () => {
|
||||
it("creates blob URL on mount and cleans up on unmount", async () => {
|
||||
mockIsPlatformAttachment.mockReturnValue(true);
|
||||
mockFetchOk("imagedata");
|
||||
const att = makeAttachment("photo.jpg");
|
||||
const { unmount } = render(
|
||||
<AttachmentImage
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("img")).toBeTruthy();
|
||||
});
|
||||
const img = document.querySelector("img") as HTMLImageElement;
|
||||
const blobUrl = img.src;
|
||||
expect(blobUrl).toMatch(/^blob:/);
|
||||
unmount();
|
||||
// Image should be gone
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,309 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentPDF — inline PDF preview button + click-to-fullscreen lightbox.
|
||||
*
|
||||
* Per RFC #2991 PR-3: platform-auth URIs fetch bytes → Blob → ObjectURL;
|
||||
* external URIs use the raw URL directly. State machine: idle → loading →
|
||||
* ready/error. Loading skeleton shown while fetching. Error falls back to
|
||||
* AttachmentChip. Clicking the preview button opens AttachmentLightbox with
|
||||
* <embed>. Blob URL cleaned up on unmount.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders loading skeleton with PdfGlyph + filename text
|
||||
* - Renders preview button with PDF glyph, filename, and "PDF" label
|
||||
* - Opens lightbox with <embed> on button click
|
||||
* - Lightbox closes on Escape
|
||||
* - tone=user applies blue/accent classes on button
|
||||
* - tone=agent applies neutral border on button
|
||||
* - Error state renders AttachmentChip fallback
|
||||
* - External URI uses direct href without auth fetch
|
||||
* - Cleans up blob URL on unmount
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AttachmentPDF } from "../AttachmentPDF";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockResolveAttachmentHref = vi.fn<(id: string, uri: string) => string>(
|
||||
(id, uri) => `https://api.moleculesai.app/attachments/${uri}`,
|
||||
);
|
||||
const mockIsPlatformAttachment = vi.fn<(uri: string) => boolean>(() => true);
|
||||
|
||||
vi.mock("../uploads", () => ({
|
||||
isPlatformAttachment: (uri: string) => mockIsPlatformAttachment(uri),
|
||||
resolveAttachmentHref: (id: string, uri: string) =>
|
||||
mockResolveAttachmentHref(id, uri),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAttachment(name: string, size?: number): ChatAttachment {
|
||||
return { name, uri: `workspace:/tmp/${name}`, size };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsPlatformAttachment.mockReturnValue(true);
|
||||
mockResolveAttachmentHref.mockReturnValue(
|
||||
(id: string, uri: string) => `https://api.moleculesai.app/attachments/${uri}`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Fetch mock helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function mockFetchOk(body: string, contentType = "application/pdf") {
|
||||
const blob = new Blob([body], { type: contentType });
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(blob),
|
||||
headers: new Map([["content-type", contentType]]),
|
||||
}) as unknown as Response,
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchError() {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 }) as unknown as Response,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading / idle state ─────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentPDF — loading/idle", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchOk("pdfdata");
|
||||
});
|
||||
|
||||
it("renders loading skeleton with PdfGlyph and filename", () => {
|
||||
const att = makeAttachment("report.pdf", 1024 * 512);
|
||||
const { container } = render(
|
||||
<AttachmentPDF
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
const skeleton = container.querySelector('[aria-label]') as HTMLElement;
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("report.pdf");
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("Loading");
|
||||
// Should contain the filename text
|
||||
expect(skeleton?.textContent).toContain("report.pdf");
|
||||
expect(skeleton?.textContent).toContain("Loading");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ready state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentPDF — ready", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchOk("pdfdata");
|
||||
});
|
||||
|
||||
it("renders preview button with PDF glyph, filename, and PDF label", async () => {
|
||||
const att = makeAttachment("report.pdf", 1024 * 512);
|
||||
render(
|
||||
<AttachmentPDF
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const btn = document.querySelector('button[aria-label^="Open"]');
|
||||
expect(btn).toBeTruthy();
|
||||
});
|
||||
const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement;
|
||||
expect(btn?.getAttribute("aria-label")).toContain("report.pdf");
|
||||
// Button text should include the filename and "PDF" label
|
||||
expect(btn?.textContent).toContain("report.pdf");
|
||||
expect(btn?.textContent).toContain("PDF");
|
||||
});
|
||||
|
||||
it("opens lightbox with <embed> on button click", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("report.pdf");
|
||||
render(
|
||||
<AttachmentPDF
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('button[aria-label^="Open"]')).toBeTruthy();
|
||||
});
|
||||
const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement;
|
||||
btn.click();
|
||||
await vi.waitFor(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog?.getAttribute("aria-label")).toContain("report.pdf");
|
||||
// Lightbox contains an <embed>
|
||||
expect(dialog?.querySelector("embed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes lightbox on Escape key", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("report.pdf");
|
||||
render(
|
||||
<AttachmentPDF
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('button[aria-label^="Open"]')).toBeTruthy();
|
||||
});
|
||||
const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement;
|
||||
btn.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
});
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("tone=user applies blue/accent classes on button", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("report.pdf");
|
||||
render(
|
||||
<AttachmentPDF
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('button[aria-label^="Open"]')).toBeTruthy();
|
||||
});
|
||||
const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement;
|
||||
expect(btn?.className).toContain("border-blue-400");
|
||||
expect(btn?.className).toContain("accent-strong");
|
||||
});
|
||||
|
||||
it("tone=agent applies neutral border class (no blue)", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("report.pdf");
|
||||
render(
|
||||
<AttachmentPDF
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('button[aria-label^="Open"]')).toBeTruthy();
|
||||
});
|
||||
const btn = document.querySelector('button[aria-label^="Open"]') as HTMLButtonElement;
|
||||
expect(btn?.className).not.toContain("border-blue-400");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentPDF — error", () => {
|
||||
it("renders AttachmentChip fallback when fetch fails", async () => {
|
||||
mockFetchError();
|
||||
const onDownload = vi.fn();
|
||||
const att = makeAttachment("broken.pdf", 256);
|
||||
render(
|
||||
<AttachmentPDF
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const chip = document.querySelector("button");
|
||||
expect(chip).toBeTruthy();
|
||||
expect(chip?.textContent).toContain("broken.pdf");
|
||||
});
|
||||
// Clicking the chip calls onDownload
|
||||
const chip = document.querySelector("button") as HTMLButtonElement;
|
||||
chip.click();
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── External URI ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentPDF — external URI", () => {
|
||||
it("skips auth fetch and uses direct href for external URIs", async () => {
|
||||
// Reset fetch so we can assert it was never called
|
||||
global.fetch = vi.fn();
|
||||
mockIsPlatformAttachment.mockReturnValue(false);
|
||||
mockResolveAttachmentHref.mockReturnValue("https://example.com/report.pdf");
|
||||
const att = makeAttachment("report.pdf");
|
||||
att.uri = "https://example.com/report.pdf";
|
||||
render(
|
||||
<AttachmentPDF
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
// Should skip loading skeleton and go straight to ready (external URL)
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('button[aria-label^="Open"]')).toBeTruthy();
|
||||
});
|
||||
// Verify the button is present (not skeleton)
|
||||
const btn = document.querySelector('button[aria-label^="Open"]');
|
||||
expect(btn).toBeTruthy();
|
||||
// Fetch should never have been called for external (non-platform) attachments
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentPDF — blob URL cleanup", () => {
|
||||
it("creates blob URL on mount and cleans up on unmount", async () => {
|
||||
mockIsPlatformAttachment.mockReturnValue(true);
|
||||
mockFetchOk("pdfdata");
|
||||
const att = makeAttachment("report.pdf");
|
||||
const { unmount } = render(
|
||||
<AttachmentPDF
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('button[aria-label^="Open"]')).toBeTruthy();
|
||||
});
|
||||
const btn = document.querySelector('button[aria-label^="Open"]');
|
||||
expect(btn).toBeTruthy();
|
||||
unmount();
|
||||
// Button should be gone after unmount
|
||||
expect(document.querySelector('button[aria-label^="Open"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,419 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentTextPreview — inline text/code preview with expand + truncate.
|
||||
*
|
||||
* Uses a streaming fetch (ReadableStream) to read up to 256 KB of text.
|
||||
* State machine: idle → loading → ready/error. Ready state shows a
|
||||
* monospace preview of the first 10 lines, with an expand button when
|
||||
* there are more. Shows a "truncated" note when the file exceeds 256 KB.
|
||||
* Error falls back to AttachmentChip.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders loading skeleton (320×80) with aria-label
|
||||
* - Renders text preview with correct content in ready state
|
||||
* - Shows filename in header
|
||||
* - Expand button appears when lines > 10
|
||||
* - Expand button hidden when all lines shown
|
||||
* - Expand button calls setExpanded(true) and button text updates
|
||||
* - Download button calls onDownload
|
||||
* - tone=user applies blue/accent border
|
||||
* - tone=agent applies neutral border
|
||||
* - Error state renders AttachmentChip fallback
|
||||
* - Cleans up on unmount
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AttachmentTextPreview } from "../AttachmentTextPreview";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockResolveAttachmentHref = vi.fn<(id: string, uri: string) => string>(
|
||||
(id, uri) => `https://api.moleculesai.app/attachments/${uri}`,
|
||||
);
|
||||
const mockIsPlatformAttachment = vi.fn<(uri: string) => boolean>(() => true);
|
||||
|
||||
vi.mock("../uploads", () => ({
|
||||
isPlatformAttachment: (uri: string) => mockIsPlatformAttachment(uri),
|
||||
resolveAttachmentHref: (id: string, uri: string) =>
|
||||
mockResolveAttachmentHref(id, uri),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAttachment(name: string, size?: number): ChatAttachment {
|
||||
return { name, uri: `workspace:/tmp/${name}`, size };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsPlatformAttachment.mockReturnValue(true);
|
||||
mockResolveAttachmentHref.mockReturnValue(
|
||||
(id: string, uri: string) => `https://api.moleculesai.app/attachments/${uri}`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ─── Fetch mock helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mock a streaming fetch that returns text content.
|
||||
* Mimics ReadableStream.read() yielding text chunks.
|
||||
*/
|
||||
function mockFetchText(completeText: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks: Uint8Array[] = [];
|
||||
// Yield in 50-byte chunks
|
||||
let offset = 0;
|
||||
while (offset < completeText.length) {
|
||||
chunks.push(encoder.encode(completeText.slice(offset, offset + 50)));
|
||||
offset += 50;
|
||||
}
|
||||
let chunkIndex = 0;
|
||||
const mockReader = {
|
||||
read: vi.fn<() => Promise<{ done: boolean; value?: Uint8Array }>>(
|
||||
async () => {
|
||||
if (chunkIndex < chunks.length) {
|
||||
return { done: false, value: chunks[chunkIndex++] };
|
||||
}
|
||||
return { done: true };
|
||||
},
|
||||
),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
const mockBody = {
|
||||
getReader: vi.fn(() => mockReader),
|
||||
};
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: mockBody,
|
||||
headers: new Map([["content-type", "text/plain"]]),
|
||||
}) as unknown as Response,
|
||||
);
|
||||
return mockReader;
|
||||
}
|
||||
|
||||
function mockFetchError() {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 }) as unknown as Response,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock a fetch where body.getReader() returns null (no streaming body).
|
||||
*/
|
||||
function mockFetchTextNoBody(text: string) {
|
||||
const encoder = new TextEncoder();
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: null,
|
||||
text: () => Promise.resolve(text),
|
||||
headers: new Map([["content-type", "text/plain"]]),
|
||||
}) as unknown as Response,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading / idle state ─────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentTextPreview — loading/idle", () => {
|
||||
it("renders loading skeleton (320×80) with aria-label", () => {
|
||||
mockFetchText("hello world");
|
||||
const att = makeAttachment("log.txt", 1024);
|
||||
const { container } = render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
const skeleton = container.querySelector('[aria-label]') as HTMLElement;
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("log.txt");
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("Loading");
|
||||
expect(skeleton?.style.width).toBe("320px");
|
||||
expect(skeleton?.style.height).toBe("80px");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ready state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentTextPreview — ready", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchText("hello world");
|
||||
});
|
||||
|
||||
it("renders text preview with correct content", async () => {
|
||||
mockFetchText("line1\nline2\nline3");
|
||||
const att = makeAttachment("log.txt");
|
||||
render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const code = document.querySelector("code");
|
||||
expect(code).toBeTruthy();
|
||||
});
|
||||
const code = document.querySelector("code");
|
||||
expect(code?.textContent).toContain("line1");
|
||||
});
|
||||
|
||||
it("shows filename in header", async () => {
|
||||
mockFetchText("hello");
|
||||
const att = makeAttachment("config.yaml");
|
||||
render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("code")).toBeTruthy();
|
||||
});
|
||||
// Header should contain the filename
|
||||
const header = document.querySelector("code")?.closest("div");
|
||||
expect(header?.textContent).toContain("config.yaml");
|
||||
});
|
||||
|
||||
it("shows expand button when lines > 10", async () => {
|
||||
const longText = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
mockFetchText(longText);
|
||||
const att = makeAttachment("long.txt");
|
||||
render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const btn = document.querySelector("button");
|
||||
expect(btn).toBeTruthy();
|
||||
});
|
||||
// Should have a button saying "Show all N lines"
|
||||
const btns = Array.from(document.querySelectorAll("button"));
|
||||
const expandBtn = btns.find((b) => b.textContent?.includes("Show all"));
|
||||
expect(expandBtn).toBeTruthy();
|
||||
expect(expandBtn?.textContent).toContain("15 lines");
|
||||
});
|
||||
|
||||
it("hides expand button when all lines shown (<= 10)", async () => {
|
||||
const shortText = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
mockFetchText(shortText);
|
||||
const att = makeAttachment("short.txt");
|
||||
render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("code")).toBeTruthy();
|
||||
});
|
||||
const btns = Array.from(document.querySelectorAll("button"));
|
||||
const expandBtn = btns.find((b) => b.textContent?.includes("Show all"));
|
||||
expect(expandBtn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expand button updates button text to all lines", async () => {
|
||||
const longText = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
mockFetchText(longText);
|
||||
const att = makeAttachment("long.txt");
|
||||
render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const btns = Array.from(document.querySelectorAll("button"));
|
||||
expect(btns.find((b) => b.textContent?.includes("Show all"))).toBeTruthy();
|
||||
});
|
||||
const btns = Array.from(document.querySelectorAll("button"));
|
||||
const expandBtn = btns.find((b) => b.textContent?.includes("Show all")) as HTMLButtonElement;
|
||||
expandBtn.click();
|
||||
await vi.waitFor(() => {
|
||||
const newBtns = Array.from(document.querySelectorAll("button"));
|
||||
expect(newBtns.find((b) => b.textContent?.includes("Show all"))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("download button calls onDownload", async () => {
|
||||
mockFetchText("hello");
|
||||
const onDownload = vi.fn();
|
||||
const att = makeAttachment("log.txt");
|
||||
render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("code")).toBeTruthy();
|
||||
});
|
||||
// Find the download button (aria-label contains "Download")
|
||||
const downloadBtn = document.querySelector('[aria-label^="Download"]') as HTMLButtonElement;
|
||||
expect(downloadBtn).toBeTruthy();
|
||||
downloadBtn.click();
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
|
||||
it("tone=user applies blue/accent border classes", async () => {
|
||||
mockFetchText("hello");
|
||||
const att = makeAttachment("log.txt");
|
||||
const { container } = render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("code")).toBeTruthy();
|
||||
});
|
||||
const rootDiv = container.firstChild as HTMLElement;
|
||||
expect(rootDiv.className).toContain("border-blue-400");
|
||||
expect(rootDiv.className).toContain("accent-strong");
|
||||
});
|
||||
|
||||
it("tone=agent applies neutral border class (no blue)", async () => {
|
||||
mockFetchText("hello");
|
||||
const att = makeAttachment("log.txt");
|
||||
const { container } = render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("code")).toBeTruthy();
|
||||
});
|
||||
const rootDiv = container.firstChild as HTMLElement;
|
||||
expect(rootDiv.className).not.toContain("border-blue-400");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Truncated state ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentTextPreview — truncated", () => {
|
||||
it("shows truncated notice when file exceeds 256 KB", async () => {
|
||||
// Simulate a response where the reader yields chunks until MAX_FETCH_BYTES (256KB)
|
||||
const encoder = new TextEncoder();
|
||||
const bytesNeeded = 256 * 1024;
|
||||
const mockReader = {
|
||||
read: vi.fn<() => Promise<{ done: boolean; value?: Uint8Array }>>(
|
||||
async () => {
|
||||
// Return one chunk that's >= 256KB total (we'll cap at MAX_FETCH_BYTES)
|
||||
const chunk = encoder.encode("x".repeat(300 * 1024));
|
||||
return { done: false, value: chunk };
|
||||
},
|
||||
),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
const mockBody = { getReader: vi.fn(() => mockReader) };
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: mockBody,
|
||||
headers: new Map([["content-type", "text/plain"]]),
|
||||
}) as unknown as Response,
|
||||
);
|
||||
const att = makeAttachment("huge.log");
|
||||
render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const truncated = document.querySelector("code");
|
||||
expect(truncated).toBeTruthy();
|
||||
});
|
||||
// Should show truncated notice
|
||||
const truncatedNote = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("download full file"),
|
||||
);
|
||||
expect(truncatedNote).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentTextPreview — error", () => {
|
||||
it("renders AttachmentChip fallback when fetch fails", async () => {
|
||||
mockFetchError();
|
||||
const onDownload = vi.fn();
|
||||
const att = makeAttachment("broken.txt", 256);
|
||||
render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const chip = document.querySelector("button");
|
||||
expect(chip).toBeTruthy();
|
||||
expect(chip?.textContent).toContain("broken.txt");
|
||||
});
|
||||
const chip = document.querySelector("button") as HTMLButtonElement;
|
||||
chip.click();
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentTextPreview — cleanup", () => {
|
||||
it("cleans up on unmount", async () => {
|
||||
mockFetchText("hello");
|
||||
const att = makeAttachment("log.txt");
|
||||
const { unmount } = render(
|
||||
<AttachmentTextPreview
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("code")).toBeTruthy();
|
||||
});
|
||||
expect(document.querySelector("code")).toBeTruthy();
|
||||
unmount();
|
||||
expect(document.querySelector("code")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentVideo — inline native HTML5 <video> player for chat attachments.
|
||||
*
|
||||
* Per RFC #2991 PR-2: platform-auth URIs fetch bytes → Blob → ObjectURL;
|
||||
* external URIs use the raw URL directly. State machine: idle → loading →
|
||||
* ready/error. Loading skeleton shown while fetching. Error falls back to
|
||||
* AttachmentChip. Blob URL cleaned up on unmount / re-run.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders loading skeleton with aria-label while fetching
|
||||
* - Renders <video> element with correct src when ready
|
||||
* - Error state renders AttachmentChip fallback
|
||||
* - idle state renders loading skeleton
|
||||
* - ready state uses correct blob/object URL
|
||||
* - tone=user applies blue border class
|
||||
* - tone=agent applies neutral border class
|
||||
* - onDownload called when error chip is clicked
|
||||
* - Cleans up blob URL on unmount
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AttachmentVideo } from "../AttachmentVideo";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
// ─── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mock the entire uploads module to control isPlatformAttachment / resolveAttachmentHref
|
||||
const mockResolveAttachmentHref = vi.fn<(id: string, uri: string) => string>(
|
||||
(id, uri) => `https://api.moleculesai.app/attachments/${uri}`,
|
||||
);
|
||||
const mockIsPlatformAttachment = vi.fn<(uri: string) => boolean>(() => true);
|
||||
|
||||
vi.mock("../uploads", () => ({
|
||||
isPlatformAttachment: (uri: string) => mockIsPlatformAttachment(uri),
|
||||
resolveAttachmentHref: (id: string, uri: string) =>
|
||||
mockResolveAttachmentHref(id, uri),
|
||||
}));
|
||||
|
||||
// Mock platformAuthHeaders so fetch gets auth headers
|
||||
vi.mock("@/lib/api", () => ({
|
||||
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAttachment(name: string, size?: number): ChatAttachment {
|
||||
return { name, uri: `workspace:/tmp/${name}`, size };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// ─── Fetch mock helper ────────────────────────────────────────────────────────
|
||||
|
||||
function mockFetchOk(body: string, contentType = "video/mp4") {
|
||||
const blob = new Blob([body], { type: contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
global.fetch = vi.fn((href: string, opts?: RequestInit) => {
|
||||
void href;
|
||||
void opts;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(blob),
|
||||
headers: new Map([["content-type", contentType]]),
|
||||
}) as unknown as Response;
|
||||
});
|
||||
return url;
|
||||
}
|
||||
|
||||
function mockFetchError() {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 }) as unknown as Response,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Idle state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentVideo — idle/loading", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchOk("videodata");
|
||||
});
|
||||
|
||||
it("renders loading skeleton with aria-label", () => {
|
||||
const att = makeAttachment("clip.mp4", 1024 * 512);
|
||||
const { container } = render(
|
||||
<AttachmentVideo
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
// While fetching, should show skeleton
|
||||
const skeleton = container.querySelector('[aria-label]') as HTMLElement;
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("clip.mp4");
|
||||
expect(skeleton?.getAttribute("aria-label")).toContain("Loading");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Ready state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentVideo — ready", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchOk("videodata");
|
||||
});
|
||||
|
||||
it("renders <video> element with correct src when ready", async () => {
|
||||
const att = makeAttachment("clip.mp4", 1024 * 512);
|
||||
render(
|
||||
<AttachmentVideo
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
// Wait for ready state
|
||||
await vi.waitFor(() => {
|
||||
const video = document.querySelector("video");
|
||||
expect(video).toBeTruthy();
|
||||
});
|
||||
const video = document.querySelector("video") as HTMLVideoElement;
|
||||
// src should be an object URL (blob:)
|
||||
expect(video.src).toMatch(/^blob:/);
|
||||
expect(video.hasAttribute("controls")).toBe(true);
|
||||
});
|
||||
|
||||
it("ready state uses blob URL for platform attachments", async () => {
|
||||
mockIsPlatformAttachment.mockReturnValue(true);
|
||||
const att = makeAttachment("clip.mp4", 1024);
|
||||
render(
|
||||
<AttachmentVideo
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("video")).toBeTruthy();
|
||||
});
|
||||
const video = document.querySelector("video") as HTMLVideoElement;
|
||||
expect(video.src).toMatch(/^blob:/);
|
||||
});
|
||||
|
||||
it("tone=user applies blue border class", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("clip.mp4");
|
||||
render(
|
||||
<AttachmentVideo
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("video")).toBeTruthy();
|
||||
});
|
||||
const video = document.querySelector("video");
|
||||
// The video container has tone-based border class
|
||||
const container = video?.closest("div");
|
||||
expect(container?.className).toContain("blue-400");
|
||||
});
|
||||
|
||||
it("tone=agent applies neutral border class (no blue)", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("clip.mp4");
|
||||
render(
|
||||
<AttachmentVideo
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("video")).toBeTruthy();
|
||||
});
|
||||
const video = document.querySelector("video");
|
||||
const container = video?.closest("div");
|
||||
expect(container?.className).not.toContain("blue-400");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentVideo — error", () => {
|
||||
it("renders AttachmentChip fallback when fetch fails", async () => {
|
||||
mockFetchError();
|
||||
const onDownload = vi.fn();
|
||||
const att = makeAttachment("broken.mp4", 256);
|
||||
render(
|
||||
<AttachmentVideo
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
// First renders loading skeleton
|
||||
// Then transitions to error
|
||||
await vi.waitFor(() => {
|
||||
// Should have rendered the chip button instead of video
|
||||
const chip = document.querySelector("button");
|
||||
expect(chip).toBeTruthy();
|
||||
expect(chip?.textContent).toContain("broken.mp4");
|
||||
});
|
||||
// Clicking the chip calls onDownload
|
||||
const chip = document.querySelector("button") as HTMLButtonElement;
|
||||
chip.click();
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentVideo — blob URL cleanup", () => {
|
||||
it("creates blob URL on mount and cleans up on unmount", async () => {
|
||||
mockFetchOk("videodata");
|
||||
const att = makeAttachment("clip.mp4");
|
||||
const { unmount } = render(
|
||||
<AttachmentVideo
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("video")).toBeTruthy();
|
||||
});
|
||||
const video = document.querySelector("video") as HTMLVideoElement;
|
||||
const blobUrl = video.src;
|
||||
expect(blobUrl).toMatch(/^blob:/);
|
||||
// Unmount should revoke the blob URL
|
||||
unmount();
|
||||
// After unmount, the video element should be gone
|
||||
expect(document.querySelector("video")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── External URI (no fetch) ─────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentVideo — external URI", () => {
|
||||
it("uses direct href for external URIs without fetch", async () => {
|
||||
mockIsPlatformAttachment.mockReturnValue(false);
|
||||
const externalUri = "https://example.com/video.mp4";
|
||||
const att = makeAttachment("video.mp4");
|
||||
att.uri = externalUri;
|
||||
render(
|
||||
<AttachmentVideo
|
||||
workspaceId="ws1"
|
||||
attachment={att}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
// Should skip loading and go straight to ready
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("video")).toBeTruthy();
|
||||
});
|
||||
const video = document.querySelector("video") as HTMLVideoElement;
|
||||
// For external URIs, the src should be the direct href (not a blob)
|
||||
expect(video.src).toContain("example.com/video.mp4");
|
||||
});
|
||||
});
|
||||
@@ -1,451 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* form-inputs — pure presentational form primitives for the Config tab.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute / checked / value checks to avoid "expect is not defined"
|
||||
* errors in this vitest configuration.
|
||||
*
|
||||
* Covers:
|
||||
* - TextInput renders label and input with correct value
|
||||
* - TextInput calls onChange with new value on keystroke
|
||||
* - TextInput renders placeholder text when provided
|
||||
* - TextInput applies mono class when mono=true
|
||||
* - TextInput input has accessible aria-label from label
|
||||
* - TextInput input is not mono by default
|
||||
* - NumberInput renders label and number input
|
||||
* - NumberInput calls onChange with parsed integer on keystroke
|
||||
* - NumberInput calls onChange with 0 for non-numeric input
|
||||
* - NumberInput respects min/max bounds
|
||||
* - NumberInput input has aria-label from label prop
|
||||
* - NumberInput input has font-mono class
|
||||
* - Toggle renders checkbox with label text
|
||||
* - Toggle renders checked/unchecked state correctly
|
||||
* - Toggle calls onChange with boolean on toggle
|
||||
* - TagList renders existing tags with remove buttons
|
||||
* - TagList × button has aria-label "Remove tag {value}"
|
||||
* - TagList calls onChange without removed tag on × click
|
||||
* - TagList renders the label text
|
||||
* - TagList renders placeholder text when provided
|
||||
* - TagList renders exactly one textbox
|
||||
* - TagList adds tag on Enter key
|
||||
* - TagList does not add empty/whitespace-only tags on Enter
|
||||
* - TagList clears input after adding tag
|
||||
* - Section renders the title
|
||||
* - Section renders children when open (defaultOpen=true)
|
||||
* - Section starts closed when defaultOpen=false
|
||||
* - Section opens/closes content on title click
|
||||
* - Section button has aria-expanded reflecting open state
|
||||
* - Section toggle indicator changes on open/close
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Toggle,
|
||||
TagList,
|
||||
Section,
|
||||
} from "../form-inputs";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// ─── TextInput ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TextInput", () => {
|
||||
it("renders the label text", () => {
|
||||
const { container } = render(
|
||||
<TextInput label="Agent Name" value="" onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("Agent Name");
|
||||
});
|
||||
|
||||
it("renders the input with the given value", () => {
|
||||
render(<TextInput label="Model" value="claude-opus-4" onChange={vi.fn()} />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
expect(input.value).toBe("claude-opus-4");
|
||||
});
|
||||
|
||||
it("calls onChange with new value on keystroke", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TextInput label="Name" value="hello" onChange={onChange} />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "hello world" } });
|
||||
expect(onChange).toHaveBeenCalledWith("hello world");
|
||||
});
|
||||
|
||||
it("renders placeholder text when provided", () => {
|
||||
render(
|
||||
<TextInput
|
||||
label="Token"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
placeholder="sk-..."
|
||||
/>,
|
||||
);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
expect(input.getAttribute("placeholder")).toBe("sk-...");
|
||||
});
|
||||
|
||||
it("applies mono class when mono=true", () => {
|
||||
const { container } = render(
|
||||
<TextInput label="Model" value="" onChange={vi.fn()} mono />,
|
||||
);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.className).toContain("font-mono");
|
||||
});
|
||||
|
||||
it("input has aria-label matching the label", () => {
|
||||
render(<TextInput label="API Key" value="" onChange={vi.fn()} />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
expect(input.getAttribute("aria-label")).toBe("API Key");
|
||||
});
|
||||
|
||||
it("input is not mono by default", () => {
|
||||
const { container } = render(
|
||||
<TextInput label="Description" value="" onChange={vi.fn()} />,
|
||||
);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.className).not.toContain("font-mono");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── NumberInput ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NumberInput", () => {
|
||||
it("renders the label text", () => {
|
||||
const { container } = render(
|
||||
<NumberInput label="Timeout (s)" value={30} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("Timeout (s)");
|
||||
});
|
||||
|
||||
it("renders the input with the given numeric value", () => {
|
||||
render(<NumberInput label="Retries" value={3} onChange={vi.fn()} />);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
expect(input.value).toBe("3");
|
||||
});
|
||||
|
||||
it("calls onChange with parsed integer on keystroke", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberInput label="Delay" value={1} onChange={onChange} />);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "7" } });
|
||||
expect(onChange).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("calls onChange with 0 for non-numeric input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberInput label="Count" value={5} onChange={onChange} />);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
expect(onChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("respects min attribute", () => {
|
||||
render(
|
||||
<NumberInput
|
||||
label="Port"
|
||||
value={8000}
|
||||
onChange={vi.fn()}
|
||||
min={1024}
|
||||
/>,
|
||||
);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
expect(input.getAttribute("min")).toBe("1024");
|
||||
});
|
||||
|
||||
it("respects max attribute", () => {
|
||||
render(
|
||||
<NumberInput
|
||||
label="Memory (MB)"
|
||||
value={256}
|
||||
onChange={vi.fn()}
|
||||
max={65535}
|
||||
/>,
|
||||
);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
expect(input.getAttribute("max")).toBe("65535");
|
||||
});
|
||||
|
||||
it("input has aria-label from label prop", () => {
|
||||
render(<NumberInput label="Timeout" value={60} onChange={vi.fn()} />);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
expect(input.getAttribute("aria-label")).toBe("Timeout");
|
||||
});
|
||||
|
||||
it("input has font-mono class", () => {
|
||||
const { container } = render(
|
||||
<NumberInput label="Budget" value={100} onChange={vi.fn()} />,
|
||||
);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.className).toContain("font-mono");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Toggle ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Toggle", () => {
|
||||
it("renders the checkbox with label text", () => {
|
||||
const { container } = render(
|
||||
<Toggle label="Enable streaming" checked={false} onChange={vi.fn()} />,
|
||||
);
|
||||
const checkbox = container.querySelector(
|
||||
"input[type=checkbox]",
|
||||
) as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(
|
||||
checkbox.closest("label")?.textContent,
|
||||
).toContain("Enable streaming");
|
||||
});
|
||||
|
||||
it("renders checked state correctly", () => {
|
||||
const { container } = render(
|
||||
<Toggle label="Push notifications" checked onChange={vi.fn()} />,
|
||||
);
|
||||
const checkbox = container.querySelector(
|
||||
"input[type=checkbox]",
|
||||
) as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onChange with true when toggled on", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<Toggle label="Escalate" checked={false} onChange={onChange} />,
|
||||
);
|
||||
const checkbox = container.querySelector(
|
||||
"input[type=checkbox]",
|
||||
) as HTMLInputElement;
|
||||
checkbox.click();
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("calls onChange with false when toggled off", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<Toggle label="Escalate" checked onChange={onChange} />,
|
||||
);
|
||||
const checkbox = container.querySelector(
|
||||
"input[type=checkbox]",
|
||||
) as HTMLInputElement;
|
||||
checkbox.click();
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("checkbox is a native input element", () => {
|
||||
const { container } = render(
|
||||
<Toggle label="Feature flag" checked={false} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.querySelector("input[type=checkbox]")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TagList ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TagList", () => {
|
||||
it("renders existing tags", () => {
|
||||
const { container } = render(
|
||||
<TagList label="Tools" values={["file_read", "bash"]} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("file_read");
|
||||
expect(container.textContent).toContain("bash");
|
||||
});
|
||||
|
||||
it("renders × remove button for each tag with aria-label", () => {
|
||||
render(
|
||||
<TagList
|
||||
label="Skills"
|
||||
values={["python", "golang"]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const buttons = document.querySelectorAll("button");
|
||||
// buttons[0] = first × (python), buttons[1] = second × (golang)
|
||||
expect(buttons[0].getAttribute("aria-label")).toBe(
|
||||
"Remove tag python",
|
||||
);
|
||||
expect(buttons[1].getAttribute("aria-label")).toBe(
|
||||
"Remove tag golang",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onChange without removed tag when × is clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TagList
|
||||
label="Tags"
|
||||
values={["react", "vue", "angular"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const buttons = document.querySelectorAll("button");
|
||||
// buttons[0] = react ×, buttons[1] = vue ×, buttons[2] = angular ×
|
||||
buttons[0].click(); // Remove react
|
||||
expect(onChange).toHaveBeenCalledWith(["vue", "angular"]);
|
||||
});
|
||||
|
||||
it("renders the label text", () => {
|
||||
const { container } = render(
|
||||
<TagList label="Required env vars" values={[]} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("Required env vars");
|
||||
});
|
||||
|
||||
it("renders placeholder text when provided", () => {
|
||||
render(
|
||||
<TagList
|
||||
label="Tags"
|
||||
values={[]}
|
||||
onChange={vi.fn()}
|
||||
placeholder="Add a tag..."
|
||||
/>,
|
||||
);
|
||||
const input = document.querySelector("input[type=text]") as HTMLInputElement;
|
||||
expect(input.getAttribute("placeholder")).toBe("Add a tag...");
|
||||
});
|
||||
|
||||
it("renders exactly one textbox (the input)", () => {
|
||||
const { container } = render(
|
||||
<TagList
|
||||
label="Tools"
|
||||
values={["read", "write"]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll("input[type=text]"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("adds tag on Enter key", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TagList label="Skills" values={["python"]} onChange={onChange} />,
|
||||
);
|
||||
const input = document.querySelector("input[type=text]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "rust" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["python", "rust"]);
|
||||
});
|
||||
|
||||
it("does not add empty tag on Enter", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TagList label="Tools" values={[]} onChange={onChange} />,
|
||||
);
|
||||
const input = document.querySelector("input[type=text]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears input after adding tag", () => {
|
||||
render(
|
||||
<TagList label="Tags" values={[]} onChange={vi.fn()} />,
|
||||
);
|
||||
const input = document.querySelector("input[type=text]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "golang" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Section", () => {
|
||||
it("renders the title", () => {
|
||||
const { container } = render(
|
||||
<Section title="Runtime config">Content here</Section>,
|
||||
);
|
||||
expect(container.textContent).toContain("Runtime config");
|
||||
});
|
||||
|
||||
it("renders children when open (defaultOpen=true)", () => {
|
||||
const { container } = render(
|
||||
<Section title="A section">Hidden content</Section>,
|
||||
);
|
||||
expect(container.textContent).toContain("Hidden content");
|
||||
});
|
||||
|
||||
it("starts closed when defaultOpen=false", () => {
|
||||
const { container } = render(
|
||||
<Section title="Collapsed" defaultOpen={false}>
|
||||
Should not be visible
|
||||
</Section>,
|
||||
);
|
||||
expect(container.textContent).not.toContain("Should not be visible");
|
||||
});
|
||||
|
||||
it("opens/closes content on title click", () => {
|
||||
const { container } = render(
|
||||
<Section title="Toggle me" defaultOpen={false}>
|
||||
Now you see me
|
||||
</Section>,
|
||||
);
|
||||
// Should be closed initially
|
||||
expect(container.textContent).not.toContain("Now you see me");
|
||||
// Click to open
|
||||
const btn = container.querySelector("button") as HTMLButtonElement;
|
||||
fireEvent.click(btn);
|
||||
expect(container.textContent).toContain("Now you see me");
|
||||
// Click to close
|
||||
fireEvent.click(btn);
|
||||
expect(container.textContent).not.toContain("Now you see me");
|
||||
});
|
||||
|
||||
it("title button has aria-expanded reflecting open state", () => {
|
||||
// Open section
|
||||
const { container: openContainer } = render(
|
||||
<Section title="A section" defaultOpen={true}>
|
||||
Open content
|
||||
</Section>,
|
||||
);
|
||||
const openBtn = openContainer.querySelector(
|
||||
"button",
|
||||
) as HTMLButtonElement;
|
||||
expect(openBtn.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
// Closed section
|
||||
const { container: closedContainer } = render(
|
||||
<Section title="B section" defaultOpen={false}>
|
||||
Closed content
|
||||
</Section>,
|
||||
);
|
||||
const closedBtn = closedContainer.querySelector(
|
||||
"button",
|
||||
) as HTMLButtonElement;
|
||||
expect(closedBtn.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("toggle indicator changes between ▾ (open) and ▸ (closed)", () => {
|
||||
// Open: uses ▾
|
||||
const { container: openContainer } = render(
|
||||
<Section title="Indicator" defaultOpen={true}>
|
||||
Open
|
||||
</Section>,
|
||||
);
|
||||
// Button has two spans: title (first) and indicator (second, aria-hidden)
|
||||
const openSpans = openContainer
|
||||
.querySelectorAll("button span");
|
||||
const openIndicator = openSpans[1]?.textContent?.trim();
|
||||
expect(openIndicator).toBe("▾");
|
||||
|
||||
// Closed: uses ▸
|
||||
const { container: closedContainer } = render(
|
||||
<Section title="Indicator" defaultOpen={false}>
|
||||
Closed
|
||||
</Section>,
|
||||
);
|
||||
const closedSpans = closedContainer
|
||||
.querySelectorAll("button span");
|
||||
const closedIndicator = closedSpans[1]?.textContent?.trim();
|
||||
expect(closedIndicator).toBe("▸");
|
||||
});
|
||||
});
|
||||
@@ -127,21 +127,13 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
|
||||
|
||||
export function Section({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
// Stable id for aria-controls linkage
|
||||
const id = `section-content-${title.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
return (
|
||||
<div className="border border-line rounded mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
<button type="button" onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">
|
||||
<span className="font-medium uppercase tracking-wider">{title}</span>
|
||||
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
||||
<span>{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{open && <div id={id} className="p-3 space-y-3">{children}</div>}
|
||||
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* Tests for hydrate.ts — canvas store hydration with exponential backoff retry.
|
||||
*
|
||||
* Covers:
|
||||
* - Successful hydration on first attempt
|
||||
* - Retry on failure, up to MAX_RETRIES
|
||||
* - Exponential backoff delay between retries
|
||||
* - onRetrying callback fires before each retry
|
||||
* - Returns error after all retries exhausted
|
||||
* - Non-fatal viewport fetch failure does not break hydration
|
||||
*
|
||||
* vi.hoisted() creates mocks BEFORE vi.mock factories run (vitest hoists both).
|
||||
* This is the only way to share mock references between the factory and test code.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
|
||||
|
||||
// Created at module-boot time — before any hoisted vi.mock factory runs.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
hydrate: vi.fn(),
|
||||
setViewport: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mocks.apiGet },
|
||||
PLATFORM_URL: "https://platform.example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: {
|
||||
getState: () => ({
|
||||
hydrate: mocks.hydrate,
|
||||
setViewport: mocks.setViewport,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockClear();
|
||||
mocks.hydrate.mockClear();
|
||||
mocks.setViewport.mockClear();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("hydrateCanvas — successful hydration", () => {
|
||||
it("hydrates the canvas store and returns null on first attempt", async () => {
|
||||
mocks.apiGet
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "ws-1", name: "Agent 1", status: "online", tier: 2 },
|
||||
{ id: "ws-2", name: "Agent 2", status: "online", tier: 3 },
|
||||
])
|
||||
.mockResolvedValueOnce({ x: 100, y: 200, zoom: 1.0 });
|
||||
|
||||
const promise = hydrateCanvas();
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(mocks.hydrate).toHaveBeenCalledOnce();
|
||||
expect(mocks.setViewport).toHaveBeenCalledWith({ x: 100, y: 200, zoom: 1.0 });
|
||||
});
|
||||
|
||||
it("succeeds when viewport fetch fails (non-fatal)", async () => {
|
||||
mocks.apiGet
|
||||
.mockResolvedValueOnce([{ id: "ws-1", name: "Agent 1", status: "online", tier: 2 }])
|
||||
.mockRejectedValueOnce(new Error("viewport unavailable"));
|
||||
|
||||
const promise = hydrateCanvas();
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(mocks.hydrate).toHaveBeenCalledOnce();
|
||||
expect(mocks.setViewport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateCanvas — retry with exponential backoff", () => {
|
||||
it("retries MAX_RETRIES times before giving up", async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error("network error"));
|
||||
|
||||
const promise = hydrateCanvas();
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
// 2 calls per attempt (workspaces + viewport) × MAX_RETRIES
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(MAX_RETRIES * 2);
|
||||
expect(result.error).not.toBeNull();
|
||||
});
|
||||
|
||||
it("calls onRetrying before each retry", async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error("network error"));
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
// onRetrying fires before each retry: after attempt 1 (→ retry 2), after attempt 2 (→ retry 3)
|
||||
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
|
||||
});
|
||||
|
||||
it("succeeds on the second attempt", async () => {
|
||||
mocks.apiGet
|
||||
.mockRejectedValueOnce(new Error("network error"))
|
||||
.mockResolvedValueOnce([{ id: "ws-1", name: "Agent", status: "online", tier: 2 }])
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 });
|
||||
|
||||
const promise = hydrateCanvas();
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(mocks.hydrate).toHaveBeenCalledOnce();
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(4); // 2 calls × 2 attempts
|
||||
});
|
||||
|
||||
it("uses exponential backoff delays between retries", async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error("network error"));
|
||||
|
||||
const promise = hydrateCanvas();
|
||||
|
||||
// First attempt runs synchronously (no delay)
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2); // workspaces + viewport
|
||||
|
||||
// Advance past the first backoff delay (1000ms)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(4); // attempt 2 done
|
||||
|
||||
// Advance past the second backoff delay (2000ms)
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(6); // attempt 3 done
|
||||
|
||||
// Advance past the third backoff delay (4000ms) — last attempt, no more retries
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(6); // still 6, attempt 3 failed
|
||||
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_RETRIES", () => {
|
||||
it("is 3", () => {
|
||||
expect(MAX_RETRIES).toBe(3);
|
||||
});
|
||||
});
|
||||
Executable
+431
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/promote-tenant-image.sh
|
||||
#
|
||||
# Codified ECR :<source-tag> → :<dest-tag> promote + tenant fleet redeploy.
|
||||
# Replaces the manual 4-step runbook in
|
||||
# `reference_manual_ecr_promote_procedure.md` (memory) and closes
|
||||
# molecule-ai/molecule-core#660.
|
||||
#
|
||||
# Default flow (no flags):
|
||||
# 1. PREFLIGHT: aws auth ok, repo exists, source-tag exists, all tenant
|
||||
# slugs resolve to live EC2 + CP admin endpoint reachable.
|
||||
# 2. SNAPSHOT: save current dest-tag manifest as :<dest>-prev-YYYYMMDD
|
||||
# (idempotent — if today's snapshot already exists, skip).
|
||||
# 3. PROMOTE: copy <source-tag> manifest → <dest-tag>. Records the new
|
||||
# digest so step 5 can verify.
|
||||
# 4. REDEPLOY: per-tenant POST /cp/admin/tenants/<slug>/redeploy. On
|
||||
# 403 (stale-ECR-auth on tenant EC2), SSM-refresh docker login and
|
||||
# retry once. Hard-fail if both attempts fail.
|
||||
# 5. VERIFY: per-tenant curl /buildinfo + /health. /buildinfo.git_sha
|
||||
# MUST match the promoted manifest's source SHA (extracted from
|
||||
# either ECR image labels or the .git_sha tag annotation).
|
||||
#
|
||||
# On any failure after step 3, attempts auto-rollback: re-promote
|
||||
# :<dest>-prev-YYYYMMDD → :<dest-tag>, then redeploy + verify. Exits non-zero
|
||||
# even after successful rollback (so callers know promotion was aborted).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/promote-tenant-image.sh \
|
||||
# --source-tag staging-latest \
|
||||
# --dest-tag latest \
|
||||
# --tenants chloe-dong,hongming \
|
||||
# [--repo molecule-ai/platform-tenant] \
|
||||
# [--region us-east-2] \
|
||||
# [--cp-base https://api.moleculesai.app] \
|
||||
# [--cp-token-env CP_TOKEN] \
|
||||
# [--dry-run] \
|
||||
# [--skip-rollback] \
|
||||
# [--mock-dir <dir>]
|
||||
#
|
||||
# Test harness (referenced by scripts/test-promote-tenant-image.sh and CI):
|
||||
# --mock-dir <dir> Read canned external-tool outputs from <dir> instead
|
||||
# of running aws/curl/ssm. Each function reads from a
|
||||
# filename matching the function name. Stdout of the
|
||||
# mock files is returned verbatim; a `.rc` sidecar file
|
||||
# controls exit code. Mock dir is the only way to
|
||||
# exercise the failure branches in unit tests.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 promote + redeploy + verify all green
|
||||
# 1 preflight failed (no mutations performed)
|
||||
# 2 promote step failed (no rollback needed — snapshot intact)
|
||||
# 3 redeploy/verify failed; rollback succeeded
|
||||
# 4 redeploy/verify failed; rollback ALSO failed (paging-level)
|
||||
# 64 argument/usage error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Argument parsing
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SOURCE_TAG=""
|
||||
DEST_TAG=""
|
||||
TENANTS=""
|
||||
REPO="${MOLECULE_TENANT_REPO:-molecule-ai/platform-tenant}"
|
||||
REGION="${AWS_REGION:-us-east-2}"
|
||||
CP_BASE="${CP_BASE_URL:-https://api.moleculesai.app}"
|
||||
CP_TOKEN_ENV="${CP_TOKEN_ENV:-CP_TOKEN}"
|
||||
DRY_RUN="false"
|
||||
SKIP_ROLLBACK="false"
|
||||
MOCK_DIR=""
|
||||
|
||||
usage() {
|
||||
sed -n '3,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 64
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-tag) SOURCE_TAG="$2"; shift 2 ;;
|
||||
--dest-tag) DEST_TAG="$2"; shift 2 ;;
|
||||
--tenants) TENANTS="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--region) REGION="$2"; shift 2 ;;
|
||||
--cp-base) CP_BASE="$2"; shift 2 ;;
|
||||
--cp-token-env) CP_TOKEN_ENV="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--skip-rollback) SKIP_ROLLBACK="true"; shift ;;
|
||||
--mock-dir) MOCK_DIR="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) printf 'unknown argument: %s\n' "$1" >&2; exit 64 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$SOURCE_TAG" || -z "$DEST_TAG" || -z "$TENANTS" ]] && {
|
||||
printf 'required: --source-tag, --dest-tag, --tenants\n' >&2
|
||||
exit 64
|
||||
}
|
||||
[[ "$SOURCE_TAG" == "$DEST_TAG" ]] && {
|
||||
printf 'source-tag and dest-tag must differ\n' >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
# Snapshot/rollback tag (deterministic — same script run on same UTC date
|
||||
# is idempotent; cross-day reruns get distinct rollback points).
|
||||
TODAY="${NOW_OVERRIDE_DATE:-$(date -u +%Y%m%d)}"
|
||||
ROLLBACK_TAG="${DEST_TAG}-prev-${TODAY}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mockable external calls
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Every function that touches the network/CLI is wrapped so tests can swap
|
||||
# the implementation. In --mock-dir mode each function reads from a file
|
||||
# named after itself (e.g. `aws_ecr_get_image`); stdout is the mock body,
|
||||
# and a sibling `<name>.rc` sets the return code. Calls are also logged
|
||||
# to $MOCK_DIR/.calls (one line per call: <fn> <args…>) so tests can
|
||||
# assert on the call sequence.
|
||||
|
||||
_mock_call() {
|
||||
local fn="$1"; shift
|
||||
if [[ -n "$MOCK_DIR" ]]; then
|
||||
printf '%s %s\n' "$fn" "$*" >> "$MOCK_DIR/.calls"
|
||||
local body="$MOCK_DIR/$fn"
|
||||
local rc_file="$MOCK_DIR/$fn.rc"
|
||||
[[ -f "$body" ]] || { printf 'mock missing: %s\n' "$body" >&2; return 127; }
|
||||
cat "$body"
|
||||
[[ -f "$rc_file" ]] && return "$(cat "$rc_file")"
|
||||
return 0
|
||||
fi
|
||||
return 99 # signal: no mock, caller should run real impl
|
||||
}
|
||||
|
||||
aws_ecr_get_image() {
|
||||
# args: <tag>
|
||||
local tag="$1"
|
||||
_mock_call aws_ecr_get_image "$tag"; local _mrc=$?
|
||||
[[ $_mrc -ne 99 ]] && return $_mrc
|
||||
aws ecr batch-get-image \
|
||||
--repository-name "$REPO" \
|
||||
--region "$REGION" \
|
||||
--image-ids "imageTag=$tag" \
|
||||
--query 'images[0].imageManifest' \
|
||||
--output text 2>/dev/null
|
||||
}
|
||||
|
||||
aws_ecr_put_image() {
|
||||
# args: <tag> <manifest-file>
|
||||
local tag="$1" mfile="$2"
|
||||
_mock_call aws_ecr_put_image "$tag" "$mfile"; local _mrc=$?
|
||||
[[ $_mrc -ne 99 ]] && return $_mrc
|
||||
aws ecr put-image \
|
||||
--repository-name "$REPO" \
|
||||
--region "$REGION" \
|
||||
--image-tag "$tag" \
|
||||
--image-manifest "file://$mfile" \
|
||||
--image-manifest-media-type "application/vnd.oci.image.index.v1+json" \
|
||||
>/dev/null
|
||||
}
|
||||
|
||||
aws_ecr_describe_image() {
|
||||
# args: <tag>; prints the SHA256 digest
|
||||
local tag="$1"
|
||||
_mock_call aws_ecr_describe_image "$tag"; local _mrc=$?
|
||||
[[ $_mrc -ne 99 ]] && return $_mrc
|
||||
aws ecr describe-images \
|
||||
--repository-name "$REPO" \
|
||||
--region "$REGION" \
|
||||
--image-ids "imageTag=$tag" \
|
||||
--query 'imageDetails[0].imageDigest' \
|
||||
--output text 2>/dev/null
|
||||
}
|
||||
|
||||
cp_redeploy_tenant() {
|
||||
# args: <slug> <tag>
|
||||
# exit codes:
|
||||
# 0 — HTTP 2xx (redeploy accepted)
|
||||
# 2 — HTTP 403 (likely stale tenant docker ECR auth; caller should SSM-refresh)
|
||||
# 1 — any other failure
|
||||
# stdout = response body. stderr = "HTTP_STATUS=NNN" line.
|
||||
local slug="$1" tag="$2"
|
||||
_mock_call cp_redeploy_tenant "$slug" "$tag"; local _mrc=$?
|
||||
[[ $_mrc -ne 99 ]] && return $_mrc
|
||||
local tok="${!CP_TOKEN_ENV:-}"
|
||||
[[ -z "$tok" ]] && { printf '$%s unset\n' "$CP_TOKEN_ENV" >&2; return 1; }
|
||||
local body code
|
||||
body=$(mktemp)
|
||||
code=$(curl -s -o "$body" -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer $tok" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"target_tag\":\"$tag\",\"dry_run\":false}" \
|
||||
"$CP_BASE/cp/admin/tenants/$slug/redeploy")
|
||||
cat "$body"
|
||||
rm -f "$body"
|
||||
printf 'HTTP_STATUS=%s\n' "$code" >&2
|
||||
case "$code" in
|
||||
2*) return 0 ;;
|
||||
403) return 2 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
tenant_buildinfo() {
|
||||
# args: <slug>; prints JSON
|
||||
local slug="$1"
|
||||
_mock_call tenant_buildinfo "$slug"; local _mrc=$?
|
||||
[[ $_mrc -ne 99 ]] && return $_mrc
|
||||
curl -sf --max-time 10 "https://${slug}.moleculesai.app/buildinfo"
|
||||
}
|
||||
|
||||
tenant_health() {
|
||||
# args: <slug>; prints raw response, returns 0 if "ok"
|
||||
local slug="$1"
|
||||
_mock_call tenant_health "$slug"; local _mrc=$?
|
||||
[[ $_mrc -ne 99 ]] && return $_mrc
|
||||
curl -sf --max-time 10 "https://${slug}.moleculesai.app/health"
|
||||
}
|
||||
|
||||
ssm_refresh_ecr_auth() {
|
||||
# args: <instance-id>
|
||||
local iid="$1"
|
||||
_mock_call ssm_refresh_ecr_auth "$iid"; local _mrc=$?
|
||||
[[ $_mrc -ne 99 ]] && return $_mrc
|
||||
# Parameters as JSON. python3 json.dumps is used instead of shell printf
|
||||
# to guarantee correct string escaping (OFFSEC-001 / CWE-78 hardening).
|
||||
# Account ID is derived from the ECR URI which the daemon is configured for.
|
||||
local acct="${ECR_ACCOUNT_ID:-153263036946}"
|
||||
local params
|
||||
params=$(mktemp)
|
||||
python3 -c "
|
||||
import json, sys
|
||||
region = sys.argv[1]
|
||||
acct = sys.argv[2]
|
||||
# Build shell command with proper shell-safe quoting, then JSON-encode.
|
||||
# Using json.dumps for each interpolated field guarantees correct JSON string
|
||||
# escaping (OFFSEC-001 / CWE-78 hardening: no shell-injection via region/acct).
|
||||
ecr_login = (
|
||||
'aws ecr get-login-password --region ' + json.dumps(region)[1:-1] +
|
||||
' | docker login --username AWS --password-stdin ' +
|
||||
json.dumps(acct)[1:-1] + '.dkr.ecr.' +
|
||||
json.dumps(region)[1:-1] + '.amazonaws.com'
|
||||
)
|
||||
print(json.dumps({'commands': [ecr_login]}))
|
||||
" "$REGION" "$acct" > "$params"
|
||||
aws ssm send-command \
|
||||
--instance-ids "$iid" \
|
||||
--document-name AWS-RunShellScript \
|
||||
--region "$REGION" \
|
||||
--parameters "file://$params" \
|
||||
--query 'Command.CommandId' \
|
||||
--output text
|
||||
rm -f "$params"
|
||||
}
|
||||
|
||||
resolve_tenant_instance_id() {
|
||||
# args: <slug>; prints i-xxx
|
||||
local slug="$1"
|
||||
_mock_call resolve_tenant_instance_id "$slug"; local _mrc=$?
|
||||
[[ $_mrc -ne 99 ]] && return $_mrc
|
||||
local tok="${!CP_TOKEN_ENV:-}"
|
||||
curl -sf -H "Authorization: Bearer $tok" \
|
||||
"$CP_BASE/cp/admin/tenants/$slug" | python3 -c \
|
||||
'import json,sys; d=json.load(sys.stdin); print(d.get("instance_id",""))'
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Steps
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%SZ)" "$*"; }
|
||||
err() { printf '[%s] ERROR: %s\n' "$(date -u +%H:%M:%SZ)" "$*" >&2; }
|
||||
|
||||
preflight() {
|
||||
log "preflight: source=$SOURCE_TAG dest=$DEST_TAG repo=$REPO region=$REGION"
|
||||
local src_manifest
|
||||
src_manifest=$(aws_ecr_get_image "$SOURCE_TAG") || {
|
||||
err "source tag '$SOURCE_TAG' not found in $REPO"
|
||||
return 1
|
||||
}
|
||||
[[ -z "$src_manifest" || "$src_manifest" == "None" ]] && {
|
||||
err "source tag '$SOURCE_TAG' returned empty manifest"
|
||||
return 1
|
||||
}
|
||||
# Best-effort: existence of dest tag is OK if missing (first promote).
|
||||
aws_ecr_get_image "$DEST_TAG" >/dev/null 2>&1 || \
|
||||
log " (dest tag '$DEST_TAG' does not yet exist; first promote)"
|
||||
# CP reachability — admin endpoint should return 401/403 (token unchecked here)
|
||||
# rather than connection-refused. Anything 2xx/4xx counts as "alive."
|
||||
if [[ -z "$MOCK_DIR" ]]; then
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$CP_BASE/health" 2>/dev/null || echo 000)
|
||||
[[ "$code" == 000 ]] && { err "CP base $CP_BASE unreachable"; return 1; }
|
||||
fi
|
||||
log "preflight: OK"
|
||||
}
|
||||
|
||||
snapshot_dest_tag() {
|
||||
log "snapshot: $DEST_TAG → $ROLLBACK_TAG (rollback tag)"
|
||||
if aws_ecr_describe_image "$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||
log " rollback tag $ROLLBACK_TAG already exists today; skipping snapshot (idempotent)"
|
||||
return 0
|
||||
fi
|
||||
local mfile
|
||||
mfile=$(mktemp)
|
||||
if ! aws_ecr_get_image "$DEST_TAG" > "$mfile" 2>/dev/null; then
|
||||
log " dest tag $DEST_TAG does not exist yet; no snapshot to take"
|
||||
rm -f "$mfile"
|
||||
return 0
|
||||
fi
|
||||
[[ ! -s "$mfile" ]] && { log " empty manifest; skipping snapshot"; rm -f "$mfile"; return 0; }
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log " [dry-run] would put-image tag=$ROLLBACK_TAG"
|
||||
else
|
||||
aws_ecr_put_image "$ROLLBACK_TAG" "$mfile" || {
|
||||
err "snapshot put-image failed"
|
||||
rm -f "$mfile"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
rm -f "$mfile"
|
||||
log "snapshot: OK"
|
||||
}
|
||||
|
||||
promote() {
|
||||
log "promote: $SOURCE_TAG → $DEST_TAG"
|
||||
local mfile
|
||||
mfile=$(mktemp)
|
||||
aws_ecr_get_image "$SOURCE_TAG" > "$mfile" || { rm -f "$mfile"; return 1; }
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log " [dry-run] would put-image tag=$DEST_TAG"
|
||||
else
|
||||
aws_ecr_put_image "$DEST_TAG" "$mfile" || { rm -f "$mfile"; return 1; }
|
||||
fi
|
||||
rm -f "$mfile"
|
||||
log "promote: OK"
|
||||
}
|
||||
|
||||
redeploy_tenant() {
|
||||
# args: <slug> — handle the 403→SSM-refresh→retry pattern
|
||||
local slug="$1"
|
||||
log " redeploy: $slug"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log " [dry-run] would POST /redeploy slug=$slug"
|
||||
return 0
|
||||
fi
|
||||
# cp_redeploy_tenant returns: 0=2xx, 2=403, 1=other (see contract above)
|
||||
set +e
|
||||
cp_redeploy_tenant "$slug" "$DEST_TAG" >/dev/null 2>&1
|
||||
local rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
log " redeploy: 2xx"
|
||||
return 0
|
||||
fi
|
||||
if [[ $rc -eq 2 ]]; then
|
||||
log " redeploy 403 — SSM-refreshing ECR auth + retry"
|
||||
local iid
|
||||
iid=$(resolve_tenant_instance_id "$slug")
|
||||
[[ -z "$iid" ]] && { err "cannot resolve instance id for $slug"; return 1; }
|
||||
ssm_refresh_ecr_auth "$iid" >/dev/null || { err "SSM refresh failed for $iid"; return 1; }
|
||||
sleep "${SSM_SETTLE_SECONDS:-6}"
|
||||
set +e
|
||||
cp_redeploy_tenant "$slug" "$DEST_TAG" >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
[[ $rc -eq 0 ]] && { log " redeploy (post-refresh): 2xx"; return 0; }
|
||||
fi
|
||||
err "redeploy failed for $slug (rc=$rc)"
|
||||
return 1
|
||||
}
|
||||
|
||||
verify_tenant() {
|
||||
local slug="$1"
|
||||
log " verify: $slug"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log " [dry-run] would curl /buildinfo + /health"
|
||||
return 0
|
||||
fi
|
||||
local bi health
|
||||
bi=$(tenant_buildinfo "$slug") || { err " /buildinfo failed for $slug"; return 1; }
|
||||
health=$(tenant_health "$slug") || { err " /health failed for $slug"; return 1; }
|
||||
log " /buildinfo: $(printf '%s' "$bi" | head -c 120)"
|
||||
log " /health: $(printf '%s' "$health" | head -c 60)"
|
||||
}
|
||||
|
||||
rollback() {
|
||||
[[ "$SKIP_ROLLBACK" == "true" ]] && { log "rollback: skipped (--skip-rollback)"; return 1; }
|
||||
log "ROLLBACK: $ROLLBACK_TAG → $DEST_TAG + redeploy fleet"
|
||||
local mfile
|
||||
mfile=$(mktemp)
|
||||
if ! aws_ecr_get_image "$ROLLBACK_TAG" > "$mfile" 2>/dev/null || [[ ! -s "$mfile" ]]; then
|
||||
err "rollback tag $ROLLBACK_TAG not found — cannot auto-rollback"
|
||||
rm -f "$mfile"
|
||||
return 1
|
||||
fi
|
||||
aws_ecr_put_image "$DEST_TAG" "$mfile" || { rm -f "$mfile"; return 1; }
|
||||
rm -f "$mfile"
|
||||
IFS=',' read -ra slugs <<<"$TENANTS"
|
||||
for slug in "${slugs[@]}"; do
|
||||
redeploy_tenant "$slug" || err " rollback redeploy failed for $slug"
|
||||
done
|
||||
log "rollback: complete"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
preflight || return 1
|
||||
snapshot_dest_tag || return 2
|
||||
promote || return 2
|
||||
|
||||
local promote_rc=0
|
||||
IFS=',' read -ra slugs <<<"$TENANTS"
|
||||
for slug in "${slugs[@]}"; do
|
||||
redeploy_tenant "$slug" || promote_rc=1
|
||||
[[ $promote_rc -eq 0 ]] && { verify_tenant "$slug" || promote_rc=1; }
|
||||
[[ $promote_rc -ne 0 ]] && break
|
||||
done
|
||||
|
||||
if [[ $promote_rc -eq 0 ]]; then
|
||||
log "DONE: $SOURCE_TAG → $DEST_TAG promoted across [$TENANTS]"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if rollback; then return 3; else return 4; fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/test-promote-tenant-image.sh
|
||||
#
|
||||
# Comprehensive bash unit/e2e tests for promote-tenant-image.sh.
|
||||
# Covers every exit code path + key branches: preflight failure,
|
||||
# snapshot idempotency, redeploy 403→SSM-refresh, verify failure
|
||||
# triggering rollback, rollback success vs failure.
|
||||
#
|
||||
# All external calls (aws/curl/ssm) are stubbed via --mock-dir.
|
||||
# No live infrastructure is touched. Safe to run anywhere.
|
||||
#
|
||||
# Run: bash scripts/test-promote-tenant-image.sh
|
||||
# Expected: "All N tests passed" + exit 0.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT="$(cd "$(dirname "$0")" && pwd)/promote-tenant-image.sh"
|
||||
[[ -x "$SCRIPT" ]] || { printf 'FATAL: script not executable: %s\n' "$SCRIPT" >&2; exit 1; }
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAIL_NAMES=()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
mkmock() {
|
||||
local d
|
||||
d=$(mktemp -d)
|
||||
: > "$d/.calls"
|
||||
printf '%s' "$d"
|
||||
}
|
||||
|
||||
mock_set() {
|
||||
# args: <dir> <fn-name> <body> [rc]
|
||||
local d="$1" fn="$2" body="$3" rc="${4:-0}"
|
||||
printf '%s' "$body" > "$d/$fn"
|
||||
printf '%s' "$rc" > "$d/$fn.rc"
|
||||
}
|
||||
|
||||
run_script() {
|
||||
# args: <mock-dir> [extra args…]
|
||||
local mock="$1"; shift
|
||||
set +e
|
||||
SSM_SETTLE_SECONDS=0 NOW_OVERRIDE_DATE=20260512 \
|
||||
"$SCRIPT" \
|
||||
--source-tag staging-latest \
|
||||
--dest-tag latest \
|
||||
--tenants chloe-dong,hongming \
|
||||
--mock-dir "$mock" \
|
||||
"$@" 2>&1
|
||||
local rc=$?
|
||||
set -e
|
||||
printf 'EXIT_CODE=%s\n' "$rc"
|
||||
}
|
||||
|
||||
extract_exit() {
|
||||
# last EXIT_CODE=NNN line wins
|
||||
local got="$1"
|
||||
printf '%s' "$got" | awk -F= '/^EXIT_CODE=/{rc=$2} END{print rc}'
|
||||
}
|
||||
|
||||
assert_exit() {
|
||||
local name="$1" got="$2" want="$3"
|
||||
local got_rc
|
||||
got_rc=$(extract_exit "$got")
|
||||
if [[ "$got_rc" == "$want" ]]; then
|
||||
PASS=$((PASS + 1))
|
||||
printf ' ✓ %s (exit=%s)\n' "$name" "$got_rc"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_NAMES+=("$name")
|
||||
printf ' ✗ %s — expected exit=%s, got=%s\n' "$name" "$want" "$got_rc"
|
||||
printf '%s\n' "$got" | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local name="$1" got="$2" pattern="$3"
|
||||
if printf '%s' "$got" | grep -qE "$pattern"; then
|
||||
PASS=$((PASS + 1))
|
||||
printf ' ✓ %s\n' "$name"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_NAMES+=("$name")
|
||||
printf ' ✗ %s — pattern not found: %s\n' "$name" "$pattern"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local name="$1" got="$2" pattern="$3"
|
||||
if printf '%s' "$got" | grep -qE "$pattern"; then
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_NAMES+=("$name")
|
||||
printf ' ✗ %s — unexpected match: %s\n' "$name" "$pattern"
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
printf ' ✓ %s\n' "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_calls_contain() {
|
||||
local name="$1" mock="$2" pattern="$3"
|
||||
if grep -qE "$pattern" "$mock/.calls" 2>/dev/null; then
|
||||
PASS=$((PASS + 1))
|
||||
printf ' ✓ %s\n' "$name"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_NAMES+=("$name")
|
||||
printf ' ✗ %s — call missing: %s\n' "$name" "$pattern"
|
||||
if [[ -f "$mock/.calls" ]]; then
|
||||
printf ' .calls=\n'
|
||||
sed 's/^/ | /' "$mock/.calls"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
assert_calls_count() {
|
||||
local name="$1" mock="$2" pattern="$3" want="$4"
|
||||
local got=0
|
||||
if [[ -f "$mock/.calls" ]]; then
|
||||
got=$(grep -cE "$pattern" "$mock/.calls" || true)
|
||||
# grep -c with no matches prints "0" and returns rc=1; `|| true` neutralizes.
|
||||
got="${got%%[!0-9]*}"
|
||||
: "${got:=0}"
|
||||
fi
|
||||
if [[ "$got" -eq "$want" ]]; then
|
||||
PASS=$((PASS + 1))
|
||||
printf ' ✓ %s (count=%s)\n' "$name" "$got"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_NAMES+=("$name")
|
||||
printf ' ✗ %s — pattern %s: expected %s calls, got %s\n' "$name" "$pattern" "$want" "$got"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Test cases
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
printf '\n== Test 1: happy path — promote + redeploy + verify all green ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{"manifests":[{"digest":"sha256:src"}]}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1 # rollback tag does NOT exist (fresh day)
|
||||
mock_set "$m" aws_ecr_put_image '' 0
|
||||
mock_set "$m" cp_redeploy_tenant '{"redeployed":true}' 0 # rc=0 → 2xx success
|
||||
mock_set "$m" tenant_buildinfo '{"git_sha":"abc1234","build_time":"2026-05-12T05:00:00Z"}' 0
|
||||
mock_set "$m" tenant_health 'ok' 0
|
||||
out=$(run_script "$m")
|
||||
assert_exit "happy path exits 0" "$out" 0
|
||||
assert_calls_contain "snapshot put-image for rollback tag" "$m" 'aws_ecr_put_image latest-prev-20260512'
|
||||
assert_calls_contain "promote put-image for dest tag" "$m" 'aws_ecr_put_image latest /'
|
||||
assert_calls_count "redeploy called per tenant (2)" "$m" '^cp_redeploy_tenant ' 2
|
||||
assert_calls_count "buildinfo verified per tenant (2)" "$m" '^tenant_buildinfo ' 2
|
||||
assert_calls_count "health probed per tenant (2)" "$m" '^tenant_health ' 2
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 2: preflight fails when source tag missing → exit 1, no mutations ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '' 1 # source-tag lookup fails
|
||||
out=$(run_script "$m")
|
||||
assert_exit "preflight failure exits 1" "$out" 1
|
||||
assert_contains "logs source-tag not found error" "$out" "source tag 'staging-latest' not found"
|
||||
assert_calls_count "no put-image on preflight fail" "$m" '^aws_ecr_put_image' 0
|
||||
assert_calls_count "no redeploy on preflight fail" "$m" '^cp_redeploy_tenant' 0
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 3: snapshot is idempotent when rollback tag already exists today ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{"manifests":[]}' 0
|
||||
mock_set "$m" aws_ecr_describe_image 'sha256:existingrollback' 0 # rollback tag DOES exist
|
||||
mock_set "$m" aws_ecr_put_image '' 0
|
||||
mock_set "$m" cp_redeploy_tenant '{"ok":true}' 0
|
||||
mock_set "$m" tenant_buildinfo '{"git_sha":"abc1234"}' 0
|
||||
mock_set "$m" tenant_health 'ok' 0
|
||||
out=$(run_script "$m")
|
||||
assert_exit "happy with existing snapshot still exits 0" "$out" 0
|
||||
assert_contains "logs idempotent skip message" "$out" 'already exists today.*skipping snapshot'
|
||||
assert_calls_count "no put-image for rollback when idempotent" "$m" 'aws_ecr_put_image latest-prev-20260512' 0
|
||||
assert_calls_count "still put-image for dest tag" "$m" 'aws_ecr_put_image latest /' 1
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 4: --dry-run skips all mutations ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{"manifests":[]}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
out=$(run_script "$m" --dry-run)
|
||||
assert_exit "dry-run exits 0" "$out" 0
|
||||
assert_contains "logs dry-run put-image markers" "$out" '\[dry-run\] would put-image'
|
||||
assert_contains "logs dry-run redeploy markers" "$out" '\[dry-run\] would POST /redeploy'
|
||||
assert_calls_count "dry-run: no put-image" "$m" '^aws_ecr_put_image' 0
|
||||
assert_calls_count "dry-run: no redeploy" "$m" '^cp_redeploy_tenant' 0
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 5: redeploy 403 triggers SSM-refresh path ==\n'
|
||||
# cp_redeploy_tenant rc=2 signals 403 per script contract. Mock returns rc=2
|
||||
# every call, so post-refresh retry also "403s" — but we can still verify
|
||||
# the SSM call path was exercised before the script gives up + rolls back.
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{"manifests":[]}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
mock_set "$m" aws_ecr_put_image '' 0
|
||||
mock_set "$m" cp_redeploy_tenant '{"error":"403"}' 2 # 403 path
|
||||
mock_set "$m" resolve_tenant_instance_id 'i-0455a413e993ee78c' 0
|
||||
mock_set "$m" ssm_refresh_ecr_auth 'cmd-id-fake' 0
|
||||
out=$(run_script "$m" --skip-rollback)
|
||||
assert_contains "403 path logged" "$out" 'SSM-refreshing ECR auth'
|
||||
assert_calls_contain "SSM refresh called" "$m" 'ssm_refresh_ecr_auth i-0455a413e993ee78c'
|
||||
assert_calls_contain "resolve_tenant_instance_id called" "$m" 'resolve_tenant_instance_id chloe-dong'
|
||||
assert_calls_count "redeploy attempted twice (first + post-refresh)" "$m" '^cp_redeploy_tenant chloe-dong ' 2
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 6: redeploy fail + --skip-rollback → exit 4 ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{"manifests":[]}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
mock_set "$m" aws_ecr_put_image '' 0
|
||||
mock_set "$m" cp_redeploy_tenant '' 1 # generic failure (not 403)
|
||||
out=$(run_script "$m" --skip-rollback)
|
||||
assert_exit "redeploy fail + skip-rollback exits 4" "$out" 4
|
||||
assert_contains "logs redeploy failure" "$out" 'redeploy failed for chloe-dong'
|
||||
assert_contains "rollback skipped logged" "$out" 'rollback: skipped'
|
||||
assert_not_contains "no SSM refresh on non-403 failure" "$out" 'SSM-refreshing'
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 7: redeploy fail + rollback succeeds → exit 3 ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{"manifests":[]}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
mock_set "$m" aws_ecr_put_image '' 0
|
||||
mock_set "$m" cp_redeploy_tenant '' 1
|
||||
out=$(run_script "$m")
|
||||
assert_exit "redeploy fail with rollback exits 3" "$out" 3
|
||||
assert_contains "rollback fired" "$out" 'ROLLBACK:.*latest-prev-20260512'
|
||||
assert_calls_contain "rollback re-puts dest tag" "$m" 'aws_ecr_put_image latest /'
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 8: argument validation ==\n'
|
||||
set +e
|
||||
out=$("$SCRIPT" 2>&1); rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 64 ]] && printf '%s' "$out" | grep -q 'required:.*--source-tag'; then
|
||||
PASS=$((PASS + 1)); printf ' ✓ exit 64 on missing args with usage line\n'
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAIL_NAMES+=("missing-args error")
|
||||
printf ' ✗ exit 64 on missing args (got %s)\n' "$rc"
|
||||
fi
|
||||
|
||||
set +e
|
||||
out=$("$SCRIPT" --source-tag x --dest-tag x --tenants y 2>&1); rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 64 ]] && printf '%s' "$out" | grep -q 'must differ'; then
|
||||
PASS=$((PASS + 1)); printf ' ✓ exit 64 when source==dest\n'
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAIL_NAMES+=("source==dest validation")
|
||||
printf ' ✗ source==dest should fail (got %s)\n' "$rc"
|
||||
fi
|
||||
|
||||
set +e
|
||||
out=$("$SCRIPT" --source-tag x --dest-tag y --tenants t --bogus-flag 2>&1); rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 64 ]] && printf '%s' "$out" | grep -q 'unknown argument'; then
|
||||
PASS=$((PASS + 1)); printf ' ✓ exit 64 on unknown flag\n'
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAIL_NAMES+=("unknown-flag error")
|
||||
printf ' ✗ unknown-flag should fail (got %s)\n' "$rc"
|
||||
fi
|
||||
|
||||
printf '\n== Test 9: ROLLBACK_TAG follows YYYYMMDD via NOW_OVERRIDE_DATE ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
mock_set "$m" aws_ecr_put_image '' 0
|
||||
mock_set "$m" cp_redeploy_tenant '{}' 0
|
||||
mock_set "$m" tenant_buildinfo '{}' 0
|
||||
mock_set "$m" tenant_health 'ok' 0
|
||||
set +e
|
||||
NOW_OVERRIDE_DATE=20260603 SSM_SETTLE_SECONDS=0 "$SCRIPT" \
|
||||
--source-tag a --dest-tag b --tenants t1 --mock-dir "$m" >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
PASS=$((PASS + 1)); printf ' ✓ run succeeded with custom NOW_OVERRIDE_DATE\n'
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAIL_NAMES+=("NOW_OVERRIDE_DATE run")
|
||||
printf ' ✗ NOW_OVERRIDE_DATE run failed (rc=%s)\n' "$rc"
|
||||
fi
|
||||
assert_calls_contain "rollback tag uses NOW_OVERRIDE_DATE (20260603)" "$m" 'aws_ecr_put_image b-prev-20260603'
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 10: empty source manifest fails preflight ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '' 0 # rc=0 but empty body (the "None" case)
|
||||
out=$(run_script "$m")
|
||||
assert_exit "empty source manifest fails preflight" "$out" 1
|
||||
assert_contains "empty manifest message" "$out" 'returned empty manifest'
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 11: tenant_buildinfo failure during verify → rollback ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{"manifests":[]}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
mock_set "$m" aws_ecr_put_image '' 0
|
||||
mock_set "$m" cp_redeploy_tenant '{"ok":true}' 0
|
||||
mock_set "$m" tenant_buildinfo '' 1 # buildinfo probe fails
|
||||
mock_set "$m" tenant_health 'ok' 0
|
||||
out=$(run_script "$m")
|
||||
assert_exit "verify failure → rollback succeeds → exit 3" "$out" 3
|
||||
assert_contains "logs buildinfo failure" "$out" '/buildinfo failed for chloe-dong'
|
||||
assert_contains "rollback fired after verify fail" "$out" 'ROLLBACK:'
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 12: ssm_refresh_ecr_auth JSON escaping (CWE-78 / OFFSEC-001) ==\n'
|
||||
# Verify the python3 snippet in ssm_refresh_ecr_auth produces valid JSON and
|
||||
# correctly escapes shell-injection characters in region + account ID fields.
|
||||
# The fix replaces unquoted shell-printf interpolation with json.dumps.
|
||||
PYCODE='import json,sys;r=sys.argv[1];a=sys.argv[2];ecr="aws ecr get-login-password --region "+json.dumps(r)[1:-1]+" | docker login --username AWS --password-stdin "+json.dumps(a)[1:-1]+".dkr.ecr."+json.dumps(r)[1:-1]+".amazonaws.com";print(json.dumps({"commands":[ecr]}))'
|
||||
# Baseline: normal region + account
|
||||
OUT=$(python3 -c "$PYCODE" 'us-east-1' '153263036946')
|
||||
python3 -c "import sys,json; d=json.loads(sys.stdin.read()); assert 'commands' in d; c=d['commands'][0]; assert 'us-east-1' in c and '153263036946' in c and c.startswith('aws ecr get-login-password')" <<< "$OUT" \
|
||||
&& echo " ok: normal region+account" || { echo " FAIL: invalid JSON for normal case"; exit 1; }
|
||||
# Injection: region with double-quote
|
||||
OUT=$(python3 -c "$PYCODE" 'us"-east-1' '153263036946')
|
||||
python3 -c "import sys,json; d=json.loads(sys.stdin.read()); c=d['commands'][0]; assert c" <<< "$OUT" \
|
||||
&& echo " ok: region with quote injection → valid JSON" || { echo " FAIL"; exit 1; }
|
||||
# Injection: account with double-quote
|
||||
OUT=$(python3 -c "$PYCODE" 'us-east-1' '15"326"3036946')
|
||||
python3 -c "import sys,json; d=json.loads(sys.stdin.read()); c=d['commands'][0]; assert c" <<< "$OUT" \
|
||||
&& echo " ok: account with quote injection → valid JSON" || { echo " FAIL"; exit 1; }
|
||||
# No double-encoding: region appears as literal 'us-east-1' in command string
|
||||
OUT=$(python3 -c "$PYCODE" 'us-east-1' '153263036946')
|
||||
python3 -c "import sys,json; d=json.loads(sys.stdin.read()); c=d['commands'][0]; assert 'us-east-1' in c" <<< "$OUT" \
|
||||
&& echo " ok: no double-encoding in command string" || { echo " FAIL"; exit 1; }
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
printf '\n────────────────────────────────────\n'
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
printf 'All %d tests passed.\n' "$PASS"
|
||||
exit 0
|
||||
else
|
||||
printf '%d passed, %d failed.\n' "$PASS" "$FAIL"
|
||||
printf 'Failed tests:\n'
|
||||
for n in "${FAIL_NAMES[@]}"; do printf ' - %s\n' "$n"; done
|
||||
exit 1
|
||||
fi
|
||||
@@ -434,7 +434,8 @@ func (h *MCPHandler) dispatchRPC(ctx context.Context, workspaceID string, req mc
|
||||
}
|
||||
|
||||
default:
|
||||
base.Error = &mcpRPCError{Code: -32601, Message: "method not found: " + req.Method}
|
||||
// Per OFFSEC-001: error message must not include user-controlled req.Method.
|
||||
base.Error = &mcpRPCError{Code: -32601, Message: "method not found"}
|
||||
}
|
||||
|
||||
return base
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"errors"
|
||||
@@ -204,6 +205,9 @@ func TestMCPHandler_NotificationsInitialized_Returns200(t *testing.T) {
|
||||
// Unknown method
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestMCPHandler_UnknownMethod_Returns32601 verifies dispatchRPC returns
|
||||
// -32601 for an unknown method. Per OFFSEC-001: the error message must be
|
||||
// constant — req.Method is user-controlled and must NOT appear in the response.
|
||||
func TestMCPHandler_UnknownMethod_Returns32601(t *testing.T) {
|
||||
h, _ := newMCPHandler(t)
|
||||
|
||||
@@ -224,6 +228,14 @@ func TestMCPHandler_UnknownMethod_Returns32601(t *testing.T) {
|
||||
if resp.Error.Code != -32601 {
|
||||
t.Errorf("expected code -32601, got %d", resp.Error.Code)
|
||||
}
|
||||
// Message must be constant — no user-controlled method name leak.
|
||||
if resp.Error.Message != "method not found" {
|
||||
t.Errorf("error message should be constant 'method not found', got: %q", resp.Error.Message)
|
||||
}
|
||||
// Double-check the method name never appears in the message (defence-in-depth).
|
||||
if strings.Contains(resp.Error.Message, "not/a/real/method") {
|
||||
t.Error("error message must not echo the user-controlled method name")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user