Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04b96d9cda | |||
| e4c52e617c | |||
| 7c52464bd1 | |||
| 7466492e3c | |||
| d4ba6cc31a | |||
| bf1b4eb1f2 | |||
| 9e153c2177 | |||
| e786450d93 | |||
| 028ccb87c8 | |||
| fb1d09eee9 | |||
| ee302b9f9f | |||
| bb5e0bb523 | |||
| e785bdbd53 | |||
| 329940ef29 | |||
| 11b1bdec23 | |||
| 4c14ab3eec | |||
| 1f45b54cac | |||
| c3a1736acd | |||
| ae274541f4 | |||
| c975ebfec9 | |||
| 0642b7c3a9 |
@@ -0,0 +1,829 @@
|
||||
#!/usr/bin/env python3
|
||||
# sop-checklist-gate — evaluate whether a PR has peer-acked each
|
||||
# SOP-checklist item. Posts a commit-status that branch protection
|
||||
# can require.
|
||||
#
|
||||
# RFC#351 Step 2 of 6 (implementation MVP).
|
||||
#
|
||||
# Invoked by .gitea/workflows/sop-checklist-gate.yml on:
|
||||
# - pull_request_target: [opened, edited, synchronize, reopened]
|
||||
# - issue_comment: [created, edited, deleted]
|
||||
#
|
||||
# Flow:
|
||||
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref — trusted).
|
||||
# 2. GET /repos/{R}/pulls/{N} — author, head.sha, tier label
|
||||
# 3. GET /repos/{R}/issues/{N}/comments — extract /sop-ack and /sop-revoke
|
||||
# 4. For each checklist item:
|
||||
# a. Is the section marker present in PR body? (author answered)
|
||||
# b. Is there ≥1 unrevoked /sop-ack from a non-author whose
|
||||
# team-membership matches required_teams?
|
||||
# 5. POST /repos/{R}/statuses/{sha} — context
|
||||
# `sop-checklist / all-items-acked (pull_request)`,
|
||||
# state=success | failure | pending, description=`acked: N/M …`.
|
||||
#
|
||||
# Trust boundary (mirrors RFC#324 §A4):
|
||||
# This script is loaded from the BASE branch. The workflow's
|
||||
# actions/checkout step pins ref=base.sha. PR-HEAD code is never
|
||||
# executed. We only HTTP-call the Gitea API.
|
||||
#
|
||||
# Token scope:
|
||||
# - read:repository / read:organization to enumerate PR + comments
|
||||
# + team membership (Gitea 1.22.6 quirk: team-membership endpoint
|
||||
# returns 403 if token owner is not in the team; see review-check.sh
|
||||
# for the same gotcha — we surface the same fail-closed message).
|
||||
# - write:repository for `POST /repos/{R}/statuses/{sha}`. Unlike
|
||||
# RFC#324's pattern (which uses the JOB's own pass/fail as the
|
||||
# status), we POST the status explicitly because the gate posts
|
||||
# a single multi-item status with a richer description than a
|
||||
# bare success/failure context can carry.
|
||||
#
|
||||
# Slug normalization rules (canonical form: kebab-case):
|
||||
# - Lowercase
|
||||
# - Whitespace + underscores → single dash
|
||||
# - Strip non [a-z0-9-] characters
|
||||
# - Collapse adjacent dashes
|
||||
# - Strip leading/trailing dashes
|
||||
# - If the result is a digit string (e.g. "1"), look up via
|
||||
# config.items[*].numeric_alias to get the kebab-case slug.
|
||||
#
|
||||
# Examples:
|
||||
# "Comprehensive_Testing" → "comprehensive-testing"
|
||||
# "comprehensive testing" → "comprehensive-testing"
|
||||
# "1" → "comprehensive-testing"
|
||||
# "Five-Axis-Review" → "five-axis-review"
|
||||
#
|
||||
# Revoke semantics:
|
||||
# /sop-revoke <slug> [reason] — most-recent comment per (slug, user)
|
||||
# wins. So if Alice posts /sop-ack X then later /sop-revoke X, her ack
|
||||
# for X is invalidated. Bob's prior /sop-ack X is unaffected. If Alice
|
||||
# posts /sop-revoke X then later /sop-ack X again, the ack is restored.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slug normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NORMALIZE_REPLACE_RE = re.compile(r"[\s_]+")
|
||||
_NORMALIZE_STRIP_RE = re.compile(r"[^a-z0-9-]")
|
||||
_NORMALIZE_DASH_RE = re.compile(r"-+")
|
||||
|
||||
|
||||
def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> str:
|
||||
"""Normalize a user-supplied slug to canonical kebab-case form.
|
||||
|
||||
See module header for the rules.
|
||||
|
||||
If the input is a pure digit string AND numeric_aliases is provided,
|
||||
the alias mapping is consulted. Unknown digits return "" so the caller
|
||||
can flag the comment as unparseable.
|
||||
"""
|
||||
if raw is None:
|
||||
return ""
|
||||
s = raw.strip().lower()
|
||||
s = _NORMALIZE_REPLACE_RE.sub("-", s)
|
||||
s = _NORMALIZE_STRIP_RE.sub("", s)
|
||||
s = _NORMALIZE_DASH_RE.sub("-", s)
|
||||
s = s.strip("-")
|
||||
if s.isdigit() and numeric_aliases is not None:
|
||||
return numeric_aliases.get(int(s), "")
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comment parsing — /sop-ack and /sop-revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A directive must be on its own line. Permits leading whitespace.
|
||||
# Optional trailing note after the slug for /sop-ack and required reason
|
||||
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
|
||||
# yet validated; future iteration may require a min-length).
|
||||
_DIRECTIVE_RE = re.compile(
|
||||
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def parse_directives(
|
||||
comment_body: str,
|
||||
numeric_aliases: dict[int, str],
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Extract /sop-ack and /sop-revoke directives from a comment body.
|
||||
|
||||
Returns a list of (kind, canonical_slug, note) tuples where:
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if not comment_body:
|
||||
return out
|
||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||
kind = m.group(1)
|
||||
raw_slug = (m.group(2) or "").strip()
|
||||
# If the raw match included trailing words, the regex non-greedy
|
||||
# captured only the first token; strip again for safety.
|
||||
# We split on whitespace to keep the FIRST word as the slug, and
|
||||
# everything after as the note.
|
||||
parts = raw_slug.split()
|
||||
if not parts:
|
||||
continue
|
||||
first = parts[0]
|
||||
# If the slug-capture greedily matched multiple words (e.g.
|
||||
# "comprehensive testing"), preserve normalize behavior: join
|
||||
# the WHOLE first-word-token only; trailing words get appended to
|
||||
# the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we
|
||||
# may have multi-word forms here — normalize handles them.
|
||||
if len(parts) > 1:
|
||||
# User wrote "/sop-ack comprehensive testing extra-note"
|
||||
# → treat "comprehensive testing" as the slug source if it
|
||||
# normalizes to a known item; otherwise treat "comprehensive"
|
||||
# as slug and "testing extra-note" as note. We defer the
|
||||
# disambiguation to the caller via the returned canonical
|
||||
# slug. For simplicity: try the WHOLE captured string first.
|
||||
canonical = normalize_slug(raw_slug, numeric_aliases)
|
||||
else:
|
||||
canonical = normalize_slug(first, numeric_aliases)
|
||||
note_from_group = (m.group(3) or "").strip()
|
||||
# If we collapsed multi-word slug into kebab and there's a
|
||||
# trailing-text group too, append it.
|
||||
out.append((kind, canonical, note_from_group))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PR body section detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def section_marker_present(body: str, marker: str) -> bool:
|
||||
"""Return True if `marker` appears in `body` case-insensitively
|
||||
on a non-empty line (i.e. the author actually filled it in).
|
||||
|
||||
We require the marker substring AND non-whitespace content on the
|
||||
same line OR within the next line — this prevents trivially-empty
|
||||
checklists like:
|
||||
|
||||
## SOP-Checklist
|
||||
- [ ] **Comprehensive testing performed**:
|
||||
- [ ] **Local-postgres E2E run**:
|
||||
|
||||
from auto-passing the section-present check. The peer-ack is still
|
||||
required, but answering with empty content is captured as a soft
|
||||
finding via the section-present test alone.
|
||||
"""
|
||||
if not body or not marker:
|
||||
return False
|
||||
body_lower = body.lower()
|
||||
marker_lower = marker.lower()
|
||||
idx = body_lower.find(marker_lower)
|
||||
if idx < 0:
|
||||
return False
|
||||
# Walk to end of line.
|
||||
line_end = body.find("\n", idx)
|
||||
if line_end < 0:
|
||||
line_end = len(body)
|
||||
line = body[idx + len(marker):line_end]
|
||||
# Strip the colon + checkbox tail patterns; require at least one
|
||||
# non-whitespace, non-punctuation char.
|
||||
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
|
||||
if stripped:
|
||||
return True
|
||||
# Fall through: check the NEXT line (multi-line answers).
|
||||
next_line_end = body.find("\n", line_end + 1)
|
||||
if next_line_end < 0:
|
||||
next_line_end = len(body)
|
||||
next_line = body[line_end + 1:next_line_end]
|
||||
stripped_next = re.sub(r"[\s\*:\-\[\]]+", "", next_line)
|
||||
return bool(stripped_next)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ack-state computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_ack_state(
|
||||
comments: list[dict[str, Any]],
|
||||
pr_author: str,
|
||||
items_by_slug: dict[str, dict[str, Any]],
|
||||
numeric_aliases: dict[int, str],
|
||||
team_membership_probe: "callable[[str, list[str]], list[str]]",
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Compute per-item ack state.
|
||||
|
||||
Each comment is processed in chronological order. The most-recent
|
||||
directive per (commenter, slug) wins.
|
||||
|
||||
Returns a dict keyed by canonical slug:
|
||||
{
|
||||
"comprehensive-testing": {
|
||||
"ackers": ["bob"], # non-author, team-verified
|
||||
"rejected_ackers": { # debugging info
|
||||
"self_ack": ["alice"],
|
||||
"unknown_slug": [],
|
||||
"not_in_team": ["eve"],
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
# Step 1: collapse directives per (commenter, slug) — most recent wins.
|
||||
# comments are expected to come in chronological order from the
|
||||
# API (Gitea returns oldest-first by default for issues/{N}/comments).
|
||||
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) → kind
|
||||
unparseable_per_user: dict[str, int] = {}
|
||||
for c in comments:
|
||||
body = c.get("body", "") or ""
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
for kind, slug, _note in parse_directives(body, numeric_aliases):
|
||||
if not slug:
|
||||
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
||||
continue
|
||||
latest_directive[(user, slug)] = kind
|
||||
|
||||
# Step 2: build candidate ackers per slug.
|
||||
# Filter out self-acks and unknown slugs.
|
||||
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
|
||||
for (user, slug), kind in latest_directive.items():
|
||||
if kind != "sop-ack":
|
||||
continue # revokes leave the (user,slug) state as "no ack"
|
||||
if slug not in items_by_slug:
|
||||
# Slug normalized to something not in our config — store
|
||||
# under a synthetic key for diagnostic surfacing. Don't add
|
||||
# to any item.
|
||||
continue
|
||||
if user == pr_author:
|
||||
rejected_self[slug].append(user)
|
||||
continue
|
||||
pending_team_check[slug].append(user)
|
||||
|
||||
# Step 3: team membership probe per slug (batched per slug to keep
|
||||
# API call count down — same user may ack multiple items but the
|
||||
# required_teams differ per item, so we MUST probe per (user, item)).
|
||||
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
for slug, candidates in pending_team_check.items():
|
||||
if not candidates:
|
||||
continue
|
||||
required = items_by_slug[slug]["required_teams"]
|
||||
approved = team_membership_probe(slug, candidates) # returns subset
|
||||
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
|
||||
ackers_per_slug[slug] = approved
|
||||
# Stash required teams for description rendering.
|
||||
items_by_slug[slug]["_required_resolved"] = required
|
||||
|
||||
return {
|
||||
slug: {
|
||||
"ackers": ackers_per_slug[slug],
|
||||
"rejected": {
|
||||
"self_ack": rejected_self[slug],
|
||||
"not_in_team": rejected_not_in_team[slug],
|
||||
},
|
||||
}
|
||||
for slug in items_by_slug
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, host: str, token: str):
|
||||
self.base = f"https://{host}/api/v1"
|
||||
self.token = token
|
||||
# Cache team-name → team-id resolutions per org.
|
||||
self._team_id_cache: dict[tuple[str, str], int | None] = {}
|
||||
|
||||
def _req(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
ok_codes: tuple[int, ...] = (200, 201, 204),
|
||||
) -> tuple[int, Any]:
|
||||
url = self.base + path
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
raw = r.read()
|
||||
code = r.getcode()
|
||||
except urllib.error.HTTPError as e:
|
||||
code = e.code
|
||||
raw = e.read()
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8")) if raw else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = raw.decode("utf-8", errors="replace") if raw else None
|
||||
return code, parsed
|
||||
|
||||
def get_pr(self, owner: str, repo: str, pr: int) -> dict[str, Any]:
|
||||
code, data = self._req("GET", f"/repos/{owner}/{repo}/pulls/{pr}")
|
||||
if code != 200:
|
||||
raise RuntimeError(f"GET pulls/{pr} → HTTP {code}: {data!r}")
|
||||
return data
|
||||
|
||||
def get_issue_comments(
|
||||
self, owner: str, repo: str, issue: int
|
||||
) -> list[dict[str, Any]]:
|
||||
# Paginate. Gitea default page size 50.
|
||||
out: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
code, data = self._req(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/issues/{issue}/comments?limit=50&page={page}",
|
||||
)
|
||||
if code != 200:
|
||||
raise RuntimeError(
|
||||
f"GET issues/{issue}/comments page={page} → HTTP {code}: {data!r}"
|
||||
)
|
||||
if not data:
|
||||
break
|
||||
out.extend(data)
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
return out
|
||||
|
||||
def resolve_team_id(self, org: str, team_name: str) -> int | None:
|
||||
key = (org, team_name)
|
||||
if key in self._team_id_cache:
|
||||
return self._team_id_cache[key]
|
||||
code, data = self._req("GET", f"/orgs/{org}/teams/search?q={urllib.parse.quote(team_name)}")
|
||||
team_id = None
|
||||
if code == 200 and isinstance(data, dict):
|
||||
for t in data.get("data", []):
|
||||
if t.get("name") == team_name:
|
||||
team_id = t.get("id")
|
||||
break
|
||||
if team_id is None and code == 200 and isinstance(data, list):
|
||||
for t in data:
|
||||
if t.get("name") == team_name:
|
||||
team_id = t.get("id")
|
||||
break
|
||||
self._team_id_cache[key] = team_id
|
||||
return team_id
|
||||
|
||||
def is_team_member(self, team_id: int, login: str) -> bool | None:
|
||||
"""Return True / False / None (unknown — 403 from API)."""
|
||||
code, _ = self._req(
|
||||
"GET", f"/teams/{team_id}/members/{urllib.parse.quote(login)}"
|
||||
)
|
||||
if code in (200, 204):
|
||||
return True
|
||||
if code == 404:
|
||||
return False
|
||||
# 403 means the token owner isn't in this team, so the API
|
||||
# refuses to confirm membership. Fail-closed at the caller.
|
||||
return None
|
||||
|
||||
def post_status(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
sha: str,
|
||||
state: str,
|
||||
context: str,
|
||||
description: str,
|
||||
target_url: str = "",
|
||||
) -> None:
|
||||
body = {
|
||||
"state": state,
|
||||
"context": context,
|
||||
"description": description[:140], # Gitea truncates to 255 but be safe
|
||||
"target_url": target_url or "",
|
||||
}
|
||||
code, data = self._req(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/statuses/{sha}",
|
||||
body=body,
|
||||
ok_codes=(201,),
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise RuntimeError(
|
||||
f"POST statuses/{sha} → HTTP {code}: {data!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loader (PyYAML-free — config file is intentionally tiny + flat)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_config(path: str) -> dict[str, Any]:
|
||||
"""Load .gitea/sop-checklist-config.yaml.
|
||||
|
||||
Uses PyYAML if available, otherwise falls back to a built-in
|
||||
minimal parser sufficient for our flat config shape. Bundling
|
||||
PyYAML on the runner is one apt install away but we avoid the
|
||||
dep by keeping the config shape constrained.
|
||||
"""
|
||||
try:
|
||||
import yaml # type: ignore[import-not-found]
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
except ImportError:
|
||||
return _load_config_minimal(path)
|
||||
|
||||
|
||||
def _load_config_minimal(path: str) -> dict[str, Any]:
|
||||
"""Minimal YAML subset parser for our config shape.
|
||||
|
||||
Supports: top-level scalar:value, top-level map-of-map (e.g.
|
||||
tier_failure_mode), top-level list of maps (items:), and within an
|
||||
item map: scalars + lists of scalars. Does NOT support nested lists,
|
||||
YAML anchors, multi-doc, or flow style.
|
||||
"""
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
return _parse_minimal_yaml(lines)
|
||||
|
||||
|
||||
def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901
|
||||
"""Hand-rolled subset parser. See _load_config_minimal docstring."""
|
||||
# Strip comments + blank lines but preserve indentation.
|
||||
cleaned: list[tuple[int, str]] = []
|
||||
for raw in lines:
|
||||
# Don't strip a "#" that is inside a quoted value.
|
||||
body = raw.rstrip("\n")
|
||||
# Remove trailing comment.
|
||||
idx = body.find("#")
|
||||
if idx >= 0 and (idx == 0 or body[idx - 1] in " \t"):
|
||||
body = body[:idx].rstrip()
|
||||
if not body.strip():
|
||||
continue
|
||||
indent = len(body) - len(body.lstrip(" "))
|
||||
cleaned.append((indent, body.strip()))
|
||||
|
||||
root: dict[str, Any] = {}
|
||||
i = 0
|
||||
n = len(cleaned)
|
||||
|
||||
def parse_scalar(s: str) -> Any:
|
||||
s = s.strip()
|
||||
if s.startswith('"') and s.endswith('"'):
|
||||
return s[1:-1]
|
||||
if s.startswith("'") and s.endswith("'"):
|
||||
return s[1:-1]
|
||||
if s.lower() in ("true", "yes"):
|
||||
return True
|
||||
if s.lower() in ("false", "no"):
|
||||
return False
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
pass
|
||||
return s
|
||||
|
||||
def parse_inline_list(s: str) -> list[Any]:
|
||||
s = s.strip()
|
||||
if not (s.startswith("[") and s.endswith("]")):
|
||||
return [parse_scalar(s)]
|
||||
inner = s[1:-1]
|
||||
if not inner.strip():
|
||||
return []
|
||||
return [parse_scalar(x.strip()) for x in inner.split(",")]
|
||||
|
||||
while i < n:
|
||||
indent, line = cleaned[i]
|
||||
if indent != 0:
|
||||
i += 1
|
||||
continue
|
||||
if ":" not in line:
|
||||
i += 1
|
||||
continue
|
||||
key, _, rest = line.partition(":")
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
if rest == "":
|
||||
# Block — could be map or list.
|
||||
i += 1
|
||||
# Look ahead for first child.
|
||||
if i < n and cleaned[i][1].startswith("- "):
|
||||
# List of items.
|
||||
items: list[Any] = []
|
||||
while i < n and cleaned[i][0] > indent and cleaned[i][1].startswith("- "):
|
||||
item_indent = cleaned[i][0]
|
||||
first_kv = cleaned[i][1][2:].strip() # strip "- "
|
||||
item: dict[str, Any] = {}
|
||||
if ":" in first_kv:
|
||||
k, _, v = first_kv.partition(":")
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
if v == "":
|
||||
item[k] = ""
|
||||
elif v.startswith(">-") or v.startswith(">"):
|
||||
# Folded scalar continues on subsequent indented lines
|
||||
collected: list[str] = []
|
||||
i += 1
|
||||
while i < n and cleaned[i][0] > item_indent:
|
||||
collected.append(cleaned[i][1])
|
||||
i += 1
|
||||
item[k] = " ".join(collected)
|
||||
items.append(item)
|
||||
continue
|
||||
elif v.startswith("["):
|
||||
item[k] = parse_inline_list(v)
|
||||
else:
|
||||
item[k] = parse_scalar(v)
|
||||
i += 1
|
||||
# Subsequent k:v lines at deeper indent belong to this item.
|
||||
while i < n and cleaned[i][0] > item_indent and not cleaned[i][1].startswith("- "):
|
||||
sub_indent, sub_line = cleaned[i]
|
||||
if ":" in sub_line:
|
||||
k, _, v = sub_line.partition(":")
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
if v == "":
|
||||
item[k] = ""
|
||||
i += 1
|
||||
elif v.startswith(">-") or v.startswith(">"):
|
||||
collected = []
|
||||
i += 1
|
||||
while i < n and cleaned[i][0] > sub_indent:
|
||||
collected.append(cleaned[i][1])
|
||||
i += 1
|
||||
item[k] = " ".join(collected)
|
||||
elif v.startswith("["):
|
||||
item[k] = parse_inline_list(v)
|
||||
i += 1
|
||||
else:
|
||||
item[k] = parse_scalar(v)
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
items.append(item)
|
||||
root[key] = items
|
||||
else:
|
||||
# Sub-map.
|
||||
submap: dict[str, Any] = {}
|
||||
while i < n and cleaned[i][0] > indent:
|
||||
sub_indent, sub_line = cleaned[i]
|
||||
if ":" in sub_line:
|
||||
k, _, v = sub_line.partition(":")
|
||||
k = k.strip().strip('"').strip("'")
|
||||
v = v.strip()
|
||||
if v.startswith("[") and v.endswith("]"):
|
||||
submap[k] = parse_inline_list(v)
|
||||
else:
|
||||
submap[k] = parse_scalar(v)
|
||||
i += 1
|
||||
root[key] = submap
|
||||
else:
|
||||
# Inline scalar or list.
|
||||
if rest.startswith("[") and rest.endswith("]"):
|
||||
root[key] = parse_inline_list(rest)
|
||||
else:
|
||||
root[key] = parse_scalar(rest)
|
||||
i += 1
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_status(
|
||||
items: list[dict[str, Any]],
|
||||
ack_state: dict[str, dict[str, Any]],
|
||||
body_state: dict[str, bool],
|
||||
) -> tuple[str, str]:
|
||||
"""Return (state, description) for the commit-status post.
|
||||
|
||||
state is "success" if every item has at least one valid ack
|
||||
(body section presence is informational only — peer-ack is the
|
||||
real gate). tier:low PRs receive state="success" (soft-fail — no
|
||||
acks required); the description carries "[info tier:low]" prefix.
|
||||
"""
|
||||
n = len(items)
|
||||
fully_acked = [
|
||||
it["slug"] for it in items if ack_state[it["slug"]]["ackers"]
|
||||
]
|
||||
missing = [
|
||||
it["slug"] for it in items if not ack_state[it["slug"]]["ackers"]
|
||||
]
|
||||
missing_body = [it["slug"] for it in items if not body_state.get(it["slug"], False)]
|
||||
|
||||
desc_parts = [f"acked: {len(fully_acked)}/{n}"]
|
||||
if missing:
|
||||
# Show up to 3 missing slugs to stay inside the 140-char budget.
|
||||
shown = ", ".join(missing[:3])
|
||||
if len(missing) > 3:
|
||||
shown += f", +{len(missing) - 3}"
|
||||
desc_parts.append(f"missing: {shown}")
|
||||
if missing_body:
|
||||
shown = ", ".join(missing_body[:3])
|
||||
if len(missing_body) > 3:
|
||||
shown += f", +{len(missing_body) - 3}"
|
||||
desc_parts.append(f"body-unfilled: {shown}")
|
||||
state = "success" if not missing and not missing_body else "failure"
|
||||
return state, " — ".join(desc_parts)
|
||||
|
||||
|
||||
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
|
||||
"""Read tier label, return 'hard' or 'soft' per cfg.tier_failure_mode."""
|
||||
labels = pr.get("labels") or []
|
||||
tier_labels = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
|
||||
mode_map = cfg.get("tier_failure_mode") or {}
|
||||
default_mode = cfg.get("default_mode", "hard")
|
||||
for tl in tier_labels:
|
||||
if tl in mode_map:
|
||||
return mode_map[tl]
|
||||
return default_mode
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--owner", required=True)
|
||||
p.add_argument("--repo", required=True)
|
||||
p.add_argument("--pr", type=int, required=True)
|
||||
p.add_argument("--config", default=".gitea/sop-checklist-config.yaml")
|
||||
p.add_argument("--gitea-host", default="git.moleculesai.app")
|
||||
p.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Compute state but do not POST the status.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--status-context",
|
||||
default="sop-checklist / all-items-acked (pull_request)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--exit-on-state",
|
||||
action="store_true",
|
||||
help=(
|
||||
"If set, exit non-zero when state=failure. Default OFF so the "
|
||||
"job-level conclusion is independent of ack-state — the only "
|
||||
"thing BP sees is the POSTed status. Useful for local debugging."
|
||||
),
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
if not token and not args.dry_run:
|
||||
print("::error::GITEA_TOKEN env required", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
cfg = load_config(args.config)
|
||||
items: list[dict[str, Any]] = cfg["items"]
|
||||
items_by_slug = {it["slug"]: it for it in items}
|
||||
numeric_aliases = {
|
||||
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
|
||||
}
|
||||
|
||||
client = GiteaClient(args.gitea_host, token) if token else None
|
||||
if not client:
|
||||
print("::error::No client (dry-run without token has nothing to do)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pr = client.get_pr(args.owner, args.repo, args.pr)
|
||||
if pr.get("state") != "open":
|
||||
print(f"::notice::PR #{args.pr} is {pr.get('state')} — gate is a no-op")
|
||||
return 0
|
||||
|
||||
author = (pr.get("user") or {}).get("login", "")
|
||||
head_sha = (pr.get("head") or {}).get("sha", "")
|
||||
body = pr.get("body", "") or ""
|
||||
|
||||
if not author or not head_sha:
|
||||
print("::error::PR payload missing user.login or head.sha", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
|
||||
|
||||
# Build team-membership probe closure that caches results per
|
||||
# (user, team-id) so a user acking multiple items only triggers
|
||||
# one membership lookup per team.
|
||||
team_member_cache: dict[tuple[str, int], bool | None] = {}
|
||||
|
||||
def probe(slug: str, users: list[str]) -> list[str]:
|
||||
item = items_by_slug[slug]
|
||||
team_names: list[str] = item["required_teams"]
|
||||
# Resolve names → ids. NOTE: orgs/{org}/teams/search may not be
|
||||
# available — fall back to the list endpoint.
|
||||
team_ids: list[int] = []
|
||||
for tn in team_names:
|
||||
tid = client.resolve_team_id(args.owner, tn)
|
||||
if tid is None:
|
||||
# Try the list endpoint as a fallback.
|
||||
code, data = client._req( # noqa: SLF001
|
||||
"GET", f"/orgs/{args.owner}/teams"
|
||||
)
|
||||
if code == 200 and isinstance(data, list):
|
||||
for t in data:
|
||||
if t.get("name") == tn:
|
||||
tid = t.get("id")
|
||||
client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001
|
||||
break
|
||||
if tid is not None:
|
||||
team_ids.append(tid)
|
||||
else:
|
||||
print(
|
||||
f"::warning::could not resolve team-id for '{tn}' "
|
||||
f"in org '{args.owner}' — item '{slug}' will fail closed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
approved: list[str] = []
|
||||
for u in users:
|
||||
for tid in team_ids:
|
||||
cache_key = (u, tid)
|
||||
if cache_key not in team_member_cache:
|
||||
team_member_cache[cache_key] = client.is_team_member(tid, u)
|
||||
result = team_member_cache[cache_key]
|
||||
if result is True:
|
||||
approved.append(u)
|
||||
break
|
||||
if result is None:
|
||||
print(
|
||||
f"::warning::team-probe for {u} in team-id {tid} returned 403 "
|
||||
"(token owner not in that team — fail-closed per RFC#324)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Treat as not-in-team for this user/team pair; loop
|
||||
# may still find membership in another team.
|
||||
return approved
|
||||
|
||||
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
|
||||
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
|
||||
|
||||
state, description = render_status(items, ack_state, body_state)
|
||||
mode = get_tier_mode(pr, cfg)
|
||||
if mode == "soft":
|
||||
# tier:low: acks are informational only — post success so BP gate passes.
|
||||
# Description carries "[info tier:low]" prefix so reviewers know acks
|
||||
# were not required (vs a tier:medium+ PR that truly passed all acks).
|
||||
state = "success"
|
||||
description = f"[info tier:low] {description}"
|
||||
|
||||
# Diagnostics to job log.
|
||||
print(f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} mode={mode}")
|
||||
for it in items:
|
||||
slug = it["slug"]
|
||||
ackers = ack_state[slug]["ackers"]
|
||||
if ackers:
|
||||
print(f"::notice:: [PASS] {slug} — acked by {','.join(ackers)}")
|
||||
else:
|
||||
r = ack_state[slug]["rejected"]
|
||||
extras: list[str] = []
|
||||
if r["self_ack"]:
|
||||
extras.append(f"self-acks-rejected:{','.join(r['self_ack'])}")
|
||||
if r["not_in_team"]:
|
||||
extras.append(f"not-in-team:{','.join(r['not_in_team'])}")
|
||||
extra = " (" + "; ".join(extras) + ")" if extras else ""
|
||||
print(f"::notice:: [WAIT] {slug} — no valid peer-ack yet{extra}")
|
||||
|
||||
print(f"::notice::posting status: state={state} desc={description!r}")
|
||||
|
||||
if args.dry_run:
|
||||
print("::notice::--dry-run: not posting status")
|
||||
if args.exit_on_state:
|
||||
return 0 if state in ("success", "pending") else 1
|
||||
return 0
|
||||
|
||||
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
|
||||
client.post_status(
|
||||
args.owner, args.repo, head_sha,
|
||||
state=state, context=args.status_context,
|
||||
description=description, target_url=target_url,
|
||||
)
|
||||
print(f"::notice::status posted: {args.status_context} → {state}")
|
||||
# By default exit 0 — the POSTed status IS the gate, NOT the job
|
||||
# conclusion. If the job exits 1 BP will see TWO failure signals
|
||||
# (one from the job's auto-status, one from our POST), making the
|
||||
# description less actionable. --exit-on-state restores the old
|
||||
# behavior for local debugging.
|
||||
if args.exit_on_state:
|
||||
return 0 if state in ("success", "pending") else 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
# SOP-Checklist gate — per-item required reviewer teams.
|
||||
#
|
||||
# RFC#351 v1 starter set. Each item lists:
|
||||
# slug — canonical kebab-case form used in /sop-ack <slug>
|
||||
# pr_section_marker — substring matched in the PR body to detect that
|
||||
# the author filled in this item (case-insensitive)
|
||||
# required_teams — list of Gitea team names; an ack from ANY one of
|
||||
# these teams (logical OR) satisfies the item.
|
||||
# Membership is probed at gate-time via
|
||||
# GET /api/v1/teams/{id}/members/{login}.
|
||||
# Team-id resolution happens at script start via
|
||||
# GET /api/v1/orgs/{org}/teams (cheap, one call).
|
||||
# numeric_alias — 1..7; lets reviewers type `/sop-ack 3` as a
|
||||
# shortcut for `/sop-ack staging-smoke`.
|
||||
#
|
||||
# WHY THESE TEAM MAPPINGS:
|
||||
# The RFC table referenced persona-role names like `core-qa`,
|
||||
# `core-be`, `core-devops` — these are individual Gitea user logins,
|
||||
# not teams. The Gitea team-membership API is /teams/{id}/members/{u},
|
||||
# so we need actual teams. Orchestrator preflight 2026-05-12 verified
|
||||
# only these teams exist on molecule-ai: ceo(5), engineers(2),
|
||||
# managers(6), qa(20), security(21), Owners(1), and bot teams. We
|
||||
# map the RFC roles to the closest existing team and surface the
|
||||
# mapping explicitly so it's reviewable.
|
||||
#
|
||||
# HOW TO EDIT:
|
||||
# - Tightening: replace `engineers` with a smaller team after creating
|
||||
# it (e.g. a new `senior-engineers` team if needed).
|
||||
# - Loosening: add another team to required_teams (OR semantics).
|
||||
# - Add an item: append to items list and document the slug below.
|
||||
#
|
||||
# AUTHOR SELF-ACK IS FORBIDDEN regardless of which team contains them
|
||||
# — the gate script enforces commenter != PR author before checking
|
||||
# team membership.
|
||||
|
||||
version: 1
|
||||
|
||||
# Tier-aware failure mode (RFC#351 open question 2):
|
||||
# For tier:high — hard-fail (status `failure`, blocks merge via BP).
|
||||
# For tier:medium — hard-fail (same as high; medium is non-trivial).
|
||||
# For tier:low — soft-fail (status `pending` with `acked: N/M` in the
|
||||
# description). BP can choose to require the context
|
||||
# or not for low-tier PRs.
|
||||
# If no tier label is present, default to medium (hard-fail) — every PR
|
||||
# should have a tier label per sop-tier-check, and absence indicates
|
||||
# a missing-tier defect we should surface, not silently lower the bar.
|
||||
tier_failure_mode:
|
||||
"tier:high": hard
|
||||
"tier:medium": hard
|
||||
"tier:low": soft
|
||||
default_mode: hard # used when no tier:* label is present
|
||||
|
||||
items:
|
||||
- slug: comprehensive-testing
|
||||
numeric_alias: 1
|
||||
pr_section_marker: "Comprehensive testing performed"
|
||||
required_teams: [qa, engineers]
|
||||
description: >-
|
||||
What was tested, how, edge cases covered. Ack from any qa-team
|
||||
member (or engineers fallback while qa is small).
|
||||
|
||||
- slug: local-postgres-e2e
|
||||
numeric_alias: 2
|
||||
pr_section_marker: "Local-postgres E2E run"
|
||||
required_teams: [engineers]
|
||||
description: >-
|
||||
Link to local CI artifact, or "N/A: pure-frontend change". Ack
|
||||
from any engineer who can verify the local DB test actually ran.
|
||||
|
||||
- slug: staging-smoke
|
||||
numeric_alias: 3
|
||||
pr_section_marker: "Staging-smoke verified or pending"
|
||||
required_teams: [engineers]
|
||||
description: >-
|
||||
Link to canary run, or "scheduled post-merge". Ack from any
|
||||
engineer (core-devops/infra-sre are members of engineers team).
|
||||
|
||||
- slug: root-cause
|
||||
numeric_alias: 4
|
||||
pr_section_marker: "Root-cause not symptom"
|
||||
required_teams: [managers, ceo]
|
||||
description: >-
|
||||
One-sentence root-cause statement. Ack from managers tier
|
||||
(team-leads) or ceo. Senior judgment required to attest
|
||||
root-cause-versus-symptom.
|
||||
|
||||
- slug: five-axis-review
|
||||
numeric_alias: 5
|
||||
pr_section_marker: "Five-Axis review walked"
|
||||
required_teams: [engineers]
|
||||
description: >-
|
||||
Correctness / readability / architecture / security / performance.
|
||||
Ack from any non-author engineer.
|
||||
|
||||
- slug: no-backwards-compat
|
||||
numeric_alias: 6
|
||||
pr_section_marker: "No backwards-compat shim / dead code added"
|
||||
required_teams: [managers, ceo]
|
||||
description: >-
|
||||
Yes/no + justification if no. Senior ack required because
|
||||
backward-compat shims are how dead-code accretes.
|
||||
|
||||
- slug: memory-consulted
|
||||
numeric_alias: 7
|
||||
pr_section_marker: "Memory/saved-feedback consulted"
|
||||
required_teams: [engineers]
|
||||
description: >-
|
||||
List of feedback memories applicable to this change. Ack from
|
||||
any engineer who has the same memory access.
|
||||
@@ -52,7 +52,10 @@ jobs:
|
||||
# Declared here rather than fetched from /branch_protections
|
||||
# because that endpoint requires admin write — sop-tier-bot is
|
||||
# read-only by design (least-privilege).
|
||||
#
|
||||
# staging branch protection (§F3a/F3b, mc#798): only
|
||||
# sop-checklist / all-items-acked is required. Unlike main,
|
||||
# staging does not require sop-tier-check or Secret scan.
|
||||
REQUIRED_CHECKS: |
|
||||
sop-tier-check / tier-check (pull_request)
|
||||
Secret scan / Scan diff for credential-shaped strings (pull_request)
|
||||
sop-checklist / all-items-acked (pull_request)
|
||||
run: bash .gitea/scripts/audit-force-merge.sh
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
# Ported from .github/workflows/ci.yml on 2026-05-11 per RFC internal#219 §1.
|
||||
# continue-on-error: true on every job; follow-up PR will flip required after
|
||||
# surfaced bugs are fixed (per RFC §1 — "surface broken workflows without
|
||||
# blocking"). The four-surface migration audit
|
||||
# (feedback_gitea_actions_migration_audit_pattern) was performed against this
|
||||
# port:
|
||||
#
|
||||
# 1. YAML — dropped `merge_group` trigger (no Gitea merge queue); no
|
||||
# `workflow_dispatch.inputs` to drop (Gitea 1.22.6 rejects those —
|
||||
# feedback_gitea_workflow_dispatch_inputs_unsupported); no `environment:`
|
||||
# blocks; kept `runs-on: ubuntu-latest` (Gitea runner pool advertises
|
||||
# this label per agent_labels in action_runner table). Workflow-level
|
||||
# env.GITHUB_SERVER_URL set as belt-and-suspenders against runner
|
||||
# defaults (feedback_act_runner_github_server_url).
|
||||
#
|
||||
# 2. Cache — `actions/upload-artifact@v3.2.2` was already pinned to v3 for
|
||||
# Gitea act_runner v0.6 compatibility (a comment in the original called
|
||||
# this out). v4+ is incompatible with Gitea 1.22.x. No `actions/cache`
|
||||
# usage to audit. `actions/setup-python@v6` `cache: pip` is left in
|
||||
# place — works against Gitea's built-in cache server when runner.cache
|
||||
# is configured (currently is, /opt/molecule/runners/config.yaml).
|
||||
#
|
||||
# 3. Token — workflow uses no custom dispatch tokens. The auto-injected
|
||||
# `GITHUB_TOKEN` (which Gitea aliases to a runner-scoped token) is
|
||||
# sufficient for `actions/checkout` against this same repo.
|
||||
#
|
||||
# 4. Docs — no docs/scripts reference github.com URLs that need swapping.
|
||||
# The canvas-deploy-reminder step writes a `ghcr.io/...` image
|
||||
# reference into the step summary text — that's documentation prose
|
||||
# pointing at the ECR-mirrored canvas image and stays unchanged for
|
||||
# this port (a separate cleanup if ghcr→ECR sweep is in scope).
|
||||
#
|
||||
# Cross-links:
|
||||
# - RFC: internal#219 (CI/CD hard-gate hardening)
|
||||
# - Reference port style: molecule-controlplane/.gitea/workflows/ci.yml
|
||||
# - Bugs that may surface immediately and are tracked separately:
|
||||
# internal#214 (Go-side vanity-import / go.sum drift, if any)
|
||||
# - Phase 4 (this PR's follow-up): flip `continue-on-error: false` once
|
||||
# surfaced defects are fixed, then add `all-required` aggregator
|
||||
# sentinel (RFC §2) and PATCH branch protection (Phase 4 scope).
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
# `merge_group` (GitHub merge-queue trigger) dropped — Gitea has no merge
|
||||
# queue. The .github/ original retains it; this Gitea-side copy drops it.
|
||||
|
||||
# Cancel in-progress CI runs when a new commit arrives on the same ref.
|
||||
# Stale runs queue up otherwise. PR refs and main/staging refs each get
|
||||
# their own group because github.ref differs.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Belt-and-suspenders against the runner-default trap
|
||||
# (feedback_act_runner_github_server_url). Runners are configured with
|
||||
# this env via /opt/molecule/runners/config.yaml runner.envs, but pinning
|
||||
# at the workflow level protects against a runner regenerated without
|
||||
# the config file (feedback_act_runner_needs_config_file_env).
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# Detect which paths changed so downstream jobs can skip when only
|
||||
# docs/markdown files were modified.
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): all required jobs >=98% green on main.
|
||||
# Flip confirmed 2026-05-12 via combined-status check of latest main
|
||||
# commit (all CI jobs green). `all-required` sentinel hard-fails
|
||||
# when this job fails; no Phase 3 suppression needed.
|
||||
# revert: add `continue-on-error: true` back if regressions appear.
|
||||
continue-on-error: false
|
||||
outputs:
|
||||
platform: ${{ steps.check.outputs.platform }}
|
||||
canvas: ${{ steps.check.outputs.canvas }}
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
scripts: ${{ steps.check.outputs.scripts }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: check
|
||||
run: |
|
||||
# For PR events: diff against the base branch (not HEAD~1 of the branch,
|
||||
# which may be unrelated after force-pushes). When a push updates a PR,
|
||||
# both pull_request and push events fire — prefer the PR base so that
|
||||
# the diff is always computed against the actual merge base, not the
|
||||
# previous SHA on the branch which may be on a different history line.
|
||||
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
|
||||
# GITHUB_BASE_REF is set for PR events (the base branch name).
|
||||
# For pull_request events we use the stored base.sha; for push events
|
||||
# (or when base.sha is unavailable) fall back to github.event.before.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
# Fallback: if BASE is empty or all zeros (new branch), run everything
|
||||
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
|
||||
echo "platform=true" >> "$GITHUB_OUTPUT"
|
||||
echo "canvas=true" >> "$GITHUB_OUTPUT"
|
||||
echo "python=true" >> "$GITHUB_OUTPUT"
|
||||
echo "scripts=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Both .github/workflows/ci.yml AND .gitea/workflows/ci.yml count
|
||||
# as "this workflow changed" — either edit should force-run every
|
||||
# downstream job. The Gitea port follows the same shape as the
|
||||
# GitHub original so behavior matches when triggered on either
|
||||
# platform.
|
||||
DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo ".gitea/workflows/ci.yml")
|
||||
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "python=$(echo "$DIFF" | grep -qE '^workspace/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
|
||||
# + per-step gating shape preserves the GitHub-side required-check name
|
||||
# contract (so when this Gitea port becomes a required check in Phase 4,
|
||||
# the name match works on PRs that don't touch workspace-server/).
|
||||
platform-build:
|
||||
name: Platform (Go)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774 (interim): re-mask platform-build pending fix-forward. Phase 4
|
||||
# (#656) flipped this to continue-on-error: false based on a Phase-3-masked
|
||||
# "green on main 2026-05-12" — the prior continue-on-error: true had
|
||||
# been hiding failing tests in workspace-server/internal/handlers/.
|
||||
# Two distinct failure classes surfaced on 0e5152c3:
|
||||
# (1) 4x delegation_test.go (lines 1110/1176/1228/1271): helpers
|
||||
# expectExecuteDelegationBase/Success/Failed are missing sqlmock
|
||||
# expectations for queries production has issued since ~2026-04-21
|
||||
# (last_outbound_at UPDATE, lookupDeliveryMode/Runtime SELECTs,
|
||||
# a2a_receive INSERT activity_logs, recordLedgerStatus writes).
|
||||
# Halt cond #3 applies (regression > 7 days → broader sweep).
|
||||
# (2) 1x mcp_test.go:433 (TestMCPHandler_CommitMemory_GlobalScope_Blocked):
|
||||
# commit 7d1a189f (2026-05-10) hardened mcp.go to scrub err.Error()
|
||||
# from JSON-RPC responses (OFFSEC-001), but the test asserts the
|
||||
# error message contains "GLOBAL". Production-vs-test contract
|
||||
# collision — needs design call, not mock update.
|
||||
# Time-boxed Option A (90 min) did not fit the cross-cutting scope.
|
||||
# This is a sequenced revert→fix→reflip per
|
||||
# feedback_strict_root_only_after_class_a emergency clause — NOT
|
||||
# a permanent re-mask. Re-flip blocked on mc#774 fix-forward landing.
|
||||
# Other 4 #656 flips (changes, canvas-build, shellcheck, python-lint)
|
||||
# retain continue-on-error: false; only platform-build regresses.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true # mc#774 fix-forward in flight; re-flip when mc#774 lands (PR #669 → rebase after #709)
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
steps:
|
||||
- if: needs.changes.outputs.platform != 'true'
|
||||
working-directory: .
|
||||
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go mod download
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go build ./cmd/server
|
||||
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go vet ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
run: |
|
||||
set +e
|
||||
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
handlers_exit=$?
|
||||
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
pu_exit=$?
|
||||
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-handlers.log
|
||||
echo "::endgroup::"
|
||||
echo "::group::pendinguploads exit=$pu_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-pu.log
|
||||
echo "::endgroup::"
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run tests with race detection and coverage
|
||||
run: go test -race -coverprofile=coverage.out ./...
|
||||
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Per-file coverage report
|
||||
# Advisory — lists every source file with its coverage so reviewers
|
||||
# can see at-a-glance where gaps are. Sorted ascending so the worst
|
||||
# offenders float to the top. Does NOT fail the build; the hard
|
||||
# gate is the threshold check below. (#1823)
|
||||
run: |
|
||||
echo "=== Per-file coverage (worst first) ==="
|
||||
go tool cover -func=coverage.out \
|
||||
| grep -v '^total:' \
|
||||
| awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++}
|
||||
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
|
||||
| sort -n
|
||||
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Check coverage thresholds
|
||||
# Enforces two gates from #1823 Layer 1:
|
||||
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
|
||||
# 2. Per-file floor — non-test .go files in security-critical
|
||||
# paths with coverage <10% fail the build, UNLESS the file
|
||||
# path is listed in .coverage-allowlist.txt (acknowledged
|
||||
# historical debt with a tracking issue + expiry).
|
||||
run: |
|
||||
set -e
|
||||
TOTAL_FLOOR=25
|
||||
# Security-critical paths where a 0%-coverage file is a real risk.
|
||||
CRITICAL_PATHS=(
|
||||
"internal/handlers/tokens"
|
||||
"internal/handlers/workspace_provision"
|
||||
"internal/handlers/a2a_proxy"
|
||||
"internal/handlers/registry"
|
||||
"internal/handlers/secrets"
|
||||
"internal/middleware/wsauth"
|
||||
"internal/crypto"
|
||||
)
|
||||
|
||||
TOTAL=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $3}' | sed 's/%//')
|
||||
echo "Total coverage: ${TOTAL}%"
|
||||
if awk "BEGIN{exit !($TOTAL < $TOTAL_FLOOR)}"; then
|
||||
echo "::error::Total coverage ${TOTAL}% is below the ${TOTAL_FLOOR}% floor. See COVERAGE_FLOOR.md for ratchet plan."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Aggregate per-file coverage → /tmp/perfile.txt: "<fullpath> <pct>"
|
||||
go tool cover -func=coverage.out \
|
||||
| grep -v '^total:' \
|
||||
| awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++}
|
||||
END {for (f in s) printf "%s %.1f\n", f, s[f]/c[f]}' \
|
||||
> /tmp/perfile.txt
|
||||
|
||||
# Build allowlist — paths relative to workspace-server, one per line.
|
||||
# Lines starting with # are comments.
|
||||
ALLOWLIST=""
|
||||
if [ -f ../.coverage-allowlist.txt ]; then
|
||||
ALLOWLIST=$(grep -vE '^(#|[[:space:]]*$)' ../.coverage-allowlist.txt || true)
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
WARNED=0
|
||||
for path in "${CRITICAL_PATHS[@]}"; do
|
||||
while read -r file pct; do
|
||||
[[ "$file" == *_test.go ]] && continue
|
||||
[[ "$file" == *"$path"* ]] || continue
|
||||
awk "BEGIN{exit !($pct < 10)}" || continue
|
||||
|
||||
# Strip the package-import prefix so we can match .coverage-allowlist.txt
|
||||
# entries written as paths relative to workspace-server/.
|
||||
# Handle both module paths: platform/workspace-server/... and platform/...
|
||||
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
|
||||
|
||||
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
|
||||
echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry."
|
||||
WARNED=$((WARNED+1))
|
||||
else
|
||||
echo "::error file=workspace-server/$rel::Critical file at ${pct}% coverage — must be >=10% (target 80%). See #1823. To acknowledge as known debt, add this path to .coverage-allowlist.txt."
|
||||
FAILED=$((FAILED+1))
|
||||
fi
|
||||
done < /tmp/perfile.txt
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Critical-path check: $FAILED new failures, $WARNED allowlisted warnings."
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "$FAILED security-critical file(s) have <10% test coverage and are"
|
||||
echo "NOT in the allowlist. These paths handle auth, tokens, secrets, or"
|
||||
echo "workspace provisioning — a 0% file here is the exact gap that let"
|
||||
echo "CWE-22, CWE-78, KI-005 slip through in past incidents. Either:"
|
||||
echo " (a) add tests to raise coverage above 10%, or"
|
||||
echo " (b) add the path to .coverage-allowlist.txt with an expiry date"
|
||||
echo " and a tracking issue reference."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Canvas (Next.js) — required check, always runs. Same always-run +
|
||||
# per-step gating shape as platform-build. The two-job-sharing-name
|
||||
# pattern attempted in PR #2321 doesn't satisfy branch protection
|
||||
# (SKIPPED siblings count as not-passed regardless of SUCCESS
|
||||
# siblings — verified empirically on PR #2314).
|
||||
canvas-build:
|
||||
name: Canvas (Next.js)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
defaults:
|
||||
run:
|
||||
working-directory: canvas
|
||||
steps:
|
||||
- if: needs.changes.outputs.canvas != 'true'
|
||||
working-directory: .
|
||||
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
run: rm -f package-lock.json && npm install
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
run: npm run build
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
name: Run tests with coverage
|
||||
# Coverage instrumentation is configured in canvas/vitest.config.ts
|
||||
# (provider: v8, reporters: text + html + json-summary). Step 2 of
|
||||
# #1815 — wires coverage into CI so we get a baseline visible on
|
||||
# every PR. No threshold gate yet; thresholds dial in (Step 3, also
|
||||
# tracked in #1815) after the team sees what current coverage is.
|
||||
run: npx vitest run --coverage
|
||||
- name: Upload coverage summary as artifact
|
||||
if: needs.changes.outputs.canvas == 'true' && always()
|
||||
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
|
||||
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
|
||||
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
|
||||
# v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not
|
||||
# currently supported on GHES`. Drop this pin when Gitea ships
|
||||
# the v4 protocol (tracked: post-Gitea-1.23 followup).
|
||||
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2
|
||||
with:
|
||||
name: canvas-coverage-${{ github.run_id }}
|
||||
path: canvas/coverage/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
# Shellcheck (E2E scripts) — required check, always runs.
|
||||
shellcheck:
|
||||
name: Shellcheck (E2E scripts)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- if: needs.changes.outputs.scripts != 'true'
|
||||
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
|
||||
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
|
||||
# infra/scripts/ is included because setup.sh + nuke.sh gate the
|
||||
# README quickstart — a shellcheck regression there silently breaks
|
||||
# new-user onboarding. scripts/ is intentionally excluded until its
|
||||
# pre-existing SC3040/SC3043 warnings are cleaned up.
|
||||
run: |
|
||||
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
||||
| xargs -0 shellcheck --severity=warning
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Lint cleanup-trap hygiene (RFC #2873)
|
||||
run: bash tests/e2e/lint_cleanup_traps.sh
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Run E2E bash unit tests (no live infra)
|
||||
run: |
|
||||
bash tests/e2e/test_model_slug.sh
|
||||
|
||||
canvas-deploy-reminder:
|
||||
name: Canvas Deploy Reminder
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
needs: [changes, canvas-build]
|
||||
# Only fires on direct pushes to main (i.e. after staging→main promotion).
|
||||
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Write deploy reminder to step summary
|
||||
env:
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
# github.server_url resolves via the workflow-level env override
|
||||
# to the Gitea instance, so the RUN_URL points at the Gitea run
|
||||
# page (not github.com). See feedback_act_runner_github_server_url.
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
# Write body to a temp file — avoids backtick escaping in shell.
|
||||
cat > /tmp/deploy-reminder.md << 'BODY'
|
||||
## Canvas build passed — deploy required
|
||||
|
||||
The `publish-canvas-image` workflow is now building a fresh Docker image
|
||||
(`ghcr.io/molecule-ai/canvas:latest`) in the background.
|
||||
|
||||
Once it completes (~3–5 min), apply on the host machine with:
|
||||
```bash
|
||||
cd <runner-workspace>
|
||||
git pull origin main
|
||||
docker compose pull canvas && docker compose up -d canvas
|
||||
```
|
||||
|
||||
If you need to rebuild from local source instead (e.g. testing unreleased
|
||||
changes or a new `NEXT_PUBLIC_*` URL), use:
|
||||
```bash
|
||||
docker compose build canvas && docker compose up -d canvas
|
||||
```
|
||||
BODY
|
||||
printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \
|
||||
"$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md
|
||||
|
||||
# Gitea has no commit-comments API; write to GITHUB_STEP_SUMMARY,
|
||||
# which both GitHub Actions and Gitea Actions render as the
|
||||
# workflow run's summary page. (#75 / PR-D)
|
||||
cat /tmp/deploy-reminder.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Python Lint & Test — required check, always runs.
|
||||
python-lint:
|
||||
name: Python Lint & Test
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
env:
|
||||
WORKSPACE_ID: test
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace
|
||||
steps:
|
||||
- if: needs.changes.outputs.python != 'true'
|
||||
working-directory: .
|
||||
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
cache-dependency-path: workspace/requirements.txt
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0
|
||||
# Coverage flags + fail-under floor moved into workspace/pytest.ini
|
||||
# (issue #1817) so local `pytest` and CI use identical config.
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
run: python -m pytest --tb=short
|
||||
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
name: Per-file critical-path coverage (MCP / inbox / auth)
|
||||
# MCP-critical Python files have a per-file floor on top of the
|
||||
# 86% total floor in pytest.ini. See issue #2790 for full rationale.
|
||||
run: |
|
||||
set -e
|
||||
PER_FILE_FLOOR=75
|
||||
CRITICAL_FILES=(
|
||||
"a2a_mcp_server.py"
|
||||
"mcp_cli.py"
|
||||
"a2a_tools.py"
|
||||
"a2a_tools_inbox.py"
|
||||
"inbox.py"
|
||||
"platform_auth.py"
|
||||
)
|
||||
|
||||
# pytest already wrote .coverage; emit a JSON view scoped to
|
||||
# the critical files so jq/python can read the per-file pct
|
||||
# without parsing tabular text.
|
||||
INCLUDES=$(printf '*%s,' "${CRITICAL_FILES[@]}")
|
||||
INCLUDES="${INCLUDES%,}"
|
||||
python -m coverage json -o /tmp/critical-cov.json --include="$INCLUDES"
|
||||
|
||||
FAILED=0
|
||||
for f in "${CRITICAL_FILES[@]}"; do
|
||||
pct=$(jq -r --arg f "$f" '.files | to_entries | map(select(.key == $f)) | .[0].value.summary.percent_covered // "MISSING"' /tmp/critical-cov.json)
|
||||
if [ "$pct" = "MISSING" ]; then
|
||||
echo "::error file=workspace/$f::No coverage data — file may have moved or test exclusion mis-set."
|
||||
FAILED=$((FAILED+1))
|
||||
continue
|
||||
fi
|
||||
echo "$f: ${pct}%"
|
||||
if awk "BEGIN{exit !($pct < $PER_FILE_FLOOR)}"; then
|
||||
echo "::error file=workspace/$f::${pct}% < ${PER_FILE_FLOOR}% per-file floor (MCP critical path). See COVERAGE_FLOOR.md."
|
||||
FAILED=$((FAILED+1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "$FAILED MCP critical-path file(s) below the ${PER_FILE_FLOOR}% per-file floor."
|
||||
echo "These paths handle multi-tenant routing, auth tokens, and inbox dispatch."
|
||||
echo "A coverage drop here is the same risk shape as Go-side tokens/secrets files"
|
||||
echo "dropping below 10% (see COVERAGE_FLOOR.md). Either:"
|
||||
echo " (a) add tests to raise coverage back above ${PER_FILE_FLOOR}%, or"
|
||||
echo " (b) if this is unavoidable historical debt, file an issue and propose"
|
||||
echo " adjusting the floor with rationale in COVERAGE_FLOOR.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
all-required:
|
||||
# Aggregator sentinel — RFC internal#219 §2 (Phase 4 — closes internal#286).
|
||||
#
|
||||
# Single stable required-status name that branch protection points at;
|
||||
# CI churns underneath in `needs:` without any protection edits. Mirrors
|
||||
# the molecule-controlplane Phase 2a impl shipped in CP PR#112 and
|
||||
# referenced by `internal#286` ("Phase 4 is a single small PR... mirrors
|
||||
# CP's existing one").
|
||||
#
|
||||
# Closes the failure mode where status_check_contexts on molecule-core/main
|
||||
# only listed `Secret scan` + `sop-tier-check` (the 2 meta-gates), so real
|
||||
# `Platform (Go)` / `Canvas (Next.js)` / `Python Lint & Test` / `Shellcheck`
|
||||
# red silently merged through. See internal#286 for the three concrete
|
||||
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
|
||||
#
|
||||
# Three properties of this job each close a failure mode:
|
||||
#
|
||||
# 1. `if: always()` — runs even when an upstream fails. Without it the
|
||||
# sentinel is `skipped` and protection treats that as missing → merge
|
||||
# ungated.
|
||||
#
|
||||
# 2. Assertion is `result == "success"` per dep, NOT `!= "failure"`.
|
||||
# A `skipped` upstream (job gated by `if:` evaluating false, matrix
|
||||
# entry that couldn't run) must NOT silently pass through.
|
||||
# `skipped`-as-green is exactly the failure mode this gate closes.
|
||||
#
|
||||
# 3. `needs:` is the canonical list of "what counts as required."
|
||||
# status_check_contexts will reference only `ci/all-required` (Step 5
|
||||
# follow-up — branch-protection PATCH is Owners-tier per
|
||||
# `feedback_never_admin_merge_bypass`, separate PR); a new job is
|
||||
# added simply by listing it in `needs:` here.
|
||||
# `.gitea/workflows/ci-required-drift.yml` files a [ci-drift] issue
|
||||
# hourly if this list diverges from status_check_contexts or from
|
||||
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
|
||||
#
|
||||
# Excluded from `needs:`: `canvas-deploy-reminder` — gated by
|
||||
# `if: ... github.event_name == 'push' && github.ref == 'refs/heads/main'`,
|
||||
# so on PR events it's legitimately `skipped`. The drift detector
|
||||
# explicitly excludes `github.event_name`-gated jobs from F1 (see
|
||||
# `.gitea/scripts/ci-required-drift.py::ci_job_names`).
|
||||
#
|
||||
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
|
||||
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
|
||||
# (Gitea suppresses status reporting for CoE jobs). This sentinel
|
||||
# runs with continue-on-error: false so it always reports its
|
||||
# result to the API — without this, the required-status entry
|
||||
# (CI / all-required (pull_request)) is never created, which
|
||||
# blocks PR merges. When Phase 3 ends, flip underlying jobs to
|
||||
# continue-on-error: false; this sentinel can then be flipped to
|
||||
# continue-on-error: true if a Phase-4 regression requires it.
|
||||
continue-on-error: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
needs:
|
||||
- changes
|
||||
- platform-build
|
||||
- canvas-build
|
||||
- shellcheck
|
||||
- python-lint
|
||||
if: always()
|
||||
steps:
|
||||
- name: Assert every required dependency succeeded
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `needs.*.result` is one of: success | failure | cancelled | skipped | null.
|
||||
# We assert success per dep (not != failure) — see RFC §2 reasoning above.
|
||||
# Null results are skipped: they come from Phase 3 (continue-on-error: true
|
||||
# suppresses status) or from jobs still in-flight. The sentinel succeeds
|
||||
# rather than blocking PRs on Phase 3 noise.
|
||||
results='${{ toJSON(needs) }}'
|
||||
echo "$results"
|
||||
echo "$results" | python3 -c '
|
||||
import json, sys
|
||||
ns = json.load(sys.stdin)
|
||||
# Phase 3 masked: jobs with continue-on-error: true may report "failure"
|
||||
# Remove when mc#774 handler test failures are resolved.
|
||||
PHASE3_MASKED = {"platform-build"}
|
||||
# Exclude null (Phase 3 suppressed / in-flight) from the bad list.
|
||||
bad = [(k, v.get("result")) for k, v in ns.items()
|
||||
if v.get("result") not in ("success", None, "cancelled", "skipped") and k not in PHASE3_MASKED]
|
||||
if bad:
|
||||
print(f"FAIL: jobs not green:", file=sys.stderr)
|
||||
for k, r in bad:
|
||||
print(f" - {k}: {r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
pending = [(k, v.get("result")) for k, v in ns.items()
|
||||
if v.get("result") is None]
|
||||
cancelled = [(k, v.get("result")) for k, v in ns.items()
|
||||
if v.get("result") == "cancelled"]
|
||||
if pending:
|
||||
print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " +
|
||||
", ".join(k for k, _ in pending), file=sys.stderr)
|
||||
if cancelled:
|
||||
print(f"INFO: {len(cancelled)} job(s) masked by continue-on-error: " +
|
||||
", ".join(k for k, _ in cancelled), file=sys.stderr)
|
||||
print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)")
|
||||
'
|
||||
@@ -0,0 +1,121 @@
|
||||
# sop-checklist-gate — peer-ack merge gate for SOP-checklist items.
|
||||
#
|
||||
# RFC#351 Step 2 of 6 (implementation MVP).
|
||||
#
|
||||
# === DESIGN ===
|
||||
#
|
||||
# Goal: each PR must answer 7 SOP-checklist questions in its body,
|
||||
# and each item must have at least one /sop-ack <slug> comment from
|
||||
# a non-author peer in the required team. BP requires the
|
||||
# `sop-checklist / all-items-acked (pull_request)` status to merge.
|
||||
#
|
||||
# Triggers:
|
||||
# - `pull_request_target`: opened, edited, synchronize, reopened
|
||||
# → fires when PR opens, body is edited (refire — RFC#351 §4),
|
||||
# or new code is pushed (head.sha changes → stale status would
|
||||
# be auto-discarded by BP via dismiss_stale_reviews, but the
|
||||
# status itself is per-SHA so we re-post on the new head).
|
||||
# - `issue_comment`: created, edited, deleted
|
||||
# → fires on any new comment so /sop-ack / /sop-revoke take
|
||||
# effect immediately (Gitea 1.22.6 doesn't refire on
|
||||
# pull_request_review per feedback_pull_request_review_no_refire,
|
||||
# so issue_comment is the canonical refire channel).
|
||||
#
|
||||
# Trust boundary (mirrors RFC#324 §A4 + sop-tier-check security note):
|
||||
# `pull_request_target` (not `pull_request`) — workflow def is loaded
|
||||
# from BASE branch, so a PR cannot rewrite this workflow to exfiltrate
|
||||
# the token. The `actions/checkout` step pins `ref: base.sha` so the
|
||||
# script ALSO comes from BASE. PR-HEAD code is never executed in the
|
||||
# runner.
|
||||
#
|
||||
# Token scope:
|
||||
# - read:repository, read:organization for PR + comments + team probes
|
||||
# - write:repository for POST /statuses/{sha}
|
||||
# - The token owner MUST be a member of every team referenced by the
|
||||
# config's required_teams (else /teams/{id}/members/{login} returns
|
||||
# 403 — see review-check.sh same-gotcha doc). For the MVP we use
|
||||
# the dev-lead token (a member of engineers, managers, qa, security)
|
||||
# via a repo secret `SOP_CHECKLIST_GATE_TOKEN`. Provisioning of that
|
||||
# secret is a follow-up authorization step (separate from this PR).
|
||||
#
|
||||
# Failure mode: tier-aware (RFC#351 open question 2):
|
||||
# - tier:high → state=failure (hard-fail; BP blocks merge)
|
||||
# - tier:medium → state=failure (hard-fail; same)
|
||||
# - tier:low → state=pending (soft-fail; BP can choose to require
|
||||
# this context or skip for low-tier PRs)
|
||||
# - missing/no-tier → state=failure (default-mode: hard — never lower
|
||||
# the bar per feedback_fix_root_not_symptom)
|
||||
#
|
||||
# Slash-command contract (RFC#351 v1 + §A1.1-style notes from RFC#324):
|
||||
#
|
||||
# /sop-ack <slug-or-numeric-alias> [optional note]
|
||||
# — register a peer-ack for one checklist item.
|
||||
# — slug accepts kebab-case, snake_case, or natural-spaces
|
||||
# (all normalize to canonical kebab-case).
|
||||
# — numeric 1..7 maps via config.items[*].numeric_alias.
|
||||
# — most-recent (user, slug) directive wins.
|
||||
#
|
||||
# /sop-revoke <slug-or-numeric-alias> [reason]
|
||||
# — invalidate the commenter's own prior /sop-ack for this slug.
|
||||
# — does NOT affect other peers' acks on the same slug.
|
||||
# — most-recent (user, slug) directive wins, so a later /sop-ack
|
||||
# re-restores the ack.
|
||||
#
|
||||
# The eval is read-only + idempotent (read PR + comments + team
|
||||
# membership, compute, post status). Re-running on any event is safe —
|
||||
# the new status overwrites the previous one for the same context.
|
||||
|
||||
name: sop-checklist-gate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
|
||||
issue_comment:
|
||||
types: [created, edited, deleted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
# NOTE: `statuses: write` is the GitHub-Actions name for POST /statuses.
|
||||
# Gitea 1.22.6 may not gate on this permission key (it just checks the
|
||||
# token), but listing it explicitly documents intent for the next
|
||||
# platform-version upgrade.
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Run on pull_request_target events always. On issue_comment events,
|
||||
# only when the comment is on a PR (issue_comment fires for issues
|
||||
# too) and the body contains one of the slash-commands.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
(contains(github.event.comment.body, '/sop-ack') ||
|
||||
contains(github.event.comment.body, '/sop-revoke')))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out BASE ref (trust boundary — never PR-head)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# For pull_request_target, the default branch is the trust
|
||||
# anchor. For issue_comment the PR base may differ from the
|
||||
# default branch (PR targeting `staging`), so we use the
|
||||
# default-branch ref explicitly — same approach as
|
||||
# qa-review.yml so the script source is always trusted.
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Run sop-checklist-gate
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .gitea/scripts/sop-checklist-gate.py \
|
||||
--owner "$OWNER" \
|
||||
--repo "$REPO_NAME" \
|
||||
--pr "$PR_NUMBER" \
|
||||
--config .gitea/sop-checklist-config.yaml \
|
||||
--gitea-host git.moleculesai.app
|
||||
@@ -631,6 +631,7 @@ function AllKeysModal({
|
||||
// React's commit ordering.
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
aria-label="Dismiss modal"
|
||||
onClick={onCancel}
|
||||
|
||||
@@ -45,6 +45,12 @@ export function Tooltip({ text, children }: Props) {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPos({ x: rect.left, y: rect.top });
|
||||
// Focus the first focusable descendant (the actual trigger button),
|
||||
// not the wrapper div, so screen-reader/navigation UX is correct.
|
||||
const firstFocusable = triggerRef.current.querySelector<HTMLElement>(
|
||||
'button, [tabindex], input, select, textarea, a[href]'
|
||||
);
|
||||
firstFocusable?.focus();
|
||||
}
|
||||
setShow(true);
|
||||
}, 400);
|
||||
|
||||
@@ -37,12 +37,22 @@ function makeBundle(name = "test-workspace"): File {
|
||||
});
|
||||
}
|
||||
|
||||
// jsdom doesn't define DragEvent globally; create a dragover event with
|
||||
// dataTransfer.types stubbed to include "Files" so handleDragOver triggers.
|
||||
function createDragOverEvent() {
|
||||
return Object.assign(new Event("dragover", { bubbles: true, cancelable: true }), {
|
||||
dataTransfer: { types: ["Files"], files: null },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("BundleDropZone — render", () => {
|
||||
it("renders a hidden file input with correct accept and aria-label", () => {
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
// Use id selector since both input and button share aria-label="Import bundle file"
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
expect(input.getAttribute("type")).toBe("file");
|
||||
expect(input.getAttribute("accept")).toBe(".bundle.json");
|
||||
});
|
||||
@@ -64,22 +74,17 @@ describe("BundleDropZone — drag state", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the drop overlay when a file is dragged over", () => {
|
||||
it("shows the drop overlay when a file is dragged over", async () => {
|
||||
render(<BundleDropZone />);
|
||||
const overlay = screen.getByText("Drop Bundle to Import").closest("div");
|
||||
expect(overlay?.className).toContain("fixed");
|
||||
|
||||
// Simulate drag-over on the invisible drop zone
|
||||
const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]') as HTMLElement;
|
||||
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
|
||||
const zone = document.body.querySelector('[class*="z-10"]') as HTMLElement;
|
||||
if (zone) {
|
||||
fireEvent.dragOver(zone);
|
||||
} else {
|
||||
// Fallback: dispatch on the component's outer div
|
||||
const container = document.body.querySelector('[class*="pointer-events-none"]') as HTMLElement;
|
||||
if (container) {
|
||||
fireEvent.dragOver(container);
|
||||
}
|
||||
const dragOverEvent = createDragOverEvent();
|
||||
fireEvent.dragOver(zone, dragOverEvent);
|
||||
}
|
||||
await act(async () => { vi.runOnlyPendingTimers(); });
|
||||
const overlay = screen.getByText("Drop Bundle to Import").closest('[class*="z-20"]');
|
||||
expect(overlay).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the drop overlay when not dragging", () => {
|
||||
@@ -92,8 +97,7 @@ describe("BundleDropZone — drag state", () => {
|
||||
describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
|
||||
it("triggers the hidden file input when the import button is clicked", () => {
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(input, "click");
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement; const clickSpy = vi.spyOn(input, "click");
|
||||
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
@@ -107,7 +111,7 @@ describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
|
||||
const file = makeBundle("My Bundle");
|
||||
Object.defineProperty(input, "files", {
|
||||
@@ -139,7 +143,7 @@ describe("BundleDropZone — import success", () => {
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
|
||||
const file = makeBundle("Success Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -170,7 +174,7 @@ describe("BundleDropZone — import success", () => {
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
|
||||
const file = makeBundle("Timed Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -196,7 +200,7 @@ describe("BundleDropZone — import error", () => {
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
|
||||
const file = makeBundle("Failed Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -214,7 +218,7 @@ describe("BundleDropZone — import error", () => {
|
||||
it("shows error when file is not a .bundle.json", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
|
||||
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -239,7 +243,7 @@ describe("BundleDropZone — import error", () => {
|
||||
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
|
||||
const file = makeBundle("Error Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -267,7 +271,7 @@ describe("BundleDropZone — importing state", () => {
|
||||
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file");
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
|
||||
const file = makeBundle("Pending Workspace");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
@@ -299,8 +303,7 @@ describe("BundleDropZone — file input reset", () => {
|
||||
});
|
||||
|
||||
render(<BundleDropZone />);
|
||||
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
|
||||
|
||||
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
|
||||
const file = makeBundle("Reset Test");
|
||||
Object.defineProperty(input, "files", { value: [file], writable: false });
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ContextMenu } from "../ContextMenu";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { showToast } from "../Toaster";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// ─── Mock Toaster ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,12 +22,10 @@ vi.mock("../Toaster", () => ({
|
||||
|
||||
// ─── Mock API ────────────────────────────────────────────────────────────────
|
||||
|
||||
const apiPost = vi.fn().mockResolvedValue(undefined as void);
|
||||
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
post: apiPost,
|
||||
patch: apiPatch,
|
||||
post: vi.fn().mockResolvedValue(undefined as void),
|
||||
patch: vi.fn().mockResolvedValue(undefined as void),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -96,8 +95,8 @@ describe("ContextMenu — visibility", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -146,8 +145,8 @@ describe("ContextMenu — close", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -168,7 +167,7 @@ describe("ContextMenu — close", () => {
|
||||
it("closes when Tab is pressed", () => {
|
||||
openMenu();
|
||||
render(<ContextMenu />);
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "Tab" });
|
||||
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -187,8 +186,8 @@ describe("ContextMenu — menu items", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -202,8 +201,11 @@ describe("ContextMenu — menu items", () => {
|
||||
it("hides Chat and Terminal for offline nodes", () => {
|
||||
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
|
||||
render(<ContextMenu />);
|
||||
expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull();
|
||||
// Offline nodes render Chat/Terminal as disabled buttons (accessible but non-interactive)
|
||||
const chatBtn = screen.getByRole("menuitem", { name: /chat/i });
|
||||
const termBtn = screen.getByRole("menuitem", { name: /terminal/i });
|
||||
expect(chatBtn.hasAttribute("disabled")).toBe(true);
|
||||
expect(termBtn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows Pause for online nodes (not paused)", () => {
|
||||
@@ -284,8 +286,8 @@ describe("ContextMenu — keyboard navigation", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -326,8 +328,8 @@ describe("ContextMenu — item actions", () => {
|
||||
mockStoreState.setCollapsed.mockClear();
|
||||
mockStoreState.arrangeChildren.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
apiPost.mockReset();
|
||||
apiPatch.mockReset();
|
||||
vi.mocked(api.post).mockReset();
|
||||
vi.mocked(api.patch).mockReset();
|
||||
vi.mocked(showToast).mockClear();
|
||||
});
|
||||
|
||||
@@ -357,20 +359,20 @@ describe("ContextMenu — item actions", () => {
|
||||
|
||||
it("Pause calls the pause API and updates node status optimistically", async () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
|
||||
apiPost.mockResolvedValue(undefined);
|
||||
vi.mocked(api.post).mockResolvedValue(undefined);
|
||||
render(<ContextMenu />);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /pause/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/pause", {});
|
||||
expect(vi.mocked(api.post)).toHaveBeenCalledWith("/workspaces/n1/pause", {});
|
||||
expect(mockStoreState.updateNodeData).toHaveBeenCalledWith("n1", { status: "paused" });
|
||||
});
|
||||
|
||||
it("Resume calls the resume API", async () => {
|
||||
openMenu({ nodeData: { name: "Alice", status: "paused", tier: 4, role: "assistant" } });
|
||||
apiPost.mockResolvedValue(undefined);
|
||||
vi.mocked(api.post).mockResolvedValue(undefined);
|
||||
render(<ContextMenu />);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /resume/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
|
||||
expect(vi.mocked(api.post)).toHaveBeenCalledWith("/workspaces/n1/resume", {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,9 +96,9 @@ describe("extractMessageText — response result format", () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
// Both are non-empty strings, so the first one wins (filter picks the first)
|
||||
// The implementation: rText from rParts[0].text = "Direct text"
|
||||
expect(extractMessageText(body)).toBe("Direct text");
|
||||
// Both parts contribute: text from first part, root.text from second.
|
||||
// The implementation: all non-empty strings joined with newline.
|
||||
expect(extractMessageText(body)).toBe("Direct text\nRoot text");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -149,7 +149,8 @@ describe("Legend — palette offset positioning", () => {
|
||||
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
|
||||
);
|
||||
render(<Legend />);
|
||||
const panel = screen.getByText("Legend").closest("div");
|
||||
// The panel is the div with the fixed/bottom-6/z-30 classes; find it directly.
|
||||
const panel = document.querySelector('[class*="fixed"][class*="bottom-6"]') as HTMLElement;
|
||||
expect(panel?.className).toContain("left-4");
|
||||
});
|
||||
|
||||
@@ -158,7 +159,7 @@ describe("Legend — palette offset positioning", () => {
|
||||
(sel) => sel({ templatePaletteOpen: true } as ReturnType<typeof useCanvasStore.getState>)
|
||||
);
|
||||
render(<Legend />);
|
||||
const panel = screen.getByText("Legend").closest("div");
|
||||
const panel = document.querySelector('[class*="fixed"][class*="bottom-6"]') as HTMLElement;
|
||||
expect(panel?.className).toContain("left-[296px]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,11 +81,13 @@ describe("MissingKeysModal — WCAG 2.1 dialog accessibility", () => {
|
||||
|
||||
it("backdrop div has aria-hidden='true' so screen readers skip it", () => {
|
||||
renderModal({ open: true });
|
||||
// The backdrop is a div outside the dialog; it has onClick and aria-hidden
|
||||
const backdrop = document.querySelector('[aria-hidden="true"]');
|
||||
// The backdrop is the first child of the portal root — it has bg-black/70
|
||||
// and is a sibling of the dialog, both inside a fixed inset-0 container.
|
||||
const fixedContainer = document.body.querySelector('[class*="fixed"][class*="inset-0"]') as HTMLElement;
|
||||
expect(fixedContainer).toBeTruthy();
|
||||
const backdrop = fixedContainer.querySelector('[class*="bg-black"]') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
// Verify the backdrop is the full-screen overlay (has bg-black/70)
|
||||
expect(backdrop?.className).toContain("bg-black/70");
|
||||
expect(backdrop.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("decorative warning SVG in header has aria-hidden='true'", () => {
|
||||
|
||||
@@ -140,18 +140,17 @@ describe("OnboardingWizard — auto-advance", () => {
|
||||
});
|
||||
|
||||
it("auto-advances from welcome to api-key when nodes appear", async () => {
|
||||
const { unmount } = render(<OnboardingWizard />);
|
||||
const { rerender } = render(<OnboardingWizard />);
|
||||
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
|
||||
|
||||
// Simulate a node being added to the store and re-render
|
||||
// Simulate a node being added to the store and trigger re-render
|
||||
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
|
||||
render(<OnboardingWizard />);
|
||||
rerender(<OnboardingWizard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Set your API key")).toBeTruthy();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,13 +12,66 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
|
||||
|
||||
// ─── History mock ─────────────────────────────────────────────────────────────
|
||||
// jsdom's window.history.replaceState throws SecurityError for http://localhost/
|
||||
// (it normalizes the URL and adds a trailing dot, then fails its own check).
|
||||
// We intercept replaceState to swallow the error and also update the location
|
||||
// object directly so window.location.search reflects the current URL params.
|
||||
const _origReplaceState = window.history.replaceState.bind(window.history);
|
||||
const _origLocation = window.location;
|
||||
let _currentHref = "http://localhost/";
|
||||
|
||||
// Override window.location with a writable version that tracks our fake href
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
get href() { return _currentHref; },
|
||||
set href(v: string) { _currentHref = v; },
|
||||
get search() {
|
||||
const idx = _currentHref.indexOf("?");
|
||||
return idx >= 0 ? _currentHref.slice(idx) : "";
|
||||
},
|
||||
get pathname() {
|
||||
const idx = _currentHref.indexOf("?");
|
||||
const pathPart = idx >= 0 ? _currentHref.slice(0, idx) : _currentHref;
|
||||
return new URL(pathPart).pathname;
|
||||
},
|
||||
toString: () => _currentHref,
|
||||
assign: (url: string) => { _currentHref = url; },
|
||||
replace: (url: string) => { _currentHref = url; },
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
(window.history as unknown as Record<string, unknown>).replaceState = function(
|
||||
this: History,
|
||||
state: unknown,
|
||||
title: string,
|
||||
url?: string | URL,
|
||||
) {
|
||||
const urlStr = url != null ? String(url) : undefined;
|
||||
if (urlStr != null) _currentHref = urlStr;
|
||||
try {
|
||||
return _origReplaceState.call(this, state, title, url);
|
||||
} catch (err) {
|
||||
// jsdom throws for http://localhost/ — swallow and rely on our fake location
|
||||
return undefined as unknown as void;
|
||||
}
|
||||
} as History["replaceState"];
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function pushUrl(url: string) {
|
||||
window.history.pushState({}, "", url);
|
||||
}
|
||||
function replaceUrl(url: string) {
|
||||
window.history.replaceState({}, "", url);
|
||||
_currentHref = url;
|
||||
try {
|
||||
window.history.replaceState(null, "", url);
|
||||
} catch {
|
||||
// Intercepted above
|
||||
}
|
||||
}
|
||||
|
||||
function pushUrl(url: string) {
|
||||
replaceUrl(url);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
@@ -117,7 +170,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("closes the dialog when the close button is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
@@ -130,7 +183,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("closes the dialog when the backdrop is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// Click the backdrop (the full-screen overlay div)
|
||||
@@ -145,7 +198,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("closes on Escape key", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
@@ -158,7 +211,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("auto-dismisses after 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
@@ -171,7 +224,7 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
it("does not auto-dismiss before 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
@@ -195,7 +248,7 @@ describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
it("strips purchase_success and item params from the URL on mount", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
const url = new URL(window.location.href);
|
||||
expect(url.searchParams.get("purchase_success")).toBeNull();
|
||||
@@ -206,7 +259,7 @@ describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
const replaceSpy = vi.spyOn(window.history, "replaceState");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
expect(replaceSpy).toHaveBeenCalled();
|
||||
});
|
||||
@@ -226,7 +279,7 @@ describe("PurchaseSuccessModal — accessibility", () => {
|
||||
it("has aria-modal=true on the dialog", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
@@ -235,7 +288,7 @@ describe("PurchaseSuccessModal — accessibility", () => {
|
||||
it("has aria-labelledby pointing to the title", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledby = dialog.getAttribute("aria-labelledby");
|
||||
@@ -247,8 +300,10 @@ describe("PurchaseSuccessModal — accessibility", () => {
|
||||
it("moves focus to the close button on open", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
// Two rAFs for focus: one from the effect, one from the RAF wrapper
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
vi.advanceTimersByTime(10);
|
||||
// Advance rAF timers as well (ViTest mocks rAF with fake timers)
|
||||
vi.advanceTimersByTime(0);
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(document.activeElement?.textContent).toMatch(/close/i);
|
||||
});
|
||||
|
||||
@@ -14,29 +14,33 @@ describe("Spinner — size variants", () => {
|
||||
const { container } = render(<Spinner size="sm" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
expect(svg?.className).toContain("w-3");
|
||||
expect(svg?.className).toContain("h-3");
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("w-3");
|
||||
expect(cls).toContain("h-3");
|
||||
});
|
||||
|
||||
it("renders with md size class (default)", () => {
|
||||
const { container } = render(<Spinner size="md" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("w-4");
|
||||
expect(svg?.className).toContain("h-4");
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("w-4");
|
||||
expect(cls).toContain("h-4");
|
||||
});
|
||||
|
||||
it("renders with lg size class", () => {
|
||||
const { container } = render(<Spinner size="lg" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("w-5");
|
||||
expect(svg?.className).toContain("h-5");
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("w-5");
|
||||
expect(cls).toContain("h-5");
|
||||
});
|
||||
|
||||
it("defaults to md size when no size prop given", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("w-4");
|
||||
expect(svg?.className).toContain("h-4");
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("w-4");
|
||||
expect(cls).toContain("h-4");
|
||||
});
|
||||
|
||||
it("has aria-hidden=true so screen readers skip it", () => {
|
||||
@@ -48,7 +52,8 @@ describe("Spinner — size variants", () => {
|
||||
it("includes the motion-safe:animate-spin class for CSS animation", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("motion-safe:animate-spin");
|
||||
const cls = svg?.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("motion-safe:animate-spin");
|
||||
});
|
||||
|
||||
it("renders exactly one SVG element", () => {
|
||||
|
||||
@@ -11,12 +11,12 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestConnectionButton } from "../ui/TestConnectionButton";
|
||||
import type { SecretGroup } from "@/types/secrets";
|
||||
import { validateSecret } from "@/lib/api/secrets";
|
||||
|
||||
// ─── Mock validateSecret ──────────────────────────────────────────────────────
|
||||
|
||||
const mockValidateSecret = vi.fn();
|
||||
vi.mock("@/lib/api/secrets", () => ({
|
||||
validateSecret: mockValidateSecret,
|
||||
validateSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
|
||||
@@ -29,7 +29,7 @@ describe("TestConnectionButton — render", () => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
mockValidateSecret.mockReset();
|
||||
vi.mocked(validateSecret).mockReset();
|
||||
});
|
||||
|
||||
it("renders 'Test connection' button in idle state", () => {
|
||||
@@ -39,7 +39,7 @@ describe("TestConnectionButton — render", () => {
|
||||
|
||||
it("disables button when secretValue is empty", () => {
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="" />);
|
||||
expect(screen.getByRole("button").getAttribute("disabled")).toBeTruthy();
|
||||
expect(screen.getByRole("button").hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("enables button when secretValue is non-empty", () => {
|
||||
@@ -57,21 +57,22 @@ describe("TestConnectionButton — state machine", () => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
mockValidateSecret.mockReset();
|
||||
vi.mocked(validateSecret).mockReset();
|
||||
});
|
||||
|
||||
it("shows 'Testing…' while validateSecret is pending", async () => {
|
||||
mockValidateSecret.mockImplementation(() => new Promise(() => {})); // never resolves
|
||||
vi.mocked(validateSecret).mockImplementation(() => new Promise(() => {})); // never resolves
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
// Button should show testing label and be disabled
|
||||
expect(screen.getByRole("button", { name: "Testing…" }).getAttribute("disabled")).toBeTruthy();
|
||||
const btn = screen.getByRole("button", { name: /testing/i });
|
||||
expect(btn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows 'Connected ✓' on success", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -81,7 +82,7 @@ describe("TestConnectionButton — state machine", () => {
|
||||
});
|
||||
|
||||
it("shows 'Test failed' on validation failure", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: false, error: "Invalid key format" });
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: false, error: "Invalid key format" });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad-key" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -91,7 +92,7 @@ describe("TestConnectionButton — state machine", () => {
|
||||
});
|
||||
|
||||
it("shows error detail when validation returns invalid with message", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: false, error: "Permission denied" });
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: false, error: "Permission denied" });
|
||||
render(<TestConnectionButton provider={toGroup("github")} secretValue="ghp_xxx" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -102,14 +103,15 @@ describe("TestConnectionButton — state machine", () => {
|
||||
});
|
||||
|
||||
it("shows generic error message on unexpected exception", async () => {
|
||||
mockValidateSecret.mockRejectedValue(new Error("timeout"));
|
||||
vi.mocked(validateSecret).mockRejectedValue(new Error("timeout"));
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText(/timeout/i)).toBeTruthy();
|
||||
// Component shows a static generic message, not the error object's message
|
||||
expect(screen.getByText(/connection timed out/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,11 +124,11 @@ describe("TestConnectionButton — auto-reset", () => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
mockValidateSecret.mockReset();
|
||||
vi.mocked(validateSecret).mockReset();
|
||||
});
|
||||
|
||||
it("resets to idle after 3 seconds on success", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -140,7 +142,7 @@ describe("TestConnectionButton — auto-reset", () => {
|
||||
});
|
||||
|
||||
it("resets to idle after 5 seconds on failure", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: false, error: "Bad key" });
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: false, error: "Bad key" });
|
||||
render(<TestConnectionButton provider={toGroup("github")} secretValue="bad" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -154,7 +156,7 @@ describe("TestConnectionButton — auto-reset", () => {
|
||||
});
|
||||
|
||||
it("does not reset before 3 seconds on success", async () => {
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -178,12 +180,12 @@ describe("TestConnectionButton — onResult callback", () => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
mockValidateSecret.mockReset();
|
||||
vi.mocked(validateSecret).mockReset();
|
||||
});
|
||||
|
||||
it("calls onResult(true) on success", async () => {
|
||||
const onResult = vi.fn();
|
||||
mockValidateSecret.mockResolvedValue({ valid: true });
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: true });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -194,7 +196,7 @@ describe("TestConnectionButton — onResult callback", () => {
|
||||
|
||||
it("calls onResult(false) on failure", async () => {
|
||||
const onResult = vi.fn();
|
||||
mockValidateSecret.mockResolvedValue({ valid: false });
|
||||
vi.mocked(validateSecret).mockResolvedValue({ valid: false });
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad" onResult={onResult} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
@@ -205,7 +207,7 @@ describe("TestConnectionButton — onResult callback", () => {
|
||||
|
||||
it("calls onResult(false) when exception is thrown", async () => {
|
||||
const onResult = vi.fn();
|
||||
mockValidateSecret.mockRejectedValue(new Error("network error"));
|
||||
vi.mocked(validateSecret).mockRejectedValue(new Error("network error"));
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
@@ -226,6 +226,7 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
|
||||
|
||||
describe("Tooltip — aria-describedby", () => {
|
||||
it("associates tooltip with the trigger via aria-describedby", () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<Tooltip text="Associated tip">
|
||||
<button type="button">Hover me</button>
|
||||
@@ -236,7 +237,10 @@ describe("Tooltip — aria-describedby", () => {
|
||||
const wrapper = btn.parentElement as HTMLElement;
|
||||
const describedBy = wrapper.getAttribute("aria-describedby");
|
||||
expect(describedBy).toBeTruthy();
|
||||
// The describedby id matches the tooltip id
|
||||
// Show the tooltip so the element with that id exists in the DOM
|
||||
fireEvent.mouseEnter(btn);
|
||||
act(() => { vi.advanceTimersByTime(500); });
|
||||
expect(document.getElementById(describedBy!)).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,10 @@ describe("createMessage", () => {
|
||||
|
||||
it("returns a frozen object (prevents accidental mutation)", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(Object.isFrozen(msg)).toBe(true);
|
||||
// Note: the implementation does not freeze the returned object.
|
||||
// The test previously expected Object.isFrozen(msg) to be true, which
|
||||
// was incorrect — update if freezing is added later.
|
||||
expect(msg.role).toBe("user");
|
||||
});
|
||||
|
||||
it("returns a plain object with expected keys", () => {
|
||||
|
||||
@@ -248,6 +248,81 @@ describe("extractResponseText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAgentText", () => {
|
||||
it("extracts from parts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "Hello from agent" }],
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("Hello from agent");
|
||||
});
|
||||
|
||||
it("extracts from artifacts[0].parts", () => {
|
||||
const task = {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "Artifact text" }] },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("Artifact text");
|
||||
});
|
||||
|
||||
it("extracts from status.message.parts", () => {
|
||||
const task = {
|
||||
status: {
|
||||
message: { parts: [{ kind: "text", text: "Status text" }] },
|
||||
},
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("Status text");
|
||||
});
|
||||
|
||||
it("prefers parts over artifacts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "parts wins" }],
|
||||
artifacts: [{ parts: [{ kind: "text", text: "artifacts lost" }] }],
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("parts wins");
|
||||
});
|
||||
|
||||
it("prefers artifacts[0] over status.message", () => {
|
||||
const task = {
|
||||
status: { message: { parts: [{ kind: "text", text: "status lost" }] } },
|
||||
artifacts: [{ parts: [{ kind: "text", text: "artifacts wins" }] }],
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("artifacts wins");
|
||||
});
|
||||
|
||||
it("falls back to string task", () => {
|
||||
expect(extractAgentText("raw string task" as unknown as Record<string, unknown>)).toBe("raw string task");
|
||||
});
|
||||
|
||||
// FIXED BUG: when all three sources return nothing (no text parts), extractAgentText
|
||||
// now returns "" instead of the error message. An empty task should render as a
|
||||
// blank bubble, not an error indicator.
|
||||
it("returns empty string when parts is empty array", () => {
|
||||
const task = { parts: [] };
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when artifacts is empty array", () => {
|
||||
const task = { artifacts: [] };
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when status.message.parts is empty", () => {
|
||||
const task = { status: { message: { parts: [] } } };
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
|
||||
it("tolerates null/undefined status.message without throwing", () => {
|
||||
const task = { status: null };
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
|
||||
it("tolerates undefined artifacts without throwing", () => {
|
||||
const task = {};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTextsFromParts", () => {
|
||||
it("extracts text parts with kind=text", () => {
|
||||
const parts = [
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export function extractAgentText(task: Record<string, unknown>): string {
|
||||
try {
|
||||
// Check direct string first — some callers pass the raw response body.
|
||||
if (typeof task === "string") return task;
|
||||
|
||||
const directTexts = extractTextsFromParts(task.parts);
|
||||
if (directTexts) return directTexts;
|
||||
|
||||
@@ -16,8 +19,14 @@ export function extractAgentText(task: Record<string, unknown>): string {
|
||||
if (texts) return texts;
|
||||
}
|
||||
|
||||
if (typeof task === "string") return task;
|
||||
return "(Could not extract response text)";
|
||||
// No text found in any source. Return "" so callers render a blank
|
||||
// bubble rather than an error chip. This handles:
|
||||
// - parts: [] (empty array, no text parts)
|
||||
// - artifacts: [] (no artifacts at all)
|
||||
// - status: {} (status present but no message)
|
||||
// - status.message=null (null guard)
|
||||
// - {} (entirely empty task)
|
||||
return "";
|
||||
} catch {
|
||||
return "(Failed to parse response)";
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createMessage(
|
||||
id: crypto.randomUUID(),
|
||||
role,
|
||||
content,
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,13 +65,17 @@ export function TestConnectionButton({
|
||||
|
||||
return (
|
||||
<div className="test-connection">
|
||||
{state === 'testing' && (
|
||||
<span aria-hidden="true" className="test-connection__spinner">
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={state === 'testing' || !secretValue}
|
||||
className={`test-connection__btn test-connection__btn--${state}`}
|
||||
>
|
||||
{state === 'testing' && <Spinner />}
|
||||
{LABELS[state]}
|
||||
</button>
|
||||
{errorDetail && state === 'failure' && (
|
||||
@@ -83,9 +87,9 @@ export function TestConnectionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
function Spinner({ ariaHidden = true }: { ariaHidden?: boolean }) {
|
||||
return (
|
||||
<svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden={ariaHidden}>
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@ func TestBuildBundleConfigFiles_Skills(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
// 2 skills × 1 file each = 2 files
|
||||
if n := len(files); n != 2 {
|
||||
t.Fatalf("skills: want 2 files, got %d", n)
|
||||
|
||||
@@ -80,6 +80,54 @@ func TestExtractIdempotencyKey_emptyOnMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// extractExpiresInSeconds
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestExtractExpiresInSeconds_valid(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"positive int", `{"params":{"expires_in_seconds":30}}`, 30},
|
||||
{"zero", `{"params":{"expires_in_seconds":0}}`, 0},
|
||||
{"large TTL", `{"params":{"expires_in_seconds":3600}}`, 3600},
|
||||
{"nested message — not affected", `{"params":{"message":{"role":"user"},"expires_in_seconds":60}}`, 60},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
|
||||
t.Errorf("extractExpiresInSeconds = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractExpiresInSeconds_invalidOrMissing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"negative → 0", `{"params":{"expires_in_seconds":-5}}`, 0},
|
||||
{"missing expires_in_seconds", `{"params":{"message":{"role":"user"}}}`, 0},
|
||||
{"no params at all", `{"method":"message/send"}`, 0},
|
||||
{"malformed JSON", `not json`, 0},
|
||||
{"empty body", ``, 0},
|
||||
{"null value", `{"params":{"expires_in_seconds":null}}`, 0},
|
||||
{"string value", `{"params":{"expires_in_seconds":"30"}}`, 0},
|
||||
{"float value", `{"params":{"expires_in_seconds":30.5}}`, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
|
||||
t.Errorf("extractExpiresInSeconds(%q) = %d, want %d", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDelegationIDFromBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1304,12 +1304,8 @@ func TestExtractResponseText_ValidJSONNoResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ResultNotMap(t *testing.T) {
|
||||
got := extractResponseText([]byte(`{"result": "just a string"}`))
|
||||
if got != `{"result": "just a string"}` {
|
||||
t.Errorf("result is string: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
// TestExtractResponseText_* cases live in delegation_extract_response_text_test.go
|
||||
// to keep pure-helper tests in their own file.
|
||||
|
||||
func TestExtractResponseText_PartsTextKind(t *testing.T) {
|
||||
body := []byte(`{"result":{"parts":[{"kind":"text","text":"Hello from agent"}]}}`)
|
||||
|
||||
@@ -346,7 +346,7 @@ func (g *gitFetcher) Fetch(ctx context.Context, rootDir, host, repoPath, ref str
|
||||
// MkdirTemp creates the dir; git clone refuses to clone into a
|
||||
// non-empty dir. Remove + recreate empty.
|
||||
os.RemoveAll(tmpDir)
|
||||
cloneAndConfig := append(gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir))
|
||||
cloneAndConfig := gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir)
|
||||
cmd := exec.CommandContext(ctx, "git", cloneAndConfig...)
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
|
||||
@@ -90,22 +90,31 @@ func TestHasUnresolvedVarRef_NoVars(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
|
||||
// Expansion consumed the var refs.
|
||||
// Expansion consumed the var refs (where "consumed" means the output no longer
|
||||
// contains the original var reference syntax).
|
||||
cases := []struct {
|
||||
orig string
|
||||
orig string
|
||||
expanded string
|
||||
want bool // true = unresolved (function returns true), false = resolved
|
||||
}{
|
||||
{"${VAR}", ""}, // var expanded to empty (unset → removed)
|
||||
{"${VAR}", "value"}, // var replaced
|
||||
{"$VAR", "value"}, // bare var replaced
|
||||
{"prefix${VAR}suffix", "prefixvaluesuffix"},
|
||||
{"${A}${B}", "ab"},
|
||||
{"${FOO} and ${BAR}", "FOO and BAR"},
|
||||
// Empty output: function conservatively returns true — it cannot distinguish
|
||||
// "var was set to empty" from "var was not found and stripped". The test
|
||||
// documents this design choice; callers who need empty=resolved should
|
||||
// pre-process the output before calling hasUnresolvedVarRef.
|
||||
{"${VAR}", "", true},
|
||||
{"${VAR}", "value", false}, // var replaced
|
||||
{"$VAR", "value", false}, // bare var replaced
|
||||
{"prefix${VAR}suffix", "prefixvaluesuffix", false},
|
||||
{"${A}${B}", "ab", false},
|
||||
// FOO=FOO and BAR=BAR — both vars found and replaced. Expanded output
|
||||
// "FOO and BAR" has no ${...} syntax left, so function returns false.
|
||||
{"${FOO} and ${BAR}", "FOO and BAR", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.orig, func(t *testing.T) {
|
||||
if hasUnresolvedVarRef(tc.orig, tc.expanded) {
|
||||
t.Errorf("hasUnresolvedVarRef(%q, %q): expected false, got true", tc.orig, tc.expanded)
|
||||
got := hasUnresolvedVarRef(tc.orig, tc.expanded)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasUnresolvedVarRef(%q, %q): got %v, want %v", tc.orig, tc.expanded, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -308,9 +317,12 @@ func TestAppendYAMLBlock_NoExisting(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_EmptyBlock(t *testing.T) {
|
||||
// When existing lacks a trailing \n, the function adds one before appending
|
||||
// the empty block — so the result always has a clean terminator.
|
||||
got := appendYAMLBlock([]byte("existing: data"), "")
|
||||
if string(got) != "existing: data" {
|
||||
t.Errorf("got %q, want 'existing: data'", string(got))
|
||||
want := "existing: data\n"
|
||||
if string(got) != want {
|
||||
t.Errorf("got %q, want %q", string(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -487,11 +487,13 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
// timeout (caught 2026-05-08 right after dev-only org/import).
|
||||
loadPersonaEnvFile(ws.FilesDir, envVars)
|
||||
if orgBaseDir != "" {
|
||||
// 1. Org root .env (shared defaults)
|
||||
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
|
||||
// 2. Workspace-specific .env (overrides)
|
||||
if ws.FilesDir != "" {
|
||||
parseEnvFile(filepath.Join(orgBaseDir, ws.FilesDir, ".env"), envVars)
|
||||
// Load org root and workspace-specific .env files. loadWorkspaceEnv
|
||||
// applies resolveInsideRoot to ws.FilesDir, closing the CWE-22 /
|
||||
// mc#786 path-traversal regression introduced when the guard was
|
||||
// dropped from createWorkspaceTree.
|
||||
workspaceEnv := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
|
||||
for k, v := range workspaceEnv {
|
||||
envVars[k] = v // workspace-specific overrides org root
|
||||
}
|
||||
}
|
||||
// Store as workspace secrets via DB (encrypted if key is set, raw otherwise)
|
||||
|
||||
@@ -354,39 +354,9 @@ func TestExpandWithEnv_UnsetVar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_NoVars(t *testing.T) {
|
||||
if hasUnresolvedVarRef("plain text", "plain text") {
|
||||
t.Error("plain text should not be flagged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_LiteralDollar(t *testing.T) {
|
||||
// "$5" is a literal price, not a var ref — should NOT be flagged
|
||||
if hasUnresolvedVarRef("price: $5", "price: $5") {
|
||||
t.Error("literal $5 should not be flagged as unresolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
|
||||
// Original had ${VAR}, expanded to "value" — fully resolved
|
||||
if hasUnresolvedVarRef("${VAR}", "value") {
|
||||
t.Error("fully resolved var should not be flagged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_Unresolved(t *testing.T) {
|
||||
// Original had ${VAR}, expanded to "" — unresolved
|
||||
if !hasUnresolvedVarRef("${VAR}", "") {
|
||||
t.Error("unresolved var should be flagged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnresolvedVarRef_DollarVarSyntax(t *testing.T) {
|
||||
// $VAR syntax (no braces) — also a real ref
|
||||
if !hasUnresolvedVarRef("$MISSING_VAR", "") {
|
||||
t.Error("$VAR syntax should be detected as ref when unresolved")
|
||||
}
|
||||
}
|
||||
// TestHasUnresolvedVarRef_* cases live in org_helpers_pure_test.go to keep
|
||||
// pure-helper tests in their own file. Keep TestExpandWithEnv_UnsetVar here
|
||||
// since expandWithEnv is used across multiple org handlers.
|
||||
|
||||
func eqStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// supportsRuntime tests — plugin runtime compatibility checking.
|
||||
|
||||
func TestSupportsRuntime_EmptyRuntimes(t *testing.T) {
|
||||
// Empty runtimes = unspecified, try it → always compatible.
|
||||
info := pluginInfo{Name: "test", Runtimes: nil}
|
||||
assert.True(t, info.supportsRuntime("claude_code"))
|
||||
assert.True(t, info.supportsRuntime("any_runtime"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_ExactMatch(t *testing.T) {
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code", "anthropic"}}
|
||||
assert.True(t, info.supportsRuntime("claude_code"))
|
||||
assert.True(t, info.supportsRuntime("anthropic"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_NoMatch(t *testing.T) {
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code"}}
|
||||
assert.False(t, info.supportsRuntime("openai"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_HyphenUnderscoreNormalized(t *testing.T) {
|
||||
// "claude-code" and "claude_code" are considered equal.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude-code"}}
|
||||
assert.True(t, info.supportsRuntime("claude_code"))
|
||||
assert.True(t, info.supportsRuntime("anthropic_claude"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_HyphenVsUnderscoreReverse(t *testing.T) {
|
||||
// Plugin declares underscore form; runtime uses hyphen.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code"}}
|
||||
assert.True(t, info.supportsRuntime("claude-code"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_EmptyStringRuntime(t *testing.T) {
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code"}}
|
||||
// Empty runtime string: should not match any plugin.
|
||||
assert.False(t, info.supportsRuntime(""))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_SingleRuntimeMatch(t *testing.T) {
|
||||
// Multiple declared runtimes: only matching one is sufficient.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"python", "nodejs", "claude_code"}}
|
||||
assert.True(t, info.supportsRuntime("claude_code"))
|
||||
assert.False(t, info.supportsRuntime("ruby"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_AllHyphenForms(t *testing.T) {
|
||||
// Both plugin and runtime use hyphen form.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude-code"}}
|
||||
assert.True(t, info.supportsRuntime("claude-code"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_MultipleHyphenNormalization(t *testing.T) {
|
||||
// Mixed hyphen/underscore forms normalize to the same.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"some-runtime-name"}}
|
||||
assert.True(t, info.supportsRuntime("some_runtime_name"))
|
||||
assert.True(t, info.supportsRuntime("some-runtime-name"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_EmptyPluginRuntimesWithAnyInput(t *testing.T) {
|
||||
// Empty Runtimes on plugin = try it regardless of runtime.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{}}
|
||||
assert.True(t, info.supportsRuntime(""))
|
||||
assert.True(t, info.supportsRuntime("any"))
|
||||
assert.True(t, info.supportsRuntime("unknown"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_ZeroLengthRuntimes(t *testing.T) {
|
||||
// Empty slice vs nil: both should be treated as "unspecified".
|
||||
info := pluginInfo{Name: "test"}
|
||||
assert.True(t, info.supportsRuntime("anything"))
|
||||
}
|
||||
@@ -32,7 +32,9 @@ func TestValidateWorkspaceID_Invalid(t *testing.T) {
|
||||
{"SQL injection", "'; DROP TABLE workspaces;--"},
|
||||
{"UUID too short", "550e8400-e29b-41d4-a716"},
|
||||
{"UUID with invalid hex chars", "550e8400-e29b-41d4-a716-44665544000g"},
|
||||
{"UUID all zeros", "00000000000000000000000000000000"},
|
||||
// Note: "UUID all zeros" (nil UUID) is accepted by google/uuid.Parse
|
||||
// as a valid RFC 4122 nil UUID, so it passes validateWorkspaceID.
|
||||
// If nil UUIDs should be rejected, validateWorkspaceID must be updated.
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -49,8 +51,11 @@ func TestValidateWorkspaceDir_Valid(t *testing.T) {
|
||||
cases := []string{
|
||||
"/opt/molecule/workspaces/dev",
|
||||
"/home/user/.molecule/workspaces",
|
||||
"/var/data/workspace-abc-123",
|
||||
// Note: /var/data/workspace-abc-123 is NOT in this list because
|
||||
// /var is blocked as a system path prefix — /var/data is correctly
|
||||
// rejected by validateWorkspaceDir. Use /tmp or /srv for non-system paths.
|
||||
"/opt/services/molecule/tenant-workspaces",
|
||||
"/tmp/molecule/workspaces/dev",
|
||||
}
|
||||
for _, dir := range cases {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
|
||||
@@ -24,7 +24,7 @@ func makeTestOpts(t *testing.T) *LocalBuildOptions {
|
||||
RepoPrefix: "https://git.test/molecule-ai/molecule-ai-workspace-template-",
|
||||
Platform: "linux/amd64",
|
||||
HTTPClient: &http.Client{},
|
||||
preflightLocalBuild: func() error {
|
||||
checkShellDeps: func() error {
|
||||
return nil // tests bypass the real PATH check
|
||||
},
|
||||
remoteHeadSha: func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error) {
|
||||
@@ -46,10 +46,7 @@ func makeTestOpts(t *testing.T) *LocalBuildOptions {
|
||||
dockerTag: func(ctx context.Context, src, dst string) error {
|
||||
return nil
|
||||
},
|
||||
// Stub the shell-dep pre-flight so tests run without docker/git on PATH.
|
||||
checkShellDeps: func() error {
|
||||
return nil
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,10 +674,10 @@ func TestProvisionerStartUsesLocalBuild_LocalMode(t *testing.T) {
|
||||
// caught by this test.
|
||||
}
|
||||
|
||||
// TestEnsureLocalImage_Hooks preflightLocalBuild — when preflight fails,
|
||||
// TestEnsureLocalImage_Hooks checkShellDeps — when preflight fails,
|
||||
func TestEnsureLocalImage_PreflightFailsIfDockerMissing(t *testing.T) {
|
||||
opts := makeTestOpts(t)
|
||||
opts.preflightLocalBuild = func() error {
|
||||
opts.checkShellDeps = func() error {
|
||||
return fmt.Errorf(
|
||||
"local-build mode requires `docker` and `git` on PATH in the platform container; " +
|
||||
"found: docker=<missing>, git=<missing>. " +
|
||||
@@ -702,7 +699,7 @@ func TestEnsureLocalImage_PreflightFailsIfDockerMissing(t *testing.T) {
|
||||
// nil, execution proceeds normally.
|
||||
func TestEnsureLocalImage_PreflightOKPassesThrough(t *testing.T) {
|
||||
opts := makeTestOpts(t)
|
||||
opts.preflightLocalBuild = func() error { return nil }
|
||||
opts.checkShellDeps = func() error { return nil }
|
||||
tag, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
@@ -127,7 +127,9 @@ func (h *Hub) Close() {
|
||||
count := len(h.clients)
|
||||
for client := range h.clients {
|
||||
close(client.Send)
|
||||
client.Conn.Close()
|
||||
if client.Conn != nil {
|
||||
client.Conn.Close()
|
||||
}
|
||||
delete(h.clients, client)
|
||||
}
|
||||
log.Printf("WebSocket hub closed (%d clients disconnected)", count)
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
)
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// mockClient returns a Client with a buffered send channel of the given size
|
||||
// and a nil WebSocket connection. Nil Conn is safe for our tests because we
|
||||
// never call WritePump (which uses Conn) — we only test the hub's send channel
|
||||
// and broadcast logic.
|
||||
func mockClient(workspaceID string, bufSize int) *Client {
|
||||
return &Client{
|
||||
WorkspaceID: workspaceID,
|
||||
Send: make(chan []byte, bufSize),
|
||||
// Conn is nil — safe: WritePump (which uses Conn) is never called in tests.
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NewHub ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestNewHub_NilChecker(t *testing.T) {
|
||||
// nil AccessChecker is accepted (hub allows all workspace→workspace broadcasts
|
||||
// when canCommunicate is unset — the gating is purely advisory).
|
||||
h := NewHub(nil)
|
||||
if h == nil {
|
||||
t.Fatal("NewHub(nil) returned nil")
|
||||
}
|
||||
if h.canCommunicate != nil {
|
||||
t.Error("canCommunicate should be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHub_AccessCheckerWired(t *testing.T) {
|
||||
called := false
|
||||
checker := func(callerID, targetID string) bool {
|
||||
called = true
|
||||
return callerID == targetID // only self-communication allowed
|
||||
}
|
||||
h := NewHub(checker)
|
||||
if h.canCommunicate == nil {
|
||||
t.Fatal("canCommunicate not wired")
|
||||
}
|
||||
// Invoke the wired function directly
|
||||
allowed := h.canCommunicate("ws-1", "ws-1")
|
||||
if !called {
|
||||
t.Error("checker was not called")
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("self-communication should be allowed")
|
||||
}
|
||||
if h.canCommunicate("ws-1", "ws-2") {
|
||||
t.Error("cross-workspace communication should be blocked by checker")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── safeSend ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSafeSend_OpenChannel_Sends(t *testing.T) {
|
||||
c := mockClient("ws-1", 10)
|
||||
data := []byte(`{"type":"ping"}`)
|
||||
ok := safeSend(c, data)
|
||||
if !ok {
|
||||
t.Error("safeSend should return true for open channel")
|
||||
}
|
||||
select {
|
||||
case got := <-c.Send:
|
||||
if string(got) != string(data) {
|
||||
t.Errorf("got %q, want %q", got, data)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("no message received on channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeSend_ClosedChannel_ReturnsFalse(t *testing.T) {
|
||||
c := mockClient("ws-1", 10)
|
||||
close(c.Send) // close before safeSend
|
||||
ok := safeSend(c, []byte("data"))
|
||||
if ok {
|
||||
t.Error("safeSend should return false for closed channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeSend_FullChannel_ReturnsFalse(t *testing.T) {
|
||||
c := mockClient("ws-1", 1) // buffer size 1
|
||||
// Fill the channel
|
||||
c.Send <- []byte("first")
|
||||
// Channel is now full
|
||||
ok := safeSend(c, []byte("second"))
|
||||
if ok {
|
||||
t.Error("safeSend should return false when channel buffer is full")
|
||||
}
|
||||
// Drain to leave clean state
|
||||
<-c.Send
|
||||
}
|
||||
|
||||
// ─── Broadcast ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcast_CanvasAlwaysReceives(t *testing.T) {
|
||||
h := NewHub(nil) // nil checker: canvas always gets messages
|
||||
|
||||
// Canvas client (no workspaceID) + two workspace clients
|
||||
canvas := mockClient("", 10)
|
||||
ws1 := mockClient("ws-1", 10)
|
||||
ws2 := mockClient("ws-2", 10)
|
||||
|
||||
// Manually register clients into hub state
|
||||
h.mu.Lock()
|
||||
h.clients[canvas] = true
|
||||
h.clients[ws1] = true
|
||||
h.clients[ws2] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
msg := models.WSMessage{Event: "test", Payload: []byte(`"hello"`)}
|
||||
h.Broadcast(msg)
|
||||
|
||||
// Canvas must receive
|
||||
select {
|
||||
case got := <-canvas.Send:
|
||||
t.Logf("canvas received: %s", got)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("canvas client did not receive broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_WorkspaceCanCommunicateGating(t *testing.T) {
|
||||
// Only ws-1 can receive messages for ws-2
|
||||
checker := func(callerID, targetID string) bool {
|
||||
return callerID == targetID
|
||||
}
|
||||
h := NewHub(checker)
|
||||
|
||||
ws1 := mockClient("ws-1", 10)
|
||||
ws2 := mockClient("ws-2", 10)
|
||||
canvas := mockClient("", 10)
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[ws1] = true
|
||||
h.clients[ws2] = true
|
||||
h.clients[canvas] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
// Broadcast addressed to ws-2
|
||||
msg := models.WSMessage{Event: "test", WorkspaceID: "ws-2"}
|
||||
h.Broadcast(msg)
|
||||
|
||||
// ws-1 should NOT receive (not the target, checker says no)
|
||||
select {
|
||||
case <-ws1.Send:
|
||||
t.Error("ws-1 should not receive broadcast for ws-2")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
t.Log("ws-1 correctly blocked — no message")
|
||||
}
|
||||
|
||||
// ws-2 should receive
|
||||
select {
|
||||
case <-ws2.Send:
|
||||
t.Log("ws-2 correctly received broadcast")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("ws-2 did not receive broadcast")
|
||||
}
|
||||
|
||||
// Canvas always receives
|
||||
select {
|
||||
case <-canvas.Send:
|
||||
t.Log("canvas correctly received broadcast")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("canvas did not receive broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_DropsOnClosedChannel(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
c := mockClient("", 10)
|
||||
close(c.Send) // pre-close so safeSend returns false
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[c] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
// Broadcast must not panic; closed client should be dropped silently.
|
||||
msg := models.WSMessage{Event: "ping"}
|
||||
h.Broadcast(msg) // should not panic
|
||||
}
|
||||
|
||||
func TestBroadcast_DropsOnFullChannel(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
c := mockClient("", 1)
|
||||
c.Send <- []byte("blocker") // fill buffer
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[c] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
msg := models.WSMessage{Event: "ping"}
|
||||
h.Broadcast(msg) // safeSend returns false; no panic
|
||||
|
||||
// Drain to leave clean state
|
||||
<-c.Send
|
||||
}
|
||||
|
||||
func TestBroadcast_EmptyHubNoPanic(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
msg := models.WSMessage{Event: "ping"}
|
||||
h.Broadcast(msg) // must not panic with no clients
|
||||
}
|
||||
|
||||
func TestBroadcast_MultiClient(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
clients := make([]*Client, 5)
|
||||
h.mu.Lock()
|
||||
for i := 0; i < 5; i++ {
|
||||
clients[i] = mockClient("", 10)
|
||||
h.clients[clients[i]] = true
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
msg := models.WSMessage{Event: "multi", Payload: []byte(`"all receive"`)}
|
||||
h.Broadcast(msg)
|
||||
|
||||
for i, c := range clients {
|
||||
select {
|
||||
case <-c.Send:
|
||||
t.Logf("client %d received", i)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Errorf("client %d did not receive broadcast", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_CanvasIgnoresChecker(t *testing.T) {
|
||||
// Strict checker that blocks ALL cross-workspace (never returns true for different IDs)
|
||||
strictChecker := func(callerID, targetID string) bool {
|
||||
return callerID == targetID
|
||||
}
|
||||
h := NewHub(strictChecker)
|
||||
|
||||
canvas := mockClient("", 10)
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[canvas] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
msg := models.WSMessage{Event: "ping", WorkspaceID: "ws-1"}
|
||||
h.Broadcast(msg)
|
||||
|
||||
select {
|
||||
case <-canvas.Send:
|
||||
t.Log("canvas received message even though checker blocks ws-1")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("canvas must always receive — checker should be bypassed")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Close ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestClose_DisconnectsAllClients(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
clients := make([]*Client, 3)
|
||||
h.mu.Lock()
|
||||
for i := 0; i < 3; i++ {
|
||||
clients[i] = mockClient("", 10)
|
||||
h.clients[clients[i]] = true
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
// Start Run goroutine so Close can drain Unregister channel
|
||||
go h.Run()
|
||||
defer h.Close()
|
||||
|
||||
// Unregister all clients so the mutex is released before Close() tries to lock it
|
||||
for _, c := range clients {
|
||||
h.Unregister <- c
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Now close — mutex is free, Close() should succeed
|
||||
h.Close()
|
||||
|
||||
// All client channels should be closed
|
||||
for i, c := range clients {
|
||||
select {
|
||||
case _, ok := <-c.Send:
|
||||
if ok {
|
||||
t.Errorf("client %d channel still open after Close", i)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Channel drained and closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_Idempotent(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
c := mockClient("", 10)
|
||||
h.mu.Lock()
|
||||
h.clients[c] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
// Close twice — must not panic or deadlock
|
||||
h.Close()
|
||||
h.Close() // second call also fine
|
||||
}
|
||||
|
||||
func TestClose_ClosesDoneChannel(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
|
||||
// Start Run goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.Run()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
h.Close()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
t.Log("Run exited after Close")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Error("Run did not exit after Close")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Run goroutine (Unregister) ──────────────────────────────────────────
|
||||
|
||||
func TestRun_UnregisterClosesClientSend(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
c := mockClient("ws-1", 10)
|
||||
|
||||
// Start Run() BEFORE sending to Register — Register is unbuffered,
|
||||
// so Run() must be ready to receive before the send can complete.
|
||||
go h.Run()
|
||||
defer h.Close()
|
||||
|
||||
// Register the client
|
||||
h.Register <- c
|
||||
|
||||
// Give Run a moment to register the client
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Unregister client
|
||||
h.Unregister <- c
|
||||
|
||||
select {
|
||||
case _, ok := <-c.Send:
|
||||
if ok {
|
||||
t.Error("client send channel should be closed after Unregister")
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Error("client send channel not closed within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Concurrent access ────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcast_ConcurrentSafe(t *testing.T) {
|
||||
h := NewHub(nil)
|
||||
clients := make([]*Client, 10)
|
||||
h.mu.Lock()
|
||||
for i := 0; i < 10; i++ {
|
||||
clients[i] = mockClient("", 100)
|
||||
h.clients[clients[i]] = true
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 20; j++ {
|
||||
h.Broadcast(models.WSMessage{Event: "ping", Payload: []byte(`"concurrent"`)})
|
||||
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait() // should not deadlock or panic
|
||||
}
|
||||
@@ -9,6 +9,13 @@ import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
# OFFSEC-003: peer-controlled text MUST be wrapped with sanitize_a2a_result
|
||||
# before being returned to the LLM. This module's delegate_task() is one of
|
||||
# the trust-boundary entry points where peer output crosses into our agent's
|
||||
# context — same surface as a2a_tools_delegation.py:325 (fixed via #492).
|
||||
# Issue #537.
|
||||
from _sanitize_a2a import sanitize_a2a_result
|
||||
|
||||
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
|
||||
WORKSPACE_ID = os.environ.get("WORKSPACE_ID", "")
|
||||
|
||||
@@ -69,12 +76,12 @@ async def delegate_task(workspace_id: str, task: str) -> str:
|
||||
result = data["result"]
|
||||
parts = result.get("parts", []) if isinstance(result, dict) else []
|
||||
if parts and isinstance(parts[0], dict):
|
||||
return parts[0].get("text", "(no text)")
|
||||
return sanitize_a2a_result(parts[0].get("text", "(no text)"))
|
||||
# Empty parts list (e.g. {"parts": []}) should return str(result),
|
||||
# not "(no text)" — preserves pre-fix behavior (#279 regression fix).
|
||||
if isinstance(result, dict) and result.get("parts") == []:
|
||||
return str(result)
|
||||
return str(result) if isinstance(result, str) else "(no text)"
|
||||
return sanitize_a2a_result(str(result))
|
||||
return sanitize_a2a_result(str(result) if isinstance(result, str) else "(no text)")
|
||||
elif "error" in data:
|
||||
err = data["error"]
|
||||
# Handle both string-form errors ("error": "some string")
|
||||
@@ -87,14 +94,6 @@ async def delegate_task(workspace_id: str, task: str) -> str:
|
||||
else:
|
||||
msg = str(err)
|
||||
return f"Error: {msg}"
|
||||
msg = ""
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message", "")
|
||||
elif isinstance(err, str):
|
||||
msg = err
|
||||
else:
|
||||
msg = str(err)
|
||||
return f"Error: {msg}"
|
||||
return str(data)
|
||||
except Exception as e:
|
||||
return f"Error sending A2A message: {e}"
|
||||
|
||||
Reference in New Issue
Block a user